GITHUB_TOKEN permissions posture¶
Strict least-privilege rule for every workflow in .github/workflows/.
Invariants¶
- Top-level
permissions:is the empty map{}. No scopes are granted at workflow scope; every scope must be declared per-job. - Every job declares its own
permissions:block. Inheritance from the (empty) top is not allowed; an omitted block fails the lint. - No scalar or list top-level
permissions:(read-all/write-all, or a sequence). Subsumed by rule 1; a string is reported with a dedicated message, any other shape (a sequence included) by its YAML kind and tag.
Why¶
Rule 1 is what stops a job from inheriting the repo-wide default: in a
workflow with no top-level permissions: at all, every job that declares
none of its own takes that default instead. The repo-wide default is
set to read, but one UI flip to write would widen every such job at
once, and nothing in the workflow file would record it.
Rule 2 then forbids leaning on the empty top-level block. A job that declares no scopes states nothing about what it needs, so the posture rests entirely on rule 1 holding at the top of the file. Reviewer-only enforcement is fragile: the omission is invisible in review precisely because nothing is written down.
Together the two rules keep every job's token surface stated in the file it runs from. Narrowing that surface is the per-job allowlist's job, below.
Enforcement¶
scripts/check-min-permissions.sh parses every workflow with yq and rejects:
- top-level
permissions:missing, non-empty map, scalar (read-all/write-all), or any other shape such as a list (read by its YAML kind and through an alias, as is apermissions:root key written as an alias, so an empty map carrying a tag of its own passes and an empty scalar carrying the map tag does not) - a workflow file
yqreads as several YAML documents - any job whose
permissions:block is omitted or not a map (read by its YAML kind, so a map carrying a tag of its own passes) - a merge key directly under
jobs:, or a job key that is empty, is not a scalar, or holds a tab, a line break, a carriage return or a NUL, or the tag of a job id, a job or itspermissions:holding a tab or a line break; that workflow's jobs are not read
Wired as the lint-workflow-security CI job (member check
min-permissions) and as a pre-commit hook.
Per-job write-scope allowlist¶
min-permissions guarantees every job declares an explicit scope block,
but it does not constrain which write scopes a block may grant. A
hand-maintained allowlist closes that gap: .github/permission-scopes.yml
pins, per job, exactly the write-valued scopes that job is permitted to
hold. Widening a job's write surface means editing the allowlist, which
makes every such change a reviewable, security-relevant diff.
Format¶
The allowlist is a per-job YAML map: workflow filename → job id → a sorted list of permitted write-scope names (only the scope lists are order-checked):
Only scope names whose value is write are listed. Read scopes (and
none) are unconstrained — they carry no least-privilege risk, so they
need no entry, and a job with no write scopes is omitted entirely.
Enforcement (permission-scopes)¶
scripts/check-permission-scopes.sh cross-checks the live workflows
against the allowlist in both directions and fails on any of:
- Over-grant — a job grants a
writescope that is not listed for that job in the allowlist. The token a compromised step could wield exceeds what was reviewed. - Stale entry — the allowlist lists a write scope the job no longer grants, or lists a scope under a workflow or job that no longer exists. Stale entries rot the allowlist into a misleading record of the actual write surface.
- Unsorted scope list — a job's scope list departs from sorted order. Sorting keeps allowlist diffs minimal and blocks the duplicate-prone append-anywhere edit pattern.
- Scalar job permissions — a job whose
permissions:is neither a map nor the stringread-all; a scalar grant bypasses the per-scope allowlist entirely (read-allis tolerated by this lint, butmin-permissionsstill rejects any non-mappermissions:, so the combined posture forbids it). How the block is read (by kind, through aliases) is stated in the header ofscripts/check-permission-scopes.sh. - Unreadable job id or scope name — one that is not a scalar, is
empty, or holds a tab, a line break or a NUL, or a merge key directly
under
jobs:; that workflow's jobs are not read. - Unreadable allowlist name — an allowlist workflow, job or scope name holding a tab or a line break, refused before the allowlist's rows are split, since it could forge a row for another entry.
Wired into the lint-workflow-security CI group and as the
permission-scopes pre-commit hook.
Adding a workflow or job¶
- Set top-level
permissions: {}in the new workflow. - For each new job, add a
permissions:block listing only the scopes that job needs. If the job does nothing more than checkout + run scripts on a public repo,contents: readis sufficient; for ghcr push,packages: write; for attestations,id-token: write+attestations: write; etc. - If the job grants any
writescope, add it to.github/permission-scopes.ymlin the same change, as<workflow>.yml: <job>: [sorted, scope, names]. An unlisted write scope is reported as an over-grant, which is a different failure from a missing block and is not fixed by adding one.
The min-permissions and permission-scopes lints catch these at
pre-commit time; CI catches them on PR. Either way, the violation is
surfaced before merge.