From 127dad6ad4a5b37b64b80cf788d9b20aa5e4fc1f Mon Sep 17 00:00:00 2001 From: Zack Proser Date: Mon, 18 Jul 2022 14:41:36 -0400 Subject: [PATCH] Implement support for Macie member accounts (#323) --- README.md | 1 + aws/aws.go | 15 ++++++ aws/macie.go | 86 ++++++++++++++++++++++++++++++++++ aws/macie_test.go | 59 +++++++++++++++++++++++ aws/macie_types.go | 29 ++++++++++++ util/get_current_account_id.go | 21 +++++++++ 6 files changed, 211 insertions(+) create mode 100644 aws/macie.go create mode 100644 aws/macie_test.go create mode 100644 aws/macie_types.go create mode 100644 util/get_current_account_id.go diff --git a/README.md b/README.md index 569f7202..bd8ccec4 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The currently supported functionality includes: - Inspecting and deleting all Customer managed keys from Key Management Service in an AWS account - Inspecting and deleting all CloudWatch Log Groups in an AWS Account - Inspecting and deleting all GuardDuty Detectors in an AWS Account +- Inspecting and deleting all Macie member accounts in an AWS account - as long as those accounts were created by Invitation - and not via AWS Organizations ### BEWARE! diff --git a/aws/aws.go b/aws/aws.go index fe4aee18..4193ae2d 100644 --- a/aws/aws.go +++ b/aws/aws.go @@ -717,6 +717,20 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp } // End GuardDuty detectors + // Macie member accounts + macieAccounts := MacieMember{} + if IsNukeable(macieAccounts.ResourceName(), resourceTypes) { + accountIds, err := getAllMacieMemberAccounts(session, excludeAfter, configObj) + if err != nil { + return nil, errors.WithStackTrace(err) + } + if len(accountIds) > 0 { + macieAccounts.AccountIds = accountIds + resourcesInRegion.Resources = append(resourcesInRegion.Resources, macieAccounts) + } + + } + // End Macie member accounts if len(resourcesInRegion.Resources) > 0 { account.Resources[region] = resourcesInRegion @@ -815,6 +829,7 @@ func ListResourceTypes() []string { KmsCustomerKeys{}.ResourceName(), CloudWatchLogGroups{}.ResourceName(), GuardDuty{}.ResourceName(), + MacieMember{}.ResourceName(), } sort.Strings(resourceTypes) return resourceTypes diff --git a/aws/macie.go b/aws/macie.go new file mode 100644 index 00000000..c99f9a6d --- /dev/null +++ b/aws/macie.go @@ -0,0 +1,86 @@ +package aws + +import ( + goerror "errors" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/macie2" + "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/go-commons/errors" +) + +func getAllMacieMemberAccounts(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]string, error) { + svc := macie2.New(session) + stssvc := sts.New(session) + + allMacieAccounts := []string{} + output, err := svc.GetAdministratorAccount(&macie2.GetAdministratorAccountInput{}) + if err != nil { + // There are several different errors that AWS may return when you attempt to call Macie operations on an account + // that doesn't yet have Macie enabled. For our purposes, this is fine, as we're only looking for those accounts and + // regions where Macie is enabled. Therefore, we ignore only these expected errors, and return any other error that might occur + var ade *macie2.AccessDeniedException + var rnfe *macie2.ResourceNotFoundException + + switch { + case goerror.As(err, &ade): + logging.Logger.Debugf("Macie AccessDeniedException means macie is not enabled in account, so skipping") + return allMacieAccounts, nil + case goerror.As(err, &rnfe): + logging.Logger.Debugf("Macie ResourceNotFoundException means macie is not enabled in account, so skipping") + return allMacieAccounts, nil + default: + return allMacieAccounts, errors.WithStackTrace(err) + } + } + // If the current account does have an Administrator account relationship, and it is enabled, then we consider this a macie member account + if output.Administrator != nil && output.Administrator.RelationshipStatus != nil { + if aws.StringValue(output.Administrator.RelationshipStatus) == macie2.RelationshipStatusEnabled { + + input := &sts.GetCallerIdentityInput{} + output, err := stssvc.GetCallerIdentity(input) + if err != nil { + return allMacieAccounts, errors.WithStackTrace(err) + } + + currentAccountId := aws.StringValue(output.Account) + + allMacieAccounts = append(allMacieAccounts, currentAccountId) + } + } + + return allMacieAccounts, nil +} + +func nukeAllMacieMemberAccounts(session *session.Session, identifiers []string) error { + svc := macie2.New(session) + region := aws.StringValue(session.Config.Region) + + if len(identifiers) == 0 { + logging.Logger.Infof("No Macie member accounts to nuke in region %s", *session.Config.Region) + return nil + } + + logging.Logger.Infof("Deleting Macie account membership and disabling Macie in %s", region) + + for _, accountId := range identifiers { + _, disassociateErr := svc.DisassociateFromAdministratorAccount(&macie2.DisassociateFromAdministratorAccountInput{}) + + if disassociateErr != nil { + return errors.WithStackTrace(disassociateErr) + } + + _, err := svc.DisableMacie(&macie2.DisableMacieInput{}) + if err != nil { + return errors.WithStackTrace(err) + } + + logging.Logger.Infof("[OK] Macie account association for accountId %s deleted in %s", accountId, region) + } + + return nil +} diff --git a/aws/macie_test.go b/aws/macie_test.go new file mode 100644 index 00000000..53a06527 --- /dev/null +++ b/aws/macie_test.go @@ -0,0 +1,59 @@ +package aws + +import ( + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/macie2" + "github.com/gruntwork-io/cloud-nuke/config" + "github.com/gruntwork-io/cloud-nuke/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListMacieAccounts(t *testing.T) { + // Currently we hardcode to region us-east-1, because this is where our "standing" test invite exists + region := "us-east-1" + session, err := session.NewSession(&aws.Config{Region: aws.String(region)}) + require.NoError(t, err) + + accountId, err := util.GetCurrentAccountId(session) + require.NoError(t, err) + + acceptTestInvite(t, session) + // Clean up after test by deleting the macie account association + defer nukeAllMacieMemberAccounts(session, []string{accountId}) + + retrievedAccountIds, lookupErr := getAllMacieMemberAccounts(session, time.Now(), config.Config{}) + require.NoError(t, lookupErr) + + assert.Contains(t, retrievedAccountIds, accountId) +} + +// Macie is not very conducive to programmatic testing. In order to make this test work, we maintain a standing invite +// from our phxdevops test account to our nuclear-wasteland account. We can continuously "nuke" our membership because +// Macie supports a member account *that was invited* to remove its own association at any time. Meanwhile, disassociating +// in this manner does not destroy or invalidate the original invitation, which allows us to to continually re-accept it +// from our nuclear-wasteland account (where cloud-nuke tests are run), just so that we can nuke it again +// +// Macie is also regional, so for the purposes of cost-savings and lower admin overhead, we're initially only testing this +// in the one hardcoded region - us-east-1 +// +// The other reason we only test in us-east-1 is to avoid conflict with our Macie test in the CIS service catalog, which uses +// these same two accounts for similar purposes, but in EU regions. +// See: https://github.com/gruntwork-io/terraform-aws-cis-service-catalog/blob/master/test/security/macie_test.go +func acceptTestInvite(t *testing.T, session *session.Session) { + svc := macie2.New(session) + + // Accept the "standing" invite from our other test account to become a Macie member account + // This works because Macie invites don't expire or get deleted when you disassociate your member account following an invitation + acceptInviteInput := &macie2.AcceptInvitationInput{ + AdministratorAccountId: aws.String("353720269506"), // sandbox + InvitationId: aws.String("18c0febb89142640f07ba497b19bac8e"), // "standing" test invite ID + } + + _, acceptInviteErr := svc.AcceptInvitation(acceptInviteInput) + require.NoError(t, acceptInviteErr) +} diff --git a/aws/macie_types.go b/aws/macie_types.go new file mode 100644 index 00000000..94decdc9 --- /dev/null +++ b/aws/macie_types.go @@ -0,0 +1,29 @@ +package aws + +import ( + "github.com/aws/aws-sdk-go/aws/session" + "github.com/gruntwork-io/go-commons/errors" +) + +type MacieMember struct { + AccountIds []string +} + +func (r MacieMember) ResourceName() string { + return "macie-member" +} + +func (r MacieMember) ResourceIdentifiers() []string { + return r.AccountIds +} + +func (r MacieMember) MaxBatchSize() int { + return 10 +} + +func (r MacieMember) Nuke(session *session.Session, identifiers []string) error { + if err := nukeAllMacieMemberAccounts(session, identifiers); err != nil { + return errors.WithStackTrace(err) + } + return nil +} diff --git a/util/get_current_account_id.go b/util/get_current_account_id.go new file mode 100644 index 00000000..840a7f39 --- /dev/null +++ b/util/get_current_account_id.go @@ -0,0 +1,21 @@ +package util + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/gruntwork-io/go-commons/errors" +) + +func GetCurrentAccountId(session *session.Session) (string, error) { + stssvc := sts.New(session) + + input := &sts.GetCallerIdentityInput{} + + output, err := stssvc.GetCallerIdentity(input) + if err != nil { + return "", errors.WithStackTrace(err) + } + + return aws.StringValue(output.Account), nil +}