Skip to content

Diagnostic GitHub API

a read-only job holds a token that can delete repositories

There is no incident to attach this to. The job runs every ten minutes, lists open pull requests, posts a count to a dashboard and has not failed once this year. The token it uses was created in four seconds by ticking repo, and it can force-push to every private repository in the organization, rewrite webhooks and delete the lot.

Read-only token Python and Node.js Tests included
A store aisle filled with lots of items
Photo by Oxana Melis on Unsplash
The short answer

Read x-oauth-scopes off GET /user and compare it against what the job actually does. A read-only integration that holds repo, workflow, admin:org, delete_repo or write:packages is holding write authority it has never exercised, and the size of that authority is the number of repositories the account can reach.

There is a second, less obvious finding underneath. For a private repository, the minimum classic scope is repo, and repo is read and write together. You cannot fix that by choosing a narrower classic scope, because there isn't one. The repair is a different credential type: a fine-grained token or an App installation with Contents: Read and Pull requests: Read. The script prints that permission list for you to mint.

The problem in plain words

The hard part is that there is nothing to debug. Every other note in this section starts from a failure; this one starts from a graph that is flat and a job that is green. That means it never gets prioritised, and it means the person who raises it has to argue rather than point.

It is also self-reinforcing. The habit that produces it is the same habit that resolves the last permissions ticket: something 403s, somebody ticks more boxes, the call works, the token is now broader and nobody narrows it afterwards. Every scope a token holds is the fossil of one incident, and none of them are ever removed.

Then the token spreads. It goes into CI, into a container image, into a developer's shell profile, into a script somebody copied. By the time it leaks — in a log line, a screenshot, a pull request diff — the question is not whether it was necessary but what it can reach, and the answer is every repository the account can see, with write.

Read only joblists pullrequestsrepo tickedoncethe only box thatworksGreen for ayearnothing to debugToken copiedonwardCI, image, laptop100 reposwritableon the day itleaks
Nothing in this chain errors. The only step that changes anything is the last one, and it happens on somebody else's afternoon.

Why it happens

Classic scopes are coarse by design. repo is a single scope covering read, write, settings, deploy keys, statuses and invitations, across every repository the user can reach — public, private and organization-owned alike. It is not a per-repository grant and it has no read-only variant, so an integration that reads one private repository gets write on all of them.

The blast radius is an account property, not a job property. A token scoped repo reaches whatever its owner reaches, so it grows when the owner joins a team. The job did not change, the credential did not change, and the number of repositories it can write to went up on somebody else's onboarding day.

Some scopes are strictly destructive. delete_repo grants nothing readable at all — there is no read that requires it. If a read-only job holds it, that is unambiguous: it is not there for a reason anyone can name.

Fine-grained tokens exist precisely for this. They select specific repositories and specific permissions at specific levels, so Contents: Read is a thing you can actually have. They also expire by default, which turns an unbounded leak into a bounded one.

You cannot audit this from the outside. A read-only token can read its own scopes and nothing else: it cannot enumerate the other tokens on the account, cannot see when they were last used, and cannot tell you who else holds a copy of this one. The inventory this script produces is for the credential it is holding, and that limit is worth stating in the report rather than glossing.

The fix, as a flow

One request, and it is GET /user. Nothing is probed, because a write probe that succeeds is a write. The report is an inventory rather than a diff: the verbs this credential authorizes, the repositories they reach, and the one state that no re-minting of a classic token can clear.

GET /user, one requestscopes against declared readsWrite scopes nothing usesdelete_repo on a readerMinimum is still repono classic token is narrowerUnused read scopesuntidy rather than dangerousNo scope headerfine grained already, nothing to do
Three of these four outcomes come from a system that is working perfectly, which is why the report has to name verbs rather than scope names.

How to fix it

Read what the token holds, in one request

GET /user returns x-oauth-scopes on the response and the account's repository counts in the body. That is both halves of the inventory: what the credential may do, and how many repositories it may do it to. If the header is absent, you already have a fine-grained or App credential and this note is not about you.

Write down what the job actually reads

Not what it might one day read. The list of endpoints it calls this week: pull requests, issues, workflow runs, organization members. This is the only input the audit needs that the API cannot supply, and it takes five minutes with the source open.

Subtract, and name the verbs rather than the scopes

A report that says "you hold admin:org" gets nodded at. A report that says "this credential can add and remove organization members, and nothing it runs has ever done so" gets acted on. Translate every excess scope into the thing it authorizes.

Notice when the minimum classic scope is still too broad

If the job reads a private repository, the smallest classic scope that works is repo, which includes write. There is no narrower tick box, so no amount of re-minting a classic token improves it. That is the signal to change credential type rather than to change scopes.

Mint the fine-grained replacement and run both in parallel

Create the fine-grained token with the printed permission list, point a copy of the job at it, and let it run alongside the old one for a cycle. When the outputs match, cut over and revoke the classic token. Nothing about this is urgent, which is exactly why it needs to be scheduled.

How to check it worked

Re-run against the fine-grained credential. The absence of x-oauth-scopes is the pass condition, and the script says so rather than reporting an empty scope list.

GITHUB_TOKEN=$FINE_GRAINED python3 github_scope_blast_radius.py --needs pull-requests
# not-scope-based: no x-oauth-scopes header, so this credential carries
# per-repository permissions rather than account-wide scopes. Nothing to narrow.

The full code

One GET, and it is GET /user. The rest is a capability model held in two tables: what each write-bearing scope authorizes, in verbs a non-engineer can read, and what the minimum classic scope and fine-grained permission set is for each kind of read a job declares. The interesting output is not a diff but a sentence about a working system, so the verdict has a state for the minimum is still too broad, which is the case no re-minting of a classic token can fix.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 101 GitHub API fixes, free and open source.
github_scope_blast_radius.py
"""Inventory what a working GitHub token is allowed to do that it never does.

Read only, and in a stronger sense than usual: the single request is GET /user.
Nothing here probes a write to see whether it would be permitted, because a
probe that is permitted is a write. The repair, a fine-grained permission list,
is printed for you to mint.

Nothing is failing when you run this. That is the point of it.
"""
import argparse
import json
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_scope_blast_radius")

API = "https://api.github.com"
UA = "github-scope-blast-radius/1.0"

# What each scope authorizes, phrased as the thing it lets somebody do rather
# than as the name of the scope. A report that names verbs gets acted on; a
# report that names scopes gets nodded at. Read-only scopes are absent from this
# table on purpose: holding one unnecessarily is untidy, not dangerous.
CAPABILITIES = {
    "repo": ["push to every public and private repository the account can reach",
             "create and remove branches, tags and releases",
             "change repository settings, collaborators and deploy keys"],
    "public_repo": ["push to every public repository the account can reach"],
    "delete_repo": ["permanently remove any repository the account administers"],
    "admin:org": ["add and remove organization members",
                  "create, rename and dissolve teams"],
    "write:org": ["change team membership and organization projects"],
    "admin:org_hook": ["create, edit and remove organization webhooks"],
    "admin:repo_hook": ["create, edit and remove repository webhooks"],
    "write:repo_hook": ["create and edit repository webhooks"],
    "workflow": ["change workflow files, which run on the next push"],
    "write:packages": ["publish and overwrite package versions"],
    "delete:packages": ["permanently remove published package versions"],
    "gist": ["create and edit gists on the account"],
    "user": ["change the account profile and its email addresses"],
    "admin:public_key": ["add an SSH key to the account"],
    "admin:gpg_key": ["add a signing key to the account"],
    "write:discussion": ["post and edit team discussions"],
    "notifications": ["mark notifications read and manage subscriptions"],
}

# The smallest classic scope that serves each kind of read. An empty list means
# no scope at all is required, which surprises people: public repository data is
# readable by any authenticated caller.
NEEDS_CLASSIC = {
    "public-repos": [],
    "private-repos": ["repo"],
    "pull-requests": ["repo"],
    "issues": ["repo"],
    "actions-runs": ["repo"],
    "org-members": ["read:org"],
    "repo-hooks": ["read:repo_hook"],
    "packages": ["read:packages"],
    "user-profile": ["read:user"],
}

# The same reads expressed as fine-grained permissions, which is the repair.
NEEDS_FINE_GRAINED = {
    "public-repos": ["Metadata: Read"],
    "private-repos": ["Contents: Read", "Metadata: Read"],
    "pull-requests": ["Metadata: Read", "Pull requests: Read"],
    "issues": ["Issues: Read", "Metadata: Read"],
    "actions-runs": ["Actions: Read", "Metadata: Read"],
    "org-members": ["Members: Read (organization)"],
    "repo-hooks": ["Webhooks: Read"],
    "packages": ["Packages: Read"],
    "user-profile": ["Profile: Read (account)"],
}

# Classic scopes that grant write and cannot be avoided for the reads that need
# them. Holding one of these is not a mistake in scope choice; it is the reason
# to stop using classic tokens.
UNAVOIDABLY_BROAD = {"repo", "public_repo"}


def held_scopes(headers):
    """Read x-oauth-scopes and say what kind of credential this is. Pure.

    Returns (scopes, kind). An absent header is the healthy answer here, not a
    missing one: fine-grained tokens and App installation tokens do not carry
    account-wide scopes at all.
    """
    lowered = {str(k).lower(): v for k, v in (headers or {}).items()}
    if "x-oauth-scopes" not in lowered:
        return None, "not-scope-based"
    raw = lowered["x-oauth-scopes"]
    return [s.strip() for s in str(raw).split(",") if s.strip()], "scope-based"


def required(reads):
    """Minimum classic scopes and fine-grained permissions for declared reads. Pure.

    Unrecognised names come back in `unknown` rather than being silently
    dropped, because a typo that quietly shrinks the requirement would make a
    token look over-scoped when it is not.
    """
    classic, fine, unknown = set(), set(), []
    for name in reads or []:
        key = str(name).strip().lower()
        if not key:
            continue
        if key not in NEEDS_CLASSIC:
            unknown.append(key)
            continue
        classic.update(NEEDS_CLASSIC[key])
        fine.update(NEEDS_FINE_GRAINED[key])
    return {"classic": sorted(classic), "fine_grained": sorted(fine),
            "unknown": sorted(unknown)}


def capabilities(scopes):
    """Every write verb the given scopes authorize, deduplicated. Pure."""
    verbs = []
    for scope in sorted(set(scopes or [])):
        for verb in CAPABILITIES.get(scope, ()):
            if verb not in verbs:
                verbs.append(verb)
    return verbs


def excess(held, needed_classic):
    """Scopes held that no declared read asks for. Pure.

    A plain difference against the minimum set, which is enough because the
    minimum is exact: anything outside it was not asked for, whether it is
    broader or merely unrelated.
    """
    needed = set(needed_classic or [])
    return sorted(s for s in set(held or []) if s not in needed)


def blast_radius(user, held):
    """How many repositories the write verbs reach. Pure.

    Counts from the GET /user body rather than a listing, so the audit stays one
    request. Returns None for the count when the body did not say, because a
    guessed number in a security report is worse than an absent one.
    """
    writes = [s for s in (held or []) if s in CAPABILITIES]
    body = user if isinstance(user, dict) else {}
    total = 0
    seen_any = False
    for field in ("public_repos", "total_private_repos"):
        value = body.get(field)
        if isinstance(value, int):
            total += value
            seen_any = True
    return {"repositories": total if seen_any else None,
            "write_scopes": writes,
            "verbs": capabilities(writes)}


def verdict(kind, held, needed, radius):
    """Turn the inventory into a finding about a system that is working. Pure."""
    if kind == "not-scope-based":
        return ("not-scope-based",
                "no x-oauth-scopes header, so this credential carries "
                "per-repository permissions rather than account-wide scopes. "
                "There is nothing to narrow here.")

    unnecessary = excess(held, needed["classic"])
    dangerous = [s for s in unnecessary if s in CAPABILITIES]
    reach = radius.get("repositories")
    where = ("%d repositories" % reach) if reach is not None \
        else "every repository the account can reach"

    if dangerous:
        return ("over-scoped",
                "%d scope(s) held that no declared read needs, and %d of them "
                "grant write across %s: %s"
                % (len(unnecessary), len(dangerous), where, ", ".join(dangerous)))
    if unnecessary:
        return ("unused-scopes",
                "%d scope(s) held that no declared read needs: %s. None of them "
                "grant write, so this is untidy rather than dangerous."
                % (len(unnecessary), ", ".join(unnecessary)))
    if set(held or []) & UNAVOIDABLY_BROAD:
        return ("coarse-by-construction",
                "the scopes held are the minimum a classic token can have for "
                "these reads, and they still grant write across %s. No classic "
                "token is narrower than this one; the repair is a different "
                "credential type." % where)
    return ("least-privilege",
            "every scope held is required by a declared read, and none of them "
            "grant write.")


def get(session, path):
    """One GET. Returns (status, json-or-None, headers)."""
    url = API + path if path.startswith("/") else path
    r = session.get(url, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = None
    return r.status_code, body, dict(r.headers)


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--needs", default="",
                    help="comma-separated list of what the job reads: " +
                         ", ".join(sorted(NEEDS_CLASSIC)))
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN to the credential you want inventoried")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    status, body, headers = get(session, "/user")
    if status == 401:
        log.error("GET /user returned 401. This credential does not "
                  "authenticate, which is a different note")
        return 2
    if status != 200:
        log.error("GET /user returned %d; cannot inventory the credential", status)
        return 2

    held, kind = held_scopes(headers)
    needed = required([s for s in args.needs.split(",") if s.strip()])
    radius = blast_radius(body, held)

    log.info("authenticated as %s", (body or {}).get("login", "an unnamed user"))
    log.info("held:     %s", "header absent" if held is None
             else (", ".join(held) or "none"))
    log.info("required: %s", ", ".join(needed["classic"]) or
             "no scope at all for the declared reads")
    if needed["unknown"]:
        log.warning("unrecognised read(s) %s were ignored; a typo here makes a "
                    "token look broader than it is",
                    ", ".join(needed["unknown"]))

    for verb in radius["verbs"]:
        log.warning("this credential can %s", verb)

    state, detail = verdict(kind, held, needed, radius)
    log.info("%s: %s", state, detail)

    if state in ("over-scoped", "coarse-by-construction"):
        log.info("repair: mint a fine-grained token limited to the repositories "
                 "this job reads, with exactly: %s",
                 ", ".join(needed["fine_grained"]) or "Metadata: Read")
        log.info("repair: run both credentials side by side for one cycle, "
                 "compare the output, then revoke the classic token.")
    if state == "unused-scopes":
        log.info("repair: re-mint without %s. Scopes cannot be removed from an "
                 "existing classic token.",
                 ", ".join(excess(held, needed["classic"])))

    log.info("note: a read-only token can only inventory itself. It cannot "
             "enumerate the other tokens on this account or say who else holds "
             "a copy of this one.")

    print(json.dumps({"kind": kind, "held": held, "required": needed,
                      "blast_radius": radius, "state": state}, indent=2))
    return 0 if state in ("least-privilege", "not-scope-based") else 1


if __name__ == "__main__":
    sys.exit(main())
github-scope-blast-radius.mjs
/**
 * Inventory what a working GitHub token is allowed to do that it never does.
 *
 * Read only, and in a stronger sense than usual: the single request is
 * GET /user. Nothing here probes a write to see whether it would be permitted,
 * because a probe that is permitted is a write.
 *
 * Nothing is failing when you run this. That is the point of it.
 */
const API = 'https://api.github.com';
const UA = 'github-scope-blast-radius/1.0';

/** What each scope authorizes, phrased as a verb rather than as a scope name. */
export const CAPABILITIES = {
  repo: ['push to every public and private repository the account can reach',
    'create and remove branches, tags and releases',
    'change repository settings, collaborators and deploy keys'],
  public_repo: ['push to every public repository the account can reach'],
  delete_repo: ['permanently remove any repository the account administers'],
  'admin:org': ['add and remove organization members',
    'create, rename and dissolve teams'],
  'write:org': ['change team membership and organization projects'],
  'admin:org_hook': ['create, edit and remove organization webhooks'],
  'admin:repo_hook': ['create, edit and remove repository webhooks'],
  'write:repo_hook': ['create and edit repository webhooks'],
  workflow: ['change workflow files, which run on the next push'],
  'write:packages': ['publish and overwrite package versions'],
  'delete:packages': ['permanently remove published package versions'],
  gist: ['create and edit gists on the account'],
  user: ['change the account profile and its email addresses'],
  'admin:public_key': ['add an SSH key to the account'],
  'admin:gpg_key': ['add a signing key to the account'],
  'write:discussion': ['post and edit team discussions'],
  notifications: ['mark notifications read and manage subscriptions'],
};

/** The smallest classic scope that serves each kind of read. */
export const NEEDS_CLASSIC = {
  'public-repos': [],
  'private-repos': ['repo'],
  'pull-requests': ['repo'],
  issues: ['repo'],
  'actions-runs': ['repo'],
  'org-members': ['read:org'],
  'repo-hooks': ['read:repo_hook'],
  packages: ['read:packages'],
  'user-profile': ['read:user'],
};

/** The same reads expressed as fine-grained permissions, which is the repair. */
export const NEEDS_FINE_GRAINED = {
  'public-repos': ['Metadata: Read'],
  'private-repos': ['Contents: Read', 'Metadata: Read'],
  'pull-requests': ['Metadata: Read', 'Pull requests: Read'],
  issues: ['Issues: Read', 'Metadata: Read'],
  'actions-runs': ['Actions: Read', 'Metadata: Read'],
  'org-members': ['Members: Read (organization)'],
  'repo-hooks': ['Webhooks: Read'],
  packages: ['Packages: Read'],
  'user-profile': ['Profile: Read (account)'],
};

/** Classic scopes that grant write and cannot be avoided for some reads. */
export const UNAVOIDABLY_BROAD = new Set(['repo', 'public_repo']);

/** Read x-oauth-scopes and say what kind of credential this is. Pure. */
export function heldScopes(headers) {
  const lowered = {};
  for (const [k, v] of Object.entries(headers ?? {})) lowered[String(k).toLowerCase()] = v;
  if (!('x-oauth-scopes' in lowered)) return [null, 'not-scope-based'];
  const raw = lowered['x-oauth-scopes'];
  return [String(raw).split(',').map((s) => s.trim()).filter(Boolean), 'scope-based'];
}

/** Minimum classic scopes and fine-grained permissions for declared reads. Pure. */
export function required(reads) {
  const classic = new Set();
  const fine = new Set();
  const unknown = [];
  for (const name of reads ?? []) {
    const key = String(name).trim().toLowerCase();
    if (!key) continue;
    if (!(key in NEEDS_CLASSIC)) { unknown.push(key); continue; }
    for (const s of NEEDS_CLASSIC[key]) classic.add(s);
    for (const p of NEEDS_FINE_GRAINED[key]) fine.add(p);
  }
  return {
    classic: [...classic].sort(),
    fine_grained: [...fine].sort(),
    unknown: unknown.sort(),
  };
}

/** Every write verb the given scopes authorize, deduplicated. Pure. */
export function capabilities(scopes) {
  const verbs = [];
  for (const scope of [...new Set(scopes ?? [])].sort()) {
    for (const verb of CAPABILITIES[scope] ?? []) {
      if (!verbs.includes(verb)) verbs.push(verb);
    }
  }
  return verbs;
}

/** Scopes held that no declared read asks for. Pure. */
export function excess(held, neededClassic) {
  const needed = new Set(neededClassic ?? []);
  return [...new Set(held ?? [])].filter((s) => !needed.has(s)).sort();
}

/** How many repositories the write verbs reach. Pure. */
export function blastRadius(user, held) {
  const writes = (held ?? []).filter((s) => s in CAPABILITIES);
  const body = (user && typeof user === 'object') ? user : {};
  let total = 0;
  let seenAny = false;
  for (const field of ['public_repos', 'total_private_repos']) {
    if (Number.isInteger(body[field])) { total += body[field]; seenAny = true; }
  }
  return {
    repositories: seenAny ? total : null,
    write_scopes: writes,
    verbs: capabilities(writes),
  };
}

/** Turn the inventory into a finding about a system that is working. Pure. */
export function verdict(kind, held, needed, radius) {
  if (kind === 'not-scope-based') {
    return ['not-scope-based',
      'no x-oauth-scopes header, so this credential carries per-repository ' +
      'permissions rather than account-wide scopes. There is nothing to narrow here.'];
  }

  const unnecessary = excess(held, needed.classic);
  const dangerous = unnecessary.filter((s) => s in CAPABILITIES);
  const reach = radius.repositories;
  const where = (reach === null || reach === undefined)
    ? 'every repository the account can reach' : `${reach} repositories`;

  if (dangerous.length) {
    return ['over-scoped',
      `${unnecessary.length} scope(s) held that no declared read needs, and ` +
      `${dangerous.length} of them grant write across ${where}: ${dangerous.join(', ')}`];
  }
  if (unnecessary.length) {
    return ['unused-scopes',
      `${unnecessary.length} scope(s) held that no declared read needs: ` +
      `${unnecessary.join(', ')}. None of them grant write, so this is untidy ` +
      'rather than dangerous.'];
  }
  if ((held ?? []).some((s) => UNAVOIDABLY_BROAD.has(s))) {
    return ['coarse-by-construction',
      'the scopes held are the minimum a classic token can have for these ' +
      `reads, and they still grant write across ${where}. No classic token is ` +
      'narrower than this one; the repair is a different credential type.'];
  }
  return ['least-privilege',
    'every scope held is required by a declared read, and none of them grant write.'];
}

async function get(token, path) {
  const url = path.startsWith('/') ? API + path : path;
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': UA,
    },
  });
  let body = null;
  try { body = await res.json(); } catch { body = null; }
  const headers = {};
  for (const [k, v] of res.headers.entries()) headers[k] = v;
  return { status: res.status, body, headers };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    console.error('set GITHUB_TOKEN to the credential you want inventoried');
    process.exitCode = 2;
    return;
  }
  const reads = (process.argv[2] ?? '').split(',').map((s) => s.trim()).filter(Boolean);

  const user = await get(token, '/user');
  if (user.status === 401) {
    console.error('GET /user returned 401. This credential does not ' +
      'authenticate, which is a different note');
    process.exitCode = 2;
    return;
  }
  if (user.status !== 200) {
    console.error(`GET /user returned ${user.status}; cannot inventory the credential`);
    process.exitCode = 2;
    return;
  }

  const [held, kind] = heldScopes(user.headers);
  const needed = required(reads);
  const radius = blastRadius(user.body, held);

  console.log(`authenticated as ${user.body?.login ?? 'an unnamed user'}`);
  console.log(`held:     ${held === null ? 'header absent' : (held.join(', ') || 'none')}`);
  console.log(`required: ${needed.classic.join(', ') || 'no scope at all for the declared reads'}`);
  if (needed.unknown.length) {
    console.warn(`unrecognised read(s) ${needed.unknown.join(', ')} were ignored; ` +
      'a typo here makes a token look broader than it is');
  }
  for (const verb of radius.verbs) console.warn(`this credential can ${verb}`);

  const [state, detail] = verdict(kind, held, needed, radius);
  console.log(`${state}: ${detail}`);

  if (state === 'over-scoped' || state === 'coarse-by-construction') {
    console.log('repair: mint a fine-grained token limited to the repositories ' +
      `this job reads, with exactly: ${needed.fine_grained.join(', ') || 'Metadata: Read'}`);
    console.log('repair: run both credentials side by side for one cycle, ' +
      'compare the output, then revoke the classic token.');
  }
  if (state === 'unused-scopes') {
    console.log(`repair: re-mint without ${excess(held, needed.classic).join(', ')}. ` +
      'Scopes cannot be removed from an existing classic token.');
  }
  console.log('note: a read-only token can only inventory itself. It cannot ' +
    'enumerate the other tokens on this account or say who else holds a copy.');

  console.log(JSON.stringify({
    kind, held, required: needed, blast_radius: radius, state,
  }, null, 2));
  process.exitCode = (state === 'least-privilege' || state === 'not-scope-based') ? 0 : 1;
}

// Only run when invoked directly, so importing this module from the test file
// does not execute main() and set an exit code that fails the suite.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The assertions here are about a healthy system, which makes them easy to get subtly wrong. The ones that matter: an absent scope header is the pass, not a failure; a job that reads only public data needs no scope whatsoever; delete_repo on a read-only job is flagged even though nothing has ever used it; and a token holding only repo for a private read is reported as coarse rather than excessive, because no classic token would be narrower.

test_github_scope_blast_radius.py
from github_scope_blast_radius import (
    blast_radius, capabilities, excess, held_scopes, required, verdict)

USER = {"login": "octo-bot", "public_repos": 12, "total_private_repos": 88}


def test_an_absent_scope_header_means_a_fine_grained_credential():
    scopes, kind = held_scopes({"X-RateLimit-Limit": "5000"})
    assert scopes is None
    assert kind == "not-scope-based"


def test_the_header_is_read_case_insensitively():
    scopes, kind = held_scopes({"X-OAuth-Scopes": "repo, delete_repo"})
    assert scopes == ["repo", "delete_repo"]
    assert kind == "scope-based"


def test_a_token_minted_with_no_scopes_is_not_the_same_as_no_header():
    scopes, kind = held_scopes({"x-oauth-scopes": ""})
    assert scopes == []
    assert kind == "scope-based"


def test_reading_public_data_requires_no_scope_at_all():
    assert required(["public-repos"])["classic"] == []
    assert required(["public-repos"])["fine_grained"] == ["Metadata: Read"]


def test_a_private_read_needs_the_broadest_classic_scope_there_is():
    assert required(["pull-requests"])["classic"] == ["repo"]


def test_an_unrecognised_read_is_reported_rather_than_dropped():
    out = required(["pull-requests", "telemetry"])
    assert out["unknown"] == ["telemetry"]
    assert out["classic"] == ["repo"]


def test_capabilities_are_verbs_and_deduplicated():
    verbs = capabilities(["repo", "public_repo", "delete_repo"])
    assert any("permanently remove" in v for v in verbs)
    assert len(verbs) == len(set(verbs))


def test_read_only_scopes_authorize_no_verbs():
    assert capabilities(["read:org", "read:packages"]) == []


def test_excess_is_everything_outside_the_minimum():
    assert excess(["repo", "delete_repo", "read:org"], ["repo"]) == [
        "delete_repo", "read:org"]


def test_blast_radius_counts_public_and_private_together():
    radius = blast_radius(USER, ["repo"])
    assert radius["repositories"] == 100
    assert radius["write_scopes"] == ["repo"]


def test_a_body_without_counts_reports_no_number_rather_than_zero():
    assert blast_radius({}, ["repo"])["repositories"] is None


def test_a_fine_grained_token_is_the_pass_condition():
    state, detail = verdict("not-scope-based", None, required([]),
                            blast_radius(USER, None))
    assert state == "not-scope-based"
    assert "nothing to narrow" in detail


def test_a_read_only_job_holding_delete_repo_is_flagged():
    held = ["repo", "delete_repo", "workflow"]
    state, detail = verdict("scope-based", held, required(["pull-requests"]),
                            blast_radius(USER, held))
    assert state == "over-scoped"
    assert "delete_repo" in detail
    assert "100 repositories" in detail


def test_unused_read_scopes_are_untidy_rather_than_dangerous():
    held = ["repo", "read:packages"]
    state, detail = verdict("scope-based", held, required(["pull-requests"]),
                            blast_radius(USER, held))
    assert state == "unused-scopes"
    assert "untidy" in detail


def test_the_minimum_classic_scope_is_still_reported_as_too_broad():
    held = ["repo"]
    state, detail = verdict("scope-based", held, required(["pull-requests"]),
                            blast_radius(USER, held))
    assert state == "coarse-by-construction"
    assert "different credential type" in detail


def test_a_genuinely_minimal_token_is_clean():
    held = ["read:org"]
    state, _ = verdict("scope-based", held, required(["org-members"]),
                       blast_radius(USER, held))
    assert state == "least-privilege"
github-scope-blast-radius.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  blastRadius, capabilities, excess, heldScopes, required, verdict,
} from './github-scope-blast-radius.mjs';

const USER = { login: 'octo-bot', public_repos: 12, total_private_repos: 88 };

test('an absent scope header means a fine-grained credential', () => {
  const [scopes, kind] = heldScopes({ 'X-RateLimit-Limit': '5000' });
  assert.equal(scopes, null);
  assert.equal(kind, 'not-scope-based');
});

test('the header is read case-insensitively', () => {
  const [scopes, kind] = heldScopes({ 'X-OAuth-Scopes': 'repo, delete_repo' });
  assert.deepEqual(scopes, ['repo', 'delete_repo']);
  assert.equal(kind, 'scope-based');
});

test('a token minted with no scopes is not the same as no header', () => {
  const [scopes, kind] = heldScopes({ 'x-oauth-scopes': '' });
  assert.deepEqual(scopes, []);
  assert.equal(kind, 'scope-based');
});

test('reading public data requires no scope at all', () => {
  assert.deepEqual(required(['public-repos']).classic, []);
  assert.deepEqual(required(['public-repos']).fine_grained, ['Metadata: Read']);
});

test('a private read needs the broadest classic scope there is', () => {
  assert.deepEqual(required(['pull-requests']).classic, ['repo']);
});

test('an unrecognised read is reported rather than dropped', () => {
  const out = required(['pull-requests', 'telemetry']);
  assert.deepEqual(out.unknown, ['telemetry']);
  assert.deepEqual(out.classic, ['repo']);
});

test('capabilities are verbs and deduplicated', () => {
  const verbs = capabilities(['repo', 'public_repo', 'delete_repo']);
  assert.ok(verbs.some((v) => v.includes('permanently remove')));
  assert.equal(verbs.length, new Set(verbs).size);
});

test('read-only scopes authorize no verbs', () => {
  assert.deepEqual(capabilities(['read:org', 'read:packages']), []);
});

test('excess is everything outside the minimum', () => {
  assert.deepEqual(excess(['repo', 'delete_repo', 'read:org'], ['repo']),
    ['delete_repo', 'read:org']);
});

test('blast radius counts public and private together', () => {
  const radius = blastRadius(USER, ['repo']);
  assert.equal(radius.repositories, 100);
  assert.deepEqual(radius.write_scopes, ['repo']);
});

test('a body without counts reports no number rather than zero', () => {
  assert.equal(blastRadius({}, ['repo']).repositories, null);
});

test('a fine-grained token is the pass condition', () => {
  const [state, detail] = verdict('not-scope-based', null, required([]),
    blastRadius(USER, null));
  assert.equal(state, 'not-scope-based');
  assert.match(detail, /nothing to narrow/);
});

test('a read-only job holding delete_repo is flagged', () => {
  const held = ['repo', 'delete_repo', 'workflow'];
  const [state, detail] = verdict('scope-based', held, required(['pull-requests']),
    blastRadius(USER, held));
  assert.equal(state, 'over-scoped');
  assert.match(detail, /delete_repo/);
  assert.match(detail, /100 repositories/);
});

test('unused read scopes are untidy rather than dangerous', () => {
  const held = ['repo', 'read:packages'];
  const [state, detail] = verdict('scope-based', held, required(['pull-requests']),
    blastRadius(USER, held));
  assert.equal(state, 'unused-scopes');
  assert.match(detail, /untidy/);
});

test('the minimum classic scope is still reported as too broad', () => {
  const held = ['repo'];
  const [state, detail] = verdict('scope-based', held, required(['pull-requests']),
    blastRadius(USER, held));
  assert.equal(state, 'coarse-by-construction');
  assert.match(detail, /different credential type/);
});

test('a genuinely minimal token is clean', () => {
  const held = ['read:org'];
  const [state] = verdict('scope-based', held, required(['org-members']),
    blastRadius(USER, held));
  assert.equal(state, 'least-privilege');
});

FAQ

Nothing is broken. Why should I spend an afternoon on this?

Because the cost is not paid now, it is paid on the day the token leaves the place you put it, and on that day the only question anyone asks is what it could reach. A classic token scoped repo can push to every repository its owner can see, so a leak in a CI log or a screenshot is an organization-wide event rather than a single-repository one. The work is small and schedulable today and unschedulable later, which is the entire argument.

Why can't I create a classic token that reads private repositories without writing them?

Because that scope does not exist. The classic scope set has repo, which is read and write across everything the owner can reach, and public_repo, which is the same for public repositories only. There is no read-only variant and no per-repository selection, so for a private read the minimum is also the maximum. That is not a mistake you made in the token dialog; it is the reason fine-grained tokens were introduced.

Can this script check every token on my account?

No, and no read-only script can. A token can read its own scopes and nothing else: there is no endpoint that lists the personal access tokens on an account for the token holder, no last-used timestamp exposed to it, and no way to discover other copies of itself. The inventory is for the credential in the environment when you ran it, so an account-wide audit means running it once per credential, from wherever that credential lives.

Should I use a fine-grained token or a GitHub App?

A fine-grained token when the work belongs to a person and a short expiry is acceptable; a GitHub App when the work belongs to a system, needs to outlive whoever set it up, or needs a rate limit that scales with the installation. Both give you per-repository selection and per-resource permission levels, which is the property that matters here. The App additionally issues installation tokens that expire in an hour, which bounds a leak without anybody having to remember to rotate.

Does removing a scope risk breaking something I cannot see?

It can, which is why the repair is to run both credentials in parallel for a cycle rather than to swap and hope. The failure mode of a too-narrow token is loud and specific: a 403 or a 404 with x-accepted-oauth-scopes naming what it wanted, which is exactly the note on an endpoint accepting a scope your token was never given. Narrowing is safe precisely because being too narrow is diagnosable in one response and being too broad is not diagnosable at all.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.