You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
sec(ci): deployment environments have no protection rules, so environment-bound credentialed jobs run unapproved #141
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.
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.
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.
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
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.
Set required reviewers on at least every prod and *-prod-rollback environment. Consider can_admins_bypass = false for prod.
Add sub to the GCP attribute_condition, or bind by attribute.environment, so GCP is no more permissive than AWS/Azure.
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.
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):
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.
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.
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: writeonto 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
Three separate problems:
environment:currently only scopes secrets/vars and sets the OIDCsubclaim. It is not a reviewer gate anywhere, in any workflow — not for rollback, not for database migrations, not for deploys.<cloud>-<env>-rollbackenvironments exists, nor dostaging/prod. GitHub auto-creates a referenced environment bare on first use, so the first rollback dispatch would silently create e.g.aws-lambda-prod-rollbackwith no reviewers and run straight through.azure-andgcp-are proof the auto-create path has already fired here. Those names are the residue of anenvironment: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: writeand runterraform apply -auto-approveagainst 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:37setsThat constrains repository and ref but never
sub. So unlike AWS and Azure — whosesuballowlists would reject an unrecognisedenvironment:<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 iftype: choiceserver-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
github_repository_environmentplusgithub_repository_environment_deployment_policy, withreviewersset. Auto-creation is what produced both the missing rules and the junkazure-/gcp-names; declaring them is what stops it recurring.prodand*-prod-rollbackenvironment. Considercan_admins_bypass = falsefor prod.azure-andgcp-environments once the workflow that created them is identified and fixed (anenvironment: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 indeploy-aws-lambda.yml).subto the GCPattribute_condition, or bind byattribute.environment, so GCP is no more permissive than AWS/Azure.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
suballowlists), LeanerCloud/cloud-commitments-cli#1591, #90.Addendum: the
ref:refs/heads/maintrust subject is not a restriction eitherAdded 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:
terraform/environments/aws/ci-cd-permissions/role.tf:29—repo:<org/repo>:ref:refs/heads/mainterraform/environments/azure/ci-cd-permissions/sp.tf:37— same subject (and:48addspull_request, which is sec(iac): Azure CI/CD federated credential trusts pull_request, handing the deploy SP to any PR workflow #90)terraform/environments/gcp/ci-cd-permissions/github_oidc.tf:37—assertion.ref == '${var.deploy_ref}'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".
mainis not protected:So the stated premise does not hold. Anyone with repo write access can push directly to
mainand 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):
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.ref:refs/heads/mainsubject so onlyenvironment:*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 oforigin/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-524instructs the operator to createaws-lambda-dev/staging/prod,aws-fargate-*,gcp-*andazure-*. Only theaws-fargate-*family matches reality. deploy-aws-lambda.yml:231,377 bind plaindev/staging/prod; rollback.yml:192,296,376,458 bind the<cloud>-<env>-rollbackfamily this issue enumerates; database-migration.yml:265,376,470 bindaws-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-levelenvironment: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 actualenvironment:expressions belongs in the same change that declares them in IaC. Audit finding A14-007.