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-level permissions: 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: read
only 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
Summary
id-token: writeis 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. Onmaintoday:id-token: writeenvironment:configure-aws-credentials,azure/login,google-github-actions/auth), and all 12 receive the grant from a top-levelpermissions:block rather than asking for itA job that can mint an OIDC token is a job where code execution becomes credential access. Granting it to
prepareandsummaryjobs that only compute a string or write a step summary is unnecessary blast radius.The 12 jobs holding an unused
id-token: writedatabase-migration.ymlvalidatedatabase-migration.ymlsummarydeploy-aws-fargate.ymlpreparedeploy-aws-fargate.ymltest-deploymentdeploy-aws-fargate.ymlsummarydeploy-aws-lambda.ymlpreparedeploy-aws-lambda.ymlsummarydeploy-azure.ymlpreparedeploy-azure.ymlsummarydeploy-gcp.ymlpreparedeploy-gcp.ymltest-deploymentdeploy-gcp.ymlsummaryrollback.yml,cleanup-staging.ymlanddestroy-fargate-dev.ymlalready declarepermissions: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 ungatedpreparejob both (a) interpolated the release tag directly into arun:block and (b) inheritedid-token: writefrom 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: writefrom the top-levelpermissions:block, leavingcontents: read, then add a job-levelonly to the jobs that actually call a cloud auth action. #1657 does exactly this for
deploy-aws-lambda.ymland 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: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 currentmainfirst.Related
deploy-aws-lambda.yml(2 of the 12) and is the templateprepareholding an inheritedid-token: write${{ }}-in-run:sweep; same workflows, different axismainunprotected whileref:refs/heads/mainis a trusted subject