diff --git a/lwgenerate/aws/aws.go b/lwgenerate/aws/aws.go index 704587f88..15983b0bc 100644 --- a/lwgenerate/aws/aws.go +++ b/lwgenerate/aws/aws.go @@ -142,6 +142,16 @@ type GenerateAwsTfConfigurationArgs struct { // Agentless monitored AWS accounts AgentlessMonitoredAccounts []AwsSubAccount + // Agentless AWS Organizations root ID (r-*). + // + // Individual account IDs in AgentlessMonitoredAccountIDs need a snapshot role created inside + // each account. That is done either with per-account provider aliases + // (AgentlessMonitoredAccounts, which requires credentials for every account) or, when this + // root is set, with a SERVICE_MANAGED StackSet instance scoped to the root and narrowed by an + // INTERSECTION account filter — which needs no per-account credentials. The root is used + // because it is an ancestor of every account in the organization. + AgentlessOrganizationRootID string + // Agentless scanning AWS accounts AgentlessScanningAccounts []AwsSubAccount @@ -340,7 +350,10 @@ func (args *GenerateAwsTfConfigurationArgs) Validate() error { if len(args.AgentlessMonitoredAccountIDs) == 0 { return errors.New("must specify monitored account ID list for Agentless organization integration") } - if len(args.AgentlessMonitoredAccounts) == 0 { + // A single monitored account needs its snapshot role created somehow: either with a + // per-account provider alias (profile/region), or by a StackSet anchored on the + // organization root. With neither, the account would be scanned but never given a role. + if len(args.AgentlessMonitoredAccounts) == 0 && args.AgentlessOrganizationRootID == "" { // profile/region is required for single accounts for _, accountID := range args.AgentlessMonitoredAccountIDs { regex, _ := regexp.Compile(`^\d{12}$`) @@ -350,6 +363,25 @@ func (args *GenerateAwsTfConfigurationArgs) Validate() error { } } } + + if args.AgentlessOrganizationRootID != "" { + if !regexp.MustCompile(`^r-[0-9a-z]{4,32}$`).MatchString(args.AgentlessOrganizationRootID) { + return errors.New("Agentless organization root ID must be an AWS Organizations root ID (r-*)") + } + // Anything routed to a CloudFormation DeploymentTargets.Accounts filter must be + // exactly 12 digits, which is stricter than the Terraform module's own regex. + if len(args.AgentlessMonitoredAccounts) == 0 { + for _, accountID := range args.AgentlessMonitoredAccountIDs { + if isAgentlessOrgUnitID(accountID) { + continue + } + if !regexp.MustCompile(`^\d{12}$`).MatchString(accountID) { + return errors.New("monitored accounts must be 12-digit AWS account IDs," + + " organizational unit IDs, or the organization root ID") + } + } + } + } if len(args.AgentlessScanningAccounts) == 0 { return errors.New("must specify scanning accounts for Agentless organization integration") } @@ -514,6 +546,14 @@ func WithAgentlessMonitoredAccounts(accounts ...AwsSubAccount) AwsTerraformModif } } +// WithAgentlessOrganizationRootID Set the Agentless AWS Organizations root ID, allowing individual +// monitored account IDs to be provisioned by StackSet instead of per-account provider aliases +func WithAgentlessOrganizationRootID(rootID string) AwsTerraformModifier { + return func(c *GenerateAwsTfConfigurationArgs) { + c.AgentlessOrganizationRootID = rootID + } +} + // WithAgentlessScanningAccounts Set Agentless scanning accounts func WithAgentlessScanningAccounts(accounts ...AwsSubAccount) AwsTerraformModifier { return func(c *GenerateAwsTfConfigurationArgs) { @@ -1198,6 +1238,13 @@ func createCloudtrail(args *GenerateAwsTfConfigurationArgs) (*hclwrite.Block, er ).ToBlock() } +// isAgentlessOrgUnitID reports whether a monitored-account entry is an organizational unit or the +// organization root, as opposed to an individual account ID. CloudFormation deployment targets +// reach the two through different attributes. +func isAgentlessOrgUnitID(id string) bool { + return strings.HasPrefix(id, "ou-") || strings.HasPrefix(id, "r-") +} + func createAgentless(args *GenerateAwsTfConfigurationArgs) ([]*hclwrite.Block, error) { if !args.Agentless { return nil, nil @@ -1302,89 +1349,175 @@ func createAgentless(args *GenerateAwsTfConfigurationArgs) ([]*hclwrite.Block, e blocks = append(blocks, monitoredModule) } - autoDeploymentBlock, err := lwgenerate.HclCreateGenericBlock( - "auto_deployment", - nil, - map[string]interface{}{"enabled": true, "retain_stacks_on_account_removal": false}, - ) - if err != nil { - return nil, err - } - lifecycleBlock, err := lwgenerate.HclCreateGenericBlock( - "lifecycle", - nil, - map[string]interface{}{ - "ignore_changes": lwgenerate.CreateSimpleTraversal([]string{"[administration_role_arn]"}), - }, - ) - if err != nil { - return nil, err + // Split the monitored list into the two things a StackSet can target. OUs and the root are + // targeted directly; individual accounts can only be reached by narrowing an ancestor OU + // with an account filter, which needs AgentlessOrganizationRootID. Without that root the + // account IDs are left to the per-account provider aliases in AgentlessMonitoredAccounts. + OUIDs := []string{} + accountIDs := []string{} + for _, accountID := range args.AgentlessMonitoredAccountIDs { + if isAgentlessOrgUnitID(accountID) { + OUIDs = append(OUIDs, fmt.Sprintf("\"%s\"", accountID)) + } else { + accountIDs = append(accountIDs, fmt.Sprintf("\"%s\"", accountID)) + } } - stacksetResource, err := lwgenerate.NewResource( - "aws_cloudformation_stack_set", - "snapshot_role", - lwgenerate.HclResourceWithAttributesAndProviderDetails( + // Each kind of target needs its own instance, because a single deployment_targets block + // cannot express "these OUs plus these accounts". Of the four account filters only UNION + // widens the OU selection that way -- INTERSECTION, DIFFERENCE and NONE all narrow it -- + // and AWS does not accept UNION on CreateStackInstances, which is the only call this + // resource ever makes: every deployment_targets field is ForceNew in the AWS provider, so + // changing a target replaces the instance rather than updating it. UNION is therefore out + // of reach here even on a later apply. + // When both instances exist they need to cooperate: disjoint targets, and serialized. + targetOUsByStackSet := len(OUIDs) > 0 + targetAccountsByStackSet := len(args.AgentlessMonitoredAccounts) == 0 && + args.AgentlessOrganizationRootID != "" && + len(accountIDs) > 0 + + // Creates StackSet template + if targetOUsByStackSet || targetAccountsByStackSet { + autoDeploymentBlock, err := lwgenerate.HclCreateGenericBlock( + "auto_deployment", + nil, + map[string]interface{}{"enabled": true, "retain_stacks_on_account_removal": false}, + ) + if err != nil { + return nil, err + } + lifecycleBlock, err := lwgenerate.HclCreateGenericBlock( + "lifecycle", + nil, map[string]interface{}{ - "capabilities": lwgenerate.CreateSimpleTraversal([]string{"[\"CAPABILITY_NAMED_IAM\"]"}), - "description": "Lacework AWS Agentless Workload Scanning Organization Roles", - "name": "lacework-agentless-scanning-stackset", - "permission_model": "SERVICE_MANAGED", - "template_url": "https://agentless-workload-scanner.s3.amazonaws.com" + - "/cloudformation-lacework/latest/snapshot-role.json", - "parameters": lwgenerate.CreateMapTraversalTokens(map[string]string{ - "ExternalId": "module.lacework_aws_agentless_scanning_global.external_id", - "ECSTaskRoleArn": "module.lacework_aws_agentless_scanning_global.agentless_scan_ecs_task_role_arn", - "ResourceNamePrefix": "module.lacework_aws_agentless_scanning_global.prefix", - "ResourceNameSuffix": "module.lacework_aws_agentless_scanning_global.suffix", - }), + "ignore_changes": lwgenerate.CreateSimpleTraversal([]string{"[administration_role_arn]"}), }, - []string{"aws.main"}, - ), - lwgenerate.HclResourceWithGenericBlocks(autoDeploymentBlock, lifecycleBlock), - ).ToResourceBlock() - if err != nil { - return nil, err - } - blocks = append(blocks, stacksetResource) + ) + if err != nil { + return nil, err + } - // Get OU IDs for the organizational_unit_ids attribute - OUIDs := []string{} - for _, accountID := range args.AgentlessMonitoredAccountIDs { - if strings.HasPrefix(accountID, "ou-") || strings.HasPrefix(accountID, "r-") { - OUIDs = append(OUIDs, fmt.Sprintf("\"%s\"", accountID)) + stacksetResource, err := lwgenerate.NewResource( + "aws_cloudformation_stack_set", + "snapshot_role", + lwgenerate.HclResourceWithAttributesAndProviderDetails( + map[string]interface{}{ + "capabilities": lwgenerate.CreateSimpleTraversal([]string{"[\"CAPABILITY_NAMED_IAM\"]"}), + "description": "Lacework AWS Agentless Workload Scanning Organization Roles", + "name": "lacework-agentless-scanning-stackset", + "permission_model": "SERVICE_MANAGED", + "template_url": "https://agentless-workload-scanner.s3.amazonaws.com" + + "/cloudformation-lacework/latest/snapshot-role.json", + "parameters": lwgenerate.CreateMapTraversalTokens(map[string]string{ + "ExternalId": "module.lacework_aws_agentless_scanning_global.external_id", + "ECSTaskRoleArn": "module.lacework_aws_agentless_scanning_global.agentless_scan_ecs_task_role_arn", + "ResourceNamePrefix": "module.lacework_aws_agentless_scanning_global.prefix", + "ResourceNameSuffix": "module.lacework_aws_agentless_scanning_global.suffix", + }), + }, + []string{"aws.main"}, + ), + lwgenerate.HclResourceWithGenericBlocks(autoDeploymentBlock, lifecycleBlock), + ).ToResourceBlock() + if err != nil { + return nil, err } + blocks = append(blocks, stacksetResource) } - deploymentTargetsBlock, err := lwgenerate.HclCreateGenericBlock( - "deployment_targets", - nil, - map[string]interface{}{"organizational_unit_ids": lwgenerate.CreateSimpleTraversal( - []string{fmt.Sprintf("[%s]", strings.Join(OUIDs, ","))}, - )}, - ) - if err != nil { - return nil, err + // Creates StackSet instance for OUs + if targetOUsByStackSet { + targetAttrs := map[string]interface{}{ + "organizational_unit_ids": lwgenerate.CreateSimpleTraversal( + []string{fmt.Sprintf("[%s]", strings.Join(OUIDs, ","))}, + ), + } + if targetAccountsByStackSet { + // A selected account may also live inside a selected OU. DIFFERENCE excludes the + // individually-targeted accounts from this instance so the two instances target + // provably disjoint sets — CloudFormation rejects a second instance covering an + // account/region pair the first already covers. + targetAttrs["accounts"] = lwgenerate.CreateSimpleTraversal( + []string{fmt.Sprintf("[%s]", strings.Join(accountIDs, ","))}, + ) + targetAttrs["account_filter_type"] = "DIFFERENCE" + } + deploymentTargetsBlock, err := lwgenerate.HclCreateGenericBlock("deployment_targets", nil, targetAttrs) + if err != nil { + return nil, err + } + stacksetInstanceResource, err := lwgenerate.NewResource( + "aws_cloudformation_stack_set_instance", + "snapshot_role_ous", + lwgenerate.HclResourceWithAttributesAndProviderDetails( + map[string]interface{}{ + "stack_set_name": lwgenerate.CreateSimpleTraversal( + []string{"aws_cloudformation_stack_set", "snapshot_role", "name"}, + ), + }, + []string{"aws.main"}, + ), + lwgenerate.HclResourceWithGenericBlocks(deploymentTargetsBlock), + ).ToResourceBlock() + + if err != nil { + return nil, err + } + + blocks = append(blocks, stacksetInstanceResource) } - stacksetInstanceResource, err := lwgenerate.NewResource( - "aws_cloudformation_stack_set_instance", - "snapshot_role", - lwgenerate.HclResourceWithAttributesAndProviderDetails( + + // Creates StackSet instance for accounts + if targetAccountsByStackSet { + // INTERSECTION against the organization root: deploy only to these accounts. The root + // is an ancestor of every account, so it is always a valid anchor. + // + // Note CloudFormation never deploys stack instances to the organization management + // account. That is fine — its snapshot role comes from the + // lacework_aws_agentless_management_scanning_role module above. + deploymentTargetsBlock, err := lwgenerate.HclCreateGenericBlock( + "deployment_targets", + nil, map[string]interface{}{ - "stack_set_name": lwgenerate.CreateSimpleTraversal( - []string{"aws_cloudformation_stack_set", "snapshot_role", "name"}, + "organizational_unit_ids": lwgenerate.CreateSimpleTraversal( + []string{fmt.Sprintf("[\"%s\"]", args.AgentlessOrganizationRootID)}, ), + "accounts": lwgenerate.CreateSimpleTraversal( + []string{fmt.Sprintf("[%s]", strings.Join(accountIDs, ","))}, + ), + "account_filter_type": "INTERSECTION", }, - []string{"aws.main"}, - ), - lwgenerate.HclResourceWithGenericBlocks(deploymentTargetsBlock), - ).ToResourceBlock() + ) + if err != nil { + return nil, err + } - if err != nil { - return nil, err - } + instanceAttrs := map[string]interface{}{ + "stack_set_name": lwgenerate.CreateSimpleTraversal( + []string{"aws_cloudformation_stack_set", "snapshot_role", "name"}, + ), + } + if targetOUsByStackSet { + // CloudFormation allows only one operation per stack set at a time and the AWS + // provider does not retry OperationInProgressException on create, so the two + // instances must be serialized explicitly. + instanceAttrs["depends_on"] = lwgenerate.CreateSimpleTraversal( + []string{"[aws_cloudformation_stack_set_instance.snapshot_role_ous]"}, + ) + } - blocks = append(blocks, stacksetInstanceResource) + stacksetInstanceAccountsResource, err := lwgenerate.NewResource( + "aws_cloudformation_stack_set_instance", + "snapshot_role_accounts", + lwgenerate.HclResourceWithAttributesAndProviderDetails(instanceAttrs, []string{"aws.main"}), + lwgenerate.HclResourceWithGenericBlocks(deploymentTargetsBlock), + ).ToResourceBlock() + if err != nil { + return nil, err + } + + blocks = append(blocks, stacksetInstanceAccountsResource) + } } else { // Create Agenetless integration for single account globalModule, err := lwgenerate.NewModule( diff --git a/lwgenerate/aws/aws_test.go b/lwgenerate/aws/aws_test.go index 46e01bb0c..5b42a21de 100644 --- a/lwgenerate/aws/aws_test.go +++ b/lwgenerate/aws/aws_test.go @@ -649,6 +649,153 @@ module "lacework_aws_agentless_scanning_region_scanning-1-us-east-1" { } ` +// Adding the organization root must not change a byte of the output when every monitored entry is +// already an OU or the root -- that is what keeps existing deployments from churning their +// CloudFormation stack set instance, whose deployment_targets are all ForceNew. +func TestGenerationAgentlessOrganizationRootNoChurnForOrgUnitTargets(t *testing.T) { + generate := func(mods ...AwsTerraformModifier) string { + base := []AwsTerraformModifier{ + WithAwsProfile("main"), + WithAwsRegion("us-east-2"), + WithAgentlessManagementAccountID("123456789000"), + WithAgentlessMonitoredAccountIDs([]string{"ou-abcd-12345678", "r-abcd"}), + WithAgentlessScanningAccounts( + NewAwsSubAccount("", "us-east-1", "us-east-1"), + ), + } + hcl, err := NewTerraform(true, true, false, false, append(base, mods...)...).Generate() + assert.Nil(t, err) + return hcl + } + + withoutRoot := generate() + withRoot := generate(WithAgentlessOrganizationRootID("r-abcd")) + + assert.Equal(t, withoutRoot, withRoot) + assert.NotContains(t, withRoot, "account_filter_type") + assert.NotContains(t, withRoot, "snapshot_role_accounts") +} + +func TestGenerationAgentlessOrganizationMixedTargets(t *testing.T) { + hcl, err := NewTerraform( + true, + true, + false, + false, + WithAwsProfile("main"), + WithAwsRegion("us-east-2"), + WithAgentlessManagementAccountID("123456789000"), + WithAgentlessMonitoredAccountIDs([]string{"ou-abcd-12345678", "123456789001"}), + WithAgentlessOrganizationRootID("r-abcd"), + WithAgentlessScanningAccounts( + NewAwsSubAccount("", "us-east-1", "us-east-1"), + NewAwsSubAccount("", "us-east-2", "us-east-2"), + ), + ).Generate() + assert.Nil(t, err) + assert.NotNil(t, hcl) + assert.Equal(t, moduleImportAgentlessOrganizationMixedTargets, hcl) +} + +func TestGenerationAgentlessOrganizationAccountTargetsOnly(t *testing.T) { + hcl, err := NewTerraform( + true, + true, + false, + false, + WithAwsProfile("main"), + WithAwsRegion("us-east-2"), + WithAgentlessManagementAccountID("123456789000"), + WithAgentlessMonitoredAccountIDs([]string{"123456789001", "123456789002"}), + WithAgentlessOrganizationRootID("r-abcd"), + WithAgentlessScanningAccounts( + NewAwsSubAccount("", "us-east-1", "us-east-1"), + ), + ).Generate() + assert.Nil(t, err) + assert.NotNil(t, hcl) + assert.Equal(t, moduleImportAgentlessOrganizationAccountTargetsOnly, hcl) +} + +func TestGenerationAgentlessOrganizationRootValidation(t *testing.T) { + generate := func(mods ...AwsTerraformModifier) error { + base := []AwsTerraformModifier{ + WithAwsProfile("main"), + WithAwsRegion("us-east-2"), + WithAgentlessManagementAccountID("123456789000"), + WithAgentlessScanningAccounts(NewAwsSubAccount("", "us-east-1", "us-east-1")), + } + _, err := NewTerraform(true, true, false, false, append(base, mods...)...).Generate() + return err + } + + t.Run("individual account without a root or per-account profile is still rejected", func(t *testing.T) { + err := generate(WithAgentlessMonitoredAccountIDs([]string{"123456789001"})) + assert.EqualError(t, err, "invalid inputs: must specify profile/region for single monitored"+ + " accounts for Agentless organization integration") + }) + + t.Run("individual account is accepted once a root anchors the stack set", func(t *testing.T) { + err := generate( + WithAgentlessMonitoredAccountIDs([]string{"123456789001"}), + WithAgentlessOrganizationRootID("r-abcd"), + ) + assert.Nil(t, err) + }) + + t.Run("root must be an organization root ID", func(t *testing.T) { + err := generate( + WithAgentlessMonitoredAccountIDs([]string{"123456789001"}), + WithAgentlessOrganizationRootID("ou-abcd-12345678"), + ) + assert.EqualError(t, err, "invalid inputs: Agentless organization root ID must be an AWS"+ + " Organizations root ID (r-*)") + }) + + t.Run("stack set targets must be 12-digit account IDs", func(t *testing.T) { + err := generate( + WithAgentlessMonitoredAccountIDs([]string{"1234"}), + WithAgentlessOrganizationRootID("r-abcd"), + ) + assert.EqualError(t, err, "invalid inputs: monitored accounts must be 12-digit AWS account"+ + " IDs, organizational unit IDs, or the organization root ID") + }) + + t.Run("an empty monitored list is still rejected", func(t *testing.T) { + err := generate(WithAgentlessOrganizationRootID("r-abcd")) + assert.EqualError(t, err, "invalid inputs: must specify monitored account ID list for"+ + " Agentless organization integration") + }) +} + +// A monitored list of only individual accounts, each already reachable through its own provider +// alias, leaves the stack set with nothing to deploy. It used to emit an instance targeting +// `organizational_unit_ids = []`, which CloudFormation rejects. +func TestGenerationAgentlessOrganizationNoStackSetWhenNothingToTarget(t *testing.T) { + hcl, err := NewTerraform( + true, + true, + false, + false, + WithAwsProfile("main"), + WithAwsRegion("us-east-2"), + WithAgentlessManagementAccountID("123456789000"), + WithAgentlessMonitoredAccountIDs([]string{"123456789001"}), + WithAgentlessMonitoredAccounts( + NewAwsSubAccount("monitored-account-1", "us-west-2", "monitored-account-1-us-west-2"), + ), + WithAgentlessScanningAccounts( + NewAwsSubAccount("scanning-1", "us-east-1", "scanning-1-us-east-1"), + ), + ).Generate() + assert.Nil(t, err) + + // The account still gets its snapshot role, just through its provider alias. + assert.Contains(t, hcl, "module \"lacework_aws_agentless_monitored_scanning_role_monitored-account-1-us-west-2\"") + assert.NotContains(t, hcl, "aws_cloudformation_stack_set") + assert.NotContains(t, hcl, "organizational_unit_ids") +} + var moduleImportAgentlessOrganization = `terraform { required_providers { aws = { @@ -759,7 +906,7 @@ resource "aws_cloudformation_stack_set" "snapshot_role" { } } -resource "aws_cloudformation_stack_set_instance" "snapshot_role" { +resource "aws_cloudformation_stack_set_instance" "snapshot_role_ous" { stack_set_name = aws_cloudformation_stack_set.snapshot_role.name provider = aws.main @@ -770,6 +917,214 @@ resource "aws_cloudformation_stack_set_instance" "snapshot_role" { } ` +var moduleImportAgentlessOrganizationMixedTargets = `terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + lacework = { + source = "lacework/lacework" + version = "~> 2.0" + } + } +} + +provider "aws" { + alias = "main" + profile = "main" + region = "us-east-2" +} + +provider "aws" { + alias = "us-east-1" + profile = "" + region = "us-east-1" +} + +provider "aws" { + alias = "us-east-2" + profile = "" + region = "us-east-2" +} + +module "lacework_aws_agentless_management_scanning_role" { + source = "lacework/agentless-scanning/aws" + version = "~> 0.6" + global_module_reference = module.lacework_aws_agentless_scanning_global + snapshot_role = true + + providers = { + aws = aws.main + } +} + +module "lacework_aws_agentless_scanning_global" { + source = "lacework/agentless-scanning/aws" + version = "~> 0.6" + global = true + organization = { + management_account = "123456789000" + monitored_accounts = ["ou-abcd-12345678", "123456789001"] + } + regional = true + + providers = { + aws = aws.us-east-1 + } +} + +module "lacework_aws_agentless_scanning_region_us-east-2" { + source = "lacework/agentless-scanning/aws" + version = "~> 0.6" + global_module_reference = module.lacework_aws_agentless_scanning_global + regional = true + + providers = { + aws = aws.us-east-2 + } +} + +resource "aws_cloudformation_stack_set" "snapshot_role" { + capabilities = ["CAPABILITY_NAMED_IAM"] + description = "Lacework AWS Agentless Workload Scanning Organization Roles" + name = "lacework-agentless-scanning-stackset" + parameters = { + ECSTaskRoleArn = module.lacework_aws_agentless_scanning_global.agentless_scan_ecs_task_role_arn + ExternalId = module.lacework_aws_agentless_scanning_global.external_id + ResourceNamePrefix = module.lacework_aws_agentless_scanning_global.prefix + ResourceNameSuffix = module.lacework_aws_agentless_scanning_global.suffix + } + permission_model = "SERVICE_MANAGED" + template_url = "https://agentless-workload-scanner.s3.amazonaws.com/cloudformation-lacework/latest/snapshot-role.json" + + provider = aws.main + + auto_deployment { + enabled = true + retain_stacks_on_account_removal = false + } + + lifecycle { + ignore_changes = [administration_role_arn] + } +} + +resource "aws_cloudformation_stack_set_instance" "snapshot_role_ous" { + stack_set_name = aws_cloudformation_stack_set.snapshot_role.name + + provider = aws.main + + deployment_targets { + account_filter_type = "DIFFERENCE" + accounts = ["123456789001"] + organizational_unit_ids = ["ou-abcd-12345678"] + } +} + +resource "aws_cloudformation_stack_set_instance" "snapshot_role_accounts" { + depends_on = [aws_cloudformation_stack_set_instance.snapshot_role_ous] + stack_set_name = aws_cloudformation_stack_set.snapshot_role.name + + provider = aws.main + + deployment_targets { + account_filter_type = "INTERSECTION" + accounts = ["123456789001"] + organizational_unit_ids = ["r-abcd"] + } +} +` + +var moduleImportAgentlessOrganizationAccountTargetsOnly = `terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + lacework = { + source = "lacework/lacework" + version = "~> 2.0" + } + } +} + +provider "aws" { + alias = "main" + profile = "main" + region = "us-east-2" +} + +provider "aws" { + alias = "us-east-1" + profile = "" + region = "us-east-1" +} + +module "lacework_aws_agentless_management_scanning_role" { + source = "lacework/agentless-scanning/aws" + version = "~> 0.6" + global_module_reference = module.lacework_aws_agentless_scanning_global + snapshot_role = true + + providers = { + aws = aws.main + } +} + +module "lacework_aws_agentless_scanning_global" { + source = "lacework/agentless-scanning/aws" + version = "~> 0.6" + global = true + organization = { + management_account = "123456789000" + monitored_accounts = ["123456789001", "123456789002"] + } + regional = true + + providers = { + aws = aws.us-east-1 + } +} + +resource "aws_cloudformation_stack_set" "snapshot_role" { + capabilities = ["CAPABILITY_NAMED_IAM"] + description = "Lacework AWS Agentless Workload Scanning Organization Roles" + name = "lacework-agentless-scanning-stackset" + parameters = { + ECSTaskRoleArn = module.lacework_aws_agentless_scanning_global.agentless_scan_ecs_task_role_arn + ExternalId = module.lacework_aws_agentless_scanning_global.external_id + ResourceNamePrefix = module.lacework_aws_agentless_scanning_global.prefix + ResourceNameSuffix = module.lacework_aws_agentless_scanning_global.suffix + } + permission_model = "SERVICE_MANAGED" + template_url = "https://agentless-workload-scanner.s3.amazonaws.com/cloudformation-lacework/latest/snapshot-role.json" + + provider = aws.main + + auto_deployment { + enabled = true + retain_stacks_on_account_removal = false + } + + lifecycle { + ignore_changes = [administration_role_arn] + } +} + +resource "aws_cloudformation_stack_set_instance" "snapshot_role_accounts" { + stack_set_name = aws_cloudformation_stack_set.snapshot_role.name + + provider = aws.main + + deployment_targets { + account_filter_type = "INTERSECTION" + accounts = ["123456789001","123456789002"] + organizational_unit_ids = ["r-abcd"] + } +} +` + var moduleImportCloudtrail = `module "main_cloudtrail" { source = "lacework/cloudtrail/aws" version = "~> 2.7" diff --git a/lwpreflight/aws/aws.go b/lwpreflight/aws/aws.go index d8db86130..62e3599e6 100644 --- a/lwpreflight/aws/aws.go +++ b/lwpreflight/aws/aws.go @@ -82,7 +82,10 @@ func New(params Params) (*Preflight, error) { if params.Agentless { tasks = append(tasks, FetchOrgAccounts) } - if params.Config { + // Agentless needs the root and OU IDs too: an org-level Agentless integration lets the + // user pick the org root, OUs, or individual accounts to monitor, and the root anchors + // the StackSet deployment target when individual accounts are chosen. + if params.Config || params.Agentless { tasks = append(tasks, FetchOrgUnits) } if params.CloudTrail { diff --git a/lwpreflight/aws/detail.go b/lwpreflight/aws/detail.go index 01e860756..f063ad7f0 100644 --- a/lwpreflight/aws/detail.go +++ b/lwpreflight/aws/detail.go @@ -83,14 +83,18 @@ func FetchOrgAccounts(p *Preflight) error { ctx := context.Background() orgSvc := organizations.NewFromConfig(p.awsConfig) - accountsOutput, err := orgSvc.ListAccounts(ctx, nil) - if err != nil { - return err - } - p.details.OrgAccountIDs = []string{} - for _, a := range accountsOutput.Accounts { - p.details.OrgAccountIDs = append(p.details.OrgAccountIDs, *a.Id) + // Paginated: ListAccounts returns at most 20 accounts per page, so an unpaginated call + // silently truncates every organization larger than that. + paginator := organizations.NewListAccountsPaginator(orgSvc, &organizations.ListAccountsInput{}) + for paginator.HasMorePages() { + accountsOutput, err := paginator.NextPage(ctx) + if err != nil { + return err + } + for _, a := range accountsOutput.Accounts { + p.details.OrgAccountIDs = append(p.details.OrgAccountIDs, *a.Id) + } } return nil @@ -113,19 +117,23 @@ func FetchOrgUnits(p *Preflight) error { p.verboseWriter.Write("Discovering all organization units") - orgUnitsOutput, err := orgSvc.ListOrganizationalUnitsForParent( - ctx, + p.details.OrgUnitIDs = []string{} + // Paginated for the same reason as ListAccounts above. Still only one level below the root: + // nested OUs are not enumerated, and callers let users type a nested OU ID by hand. + paginator := organizations.NewListOrganizationalUnitsForParentPaginator( + orgSvc, &organizations.ListOrganizationalUnitsForParentInput{ ParentId: &p.details.RootOrgUnitID, }, ) - if err != nil { - return err - } - - p.details.OrgUnitIDs = []string{} - for _, ou := range orgUnitsOutput.OrganizationalUnits { - p.details.OrgUnitIDs = append(p.details.OrgUnitIDs, *ou.Id) + for paginator.HasMorePages() { + orgUnitsOutput, err := paginator.NextPage(ctx) + if err != nil { + return err + } + for _, ou := range orgUnitsOutput.OrganizationalUnits { + p.details.OrgUnitIDs = append(p.details.OrgUnitIDs, *ou.Id) + } } return nil