Skip to content

Commit

Permalink
Add functionality to nuke AWS IAM Access Analyzers (#202)
Browse files Browse the repository at this point in the history
* 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
yorinasub17 authored Jul 26, 2021
1 parent 922a488 commit 09b266e
Show file tree
Hide file tree
Showing 7 changed files with 301 additions and 0 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
106 changes: 106 additions & 0 deletions aws/access_analyzer.go
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."
}
135 changes: 135 additions & 0 deletions aws/access_analyzer_test.go
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)
}
}
38 changes: 38 additions & 0 deletions aws/access_analyzer_types.go
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
}
15 changes: 15 additions & 0 deletions aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -642,6 +656,7 @@ func ListResourceTypes() []string {
IAMUsers{}.ResourceName(),
SecretsManagerSecrets{}.ResourceName(),
NatGateways{}.ResourceName(),
AccessAnalyzer{}.ResourceName(),
}
sort.Strings(resourceTypes)
return resourceTypes
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func emptyConfig() *Config {
ResourceType{FilterRule{}, FilterRule{}},
ResourceType{FilterRule{}, FilterRule{}},
ResourceType{FilterRule{}, FilterRule{}},
ResourceType{FilterRule{}, FilterRule{}},
}
}

Expand Down

0 comments on commit 09b266e

Please sign in to comment.