diff --git a/README.md b/README.md index 9a15b4b8..152918f0 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ The currently supported functionality includes: - Inspecting and deleting all API Gateways (v1 and v2) in an AWS account - Inspecting and deleting all Elastic FileSystems (efs) in an AWS account - Inspecting and deleting all SNS Topics in an AWS account +- Inspecting and deleting all CloudTrail Trails in an AWS account ### BEWARE! diff --git a/aws/aws.go b/aws/aws.go index 1f9ed386..24a5f64a 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -816,6 +816,20 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp } // End SNS Topics + // Cloudtrail Trails + cloudtrailTrails := CloudtrailTrail{} + if IsNukeable(cloudtrailTrails.ResourceName(), resourceTypes) { + cloudtrailArns, err := getAllCloudtrailTrails(cloudNukeSession, excludeAfter, configObj) + if err != nil { + return nil, errors.WithStackTrace(err) + } + if len(cloudtrailArns) > 0 { + cloudtrailTrails.Arns = awsgo.StringValueSlice(cloudtrailArns) + resourcesInRegion.Resources = append(resourcesInRegion.Resources, cloudtrailTrails) + } + } + // End Cloudtrail Trails + if len(resourcesInRegion.Resources) > 0 { account.Resources[region] = resourcesInRegion } @@ -932,6 +946,7 @@ func ListResourceTypes() []string { ApiGatewayV2{}.ResourceName(), ElasticFileSystem{}.ResourceName(), SNSTopic{}.ResourceName(), + CloudtrailTrail{}.ResourceName(), } sort.Strings(resourceTypes) return resourceTypes diff --git a/aws/cloudtrail.go b/aws/cloudtrail.go new file mode 100644 index 00000000..95bfd350 --- /dev/null +++ b/aws/cloudtrail.go @@ -0,0 +1,78 @@ +package aws + +import ( + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudtrail" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/go-commons/errors" +) + +func getAllCloudtrailTrails(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]*string, error) { + svc := cloudtrail.New(session) + + param := &cloudtrail.ListTrailsInput{} + + trailIds := []*string{} + + paginator := func(output *cloudtrail.ListTrailsOutput, lastPage bool) bool { + for _, trailInfo := range output.Trails { + if shouldIncludeCloudtrailTrail(trailInfo, configObj) { + trailIds = append(trailIds, trailInfo.TrailARN) + } + } + return !lastPage + } + + err := svc.ListTrailsPages(param, paginator) + if err != nil { + return trailIds, errors.WithStackTrace(err) + } + + return trailIds, nil +} + +func shouldIncludeCloudtrailTrail(trail *cloudtrail.TrailInfo, configObj config.Config) bool { + if trail == nil { + return false + } + + return config.ShouldInclude( + aws.StringValue(trail.Name), + configObj.CloudtrailTrail.IncludeRule.NamesRegExp, + configObj.CloudtrailTrail.ExcludeRule.NamesRegExp, + ) +} + +func nukeAllCloudTrailTrails(session *session.Session, arns []*string) error { + svc := cloudtrail.New(session) + + if len(arns) == 0 { + logging.Logger.Infof("No Cloudtrail Trails to nuke in region %s", *session.Config.Region) + return nil + } + + logging.Logger.Infof("Deleting all Cloudtrail Trails in region %s", *session.Config.Region) + var deletedArns []*string + + for _, arn := range arns { + params := &cloudtrail.DeleteTrailInput{ + Name: arn, + } + + _, err := svc.DeleteTrail(params) + if err != nil { + logging.Logger.Errorf("[Failed] %s", err) + } else { + deletedArns = append(deletedArns, arn) + logging.Logger.Infof("Deleted Cloudtrail Trail: %s", aws.StringValue(arn)) + } + } + + logging.Logger.Infof("[OK] %d Cloudtrail Trail deleted in %s", len(deletedArns), *session.Config.Region) + + return nil +} diff --git a/aws/cloudtrail_test.go b/aws/cloudtrail_test.go new file mode 100644 index 00000000..07443a9e --- /dev/null +++ b/aws/cloudtrail_test.go @@ -0,0 +1,214 @@ +package aws + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudtrail" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/cloud-nuke/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListCloudTrailTrails(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + + trailArn := createCloudTrailTrail(t, region) + defer deleteCloudTrailTrail(t, region, trailArn, false) + + trailArns, err := getAllCloudtrailTrails(session, time.Now(), config.Config{}) + require.NoError(t, err) + assert.Contains(t, aws.StringValueSlice(trailArns), aws.StringValue(trailArn)) +} + +func deleteCloudTrailTrail(t *testing.T, region string, trailARN *string, checkErr bool) { + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + + cloudtrailSvc := cloudtrail.New(session) + + param := &cloudtrail.DeleteTrailInput{ + Name: trailARN, + } + + _, deleteErr := cloudtrailSvc.DeleteTrail(param) + if checkErr { + require.NoError(t, deleteErr) + } +} + +func createCloudTrailTrail(t *testing.T, region string) *string { + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + + cloudtrailSvc := cloudtrail.New(session) + s3Svc := s3.New(session) + stsSvc := sts.New(session) + + name := strings.ToLower(fmt.Sprintf("cloud-nuke-test-%s-%s", util.UniqueID(), util.UniqueID())) + + logging.Logger.Debugf("Bucket: %s - creating", name) + + _, bucketCreateErr := s3Svc.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(name), + }) + + require.NoError(t, bucketCreateErr) + + waitErr := s3Svc.WaitUntilBucketExists( + &s3.HeadBucketInput{ + Bucket: aws.String(name), + }, + ) + + require.NoError(t, waitErr) + + // Create and attach the expected S3 bucket policy that CloudTrail requires + policyJson := ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AWSCloudTrailAclCheck20150319", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:GetBucketAcl", + "Resource": "arn:aws:s3:::%s", + "Condition": { + "StringEquals": { + "aws:SourceArn": "arn:aws:cloudtrail:%s:%s:trail/%s" + } + } + }, + { + "Sid": "AWSCloudTrailWrite20150319", + "Effect": "Allow", + "Principal": {"Service": "cloudtrail.amazonaws.com"}, + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::%s/AWSLogs/%s/*", + "Condition": { + "StringEquals": { + "s3:x-amz-acl": "bucket-owner-full-control", + "aws:SourceArn": "arn:aws:cloudtrail:%s:%s:trail/%s" + } + } + } + ] +} +` + + // Look up the current account ID so that we can interpolate it in the S3 bucket policy + callerIdInput := &sts.GetCallerIdentityInput{} + + result, err := stsSvc.GetCallerIdentity(callerIdInput) + + require.NoError(t, err) + + renderedJson := fmt.Sprintf( + policyJson, + name, + region, + aws.StringValue(result.Account), + name, + name, + aws.StringValue(result.Account), + region, + aws.StringValue(result.Account), + name, + ) + + _, err = s3Svc.PutBucketPolicy(&s3.PutBucketPolicyInput{ + Bucket: aws.String(name), + Policy: aws.String(strings.TrimSpace(renderedJson)), + }) + + require.NoError(t, err) + + // Add an arbitrary sleep to account for eventual consistency + time.Sleep(15 * time.Second) + + param := &cloudtrail.CreateTrailInput{ + Name: aws.String(name), + S3BucketName: aws.String(name), + } + + output, createTrailErr := cloudtrailSvc.CreateTrail(param) + require.NoError(t, createTrailErr) + + return output.TrailARN +} + +func TestNukeCloudTrailOne(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + + trailArn := createCloudTrailTrail(t, region) + defer deleteCloudTrailTrail(t, region, trailArn, false) + + identifiers := []*string{trailArn} + + require.NoError( + t, + nukeAllCloudTrailTrails(session, identifiers), + ) + + assertCloudTrailTrailsDeleted(t, region, identifiers) +} + +func TestNukeCloudTrailTrailMoreThanOne(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + require.NoError(t, err) + + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + + trailArns := []*string{} + for i := 0; i < 3; i++ { + // 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. + trailArn := createCloudTrailTrail(t, region) + defer deleteCloudTrailTrail(t, region, trailArn, false) + trailArns = append(trailArns, trailArn) + } + + require.NoError( + t, + nukeAllCloudTrailTrails(session, trailArns), + ) + + assertCloudTrailTrailsDeleted(t, region, trailArns) +} + +func assertCloudTrailTrailsDeleted(t *testing.T, region string, identifiers []*string) { + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + svc := cloudtrail.New(session) + + resp, err := svc.DescribeTrails(&cloudtrail.DescribeTrailsInput{ + TrailNameList: identifiers, + }) + require.NoError(t, err) + if len(resp.TrailList) > 0 { + t.Fatalf("At least one of the following CloudTrail Trails was not deleted: %+v\n", aws.StringValueSlice(identifiers)) + } +} diff --git a/aws/cloudtrail_types.go b/aws/cloudtrail_types.go new file mode 100644 index 00000000..5ee79206 --- /dev/null +++ b/aws/cloudtrail_types.go @@ -0,0 +1,35 @@ +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" +) + +// CloudWatchLogGroup - represents all ec2 instances +type CloudtrailTrail struct { + Arns []string +} + +// ResourceName - the simple name of the aws resource +func (ct CloudtrailTrail) ResourceName() string { + return "cloudtrail" +} + +// ResourceIdentifiers - The instance ids of the ec2 instances +func (ct CloudtrailTrail) ResourceIdentifiers() []string { + return ct.Arns +} + +func (ct CloudtrailTrail) MaxBatchSize() int { + return 50 +} + +// Nuke - nuke 'em all!!! +func (ct CloudtrailTrail) Nuke(session *session.Session, identifiers []string) error { + if err := nukeAllCloudTrailTrails(session, awsgo.StringSlice(identifiers)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/config/config.go b/config/config.go index a7e6e5ab..0cfe8a3e 100644 --- a/config/config.go +++ b/config/config.go @@ -39,6 +39,7 @@ type Config struct { APIGateway ResourceType `yaml:"APIGateway"` APIGatewayV2 ResourceType `yaml:"APIGatewayV2"` ElasticFileSystem ResourceType `yaml:"ElasticFileSystem"` + CloudtrailTrail ResourceType `yaml:"CloudtrailTrail"` } type ResourceType struct { diff --git a/config/config_test.go b/config/config_test.go index 9cf455eb..79ed1347 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -40,6 +40,7 @@ func emptyConfig() *Config { ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, + ResourceType{FilterRule{}, FilterRule{}}, } } @@ -919,6 +920,101 @@ func TestConfigElasticFileSystem_FilterNames(t *testing.T) { // end ElasticFileSystem tests +// Cloudtrail Trail Tests + +func TestConfigCloudtrailTrail_Empty(t *testing.T) { + configFilePath := "./mocks/cloudtrail_empty.yaml" + configObj, err := GetConfig(configFilePath) + + require.NoError(t, err) + + if !reflect.DeepEqual(configObj, emptyConfig()) { + assert.Fail(t, "Config should be empty, %+v\n", configObj.APIGateway) + } + + return +} + +func TestConfigCloudtrailTrail_EmptyFilters(t *testing.T) { + configFilePath := "./mocks/cloudtrail_empty_filters.yaml" + configObj, err := GetConfig(configFilePath) + + require.NoError(t, err) + + if !reflect.DeepEqual(configObj, emptyConfig()) { + assert.Fail(t, "Config should be empty, %+v\n", configObj) + } + + return +} + +func TestConfigCloudtrailTrail_EmptyRules(t *testing.T) { + configFilePath := "./mocks/cloudtrail_empty_rules.yaml" + configObj, err := GetConfig(configFilePath) + + require.NoError(t, err) + + if !reflect.DeepEqual(configObj, emptyConfig()) { + assert.Fail(t, "Config should be empty, %+v\n", configObj) + } + + return +} + +func TestConfigCloudtrailTrail_IncludeNames(t *testing.T) { + configFilePath := "./mocks/cloudtrail_include_names.yaml" + configObj, err := GetConfig(configFilePath) + + require.NoError(t, err) + + if reflect.DeepEqual(configObj, emptyConfig()) { + assert.Fail(t, "Config should not be empty, %+v\n", configObj) + } + + if len(configObj.CloudtrailTrail.IncludeRule.NamesRegExp) == 0 { + assert.Fail(t, "ConfigObj should contain CloudtrailTrail regexes, %+v\n", configObj) + } + + return +} + +func TestConfigCloudtrailTrail_ExcludeNames(t *testing.T) { + configFilePath := "./mocks/cloudtrail_exclude_names.yaml" + configObj, err := GetConfig(configFilePath) + + require.NoError(t, err) + + if reflect.DeepEqual(configObj, emptyConfig()) { + assert.Fail(t, "Config should not be empty, %+v\n", configObj) + } + + if len(configObj.CloudtrailTrail.ExcludeRule.NamesRegExp) == 0 { + assert.Fail(t, "ConfigObj should contain CloudtrailTrail regexes, %+v\n", configObj) + } + + return +} + +func TestConfigCloudtrailTrail_FilterNames(t *testing.T) { + configFilePath := "./mocks/cloudtrail_filter_names.yaml" + configObj, err := GetConfig(configFilePath) + + require.NoError(t, err) + + if reflect.DeepEqual(configObj, emptyConfig()) { + assert.Fail(t, "Config should not be empty, %+v\n", configObj) + } + + if len(configObj.CloudtrailTrail.IncludeRule.NamesRegExp) == 0 || + len(configObj.CloudtrailTrail.ExcludeRule.NamesRegExp) == 0 { + assert.Fail(t, "ConfigObj should contain CloudtrailTrail regexes, %+v\n", configObj) + } + + return +} + +// end CloudtrailTrail tests + func TestShouldInclude_AllowWhenEmpty(t *testing.T) { var includeREs []Expression var excludeREs []Expression diff --git a/config/mocks/cloudtrail_empty.yaml b/config/mocks/cloudtrail_empty.yaml new file mode 100644 index 00000000..3886d816 --- /dev/null +++ b/config/mocks/cloudtrail_empty.yaml @@ -0,0 +1 @@ +CloudtrailTrail: diff --git a/config/mocks/cloudtrail_empty_filters.yaml b/config/mocks/cloudtrail_empty_filters.yaml new file mode 100644 index 00000000..df4ce1cb --- /dev/null +++ b/config/mocks/cloudtrail_empty_filters.yaml @@ -0,0 +1,5 @@ +CloudtrailTrail: + include: + names_regex: + exclude: + names_regex: diff --git a/config/mocks/cloudtrail_empty_rules.yaml b/config/mocks/cloudtrail_empty_rules.yaml new file mode 100644 index 00000000..7d97bbcb --- /dev/null +++ b/config/mocks/cloudtrail_empty_rules.yaml @@ -0,0 +1,3 @@ +CloudtrailTrail: + include: + exclude: diff --git a/config/mocks/cloudtrail_exclude_names.yaml b/config/mocks/cloudtrail_exclude_names.yaml new file mode 100644 index 00000000..3e399758 --- /dev/null +++ b/config/mocks/cloudtrail_exclude_names.yaml @@ -0,0 +1,5 @@ +CloudtrailTrail: + exclude: + names_regex: + - alice + - bob diff --git a/config/mocks/cloudtrail_filter_names.yaml b/config/mocks/cloudtrail_filter_names.yaml new file mode 100644 index 00000000..2da9f614 --- /dev/null +++ b/config/mocks/cloudtrail_filter_names.yaml @@ -0,0 +1,9 @@ +CloudtrailTrail: + include: + names_regex: + - ^cloud-nuke-* + - test + exclude: + names_regex: + - alice + - bob diff --git a/config/mocks/cloudtrail_include_names.yaml b/config/mocks/cloudtrail_include_names.yaml new file mode 100644 index 00000000..b5cd9b3a --- /dev/null +++ b/config/mocks/cloudtrail_include_names.yaml @@ -0,0 +1,5 @@ +CloudtrailTrail: + include: + names_regex: + - ^cloud-nuke-* + - test