Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions aws/resource_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func getRegisteredRegionalResources() []resources.AwsResource {
resources.NewSageMakerEndpoint(),
resources.NewSageMakerEndpointConfig(),
resources.NewSecretsManagerSecrets(),
resources.NewSSMParameter(),
resources.NewSecurityHub(),
resources.NewSesConfigurationSet(),
resources.NewSesEmailTemplates(),
Expand Down
115 changes: 115 additions & 0 deletions aws/resources/ssm_parameter.go
Original file line number Diff line number Diff line change
@@ -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)
}
207 changes: 207 additions & 0 deletions aws/resources/ssm_parameter_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 2 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -266,6 +267,7 @@ func (c *Config) allResourceTypes() []*ResourceType {
&c.SageMakerNotebook,
&c.SageMakerStudioDomain,
&c.SecretsManager,
&c.SSMParameter,
&c.SecurityHub,
&c.Snapshots,
&c.TransitGateway,
Expand Down
1 change: 1 addition & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 2 additions & 0 deletions docs/supported-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 | | ✓ | | ✓ |
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading