From fcef63089657edecb1021aa5e834f4704376af43 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:47:00 +0200 Subject: [PATCH 1/2] sec(2157): scope the add-to-kanban App token to least privilege (#533) Brings this repo's copy to the fleet canonical form. `add-to-kanban.yml` is a byte-compared copy, so this is one pass over every repo rather than a fix here. WHAT CHANGES repositories: ${{ github.event.repository.name }} the two content reads stop being org-wide permission-issues: read add-to-project must RESOLVE permission-pull-requests: read the triggering node before permission-organization-projects: write it can add it permissions: {} the job needs no GITHUB_TOKEN Without any `permission-*` the mint carried the App's FULL installation grant -- contents+PR write across every installed repo -- and the App holds bypass_reviews on staging and prod fleet-wide, so the blast radius was merge-past-review rather than merely write. WHY THE FILE IS BYTE-IDENTICAL TO backend's. That copy is the one that survived review: saadqbal caught that `owner:` narrows nothing ("Input 'repositories' is not set. Creating token for all repositories owned by tracebloc"), and aptracebloc caught a run cited as evidence that was not one. Copying the corrected version rather than re-deriving it is the point of a byte-compared file. VERIFIED, not assumed: run 32255581084 on backend#2181's head exercised these exact scopes and landed the card (Status=Code review), which settles the one open question -- `repositories:` scoping does not clip `organization_projects`. Refs backend#2157. --- .github/workflows/add-to-kanban.yml | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.github/workflows/add-to-kanban.yml b/.github/workflows/add-to-kanban.yml index 07283e30..2a3a432b 100644 --- a/.github/workflows/add-to-kanban.yml +++ b/.github/workflows/add-to-kanban.yml @@ -9,6 +9,11 @@ on: jobs: add-to-project: runs-on: ubuntu-latest + # NO GITHUB_TOKEN AT ALL (saadqbal, #2181). Every call in this job authenticates + # as the App, so the workflow token needs nothing -- and an empty grant is the + # only version of that claim a reader can check. Free, and it means the least- + # privilege story covers both credentials in the job rather than just the loud one. + permissions: {} steps: # Board writes authenticate as the tracebloc-release-train App (backend#2036), # not a human's PAT. `owner:` yields an ORG-scoped installation token; a @@ -25,6 +30,60 @@ jobs: app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }} + # SCOPED TO THIS REPO, or the two reads below land org-wide (saadqbal, + # #2181). `owner:` alone does not narrow anything -- run 32239403796 says + # so in as many words: "Input 'repositories' is not set. Creating token for + # all repositories owned by tracebloc." A token calling itself + # least-privilege while carrying issue+PR read across all 19 installed + # repos is the claim this PR exists to stop making. + # + # `organization_projects` is an ORG-level permission and is not affected by + # repo scoping, so the board write should be unchanged -- but that is an + # assumption, and it is the same class of assumption that broke the first + # attempt, so the verification run is what settles it rather than this + # comment. If it is wrong the failure is LOUD (see below), which is what + # makes trying it cheap. + repositories: ${{ github.event.repository.name }} + # Least privilege (backend#2166): without any `permission-*` the token + # carries the App's FULL installation grant. actions/add-to-project needs + # THREE scopes, not one: it must RESOLVE the triggering issue/PR node + # before it can add it to the board, so it needs read on both content + # types (this workflow fires on `issues` and `pull_request`) in addition + # to the project write. Projects-write alone leaves the node unresolvable + # -- the add fails with "Could not resolve to a node with the global id". + # + # WHAT IS ACTUALLY DEMONSTRATED, and what is not. Stated narrowly because + # two earlier versions of this paragraph each overclaimed in a different + # direction, and this text is copied verbatim into 17 repos -- a wrong + # argument here is a wrong argument 17 times, in a byte-compared file + # nobody re-derives. + # + # DEMONSTRATED: a MISSING READ scope fails loudly. Run 32239403796 on this + # branch, at commit 218f0b13 (projects-write only), errored with + # `Could not resolve to a node with the global id` and the job went RED -- + # `add-to-project` routes GraphQL errors through `setFailed`. + # + # NOT DEMONSTRATED: the case the FIRST version of this comment described -- + # the token resolving the node fine and then 403ing the BOARD WRITE. No run + # has ever produced it. So "fails loudly" is proven for the read scopes and + # is an expectation, not a measurement, for the write. + # + # AND ONE RUN THAT LOOKED LIKE EVIDENCE IS NOT (aptracebloc). The previous + # wording cited run 32237283072 as a second scope failure. It is not one: + # it ran on `develop`, whose file at that sha passes NO `permission-*` at + # all (the App's full grant), and it failed on + # `Could not resolve to a node with the global id of I_kwDONNfQt88...` -- + # a node a fully-privileged token also cannot see, i.e. an issue that no + # longer exists (this workflow fires on `issues: transferred`). Run + # 32237067262, the SAME develop sha, succeeded 2m34s earlier. A dead node, + # not a permission. + # + # The proof this is right is therefore still a LANDED CARD, not a passing + # mint: a mint can succeed with a scope the board write then needs and + # lacks, and that is the one path nothing here has exercised. + permission-issues: read + permission-pull-requests: read + permission-organization-projects: write - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 with: From c28c120ee0532f6c276d2e6274a47ea646e79509 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:35:04 +0200 Subject: [PATCH 2/2] ci(2212): the fixtures drift check must fail when it cannot run (#536) * ci(2212): the fixtures drift check must fail when it cannot run `Backend fixtures drift check` is being armed as a required context (backend#2212). Its activation-phase fail-open has to go first: when BACKEND_CONTRACTS_TOKEN was unreadable the step printed a warning and exited 0, so a check that never executed reported as a passing one. Inert-not-red was the right call while the secret did not exist; the secret has existed since 2026-08-05, and once the context is required an exit-0-when-unable is strictly worse than an advisory guard, because it also looks solved (backend#2183). `cli` is PUBLIC, so the two reasons the token can be missing are different things and the step now splits three ways: token present -> run the check absent, fork PR -> FAIL. GitHub withholds repo secrets from forks by design, so the check genuinely cannot run. A maintainer verifies internal/api/testdata/*.json by hand and applies `skip-fixtures-drift` -- a permanent artifact on the PR, the same model as skip-fr-gate. Silently passing forks would fail open on exactly the contributions that deserve the most scrutiny. absent, same-repo -> FAIL. Rotated, removed or expired: a misconfiguration that used to read as a clean run. `types: [.., labeled, unlabeled]` added to the pull_request trigger, because without them the default opened/synchronize/reopened means applying the override label changes nothing until the next push -- the same defect Bugbot caught on version-bump-gate-caller.yml's skip-version-gate. Every ${{ }} goes through env:, none into the run: body. Mutation-proved, all five paths, by running the step body against a stubbed sync script: override label present exit 0 (OVERRIDDEN warning) token present exit 0 (real check ran) token absent, fork PR exit 1 (could not run) token absent, same-repo exit 1 (secret missing) token present, script reports drift exit 3 (exec propagates the status) The last one matters: `exec` replaces the shell, so a real drift failure still fails the step rather than being swallowed. Label `skip-fixtures-drift` created on this repo. Refs tracebloc/backend#2212 Co-Authored-By: Claude Opus 5 * fix(2212): Dependabot PRs are not forks, and would have been blocked I claimed in chat that this PR was unaffected by the Dependabot finding on averaging-service#367. Wrong, and this repo is the worse case of the two. Dependabot branches live in THIS repo, not a fork, so `github.event.pull_request.head.repo.fork` is FALSE on them -- verified on the real #530: head.repo.fork=false, head.repo.full_name=tracebloc/cli. Their runs still receive only Dependabot-scoped secrets, so BACKEND_CONTRACTS_TOKEN is empty. Under the previous commit that combination landed in the "absent, same-repo -> misconfiguration -> FAIL" branch, which would have blocked every Dependabot PR once the context is armed. Not theoretical: this repo has 4 Dependabot PRs, #530 is OPEN right now, and it currently reports `Backend fixtures drift check: success` -- the fail-open passing vacuously on a live PR today. So Dependabot gets a fourth branch, passing with a ::notice::. Safe for the same structural reason as averaging-service#367, via a different always-running guard: a dependency bump cannot alter internal/api/testdata/*.json, and if it did, internal/api/contracts_test.go replays every fixture through the real decode paths under the REQUIRED `Test` check with no token. Drift against the pinned backend ref is re-checked by the push run on develop/main, where Actions secrets are available. Mutation-proved, all five: Dependabot PR (fork=false, no token) exit 0 (notice: deferred) fork PR, no token exit 1 (could not run) human same-repo, no token exit 1 (secret missing) token present exit 0 (real check ran) override label exit 0 (OVERRIDDEN warning) PR_AUTHOR uses github.event.pull_request.user.login, not github.actor, so it stays correct across re-runs. Refs tracebloc/backend#2212 Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .github/workflows/backend-fixtures-drift.yml | 76 ++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/.github/workflows/backend-fixtures-drift.yml b/.github/workflows/backend-fixtures-drift.yml index 0ab93a36..4c5fc8eb 100644 --- a/.github/workflows/backend-fixtures-drift.yml +++ b/.github/workflows/backend-fixtures-drift.yml @@ -19,12 +19,24 @@ name: Backend fixtures drift # org Actions secret. Until that secret exists the job SKIPS with a warning # instead of failing: the gate is inert, not red (same activation model as # the public PII gate's denylist secret). +# +# THAT ACTIVATION PHASE IS OVER. The secret has existed since 2026-08-05 and +# this check is being armed as a required context (backend#2212), so the +# skip-with-a-warning path is now a fail-open: a *required* check that exits 0 +# when it cannot run is worse than an advisory one, because it also looks +# solved. It now FAILS instead -- see the step below for the three-way split +# and why a public repo needs an override label rather than a silent pass. on: push: branches: [develop, main] pull_request: branches: [develop, main] + # labeled/unlabeled so the skip-fixtures-drift override actually re-runs the + # gate. The default types are opened/synchronize/reopened, so without these + # applying the label would change nothing until the next push -- exactly the + # bug Bugbot caught on version-bump-gate-caller.yml's skip-version-gate. + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] workflow_dispatch: permissions: @@ -42,13 +54,69 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Three-way, because this repo is PUBLIC and the two reasons the token can + # be missing are not the same thing: + # + # token present -> run the check (the normal path) + # absent, Dependabot PR -> PASS with a ::notice::. Dependabot runs get + # DEPENDABOT-scoped secrets, never Actions + # secrets, so the token is empty on them + # however correctly it is set -- and unlike a + # fork, head.repo.fork is FALSE, so they would + # otherwise land in the misconfiguration branch + # below and block every security bump (Bugbot, + # averaging-service#367). Safe to defer here: + # a dependency bump cannot alter + # internal/api/testdata/*.json, and if it did, + # internal/api/contracts_test.go replays every + # fixture through the real decode paths under + # the REQUIRED `Test` check, with no token. + # Divergence from the pinned backend ref is + # re-checked by the push run on develop/main, + # where Actions secrets are available. Set the + # token as a Dependabot secret too and this + # branch stops being reached. + # absent, fork PR -> FAIL. GitHub does not expose repo secrets to + # forks by design, so the check genuinely + # cannot run. A maintainer verifies the + # fixtures by hand and applies + # `skip-fixtures-drift`, which is a permanent + # artifact on the PR -- the same model as + # skip-fr-gate. Passing forks silently would + # fail open on exactly the contributions that + # warrant the most scrutiny. + # absent, not a fork -> FAIL. The secret was removed, rotated or + # expired. That is a misconfiguration, and it + # used to read as a clean run. + # + # Every ${{ }} goes through env:, never into the run: body. - name: scripts/sync-backend-fixtures.sh --check env: BACKEND_CONTRACTS_TOKEN: ${{ secrets.BACKEND_CONTRACTS_TOKEN }} + IS_FORK: ${{ github.event.pull_request.head.repo.fork || false }} + # The PR author, not github.actor: stable across re-runs of the same PR. + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'skip-fixtures-drift') }} run: | - if [ -z "$BACKEND_CONTRACTS_TOKEN" ]; then - echo "::warning::BACKEND_CONTRACTS_TOKEN is not set — skipping the backend fixtures drift check." \ - "Add a read-only (Contents: read) token for tracebloc/backend as a repo/org Actions secret to activate this gate." + set -uo pipefail + + if [ "$OVERRIDE" = "true" ]; then + echo "::warning title=Backend fixtures drift OVERRIDDEN::The skip-fixtures-drift label is applied, so this gate did NOT verify internal/api/testdata/*.json against tracebloc/backend. Whoever applied the label is asserting they checked the fixtures by hand. The label stays on the PR as the record." exit 0 fi - ./scripts/sync-backend-fixtures.sh --check + + if [ -n "$BACKEND_CONTRACTS_TOKEN" ]; then + exec ./scripts/sync-backend-fixtures.sh --check + fi + + if [ "$PR_AUTHOR" = "dependabot[bot]" ]; then + echo "::notice title=Backend fixtures drift deferred::Dependabot runs receive Dependabot-scoped secrets, not Actions secrets, so BACKEND_CONTRACTS_TOKEN is empty here by design. A dependency bump cannot alter internal/api/testdata/*.json, and internal/api/contracts_test.go replays every fixture through the real decode paths under the required Test check without needing a token. Drift against the pinned backend ref is re-checked by the push run on develop/main. Set BACKEND_CONTRACTS_TOKEN as a Dependabot secret too for full coverage here." + exit 0 + fi + + if [ "$IS_FORK" = "true" ]; then + echo "::error title=Backend fixtures drift could not run::This PR is from a fork, and GitHub does not expose BACKEND_CONTRACTS_TOKEN to forks, so the vendored fixtures could not be checked against tracebloc/backend. A maintainer must verify internal/api/testdata/*.json by hand and apply the 'skip-fixtures-drift' label. Refusing to report green on a check that did not run (backend#2212)." + else + echo "::error title=BACKEND_CONTRACTS_TOKEN is missing::The secret is not readable on this run, so the backend fixtures drift check did not execute. It is expected on develop/main PRs from this repo -- if it was rotated or removed, restore a read-only (Contents: read) token for tracebloc/backend as a repo/org Actions secret. This step used to exit 0 here, which reported a check that never ran as a passing one (backend#2212)." + fi + exit 1