Repository navigation
sec(ci): id-token: write granted top-level - 12 jobs hold it without ever authenticating #1694
Description
Summary
id-token: write is granted at the top level of most deployment workflows, so every job in those workflows inherits the ability to mint a GitHub OIDC token, including jobs that never authenticate to any cloud. On main today:
- 32 jobs hold
id-token: write - 17 of those are not bound to an
environment: - 12 of those never call any cloud auth action at all (
configure-aws-credentials,azure/login,google-github-actions/auth), and all 12 receive the grant from a top-levelpermissions:block rather than asking for it
A job that can mint an OIDC token is a job where code execution becomes credential access. Granting it to prepare and summary jobs that only compute a string or write a step summary is unnecessary blast radius.
The 12 jobs holding an unused id-token: write
| workflow | job | granted by |
|---|---|---|
database-migration.yml |
validate |
top-level |
database-migration.yml |
summary |
top-level |
deploy-aws-fargate.yml |
prepare |
top-level |
deploy-aws-fargate.yml |
test-deployment |
top-level |
deploy-aws-fargate.yml |
summary |
top-level |
deploy-aws-lambda.yml |
prepare |
top-level (fixed by #1657) |
deploy-aws-lambda.yml |
summary |
top-level (fixed by #1657) |
deploy-azure.yml |
prepare |
top-level |
deploy-azure.yml |
summary |
top-level |
deploy-gcp.yml |
prepare |
top-level |
deploy-gcp.yml |
test-deployment |
top-level |
deploy-gcp.yml |
summary |
top-level |
rollback.yml, cleanup-staging.yml and destroy-fargate-dev.yml already declare permissions: per job and are not part of this debt.
Why it matters
The concrete case is #1649. In the pre-#1657 deploy-aws-lambda.yml, the ungated prepare job both (a) interpolated the release tag directly into a run: block and (b) inherited id-token: write from the top level. Those two facts together are what turned a string-injection bug into a potential credential-minting one. #1657 fixes both, and the second fix is the one that would still have helped had the first been incomplete: defence in depth, from two independent directions.
The other seven workflows still have the (b) half.
Whether a minted token is accepted depends on the cloud-side trust policy, which is a separate control (see #1683 for the AWS subject allowlist). This issue is about not handing out the token in the first place, which is the half this repo controls unilaterally and can fix without any cloud-side change.
Suggested fix
Per workflow: delete id-token: write from the top-level permissions: block, leaving contents: read, then add a job-level
permissions:
id-token: write
contents: readonly to the jobs that actually call a cloud auth action. #1657 does exactly this for deploy-aws-lambda.yml and can serve as the template, including its comment explaining why the ungated jobs must not hold the grant.
Mechanical and independently reviewable per file, same as the #1641 sweep.
Reproducing the census
Parse rather than grep, since permissions: may be top-level or per-job and the two must be resolved together:
import yaml, glob
def has_idt(p):
if p is None: return False
if isinstance(p, str): return p == "write-all"
return p.get("id-token") == "write"
for f in glob.glob(".github/workflows/*.yml"):
d = yaml.safe_load(open(f)) or {}
top = d.get("permissions")
for name, job in (d.get("jobs") or {}).items():
if not has_idt(job.get("permissions", top)):
continue
authenticates = any(
any(k in str(s.get("uses", "")) for k in
("configure-aws-credentials", "azure/login", "google-github-actions/auth"))
for s in (job.get("steps") or []) if isinstance(s, dict))
if not authenticates:
print(f, name, "holds id-token:write but never authenticates")Sanity-check the result before trusting it: a run that reports zero because it failed to resolve the top-level permissions: block is indistinguishable from a clean repo. Confirm it reports the 12 rows above on current main first.
Related
- fix(ci): stop interpolating the release tag into a run block #1657 -- fixes this for
deploy-aws-lambda.yml(2 of the 12) and is the template - sec(ci): deploy-aws-lambda.yml interpolates a release tag name into a run block in an ungated job holding id-token write #1649 -- the bug whose severity depended on
prepareholding an inheritedid-token: write - chore(ci): finish the #1641 sweep - 159 ${{ }} interpolations remain in run: blocks across 7 workflows cloud-commitments-platform#155 -- the sibling
${{ }}-in-run:sweep; same workflows, different axis - sec(ci): main is unprotected while ref:refs/heads/main is a trusted OIDC subject for the AWS deploy role cloud-commitments-platform#154 --
mainunprotected whileref:refs/heads/mainis a trusted subject - fix(iac/aws): extend OIDC trust allowlist to cover every job assuming cudly_deploy #1683 -- the cloud-side AWS subject allowlist (the other half of the control)
Activity
cristim commentedon Sep 2, 2026
Verified resolved at 3c0f8ac: every deploy and migration workflow now declares only
contents: readat the top level and grantsid-token: writeper job, solely on jobs that call configure-aws-credentials / azure/login / google-github-actions/auth. The census script from this issue reports zero of the 12 listed jobs; the four rows it still prints aredeploy-all.ymlcaller jobs, which must holdid-token: writeto pass the grant into the reusable deploy workflows they invoke. Evidence: .github/workflows/deploy-azure.yml:25, deploy-gcp.yml:23, deploy-aws-fargate.yml:24, database-migration.yml:31 (#1870 for the three deploy workflows, #1726 for database-migration, #1657 for deploy-aws-lambda). Residual axes checked: deploy-all.yml top level iscontents: readonly; rollback.yml, cleanup-staging.yml and destroy-fargate-dev.yml were already per-job; no workflow usespermissions: write-all. Closing as completed; reopen if the behaviour recurs.