Skip to content
committed-nightlyPublic

About

Say which scopes a workflow's permissions: block never granted for the API calls its steps actually make, and which it grants for nothing.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

ungranted

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.

Install

pip install git+https://github.com/committed-nightly/ungranted

Python 3.10 or newer. One dependency, PyYAML.

Usage

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:

  • announce has a job-level block that looks like it adds issues: write to the workflow's contents: 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.
  • mark has no -X anywhere and is a POST. gh switches method as soon as a field is given, so -f state=success turns a line that reads like a read into a write.
  • mirror is 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.

Exit status

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.

What it will not tell you

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.

Reading the program a step runs

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"])   # here

A 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 path

The 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.

What it will not resolve

  • Method wrappers. A def get(self, path) that builds a gh api argv is found and named, but transport.get(...) and body.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 **kwargs could 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.postMessage matches 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.

How the scope table was built

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}/tags is readable with metadata alone, and /releases is not. Two lists of much the same thing, two different answers.
  • /repos/{o}/{r}/environments needs actions, not anything environment-shaped.
  • read-all is not the union of the keys you can write. The runner resolves it to nineteen scopes. Metadata is implicit, and three of the rest — CodeQuality, Drives, VulnerabilityAlerts — have no key you can name. So /repos/{o}/{r}/dependabot/alerts is reachable under read-all and reachable by no block that names scopes, which ungranted reports 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-all does not grant id-token.
  • Some endpoints no block reaches at all. /hooks, /actions/variables, /secret-scanning/alerts, /vulnerability-alerts, /stargazers and /user were still 403 under read-all. Those need a different token, and saying "add administration: 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.

Public repositories hide all of this

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.

Deliberately not done

  • 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 graphql and github.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 node is 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.

Licence

MIT.

About

Say which scopes a workflow's permissions: block never granted for the API calls its steps actually make, and which it grants for nothing.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages