Skip to content

sec(ci): id-token: write granted top-level - 12 jobs hold it without ever authenticating #1694

Description

@cristim

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

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