Skip to content

sec(ci): deployment environments have no protection rules, so environment-bound credentialed jobs run unapproved #141

Description

@cristim

Found while fixing LeanerCloud/cloud-commitments-cli#1542 (PR LeanerCloud/cloud-commitments-cli#1641) and confirmed against the live API. This is the missing half of LeanerCloud/cloud-commitments-cli#1542's control LeanerCloud/cloud-commitments-cli#2. PR LeanerCloud/cloud-commitments-cli#1641 eliminated the injection and narrowed id-token: write onto four environment-bound jobs — but the environments those jobs bind to do not exist and have no protection rules, so the approval gate the narrowing was meant to enforce is not actually enforcing anything.

Evidence

$ gh api repos/LeanerCloud/CUDly/environments
total_count: 5
aws-fargate-dev:     protection_rules=[]  can_admins_bypass=true
aws-fargate-staging: protection_rules=[]  can_admins_bypass=true
azure-:              protection_rules=[]  can_admins_bypass=true
dev:                 protection_rules=[]  can_admins_bypass=true
gcp-:                protection_rules=[]  can_admins_bypass=true

Three separate problems:

  1. No environment in this repo has any protection rules. environment: currently only scopes secrets/vars and sets the OIDC sub claim. It is not a reviewer gate anywhere, in any workflow — not for rollback, not for database migrations, not for deploys.
  2. None of the 12 <cloud>-<env>-rollback environments exists, nor do staging / prod. GitHub auto-creates a referenced environment bare on first use, so the first rollback dispatch would silently create e.g. aws-lambda-prod-rollback with no reviewers and run straight through.
  3. azure- and gcp- are proof the auto-create path has already fired here. Those names are the residue of an environment: expression rendering empty — a workflow ran with an unset environment input, GitHub minted the junk environment, and nobody noticed.

Why it matters

The jobs binding to these environments hold id-token: write and run terraform apply -auto-approve against production. Auto-created-bare means a credentialed, production-mutating job with no approval step.

Aggravating factor (GCP): terraform/environments/gcp/ci-cd-permissions/github_oidc.tf:37 sets

attribute_condition = "assertion.repository == '${var.github_repo}' && assertion.ref == '${var.deploy_ref}'"

That constrains repository and ref but never sub. So unlike AWS and Azure — whose sub allowlists would reject an unrecognised environment:<name> (see LeanerCloud/cloud-commitments-cli#1648) — GCP would mint a token for a job bound to any environment name, including one auto-created bare. The environment name is itself built from a dispatch input (gcp-${{ inputs.environment }}-rollback), so if type: choice server-side enforcement were ever absent or bypassed, a dispatcher could name an arbitrary ruleless environment and still authenticate to GCP. Unverifiable without dispatching a real rollback, so recorded as a risk rather than a demonstrated exploit.

Fix direction

  1. Declare the environments in IaC, not by auto-creation — github_repository_environment plus github_repository_environment_deployment_policy, with reviewers set. Auto-creation is what produced both the missing rules and the junk azure- / gcp- names; declaring them is what stops it recurring.
  2. Set required reviewers on at least every prod and *-prod-rollback environment. Consider can_admins_bypass = false for prod.
  3. Delete the junk azure- and gcp- environments once the workflow that created them is identified and fixed (an environment: name must never be allowed to render empty — validate the input before it reaches the binding, as PR fix(ci): stop interpolating the release tag into a run block cloud-commitments-cli#1657 now does in deploy-aws-lambda.yml).
  4. Add sub to the GCP attribute_condition, or bind by attribute.environment, so GCP is no more permissive than AWS/Azure.
  5. Keep in lockstep with LeanerCloud/cloud-commitments-cli#1648: the environments must both exist with reviewers (this issue) and be listed in each cloud's trust policy (LeanerCloud/cloud-commitments-cli#1648). Either one alone leaves the rollback path broken or ungated.

Scope note

PR LeanerCloud/cloud-commitments-cli#1641 does not claim to fix this, and its workflow header comment says the binding is necessary but not sufficient. The p0 injection in LeanerCloud/cloud-commitments-cli#1542 is genuinely dead; this is the separate, previously-unnoticed fact that the control it was narrowed onto was never provisioned.

Related: LeanerCloud/cloud-commitments-cli#1542 / PR LeanerCloud/cloud-commitments-cli#1641, LeanerCloud/cloud-commitments-cli#1649 / PR LeanerCloud/cloud-commitments-cli#1657, LeanerCloud/cloud-commitments-cli#1648 (trust-policy sub allowlists), LeanerCloud/cloud-commitments-cli#1591, #90.


Addendum: the ref:refs/heads/main trust subject is not a restriction either

Added after the same finding surfaced under LeanerCloud/cloud-commitments-cli#1649 / PR LeanerCloud/cloud-commitments-cli#1657. Filing it here rather than as a fourth issue in this family, because it is the same defect species this issue is about: a control that reads as a restriction and restricts nothing.

Every cloud's trust policy accepts a main-branch subject:

The comment above the AWS list says the intent is to "restrict to protected branches and named deployment environments", and rules out an any-branch wildcard because it "would let a developer push to an unprotected feature branch and mint valid deploy credentials".

main is not protected:

$ gh api repos/LeanerCloud/CUDly/branches/main/protection
{"message":"Branch not protected","status":"404"}

So the stated premise does not hold. Anyone with repo write access can push directly to main and mint credentials for the full production deploy role — no review, no environment, no approval. The subject narrows which ref can assume the role, but since that ref is writable by every collaborator, it does not narrow who.

Consequence: repo write access is currently equivalent to production deploy credentials on all three clouds. That is the ceiling on every workflow-level hardening in this family — LeanerCloud/cloud-commitments-cli#1542/#1641, LeanerCloud/cloud-commitments-cli#1649/#1657, LeanerCloud/cloud-commitments-cli#1591, LeanerCloud/cloud-commitments-cli#1659 all reduce the number of paths to that capability, but none of them can reduce the capability itself while this holds.

It is also why the severity caveat on LeanerCloud/cloud-commitments-cli#1649 is honest rather than deflecting: an attacker who can cut a release can already push to main, so an injection bug that yields the same credentials adds little marginal capability.

Fix direction (either, ideally both):

  1. Protect main — require PR review, dismiss stale approvals, disallow force-push and deletion. This makes the existing trust-policy comment true rather than aspirational, and costs nothing in IaC.
  2. Drop the ref:refs/heads/main subject so only environment:* subjects can assume the role. This cannot be done until the ungated credentialed jobs in sec(ci): destroy and rollback workflows have no environment binding, so no reviewer gate applies cloud-commitments-cli#1591 and LeanerCloud/cloud-commitments-cli#1659 are given environment bindings — they depend on that subject today. Sequencing: sec(ci): destroy and rollback workflows have no environment binding, so no reviewer gate applies cloud-commitments-cli#1591 + LeanerCloud/cloud-commitments-cli#1659 first, then drop the subject, then this family is actually closed.

Item 1 is a settings/API change a privileged human makes; item 2 is IaC. Neither is code in a workflow file.

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.

A14-007 (medium)

There is a documentation half to this that will misdirect whoever implements fix direction 1. .github/workflows/README.md:521-524 instructs the operator to create aws-lambda-dev/staging/prod, aws-fargate-*, gcp-* and azure-*. Only the aws-fargate-* family matches reality. deploy-aws-lambda.yml:231,377 bind plain dev/staging/prod; rollback.yml:192,296,376,458 bind the <cloud>-<env>-rollback family this issue enumerates; database-migration.yml:265,376,470 bind aws-db-<env> / gcp-db-<env> / azure-db-<env>, which appear nowhere in the README; and deploy-gcp.yml and deploy-azure.yml carry no job-level environment: at all. An operator following the README configures required reviewers on environments nothing binds to while the credentialed jobs keep auto-creating theirs bare, which is precisely the state this issue describes. Regenerating the README list from the actual environment: expressions belongs in the same change that declares them in IaC. Audit finding A14-007.

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