Repository navigation
sec(iac/aws): boundary guard walk misses two deploy-path modules, and lambda:InvokeFunction stays at Resource = "*" #200
Description
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:19sources../../modules/buildterraform/environments/aws/checks.tf:11sources../../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:452usesResource = aws_lambda_function.main.arnmodules/compute/aws/lambda/signing-key.tf:60usesarn: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.
Activity
sec(iac/aws): pin ecs, lambda and ssm per action in the deploy bounda…
IAmJSD commentedon Oct 16, 2026
Released: already fixed by #512 (closing, details below)
IAmJSD commentedon Oct 16, 2026
Closing: both parts were fixed by #512 (15a5eea, "derive boundary guard walk from the module tree; scope lambda:InvokeFunction", merged 2026-10-08). That PR said
Closes #511, a re-filing of this same finding, so this issue stayed open.Checked on current main:
findAwsModuleFilesnow goes throughdeployPathFiles/walkDeployPath, which follows every localmodulesource from the environment root. That brings inmodules/buildandmodules/deployment-checks. It fails closed on non-literal, remote and missing sources (TestDeployPathWalkReachesModulesWithoutAwsSegment,TestDeployPathWalkFailsClosed). The issue's own probe, an unboundaried role inmodules/build, now failsTestEveryModuleRoleHasPermissionsBoundary.lambda:InvokeFunction. This is now its ownLambdaInvokeCeilingstatement onarn:aws:lambda:*:*:function:cudly-*, pinned byTestBoundaryInvokeFunctionIsScopedToCudlyFunctions. Setting it back to"*"fails that test.go test ./terraform/environments/aws/ci-cd-permissions/andscripts/check-aws-iam-parity.shpass.Two small leftovers that aren't part of this issue:
Statement = [{ ... }]. Nothing uses that form today.TestNoIAMRolesOutsideGuardedModules(~line 1439) still says the guard "only walks terraform/modules/**/aws".