From 61ed6a7955f28f4704e75ca3d69a0f9b57a27711 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 5 Sep 2026 14:59:23 +0200 Subject: [PATCH] test(885): exercise REGISTRY_PASSWORD at both if: levels, re-confirm the layer measurement Round-nine's bare-if fix table paired each secret name with only one level (job for REGISTRY_PASSWORD, step for RENOVATE_TOKEN), so the exact REGISTRY_PASSWORD-at-step-level and RENOVATE_TOKEN-at-job-level cases the finding named were never driven. All four combinations now run. The decision record's anonymous-layer-download closure read as reporting a past run without saying who ran it. Re-measured directly this session (2026-09-05, no stored credential): anonymous token -> pinned manifest's first layer -> 200/32991280 bytes, same GET with no token -> 401. Record updated to say the leg was re-confirmed, not merely "measured...since". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV --- .../records/ci/pr-route-carries-no-stored-credential.md | 2 +- scripts/tests/test_workflow_persist_credentials.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md index badb58d2e..068fb1bb1 100644 --- a/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md +++ b/docs/decisions/records/ci/pr-route-carries-no-stored-credential.md @@ -7,7 +7,7 @@ supersedes: none superseded-by: none rule: 'Gitea resolves a `pull_request` run from the PR HEAD, so `docker-build.yml` is contributor-authored on that route and every `secrets.*` it names is materialised into the run. NO PART of a `pull_request`-triggered workflow may name a stored secret — neither a job the trigger reaches NOR the WORKFLOW SCOPE outside `jobs:`, because a root `env:` or `defaults:` is materialised into EVERY job and no job-level `if:` can take it off the route. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way (`toolchain-preflight`, `test`, `migrations`, `functional-e2e`, `api-docs`, `format`), two of them branch-protection required contexts; all six now work without it. The five `container:` blocks pull the toolchain image with no `credentials:`, `scripts/ci-toolchain-image-resolves.sh` reads the registry through the anonymous Bearer-token flow, and `scripts/ci-detect-already-validated.sh` GETs the combined commit status unauthenticated. `REGISTRY_PASSWORD` survives in `build` alone, which carries `if: github.event_name != ''pull_request''`. A DEDICATED read-only pull PAT was costed and REJECTED: measured 2026-09-04 against this instance, an unauthenticated client already obtains a pull token for `timothy/ersatztv-ci` (`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200) and reads the pinned manifest and its config blob with it (both 200), and the combined-status GET answers 200 with no credential — so a read-only PAT would grant exactly what anonymity already grants while ADDING a credential to the store, which is the store head-supplied YAML reaches. The invariant is DERIVED, never listed, by `scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret`: the workflow population comes from the git index, a job is in scope unless its `if:` is the one pinned exclusion string, and the workflow scope (everything outside `jobs:`) is walked by the same structure-blind collector. The whole-document cross-check compares occurrence COUNTS, not name sets — measured 2026-09-05, a name-set comparison was blind to a workflow-scope hoist of names `build` already carries, since a set cannot see a SECOND copy of a reference. The detector matches EVERY spelling of a `secrets` reference the expression grammar admits, not only `secrets.NAME`, and reads EVERY one of them only inside an EXPRESSION, because outside one `secrets.` is a full stop and not a context — which means a `${{ }}` span anywhere, AND the WHOLE VALUE of an `if:`, the one key the grammar lets omit the delimiters in (`docker-build.yml`''s own `build` job carries `if: github.event_name != ''pull_request''` unwrapped, and this guard pins that exact bare string): the index forms `secrets[''NAME'']` and `secrets["NAME"]`, a case-varied context (`Secrets.NAME`), and — inside a `${{ }}` span — any `secrets` token that resolves to no literal name at all (`toJSON(secrets)`, a COMPUTED index), which is reported under the sentinel name `secrets.*`. Measured 2026-09-05 against the dot-only predecessor, a PR-route job whose `env:` read `${{ secrets[''REGISTRY_PASSWORD''] }}` produced NO fault and NO cross-check disagreement: both halves of the cross-check read through the one `secret_refs` function, so a spelling it does not know is a SHARED blind spot they agree at zero on rather than a disagreement (`proof-sharing-with-subject-proves-nothing`). Measured 2026-09-05 against the span-scoped predecessor, an unwrapped `if: secrets.REGISTRY_PASSWORD != ''''` on a PR-route job produced NO fault either, job-level or step-level — the scoping is right about prose and wrong about that one key, so an `if:` value is read WHOLE, with the delimiters neutralised so a wrapped condition still counts once. A handover written in the YAML GRAMMAR rather than the expression grammar is judged by a separate clause, because no expression exists for the reference collector to read: a `secrets:` KEY whose value is not a mapping of names — `jobs..secrets: inherit` on a `uses:` job, which passes the WHOLE store to the called workflow while naming nothing — faults under the same `secrets.*` sentinel. The test is on the VALUE SHAPE and not on the word `inherit`, for the reason the residue counter is not a match on `toJSON`: any value that is not a mapping hands over a set this guard cannot enumerate, including a spelling act_runner grows later. Measured 2026-09-05 against the predecessor of that clause, such a job was put in the population, walked, and reported CLEAN, while the mapping spelling `secrets: {TOK: "${{ secrets.RENOVATE_TOKEN }}"}` produced one correctly-named fault. That clause reads the DOCUMENT only, so the text cross-check does not cover it and cannot: there is nothing in the text for its half to match. Do NOT re-derive it as "every `container:` job" — that predicate names FIVE of the six, because `toolchain-preflight` is deliberately container-free and took the credential through a step `env:`. `secrets.GITEA_TOKEN` is allow-listed because it is INJECTED per run and not stored — NOT because it is bounded: the workflow''s own `permissions:` narrows it for the COMMITTED file only, and on this route the head supplies that file and can delete the block, so the injected token stays in the residual set `review-verdict.yml` enumerates: it is not in the repo secret store (read 2026-09-04: `GH_COM_TOKEN`, `REGISTRY_PASSWORD`, `REGISTRY_USER`, `RENOVATE_TOKEN`, `SERVERMGMT_DEPLOY_KEY`), and a head-authored run receives it whether or not any job names it. `pull_request_target` is deliberately OUT of the population: that trigger is base-resolved (`ci.gate-trigger-base-resolved`), which is exactly why `review-verdict.yml` uses it to hold a write-capable token. THIS DOES NOT CLOSE THE ROUTE and must not be cited as if it did — `REGISTRY_PASSWORD` is still in the store, and head-supplied YAML can still name it, `RENOVATE_TOKEN` or `SERVERMGMT_DEPLOY_KEY` in a job of its own. Bounding the store needs per-environment secret scoping, which Gitea 1.27.1 does not have (probed in #853). What is removed is the ROUTINE materialisation of a write-capable credential into six PR-run environments. Anonymity has TWO dependencies, on two different objects, with OPPOSITE failure directions, and they must not be stated as one: the `ersatztv-ci` package is linked to no repository (measured 2026-09-05: every version reports "repository": null), so this repo''s visibility is not what gates the pull token. The registry reads need that PACKAGE to stay anonymously pullable and losing it is LOUD — every container job dies at image pull, before it runs a step, both required contexts among them, and the preflight names the cause in its own 401/403 message. The commit-status GETs need `timothy/ersatztv` itself to stay publicly readable and losing that is SILENT and safe — `curl -sf` fails, `ci-detect-already-validated.sh` emits `skip=false`, the jobs stay GREEN, and only the #420 cross-run skip stops firing, at the cost of a redundant re-validation and never a skip that was not earned.' signals: 'pull_request runs head-supplied YAML, REGISTRY_PASSWORD on the PR route, container credentials removed, anonymous registry pull, Bearer token realm challenge, read-only pull PAT rejected, ETV_REGISTRY_AUTH removed, ETV_STATUS_AUTH removed, anonymous commit status read, derived PR-route job population, workflow-scope env is on the route, cross-check counts occurrences not names, every secrets spelling matched not just the dot form, an unwrapped if condition is an expression, indexed secrets reference, whole-context secrets reference, secrets inherit on a reusable-workflow job, GITEA_TOKEN is injected not stored, pull_request_target is base-resolved, public package dependency · paths: `.gitea/workflows/docker-build.yml`, `scripts/ci-toolchain-image-resolves.sh`, `scripts/ci-detect-already-validated.sh`, `scripts/tests/test_workflow_persist_credentials.py` · issues: #885, #853, #697, #742, #772, #420' -mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. That run exercised neither an anonymous LAYER download (those layers were already on that host) nor act_runner''s own pull call path rather than the CLI''s. The layer leg has since been measured directly, 2026-09-05 from a workstation holding no registry credential: the anonymous pull token reads the pinned manifest''s first layer `sha256:179c68a720750ab4d…` -> 200, 32991280 bytes, while the same GET with NO token -> 401 — so the blob path is anonymous THROUGH the token challenge and not merely open. act_runner''s call path is the one leg still unexercised, and it is first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' +mechanics: 'Measured 2026-09-04 against Gitea 1.27.1 at `192.168.1.95:3000`, repo `private: false`. A naive unauthenticated `GET /v2/timothy/ersatztv-ci/manifests/32747a0` answers `401` with `Www-Authenticate: Bearer realm="http://192.168.1.95:3000/v2/token",service="container_registry",scope="*"` — which is why the old script demanded a credential. Following that challenge: `GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` (with or without `&service=container_registry`) -> 200 + token; that bearer reads the manifest -> 200, and its config blob `sha256:bbfaa80b…` -> 200; a tag that does not exist -> 404, NOT 401, so the deleted-tag diagnosis survives. `GET /api/v1/repos/timothy/ersatztv/commits//status` unauthenticated -> 200 carrying `state`. End-to-end: the shipped preflight run with `ETV_REGISTRY_AUTH` unset reports `resolves (HTTP 200, manifest present)`, and against a fabricated pin reports `IS GONE`; the shipped `#420` detector run at merge commit `efadbec29` in a throwaway clone reports `merged tree == green PR head a7d91bf15 (status=success) -> skipping re-validation`. The DAEMON leg was measured 2026-09-05 on the runner host `192.168.1.99`, which runs both `act_runner` containers and creates every `GITEA-ACTIONS-TASK-*` job container on its own docker socket: `docker --config DIR pull 192.168.1.95:3000/timothy/ersatztv-ci:32747a0`, with DIR a scratch directory holding nothing but a `config.json` of `{}` (no stored auth, no credential helper), exits 0 reporting `Digest: sha256:a93ee792c25e…` and `Status: Image is up to date`, so the daemon that creates job containers resolves this reference with no stored auth. That run exercised neither an anonymous LAYER download (those layers were already on that host) nor act_runner''s own pull call path rather than the CLI''s. The layer leg has since been measured directly and re-confirmed 2026-09-05 from a workstation holding no registry credential: `GET /v2/token?service=container_registry&scope=repository:timothy/ersatztv-ci:pull` -> 200 + token, that bearer against the pinned manifest''s first layer `sha256:179c68a720750ab4d…` -> 200, 32991280 bytes, while the same GET with NO token -> 401 — so the blob path is anonymous THROUGH the token challenge and not merely open. act_runner''s call path is the one leg still unexercised, and it is first observable on a real PR run, where a failure is loud (both required contexts die at image pull, before running a step) rather than silent.' --- **What was wrong.** `docker-build.yml` triggers on `pull_request:`, and Gitea resolves that run from diff --git a/scripts/tests/test_workflow_persist_credentials.py b/scripts/tests/test_workflow_persist_credentials.py index 49258324f..68f5b30e1 100644 --- a/scripts/tests/test_workflow_persist_credentials.py +++ b/scripts/tests/test_workflow_persist_credentials.py @@ -979,6 +979,8 @@ def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None: # and it is asserted empty on each condition here before the fault is demanded. for name, condition, at_step in ( ("REGISTRY_PASSWORD", "secrets.REGISTRY_PASSWORD != ''", False), + ("REGISTRY_PASSWORD", "secrets.REGISTRY_PASSWORD != ''", True), + ("RENOVATE_TOKEN", "secrets.RENOVATE_TOKEN != ''", False), ("RENOVATE_TOKEN", "secrets.RENOVATE_TOKEN != ''", True), ): assert secret_refs(condition) == [], (