From 044f1f88aa84956a04d6f4c6b2e806e0b9c6b107 Mon Sep 17 00:00:00 2001 From: Zack Proser Date: Tue, 19 Jul 2022 11:05:42 -0400 Subject: [PATCH] Implement support for IAM Roles (#330) Co-authored-by: Erik Kristensen --- .circleci/nuke_config.yml | 4 + .gitignore | 4 +- README.md | 1 + aws/aws.go | 15 +++ aws/iam_role.go | 227 ++++++++++++++++++++++++++++++++++++++ aws/iam_role_test.go | 134 ++++++++++++++++++++++ aws/iam_role_types.go | 36 ++++++ aws/macie_test.go | 4 +- aws/types.go | 7 +- config/config.go | 1 + config/config_test.go | 1 + 11 files changed, 428 insertions(+), 6 deletions(-) create mode 100644 aws/iam_role.go create mode 100644 aws/iam_role_test.go create mode 100644 aws/iam_role_types.go diff --git a/.circleci/nuke_config.yml b/.circleci/nuke_config.yml index 15664bd5..d2a86bbf 100644 --- a/.circleci/nuke_config.yml +++ b/.circleci/nuke_config.yml @@ -106,3 +106,7 @@ IAMUsers: names_regex: # We want to make sure the main circle-ci-test user is never deleted - "^circle-ci-test$" +IAMRoles: + include: + names_regex: + - "^cloud-nuke-Test*" diff --git a/.gitignore b/.gitignore index 4a44b110..9c7978a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea vendor .vscode -cloud-nuke \ No newline at end of file +cloud-nuke +config.yaml +.envrc \ No newline at end of file diff --git a/README.md b/README.md index bd8ccec4..32efb6ee 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The currently supported functionality includes: - Inspecting and deleting all default VPCs in an AWS account - Deleting VPCs in an AWS Account (except for default VPCs which is handled by the dedicated `defaults-aws` subcommand) - Inspecting and deleting all IAM users in an AWS account +- Inspecting and deleting all IAM roles in an AWS account - Inspecting and deleting all Secrets Manager Secrets in an AWS account - Inspecting and deleting all NAT Gateways in an AWS account - Inspecting and deleting all IAM Access Analyzers in an AWS account diff --git a/aws/aws.go b/aws/aws.go index c14941a0..f1bab4cd 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -786,6 +786,20 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp } // End IAM OpenIDConnectProviders + // IAM Roles + iamRoles := IAMRoles{} + if IsNukeable(iamRoles.ResourceName(), resourceTypes) { + roleNames, err := getAllIamRoles(session, excludeAfter, configObj) + if err != nil { + return nil, errors.WithStackTrace(err) + } + if len(roleNames) > 0 { + iamRoles.RoleNames = awsgo.StringValueSlice(roleNames) + globalResources.Resources = append(globalResources.Resources, iamRoles) + } + } + // End IAM Roles + if len(globalResources.Resources) > 0 { account.Resources[GlobalRegion] = globalResources } @@ -818,6 +832,7 @@ func ListResourceTypes() []string { LambdaFunctions{}.ResourceName(), S3Buckets{}.ResourceName(), IAMUsers{}.ResourceName(), + IAMRoles{}.ResourceName(), SecretsManagerSecrets{}.ResourceName(), NatGateways{}.ResourceName(), OpenSearchDomains{}.ResourceName(), diff --git a/aws/iam_role.go b/aws/iam_role.go new file mode 100644 index 00000000..7017348a --- /dev/null +++ b/aws/iam_role.go @@ -0,0 +1,227 @@ +package aws + +import ( + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" + "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" +) + +// List all IAM Roles in the AWS account +func getAllIamRoles(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]*string, error) { + svc := iam.New(session) + + allIAMRoles := []*string{} + err := svc.ListRolesPages( + &iam.ListRolesInput{}, + func(page *iam.ListRolesOutput, lastPage bool) bool { + for _, iamRole := range page.Roles { + if shouldIncludeIAMRole(iamRole, excludeAfter, configObj) { + allIAMRoles = append(allIAMRoles, iamRole.RoleName) + } + } + return !lastPage + }, + ) + if err != nil { + return nil, errors.WithStackTrace(err) + } + return allIAMRoles, nil +} + +func deleteManagedRolePolicies(svc *iam.IAM, roleName *string) error { + policiesOutput, err := svc.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{ + RoleName: roleName, + }) + if err != nil { + return errors.WithStackTrace(err) + } + + for _, attachedPolicy := range policiesOutput.AttachedPolicies { + arn := attachedPolicy.PolicyArn + _, err = svc.DetachRolePolicy(&iam.DetachRolePolicyInput{ + PolicyArn: arn, + RoleName: roleName, + }) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + logging.Logger.Infof("Detached Policy %s from Role %s", aws.StringValue(arn), aws.StringValue(roleName)) + } + + return nil +} + +func deleteInlineRolePolicies(svc *iam.IAM, roleName *string) error { + policyOutput, err := svc.ListRolePolicies(&iam.ListRolePoliciesInput{ + RoleName: roleName, + }) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + + for _, policyName := range policyOutput.PolicyNames { + _, err := svc.DeleteRolePolicy(&iam.DeleteRolePolicyInput{ + PolicyName: policyName, + RoleName: roleName, + }) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + logging.Logger.Infof("Deleted Inline Policy %s from Role %s", aws.StringValue(policyName), aws.StringValue(roleName)) + } + + return nil +} + +func detachInstanceProfilesFromRole(svc *iam.IAM, roleName *string) error { + profilesOutput, err := svc.ListInstanceProfilesForRole(&iam.ListInstanceProfilesForRoleInput{ + RoleName: roleName, + }) + if err != nil { + return errors.WithStackTrace(err) + } + + for _, profile := range profilesOutput.InstanceProfiles { + _, err := svc.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{ + InstanceProfileName: profile.InstanceProfileName, + RoleName: roleName, + }) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + logging.Logger.Infof("Detached InstanceProfile %s from Role %s", aws.StringValue(profile.InstanceProfileName), aws.StringValue(roleName)) + } + return nil +} + +func deleteIamRole(svc *iam.IAM, roleName *string) error { + _, err := svc.DeleteRole(&iam.DeleteRoleInput{ + RoleName: roleName, + }) + if err != nil { + return errors.WithStackTrace(err) + } + + return nil +} + +// Delete all IAM Roles +func nukeAllIamRoles(session *session.Session, roleNames []*string) error { + region := aws.StringValue(session.Config.Region) + svc := iam.New(session) + + if len(roleNames) == 0 { + logging.Logger.Info("No IAM Roles to nuke") + return nil + } + + // NOTE: we don't need to do pagination here, because the pagination is handled by the caller to this function, + // based on IAMRoles.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(roleNames) > 100 { + logging.Logger.Errorf("Nuking too many IAM Roles at once (100): halting to avoid hitting AWS API rate limiting") + return TooManyIamRoleErr{} + } + + // There is no bulk delete IAM Roles API, so we delete the batch of IAM roles concurrently using go routines + logging.Logger.Infof("Deleting all IAM Roles in region %s", region) + wg := new(sync.WaitGroup) + wg.Add(len(roleNames)) + errChans := make([]chan error, len(roleNames)) + for i, roleName := range roleNames { + errChans[i] = make(chan error, 1) + go deleteIamRoleAsync(wg, errChans[i], svc, roleName) + } + 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() + if finalErr != nil { + return errors.WithStackTrace(finalErr) + } + + for _, roleName := range roleNames { + logging.Logger.Infof("[OK] IAM Role %s was deleted in %s", aws.StringValue(roleName), region) + } + return nil +} + +func shouldIncludeIAMRole(iamRole *iam.Role, excludeAfter time.Time, configObj config.Config) bool { + if iamRole == nil { + return false + } + + if strings.Contains(aws.StringValue(iamRole.RoleName), "OrganizationAccountAccessRole") { + return false + } + + // The arns of AWS-managed IAM roles, which can only be modified or deleted by AWS, contain "aws-service-role", so we can filter them out + // of the Roles found and managed by cloud-nuke + // The same general rule applies with roles whose arn contains "aws-reserved" + if strings.Contains(aws.StringValue(iamRole.Arn), "aws-service-role") || strings.Contains(aws.StringValue(iamRole.Arn), "aws-reserved") { + return false + } + + if excludeAfter.Before(*iamRole.CreateDate) { + return false + } + + return config.ShouldInclude( + aws.StringValue(iamRole.RoleName), + configObj.IAMRoles.IncludeRule.NamesRegExp, + configObj.IAMRoles.ExcludeRule.NamesRegExp, + ) +} + +func deleteIamRoleAsync(wg *sync.WaitGroup, errChan chan error, svc *iam.IAM, roleName *string) { + defer wg.Done() + + var result *multierror.Error + + // Functions used to really nuke an IAM Role as a role can have many attached + // items we need delete/detach them before actually deleting it. + // NOTE: The actual role deletion should always be the last one. This way we + // can guarantee that it will fail if we forgot to delete/detach an item. + functions := []func(svc *iam.IAM, roleName *string) error{ + detachInstanceProfilesFromRole, + deleteInlineRolePolicies, + deleteManagedRolePolicies, + deleteIamRole, + } + + for _, fn := range functions { + if err := fn(svc, roleName); err != nil { + result = multierror.Append(result, err) + } + } + + errChan <- result.ErrorOrNil() +} + +// Custom errors + +type TooManyIamRoleErr struct{} + +func (err TooManyIamRoleErr) Error() string { + return "Too many IAM Roles requested at once" +} diff --git a/aws/iam_role_test.go b/aws/iam_role_test.go new file mode 100644 index 00000000..942bf80b --- /dev/null +++ b/aws/iam_role_test.go @@ -0,0 +1,134 @@ +package aws + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListIamRoles(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + roleNames, err := getAllIamRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + + assert.NotEmpty(t, roleNames) +} + +func createTestRole(t *testing.T, session *session.Session, name string) error { + svc := iam.New(session) + + input := &iam.CreateRoleInput{ + RoleName: aws.String(name), + AssumeRolePolicyDocument: aws.String(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }`), + } + + _, err := svc.CreateRole(input) + require.NoError(t, err) + + return nil +} + +func TestCreateIamRole(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + name := "cloud-nuke-test-" + util.UniqueID() + roleNames, err := getAllIamRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.NotContains(t, awsgo.StringValueSlice(roleNames), name) + + err = createTestRole(t, session, name) + defer nukeAllIamRoles(session, []*string{&name}) + require.NoError(t, err) + + roleNames, err = getAllIamRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.Contains(t, awsgo.StringValueSlice(roleNames), name) +} + +func TestNukeIamRoles(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + name := "cloud-nuke-test-" + util.UniqueID() + err = createTestRole(t, session, name) + require.NoError(t, err) + + err = nukeAllIamRoles(session, []*string{&name}) + require.NoError(t, err) +} + +func TestTimeFilterExclusionNewlyCreatedIamRole(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + require.NoError(t, err) + + // Assert role didn't exist + name := "cloud-nuke-test-" + util.UniqueID() + roleNames, err := getAllIamRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.NotContains(t, awsgo.StringValueSlice(roleNames), name) + + // Creates a role + err = createTestRole(t, session, name) + defer nukeAllIamRoles(session, []*string{&name}) + + // Assert role is created + roleNames, err = getAllIamRoles(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.Contains(t, awsgo.StringValueSlice(roleNames), name) + + // Assert role doesn't appear when we look at roles older than 1 Hour + olderThan := time.Now().Add(-1 * time.Hour) + roleNames, err = getAllIamRoles(session, olderThan, config.Config{}) + require.NoError(t, err) + assert.NotContains(t, awsgo.StringValueSlice(roleNames), name) +} diff --git a/aws/iam_role_types.go b/aws/iam_role_types.go new file mode 100644 index 00000000..0676cf19 --- /dev/null +++ b/aws/iam_role_types.go @@ -0,0 +1,36 @@ +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" +) + +// IAMRoles - represents all IAMRoles on the AWS Account +type IAMRoles struct { + RoleNames []string +} + +// ResourceName - the simple name of the aws resource +func (r IAMRoles) ResourceName() string { + return "iam-role" +} + +// ResourceIdentifiers - The IAM UserNames +func (r IAMRoles) ResourceIdentifiers() []string { + return r.RoleNames +} + +// Tentative batch size to ensure AWS doesn't throttle +func (r IAMRoles) MaxBatchSize() int { + return 80 +} + +// Nuke - nuke 'em all!!! +func (r IAMRoles) Nuke(session *session.Session, identifiers []string) error { + if err := nukeAllIamRoles(session, awsgo.StringSlice(identifiers)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/aws/macie_test.go b/aws/macie_test.go index 53a06527..27915a62 100644 --- a/aws/macie_test.go +++ b/aws/macie_test.go @@ -2,12 +2,10 @@ package aws import ( "testing" - "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/macie2" - "github.com/gruntwork-io/cloud-nuke/config" "github.com/gruntwork-io/cloud-nuke/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -26,7 +24,7 @@ func TestListMacieAccounts(t *testing.T) { // Clean up after test by deleting the macie account association defer nukeAllMacieMemberAccounts(session, []string{accountId}) - retrievedAccountIds, lookupErr := getAllMacieMemberAccounts(session, time.Now(), config.Config{}) + retrievedAccountIds, lookupErr := getAllMacieMemberAccounts(session) require.NoError(t, lookupErr) assert.Contains(t, retrievedAccountIds, accountId) diff --git a/aws/types.go b/aws/types.go index e2542dba..c57068c5 100644 --- a/aws/types.go +++ b/aws/types.go @@ -110,12 +110,15 @@ func (q *Query) Validate() error { q.ResourceTypes = resourceTypes - enabledRegions, err := GetEnabledRegions() + regions, err := GetEnabledRegions() if err != nil { return CouldNotDetermineEnabledRegionsError{Underlying: err} } - targetRegions, err := GetTargetRegions(enabledRegions, q.Regions, q.ExcludeRegions) + // global is a fake region, used to represent global resources + regions = append(regions, GlobalRegion) + + targetRegions, err := GetTargetRegions(regions, q.Regions, q.ExcludeRegions) if err != nil { return CouldNotSelectRegionError{Underlying: err} } diff --git a/config/config.go b/config/config.go index a52d77c0..df11939b 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,7 @@ import ( type Config struct { S3 ResourceType `yaml:"s3"` IAMUsers ResourceType `yaml:"IAMUsers"` + IAMRoles ResourceType `yaml:"IAMRoles"` SecretsManagerSecrets ResourceType `yaml:"SecretsManager"` NatGateway ResourceType `yaml:"NatGateway"` AccessAnalyzer ResourceType `yaml:"AccessAnalyzer"` diff --git a/config/config_test.go b/config/config_test.go index c6927b27..321f92a1 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -34,6 +34,7 @@ func emptyConfig() *Config { ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, + ResourceType{FilterRule{}, FilterRule{}}, } }