Skip to content

sec(iac/aws): boundary guard walk misses two deploy-path modules, and lambda:InvokeFunction stays at Resource = "*" #200

Description

@cristim

Deferred from LeanerCloud/cloud-commitments-cli#1818. Two findings from that PR's adversarial review, both CONFIRMED by mutation, neither fixed there to keep the PR to one concern.

1. The guard walk is narrower than the invariant it claims to enforce

terraform/environments/aws/ci-cd-permissions/policy_guard_test.go:223-246

findAwsModuleFiles keeps only paths containing an aws path segment. But terraform/environments/aws consumes two modules that have no such segment:

  • terraform/environments/aws/build.tf:19 sources ../../modules/build
  • terraform/environments/aws/checks.tf:11 sources ../../modules/deployment-checks

That is 6 .tf files on the AWS deploy path that no guard in the file walks.

Confirmed by mutation: adding an unboundaried aws_iam_role plus an aws_iam_role_policy granting states:StartExecution to terraform/modules/build/main.tf leaves TestEveryModuleRoleHasPermissionsBoundary, TestNoIAMRolesOutsideGuardedModules and TestBoundaryCoversWorkloadServices all green.

TestNoIAMRolesOutsideGuardedModules exists specifically as a guard on the guard's reach, and its doc comment states the invariant as "every role on the deploy path must live in a module under modulesDir" (policy_guard_test.go:1145-1155). The walk is narrower than modulesDir, so the stated invariant and the enforced one differ. This is the mismatched-reach shape: two axes of one guard seeing different sets.

Latent today, not live: neither module declares a single aws_ resource, IAM role, or Action list (rg 'resource "aws_' over both returns nothing). That is why this was not a blocker on LeanerCloud/cloud-commitments-cli#1818.

Fix direction: either walk all of modulesDir and filter on provider usage rather than on a path segment, or extend TestNoIAMRolesOutsideGuardedModules to assert that every module source referenced from terraform/environments/aws/*.tf resolves into the walked set. The second is more directly the invariant that matters, and it fails loudly when someone adds a module rather than silently not covering it.

2. lambda:InvokeFunction sits at Resource = "*" while the narrower ECS residual gets 20 lines of analysis

terraform/environments/aws/ci-cd-permissions/policy_boundary.tf:138

Invoking any function in the account executes that function's code as its execution role with an attacker-chosen event payload, and needs no iam:PassRole. That is the file's own cross-principal escape criterion verbatim. Functions not created by this deploy role are not boundaried.

LeanerCloud/cloud-commitments-cli#1818 documents this residual (that was the minimum acceptable outcome there), but leaves the scope open. The asymmetry is worth closing: the strictly-more-constrained ecs:RunTask residual gets 20 lines of analysis at :86-102 while this one was undocumented until now.

Fix direction: scope the ceiling statement to arn:aws:lambda:*:*:function:cudly-*. This would cost nothing operationally, because the module identity grants are already narrower:

  • modules/compute/aws/lambda/main.tf:452 uses Resource = aws_lambda_function.main.arn
  • modules/compute/aws/lambda/signing-key.tf:60 uses arn:aws:lambda:*:*:function:${var.stack_name}-api*

and it would exactly mirror CrossAccountAssumeRoleCeiling (same module-default coupling, same guard-test pattern). stack_name is ${var.project_name}-${var.environment}-${hex} with project_name defaulting to cudly (terraform/environments/aws/main.tf:55, variables.tf:5-9).

Note the trap that applies to any such prefix scope, tracked separately in LeanerCloud/cloud-commitments-cli#1822: a trailing wildcard on a role ARN is satisfiable via the IAM path. Lambda function ARNs have no path component, so that specific bypass does not apply here, but pin the pattern with a guard test either way rather than relying on the prefix alone.

Verification bar for both

Both directions. A guard that refuses everything passes a refusal-only test, and a narrowed Resource that matches nothing is an unmatchable ARN, which looks correct on the page and 403s at runtime with the apply green. Assert that the legitimate deploy path still passes, and mutate to confirm each new assertion fails by assertion, not by panic.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions