From 7513e6b6729fa42d67ada9adc2fcb078ae39af90 Mon Sep 17 00:00:00 2001 From: Erik Kristensen Date: Wed, 17 Nov 2021 16:38:41 -0700 Subject: [PATCH 1/2] support nuking iam-roles --- .gitignore | 4 +- aws/aws.go | 16 ++++ aws/iam_role.go | 176 ++++++++++++++++++++++++++++++++++++++++++ aws/iam_role_test.go | 134 ++++++++++++++++++++++++++++++++ aws/iam_role_types.go | 36 +++++++++ config/config.go | 1 + config/config_test.go | 1 + 7 files changed, 367 insertions(+), 1 deletion(-) 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/.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/aws/aws.go b/aws/aws.go index a01d4e63..14df9404 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -695,6 +695,21 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp } // End IAM Users + // 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 } @@ -727,6 +742,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..dbe0b05b --- /dev/null +++ b/aws/iam_role.go @@ -0,0 +1,176 @@ +package aws + +import ( + "strings" + "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 users in the AWS account and returns a slice of the UserNames +func getAllIamRoles(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]*string, error) { + svc := iam.New(session) + + var roleNames []*string + + // TODO: Probably use ListRoles together with ListRolesPages in case there are lots of roles + output, err := svc.ListRoles(&iam.ListRolesInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + for _, role := range output.Roles { + if strings.Contains(aws.StringValue(role.RoleName), "OrganizationAccountAccessRole") { + continue + } + if strings.Contains(aws.StringValue(role.Arn), "aws-service-role") || strings.Contains(aws.StringValue(role.Arn), "aws-reserved") { + continue + } + + if config.ShouldInclude(aws.StringValue(role.RoleName), configObj.IAMRoles.IncludeRule.NamesRegExp, configObj.IAMRoles.ExcludeRule.NamesRegExp) && excludeAfter.After(*role.CreateDate) { + roleNames = append(roleNames, role.RoleName) + } + } + + return roleNames, nil +} + +func detachRolePolicies(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 removeRoleFromInstanceProfiles(svc *iam.IAM, roleName *string) error { + resp, err := svc.ListInstanceProfilesForRole(&iam.ListInstanceProfilesForRoleInput{ + RoleName: roleName, + }) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + + for _, profile := range resp.InstanceProfiles { + _, err := svc.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{ + RoleName: roleName, + InstanceProfileName: profile.InstanceProfileName, + }) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("Removed Role %s from Instance Profile %s", aws.StringValue(roleName), aws.StringValue(profile.InstanceProfileName)) + } + + 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 +} + +// Nuke a single user +func nukeRole(svc *iam.IAM, roleName *string) 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{ + detachRolePolicies, + deleteInlineRolePolicies, + removeRoleFromInstanceProfiles, + deleteIamRole, + } + + for _, fn := range functions { + if err := fn(svc, roleName); err != nil { + return err + } + } + + return nil +} + +// Delete all IAM Roles +func nukeAllIamRoles(session *session.Session, roleNames []*string) error { + if len(roleNames) == 0 { + logging.Logger.Info("No IAM Users to nuke") + return nil + } + + logging.Logger.Info("Deleting all IAM Users") + + deletedUsers := 0 + svc := iam.New(session) + multiErr := new(multierror.Error) + + for _, roleName := range roleNames { + err := nukeRole(svc, roleName) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + multierror.Append(multiErr, err) + } else { + deletedUsers++ + logging.Logger.Infof("Deleted IAM Role: %s", *roleName) + } + } + + logging.Logger.Infof("[OK] %d IAM Roles(s) terminated", deletedUsers) + return multiErr.ErrorOrNil() +} 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..437a3f96 --- /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 200 +} + +// 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/config/config.go b/config/config.go index b3f0055e..5e65ab54 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 e4ea738b..b30c088e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -18,6 +18,7 @@ func emptyConfig() *Config { ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, + ResourceType{FilterRule{}, FilterRule{}}, } } From 604f2e2356fd614edfe6f3fa2313713f730f571f Mon Sep 17 00:00:00 2001 From: Erik Kristensen Date: Wed, 17 Nov 2021 16:39:42 -0700 Subject: [PATCH 2/2] updating documentation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 08db2aa7..a6b1208f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The currently supported functionality includes: - 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) - Deleting all IAM users in an AWS account +- Deleting all IAM roles in an AWS account (except aws defined roles and OrganizationAccountAccessRole) - 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