Say which scopes a workflow's permissions: block never granted for the API
calls its steps actually make, and which it grants for nothing.
permissions: replaces the default token grant rather than extending it, so
every scope you do not name is none. Nothing checks that the list you did
name covers the APIs your steps call. The failure is a 403 Resource not accessible by integration at run time, in whichever job is least watched, and
it does not always look like a permissions problem — when actions/checkout is
the step that hits it, git reports the repository as not found.
actionlint will not tell you. It validates the keys and the values, which is a different question. Both of these are measured, with actionlint 1.7.12:
$ actionlint examples/under.yaml; echo "exit $?"
exit 0
$ ungranted examples/under.yaml
examples/under.yaml
summarise
grant contents: read (workflow)
ungranted gh api repos/o/r/actions/workflows needs actions: read; this job has actions: none
in step: count the workflows
unused contents: read is granted and no step in this job uses it
2 findings in 1 of 1 job.
pip install git+https://github.com/committed-nightly/ungranted
Python 3.10 or newer. One dependency, PyYAML.
ungranted with no arguments checks the repository you are standing in. Point
it at a repository, a .github/workflows directory, or one file.
There is a worked example in examples/, so this runs from a fresh clone:
$ ungranted examples
.github/workflows/release.yml
summarise
grant contents: read (workflow)
ungranted gh api repos/$GITHUB_REPOSITORY/actions/workflows needs actions: read; this job has actions: none
in step: count the workflows
announce
grant issues: write (job)
ungranted actions/checkout needs contents: read; this job has contents: none
in step: uses: actions/checkout@v4
mark
grant contents: read (job)
ungranted gh api repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA needs statuses: write; this job has statuses: none
in step: flag the commit
unused contents: read is granted and no step in this job uses it
verify
grant contents: write, pull-requests: write (job)
over-level contents: write is granted and every call here only reads
unused pull-requests: write is granted and no step in this job uses it
comment
grant issues: write (job)
covered
mirror
grant contents: read (job)
unused contents: read is granted and no step in this job uses it
Not governed by a permissions: block, or not read:
.github/workflows/release.yml mirror gh api repos/other/mirror/merge-upstream
authenticated with a different secret, so this block does not govern it
.github/workflows/release.yml mirror gh api repos/$GITHUB_REPOSITORY/pulls
nothing in scope sets GH_TOKEN
7 findings in 5 of 6 jobs.
Every job in that file is a shape people write. Three worth pulling out:
announcehas a job-level block that looks like it addsissues: writeto the workflow'scontents: read. It replaces it, so the checkout on the line above fails. This is the finding a grep cannot produce: the answer depends on the job structure, not on any one line of the file.markhas no-Xanywhere and is aPOST.ghswitches method as soon as a field is given, so-f state=successturns a line that reads like a read into a write.mirroris deliberately not reported, twice. One step authenticates with a different secret, so this block does not govern it; the other sets no token at all, so it fails before permissions come into it. Both would be false positives, and both are in the "not governed" section instead.
-q gives one line per finding for grepping. --json gives the whole report,
including the needs it worked out and whether it managed to read each job
completely. --no-unused reports only what is missing.
0 every job's block covers the calls its steps make
1 at least one does not, or grants a scope for nothing
2 the check could not run, or could not finish
2 covers an unreadable file, a path with no workflows in it, and an API path
the scope table does not recognise. An unrecognised path is not "needs
nothing": that answer is wrong in the direction that makes a workflow look
covered until the morning the scheduled job stops. The findings alongside it
are still printed.
--strict also fails when a job holds the workflow's own token and runs
something this cannot read, so that covered always means checked.
A job with no permissions: block is never a finding. That default is set
on the repository or the organisation, not in the file, and guessing at it
would make the common safe case the noisiest output in the tool. An empty block
is a different thing entirely and is checked like any other: permissions: {}
resolves to exactly Metadata: read, measured.
Over-granting is only reported when the whole job was readable. This is the
asymmetry the tool is built around. A missing scope comes from a call we read,
so it is sound even though the reader is incomplete. An unused scope is a claim
about calls that are not there, so anything unreadable in the job switches it
off: an action not in the table, a gh subcommand not in the table, a reusable
workflow, and above all a step that holds GH_TOKEN and runs a program of its
own. python3 scripts/thing.py can call anything. In the organisation this was
written in, every job whose grant is actually load bearing is that shape — the
API calls are inside a CLI the step runs — and the first version of this tool
called all of their grants unused.
For the same reason, scopes whose use is invisible in a workflow file —
packages, id-token, attestations, models, pages — are never reported
as unused. A docker push ghcr.io/... is detected; a library three
repositories away asking for an OIDC token is not.
A program it cannot resolve is still a wall. It reads Python source in the
repository being checked (below). Anything else — a compiled binary, a shell
script, node build.js, claude -p — is named and skipped, and --strict
turns that into an answer rather than a silence.
The jobs whose permissions: block is actually load bearing mostly do not make
their API calls in the workflow file. A step holds GITHUB_TOKEN, runs a CLI
from the same repository, and every call the grant exists for is inside that
program. For those, naming the step and giving up is not a check.
So when a step holds the workflow's own token and runs a command that resolves
to Python source in the repository, that source is read and its API calls are
folded in as though they had been written in the workflow file — which is what
they are. Three spellings of the command are resolved: a script named directly
(./ledger.py), a script handed to an interpreter (python3 scripts/x.py),
and an installed console script (greenwash), which is looked up through
pyproject.toml because the entry point does not exist as a file in a
checkout.
It reads the source; it does not run it. So the question answered is "which API paths are in this program", not "which did this run reach" — a call behind a branch nobody took is still reported, which is the safe direction for a permissions question. A grant you need on one code path is a grant you need.
Substitutions become {}. An f-string's values are unknowable statically,
so f"repos/{org}/{name}/pulls" reads as repos/{}/{}/pulls. That is the same
answer the tool already gives for ${{ }} in a workflow file, and the scope
table already treats such a segment as unresolved: it matches a wildcard, never
a literal, and the table refuses rather than guess when the variable sits
exactly where the answer would change. The imprecision is carried by the
existing table instead of being invented by the reader.
It follows one wrapper, in either of its two shapes. The case this was
built for has no path at the subprocess call at all:
def gh_json(args, gh="gh"):
exe = shutil.which(gh)
return subprocess.run([exe, *args], capture_output=True) # no path here
def workflow_states(org, repo, gh="gh"):
return gh_json(["api", f"repos/{org}/{repo}/actions/workflows"]) # hereA first pass finds functions that put one of their parameters into a gh argv;
a second reads what their callers pass. A function is only believed to be a
gh wrapper if the head of the argv traces to the string "gh" — a literal, a
parameter whose default is "gh", or a local assignment from one of those.
Nothing is inferred from the function's name.
The other shape passes only the endpoint, and the wrapper writes the rest of the argv itself:
def gh_api(path, gh="gh"):
subprocess.run([gh, "api", "-H", ACCEPT, path]) # the argv, minus the path
def workflow_states(org, repo):
return gh_api(f"repos/{org}/{repo}/actions/workflows") # the pathThe call site's argument is substituted into the wrapper's own argv at the slot
it fills, and the whole line is then read as if it had been written at the call
site. Keeping the real argv is what makes the method right: a wrapper whose
argv says -X POST, or carries a -f field and no -X at all, is a write,
and both reach the same parser that reads a gh api line out of a workflow
file. Which parameter is the endpoint is decided by that parser too, rather
than by a second copy of its flag rules — so ["api", "-H", header, path]
resolves to path, because -H eats the word after it.
urllib requests are read too, including the method, which is where a real
difference hides: a Request with a data= body and no method= anywhere is
a POST, and that is actions: write rather than actions: read.
- Method wrappers. A
def get(self, path)that builds agh apiargv is found and named, buttransport.get(...)andbody.get("message")are spelled identically and the receiver's class is not known here. Guessing between them would put dictionary keys in the scope table. The wrapper is reported as an unresolved entry point, so its job stays honestly blind rather than quietly clean. - A wrapper nothing calls by name.
ledger.gh_api(...)through the module is an attribute call and lands in the same blind spot as a method wrapper. A wrapper whose call sites were never found is reported the same way, because the alternative — no calls and nothing unreadable — is indistinguishable from a program that touches no API at all. - An endpoint behind a splat.
gh_api(*argv)hides which value reaches the path, and**kwargscould carry it under any name. Reported, not guessed. - Imports are not followed. A loose script is read on its own and a script
in a package is read with its package; neither reads a module it imports from
elsewhere. An API call in an unfollowed sibling is currently invisible rather
than reported, which is tracked as
committed-nightly/logbook#45. - A URL on a variable base is read as GitHub only if what follows the base
is a path the scope table recognises. One real script builds both its GitHub
and its Slack requests as
f"{api_base}/...", and the base is the only thing that distinguishes them.chat.postMessagematches no rule and is left alone. - A name assigned twice is not traced. Two assignments have two answers and taking the later one is not a fact about anything.
- Partly read is not read. If any call site in the program cannot be resolved, the job keeps its benefit of the doubt and no grant in it is reported as unused — the same rule as for an unrecognised action.
examples/program is a repository of this shape, and the bug in it is the one
that actually happened in committed-nightly/logbook#41 — a block short of
actions: read for a call inside the repository's own CLI, which arrived as a
403 on a scheduled run because nothing static could see it:
$ ungranted examples/program
.github/workflows/org.yaml
org
grant contents: read, pull-requests: read (workflow)
ungranted ledger.py: gh api repos/{}/{}/actions/workflows needs actions: read; this job has actions: none
in step: every repo in the org is accounted for
1 finding in 1 of 1 job.
The endpoint is not in the workflow file, and it is not a literal in
ledger.py either — it is f"repos/{org}/{repo}/actions/workflows", four
functions away from the subprocess.run that issues it.
GitHub publishes no machine-readable map from endpoint to required scope. Its
OpenAPI description carries category and subcategory, which are close
enough to mislead and not close enough to use.
So the table was measured. A workflow was pushed to a scratch branch with one
job per permissions: block, each job issuing the same list of requests with
its own GITHUB_TOKEN, and the status codes read back out of the run log.
tests/measured.tsv holds the results with links to the runs, and
tests/test_measured.py turns every row back into an assertion about the
table — so a rule added from a documentation page the API disagrees with fails
the suite.
Four things came out of that which are not what the documentation implies:
/repos/{o}/{r}/tagsis readable withmetadataalone, and/releasesis not. Two lists of much the same thing, two different answers./repos/{o}/{r}/environmentsneedsactions, not anything environment-shaped.read-allis not the union of the keys you can write. The runner resolves it to nineteen scopes.Metadatais implicit, and three of the rest —CodeQuality,Drives,VulnerabilityAlerts— have no key you can name. So/repos/{o}/{r}/dependabot/alertsis reachable underread-alland reachable by no block that names scopes, whichungrantedreports as its own finding rather than telling you to add a key that does not exist. It does not go the other way either:read-alldoes not grantid-token.- Some endpoints no block reaches at all.
/hooks,/actions/variables,/secret-scanning/alerts,/vulnerability-alerts,/stargazersand/userwere still403underread-all. Those need a different token, and saying "addadministration: read" would send you to a key actionlint rejects.
The one list here that has a second authority to check against is which scope
names a block may contain, and tests/test_scopes.py checks it against the
list actionlint prints. That caught two missing names in the first draft.
The measurement only works on a private repository. On a public one almost every read succeeds with no token at all:
# no Authorization header, public repository
$ curl -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/o/r/actions/workflows
200
So a public repository's workflow can be missing actions: read for a year and
never notice. ungranted reports it anyway: the grant is still wrong, the call
only works by accident of visibility, and the accident ends the day the
repository goes private or the file is copied into one that already is. The
first probe run for this tool was against a public repository and measured
nothing at all, which is why this section exists.
- No way to wave through an unrecognised path. If the table does not know an endpoint you get exit 2 and no flag to suppress it. That is the right default and it may be the wrong only option: a repository with one exotic endpoint cannot currently use this as a gate at all.
- No GraphQL.
gh api graphqlandgithub.graphql(...)are reported as unreadable rather than guessed at. The scopes are in the query body, and reading that properly is a second tool. - No reusable workflows. A
uses:job is named and skipped, not followed into the other file. - Only Python programs are read. A step that runs a shell script, a Go
binary or
nodeis named and skipped. Python is what the organisation this was written in actually runs; nothing about the approach is specific to it, and nothing else is implemented. - It does not run anything against your repository. The table is a record of a measurement taken once, not a live probe.
MIT.