Skip to content

sec(iac): onboarding paths drift - shell/CFN/ARM templates lack the guards their Terraform siblings enforce #91

Description

@cristim

Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code.

The structural finding

Every serious IaC security defect found in the 2026-07-28 review is the same shape: for a given onboarding job, the Terraform path enforces a guard and the shell / CloudFormation / ARM path for the identical job does not.

This class cannot be caught by reviewing one cloud's template in isolation, which is exactly why it survived. Each individual instance looks like an isolated oversight; together they are a missing invariant.

The instances (each filed separately, listed here for the pattern)

  1. AWS target, OIDC subject claim. iac/federation/aws-target/cloudformation/template.yaml:52-57,171-178 defaults OIDCSubjectClaim to "" and omits the :sub condition entirely. The Terraform sibling terraform/variables.tf:28-43 makes it mandatory with a validation reading "Leaving it empty would allow any principal in the OIDC provider tenant to assume this role."

  2. GCP WIF, attribute condition. arm/CUDly-CrossSubscription/setup-gcp-wif.sh:99-103,133-139 creates the AWS-mode provider with no --attribute-condition and binds impersonation pool-wide. The Terraform sibling iac/federation/gcp-target/terraform/main.tf:137-147 has a hard precondition forbidding exactly that state and quoting the same attack.

  3. Azure reservation role, assignment scope. arm/CUDly-CrossSubscription/template.json:86-100 assigns the purchase role at tenant /providers/Microsoft.Capacity scope as well as subscription scope. The Terraform sibling iac/federation/azure-target/terraform/main.tf:77-83 assigns subscription-only, and the shared module terraform/modules/iam/azure/cudly-reservation-role/main.tf:58-71 documents that the tenant scope was deliberately removed.

  4. AWS target, OIDC thumbprint. iac/federation/aws-target/cloudformation/template.yaml:42-50 accepts the all-zeros placeholder thumbprint for arbitrary issuers. The Terraform sibling terraform/variables.tf:79-87 rejects it unless the issuer is one AWS natively validates, reasoning that "the all-zeros value bypasses the CA-chain check entirely."

A fifth, adjacent instance in our own CI IAM: terraform/environments/azure/ci-cd-permissions/sp.tf:40-49 trusts the bare pull_request subject while the AWS and GCP siblings pin to main and named environments.

Failure scenario

A customer picks whichever onboarding path their tooling supports. Choosing CloudFormation or the ARM/shell script instead of Terraform silently opts them out of guards that the Terraform path treats as mandatory, with no warning anywhere in the docs that the paths are not equivalent. In every case above the resulting exposure is spend authority on the customer's own cloud account.

The defect is not any single template. It is that the paths are maintained independently with no mechanism keeping them in parity.

Fix direction

Beyond fixing the four instances individually:

  • Parity tests in CI that assert the guard set is equivalent across the paths for each onboarding job, so a future divergence fails CI rather than shipping. At minimum: every path that creates a trust relationship must require a subject/attribute restriction, and every role assignment must be scoped no wider than the Terraform sibling's.
  • Treat the Terraform module as the normative definition and generate or lint the other paths against it.
  • Document explicitly, in the onboarding docs, that the paths are intended to be equivalent, so a reviewer knows divergence is a bug.

Findings from the 2026-09-02 codebase audit

Added by an automated audit of 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd (tip of origin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report: docs/audits/codebase-audit-2026-09-02.md.

A06-028 (medium)

A fifth instance of the same shape, this time shell-versus-shell rather than shell-versus-Terraform. internal/iacfiles/templates/azure-wif-deploy.sh.tmpl:77-81 ends the federated-credential create with || echo "(federated credential may already exist - continuing)", which swallows every non-zero exit and defeats the script's own set -euo pipefail at :14. The script then assigns the Reservation Purchaser role at subscription scope and prints '=== Done ===' (:83-97), so a Graph permission denial or a throttle produces a confident success with no credential in place at all. The same swallow is at azure-wif-cli.sh.tmpl:58-61. The GCP script does the compare-and-abort this needs: gcp-wif-cli.sh.tmpl:177-233 re-reads an existing provider and aborts unless issuer, attribute condition, mapping, state and audience all match. Audit finding A06-028.

A13b-012 (medium)

A further instance of this pattern, from finding A13b-012, and one that runs Terraform-to-Terraform rather than across formats. iac/federation/azure-target/terraform/variables.tf:19-33 declares cudly_issuer_url, cudly_federated_subject and cudly_federated_audience with no validation block on any of the three, and all three feed the federated identity credential unchecked at main.tf:56-58. The AWS sibling guards the equivalent inputs hard: the issuer at aws-target/terraform/variables.tf:11-15, the audience at :36-39, and the subject at :59-62 including the $/* rejection. Azure AD fetches JWKS from whatever host the issuer names and mints tokens for CUDly's app registration for any JWT that host signs with the default subject cudly-controller, one constant shared across every customer tenant, so a wrong or attacker-controlled issuer URL yields reservation-purchase authority in the customer's subscription. Worth adding to the parity-test set this issue proposes, since a cross-format-only test would not catch it.

A06-029 (low)

Another instance of this pattern, from finding A06-029, between sibling shell templates rather than across formats. CUDLY_FEDERATED_SUBJECT is guarded on two of the three onboarding scripts: aws-wif-cli.sh.tmpl:31-41 rejects an empty value and any $, * or whitespace because IAM expands policy variables inside Condition values, and gcp-wif-cli.sh.tmpl:50-61 pins a charset plus a 127-character cap because the value lands in a CEL string literal. Both Azure templates interpolate the override straight into the federated-credential JSON heredoc with no check at all (azure-wif-cli.sh.tmpl:26,47-56 and azure-wif-deploy.sh.tmpl:61,66-75), where a " closes the subject string and lets sibling keys such as audiences be injected into the request body. Operator-supplied rather than attacker-supplied, so this is defense in depth, but it is the same missing-invariant shape and belongs in the parity check.

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