Finishes the 1.25.4-dated CI claim sweep #747 deliberately left incomplete (#869), and answers #893 from the Gitea v1.27.1 source instead of inferring it from a header. Docs and comments only - zero non-comment changes in scripts/ and .gitea/. Population derived with `git ls-files`, not from the issue's item list: 17 files, 43 occurrences of `1.25.4`, against the 4 items #869 named. Re-established on 1.27.1: the `creator`-attribution claim the H10 allow-list rests on (4 merged heads, both endpoints); the scope enum (no `status` scope); the `reqRepoWriter(unit.TypeCode)` gate; the `write:package` 403 (live probe with a read control 200 and a write control 201, throwaway repo, artifacts deleted); the absence of any REST cancel route (from source, which a 404 alone cannot establish); and `pull_request`/`pull_request_target` definition resolution. #893: `/statuses/{sha}` does NOT drop rows after pagination. `getCommitStatuses` appends unconditionally and its only filter is a SQL WHERE in the same query as the LIMIT/OFFSET, so an empty page really is the end, `page_statuses` terminating on its first empty page is safe, and the asymmetry with `count_pr_mutations` is correct - recorded with its reason and a date so it is not tidied away. Corrected rather than re-dated: the `--depth=1` no-merge-base claim was filed against the wrong axis (a git property, re-probed on git 2.55.0), and `enable_bypass_allowlist` postdating 1.25.4 had an issue body as its only provenance. Five cold review rounds plus a cross-family Codex pass. They caught a wrong MECHANISM for `creator: null` (it is `CreatorID == -2`, not `== 0`), an evidence count that straddled the upgrade, and a reason for not re-probing MCP `cancel_run` that was invented - all fixed, final verdict CLEAN. fixes #869 fixes #893 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Whe75djeAEuZpdNk6KU7No Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
16 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 `GITEA_TOKEN` into EVERY job and it USED to be write-capable everywhere, so any ref-resolved workflow could forge `review-verdict/h10`; since #748 (2026-08-27) all six workflows here declare `permissions:` — five `code: read`, and `review-verdict.yml`'s gate job `code: write`, deliberately, because it IS the gate (the five rest on the TOP-LEVEL form, which is INFERRED rather than probed — the gate job's JOB-LEVEL form is the one measured; see `ci.actions-credential-scoping`). That narrows the INJECTED token and only that: deliberately NO "only X can post a status" summary is stated here, because any such summary generalizes from the injected token, which `permissions:` scopes, to every credential a job can reach, which it does not touch — `RENOVATE_TOKEN` is in the same secret store and is referenced by `renovate.yml`, one of the five holding `code: read`. `ci-image.yml` was the last holdout — its unfiltered `push:` made it the worst-placed of the six — and #744 closed that trigger route (`ci.toolchain-image-publish-is-a-dispatch`) while #748 scoped its token. So the route is NARROWED, not closed, and the SHAPE is open — a newly added workflow declaring write, or omitting a declaration and inheriting the owner default, is a route again, which is why `test_pr_changed_files.py` asserts every tracked workflow declares a block. And a collaborator's own API token still can, since branch protection binds the context and not its issuer. 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 (an ALLOW-LISTED user credential's forgery carries a matching `creator` and is inherited as a verdict; an Actions job's carries `creator: null` and is re-derived, as since #742 is every account outside `H10_REVIEWERS` — 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 owner-level Restricted default (server-management#714) remains a separate OPEN lever — its effect on a declared `code: write` is unmeasured, and the version half of that condition has been satisfied since the 1.25.4 -> 1.27.1 upgrade on 2026-08-05, so it is not something to wait on a Gitea release for. 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.
The DEFINITION-RESOLUTION half re-established FROM THE v1.27.1 SOURCE, 2026-09-02 (#869) — the
mechanism behind the measurement, without spending four more scratch PRs. Be precise about which
half, because an earlier draft of this block overclaimed and cold review caught it: notify()
establishes only WHICH COMMIT each trigger reads its definition from. It says nothing about which
types produce a run, and nothing about secrets availability — the file's only other
pull_request_target reference is ifNeedApprovalWith, an APPROVAL gate, not a secrets gate.
What IS established on 1.27.1, stated no wider than it was measured: pull_request_target fires
for review-verdict.yml continuously — 210 such runs across 189 distinct head shas, counting only
runs started strictly after the 2026-08-05 upgrade (measured 2026-09-02). All 210 belong to this
workflow. The WINDOW is part of the claim: the all-time REPO-WIDE figure is 258 runs / 233 shas (this
workflow's own all-time count is 250 — the difference is 8 runs from three since-deleted probe
workflows, all pre-upgrade), which splits 44 before the upgrade date + 4 ON it + 210 after, so citing
258 here would import 1.25.4 evidence into a 1.27.1 sentence. The 4 same-day runs are excluded rather
than assigned — the upgrade landed mid-session and their 17:41-22:36 UTC start times cannot be
placed either side of it without the exact upgrade time, so they go to the side that weakens the
claim. Read those times as UTC deliberately: this endpoint returns started_at Z-suffixed, while
other Gitea timestamps render in the host zone, Europe/Brussels — +02:00 in August, +01:00 in
winter — so a UI comparison shows different clock times. What that
deliberately does NOT establish: WHICH types: fire, since a run count identifies no event type;
and whether the STORED secret store is reachable under this trigger, since the job references only the
injected secrets.GITEA_TOKEN. Both stay 1.25.4-dated.
Two earlier attempts at this paragraph are worth not repeating. The first re-dated BOTH halves —
over-retreating past what production already shows. The second over-corrected into a per-PR
completeness claim ("every PR merged since the upgrade carries this job's context"), which is false
and refuted by this record's own PR #863 observation — that PR merged carrying "no review-verdict
context of any kind", because a PR into a scratch base does not match branches: [main], the very
filter this record exists to explain. (Cited by content rather than by position: a "two paragraphs
down" pointer was wrong the one time it was written, and a positional reference breaks whenever
anything is inserted above it.) A blunt run count needs no per-PR census and cannot rot that way.
Now the mechanism itself. In services/actions/notifier_helper.go, one function resolves both. For a
PR event input.Ref resolves
to the PR's own HEAD ref — WithPullRequest sets input.Ref = git.RefName(pr.GetGitHeadRefName())
when the caller has not already supplied one; that ref's commit is passed to
DetectWorkflows, and every workflow found there is kept only if its trigger is NOT
pull_request_target (if wf.TriggerEvent.Name != actions_module.GithubEventPullRequestTarget).
Then, separately and only when input.PullRequest != nil, it resolves baseRef := git.BranchPrefix + input.PullRequest.BaseBranch, calls DetectWorkflows again on that commit, and from THAT set keeps
only workflows whose trigger is pull_request_target. So the two triggers read their definitions
from two SEPARATELY RESOLVED refs — head and base — and a head rewrite cannot reach a
pull_request_target definition at all. (Separately resolved, not necessarily different: nothing
forbids the two refs resolving to the same commit, which is simply the uninteresting case where the
rewrite is not a rewrite.)
The same lines make the branches: [main] filter's necessity concrete rather than cautionary: the
base is resolved by BRANCH NAME (BranchPrefix + BaseBranch), not by a pinned sha, so "the base
definition" is whatever that branch points at when the event fires. That is why a PR opened into an
attacker-pushed base branch would run that branch's gate, and it is the same re-resolution the
retarget fence exists to fence (ci.verdict-write-retarget-fence).
That third item was re-confirmed at the STATUS level on 1.27.1, 2026-08-28 (#747), as a by-product
of an unrelated probe (PR #863): a PR from a scratch head into a scratch base carried thirteen pull_request
contexts and no review-verdict context of any kind. The run list itself was not re-enumerated, so the
observation is also consistent with a run that started and posted nothing.
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, and it used to
default to read/write, so head-resolved, push-triggered and workflow_dispatch workflows alike
were routes (1.24+ loads a dispatched definition from the selected branch). Narrowed since #748
(2026-08-27): all six workflows here now declare permissions: explicitly — five code: read, and
review-verdict.yml's gate job code: write, deliberately, because it IS the gate. (The five rest
on the TOP-LEVEL declaration form, which is inferred rather than probed; the gate job's JOB-LEVEL
form is the one that was measured — see ci.actions-credential-scoping.) ci-image.yml was the last
holdout: its unfiltered push: trigger made it the worst-placed of the six, and #744 closed that
route (ci.toolchain-image-publish-is-a-dispatch) in the same week #748 scoped its token.
What that narrows is the INJECTED token, and only that. No one-line "only X can post a status"
summary belongs here: every such summary quietly generalizes from the injected token, which
permissions: scopes, to every credential a job can reach, which it does not touch. The falsifying
case is concrete and current — RENOVATE_TOKEN still writes statuses and is referenced by
renovate.yml, one of the five holding code: read. The SHAPE is open too: a new workflow that
declares write, or omits a declaration and inherits the owner default, is a route again, which is why
test_pr_changed_files.py asserts every tracked workflow declares a block. 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. Its
status carries a real creator, and until #742 that alone made it INHERITED as a verdict; the
provenance test is now membership in H10_REVIEWERS, so a renovate-posted verdict is re-derived
like an Actions-token one. The token still writes statuses — what it lost is durability, not the
ability to POST. 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).