Round 1 BLOCKED (1 Blocker, 4 High, 3 Medium, 2 Low); round 2 BLOCKED on the fix (1 Blocker, 2 High, 4 Medium, 2 Low); round 3 BLOCKED on one Medium. Every finding re-verified against the live instance before acting. ROUND 2 — the blocker was self-inflicted and the local gate could not see it. Adding `branches: [main]` to ci-image.yml re-points `ci-image-pin`'s `expected` at the editing commit, staling all five `container:` pins and failing that BLOCKING job — for a change altering zero bytes of the toolchain image. Reproduced: expected=ed9dd6254 vs pins=32747a0. Reverted here (the commit was amended, so no commit on the branch touches that path) and filed as #744. That edit had also FALSIFIED its own justification: branch publishing IS load-bearing — docs/ci-cd.md documents the rebase-recovery flow as "let ci-image.yml publish :<short sha>, then bump the pin", which is how you satisfy ci-image-pin from inside a PR. Reverting also keeps three trigger descriptions true (ci-cd.md:1043, the recovery flow, pr-checks.yml's escape-hatch comment). Also fixed: - gate-trigger-base-resolved.md was the file round 1's fix did not touch, and still said "no workflow route retains human provenance" — false, since a PR-added workflow can reference RENOVATE_TOKEN. Its `rule:` also kept the race framing, and `rule:` is what the catalog and MemPalace mirror. - `mechanics:` claimed "independent review confirmed no CI consumption breaks". It confirmed no such thing. Round 3 then caught the REPLACEMENT sentence making the same class of error: only the `container:` pull is exercised by a PR, because `build` carries `if: github.event_name != 'pull_request'` and cache-to/cache-from live only there. Those and the base-image pull first run on the post-merge push to main — a wrong inference reddens main, not the PR. - A fourth surviving route was unnamed: docker-build.yml publishes :prod from a `v*` tag push and a tag may point at any commit (tag protections are empty). "three surviving routes" became "at least these" — a count reads as complete. - Unmarked inferences, a "three later sections" that undercounted four, a dangling "the two items below", and a #744 rationale that stated the pin toll without its documented remedy. Local gate: 432 script tests pass; `decisions_validate.py --base origin/main --head HEAD` and `build_decisions_catalog.py --check` both exit 0; ci-image-pin recomputed by hand and matching the pinned commit. The record is 62 prose lines against a 60-line ceiling that is a `::warning::` by design (#520) — the blocking constraint is the 2-25% minority band, currently 10.8%. Refs #697, #742, #743, #744. Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.4 KiB
key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
| key | title | status | since | supersedes | superseded-by | rule | signals | mechanics |
|---|---|---|---|---|---|---|---|---|
| ci.gate-trigger-base-resolved | 2026-07-28 — `review-verdict.yml` triggers on `pull_request_target` scoped to `branches: [main]`, so the PR under judgment cannot supply the gate's own definition (#672) | active | 2026-07-28 | none | none | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class: Gitea injects a write-capable `GITEA_TOKEN` into EVERY job, so any ref-resolved workflow — and a collaborator's own API token, since branch protection binds the context and not its issuer — can still forge `review-verdict/h10`. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (a user credential's forgery carries a real `creator` and is inherited as a human verdict; an Actions job's carries `creator: null` and is re-derived — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The injected token stays write-capable until Gitea >=1.26 with a Restricted default (server-management#714), and a collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. | workflow definition resolved from head, PR rewrites the gate that judges it, self-approve a required status check, pull_request_target vs pull_request, gate trigger branches filter, attacker-supplied base branch, how to test a change to review-verdict.yml, workflow not exercised by its own PR, gate edit goes live only on merge, required_approvals 0 does not bind an author, forged commit status inherited by sha · paths: `.gitea/workflows/review-verdict.yml`, `scripts/tests/test_pr_changed_files.py` · issues: #672, #663, #649, #622 | `on: pull_request_target: {branches: [main], types: [opened, reopened, synchronize, ready_for_review, edited]}` (`edited` added by `ci.exemption-provenance` so a retarget reclassifies); asserted by `test_the_workflow_trigger_is_pull_request_TARGET_scoped_to_main` in `scripts/tests/test_pr_changed_files.py`; the job's own context is renamed to `... (pull_request_target)` and must stay OUT of branch protection's required list |
ci.shared-pr-file-enumeration had this job check out the PR's base ref so the PR cannot supply
the scripts that judge it — real but partial, as that record said: it does not bind the job
definition. This closes that half.
What was actually wrong. Gitea, like GitHub, resolves a pull_request workflow definition from
the PR's own head, so a PR editing review-verdict.yml ran its own rewritten copy — which could
delete the base checkout or skip straight to posting review-verdict/h10=success for its head sha.
Two things that look preventive were not: PROTECTED is defined by the same rewritten file, and
branch protection requires the context, not an author, while carrying required_approvals: 0.
Measured, not inferred. The premise is a claim about someone else's software, so it was settled
on this instance (Gitea 1.25.4) with four scratch PRs, not by analogy to GitHub: pull_request ran
the head's rewrite and never wrote the real h10; pull_request_target ignored the identical rewrite
and the base definition posted h10=pending on opened and synchronize alike, secrets available;
branches: [main] produced no run at all from a non-main base; and the fourth — the negative one
establishing the residual below — is counted because omitting it turns an honest partial into an
overclaim. Probes posted only probe-named contexts, never a forged h10. Full results in #699.
Why branches: [main] is load-bearing rather than tidy. The base branch supplies the
definition, and anyone who can push a branch can make it a base — so dropping the filter trades a
head-supplied gate for a base-supplied one and closes nothing. Worse than lateral: a commit status is
repo-global per sha (#663), so a success forged against a scratch base is inherited by a later
genuine PR into main with the same head.
Why pull_request_target is not the footgun it usually is. Its standard danger is executing
untrusted head code with a privileged token; this job executes none, checking out base.sha with
persist-credentials: false and running only that tree's scripts. Trigger and checkout are one
decision — under this trigger a head checkout would be strictly worse than #672 was.
Options not taken. required_approvals: 1, the cheapest mechanical fix, is unusable here: Gitea
forbids approving your own PR and this is effectively a single-maintainer repo, so it deadlocks every
PR instead of gating the dangerous ones. Verifying the status author needs an actor the PR cannot
control, and the tampered workflow holds the same GITEA_TOKEN.
Severity, stated plainly. Never remotely exploitable — pushing a branch requires write access, so the threat model is a compromised contributor, who has other paths. Fixed because a gate whose authority the judged thing can assert is not a gate, not because an attack was expected.
The class is NOT closed, and this record must not be read as claiming otherwise. This fixed one
instance of "a ref-resolved workflow can obtain credentials that POST a commit status", and that
inventory is not a short list: Gitea injects GITEA_TOKEN into every job, defaulting to
read/write, so head-resolved, push-triggered and workflow_dispatch workflows alike are routes
(1.24+ loads a dispatched definition from the selected branch). A collaborator's own API token is a
route with no workflow at all — branch protection binds the context, not its issuer. Full inventory
in #697, whose credential half is resolved in ci.actions-credential-scoping — the registry secret
no longer carries status-write. That does NOT leave the workflow routes provenance-free: any
PR-added workflow can reference RENOVATE_TOKEN, a write:repository bot PAT in the same store,
whose status carries a real creator and IS inherited (#742). The exemption path's
own defects are #698. No in-repository test can establish
status-authority isolation: the sibling guard added here catches only plain-text naming of the
context.
The gate is no longer exercised by its own PR — base resolution cuts both ways, so an edit here
goes live only on merge, repo-wide, untested. Verify one safely per docs/ci-cd.md → Review-verdict gate.
Residual. The job's own context is renamed to ... (pull_request_target), safe only because it
was never one of branch protection's required contexts (the two docker-build.yml contexts plus
review-verdict/h10); adding it would let the workflow satisfy the gate by merely running. A trap
for #697: those two carry the literal (pull_request) suffix, so giving docker-build.yml the same
treatment renames them and deadlocks merges unless branch protection is edited in the same operation.
A non-main base now yields no status where it previously got one — fail-closed, removing a #663
hazard. The edited gap this section once recorded as a mere inconvenience ("statusless until its next
synchronize") was the persistence half of a live forgery; RESOLVED in ci.exemption-provenance (#698).