From 09b266e0df276a65269e25fce10d798caf72f842 Mon Sep 17 00:00:00 2001 From: Yoriyasu Yano <430092+yorinasub17@users.noreply.github.com> Date: Mon, 26 Jul 2021 10:37:04 -0500 Subject: [PATCH] Add functionality to nuke AWS IAM Access Analyzers (#202) * Add functionality to nuke AWS IAM Access Analyzers * Fix compile error * Fix test * Update test to work with limitation of one analyzer per region --- README.md | 5 ++ aws/access_analyzer.go | 106 +++++++++++++++++++++++++++ aws/access_analyzer_test.go | 135 +++++++++++++++++++++++++++++++++++ aws/access_analyzer_types.go | 38 ++++++++++ aws/aws.go | 15 ++++ config/config.go | 1 + config/config_test.go | 1 + 7 files changed, 301 insertions(+) create mode 100644 aws/access_analyzer.go create mode 100644 aws/access_analyzer_test.go create mode 100644 aws/access_analyzer_types.go diff --git a/README.md b/README.md index 0f36a481..67e7cf67 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The currently supported functionality includes: - Deleting all IAM users in an AWS account - Deleting all Secrets Manager Secrets in an AWS account - Deleting all NAT Gateways in an AWS account +- Deleting all IAM Access Analyzers in an AWS account - Revoking the default rules in the un-deletable default security group of a VPC ### BEWARE! @@ -152,6 +153,9 @@ The following resources support the Config file: - NAT Gateways - Resource type: `nat-gateway` - Config key: `NATGateway` +- IAM Access Analyzers + - Resource type: `accessanalyzer` + - Config key: `AccessAnalyzer` #### Example @@ -243,6 +247,7 @@ To find out what we options are supported in the config file today, consult this | iam | none | ✅ | none | none | | secretsmanager | none | ✅ | none | none | | nat-gateway | none | ✅ | none | none | +| accessanalyzer | none | ✅ | none | none | | ec2 instance | none | none | none | none | | iam role | none | none | none | none | | ... (more to come) | none | none | none | none | diff --git a/aws/access_analyzer.go b/aws/access_analyzer.go new file mode 100644 index 00000000..a72cc830 --- /dev/null +++ b/aws/access_analyzer.go @@ -0,0 +1,106 @@ +package aws + +import ( + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/accessanalyzer" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/go-commons/errors" + "github.com/hashicorp/go-multierror" +) + +func getAllAccessAnalyzers(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]*string, error) { + svc := accessanalyzer.New(session) + + allAnalyzers := []*string{} + err := svc.ListAnalyzersPages( + &accessanalyzer.ListAnalyzersInput{}, + func(page *accessanalyzer.ListAnalyzersOutput, lastPage bool) bool { + for _, analyzer := range page.Analyzers { + if shouldIncludeAccessAnalyzer(analyzer, excludeAfter, configObj) { + allAnalyzers = append(allAnalyzers, analyzer.Name) + } + } + return !lastPage + }, + ) + return allAnalyzers, errors.WithStackTrace(err) +} + +func shouldIncludeAccessAnalyzer(analyzer *accessanalyzer.AnalyzerSummary, excludeAfter time.Time, configObj config.Config) bool { + if analyzer == nil { + return false + } + + if excludeAfter.Before(aws.TimeValue(analyzer.CreatedAt)) { + return false + } + + return config.ShouldInclude( + aws.StringValue(analyzer.Name), + configObj.AccessAnalyzer.IncludeRule.NamesRegExp, + configObj.AccessAnalyzer.ExcludeRule.NamesRegExp, + ) +} + +func nukeAllAccessAnalyzers(session *session.Session, names []*string) error { + if len(names) == 0 { + logging.Logger.Infof("No IAM Access Analyzers to nuke in region %s", *session.Config.Region) + return nil + } + + // NOTE: we don't need to do pagination here, because the pagination is handled by the caller to this function, + // based on AccessAnalyzer.MaxBatchSize, however we add a guard here to warn users when the batching fails and has a + // chance of throttling AWS. Since we concurrently make one call for each identifier, we pick 100 for the limit here + // because many APIs in AWS have a limit of 100 requests per second. + if len(names) > 100 { + logging.Logger.Errorf("Nuking too many Access Analyzers at once (100): halting to avoid hitting AWS API rate limiting") + return TooManyAccessAnalyzersErr{} + } + + // There is no bulk delete access analyzer API, so we delete the batch of Access Analyzers concurrently using go routines. + logging.Logger.Infof("Deleting all Access Analyzers in region %s", *session.Config.Region) + + svc := accessanalyzer.New(session) + wg := new(sync.WaitGroup) + wg.Add(len(names)) + errChans := make([]chan error, len(names)) + for i, analyzerName := range names { + errChans[i] = make(chan error, 1) + go deleteAccessAnalyzerAsync(wg, errChans[i], svc, analyzerName) + } + wg.Wait() + + // Collect all the errors from the async delete calls into a single error struct. + var allErrs *multierror.Error + for _, errChan := range errChans { + if err := <-errChan; err != nil { + allErrs = multierror.Append(allErrs, err) + logging.Logger.Errorf("[Failed] %s", err) + } + } + finalErr := allErrs.ErrorOrNil() + return errors.WithStackTrace(finalErr) +} + +// deleteAccessAnalyzerAsync deletes the provided IAM Access Analyzer asynchronously in a goroutine, using wait groups +// for concurrency control and a return channel for errors. +func deleteAccessAnalyzerAsync(wg *sync.WaitGroup, errChan chan error, svc *accessanalyzer.AccessAnalyzer, analyzerName *string) { + defer wg.Done() + + input := &accessanalyzer.DeleteAnalyzerInput{AnalyzerName: analyzerName} + _, err := svc.DeleteAnalyzer(input) + errChan <- err +} + +// Custom errors + +type TooManyAccessAnalyzersErr struct{} + +func (err TooManyAccessAnalyzersErr) Error() string { + return "Too many Access Analyzers requested at once." +} diff --git a/aws/access_analyzer_test.go b/aws/access_analyzer_test.go new file mode 100644 index 00000000..b4df948d --- /dev/null +++ b/aws/access_analyzer_test.go @@ -0,0 +1,135 @@ +package aws + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/accessanalyzer" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/terratest/modules/random" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListAccessAnalyzers(t *testing.T) { + t.Parallel() + + // We hard code the region here to avoid the tests colliding with each other, since we can only have one account + // analyzer per region (but we can have multiple org analyzers). + region := "us-west-1" + + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + svc := accessanalyzer.New(session) + + analyzerName := createAccessAnalyzer(t, svc) + defer deleteAccessAnalyzer(t, svc, analyzerName, true) + + analyzerNames, err := getAllAccessAnalyzers(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.Contains(t, aws.StringValueSlice(analyzerNames), aws.StringValue(analyzerName)) +} + +func TestTimeFilterExclusionNewlyCreatedAccessAnalyzer(t *testing.T) { + t.Parallel() + + // We hard code the region here to avoid the tests colliding with each other, since we can only have one account + // analyzer per region (but we can have multiple org analyzers). + region := "us-west-2" + + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + svc := accessanalyzer.New(session) + + analyzerName := createAccessAnalyzer(t, svc) + defer deleteAccessAnalyzer(t, svc, analyzerName, true) + + // Assert Access Analyzer is picked up without filters + analyzerNamesNewer, err := getAllAccessAnalyzers(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.Contains(t, aws.StringValueSlice(analyzerNamesNewer), aws.StringValue(analyzerName)) + + // Assert analyzer doesn't appear when we look at users older than 1 Hour + olderThan := time.Now().Add(-1 * time.Hour) + analyzerNamesOlder, err := getAllAccessAnalyzers(session, olderThan, config.Config{}) + require.NoError(t, err) + assert.NotContains(t, aws.StringValueSlice(analyzerNamesOlder), aws.StringValue(analyzerName)) +} + +func TestNukeAccessAnalyzerOne(t *testing.T) { + t.Parallel() + + // We hard code the region here to avoid the tests colliding with each other, since we can only have one account + // analyzer per region (but we can have multiple org analyzers). + region := "eu-west-1" + + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + svc := accessanalyzer.New(session) + + // We ignore errors in the delete call here, because it is intended to be a stop gap in case there is a bug in nuke. + analyzerName := createAccessAnalyzer(t, svc) + defer deleteAccessAnalyzer(t, svc, analyzerName, false) + identifiers := []*string{analyzerName} + + require.NoError( + t, + nukeAllAccessAnalyzers(session, identifiers), + ) + + // Make sure the Access Analyzer is deleted. + assertAccessAnalyzersDeleted(t, svc, identifiers) +} + +// Helper functions for driving the Access Analyzer tests + +// createAccessAnalyzer will create a new IAM Access Analyzer for test purposes +func createAccessAnalyzer(t *testing.T, svc *accessanalyzer.AccessAnalyzer) *string { + name := fmt.Sprintf("cloud-nuke-test-%s", strings.ToLower(random.UniqueId())) + resp, err := svc.CreateAnalyzer(&accessanalyzer.CreateAnalyzerInput{ + AnalyzerName: aws.String(name), + Type: aws.String("ACCOUNT"), + }) + require.NoError(t, err) + if resp.Arn == nil { + t.Fatalf("Impossible error: AWS returned nil NAT gateway") + } + + // AccessAnalyzer API operates on the name, so we need to extract out the name part of the ARN. Analyzer ARNs are of + // the form: arn:aws:access-analyzer:sa-east-1:000000000000:analyzer/test-iam-access-analyzer-fhkb2x-sa_east_1, so + // to get the name we can extract the resource part and split on `/` and return the second part. + arn, err := arn.Parse(aws.StringValue(resp.Arn)) + require.NoError(t, err) + nameParts := strings.Split(arn.Resource, "/") + require.Equal(t, 2, len(nameParts)) + require.Equal(t, "analyzer", nameParts[0]) + return aws.String(nameParts[1]) +} + +// deleteAccessAnalyzer is a function to delete the given NAT gateway. +func deleteAccessAnalyzer(t *testing.T, svc *accessanalyzer.AccessAnalyzer, analyzerName *string, checkErr bool) { + input := &accessanalyzer.DeleteAnalyzerInput{AnalyzerName: analyzerName} + _, err := svc.DeleteAnalyzer(input) + if checkErr { + require.NoError(t, err) + } +} + +func assertAccessAnalyzersDeleted(t *testing.T, svc *accessanalyzer.AccessAnalyzer, identifiers []*string) { + for _, identifier := range identifiers { + _, err := svc.GetAnalyzer(&accessanalyzer.GetAnalyzerInput{AnalyzerName: identifier}) + if err == nil { + t.Fatalf("Access Analyzer %s still exists", aws.StringValue(identifier)) + } + if awsErr, ok := err.(awserr.Error); ok && awsErr.Code() == accessanalyzer.ErrCodeResourceNotFoundException { + continue + } + t.Fatalf("Error checking for access analyzer %s: %s", aws.StringValue(identifier), err) + } +} diff --git a/aws/access_analyzer_types.go b/aws/access_analyzer_types.go new file mode 100644 index 00000000..6734076d --- /dev/null +++ b/aws/access_analyzer_types.go @@ -0,0 +1,38 @@ +package aws + +import ( + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/go-commons/errors" +) + +// AccessAnalyzer - represents all AWS secrets manager secrets that should be deleted. +type AccessAnalyzer struct { + AnalyzerNames []string +} + +// ResourceName - the simple name of the aws resource +func (analyzer AccessAnalyzer) ResourceName() string { + return "accessanalyzer" +} + +// ResourceIdentifiers - The instance ids of the ec2 instances +func (analyzer AccessAnalyzer) ResourceIdentifiers() []string { + return analyzer.AnalyzerNames +} + +func (analyzer AccessAnalyzer) MaxBatchSize() int { + // Tentative batch size to ensure AWS doesn't throttle. Note that IAM Access Analyzer does not support bulk delete, + // so we will be deleting this many in parallel using go routines. We conservatively pick 10 here, both to limit + // overloading the runtime and to avoid AWS throttling with many API calls. + return 10 +} + +// Nuke - nuke 'em all!!! +func (analyzer AccessAnalyzer) Nuke(session *session.Session, identifiers []string) error { + if err := nukeAllAccessAnalyzers(session, awsgo.StringSlice(identifiers)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/aws/aws.go b/aws/aws.go index c4f710be..c8164e63 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -532,6 +532,20 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp } // End Secrets Manager Secrets + // AccessAnalyzer + accessAnalyzer := AccessAnalyzer{} + if IsNukeable(accessAnalyzer.ResourceName(), resourceTypes) { + analyzerNames, err := getAllAccessAnalyzers(session, excludeAfter, configObj) + if err != nil { + return nil, errors.WithStackTrace(err) + } + if len(analyzerNames) > 0 { + accessAnalyzer.AnalyzerNames = awsgo.StringValueSlice(analyzerNames) + resourcesInRegion.Resources = append(resourcesInRegion.Resources, accessAnalyzer) + } + } + // End AccessAnalyzer + // S3 Buckets s3Buckets := S3Buckets{} if IsNukeable(s3Buckets.ResourceName(), resourceTypes) { @@ -642,6 +656,7 @@ func ListResourceTypes() []string { IAMUsers{}.ResourceName(), SecretsManagerSecrets{}.ResourceName(), NatGateways{}.ResourceName(), + AccessAnalyzer{}.ResourceName(), } sort.Strings(resourceTypes) return resourceTypes diff --git a/config/config.go b/config/config.go index 2a26c755..949c7460 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Config struct { IAMUsers ResourceType `yaml:"IAMUsers"` SecretsManagerSecrets ResourceType `yaml:"SecretsManager"` NatGateway ResourceType `yaml:"NatGateway"` + AccessAnalyzer ResourceType `yaml:"AccessAnalyzer"` } type ResourceType struct { diff --git a/config/config_test.go b/config/config_test.go index bee2f57c..13d3c26c 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -14,6 +14,7 @@ func emptyConfig() *Config { ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, + ResourceType{FilterRule{}, FilterRule{}}, } }