-
-
Notifications
You must be signed in to change notification settings - Fork 355
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
922a488
commit 09b266e
Showing
7 changed files
with
301 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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." | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters