ETV_STATUS_AUTH can write commit statuses, so any head-resolved workflow can still forge review-verdict/h10 #697

Closed
opened 2026-07-28 21:37:18 +02:00 by timothy · 5 comments
Owner

Found while fixing #672, by probing rather than reasoning. #672 narrows the hole; this is the rest of it.

The finding

#672 switches review-verdict.yml to pull_request_target so a PR can no longer rewrite that workflow's definition. But the vulnerability class is "a head-resolved workflow holding credentials that can POST a commit status", and review-verdict.yml is not the only such workflow.

docker-build.yml triggers on pull_request — therefore head-resolved — and carries:

ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}

for scripts/ci-detect-already-validated.sh (#420), which only ever reads statuses. Those are Gitea basic-auth credentials, and basic auth is not scoped — the same account can write.

Confirmed empirically, not inferred

Scratch PR #696 replaced docker-build.yml with a minimal job that POSTed a probe-named context (review-verdict/h10-PROBE672D, never the real h10) using those credentials. It succeeded — the status appeared on the head sha. A real attacker changes one string and posts review-verdict/h10=success.

Why the #672 fix cannot be extended here

docker-build.yml must stay on pull_request: it builds and tests the PR's code, so it has to resolve from the head. pull_request_target would be actively dangerous there — it is precisely the "privileged token + untrusted head code" combination. So this needs a different fix.

Options

  1. Give ETV_STATUS_AUTH a read-only identity. The script only reads. A dedicated Gitea account/token without status-write closes it without touching any workflow logic. Note a token also needs packages:write kept separate from status-write, so this likely means splitting the registry credential from the status-read credential.
  2. Drop the #420 revalidation read and lose the cross-run tree-identity skip.
  3. Accept and document — same argument as #672: push access is already trust. Weaker here, because #672 was fixed rather than accepted, and leaving this open means the H10 gate's authority is still assertable.

Option 1 looks right and is mostly an infra/secrets change (server-management territory for the credential itself).

Severity

Same as #672: requires push access, so the threat model is a compromised contributor, not an anonymous attacker. Not remotely exploitable. Worth closing because the whole point of H10 is that merge-consent is derived, not assertable.

Done-when

  • Decide between the options above, with the cost stated
  • If fixed: demonstrate by re-running the #696-shaped probe and showing the status POST now fails
  • Audit every .gitea/workflows/*.yml for credentials that can write statuses, not just docker-build.yml — enumerate the full set rather than fixing the one instance found
  • ci.gate-trigger-base-resolved updated to record the resolved state (it currently names this as an open residual)
  • Adversarial review passed
Found while fixing #672, by probing rather than reasoning. **#672 narrows the hole; this is the rest of it.** ## The finding #672 switches `review-verdict.yml` to `pull_request_target` so a PR can no longer rewrite *that* workflow's definition. But the vulnerability class is "a head-resolved workflow holding credentials that can POST a commit status", and `review-verdict.yml` is not the only such workflow. `docker-build.yml` triggers on `pull_request` — therefore head-resolved — and carries: ```yaml ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }} ``` for `scripts/ci-detect-already-validated.sh` (#420), which only ever **reads** statuses. Those are Gitea basic-auth credentials, and basic auth is not scoped — the same account can write. ## Confirmed empirically, not inferred Scratch PR #696 replaced `docker-build.yml` with a minimal job that POSTed a **probe-named** context (`review-verdict/h10-PROBE672D`, never the real `h10`) using those credentials. It succeeded — the status appeared on the head sha. A real attacker changes one string and posts `review-verdict/h10=success`. ## Why the #672 fix cannot be extended here `docker-build.yml` **must** stay on `pull_request`: it builds and tests the PR's code, so it has to resolve from the head. `pull_request_target` would be actively dangerous there — it is precisely the "privileged token + untrusted head code" combination. So this needs a different fix. ## Options 1. **Give `ETV_STATUS_AUTH` a read-only identity.** The script only reads. A dedicated Gitea account/token without status-write closes it without touching any workflow logic. Note a token also needs `packages:write` kept separate from status-write, so this likely means splitting the registry credential from the status-read credential. 2. **Drop the #420 revalidation read** and lose the cross-run tree-identity skip. 3. **Accept and document** — same argument as #672: push access is already trust. Weaker here, because #672 was fixed rather than accepted, and leaving this open means the H10 gate's authority is still assertable. Option 1 looks right and is mostly an infra/secrets change (server-management territory for the credential itself). ## Severity Same as #672: requires push access, so the threat model is a compromised contributor, not an anonymous attacker. Not remotely exploitable. Worth closing because the whole point of H10 is that merge-consent is *derived*, not assertable. ## Done-when - [x] Decide between the options above, with the cost stated - [x] If fixed: demonstrate by re-running the #696-shaped probe and showing the status POST now fails - [x] Audit **every** `.gitea/workflows/*.yml` for credentials that can write statuses, not just `docker-build.yml` — enumerate the full set rather than fixing the one instance found - [x] `ci.gate-trigger-base-resolved` updated to record the resolved state (it currently names this as an open residual) - [x] Adversarial review passed
timothy added the ci-cdpriority: mediumsecurity labels 2026-07-28 21:37:18 +02:00
Author
Owner

Independent review of the #672 PR surfaced a second instance, so the "audit every workflow" box is not hypothetical — the enumeration already has two entries and I only found one by probing.

ci-image.yml — on: push with no branches: filter, path-scoped to .gitea/workflows/ci-image.yml itself, holding the same REGISTRY_USER/REGISTRY_PASSWORD. No PR is needed at all: pushing a feature branch that edits that file is enough to run attacker-supplied YAML with credentials that can write commit statuses.

Also worth noting for whoever takes this: a same-repo PR gets repo secrets, and Gitea injects secrets.GITEA_TOKEN per run — so adding a new .gitea/workflows/*.yml with on: pull_request is a route too, not just editing an existing one. That makes "audit the current set" necessary but not sufficient; the durable fix has to be about what credentials PR-triggered runs can obtain, not about which files exist today.

A trap for the obvious fix. Branch protection's required contexts are currently:

Build ErsatzTV Image / Build & test (.NET) (pull_request)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request)
review-verdict/h10

Two of those carry the literal (pull_request) suffix. Switching docker-build.yml to pull_request_target (which you should not do anyway — it builds PR code) would rename both contexts and deadlock every merge until branch protection is edited in the same operation.

Partial mitigation already landed in the #672 PR: a test asserting that no workflow other than review-verdict.yml references review-verdict/h10 in executable lines. That catches a second workflow openly writing the context; it does not catch one doing it through a variable or a script it calls, and its docstring says so. It is a drift guard, not a boundary.

Independent review of the #672 PR surfaced a **second instance**, so the "audit every workflow" box is not hypothetical — the enumeration already has two entries and I only found one by probing. **`ci-image.yml`** — `on: push` with **no** `branches:` filter, path-scoped to `.gitea/workflows/ci-image.yml` itself, holding the same `REGISTRY_USER`/`REGISTRY_PASSWORD`. No PR is needed at all: pushing a feature branch that edits that file is enough to run attacker-supplied YAML with credentials that can write commit statuses. Also worth noting for whoever takes this: a same-repo PR gets repo secrets, and Gitea injects `secrets.GITEA_TOKEN` per run — so **adding a new** `.gitea/workflows/*.yml` with `on: pull_request` is a route too, not just editing an existing one. That makes "audit the current set" necessary but not sufficient; the durable fix has to be about what credentials PR-triggered runs can obtain, not about which files exist today. **A trap for the obvious fix.** Branch protection's required contexts are currently: ``` Build ErsatzTV Image / Build & test (.NET) (pull_request) Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) review-verdict/h10 ``` Two of those carry the literal `(pull_request)` suffix. Switching `docker-build.yml` to `pull_request_target` (which you should not do anyway — it builds PR code) would **rename** both contexts and deadlock every merge until branch protection is edited in the same operation. **Partial mitigation already landed in the #672 PR:** a test asserting that no workflow other than `review-verdict.yml` references `review-verdict/h10` in executable lines. That catches a second workflow *openly* writing the context; it does not catch one doing it through a variable or a script it calls, and its docstring says so. It is a drift guard, not a boundary.
Author
Owner

Cross-family review of the #672 PR widens this considerably. The scope in the issue title is too narrow — this is not really about ETV_STATUS_AUTH.

Gitea injects ${{ secrets.GITEA_TOKEN }} into every job automatically, whether or not the workflow mentions it, and its default permission set is read/write for backwards compatibility (token-permissions). Our own review-verdict.yml proves that token can POST commit statuses here. So the credential inventory is not "the two workflows that reference registry creds" — it is every workflow, plus a handful with extra credentials on top:

Workflow Trigger Ref-resolution Extra credentials
pr-checks.yml pull_request head — (built-in token only)
docker-build.yml pull_request, workflow_dispatch, v* tags head / arbitrary ref / tag ETV_STATUS_AUTH, registry
ci-image.yml unfiltered push (path-scoped to itself) any branch registry
dependency-scan.yml workflow_dispatch selected ref —
renovate.yml workflow_dispatch selected ref Renovate PAT, registry

workflow_dispatch matters because Gitea 1.24+ loads the workflow definition from the selected branch, so it is another self-supplied-definition path, not just a manual trigger. And docker-build.yml is reachable via an attacker-created v* tag if tags aren't protected.

There is also a route with no workflow at all: a collaborator's own write-scoped API token can POST the context directly, because branch protection binds the required context, not its issuer.

Consequence for the fix. Option 1 in the issue body (read-only identity for ETV_STATUS_AUTH) is necessary but not sufficient — it leaves the built-in token untouched. The durable shape is:

  1. Make repository job tokens read-only (and audit whether anything actually needs write).
  2. Remove status-write-capable credentials from repo-wide Actions secrets.
  3. Move h10 posting to a credential that ref-resolved workflows cannot obtain, and/or enforce the expected issuer server-side.

Since branch protection cannot express "posted by X", 3 probably means a server-side check or an independent required approval rather than anything expressible in a workflow file.

Also honest: no in-repository test can establish status-authority isolation — the guard added in the #672 PR only catches a workflow that names the context in plain text. Related exemption-path defects split out into #698.

Cross-family review of the #672 PR widens this considerably. **The scope in the issue title is too narrow — this is not really about `ETV_STATUS_AUTH`.** Gitea injects `${{ secrets.GITEA_TOKEN }}` into **every job automatically**, whether or not the workflow mentions it, and its default permission set is read/**write** for backwards compatibility ([token-permissions](https://docs.gitea.com/usage/actions/token-permissions)). Our own `review-verdict.yml` proves that token can POST commit statuses here. So the credential inventory is not "the two workflows that reference registry creds" — it is **every workflow**, plus a handful with extra credentials on top: | Workflow | Trigger | Ref-resolution | Extra credentials | |---|---|---|---| | `pr-checks.yml` | `pull_request` | head | — (built-in token only) | | `docker-build.yml` | `pull_request`, `workflow_dispatch`, `v*` tags | head / arbitrary ref / tag | `ETV_STATUS_AUTH`, registry | | `ci-image.yml` | unfiltered `push` (path-scoped to itself) | any branch | registry | | `dependency-scan.yml` | `workflow_dispatch` | selected ref | — | | `renovate.yml` | `workflow_dispatch` | selected ref | Renovate PAT, registry | `workflow_dispatch` matters because Gitea 1.24+ loads the workflow definition from the **selected branch**, so it is another self-supplied-definition path, not just a manual trigger. And `docker-build.yml` is reachable via an attacker-created `v*` tag if tags aren't protected. There is also a route with no workflow at all: **a collaborator's own write-scoped API token can POST the context directly**, because branch protection binds the required *context*, not its issuer. **Consequence for the fix.** Option 1 in the issue body (read-only identity for `ETV_STATUS_AUTH`) is necessary but **not sufficient** — it leaves the built-in token untouched. The durable shape is: 1. Make repository job tokens read-only (and audit whether anything actually needs write). 2. Remove status-write-capable credentials from repo-wide Actions secrets. 3. Move `h10` posting to a credential that ref-resolved workflows cannot obtain, and/or enforce the expected issuer server-side. Since branch protection cannot express "posted by X", 3 probably means a server-side check or an independent required approval rather than anything expressible in a workflow file. **Also honest:** no in-repository test can establish status-authority isolation — the guard added in the #672 PR only catches a workflow that names the context in plain text. Related exemption-path defects split out into #698.
timothy added the in-progress label 2026-08-05 20:14:55 +02:00
Author
Owner

Claiming this (Claude Code session, 2026-08-05). Starting from the two review comments above: treating the scope as the widened one (every ref-resolved workflow + the injected GITEA_TOKEN), not the ETV_STATUS_AUTH-only framing in the title.

Claiming this (Claude Code session, 2026-08-05). Starting from the two review comments above: treating the scope as the widened one (every ref-resolved workflow + the injected `GITEA_TOKEN`), not the `ETV_STATUS_AUTH`-only framing in the title.
Author
Owner

Audit deliverable (Done-when box 3): the enumeration, and why the unit is wrong

The box asks to audit every workflow for status-write-capable credentials. Doing that surfaced that the workflow set is the wrong unit of enumeration: Actions secrets are a per-repo store, and a same-repo PR can add a workflow that references any secret in it. Auditing today's six files is therefore necessary but structurally insufficient — which the second review comment above had already predicted.

By workflow (verified 2026-08-05, Gitea 1.25.4)

Workflow Trigger Definition resolved from Extra credentials permissions:
pr-checks.yml pull_request PR head none absent
docker-build.yml pull_request, push (main + v*), workflow_dispatch head / pushed branch / tag / selected ref ETV_STATUS_AUTH, registry absent
ci-image.yml push (no branch filter, path-scoped to itself), workflow_dispatch, schedule any pushed branch registry absent
dependency-scan.yml workflow_dispatch, schedule selected ref / main none absent
renovate.yml workflow_dispatch, schedule selected ref / main RENOVATE_TOKEN, GH_COM_TOKEN, registry absent
review-verdict.yml pull_request_target (branches: [main]) PR base GITEA_TOKEN absent

No workflow declares permissions: — and that is correct, because the key is a no-op on 1.25.4. It landed in Gitea 1.26.0 (PR #36173); there is no parsing or enforcement of it in services/actions/ at tag v1.25.4. Adding it today would be false comfort: inert, while reading in review as a constraint.

By secret (the unit that actually matters)

Secret Can POST a commit status? Provenance of a forgery
REGISTRY_USER/REGISTRY_PASSWORD was YES (admin basic auth) — now NO was creator: timothy, i.e. durable
RENOVATE_TOKEN YES — write:repository bot PAT real creator → durable
GH_COM_TOKEN no (github.com, no scopes) n/a
SERVERMGMT_DEPLOY_KEY no — but see below n/a
injected GITEA_TOKEN YES (every job, read/write by default) creator: null → re-derived, must win a race

Two findings not in the issue

  1. RENOVATE_TOKEN is a surviving DURABLE route. It is write:repository on a real bot account, so a status posted with it carries a non-null .creator.login and is inherited as a human verdict by review-verdict.yml's provenance check. It cannot be scoped down — Renovate needs repo write to push branches and open PRs. Closing this needs the provenance check tightened from "non-null creator" to an allow-list of approved reviewers; deliberately not done in this PR, because an edit to the gate goes live only on merge, repo-wide and untested.
  2. SERVERMGMT_DEPLOY_KEY is an orphan. The bump-prod-compose job that used it was removed in 1b5efd7b9; the secret was never deleted. It is an SSH deploy key to another repo, obtainable by any PR-added workflow, with no remaining benefit. Enumerating workflows would never have found it — enumerating the store did.

Severity, unchanged

All of this requires push access, so the threat model remains a compromised contributor, not an anonymous attacker. What the audit did change is the blast radius: REGISTRY_PASSWORD was the admin account's basic auth, unscoped, handed to head-resolved PR code — so the exposure was the whole Gitea instance (every repo on it), not merely this repo's merge gate.

## Audit deliverable (Done-when box 3): the enumeration, and why the unit is wrong The box asks to audit **every workflow** for status-write-capable credentials. Doing that surfaced that the workflow set is the **wrong unit of enumeration**: Actions secrets are a per-repo **store**, and a same-repo PR can *add* a workflow that references any secret in it. Auditing today's six files is therefore necessary but structurally insufficient — which the second review comment above had already predicted. ### By workflow (verified 2026-08-05, Gitea 1.25.4) | Workflow | Trigger | Definition resolved from | Extra credentials | `permissions:` | |---|---|---|---|---| | `pr-checks.yml` | `pull_request` | PR head | none | absent | | `docker-build.yml` | `pull_request`, `push` (main + `v*`), `workflow_dispatch` | head / pushed branch / tag / selected ref | `ETV_STATUS_AUTH`, registry | absent | | `ci-image.yml` | `push` (**no branch filter**, path-scoped to itself), `workflow_dispatch`, `schedule` | any pushed branch | registry | absent | | `dependency-scan.yml` | `workflow_dispatch`, `schedule` | selected ref / main | none | absent | | `renovate.yml` | `workflow_dispatch`, `schedule` | selected ref / main | `RENOVATE_TOKEN`, `GH_COM_TOKEN`, registry | absent | | `review-verdict.yml` | `pull_request_target` (`branches: [main]`) | PR **base** | `GITEA_TOKEN` | absent | No workflow declares `permissions:` — and that is correct, because **the key is a no-op on 1.25.4**. It landed in Gitea **1.26.0** (PR #36173); there is no parsing or enforcement of it in `services/actions/` at tag `v1.25.4`. Adding it today would be *false comfort*: inert, while reading in review as a constraint. ### By secret (the unit that actually matters) | Secret | Can POST a commit status? | Provenance of a forgery | |---|---|---| | `REGISTRY_USER`/`REGISTRY_PASSWORD` | **was YES** (admin basic auth) — **now NO** | was `creator: timothy`, i.e. **durable** | | `RENOVATE_TOKEN` | **YES** — `write:repository` bot PAT | real creator → **durable** | | `GH_COM_TOKEN` | no (github.com, no scopes) | n/a | | `SERVERMGMT_DEPLOY_KEY` | no — but see below | n/a | | injected `GITEA_TOKEN` | **YES** (every job, read/write by default) | `creator: null` → re-derived, must win a race | ### Two findings not in the issue 1. **`RENOVATE_TOKEN` is a surviving DURABLE route.** It is `write:repository` on a real bot account, so a status posted with it carries a non-null `.creator.login` and is **inherited as a human verdict** by `review-verdict.yml`'s provenance check. It cannot be scoped down — Renovate needs repo write to push branches and open PRs. Closing this needs the provenance check tightened from "non-null creator" to an **allow-list of approved reviewers**; deliberately not done in this PR, because an edit to the gate goes live only on merge, repo-wide and untested. 2. **`SERVERMGMT_DEPLOY_KEY` is an orphan.** The `bump-prod-compose` job that used it was removed in `1b5efd7b9`; the secret was never deleted. It is an SSH deploy key to **another repo**, obtainable by any PR-added workflow, with no remaining benefit. Enumerating workflows would never have found it — enumerating the store did. ### Severity, unchanged All of this requires push access, so the threat model remains a compromised contributor, not an anonymous attacker. What the audit *did* change is the blast radius: `REGISTRY_PASSWORD` was the **admin account's** basic auth, unscoped, handed to head-resolved PR code — so the exposure was the whole Gitea instance (every repo on it), not merely this repo's merge gate.
Author
Owner

Closing record

Outcome: Fixed and merged via PR #745. REGISTRY_PASSWORD is no longer the admin account's basic auth but a PAT scoped write:package + read:repository. The issue's own framing was too narrow twice over, and correcting it produced three follow-ups that matter more than the fix.

Root cause: docker-build.yml triggers on pull_request — head-resolved — while carrying ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}. Those were the admin account's credentials, and basic auth carries no scope. So a PR's own code received instance-admin access: not merely the ability to forge review-verdict/h10, but administrative control of every repo on the instance. Gitea has no status token scope (POST /statuses is gated by reqRepoWriter(unit.TypeCode)), so status-write cannot be separated from repo-write — but write:package is a separate scope, and ci-detect-already-validated.sh only ever GETs. Hence a like-for-like credential that cannot post a status.

Decisions/conventions changed: Added ci.actions-credential-scoping. Corrected ci.gate-trigger-base-resolved, release.verdict-status-check and ci.exemption-provenance, all of which named #697 as open; two also overstated what the fix closes.

Reusable knowledge:

  • Audit the secret STORE, not the workflow set — and really "everything reachable from a job". Any PR-added workflow can reference any repo secret, so enumerating today's workflow files is structurally insufficient. This is what found both RENOVATE_TOKEN and the orphaned SERVERMGMT_DEPLOY_KEY; neither was reachable by the audit the issue asked for.
  • Provenance decides durability. A status POSTed with a user credential carries a real .creator.login and is INHERITED as a human verdict; one POSTed by an Actions job carries creator: null and is re-derived. But re-derivation is not protection: it 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.
  • The 60-line decision-record ceiling is a ::warning::, not a cap (#520; #688 v5 split the test so marks_tail is pinned against synthetic data). The blocking constraint is a 2–25% minority band, currently 10.8% with ~38 records of slack. Do not compress prose to satisfy it.
  • Editing .gitea/workflows/ci-image.yml re-points ci-image-pin's expected at the editing commit, staling all five container: pins and failing a BLOCKING job — for a change altering zero bytes of the image. A working-tree revert is not enough: git log -1 -- <path> reads history, so the commit must be amended.

Verification: Registry push of a probe tag SUCCEEDED (deleted, confirmed 404); GET /commits/{sha}/status 200; POST /statuses/{sha} 403 required=[write:repository], token scope=write:package,read:repository — the #696-shaped probe now refused, which is Done-when box 2. An independent review separately confirmed no CI consumption breaks (write implies read per category). Locally: 432 script tests, decisions validator and catalog check all green. Three cold adversarial review rounds: BLOCKED (1B/4H/3M/2L) → BLOCKED (1B/2H/4M/2L) → BLOCKED (1M) → MERGEABLE.

Deferred:

  • #743 (priority: high) — direct pushes to main are server-side permitted (enable_push: true, empty whitelist), so the gate is bypassable with no forgery at all. This is larger than #697 and corrects its premise: a push whitelist closes more of this class than the Gitea 1.26 upgrade does.
  • #742 — tighten the provenance check from "non-null creator" to an allow-list, closing the RENOVATE_TOKEN route.
  • #744 — ci-image.yml's unfiltered push trigger; attempted here and reverted (see above), with the false "branch publishing is not load-bearing" justification recorded.
  • server-management#714 — Gitea >=1.26 + Actions default set to Restricted, the only lever that binds the injected GITEA_TOKEN.
  • SERVERMGMT_DEPLOY_KEY left in place by explicit decision, with the fact that its key on server-management is read_only: false — write access to the repo holding prod's GitOps stack definitions — recorded so it is accepted rather than forgotten.

Docs updated: docs/ci-cd.md; docs/decisions/records/ci/actions-credential-scoping.md (new); docs/decisions/records/ci/gate-trigger-base-resolved.md; docs/decisions/records/ci/exemption-provenance.md; docs/decisions/records/release/verdict-status-check.md; docs/decisions/README.md (regenerated). Also two comments in .gitea/workflows/review-verdict.yml and two docstrings in scripts/tests/test_pr_changed_files.py.

Honest note on severity: unchanged throughout — push access is required, so the threat model is a compromised contributor or a subverted automated session, never an anonymous attacker. review-verdict/h10 is a process guard against mistakes and drift, not a security boundary against push access. This PR closes the admin escalation; it does not make the gate unforgeable, and the record says so in four places rather than claiming otherwise.

## Closing record **Outcome:** Fixed and merged via PR #745. `REGISTRY_PASSWORD` is no longer the admin account's basic auth but a PAT scoped `write:package` + `read:repository`. The issue's own framing was too narrow twice over, and correcting it produced three follow-ups that matter more than the fix. **Root cause:** `docker-build.yml` triggers on `pull_request` — head-resolved — while carrying `ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}`. Those were the **admin account's** credentials, and basic auth carries no scope. So a PR's own code received instance-admin access: not merely the ability to forge `review-verdict/h10`, but administrative control of every repo on the instance. Gitea has no `status` token scope (`POST /statuses` is gated by `reqRepoWriter(unit.TypeCode)`), so status-write cannot be separated from repo-write — but `write:package` **is** a separate scope, and `ci-detect-already-validated.sh` only ever GETs. Hence a like-for-like credential that cannot post a status. **Decisions/conventions changed:** Added `ci.actions-credential-scoping`. Corrected `ci.gate-trigger-base-resolved`, `release.verdict-status-check` and `ci.exemption-provenance`, all of which named #697 as open; two also overstated what the fix closes. **Reusable knowledge:** - **Audit the secret STORE, not the workflow set** — and really "everything reachable from a job". Any PR-added workflow can reference any repo secret, so enumerating today's workflow files is structurally insufficient. This is what found both `RENOVATE_TOKEN` and the orphaned `SERVERMGMT_DEPLOY_KEY`; neither was reachable by the audit the issue asked for. - **Provenance decides durability.** A status POSTed with a *user* credential carries a real `.creator.login` and is INHERITED as a human verdict; one POSTed by an Actions job carries `creator: null` and is re-derived. But re-derivation is **not** protection: it 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. - **The 60-line decision-record ceiling is a `::warning::`, not a cap** (#520; #688 v5 split the test so `marks_tail` is pinned against synthetic data). The blocking constraint is a 2–25% minority band, currently 10.8% with ~38 records of slack. Do not compress prose to satisfy it. - **Editing `.gitea/workflows/ci-image.yml` re-points `ci-image-pin`'s `expected` at the editing commit**, staling all five `container:` pins and failing a BLOCKING job — for a change altering zero bytes of the image. A working-tree revert is not enough: `git log -1 -- <path>` reads history, so the commit must be amended. **Verification:** Registry push of a probe tag SUCCEEDED (deleted, confirmed 404); `GET /commits/{sha}/status` 200; `POST /statuses/{sha}` **403 `required=[write:repository], token scope=write:package,read:repository`** — the #696-shaped probe now refused, which is Done-when box 2. An independent review separately confirmed no CI consumption breaks (write implies read per category). Locally: 432 script tests, decisions validator and catalog check all green. Three cold adversarial review rounds: BLOCKED (1B/4H/3M/2L) → BLOCKED (1B/2H/4M/2L) → BLOCKED (1M) → MERGEABLE. **Deferred:** - **#743** (`priority: high`) — direct pushes to `main` are server-side permitted (`enable_push: true`, empty whitelist), so the gate is bypassable with **no forgery at all**. This is larger than #697 and corrects its premise: a push whitelist closes more of this class than the Gitea 1.26 upgrade does. - **#742** — tighten the provenance check from "non-null creator" to an allow-list, closing the `RENOVATE_TOKEN` route. - **#744** — `ci-image.yml`'s unfiltered push trigger; attempted here and reverted (see above), with the false "branch publishing is not load-bearing" justification recorded. - **server-management#714** — Gitea >=1.26 + Actions default set to Restricted, the only lever that binds the injected `GITEA_TOKEN`. - **`SERVERMGMT_DEPLOY_KEY` left in place by explicit decision**, with the fact that its key on server-management is `read_only: false` — write access to the repo holding prod's GitOps stack definitions — recorded so it is accepted rather than forgotten. **Docs updated:** `docs/ci-cd.md`; `docs/decisions/records/ci/actions-credential-scoping.md` (new); `docs/decisions/records/ci/gate-trigger-base-resolved.md`; `docs/decisions/records/ci/exemption-provenance.md`; `docs/decisions/records/release/verdict-status-check.md`; `docs/decisions/README.md` (regenerated). Also two comments in `.gitea/workflows/review-verdict.yml` and two docstrings in `scripts/tests/test_pr_changed_files.py`. **Honest note on severity:** unchanged throughout — push access is required, so the threat model is a compromised contributor or a subverted automated session, never an anonymous attacker. `review-verdict/h10` is a process guard against mistakes and drift, **not** a security boundary against push access. This PR closes the admin escalation; it does not make the gate unforgeable, and the record says so in four places rather than claiming otherwise.
timothy removed the in-progress label 2026-08-05 21:57:21 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: timothy/ersatztv#697