diff --git a/aws/resource_registry.go b/aws/resource_registry.go index a2a46556..9aba159f 100644 --- a/aws/resource_registry.go +++ b/aws/resource_registry.go @@ -143,6 +143,7 @@ func getRegisteredRegionalResources() []resources.AwsResource { resources.NewSageMakerEndpoint(), resources.NewSageMakerEndpointConfig(), resources.NewSecretsManagerSecrets(), + resources.NewSSMParameter(), resources.NewSecurityHub(), resources.NewSesConfigurationSet(), resources.NewSesEmailTemplates(), diff --git a/aws/resources/ssm_parameter.go b/aws/resources/ssm_parameter.go new file mode 100644 index 00000000..81b5ed81 --- /dev/null +++ b/aws/resources/ssm_parameter.go @@ -0,0 +1,115 @@ +package resources + +import ( + "context" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/logging" + "github.com/gruntwork-io/cloud-nuke/resource" + "github.com/gruntwork-io/go-commons/errors" +) + +// SSMParameterAPI defines the interface for SSM Parameter Store operations. +type SSMParameterAPI interface { + DescribeParameters(ctx context.Context, params *ssm.DescribeParametersInput, optFns ...func(*ssm.Options)) (*ssm.DescribeParametersOutput, error) + ListTagsForResource(ctx context.Context, params *ssm.ListTagsForResourceInput, optFns ...func(*ssm.Options)) (*ssm.ListTagsForResourceOutput, error) + DeleteParameter(ctx context.Context, params *ssm.DeleteParameterInput, optFns ...func(*ssm.Options)) (*ssm.DeleteParameterOutput, error) +} + +// NewSSMParameter creates a new SSMParameter resource using the generic resource pattern. +func NewSSMParameter() AwsResource { + return NewAwsResource(&resource.Resource[SSMParameterAPI]{ + ResourceTypeName: "ssm-parameter", + // Conservative batch size to avoid AWS throttling. + BatchSize: 10, + InitClient: WrapAwsInitClient(func(r *resource.Resource[SSMParameterAPI], cfg aws.Config) { + r.Scope.Region = cfg.Region + r.Client = ssm.NewFromConfig(cfg) + }), + ConfigGetter: func(c config.Config) config.ResourceType { + return c.SSMParameter + }, + Lister: listSSMParameters, + Nuker: resource.SimpleBatchDeleter(deleteSSMParameter), + }) +} + +// listSSMParameters retrieves all SSM parameters that match the config filters. +func listSSMParameters(ctx context.Context, client SSMParameterAPI, scope resource.Scope, cfg config.ResourceType) ([]*string, error) { + var names []*string + + paginator := ssm.NewDescribeParametersPaginator(client, &ssm.DescribeParametersInput{}) + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + for _, param := range page.Parameters { + if param.Name == nil { + continue + } + + // AWS reserves the /aws/ prefix for public parameters (e.g. /aws/service/*, /aws/reference/*). + // Customers cannot create parameters under this path, and deletion will fail with AccessDeniedException. + if strings.HasPrefix(aws.ToString(param.Name), "/aws/") { + logging.Debugf("Skipping %s since it is an AWS-managed parameter", aws.ToString(param.Name)) + continue + } + + tags, err := getSSMParameterTags(ctx, client, param.Name) + if err != nil { + // Skip rather than proceed with nil tags. Passing nil to ShouldInclude + // signals "no tag support", which bypasses cloud-nuke-excluded and + // cloud-nuke-after protection checks. Skip this run; retry on the next. + logging.Errorf("Unable to fetch tags for SSM parameter %s: %s", aws.ToString(param.Name), err) + continue + } + + // SSM DescribeParameters does not expose a creation time; LastModifiedDate is the + // closest available timestamp and is used as the time filter for this resource type. + if cfg.ShouldInclude(config.ResourceValue{ + Name: param.Name, + Time: param.LastModifiedDate, + Tags: tags, + }) { + names = append(names, param.Name) + } + } + } + + return names, nil +} + +// getSSMParameterTags returns the tags for an SSM parameter by name. +// Returns an empty non-nil map when the parameter has no tags. +func getSSMParameterTags(ctx context.Context, client SSMParameterAPI, name *string) (map[string]string, error) { + output, err := client.ListTagsForResource(ctx, &ssm.ListTagsForResourceInput{ + ResourceId: name, + ResourceType: types.ResourceTypeForTaggingParameter, + }) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + tags := make(map[string]string, len(output.TagList)) + for _, tag := range output.TagList { + if tag.Key != nil && tag.Value != nil { + tags[*tag.Key] = *tag.Value + } + } + + return tags, nil +} + +// deleteSSMParameter deletes a single SSM parameter by name. +func deleteSSMParameter(ctx context.Context, client SSMParameterAPI, name *string) error { + _, err := client.DeleteParameter(ctx, &ssm.DeleteParameterInput{ + Name: name, + }) + return errors.WithStackTrace(err) +} diff --git a/aws/resources/ssm_parameter_test.go b/aws/resources/ssm_parameter_test.go new file mode 100644 index 00000000..ece12bc8 --- /dev/null +++ b/aws/resources/ssm_parameter_test.go @@ -0,0 +1,207 @@ +package resources + +import ( + "context" + "fmt" + "regexp" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSSMParameterClient implements SSMParameterAPI for testing. +type mockSSMParameterClient struct { + DescribeParametersOutput ssm.DescribeParametersOutput + DescribeParametersError error + + // TagsByName maps parameter name to its tags. If a name is absent, an empty tag list is returned. + TagsByName map[string][]types.Tag + TagsError error + TagsErrorNames map[string]bool // names that trigger TagsError + + DeleteParameterError error +} + +func (m *mockSSMParameterClient) DescribeParameters(ctx context.Context, params *ssm.DescribeParametersInput, optFns ...func(*ssm.Options)) (*ssm.DescribeParametersOutput, error) { + if m.DescribeParametersError != nil { + return nil, m.DescribeParametersError + } + return &m.DescribeParametersOutput, nil +} + +func (m *mockSSMParameterClient) ListTagsForResource(ctx context.Context, params *ssm.ListTagsForResourceInput, optFns ...func(*ssm.Options)) (*ssm.ListTagsForResourceOutput, error) { + name := aws.ToString(params.ResourceId) + if m.TagsErrorNames != nil && m.TagsErrorNames[name] { + return nil, m.TagsError + } + return &ssm.ListTagsForResourceOutput{TagList: m.TagsByName[name]}, nil +} + +func (m *mockSSMParameterClient) DeleteParameter(ctx context.Context, params *ssm.DeleteParameterInput, optFns ...func(*ssm.Options)) (*ssm.DeleteParameterOutput, error) { + if m.DeleteParameterError != nil { + return nil, m.DeleteParameterError + } + return &ssm.DeleteParameterOutput{}, nil +} + +func TestSSMParameter_ResourceName(t *testing.T) { + t.Parallel() + r := NewSSMParameter() + assert.Equal(t, "ssm-parameter", r.ResourceName()) +} + +func TestSSMParameter_MaxBatchSize(t *testing.T) { + t.Parallel() + r := NewSSMParameter() + assert.Equal(t, 10, r.MaxBatchSize()) +} + +func TestSSMParameter_GetAll(t *testing.T) { + t.Parallel() + + now := time.Now() + + mock := &mockSSMParameterClient{ + DescribeParametersOutput: ssm.DescribeParametersOutput{ + Parameters: []types.ParameterMetadata{ + {Name: aws.String("/app/db-password"), LastModifiedDate: aws.Time(now)}, + {Name: aws.String("/app/api-key"), LastModifiedDate: aws.Time(now.Add(1 * time.Hour))}, + {Name: nil, LastModifiedDate: aws.Time(now)}, // nil names must be skipped + }, + }, + TagsByName: map[string][]types.Tag{ + "/app/db-password": {{Key: aws.String("Env"), Value: aws.String("prod")}}, + "/app/api-key": {{Key: aws.String("Env"), Value: aws.String("test")}}, + }, + } + + tests := map[string]struct { + configObj config.ResourceType + expected []string + }{ + "emptyFilter": { + configObj: config.ResourceType{}, + expected: []string{"/app/db-password", "/app/api-key"}, + }, + "nameExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + NamesRegExp: []config.Expression{{RE: *regexp.MustCompile("db-password")}}, + }, + }, + expected: []string{"/app/api-key"}, + }, + "timeAfterExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + TimeAfter: aws.Time(now.Add(30 * time.Minute)), + }, + }, + expected: []string{"/app/db-password"}, + }, + "tagExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + Tags: map[string]config.Expression{ + "Env": {RE: *regexp.MustCompile("prod")}, + }, + }, + }, + expected: []string{"/app/api-key"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + names, err := listSSMParameters(context.Background(), mock, resource.Scope{}, tc.configObj) + require.NoError(t, err) + require.Equal(t, tc.expected, aws.ToStringSlice(names)) + }) + } +} + +// TestSSMParameter_GetAll_TagFetchFailure verifies that a parameter whose tags cannot be +// fetched is skipped. Proceeding with nil tags would bypass the cloud-nuke-excluded and +// cloud-nuke-after protection checks, risking accidental deletion of protected resources. +func TestSSMParameter_GetAll_TagFetchFailure(t *testing.T) { + t.Parallel() + + now := time.Now() + mock := &mockSSMParameterClient{ + DescribeParametersOutput: ssm.DescribeParametersOutput{ + Parameters: []types.ParameterMetadata{ + {Name: aws.String("/param/good"), LastModifiedDate: aws.Time(now)}, + {Name: aws.String("/param/no-tag-access"), LastModifiedDate: aws.Time(now)}, + }, + }, + TagsError: fmt.Errorf("AccessDenied: not authorized to list tags"), + TagsErrorNames: map[string]bool{"/param/no-tag-access": true}, + } + + names, err := listSSMParameters(context.Background(), mock, resource.Scope{}, config.ResourceType{}) + require.NoError(t, err) + require.Equal(t, []string{"/param/good"}, aws.ToStringSlice(names)) +} + +// TestSSMParameter_GetAll_SkipsAwsManagedParameters verifies that AWS-managed public +// parameters (those under /aws/) are never included for deletion. +func TestSSMParameter_GetAll_SkipsAwsManagedParameters(t *testing.T) { + t.Parallel() + + now := time.Now() + mock := &mockSSMParameterClient{ + DescribeParametersOutput: ssm.DescribeParametersOutput{ + Parameters: []types.ParameterMetadata{ + {Name: aws.String("/app/my-param"), LastModifiedDate: aws.Time(now)}, + {Name: aws.String("/aws/service/ami-amazon-linux-latest/hvm/ebs/x86_64/al2/recommended/ami-id"), LastModifiedDate: aws.Time(now)}, + {Name: aws.String("/aws/reference/secretsmanager/my-secret"), LastModifiedDate: aws.Time(now)}, + }, + }, + TagsByName: map[string][]types.Tag{ + "/app/my-param": {}, + }, + } + + names, err := listSSMParameters(context.Background(), mock, resource.Scope{}, config.ResourceType{}) + require.NoError(t, err) + require.Equal(t, []string{"/app/my-param"}, aws.ToStringSlice(names)) +} + +func TestSSMParameter_GetAll_DescribeError(t *testing.T) { + t.Parallel() + + mock := &mockSSMParameterClient{ + DescribeParametersError: fmt.Errorf("AccessDenied: not authorized"), + } + + _, err := listSSMParameters(context.Background(), mock, resource.Scope{}, config.ResourceType{}) + require.Error(t, err) + require.Contains(t, err.Error(), "AccessDenied") +} + +func TestSSMParameter_NukeAll(t *testing.T) { + t.Parallel() + + mock := &mockSSMParameterClient{} + err := deleteSSMParameter(context.Background(), mock, aws.String("/app/db-password")) + require.NoError(t, err) +} + +func TestSSMParameter_NukeAll_Error(t *testing.T) { + t.Parallel() + + mock := &mockSSMParameterClient{ + DeleteParameterError: fmt.Errorf("AccessDenied: not authorized to delete"), + } + err := deleteSSMParameter(context.Background(), mock, aws.String("/app/db-password")) + require.Error(t, err) + require.Contains(t, err.Error(), "AccessDenied") +} diff --git a/config/config.go b/config/config.go index e20d52ef..58a6c635 100644 --- a/config/config.go +++ b/config/config.go @@ -125,6 +125,7 @@ type Config struct { SageMakerNotebook ResourceType `yaml:"SageMakerNotebook"` SageMakerStudioDomain ResourceType `yaml:"SageMakerStudioDomain"` SecretsManager ResourceType `yaml:"SecretsManager"` + SSMParameter ResourceType `yaml:"SSMParameter"` SecurityHub ResourceType `yaml:"SecurityHub"` Snapshots ResourceType `yaml:"Snapshots"` TransitGateway ResourceType `yaml:"TransitGateway"` @@ -266,6 +267,7 @@ func (c *Config) allResourceTypes() []*ResourceType { &c.SageMakerNotebook, &c.SageMakerStudioDomain, &c.SecretsManager, + &c.SSMParameter, &c.SecurityHub, &c.Snapshots, &c.TransitGateway, diff --git a/config/config_test.go b/config/config_test.go index 5f55c6cf..8a0a1ac1 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -112,6 +112,7 @@ func emptyConfig() *Config { SageMakerNotebook: ResourceType{FilterRule{}, FilterRule{}, "", false}, SageMakerStudioDomain: ResourceType{FilterRule{}, FilterRule{}, "", false}, SecretsManager: ResourceType{FilterRule{}, FilterRule{}, "", false}, + SSMParameter: ResourceType{FilterRule{}, FilterRule{}, "", false}, SecurityHub: ResourceType{FilterRule{}, FilterRule{}, "", false}, Snapshots: ResourceType{FilterRule{}, FilterRule{}, "", false}, TransitGateway: ResourceType{FilterRule{}, FilterRule{}, "", false}, diff --git a/docs/supported-resources.md b/docs/supported-resources.md index 71f0caa4..143cf625 100644 --- a/docs/supported-resources.md +++ b/docs/supported-resources.md @@ -123,6 +123,7 @@ cloud-nuke supports inspecting and deleting the following AWS resources. The **C | `ses-receipt-rule-set` | SES Receipt Rule Set | | `sns-topic` | SNS Topic | | `sqs` | SQS Queue | +| `ssm-parameter` | SSM Parameter Store Parameter | | `transit-gateway` | Transit Gateway | | `transit-gateway-attachment` | Transit Gateway VPC Attachment | | `transit-gateway-peering-attachment` | Transit Gateway Peering Attachment | @@ -262,6 +263,7 @@ This table shows which filtering features are supported for each resource type i | ses-receipt-rule-set | SESReceiptRuleSet | ✓ | ✓ | | ✓ | | sns-topic | SNS | ✓ | ✓ | | ✓ | | sqs | SQS | ✓ | ✓ | | ✓ | +| ssm-parameter | SSMParameter | ✓ | ✓ | ✓ | ✓ | | transit-gateway | TransitGateway | ✓ | ✓ | | ✓ | | transit-gateway-attachment | TransitGatewayVPCAttachment | | ✓ | | ✓ | | transit-gateway-peering-attachment | TransitGatewayPeeringAttachment | | ✓ | | ✓ | diff --git a/go.mod b/go.mod index c69d9a82..72affd52 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ses v1.29.9 github.com/aws/aws-sdk-go-v2/service/sns v1.33.18 github.com/aws/aws-sdk-go-v2/service/sqs v1.37.13 + github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 github.com/aws/aws-sdk-go-v2/service/vpclattice v1.13.9 github.com/aws/smithy-go v1.24.2 @@ -123,6 +124,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gookit/color v1.5.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/lithammer/fuzzysearch v1.1.5 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect diff --git a/go.sum b/go.sum index 59fe8c90..bf5abbab 100644 --- a/go.sum +++ b/go.sum @@ -179,6 +179,8 @@ github.com/aws/aws-sdk-go-v2/service/sns v1.33.18 h1:jiLcwPNwOzhnM7sIjuz0L5C3Xgl github.com/aws/aws-sdk-go-v2/service/sns v1.33.18/go.mod h1:2UJVrquCqVh4UXGmRXrqFAmuAPc61ybOekjnsjdKWwY= github.com/aws/aws-sdk-go-v2/service/sqs v1.37.13 h1:IAmaBOTC4OaogLKBIWCzSKLXBLbXQxFAEktBVMLCwis= github.com/aws/aws-sdk-go-v2/service/sqs v1.37.13/go.mod h1:LG6s2xJm3K9X9ee5EmYyOveXOgVK4jtunBJBXFJ2TqE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 h1:mADKqoZaodipGgiZfuAjtlcr4IVBtXPZKVjkzUZCCYM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0/go.mod h1:l9qF25TzH95FhcIak6e4vt79KE4I7M2Nf59eMUVjj6c= github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= @@ -242,6 +244,10 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12 h1:p9dKCg8i4gmOxtv35DvrYoWqYzQrvEVdjQ762Y0OqZE= @@ -364,6 +370,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=