Repository: gruntwork-io/cloud-nuke
Pull request #665 (2024-04-02 01:07:21)

implemented network acl nuke
<!-- Prepend '[WIP]' to the title if this PR is still a work-in-progress. Remove it when it is ready for review! -->

## Description

Partially Fixing https://github.com/gruntwork-io/cloud-nuke/issues/476

<!-- Description of the changes introduced by this PR. -->

## TODOs

Read the [Gruntwork contribution guidelines](https://gruntwork.notion.site/Gruntwork-Coding-Methodology-02fdcd6e4b004e818553684760bf691e).

- [ ] Update the docs.
- [ ] Run the relevant tests successfully, including pre-commit checks.
- [ ] Ensure any 3rd party code adheres with our [license policy](https://www.notion.so/gruntwork/Gruntwork-licenses-and-open-source-usage-policy-f7dece1f780341c7b69c1763f22b1378) or delete this line if its not applicable.
- [ ] Include release notes. If this PR is backward incompatible, include a migration guide.
- [ ] Attention Grunts - if this PR adds support for a new resource, ensure the `nuke_sandbox` and `nuke_phxdevops` jobs in `.circleci/config.yml` have been updated with appropriate exclusions (either directly in the job or via the `.circleci/nuke_config.yml` file) to prevent nuking IAM roles, groups, resources, etc that are important for the test accounts.


## Release Notes (draft)

<!-- One-line description of the PR that can be included in the final release notes. -->
Added / Removed / Updated [X].

### Migration Guide

<!-- Important: If you made any backward incompatible changes, then you must write a migration guide! -->




Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index faa526f..9dc2f06 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -61,6 +61,7 @@ jobs:
               --exclude-resource-type config-rules \
               --exclude-resource-type eip \
               --exclude-resource-type nat-gateway \
+              --exclude-resource-type ec2-subnet \
               --delete-unaliased-kms-keys \
               --log-level debug
           no_output_timeout: 1h
@@ -85,6 +86,7 @@ jobs:
               --exclude-resource-type config-rules \
               --exclude-resource-type eip \
               --exclude-resource-type nat-gateway \
+              --exclude-resource-type ec2-subnet \
               --delete-unaliased-kms-keys \
               --log-level debug
           no_output_timeout: 1h
diff --git a/README.md b/README.md
index 7d4b6a0..bcc6096 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ Cloud-nuke suppports 🔎 inspecting and 🔥💀 deleting the following AWS res
 | EC2                     | IPAM BYOASN	          								                   |
 | EC2                     | IPAM Resource Discovery 							                   |
 | EC2                     | Internet Gateway        							                   |
+| EC2                     | Network ACL             							                   |
 | EC2                     | Egress only internet gateway					                   |
 | EC2                     | Endpoint                							                   |
 | EC2                     | Security Group           							                   |
@@ -162,6 +163,8 @@ to [download from the releases page](https://github.com/gruntwork-io/cloud-nuke/
 
 - **Linux:** Most Linux users can use [Homebrew](https://docs.brew.sh/Homebrew-on-Linux): `brew install cloud-nuke`.
 
+- **Windows:** You can install cloud-nuke using [winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/): `winget install cloud-nuke`
+
 ## Usage
 
 Simply running `cloud-nuke aws` will start the process of cleaning up your cloud account. You'll be shown a list of
@@ -582,6 +585,7 @@ of the file that are supported are listed here.
 | macie-member                | MacieMember                  | ❌                                     | ✅ (Creation Time)                   | ❌    |    ❌   |
 | msk-cluster                 | MSKCluster                   | ✅ (Cluster Name)                      | ✅ (Creation Time)                   | ❌    |    ❌   |
 | nat-gateway                 | NatGateway                   | ✅ (EC2 Name Tag)                      | ✅ (Creation Time)                   | ✅    |    ❌   |
+| network-acl                 | NetworkACL                   | ✅ (ACL Name Tag)                      | ✅ (Creation Time)                   | ✅    |    ❌   |
 | network-interface           | NetworkInterface             | ✅ (Interface Name Tag)                | ✅ (Creation Time)                   | ✅    |    ❌   |
 | oidcprovider                | OIDCProvider                 | ✅ (Provider URL)                      | ✅ (Creation Time)                   | ❌    |    ❌   |
 | opensearchdomain            | OpenSearchDomain             | ✅ (Domain Name)                       | ✅ (First Seen Tag Time)             | ❌    |    ❌   |
diff --git a/aws/resource_registry.go b/aws/resource_registry.go
index 231fd36..91e2811 100644
--- a/aws/resource_registry.go
+++ b/aws/resource_registry.go
@@ -133,6 +133,7 @@ func getRegisteredRegionalResources() []AwsResource {
 		&resources.InternetGateway{},
 		&resources.NetworkInterface{},
 		&resources.SecurityGroup{},
+		&resources.NetworkACL{},
 	}
 }
 
diff --git a/aws/resources/ec2_network_acl.go b/aws/resources/ec2_network_acl.go
new file mode 100644
index 0000000..bba7f17
--- /dev/null
+++ b/aws/resources/ec2_network_acl.go
@@ -0,0 +1,249 @@
+package resources
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/cloud-nuke/logging"
+	"github.com/gruntwork-io/cloud-nuke/report"
+	"github.com/gruntwork-io/cloud-nuke/util"
+	"github.com/gruntwork-io/go-commons/errors"
+)
+
+func (nacl *NetworkACL) setFirstSeenTag(networkAcl ec2.NetworkAcl, value time.Time) error {
+	_, err := nacl.Client.CreateTags(&ec2.CreateTagsInput{
+		Resources: []*string{networkAcl.NetworkAclId},
+		Tags: []*ec2.Tag{
+			{
+				Key:   aws.String(util.FirstSeenTagKey),
+				Value: aws.String(util.FormatTimestamp(value)),
+			},
+		},
+	})
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
+
+func (nacl *NetworkACL) getFirstSeenTag(networkAcl ec2.NetworkAcl) (*time.Time, error) {
+	for _, tag := range networkAcl.Tags {
+		if util.IsFirstSeenTag(tag.Key) {
+			firstSeenTime, err := util.ParseTimestamp(tag.Value)
+			if err != nil {
+				return nil, errors.WithStackTrace(err)
+			}
+
+			return firstSeenTime, nil
+		}
+	}
+
+	return nil, nil
+}
+
+func shouldIncludeNetworkACL(networkAcl *ec2.NetworkAcl, firstSeenTime *time.Time, configObj config.Config) bool {
+	var naclName string
+	// get the tags as map
+	tagMap := util.ConvertEC2TagsToMap(networkAcl.Tags)
+	if name, ok := tagMap["Name"]; ok {
+		naclName = name
+	}
+	return configObj.NetworkACL.ShouldInclude(config.ResourceValue{
+		Name: &naclName,
+		Tags: tagMap,
+		Time: firstSeenTime,
+	})
+}
+
+func (nacl *NetworkACL) getAll(_ context.Context, configObj config.Config) ([]*string, error) {
+	var identifiers []*string
+	resp, err := nacl.Client.DescribeNetworkAcls(&ec2.DescribeNetworkAclsInput{
+		Filters: []*ec2.Filter{
+			{
+				Name: aws.String("default"),
+				Values: []*string{
+					aws.String("false"), // can't able to nuke default nacl
+				},
+			},
+		},
+	})
+	if err != nil {
+		logging.Debugf("[Network ACL] Failed to list network ACL: %s", err)
+		return nil, err
+	}
+
+	for _, networkAcl := range resp.NetworkAcls {
+		// check first seen tag
+		firstSeenTime, err := nacl.getFirstSeenTag(*networkAcl)
+		if err != nil {
+			logging.Errorf(
+				"Unable to retrieve tags for network acl: %s, with error: %s", aws.StringValue(networkAcl.NetworkAclId), err)
+			continue
+		}
+
+		// if the first seen tag is not there, then create one
+		if firstSeenTime == nil {
+			now := time.Now().UTC()
+			firstSeenTime = &now
+			if err := nacl.setFirstSeenTag(*networkAcl, time.Now().UTC()); err != nil {
+				logging.Errorf(
+					"Unable to apply first seen tag network acl: %s, with error: %s", aws.StringValue(networkAcl.NetworkAclId), err)
+				continue
+			}
+		}
+
+		if shouldIncludeNetworkACL(networkAcl, firstSeenTime, configObj) {
+			identifiers = append(identifiers, networkAcl.NetworkAclId)
+		}
+	}
+
+	// Check and verify the list of allowed nuke actions
+	nacl.VerifyNukablePermissions(identifiers, func(id *string) error {
+		_, err := nacl.Client.DeleteNetworkAcl(&ec2.DeleteNetworkAclInput{
+			NetworkAclId: id,
+			DryRun:       aws.Bool(true),
+		})
+		return err
+	})
+
+	return identifiers, nil
+}
+
+func (nacl *NetworkACL) nuke(id *string) error {
+
+	// nuke attached subnets
+	if err := nacl.nukeAssociatedSubnets(id); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	// nuking the network ACL
+	if err := nacl.nukeNetworkAcl(id); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
+
+// nukeAssociatedSubnets dissociates the specified network ACL from associated subnets and replaces the associations with the default network ACL in the same VPC.
+// Important : You can't delete the ACL if it's associated with any subnets. You can't delete the default network ACL.
+//
+// Thus, to remove the association, it requires another network ACL ID. Here, we check the default network ACL of the VPC to which the current network ACL is attached,
+// and then associate that network ACL with the association.
+func (nacl *NetworkACL) nukeAssociatedSubnets(id *string) error {
+	resp, err := nacl.Client.DescribeNetworkAcls(&ec2.DescribeNetworkAclsInput{
+		NetworkAclIds: []*string{id},
+	})
+	if err != nil {
+		logging.Debugf("[Network ACL] Failed to describe network ACL: %s", err)
+		return err
+	}
+
+	if len(resp.NetworkAcls) == 0 {
+		logging.Debugf("[Network ACL] Nothing found: %s", aws.StringValue(id))
+		return nil
+	}
+
+	var (
+		networkAcl = resp.NetworkAcls[0]
+		vpcID      = networkAcl.VpcId // get the vpc of this nacl
+	)
+
+	// Get the default nacl association id
+	networkACLs, err := nacl.Client.DescribeNetworkAcls(
+		&ec2.DescribeNetworkAclsInput{
+			Filters: []*ec2.Filter{
+				{
+					Name:   aws.String("vpc-id"),
+					Values: []*string{vpcID},
+				}, {
+					Name: aws.String("default"),
+					Values: []*string{
+						aws.String("true"),
+					},
+				},
+			},
+		},
+	)
+	if err != nil {
+		logging.Debugf("[Network ACL] Failed to describe network ACL: %s", err)
+		return err
+	}
+
+	if len(networkACLs.NetworkAcls) == 0 {
+		logging.Debugf("[Network ACL] Nothing found to check the default association: %s", aws.StringValue(id))
+		return nil
+	}
+
+	var defaultNetworkAclID *string
+	defaultNetworkAclID = networkACLs.NetworkAcls[0].NetworkAclId
+
+	// repalce the association with default
+	for _, association := range networkAcl.Associations {
+		logging.Debug(fmt.Sprintf("Found %d network ACL associations to replace", len(networkAcl.Associations)))
+		_, err := nacl.Client.ReplaceNetworkAclAssociation(&ec2.ReplaceNetworkAclAssociationInput{
+			AssociationId: association.NetworkAclAssociationId,
+			NetworkAclId:  defaultNetworkAclID,
+		})
+		if err != nil {
+			logging.Debug(fmt.Sprintf("Failed to replace network ACL association: %s to default",
+				aws.StringValue(association.NetworkAclAssociationId)))
+			return errors.WithStackTrace(err)
+		}
+		logging.Debug(fmt.Sprintf("Successfully replaced network ACL association: %s to default", aws.StringValue(association.NetworkAclAssociationId)))
+	}
+	return nil
+}
+
+func (nacl *NetworkACL) nukeNetworkAcl(id *string) error {
+	logging.Debugf("Deleting network Acl %s", aws.StringValue(id))
+
+	if _, err := nacl.Client.DeleteNetworkAcl(&ec2.DeleteNetworkAclInput{
+		NetworkAclId: id,
+	}); err != nil {
+		logging.Debugf("An error happened while nuking NACL %s, error %v", aws.StringValue(id), err)
+		return err
+	}
+	logging.Debugf("[Ok] network acl deleted successfully %s", aws.StringValue(id))
+
+	return nil
+}
+
+func (nacl *NetworkACL) nukeAll(identifiers []*string) error {
+	if len(identifiers) == 0 {
+		logging.Debugf("No network acl identifiers to nuke in region %s", nacl.Region)
+		return nil
+	}
+
+	logging.Debugf("Deleting all network interface in region %s", nacl.Region)
+	var deleted []*string
+
+	for _, id := range identifiers {
+		if nukable, err := nacl.IsNukable(*id); !nukable {
+			logging.Debugf("[Skipping] %s nuke because %v", *id, err)
+			continue
+		}
+
+		err := nacl.nuke(id)
+
+		// Record status of this resource
+		e := report.Entry{ // Use the 'r' alias to refer to the package
+			Identifier:   aws.StringValue(id),
+			ResourceType: "Network ACL",
+			Error:        err,
+		}
+		report.Record(e)
+
+		if err == nil {
+			deleted = append(deleted, id)
+		}
+	}
+
+	logging.Debugf("[OK] %d network interface(s) deleted in %s", len(deleted), nacl.Region)
+
+	return nil
+}
diff --git a/aws/resources/ec2_network_acl_types.go b/aws/resources/ec2_network_acl_types.go
new file mode 100644
index 0000000..6b909e7
--- /dev/null
+++ b/aws/resources/ec2_network_acl_types.go
@@ -0,0 +1,54 @@
+package resources
+
+import (
+	"context"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/go-commons/errors"
+)
+
+type NetworkACL struct {
+	BaseAwsResource
+	Client ec2iface.EC2API
+	Region string
+	Ids    []string
+}
+
+func (nacl *NetworkACL) Init(session *session.Session) {
+	nacl.BaseAwsResource.Init(session)
+	nacl.Client = ec2.New(session)
+}
+
+func (nacl *NetworkACL) ResourceName() string {
+	return "network-acl"
+}
+
+func (nacl *NetworkACL) ResourceIdentifiers() []string {
+	return nacl.Ids
+}
+
+func (nacl *NetworkACL) MaxBatchSize() int {
+	return 50
+}
+
+func (nacl *NetworkACL) GetAndSetIdentifiers(c context.Context, configObj config.Config) ([]string, error) {
+	identifiers, err := nacl.getAll(c, configObj)
+	if err != nil {
+		return nil, err
+	}
+
+	nacl.Ids = aws.StringValueSlice(identifiers)
+	return nacl.Ids, nil
+}
+
+func (nacl *NetworkACL) Nuke(identifiers []string) error {
+	if err := nacl.nukeAll(aws.StringSlice(identifiers)); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
diff --git a/config/config.go b/config/config.go
index efcfa06..0434aaf 100644
--- a/config/config.go
+++ b/config/config.go
@@ -102,6 +102,7 @@ type Config struct {
 	Route53CIDRCollection           ResourceType               `yaml:"Route53CIDRCollection"`
 	Route53TrafficPolicy            ResourceType               `yaml:"Route53TrafficPolicy"`
 	InternetGateway                 ResourceType               `yaml:"InternetGateway"`
+	NetworkACL                      ResourceType               `yaml:"NetworkACL"`
 	SecurityGroup                   ResourceType               `yaml:"SecurityGroup"`
 	NetworkInterface                ResourceType               `yaml:"NetworkInterface"`
 }


Test patch:
diff --git a/aws/resources/ec2_network_acl_test.go b/aws/resources/ec2_network_acl_test.go
new file mode 100644
index 0000000..f807754
--- /dev/null
+++ b/aws/resources/ec2_network_acl_test.go
@@ -0,0 +1,196 @@
+package resources
+
+import (
+	"context"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/cloud-nuke/telemetry"
+	"github.com/gruntwork-io/cloud-nuke/util"
+	"github.com/stretchr/testify/require"
+)
+
+type mockedNetworkACL struct {
+	BaseAwsResource
+	ec2iface.EC2API
+
+	DescribeNetworkAclsOutput          ec2.DescribeNetworkAclsOutput
+	DeleteNetworkAclOutput             ec2.DeleteNetworkAclOutput
+	ReplaceNetworkAclAssociationOutput ec2.ReplaceNetworkAclAssociationOutput
+}
+
+func (m mockedNetworkACL) DescribeNetworkAcls(*ec2.DescribeNetworkAclsInput) (*ec2.DescribeNetworkAclsOutput, error) {
+	return &m.DescribeNetworkAclsOutput, nil
+}
+
+func (m mockedNetworkACL) DeleteNetworkAcl(*ec2.DeleteNetworkAclInput) (*ec2.DeleteNetworkAclOutput, error) {
+	return &m.DeleteNetworkAclOutput, nil
+}
+
+func (m mockedNetworkACL) ReplaceNetworkAclAssociation(*ec2.ReplaceNetworkAclAssociationInput) (*ec2.ReplaceNetworkAclAssociationOutput, error) {
+	return &m.ReplaceNetworkAclAssociationOutput, nil
+}
+
+func TestNetworkAcl_GetAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+
+	var (
+		now     = time.Now()
+		testId1 = "acl-09e36c45cbdbfb001"
+		testId2 = "acl-09e36c45cbdbfb002"
+
+		testName1 = "cloud-nuke-acl-001"
+		testName2 = "cloud-nuke-acl-002"
+	)
+
+	resourceObject := NetworkACL{
+		Client: mockedNetworkACL{
+			DescribeNetworkAclsOutput: ec2.DescribeNetworkAclsOutput{
+				NetworkAcls: []*ec2.NetworkAcl{
+					{
+						NetworkAclId: aws.String(testId1),
+						Tags: []*ec2.Tag{
+							{
+								Key:   aws.String("Name"),
+								Value: aws.String(testName1),
+							},
+							{
+								Key:   aws.String(util.FirstSeenTagKey),
+								Value: aws.String(util.FormatTimestamp(now)),
+							},
+						},
+					},
+					{
+						NetworkAclId: aws.String(testId2),
+						Tags: []*ec2.Tag{
+							{
+								Key:   aws.String("Name"),
+								Value: aws.String(testName2),
+							},
+							{
+								Key:   aws.String(util.FirstSeenTagKey),
+								Value: aws.String(util.FormatTimestamp(now.Add(1 * time.Hour))),
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	resourceObject.BaseAwsResource.Init(nil)
+
+	tests := map[string]struct {
+		configObj config.ResourceType
+		expected  []string
+	}{
+		"emptyFilter": {
+			configObj: config.ResourceType{},
+			expected:  []string{testId1, testId2},
+		},
+		"nameExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					NamesRegExp: []config.Expression{{
+						RE: *regexp.MustCompile(testName1),
+					}}},
+			},
+			expected: []string{testId2},
+		},
+		"nameInclusionFilter": {
+			configObj: config.ResourceType{
+				IncludeRule: config.FilterRule{
+					NamesRegExp: []config.Expression{{
+						RE: *regexp.MustCompile(testName1),
+					}}},
+			},
+			expected: []string{testId1},
+		},
+		"timeAfterExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					TimeAfter: aws.Time(now),
+				}},
+			expected: []string{
+				testId1,
+			},
+		},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			names, err := resourceObject.getAll(context.Background(), config.Config{
+				NetworkACL: tc.configObj,
+			})
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, aws.StringValueSlice(names))
+		})
+	}
+
+}
+
+func TestNetworkAcl_NukeAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+
+	var (
+		testId1 = "acl-09e36c45cbdbfb001"
+		testId2 = "acl-09e36c45cbdbfb002"
+
+		testName1 = "cloud-nuke-acl-001"
+		testName2 = "cloud-nuke-acl-002"
+	)
+
+	resourceObject := NetworkACL{
+		BaseAwsResource: BaseAwsResource{
+			Nukables: map[string]error{
+				testId1: nil,
+				testId2: nil,
+			},
+		},
+		Client: mockedNetworkACL{
+			DescribeNetworkAclsOutput: ec2.DescribeNetworkAclsOutput{
+				NetworkAcls: []*ec2.NetworkAcl{
+					{
+						NetworkAclId: aws.String(testId1),
+						Associations: []*ec2.NetworkAclAssociation{
+							{
+								NetworkAclAssociationId: aws.String("assoc-09e36c45cbdbfb001"),
+								NetworkAclId:            aws.String("acl-09e36c45cbdbfb001"),
+							},
+						},
+						Tags: []*ec2.Tag{
+							{
+								Key:   aws.String("Name"),
+								Value: aws.String(testName1),
+							},
+						},
+					},
+					{
+						NetworkAclId: aws.String(testId2),
+						Associations: []*ec2.NetworkAclAssociation{
+							{
+								NetworkAclAssociationId: aws.String("assoc-09e36c45cbdbfb002"),
+								NetworkAclId:            aws.String("acl-09e36c45cbdbfb002"),
+							},
+						},
+						Tags: []*ec2.Tag{
+							{
+								Key:   aws.String("Name"),
+								Value: aws.String(testName2),
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	err := resourceObject.nukeAll([]*string{
+		aws.String(testId1),
+		aws.String(testId2),
+	})
+	require.NoError(t, err)
+}
diff --git a/config/config_test.go b/config/config_test.go
index c49abb4..53e9ec9 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -102,6 +102,7 @@ func emptyConfig() *Config {
 		ResourceType{FilterRule{}, FilterRule{}, ""},
 		ResourceType{FilterRule{}, FilterRule{}, ""},
 		ResourceType{FilterRule{}, FilterRule{}, ""},
+		ResourceType{FilterRule{}, FilterRule{}, ""},
 	}
 }