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.
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-246findAwsModuleFileskeeps only paths containing anawspath segment. Butterraform/environments/awsconsumes two modules that have no such segment:terraform/environments/aws/build.tf:19sources../../modules/buildterraform/environments/aws/checks.tf:11sources../../modules/deployment-checksThat is 6
.tffiles on the AWS deploy path that no guard in the file walks.Confirmed by mutation: adding an unboundaried
aws_iam_roleplus anaws_iam_role_policygrantingstates:StartExecutiontoterraform/modules/build/main.tfleavesTestEveryModuleRoleHasPermissionsBoundary,TestNoIAMRolesOutsideGuardedModulesandTestBoundaryCoversWorkloadServicesall green.TestNoIAMRolesOutsideGuardedModulesexists 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 undermodulesDir" (policy_guard_test.go:1145-1155). The walk is narrower thanmodulesDir, 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, orActionlist (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
modulesDirand filter on provider usage rather than on a path segment, or extendTestNoIAMRolesOutsideGuardedModulesto assert that every modulesourcereferenced fromterraform/environments/aws/*.tfresolves 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:InvokeFunctionsits atResource = "*"while the narrower ECS residual gets 20 lines of analysisterraform/environments/aws/ci-cd-permissions/policy_boundary.tf:138Invoking 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:RunTaskresidual gets 20 lines of analysis at:86-102while 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_nameis${var.project_name}-${var.environment}-${hex}withproject_namedefaulting tocudly(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
Resourcethat 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.