Skip to content

sec(mint-scope): the board-writing mint asks for a board write, not everything - #349

Merged
LukasWodka merged 1 commit into
developfrom
sec/2157-scope-the-set-pr-status-mint
Aug 26, 2026
Merged

sec(mint-scope): the board-writing mint asks for a board write, not everything#349
LukasWodka merged 1 commit into
developfrom
sec/2157-scope-the-set-pr-status-mint

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

actions/create-github-app-token with no permission-* inputs mints the App's full
installation grant
. For tracebloc-release-train that is contents + pull-requests +
issues + organization-projects write across every installed repo — and the App holds
bypass_reviews on staging and prod fleet-wide, so the blast radius of a misused token is
merge past review, not merely write.

set-pr-status.yml only moves a card between board columns. It now asks for exactly that:

permission-pull-requests: readpermission-organization-projects: write

The guard is the point, not the one-line fix

Nine workflows acquired a full-permission mint in a single day because the first one did
and the rest were copied from it. Least privilege applied by hand is a state;
scripts/mint-scope.py is what makes it a property — without a check, the twentieth
mint arrives the way the first nineteen did.

It is written to the house rules rather than around them:

  • Derived, never restated — the workflow list is the directory, and mint steps are
    found by parsing YAML and matching the uses: value, not by grepping names typed into
    the checker. Add a workflow and it is covered; rename one and nothing silently stops
    being checked.
  • Fails closed — an unparseable workflow, an unreadable directory, and zero mints
    found
    are all findings. Zero is the important one: the premise is that mints exist, so
    finding none means the matcher broke, not that the fleet got clean.
  • Exemptions carry reasons and are asserted live — a stale exemption is itself a
    finding, so it cannot become quiet cover for the next unscoped mint in that file.

Current state on this branch: 17 mint steps across 37 workflows — 14 scoped, 3 exempted
with stated reasons (fr-gate, kanban-reconcile, standards-sync), 0 findings.

This does NOT close backend#2157 — and I found why while checking

I was ready to add a closing keyword, because a green mint-scope over this repo reads
like piece 1 of #2157 being finished. It isn't. release-train has three unscoped
mints
promote.yml (×2) and hotfix.yml — filed as backend#2599.

mint-scope.py cannot see them: it scans this repo only, on the argument that "the
reusables live here, and the one per-repo copy is compared byte-for-byte … so a scoped
canonical here means scoped everywhere."
That holds for reusables and copies. It does not
hold for release-train, whose prod-merging workflows are first-party and neither. So the
docstring's "the whole surface rather than a convenient subset" is false, in the section
headed DERIVED, NEVER RESTATED.

Worth deciding in #2599 whether mint-scope.py widens past its own repo or narrows its
claim to match its reach — the current pairing, widest claim over narrowest scan, is the
one combination that isn't defensible.

Part of tracebloc/backend#2157. Follow-up: tracebloc/backend#2599.

backend#2157, ONE OF FOUR -- this does not close it.
`set-pr-status.yml`s first mint passed `owner:` with no `permission-*`
inputs, so it minted the App ENTIRE permission set -- contents:write,
issues:write, administration:read and the rest -- across every repo the
installation covers, on every PR event in the fleet. Largest exposure of
the four purely because of how often it runs.
SCOPES DERIVED FROM THE CALLS, not copied from a template:
organization.projectV2 { fields } -> projects, read
repository.pullRequest.projectItems -> pull-requests, read
updateProjectV2ItemFieldValue -> projects, WRITE
No `issues` scope: this job is PR-only. The SECOND mint in this same file
already carried `permission-pull-requests: read` + `permission-issues:
read` -- the closing-ref job reads the linked issue. Worth naming because
the exemption is per FILE, so `set-pr-status.yml` read as wholly unscoped
while half of it was already done.
Exemption removed. The guard now reports 3 unscoped / 3 exempted / 0
findings, down from 4 and 4.
A PASSING MINT IS NOT THE PROOF, and the comment says so: release-train
add-to-kanban.yml records that "a mint can succeed with a scope the board
write then needs and lacks". The evidence is a card that actually moves,
which is why this lands alone rather than with the other three.
Verified: make lint clean, selftests 28 passed, actionlint clean.
Part of tracebloc/backend#2157
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 26, 2026
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

Correcting a claim I made in the original body before anyone acts on it.

I wrote that this PR's own card reaching Code review would exercise the newly-scoped mint. It does not. Every caller in the fleet pins the reusable workflow at @main:

tracebloc/.github/.github/workflows/set-pr-status.yml@main

so this card — and every card until the train promotes develop → staging → main — is written by main's unscoped copy. A green run here is therefore not evidence that the narrowed scope works.

The first real exercise is the first PR opened anywhere in the fleet after that promotion. If its card does not reach Code review, the scope is short and this is the thing to revert. Body updated to say so.

@LukasWodka
LukasWodka requested review from aptracebloc and removed request for saadqbalAugust 26, 2026 14:39
@LukasWodkaLukasWodka changed the title sec(2157): the board-writing mint asks for a board write, not everythingsec(mint-scope): the board-writing mint asks for a board write, not everythingAug 26, 2026

@aptraceblocaptracebloc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed (high effort — security scope change). Correct and safe. The mint now requests exactly pull-requests: read + organization-projects: write, which matches the set-status job's actual API calls — read the org ProjectV2 fields, read the PR's project items, write the Status field — and nothing more (no issues / contents / administration). organization-projects is the right ProjectsV2 permission and a strict subset of the canonical add-to-kanban scope (this even drops issues:read). And it's proven at runtime: the set-status / set-status job is green on this head, i.e. the board write actually ran under the narrowed token — not just a successful mint. The EXEMPT removal is required (both mints in the file are now scoped).

Approving.

Two non-blocking follow-ups — ironic in a "don't leave restated claims that drift" PR, so worth a quick fix (ideally here): the comment in set-pr-status.yml (~262–265) still calls the board-write mint "unscoped" and says this file "keeps its mint-scope exemption" — both now false; and mint-scope.py prose (~94, ~120–124) still says "six became four" and lists set-pr-status among the exempt four, but EXEMPT now holds three. The guard behavior is correct (dict-driven) — it's only the prose that drifted.

— drafted with Claude Code

@LukasWodka
LukasWodka requested review from aptracebloc and saadqbal and removed request for saadqbalAugust 26, 2026 15:48
@LukasWodka
LukasWodka merged commit 2017a1c into developAug 26, 2026
18 of 19 checks passed
@LukasWodka
LukasWodka deleted the sec/2157-scope-the-set-pr-status-mint branch August 26, 2026 16:20
LukasWodka added a commit that referenced this pull request Aug 26, 2026
backend#2157. `set-pr-status.yml` landed on develop (#349) and dropped its
own EXEMPT row; this branch drops `kanban-reconcile.yml`'s. Git saw one
line changed twice and called it a conflict -- both deletions are correct,
so the resolution keeps NEITHER row.
EXEMPT is now fr-gate + standards-sync, and the prose above it is updated
to say two rather than four. It already said four on develop while three
remained, so this fixes a drift it did not introduce -- in the commit that
would otherwise have made it twice wrong.
Verified: make lint exit 0 (mint-scope 2 unscoped / 2 exempted / 0
findings, "exemptions all still apply"), make selftests exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LukasWodka added a commit that referenced this pull request Aug 26, 2026
backend#2157. `set-pr-status.yml` landed on develop (#349) and dropped its
own EXEMPT row; this branch drops `standards-sync.yml`'s. Adjacent line
deletions, so git called it a conflict -- both are correct and the
resolution keeps NEITHER row.
EXEMPT is now fr-gate + kanban-reconcile, and the prose above it is updated
to say two rather than four. It already said four on develop while three
remained; this fixes that drift rather than adding a second wrong number.
Verified: make lint exit 0 (mint-scope 2 unscoped / 2 exempted / 0
findings, "exemptions all still apply"), make selftests exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LukasWodka added a commit that referenced this pull request Aug 27, 2026
backend#2157, LAST OF FOUR. With #349, #352 and #353 this empties EXEMPT.
Scoping this to the UNION would have kept fleet-wide contents:WRITE on every
scheduled report -- which is the mismatch, not the fix, since `report` mode
only reads. So the contents scope is conditional on the mode:
permission-contents: ${{ (dispatch && create-prs == true) && write || read }}
On a schedule run `inputs` is empty, the condition is false, and the same job
gets READ.
DERIVED FROM THE CALLS THIS TOKEN MAKES, checked one at a time:
git/ref/heads/develop, repos/{r}, contents/CLAUDE.md?ref= -> contents: read
PUT contents/CLAUDE.md, POST git/refs (create-prs only) -> contents: WRITE
gh pr list -> pull-requests: read
gh issue comment 1602 (the drift report) -> issues: WRITE
NOT pull-requests:write: `pr create` and both `pr edit` calls run on
SYNC_PR_AUTHOR_TOKEN, not on this token (backend#2590). The App only LISTS.
TWO CORRECTIONS TO WHAT I SAID EARLIER ON THE TICKET, both from reading the
file rather than the summary:
* The minting job is gated on `schedule || workflow_dispatch`, so it never
minted on push/pull_request/issues. The exposure was real but narrower
than "nearly every run".
* `== true` rather than a bare truthiness test, matching the CREATE_PRS line
below it. Equivalent for a `type: boolean` input -- but if it ever became
a string, `false` is TRUTHY and this would mint write on every scheduled
run while still looking conditional.
Verified: make lint clean, selftests 29 passed, actionlint clean, mint-scope
3 unscoped / 3 exempted / 0 findings on this branch (the other three are on
their own branches; together they reach 0 and 0).
Part of tracebloc/backend#2157 -- the LAST of four. Merge order is not
guaranteed, so this does not carry a closing keyword: if it landed first it
would close the ticket with three mints still unscoped. Close #2157 by hand
once #349, #352, #353 and this one are all in.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@aptracebloc