diff --git a/README.md b/README.md index 0f36a4814..f24180952 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ The currently supported functionality includes: ## AWS +- Deleting all ACM Private CA in an AWS account - Deleting all Auto scaling groups in an AWS account - Deleting all Elastic Load Balancers (Classic and V2) in an AWS account - Deleting all Transit Gateways in an AWS account @@ -152,6 +153,9 @@ The following resources support the Config file: - NAT Gateways - Resource type: `nat-gateway` - Config key: `NATGateway` +- ACM Private CAs + - Resource type: `acmpca` + - Config key: `ACMPCA` #### Example @@ -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 | +| acmpca | none | ✅ | none | none | | ec2 instance | none | none | none | none | | iam role | none | none | none | none | | ... (more to come) | none | none | none | none | diff --git a/aws/acmpca.go b/aws/acmpca.go new file mode 100644 index 000000000..497c8e707 --- /dev/null +++ b/aws/acmpca.go @@ -0,0 +1,65 @@ +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/acmpca" + "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/go-commons/errors" +) + +// getAllACMPCA returns a list of all arns of ACMPCA, which can be deleted. +func getAllACMPCA(session *session.Session, region string, excludeAfter time.Time) ([]*string, error) { + svc := acmpca.New(session) + + result, err := svc.ListCertificateAuthorities(&acmpca.ListCertificateAuthoritiesInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + var arns []*string + for _, ca := range result.CertificateAuthorities { + if excludeAfter.After(*ca.CreatedAt) { + arns = append(arns, ca.Arn) + } + } + return arns, nil +} + +// nukeAllACMPCA will delete all ACMPCA, which are given by a list of arns. +func nukeAllACMPCA(session *session.Session, arns []*string) error { + if len(arns) == 0 { + logging.Logger.Infof("No ACMPCA to nuke in region %s", *session.Config.Region) + return nil + } + svc := acmpca.New(session) + + logging.Logger.Infof("Deleting all ACMPCA in region %s", *session.Config.Region) + var deletedARNs []*string + + for _, arn := range arns { + logging.Logger.Infof("Setting status to 'DISABLED' for ACMPCA %s in region %s", *arn, *session.Config.Region) + if _, updateStatusErr := svc.UpdateCertificateAuthority(&acmpca.UpdateCertificateAuthorityInput{ + CertificateAuthorityArn: arn, + Status: aws.String(acmpca.CertificateAuthorityStatusDisabled), + }); updateStatusErr != nil { + logging.Logger.Errorf("[Failed] %s", updateStatusErr) + continue + } + logging.Logger.Infof("Did set status to 'DISABLED' for ACMPCA: %s in region %s", *arn, *session.Config.Region) + + if _, deleteErr := svc.DeleteCertificateAuthority(&acmpca.DeleteCertificateAuthorityInput{ + CertificateAuthorityArn: arn, + PermanentDeletionTimeInDays: aws.Int64(30), + }); deleteErr != nil { + logging.Logger.Errorf("[Failed] %s", deleteErr) + continue + } + deletedARNs = append(deletedARNs, arn) + logging.Logger.Infof("Deleted ACMPCA: %s", *arn) + } + logging.Logger.Infof("[OK] %d ACMPCA(s) deleted in %s", len(arns), *session.Config.Region) + return nil +} diff --git a/aws/acmpca_test.go b/aws/acmpca_test.go new file mode 100644 index 000000000..2804397cd --- /dev/null +++ b/aws/acmpca_test.go @@ -0,0 +1,103 @@ +package aws + +import ( + "testing" + "time" + + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/acmpca" + "github.com/gruntwork-io/cloud-nuke/util" + "github.com/gruntwork-io/go-commons/errors" + "github.com/stretchr/testify/assert" +) + +// createTestACMPCA will create am ACMPCA and return its ARN. +func createTestACMPCA(t *testing.T, session *session.Session, name string) *string { + svc := acmpca.New(session) + ca, err := svc.CreateCertificateAuthority(&acmpca.CreateCertificateAuthorityInput{ + CertificateAuthorityConfiguration: &acmpca.CertificateAuthorityConfiguration{ + KeyAlgorithm: nil, + SigningAlgorithm: nil, + Subject: &acmpca.ASN1Subject{ + CommonName: awsgo.String(name), + }, + }, + CertificateAuthorityType: awsgo.String("ROOT"), + Tags: []*acmpca.Tag{ + { + Key: awsgo.String("Name"), + Value: awsgo.String(name), + }, + }, + }) + if err != nil { + assert.Failf(t, "Could not create ACMPCA", errors.WithStackTrace(err).Error()) + } + return ca.CertificateAuthorityArn +} + +func TestListACMPCA(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + uniqueTestID := "cloud-nuke-test-" + util.UniqueID() + arn := createTestACMPCA(t, session, uniqueTestID) + // clean up after this test + defer nukeAllACMPCA(session, []*string{arn}) + + newARNs, err := getAllACMPCA(session, region, time.Now().Add(1*time.Hour*-1)) + if err != nil { + assert.Fail(t, "Unable to fetch list of ACMPCA arns") + } + assert.NotContains(t, awsgo.StringValueSlice(newARNs), awsgo.StringValue(arn)) + + allARNs, err := getAllACMPCA(session, region, time.Now().Add(1*time.Hour)) + if err != nil { + assert.Fail(t, "Unable to fetch list of ACMPCA arns") + } + + assert.Contains(t, awsgo.StringValueSlice(allARNs), awsgo.StringValue(arn)) +} + +func TestNukeACMPCA(t *testing.T) { + t.Parallel() + + region, err := getRandomRegion() + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + session, err := session.NewSession(&awsgo.Config{ + Region: awsgo.String(region)}, + ) + + if err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + uniqueTestID := "cloud-nuke-test-" + util.UniqueID() + arn := createTestACMPCA(t, session, uniqueTestID) + + if err := nukeAllACMPCA(session, []*string{arn}); err != nil { + assert.Fail(t, errors.WithStackTrace(err).Error()) + } + + arns, err := getAllACMPCA(session, region, time.Now().Add(1*time.Hour)) + if err != nil { + assert.Fail(t, "Unable to fetch list of ACMPCA arns") + } + + assert.NotContains(t, awsgo.StringValueSlice(arns), awsgo.StringValue(arn)) +} diff --git a/aws/acmpca_types.go b/aws/acmpca_types.go new file mode 100644 index 000000000..35c681572 --- /dev/null +++ b/aws/acmpca_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" +) + +// ACMPA - represents all ACMPA +type ACMPCA struct { + ARNs []string +} + +// ResourceName - the simple name of the aws resource +func (ca ACMPCA) ResourceName() string { + return "acmpca" +} + +// ResourceIdentifiers - The volume ids of the ebs volumes +func (ca ACMPCA) ResourceIdentifiers() []string { + return ca.ARNs +} + +func (ca ACMPCA) MaxBatchSize() int { + // Tentative batch size to ensure AWS doesn't throttle + return 10 +} + +// Nuke - nuke 'em all!!! +func (ca ACMPCA) Nuke(session *session.Session, arns []string) error { + if err := nukeAllACMPCA(session, awsgo.StringSlice(arns)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/aws/aws.go b/aws/aws.go index c4f710bec..ce2cc5f5f 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -220,6 +220,20 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp // The order in which resources are nuked is important // because of dependencies between resources + // ACMPCA arns + acmpca := ACMPCA{} + if IsNukeable(acmpca.ResourceName(), resourceTypes) { + arns, err := getAllACMPCA(session, region, excludeAfter) + if err != nil { + return nil, errors.WithStackTrace(err) + } + if len(arns) > 0 { + acmpca.ARNs = awsgo.StringValueSlice(arns) + resourcesInRegion.Resources = append(resourcesInRegion.Resources, acmpca) + } + } + // End ACMPCA arns + // ASG Names asGroups := ASGroups{} if IsNukeable(asGroups.ResourceName(), resourceTypes) { @@ -620,6 +634,7 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp // ListResourceTypes - Returns list of resources which can be passed to --resource-type func ListResourceTypes() []string { resourceTypes := []string{ + ACMPCA{}.ResourceName(), ASGroups{}.ResourceName(), LaunchConfigs{}.ResourceName(), LoadBalancers{}.ResourceName(), diff --git a/config/config.go b/config/config.go index 2a26c7552..8f54a96db 100644 --- a/config/config.go +++ b/config/config.go @@ -14,6 +14,7 @@ type Config struct { IAMUsers ResourceType `yaml:"IAMUsers"` SecretsManagerSecrets ResourceType `yaml:"SecretsManager"` NatGateway ResourceType `yaml:"NatGateway"` + ACMPCA ResourceType `yaml:"ACMPCA"` } type ResourceType struct { diff --git a/config/config_test.go b/config/config_test.go index bee2f57c4..e33ff016e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -14,6 +14,7 @@ func emptyConfig() *Config { ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, ResourceType{FilterRule{}, FilterRule{}}, + ResourceType{FilterRule{}, FilterRule{}}, } } @@ -52,6 +53,99 @@ func TestConfig_Empty(t *testing.T) { return } +// ACMPCA Tests + +func TestConfigACMPCA_Empty(t *testing.T) { + configFilePath := "./mocks/acmpa_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.ACMPCA) + } + + return +} + +func TestConfigACMPCA_EmptyFilters(t *testing.T) { + configFilePath := "./mocks/acmpca_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 TestConfigACMPCA_EmptyRules(t *testing.T) { + configFilePath := "./mocks/acmpca_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 TestConfigACMPCA_IncludeNames(t *testing.T) { + configFilePath := "./mocks/acmpca_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.ACMPCA.IncludeRule.NamesRegExp) == 0 { + assert.Fail(t, "ConfigObj should contain ACMPCA arn regexes, %+v\n", configObj) + } + + return +} + +func TestConfigACMPCA_ExcludeNames(t *testing.T) { + configFilePath := "./mocks/acmpca_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.ACMPCA.ExcludeRule.NamesRegExp) == 0 { + assert.Fail(t, "ConfigObj should contain ACMPCA arn regexes, %+v\n", configObj) + } + + return +} + +func TestConfigACMPCA_FilterNames(t *testing.T) { + configFilePath := "./mocks/acmpca_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.ACMPCA.IncludeRule.NamesRegExp) == 0 || + len(configObj.ACMPCA.ExcludeRule.NamesRegExp) == 0 { + assert.Fail(t, "ConfigObj should contain ACMPCA arn regexes, %+v\n", configObj) + } + + return +} + // S3 Tests func TestConfigS3_Empty(t *testing.T) { diff --git a/config/mocks/acmpca_all.yaml b/config/mocks/acmpca_all.yaml new file mode 100644 index 000000000..825fd8a23 --- /dev/null +++ b/config/mocks/acmpca_all.yaml @@ -0,0 +1,4 @@ +acmpca: + include: + names_regex: + - .* diff --git a/config/mocks/acmpca_cleanup.yaml b/config/mocks/acmpca_cleanup.yaml new file mode 100644 index 000000000..a13c4c086 --- /dev/null +++ b/config/mocks/acmpca_cleanup.yaml @@ -0,0 +1,5 @@ +acmpca: + include: + names_regex: + - ^cloud-nuke-test- + - -cloud-nuke-test- diff --git a/config/mocks/acmpca_empty.yaml b/config/mocks/acmpca_empty.yaml new file mode 100644 index 000000000..75232ae60 --- /dev/null +++ b/config/mocks/acmpca_empty.yaml @@ -0,0 +1 @@ +acmpca: diff --git a/config/mocks/acmpca_empty_filters.yaml b/config/mocks/acmpca_empty_filters.yaml new file mode 100644 index 000000000..b4afa920a --- /dev/null +++ b/config/mocks/acmpca_empty_filters.yaml @@ -0,0 +1,5 @@ +acmpca: + include: + names_regex: + exclude: + names_regex: diff --git a/config/mocks/acmpca_empty_rules.yaml b/config/mocks/acmpca_empty_rules.yaml new file mode 100644 index 000000000..a0f984c20 --- /dev/null +++ b/config/mocks/acmpca_empty_rules.yaml @@ -0,0 +1,3 @@ +acmpca: + include: + exclude: diff --git a/config/mocks/acmpca_exclude_names.yaml b/config/mocks/acmpca_exclude_names.yaml new file mode 100644 index 000000000..b998c0c5d --- /dev/null +++ b/config/mocks/acmpca_exclude_names.yaml @@ -0,0 +1,5 @@ +acmpca: + exclude: + names_regex: + - donotdelete-0 + - donotdelete-1 diff --git a/config/mocks/acmpca_filter_names.yaml b/config/mocks/acmpca_filter_names.yaml new file mode 100644 index 000000000..ed54bb58f --- /dev/null +++ b/config/mocks/acmpca_filter_names.yaml @@ -0,0 +1,9 @@ +acmpca: + include: + names_regex: + - ^cloud-nuke-* + - test + exclude: + names_regex: + - donotdelete-0 + - donotdelete-1 diff --git a/config/mocks/acmpca_include_names.yaml b/config/mocks/acmpca_include_names.yaml new file mode 100644 index 000000000..639ee0c42 --- /dev/null +++ b/config/mocks/acmpca_include_names.yaml @@ -0,0 +1,5 @@ +acmpca: + include: + names_regex: + - ^cloud-nuke-* + - test