Files
ersatztv/scripts/tests/test_workflow_persist_credentials.py
T
timothyandClaude Fable 5.1 61ed6a7955
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 6s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 23s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 15s
review-verdict/h10 Review-verdict: MERGEABLE @ 61ed6a7 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m35s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m8s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 15:15:44 +02:00

1187 lines
68 KiB
Python

"""TWO credential invariants over one derived workflow population.
Every `actions/checkout` drops the persisted credential (ersatztv#835) — below — and no job
the `pull_request` trigger reaches, nor the workflow scope outside `jobs:`, names a stored
secret (ersatztv#885). The second is documented at its own banner further down, where the
population it adds to this one is derived; what follows here is the first.
WHAT THIS IS PROTECTING. `actions/checkout` writes an `Authorization` header into `.git/config`
unless `persist-credentials: false` is set, and this instance's Actions default token permission is
`permissive` — so that header is write-capable, and every later step in the job inherits it, not
just the checkout. ersatztv#746 set the flag on every checkout but one; nothing held the convention
afterwards. A job added tomorrow gets a checkout without the flag, and **nothing goes
red** — the silent-by-construction shape `docs/defect-shapes-773.md` §4 exists to catch.
WHY THERE IS NO EXEMPTION LIST. `ci-image.yml`'s checkout was the one left unset, for a mechanical
reason in another issue (#744), and this guard was deliberately held back rather than shipped with a
one-entry exemption for it: an exemption outlives its reason silently — once #744 set the flag there,
the entry would still have passed and the guard would have been permanently blind to the very file it
was written for. #744 landed with this, so the assertion is universal over the derived population.
POPULATION. The git index, via `tracked_files.tracked_paths` — not a filesystem walk and not a
hand-written list (`testing.guard-derives-population-from-source`, ersatztv#806). Both `*.yml` and
`*.yaml` are matched: Gitea accepts either spelling, so a `.yaml` workflow would otherwise be
structurally invisible to a check that reads as covering all of them.
`.github/workflows` is OUT of the population and is asserted empty rather than assumed so: Gitea
reads it only when `.gitea/workflows` is absent, which is a precedence rule this repo has not probed,
and `.github/` already exists here (ISSUE_TEMPLATE), so it is a plausible place to add a workflow by
habit. `test_no_workflow_hides_in_a_SUBDIRECTORY` covers nesting below `.gitea/workflows`.
"""
from __future__ import annotations
import re
from collections import Counter
from pathlib import Path
import yaml
from scripts.tests.tracked_files import _git_ls_files, tracked_paths
WORKFLOW_DIR = ".gitea/workflows"
WORKFLOWS = (WORKFLOW_DIR, ("*.yml", "*.yaml"))
# Compared LOWERCASE throughout: Gitea resolves an action ref through a forge that is
# case-insensitive on owner/repo, so `uses: Actions/Checkout@v4` runs the real checkout. A
# case-sensitive comparison missed it in BOTH halves identically, so the count cross-check agreed
# and the guard reported clean over a step that persists the credential.
CHECKOUT = "actions/checkout"
# Matches the `uses:` line the YAML walk is supposed to reach, in every spelling below. Used ONLY as
# an independent second opinion on how many there are — never as the population itself.
_USES_CHECKOUT = re.compile(
r"^\s*-?\s*uses:\s*['\"]?(?:[^'\"\s]*/)?actions/checkout(?:\.git)?(@|\s|['\"]|$)",
re.M | re.I,
)
def _canonical_action(uses: str) -> str:
"""The last two path segments of a `uses:` value, without its `@version`.
Gitea accepts a full action URL as well as `owner/repo`, and the host in one is NOT required to
contain a dot — `http://your-git-server/actions/checkout@v4` is documented and valid. So this
takes the last two segments rather than trying to recognise a hostname: every spelling of the
same action canonicalises to `actions/checkout`, with no host heuristic to get wrong.
It over-matches in one direction on purpose: a genuinely different action whose path happens to
END in `actions/checkout` would be treated as a checkout and required to carry the flag. That
costs a spurious requirement on an action nobody has; the opposite error costs a live credential.
Spellings normalised here, each of which escaped a simpler normalisation: an absolute
URL with or without a scheme, a host with no dot, a doubled slash, `@`-userinfo before the host,
a `.git` suffix, and any letter case.
"""
# rsplit, not split: a URL may carry userinfo (`https://user@host/actions/checkout@v4`), and
# splitting on the FIRST `@` would strip the host instead of the version.
ref = uses.strip().strip("'\"").rsplit("@", 1)[0].strip().lower()
ref = re.sub(r"^https?://", "", ref)
# Empty segments absorb a doubled slash; `.git` is how Gitea itself writes the clone URL.
segments = [seg for seg in ref.split("/") if seg]
if segments:
segments[-1] = re.sub(r"\.git$", "", segments[-1])
if ref.startswith("./") or ref.startswith("../") or len(segments) < 2:
return "/".join(segments) if segments else ref
return "/".join(segments[-2:])
def workflow_files() -> list[Path]:
return tracked_paths(*WORKFLOWS)
def _checkout_steps(doc: object) -> list[tuple[str, int, dict]]:
"""Every `actions/checkout` step in a parsed workflow, as (job id, index within job, step).
Reads `jobs.<id>.steps` only, which is the whole shape this repo uses. Different escapes are
reported by different tests, and they are not interchangeable:
`test_the_walk_finds_every_actions_checkout_the_TEXT_does` covers a shape INSIDE a workflow file
that the walk cannot reach, because both halves read the same files. A **composite action** is
invisible to both halves — its `action.yml` is not in the workflow population at all — so that
case is reported by `test_no_LOCAL_COMPOSITE_ACTION_exists_AT_ALL` instead. A **reusable
workflow** (`jobs.<id>.uses:`) is a third shape, invisible to both halves in the same way, and is
reported by `test_no_job_DELEGATES_to_a_reusable_workflow` — it is not covered by the text
cross-check, which would see zero on both sides and agree.
"""
found: list[tuple[str, int, dict]] = []
if not isinstance(doc, dict):
return found
jobs = doc.get("jobs")
if not isinstance(jobs, dict):
return found
for job_id, job in jobs.items():
if not isinstance(job, dict):
continue
steps = job.get("steps")
if not isinstance(steps, list):
continue
for index, step in enumerate(steps):
if not isinstance(step, dict):
continue
uses = step.get("uses")
if isinstance(uses, str) and _canonical_action(uses) == CHECKOUT:
found.append((str(job_id), index, step))
return found
def checkout_faults(rel: str, doc: object) -> list[str]:
"""Human-readable faults for one workflow — accumulated, not failed fast.
A missing key and an explicit `true` are the same defect and are reported the same way: what
matters is whether the credential is left behind, not how the step spelled it.
"""
faults: list[str] = []
for job_id, index, step in _checkout_steps(doc):
with_block = step.get("with")
value = with_block.get("persist-credentials") if isinstance(with_block, dict) else None
if value is False:
continue
how = "does not set it at all" if value is None else f"sets it to {value!r}"
faults.append(
f"{rel}: job `{job_id}` step #{index} uses {CHECKOUT} and {how} — so the action "
f"persists a write-capable Authorization header into .git/config, and every later "
f"step in that job inherits it. Add `persist-credentials: false` under `with:` "
f"(ersatztv#746, guarded by ersatztv#835). There is deliberately no exemption list: "
f"if a checkout genuinely needs the credential, say so in the step and change this "
f"guard in the same PR."
)
return faults
def test_every_actions_checkout_DROPS_the_persisted_credential() -> None:
faults: list[str] = []
for path in workflow_files():
rel = path.relative_to(Path(__file__).resolve().parents[2]).as_posix()
faults.extend(checkout_faults(rel, yaml.safe_load(path.read_text())))
assert not faults, "\n".join(faults)
def test_the_walk_finds_every_actions_checkout_the_TEXT_does() -> None:
"""The YAML walk and a plain text scan must agree on the count, per file.
Without this, a step list the walk cannot reach — a composite action, a reusable workflow, a
shape act_runner grows later — makes checkouts structurally invisible while the guard above
still reports a clean run. Absence of output is not evidence; make the system report it.
"""
disagreements: list[str] = []
for path in workflow_files():
text = path.read_text()
walked = len(_checkout_steps(yaml.safe_load(text)))
scanned = len(_USES_CHECKOUT.findall(text))
if walked != scanned:
disagreements.append(
f"{path.name}: the jobs.<id>.steps walk found {walked} {CHECKOUT} step(s) but the "
f"text scan found {scanned}. The walk is what the flag assertion runs over, so the "
f"difference is checkouts this guard cannot see — widen `_checkout_steps`."
)
assert not disagreements, "\n".join(disagreements)
def test_the_population_and_the_checkout_set_are_NOT_empty() -> None:
"""Anti-vacuity, in both directions.
An empty population, or a population of workflows in which the walk finds no checkout at all,
makes the assertion above pass while measuring nothing — which is the failure this guard exists
to prevent, arriving through the guard itself.
"""
files = workflow_files()
assert files, (
"`git ls-files` reported no .gitea/workflows/*.yml — the derivation is broken, not the repo, "
"and every assertion in this file would pass vacuously."
)
total = sum(len(_checkout_steps(yaml.safe_load(p.read_text()))) for p in files)
assert total > 0, (
f"Parsed {len(files)} workflow(s) and found no {CHECKOUT} step in any of them. Either the "
"repo really has none (then delete this guard and say why) or `_checkout_steps` stopped "
"matching, in which case the flag assertion is measuring an empty set."
)
def test_the_fault_collector_reports_a_checkout_that_OMITS_the_flag() -> None:
"""Negative control on the collector itself, over both defect spellings.
The mutation proof (`mutation_manifest.py`) drives the real tree; this drives the collector
directly so a collector that silently stopped reporting is caught without waiting for the
harness, and so `true` is covered as well as an absent key.
"""
omitted = {"jobs": {"j": {"steps": [{"uses": "actions/checkout@v4"}]}}}
explicit_true = {"jobs": {"j": {"steps": [{"uses": "actions/checkout@v4", "with": {"persist-credentials": True}}]}}}
compliant = {"jobs": {"j": {"steps": [{"uses": "actions/checkout@v4", "with": {"persist-credentials": False}}]}}}
assert len(checkout_faults("synthetic.yml", omitted)) == 1
assert "does not set it at all" in checkout_faults("synthetic.yml", omitted)[0]
assert len(checkout_faults("synthetic.yml", explicit_true)) == 1
assert "sets it to True" in checkout_faults("synthetic.yml", explicit_true)[0]
assert checkout_faults("synthetic.yml", compliant) == []
# Gitea accepts a full action URL, so the same step spelled as a URL must not escape either —
# pinned rather than probed once, because a canonicalisation that quietly stopped working would
# leave the guard green over a checkout it no longer recognises as one.
for spelling in (
"https://github.com/actions/checkout@v4",
# A Gitea action host is not required to contain a dot — this is the documented
# self-hosted spelling, and a dot-requiring canonicaliser let it through silently.
"http://your-git-server/actions/checkout@v4",
"github.com/actions/checkout@v4",
# The forge is case-insensitive on owner/repo, so this runs the real checkout.
"Actions/Checkout@v4",
# Gitea writes the clone URL with the suffix; both halves must accept it.
"https://github.com/actions/checkout.git@v4",
# Userinfo before the host — splitting on the FIRST `@` used to strip the host.
"https://user@github.com/actions/checkout@v4",
"https://github.com//actions/checkout@v4",
):
as_url = {"jobs": {"j": {"steps": [{"uses": spelling}]}}}
assert len(checkout_faults("synthetic.yml", as_url)) == 1, spelling
assert _USES_CHECKOUT.search(f" - uses: {spelling}"), spelling
assert not _USES_CHECKOUT.search(" - uses: actions/setup-node@v4")
def test_no_workflow_hides_in_a_SUBDIRECTORY() -> None:
"""The population is direct children of `.gitea/workflows`; prove nothing sits below it.
`tracked_children` is direct-children-only by design (see `tracked_files`), which is right for a
flat directory and blind the moment one stops being flat. This does not widen the population —
it makes the assumption REPORT itself, so a nested workflow reddens the guard instead of being
silently uncovered, and it covers the two sibling guards that derive the same directory the same
way (`test_ci_image_pin_population.py`, `test_pr_changed_files.py`).
"""
nested = sorted(
path for path in _git_ls_files() if path.startswith(f"{WORKFLOW_DIR}/") and "/" in path[len(WORKFLOW_DIR) + 1 :]
)
assert not nested, (
f"tracked workflow file(s) below {WORKFLOW_DIR}/: {nested}. The population here is direct "
"children only, so these are NOT checked for `persist-credentials: false` — widen the "
"derivation (and the sibling guards that share it) before adding them."
)
def test_no_LOCAL_COMPOSITE_ACTION_exists_AT_ALL() -> None:
"""A checkout inside a local composite action runs in the job but is outside the workflow set.
The repo has no `action.yml`/`action.yaml` today, so rather than write a parser for a shape that
does not exist, assert the absence: the first one added reddens this and its author widens the
guard, instead of the guard reading as complete while a whole class walked around it.
"""
actions = sorted(path for path in _git_ls_files() if path.rpartition("/")[2] in {"action.yml", "action.yaml"})
assert not actions, (
f"local composite action definition(s) found: {actions}. A composite action's steps run in "
"the job, so an `actions/checkout` in one is subject to the same rule and is NOT covered by "
"the workflow-file population above — extend `_checkout_steps` to walk them."
)
def test_no_job_DELEGATES_to_a_reusable_workflow() -> None:
"""A `jobs.<id>.uses:` callee's steps run in the job but are outside the walk.
Both halves would see zero and AGREE, so the count cross-check reports nothing — the same blind
spot the composite-action test exists for. Gitea Actions has no `workflow_call` today, so assert
the absence rather than parse a shape that cannot occur: the first one added reddens here and its
author widens the guard.
"""
delegating = []
for path in workflow_files():
doc = yaml.safe_load(path.read_text())
jobs = doc.get("jobs") if isinstance(doc, dict) else None
if not isinstance(jobs, dict):
continue
for job_id, job in jobs.items():
if isinstance(job, dict) and isinstance(job.get("uses"), str):
delegating.append(f"{path.name}: job `{job_id}` -> {job['uses']}")
assert not delegating, (
f"job(s) delegating to a reusable workflow: {delegating}. The callee's `actions/checkout` "
"steps run in this job and are subject to the same rule, but are NOT in the population "
"above — extend the walk before adding one."
)
def test_no_workflow_lives_under_dot_GITHUB() -> None:
"""`.github/workflows` is out of the population, so prove it is empty rather than assume it.
Gitea reads `.github/workflows` only when `.gitea/workflows` is absent — a precedence rule this
repo has never probed — and `.github/` already exists here, so it is a plausible place to add a
workflow out of habit. Cheap to assert; silently uncovered otherwise.
"""
stray = sorted(p for p in _git_ls_files() if p.startswith(".github/workflows/"))
assert not stray, (
f"tracked workflow file(s) under .github/workflows: {stray}. This guard's population is "
f"{WORKFLOW_DIR} only — decide whether Gitea runs these and widen the population or delete "
"them, but do not leave them unchecked."
)
# =================================================================================================
# NO JOB ON THE `pull_request` ROUTE NAMES A STORED SECRET (ersatztv#885)
#
# The second credential invariant over the same derived population, and the same shape of defect one
# step out: `persist-credentials` is about a credential a step LEAVES BEHIND, this is about a
# credential the workflow ASKS FOR. Gitea resolves a `pull_request` run from the PR HEAD, so on that
# route the YAML is authored by the contributor, and every `secrets.*` it names is materialised into
# the run environment. Six jobs in `docker-build.yml` held `REGISTRY_PASSWORD` that way, two of them
# branch-protection required contexts.
#
# WHY THE PREDICATE IS "names a stored secret", NOT "is a `container:` job". The issue's own first
# statement of the invariant was the latter, and it was wrong: `toolchain-preflight` is deliberately
# container-free and took the credential through a step `env:` instead, so that predicate named five
# of six and would have gone stale the day it shipped. A population derived by the WRONG predicate is
# not better than a hand-written list — it is a list with a false claim of completeness attached.
#
# WHY `pull_request_target` IS NOT IN THIS POPULATION. That trigger is BASE-resolved
# (`ci.gate-trigger-base-resolved`): the YAML that runs is `main`'s, not the head's, which is exactly
# why `review-verdict.yml` uses it to hold a write-capable token. The exposure here is head-authored
# YAML, so the trigger that is not head-authored is out — and a workflow that adds `pull_request`
# alongside it enters the population on that key alone.
#
# WHAT THIS DOES NOT CLOSE, so no reader mistakes it for a boundary: `REGISTRY_PASSWORD` is still in
# the repo's Actions store for `build`, and head-supplied YAML can still NAME it, or `RENOVATE_TOKEN`,
# or `SERVERMGMT_DEPLOY_KEY`. What is removed is the ROUTINE materialisation of a write-capable
# credential into six PR-run environments. Bounding the store itself needs per-environment secret
# scoping, which Gitea 1.27.1 does not have (probed in ersatztv#853).
# =================================================================================================
# The repo's Actions secret store, read 2026-09-04: GH_COM_TOKEN, REGISTRY_PASSWORD, REGISTRY_USER,
# RENOVATE_TOKEN, SERVERMGMT_DEPLOY_KEY. `GITEA_TOKEN` is deliberately NOT one of them — it is the
# per-run token Gitea injects, and a head-authored run receives it whether or not any job names it.
# Its `permissions:` block narrows it for the COMMITTED file only — on this route the head supplies
# that file and can delete the block — so allow-listing it is NOT a claim that it is bounded. It is a
# statement about a MECHANISM (injected, unavoidable, out of the store this guard is about) and not
# an exemption for a site, which is why it is a
# closed one-member set rather than a list that can grow: a second entry would be an exemption, and
# an exemption outlives its reason silently.
INJECTED_SECRETS = frozenset({"GITEA_TOKEN"})
PULL_REQUEST = "pull_request"
# The name a WHOLE-CONTEXT reference is reported under. It cannot collide with a real secret: a
# secret name is `[A-Za-z_][A-Za-z0-9_]*`, so no stored name can contain `.` or `*`.
WHOLE_SECRETS_CONTEXT = "secrets.*"
# Every spelling of a `secrets` reference the expression grammar admits — NOT just `secrets.NAME`.
# `secrets['NAME']` is the same reference to the evaluator (GitHub's expression syntax, which Gitea's
# runner implements, defines `[]` as the index operator alongside `.`), and context names are matched
# case-insensitively, so `Secrets.NAME` is one too. Recognising only the dot spelling made this a
# detector an added job could step around by writing a different one — and the cross-check below
# cannot report that, because BOTH of its halves read through this same function: a spelling neither
# half knows is a SHARED blind spot they agree at zero on, not a disagreement
# (`proof-sharing-with-subject-proves-nothing`, ersatztv#819). Measured 2026-09-05 against the
# predecessor of this commit, an `env:` of `"${{ secrets['REGISTRY_PASSWORD'] }}"` on a synthetic
# `pull_request` job produced `stored_secret_faults(...) == []` AND `walk_versus_text_faults(...) == []`.
#
# The name is taken VERBATIM, not case-folded onto the store's uppercase spelling. Whether the
# evaluator resolves `secrets.gitea_token` to the stored `GITEA_TOKEN` was not probed from here, and
# the un-folded reading is the demanding one: an unrecognised spelling misses the one-member
# `INJECTED_SECRETS` allow-list and is reported, rather than being forgiven on an unmeasured claim.
_SECRET_REF = re.compile(
r"(?<![A-Za-z0-9_])secrets\s*(?:"
r"\.\s*([A-Za-z_][A-Za-z0-9_]*)" # secrets.NAME
r"|\[\s*'([^']*)'\s*\]" # secrets['NAME']
r"|\[\s*\"([^\"]*)\"\s*\]" # secrets["NAME"]
r")",
re.I,
)
# The residue: a `secrets` token inside a `${{ }}` expression that `_SECRET_REF` did NOT resolve to a
# literal name. Two shapes reach it, and both hand over more than one secret rather than fewer —
# `toJSON(secrets)` or a bare `secrets` passed as an action input, and an index whose subscript is
# COMPUTED (`secrets[format('{0}_TOKEN', x)]`), which no static reader resolves. Counting the residue
# rather than pattern-matching those two shapes is what makes the widening exhaustive over the
# grammar: any spelling of the context that does not yield a literal name is reported, including one
# nobody has written yet.
#
# EVERY pattern here reads only inside `${{ }}` spans, `_SECRET_REF` included, because outside one
# the word is ordinary English and `secrets.` is a sentence boundary. That scoping is enforced in
# `secret_refs` below rather than in the patterns, which cannot express "within the enclosing span".
# Applied to the whole string instead, `_SECRET_REF` reads the English `# We pass no secrets. Then
# the pull is anonymous.` as a reference to a secret named `Then` — a fabricated name, faulting a
# PR-route job for a comment, on this branch's own subject.
#
# THE SCOPING COSTS EXACTLY ONE REAL SPELLING, AND IT IS NOT NOTHING: an `if:` value is an expression
# WHETHER OR NOT it is wrapped, so `if: secrets.REGISTRY_PASSWORD != ''` names a stored secret in a
# document that contains no `${{` at all. That an unwrapped condition is evaluated rather than read
# as text is demonstrated by this repo's own `docker-build.yml` and not inferred — `build` carries
# `if: github.event_name != 'pull_request'` bare, and `PR_EXCLUDING_IFS` below pins that exact
# unwrapped string. `if:` is the only key whose value the grammar lets omit the delimiters, so the
# scoping is repaired AT THAT KEY rather than abandoned: `condition_refs` reads an `if:` value as one
# span, and `secret_name_counts` routes the value there instead of through `secret_refs`. Everywhere
# else the scoping stands, and the English sentence above still costs nothing.
_EXPRESSION = re.compile(r"\$\{\{(.*?)\}\}", re.S)
_SECRETS_TOKEN = re.compile(r"(?<![A-Za-z0-9_])secrets(?![A-Za-z0-9_])", re.I)
# `${{` and `}}` as tokens, for reading an `if:` value that mixes the wrapped and unwrapped forms.
# They are replaced by a SPACE and not deleted: `${{ secrets.A }}${{ secrets.B }}` collapsed by
# deletion reads as the one identifier `secrets.Asecrets` and LOSES a reference, the fail-open
# direction.
_EXPRESSION_DELIMITER = re.compile(r"\$\{\{|\}\}")
# The one key whose value the expression grammar evaluates with the delimiters omitted. Matched
# case-INSENSITIVELY where it is read, the direction `_SECRET_REF` and `SECRETS_KEY` both take.
IF_KEY = "if"
def _refs_in_expression(expression: str) -> list[str]:
"""Every `secrets` reference inside ONE expression span — the resolved names, then the residue.
Shared by both entry points, so that widening a spelling widens the wrapped and the unwrapped
reading together: a second copy of this resolution would be free to drift from the one the
assertion runs on, which is the shape this guard exists to catch.
"""
resolved = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(expression)]
# The residue: `secrets` tokens in this span that resolved to no literal name.
residue = len(_SECRETS_TOKEN.findall(expression)) - len(resolved)
return resolved + [WHOLE_SECRETS_CONTEXT] * residue
def secret_refs(text: str) -> list[str]:
"""Every `secrets` reference in one string — occurrences, not names.
The single entry point for both the document walk and the text cross-check, so that widening one
spelling widens both. A list rather than a set for the reason `secret_name_counts` is a Counter:
only the lossless direction can be narrowed afterwards.
"""
found: list[str] = []
for expression in _EXPRESSION.findall(text):
found.extend(_refs_in_expression(expression))
return found
def condition_refs(condition: str) -> list[str]:
"""Every `secrets` reference in an `if:` value, which is an expression with or without `${{ }}`.
The WHOLE value is read as one span, with the delimiters neutralised rather than honoured, so
that a condition mixing the two forms — `${{ true }} && secrets.X != ''` — is covered by the same
read as the bare one, and a fully wrapped condition still counts each reference exactly once.
This is strictly more demanding than `secret_refs` on the same string and never less: an `if:` is
never prose, so the over-match the span scoping exists to avoid cannot arise here.
WHAT AN `if:` REFERENCE COSTS DIFFERS FROM EVERY OTHER SITE THIS GUARD READS, and it is faulted
anyway. A secret in an `env:` or a `run:` is materialised into the job environment; a secret in a
condition is resolved by the evaluator and the job sees only the boolean. The predicate here is
"names a stored secret", never "exports one" — on the head-authored route a condition comparing a
secret to a literal the contributor chooses is an oracle over its value, and a predicate that
asked about EXPOSURE would have to model what each site does with the reference, which is the
structure-blindness this collector deliberately does not give up (it is what saw
`toolchain-preflight`'s step `env:` when a `container:`-shaped predicate did not).
"""
return _refs_in_expression(_EXPRESSION_DELIMITER.sub(" ", condition))
# The ONLY job-level `if:` in this repo that takes a job OFF the `pull_request` route. This is a PIN,
# not an expression parser, and the direction is the point: an `if:` that is not in this set leaves
# the job IN the population, so an unrecognised guard reddens rather than exempting. Parsing
# `github.event_name` expressions was tried elsewhere and withdrawn after repeated defects from that
# one mechanism (`test_image_build_delegates_the_spa_suite`); a pin has no such failure mode, because
# the only way to get it wrong is to be too demanding.
PR_EXCLUDING_IFS = frozenset({"github.event_name != 'pull_request'"})
def _unwrap_expression(value: str) -> str:
"""`${{ x }}` -> `x`; anything else unchanged, whitespace-trimmed."""
stripped = value.strip()
if stripped.startswith("${{") and stripped.endswith("}}"):
stripped = stripped[3:-2]
return stripped.strip()
def workflow_triggers(doc: object) -> set[str]:
"""The trigger names under `on:`, in all three spellings it can take.
`on` is read back from `yaml.safe_load` as the BOOLEAN `True`, not the string `"on"` — YAML 1.1
resolves a bare `on` to a boolean, and PyYAML implements 1.1. A `doc.get("on")` here returns
None for every workflow in this repo, which would empty the population and make every assertion
below pass having measured nothing. Both keys are read so the function survives a loader that
resolves it either way.
"""
if not isinstance(doc, dict):
return set()
on = doc.get(True)
if on is None:
on = doc.get("on")
if isinstance(on, str):
return {on}
if isinstance(on, list):
return {str(item) for item in on}
if isinstance(on, dict):
return {str(key) for key in on}
return set()
def runs_on_pull_request(doc: object) -> bool:
return PULL_REQUEST in workflow_triggers(doc)
def pull_request_jobs(doc: object) -> list[tuple[str, dict]]:
"""Every job of a `pull_request`-triggered workflow that the trigger can actually reach."""
jobs = doc.get("jobs") if isinstance(doc, dict) else None
if not isinstance(jobs, dict):
return []
reachable: list[tuple[str, dict]] = []
for job_id, job in jobs.items():
if not isinstance(job, dict):
continue
condition = job.get("if")
if isinstance(condition, str) and _unwrap_expression(condition) in PR_EXCLUDING_IFS:
continue
reachable.append((str(job_id), job))
return reachable
def secret_name_counts(node: object) -> Counter:
"""Every secret reference reachable anywhere in a subtree — keys and values, at any depth — COUNTED.
Deliberately structure-blind: the credential entered through `container.credentials`, a step
`env:`, and a job `env:` in this repo already, and naming those three places would be the
hand-written-population mistake in a different coat.
Occurrences, not a set of names, because the two consumers want different views of ONE walk and
only this direction is lossless: `secret_names` is derived from it below. A second traversal
with a different accumulator would be a copy of a mechanism, free to drift from the one the
assertion runs on — the shape this guard exists to catch, in the guard itself.
One key is read differently, and it is a KEY rather than a place in the document: the value of an
`if:` is an expression with or without `${{ }}`, so it goes through `condition_refs`. Read as an
ordinary string it would be scoped to its `${{ }}` spans and a bare `if: secrets.X != ''` would
be invisible, on the route where the head writes the file. The value is routed there INSTEAD of
onto the stack, so a wrapped condition is counted once rather than twice.
"""
found: Counter = Counter()
stack: list[object] = [node]
while stack:
item = stack.pop()
if isinstance(item, dict):
for key, value in item.items():
stack.append(key)
if isinstance(key, str) and key.lower() == IF_KEY and isinstance(value, str):
found.update(condition_refs(value))
else:
stack.append(value)
elif isinstance(item, list):
stack.extend(item)
elif isinstance(item, str):
found.update(secret_refs(item))
return found
def secret_names(node: object) -> set[str]:
"""The names `secret_name_counts` reached. The fault collector wants names — it reports which
secret a job holds, once — and only the cross-check needs locations."""
return set(secret_name_counts(node))
# The second way a document hands stored secrets to a run: a `secrets:` KEY, which names them in the
# YAML grammar rather than inside an expression. `jobs.<id>.secrets:` on a `uses:` (reusable-workflow)
# job passes the caller's store to the called workflow, and its value takes one of two shapes — a
# MAPPING of name -> value, whose values are `${{ }}` expressions the collector above already reads,
# or the bare scalar `inherit`, which hands over the WHOLE store while naming nothing at all.
#
# `inherit` is the `toJSON(secrets)` shape one level up, and it defeats the collector for the same
# reason the span scoping is correct: `secret_refs` reads only inside `${{ }}`, and `inherit` is not
# an expression. Measured 2026-09-05 against the predecessor of this commit, a `pull_request` job of
# `{'uses': './.gitea/workflows/reusable.yml', 'secrets': 'inherit'}` was put in the population by
# `pull_request_jobs`, walked, and reported CLEAN (`stored_secret_faults(...) == []`), while the
# mapping spelling produced one correctly-named fault — so the miss was in the VALUE SHAPE, not the
# key. That is precisely a new job silently joining the population unprotected. Both halves of that
# measurement are RE-DERIVED every run rather than left as prose — `secret_names(job) -
# INJECTED_SECRETS` is the predecessor collector verbatim, and
# `test_a_SECRETS_HANDOVER_naming_nothing_is_reported_under_the_WHOLE_CONTEXT_sentinel` asserts it
# empty on the same fixture it asserts the fault on.
#
# 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 of names hands over something this guard
# cannot enumerate, including a spelling act_runner grows later. Whether this instance's runner
# resolves `workflow_call` + `secrets: inherit` at all was NOT probed from here; the direction makes
# that acceptable, as it does for the expression spellings — an unsupported shape costs a spurious
# demand on a job nobody has written, and the omission cost the whole store.
#
# This clause is DOCUMENT-ONLY, stated here rather than left to be discovered: `secrets:` is a plain
# YAML key, so the text-versus-walk cross-check has nothing to match on and does not cover it. It
# also does not REDDEN that cross-check, because the clause feeds the fault collector and not
# `secret_name_counts`, which both halves read through.
#
# The key is matched case-INSENSITIVELY, the same direction `_SECRET_REF` takes: the grammar spells it
# lowercase, and reading `Secrets:` as one too can only ever be too demanding.
SECRETS_KEY = "secrets"
def opaque_secret_handovers(node: object) -> int:
"""`secrets:` keys in a subtree whose value is not a mapping of names — occurrences, not sites.
Structure-blind for the reason `secret_name_counts` is: walking for the key anywhere rather than
at `jobs.<id>.secrets` avoids pinning the one location today's grammar documents
(`testing.guard-derives-population-from-source`).
"""
found = 0
stack: list[object] = [node]
while stack:
item = stack.pop()
if isinstance(item, dict):
for key, value in item.items():
if isinstance(key, str) and key.lower() == SECRETS_KEY and not isinstance(value, dict):
found += 1
stack.append(value)
elif isinstance(item, list):
stack.extend(item)
return found
def held_secret_names(node: object) -> set[str]:
"""Every STORED secret a subtree hands to the run: the names it references, plus the whole-context
sentinel when it hands over a set this guard cannot enumerate.
The one place `INJECTED_SECRETS` is subtracted, so the two fault sites below cannot drift apart on
which references are forgiven.
"""
held = secret_names(node) - INJECTED_SECRETS
if opaque_secret_handovers(node):
held.add(WHOLE_SECRETS_CONTEXT)
return held
def outside_jobs(doc: object) -> dict:
"""Everything in a workflow document EXCEPT `jobs:` — the workflow scope.
A root `env:` is materialised into every job, and `defaults:` likewise; neither is reachable by
a walk that starts at `jobs.<id>`, and no job-level `if:` can take a workflow-scope reference off
the route. So the scope is a SECOND site the credential can enter through, on equal footing with
the job subtree, and it is walked by the same structure-blind collector rather than by naming
`env:` and `defaults:` — naming them would reproduce the hand-written-population mistake the
job-level predicate already avoids (`testing.guard-derives-population-from-source`).
"""
if not isinstance(doc, dict):
return {}
return {key: value for key, value in doc.items() if key != "jobs"}
def stored_secret_faults(rel: str, doc: object) -> list[str]:
"""Human-readable faults for one workflow — accumulated, not failed fast."""
faults: list[str] = []
if not runs_on_pull_request(doc):
return faults
workflow_scope = sorted(held_secret_names(outside_jobs(doc)))
if workflow_scope:
faults.append(
f"{rel}: the WORKFLOW SCOPE (outside `jobs:`) names stored secret(s) on the "
f"pull_request route: {', '.join(workflow_scope)}. A root `env:` or `defaults:` is "
f"materialised into EVERY job, so no job-level "
f"`if: github.event_name != 'pull_request'` can take it off the head-authored route. "
f"Move the reference into a job that is gated off the route the way `build` is, or "
f"take the credential out entirely (the toolchain image pulls anonymously and the "
f"commit-status API answers unauthenticated). There is deliberately no exemption "
f"list: `ci.pr-route-carries-no-stored-credential`."
)
for job_id, job in pull_request_jobs(doc):
named = sorted(held_secret_names(job))
if named:
faults.append(
f"{rel}: job `{job_id}` names stored secret(s) on the pull_request route: "
f"{', '.join(named)}. Gitea resolves a `pull_request` run from the PR HEAD, so this "
f"YAML is contributor-authored and every secret it names is handed to that run. "
f"Take the credential out of the job (the toolchain image pulls anonymously and the "
f"commit-status API answers unauthenticated), or gate the job off the route with "
f"`if: github.event_name != 'pull_request'` the way `build` is. There is "
f"deliberately no exemption list: `ci.pr-route-carries-no-stored-credential`."
)
return faults
def test_no_PULL_REQUEST_route_job_names_a_STORED_secret() -> None:
faults: list[str] = []
for path in workflow_files():
rel = path.relative_to(Path(__file__).resolve().parents[2]).as_posix()
faults.extend(stored_secret_faults(rel, yaml.safe_load(path.read_text())))
assert not faults, "\n".join(faults)
def walk_versus_text_faults(name: str, text: str) -> list[str]:
"""One file's half of the cross-check below, extracted so a negative control can DRIVE it.
Inlined in the loop it could be reverted to a name-set comparison — restoring the blind spot this
whole change is about — with every test in this file still green, because nothing but the loop
over the real workflows would ever call it and those agree either way
(`testing.guard-ships-with-mutation-proof`).
"""
walked = secret_name_counts(yaml.safe_load(text))
scanned = Counter(secret_refs("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))))
if walked == scanned:
return []
return [
f"{name}: the document walk reached {sorted(walked.items())} but the text scan found "
f"{sorted(scanned.items())}. The walk is what the stored-secret assertion runs over, so the "
f"difference is secret references this guard cannot see — widen `secret_names`' entry "
f"point, or take the reference out of the file."
]
def test_the_DOCUMENT_walk_finds_every_secret_reference_the_TEXT_does() -> None:
"""The document walk and a plain text scan must agree per file, COUNTED not merely named.
The two halves of the assertion above — the workflow scope and each reachable job — are both
reached from the document root, so the cross-check is over the whole document rather than over
`jobs.<id>`. A reference the walk cannot reach at all (a shape act_runner grows later, a key the
loader drops) is then a disagreement, and the guard says so instead of reporting clean.
IT COUNTS OCCURRENCES, IT DOES NOT COMPARE NAME SETS, and that is the load-bearing part rather
than a refinement. Measured 2026-09-05 against the predecessor of this commit — the guard as it
walked `jobs.<id>` only and compared per-file NAME SETS — hoisting
`ETV_REGISTRY_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}` into
`docker-build.yml`'s root `env:` left a set-comparison cross-check GREEN (`14 passed`), because
`build` legitimately keeps naming both names — the two halves cancelled and the check could only
ever see a name NO job already used. That is the `dont-keep-a-copy-of-a-set` /
`proof-sharing-with-subject-proves-nothing` shape: a set says a name appears SOMEWHERE, which is
exactly the fact a second copy of the same reference does not change. A count changes.
COMMENT-ONLY LINES ARE STRIPPED first, so that a comment DISCUSSING a secret name does not read
as a reference the walk missed — `review-verdict.yml` names `secrets.GITEA_TOKEN` in prose, and
under counting that strip IS load-bearing (a set comparison forgave it; a count does not).
Two blind spots the strip has, stated rather than left to be discovered:
* it is LINE-level, so a TRAILING comment (`foo: bar # secrets.X`) survives into the text
half. The text count is then larger than the walk's, which REDDENS — the safe direction;
* conversely a `#`-prefixed line inside a `run: |` block is a shell comment to a reader and
part of the YAML scalar to the walk, so the strip removes it from the text half only and
the walk's count becomes the larger one. That reddens too, for a benign cause; reword the
comment rather than widening the strip, which would start hiding real references.
A YAML anchor/alias would also redden benignly (the walk visits the aliased node once per
reference, the text carries `*alias`); measured 2026-09-05 no tracked workflow uses one.
A THIRD asymmetry, which is not the strip's: an `if:` value is read by `condition_refs`, so an
UNWRAPPED `if: secrets.X != ''` is counted by the walk and not by the text half, which has no key
to read it by and is scoped to `${{ }}` spans. The walk's count is then the larger one, which
reddens — the safe direction, and on a document that is already faulting the stored-secret
assertion for the same reference. Measured 2026-09-05, no tracked workflow names a secret in an
`if:` at all, so nothing in the tree reaches it.
WHAT THIS CROSS-CHECK STRUCTURALLY CANNOT REPORT, since it is the reason `secret_refs` has to be
widened rather than leaned on: both halves read through that one function, so a spelling IT does
not recognise is invisible to both and they agree at zero. The dot/index/whole-context spellings
are covered there; what remains uncovered is a name the expression COMPUTES
(`secrets[format('{0}_TOKEN', x)]`), which no static reader resolves. That spelling still faults,
but as `secrets.*` — the whole-context sentinel — because the index contains no literal, which is
the fail-closed direction and is asserted in
`test_the_collector_sees_every_SPELLING_of_a_secret_reference`.
It equally cannot report a handover written in the YAML grammar rather than the expression
grammar — `jobs.<id>.secrets: inherit` on a `uses:` job — and for a stronger reason than a shared
blind spot: there is no expression for the text half to match at all. That shape is judged by
`opaque_secret_handovers`, which reads the DOCUMENT only, and is asserted in
`test_a_SECRETS_HANDOVER_naming_nothing_is_reported_under_the_WHOLE_CONTEXT_sentinel`.
"""
disagreements: list[str] = []
total = Counter()
for path in workflow_files():
text = path.read_text()
total += secret_name_counts(yaml.safe_load(text))
disagreements.extend(walk_versus_text_faults(path.name, text))
assert not disagreements, "\n".join(disagreements)
assert sum(total.values()) > 0, (
"no workflow reached by this check names a single `secrets.*` reference. Two empty Counters "
"compare equal, so the loop above would agree having measured nothing — either every "
"reference really is gone (say so here and in `ci.pr-route-carries-no-stored-credential`) "
"or `workflow_files()`/`secret_refs` has stopped matching."
)
def test_the_cross_check_COUNTS_locations_rather_than_collecting_NAMES() -> None:
"""The property that distinguishes this check from the one it replaced, asserted directly.
A duplicated reference — the same secret named at a second location — changes no NAME and
changes the COUNT. That difference is the entire reason a workflow-level hoist of a name `build`
already uses was invisible before, so it is pinned on the collectors themselves rather than left
to be inferred from the mutation tests.
"""
once = {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}}
twice = {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}", "B": "${{ secrets.REGISTRY_PASSWORD }}"}}
assert secret_names(once) == secret_names(twice) == {"REGISTRY_PASSWORD"}
assert secret_name_counts(once) == Counter({"REGISTRY_PASSWORD": 1})
assert secret_name_counts(twice) == Counter({"REGISTRY_PASSWORD": 2})
assert secret_name_counts(once) != secret_name_counts(twice)
# And the CROSS-CHECK ITSELF, on a text/walk pair whose NAME SETS AGREE. Reverting the
# comparison to name sets would restore the blind spot this change is about, so the difference
# is driven rather than left to the collectors' unit assertions above.
clean = 'jobs:\n j:\n env:\n A: "${{ secrets.REGISTRY_PASSWORD }}"\n'
assert walk_versus_text_faults("synthetic.yml", clean) == []
# The same name a SECOND time, at a location the YAML walk cannot reach — a trailing comment,
# which the line-level strip leaves in the text half.
duplicated = (
'jobs:\n j:\n env:\n A: "${{ secrets.REGISTRY_PASSWORD }}" # ${{ secrets.REGISTRY_PASSWORD }}\n'
)
faults = walk_versus_text_faults("synthetic.yml", duplicated)
assert len(faults) == 1, faults
assert "REGISTRY_PASSWORD" in faults[0]
# A name-set comparison — the mechanism this replaced — reports NOTHING on that same input.
assert secret_names(yaml.safe_load(duplicated)) == set(secret_refs(duplicated)) == {"REGISTRY_PASSWORD"}
# Two references in ONE scalar count twice, which is the shape a hoisted
# `user:${{ secrets.X }}:${{ secrets.Y }}` line takes.
pair = {"env": {"A": "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}"}}
assert secret_name_counts(pair) == Counter({"REGISTRY_USER": 1, "REGISTRY_PASSWORD": 1})
def test_the_PULL_REQUEST_populations_are_NOT_empty() -> None:
"""Anti-vacuity in three directions, because each one alone can empty the assertion silently."""
docs = [(p, yaml.safe_load(p.read_text())) for p in workflow_files()]
pr_workflows = [(p, d) for p, d in docs if runs_on_pull_request(d)]
assert pr_workflows, (
"no tracked workflow was read as triggering on `pull_request`. Either the repo really has "
"none, or `workflow_triggers` has stopped resolving the `on:` key — note YAML 1.1 gives it "
"back as the boolean True. Every assertion above would pass having measured nothing."
)
reachable = sum(len(pull_request_jobs(d)) for _, d in pr_workflows)
assert reachable > 0, (
f"{len(pr_workflows)} workflow(s) trigger on `pull_request` and not one reachable job was "
"found — `pull_request_jobs` is excluding everything, so the guard measures an empty set."
)
named = set()
for _, doc in pr_workflows:
jobs = doc.get("jobs") if isinstance(doc, dict) else None
if isinstance(jobs, dict):
named |= secret_names(jobs)
assert "REGISTRY_PASSWORD" in named, (
"`REGISTRY_PASSWORD` is no longer named anywhere in the pull_request-triggered workflows. "
"It is supposed to survive in `build`, which is gated off the route — if the secret was "
"renamed, this guard has quietly become a check on a string nothing uses, so update the "
"name here in the same commit."
)
def test_a_job_whose_IF_is_UNRECOGNISED_stays_on_the_pull_request_route() -> None:
"""The fail-closed direction of the `if:` pin, driven rather than assumed.
`PR_EXCLUDING_IFS` is one string. Every other condition — including ones that a human can see
exclude the route — must leave the job in the population, so that widening the pin is a
deliberate edit and never an accident of expression parsing.
"""
for condition in (
"github.event_name == 'push'",
"${{ github.event_name != 'pull_request' && true }}",
"github.ref == 'refs/heads/main'",
"always()",
):
job = {"if": condition, "env": {"X": "${{ secrets.REGISTRY_PASSWORD }}"}}
doc = {True: ["pull_request"], "jobs": {"j": job}}
assert [job_id for job_id, _ in pull_request_jobs(doc)] == ["j"], condition
assert len(stored_secret_faults("synthetic.yml", doc)) == 1, condition
gated = {"if": "${{ github.event_name != 'pull_request' }}", "env": {"X": "${{ secrets.REGISTRY_PASSWORD }}"}}
excluded = {True: ["pull_request"], "jobs": {"j": gated}}
assert pull_request_jobs(excluded) == []
assert stored_secret_faults("synthetic.yml", excluded) == []
def test_the_stored_secret_collector_reports_a_pull_request_job_naming_ONE() -> None:
"""Negative control on the collector, over every shape the credential actually entered through."""
for job in (
{"container": {"image": "x", "credentials": {"username": "${{ secrets.REGISTRY_USER }}"}}},
{"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}},
{"steps": [{"run": "x", "env": {"A": "${{ secrets.RENOVATE_TOKEN }}"}}]},
{"steps": [{"run": "echo ${{ secrets.SERVERMGMT_DEPLOY_KEY }}"}]},
):
doc = {True: {"pull_request": None}, "jobs": {"j": job}}
faults = stored_secret_faults("synthetic.yml", doc)
assert len(faults) == 1, job
assert "names stored secret(s) on the pull_request route" in faults[0]
# The injected token is not a stored secret and must not be reported.
injected = {True: {"pull_request": None}, "jobs": {"j": {"env": {"A": "${{ secrets.GITEA_TOKEN }}"}}}}
assert stored_secret_faults("synthetic.yml", injected) == []
# A workflow that never triggers on `pull_request` is out of the population entirely, and
# `pull_request_target` does NOT put it back in: that trigger is base-resolved, so the YAML that
# runs is `main`'s rather than the head's.
for trigger in ({"push": None}, {"pull_request_target": None}, {"workflow_dispatch": None}):
off_route = {True: trigger, "jobs": {"j": {"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}}}}
assert stored_secret_faults("synthetic.yml", off_route) == [], trigger
def test_the_collector_sees_every_SPELLING_of_a_secret_reference() -> None:
"""The spellings that are the SAME reference to the evaluator and were invisible to the detector.
Driven against the real predecessor (`dot_only` below is `_SECRET_REF` exactly as this branch
shipped it before this commit), because that is the mutation this test exists to catch: revert
the widening and every case here goes back to `faults == []`. It cannot be caught by the
text-versus-walk cross-check — both halves read through `secret_refs`, 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, the predecessor reported
`stored_secret_faults(...) == []` and `walk_versus_text_faults(...) == []` on the index spelling.
Whether act_runner resolves each of these against this instance was NOT probed from here — that
needs a live run. The direction is what makes that acceptable: a spelling the runner happens not
to support costs a spurious demand on a job nobody has written; the omission cost a live
write-capable credential on the head-authored route.
"""
dot_only = re.compile(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)")
for expression, reported in (
("${{ secrets['REGISTRY_PASSWORD'] }}", "REGISTRY_PASSWORD"),
('${{ secrets["REGISTRY_PASSWORD"] }}', "REGISTRY_PASSWORD"),
("${{ secrets [ 'REGISTRY_PASSWORD' ] }}", "REGISTRY_PASSWORD"),
("${{ Secrets.REGISTRY_PASSWORD }}", "REGISTRY_PASSWORD"),
# Names NO secret and hands over ALL of them; and an index the expression COMPUTES, which no
# static reader resolves. Both are reported under the whole-context sentinel.
("${{ toJSON(secrets) }}", WHOLE_SECRETS_CONTEXT),
("${{ secrets[format('{0}_PASSWORD', 'REGISTRY')] }}", WHOLE_SECRETS_CONTEXT),
):
assert dot_only.findall(expression) == [], (
f"{expression!r} is supposed to be a spelling the dot-only predecessor could not see — "
"if it can, this row proves nothing about the widening."
)
assert secret_refs(expression) == [reported], expression
doc = {True: {"pull_request": None}, "jobs": {"j": {"env": {"A": expression}}}}
faults = stored_secret_faults("synthetic.yml", doc)
assert len(faults) == 1, (expression, faults)
assert reported in faults[0], (expression, faults)
# The workflow scope is judged through the same function, so it widens with it.
scoped = {True: {"pull_request": None}, "env": {"A": "${{ secrets['REGISTRY_PASSWORD'] }}"}, "jobs": {}}
assert len(stored_secret_faults("synthetic.yml", scoped)) == 1
# The cross-check now COUNTS these spellings on both sides rather than agreeing at zero.
indexed = "jobs:\n j:\n env:\n A: \"${{ secrets['REGISTRY_PASSWORD'] }}\"\n"
assert secret_name_counts(yaml.safe_load(indexed)) == Counter({"REGISTRY_PASSWORD": 1})
assert walk_versus_text_faults("synthetic.yml", indexed) == []
# The over-match direction, which is the cost of matching a bare `secrets` at all: the word is
# only a context reference INSIDE an expression. Ordinary prose and a longer identifier are not.
assert secret_refs("echo 'no secrets are used here'") == []
assert secret_refs("${{ env.mysecrets.REGISTRY_PASSWORD }}") == []
prose = {True: {"pull_request": None}, "jobs": {"j": {"steps": [{"run": "echo 'no secrets here'"}]}}}
assert stored_secret_faults("synthetic.yml", prose) == []
# The prose row above passes for a reason that does not generalise — no `.` follows the word. An
# English sentence ENDING in "secrets." is the shape that reached the unscoped predecessor, and
# it is the likeliest sentence to be written into a PR-route `run:` block on this branch's own
# subject. Driven against that predecessor: `unscoped` is `_SECRET_REF` applied to the whole
# string, which is how `secret_refs` read before the span scoping.
for sentence in (
"# We deliberately pass no secrets. Then the pull is anonymous.",
"echo 'this job holds no secrets. Anonymous pull only'",
):
unscoped = [next(group for group in m.groups() if group is not None) for m in _SECRET_REF.finditer(sentence)]
assert unscoped, (
f"{sentence!r} is supposed to be a sentence the unscoped predecessor read as a secret "
"name — if it is not, this row proves nothing about the scoping."
)
assert secret_refs(sentence) == [], sentence
commented = {True: {"pull_request": None}, "jobs": {"j": {"steps": [{"run": f"{sentence}\ntrue"}]}}}
assert stored_secret_faults("synthetic.yml", commented) == [], sentence
# The same comment reddened the text-versus-walk cross-check separately, and for a different
# mechanism: the strip is LINE-level, so a fabricated name inside a `run:` scalar was removed
# from the text half only and the two halves disagreed on a name no workflow ever held.
as_text = f"jobs:\n j:\n steps:\n - run: |\n {sentence}\n true\n"
assert walk_versus_text_faults("synthetic.yml", as_text) == [], sentence
# AND THE SPELLING THAT SCOPING COSTS: an `if:` value is an expression whether or not it is
# wrapped, so a condition naming a stored secret contains no `${{` and the span scoping cannot
# see it. `docker-build.yml`'s own `build` job carries an unwrapped `if:`, so this is the shape
# the repo already writes and not a hypothetical. Driven against the REAL predecessor on BOTH
# rows: `secret_refs` is how `secret_name_counts` read an `if:` value before `condition_refs`,
# 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) == [], (
f"{condition!r} is supposed to be invisible to the span-scoped reader — if it is not, "
"this row proves nothing about `condition_refs`."
)
assert condition_refs(condition) == [name], condition
job = (
{"steps": [{"if": condition, "run": "true"}]} if at_step else {"if": condition, "steps": [{"run": "true"}]}
)
doc = {True: {"pull_request": None}, "jobs": {"j": job}}
faults = stored_secret_faults("synthetic.yml", doc)
assert len(faults) == 1, (name, faults)
assert name in faults[0], (name, faults)
# A WRAPPED condition counts once, not twice — the delimiters are neutralised rather than read as
# a second span, so the cross-check still agrees with the text half on the shape workflows write.
wrapped = "jobs:\n j:\n if: ${{ secrets.REGISTRY_PASSWORD != '' }}\n steps:\n - run: true\n"
assert secret_name_counts(yaml.safe_load(wrapped)) == Counter({"REGISTRY_PASSWORD": 1})
assert walk_versus_text_faults("synthetic.yml", wrapped) == []
# A condition MIXING the two forms is read whole, so the unwrapped half is not lost behind the
# wrapped one.
assert condition_refs("${{ true }} && secrets.RENOVATE_TOKEN != ''") == ["RENOVATE_TOKEN"]
# And two wrapped references in one condition stay two: the delimiters become a SPACE, so the
# names cannot collapse into one identifier.
assert condition_refs("${{ secrets.REGISTRY_USER }}${{ secrets.REGISTRY_PASSWORD }}") == [
"REGISTRY_USER",
"REGISTRY_PASSWORD",
]
# The clause is on the `if:` KEY, so the English sentences above are untouched by it: they are
# `run:` scalars, where the word is prose and the scoping still costs nothing.
still_clean = {
True: {"pull_request": None},
"jobs": {"j": {"if": "github.event_name == 'push'", "steps": [{"run": "# We pass no secrets. Then true"}]}},
}
assert stored_secret_faults("synthetic.yml", still_clean) == []
# The pinned exclusion is itself an unwrapped condition and must stay clean — it names no secret,
# and reading conditions must not start faulting every gated job.
assert condition_refs("github.event_name != 'pull_request'") == []
def test_a_SECRETS_HANDOVER_naming_nothing_is_reported_under_the_WHOLE_CONTEXT_sentinel() -> None:
"""`secrets: inherit` hands the WHOLE store to a `uses:` job while naming no secret at all.
The `toJSON(secrets)` shape one level up — in the YAML grammar rather than the expression
grammar — and so the one shape the span scoping above cannot see, since `inherit` is a plain
scalar and not an expression. Driven against the REAL predecessor rather than a hand-written
mutant: `secret_names(job) - INJECTED_SECRETS` is the collector exactly as it read before this
clause, and it is asserted empty on every row here while the job IS in the population — walked,
and reported clean. That is a job joining the population unprotected without reddening anything.
Whether act_runner on this instance resolves `workflow_call` + `secrets: inherit` was NOT probed
from here; it affects reachability today, not the guard's silence, and the direction is the same
one the spelling rows take — an unsupported shape costs a spurious demand on a job nobody has
written.
"""
for value in ("inherit", "INHERIT", None, ["REGISTRY_PASSWORD"]):
job = {"uses": "./.gitea/workflows/reusable.yml", "secrets": value}
doc = {True: {"pull_request": None}, "jobs": {"reused": job}}
assert [job_id for job_id, _ in pull_request_jobs(doc)] == ["reused"], value
assert secret_names(job) - INJECTED_SECRETS == set(), (
f"a `secrets:` value of {value!r} is supposed to name nothing the reference collector "
"can see — if it does, this row proves nothing about the handover clause."
)
faults = stored_secret_faults("synthetic.yml", doc)
assert len(faults) == 1, (value, faults)
assert WHOLE_SECRETS_CONTEXT in faults[0], (value, faults)
# The KEY is read case-insensitively, the direction `_SECRET_REF` takes — asserted rather than
# only claimed in the comment beside it.
cased = {True: {"pull_request": None}, "jobs": {"reused": {"uses": "./x.yml", "Secrets": "inherit"}}}
assert len(stored_secret_faults("synthetic.yml", cased)) == 1
# The MAPPING spelling is not what this clause reports: every value in it is an expression the
# reference collector already reads, so it faults by NAME and not under the sentinel.
named = {
True: {"pull_request": None},
"jobs": {"reused": {"uses": "./x.yml", "secrets": {"TOK": "${{ secrets.RENOVATE_TOKEN }}"}}},
}
faults = stored_secret_faults("synthetic.yml", named)
assert len(faults) == 1, faults
assert "RENOVATE_TOKEN" in faults[0] and WHOLE_SECRETS_CONTEXT not in faults[0], faults
# A `workflow_call` DECLARATION is a mapping of names, not a handover — the negative control that
# keeps the clause from faulting every reusable workflow that declares its own inputs.
declaring = {
True: {"pull_request": None, "workflow_call": {"secrets": {"TOK": {"required": True}}}},
"jobs": {"j": {"steps": [{"run": "true"}]}},
}
assert stored_secret_faults("synthetic.yml", declaring) == []
# The workflow scope is judged through the same helper, so the clause widens with it.
scoped = {True: {"pull_request": None}, "secrets": "inherit", "jobs": {}}
scope_faults = stored_secret_faults("synthetic.yml", scoped)
assert len(scope_faults) == 1 and "WORKFLOW SCOPE" in scope_faults[0], scope_faults
# And it is judged on the route only, and only for a job the trigger reaches.
off_route = {True: {"push": None}, "jobs": {"reused": {"uses": "./x.yml", "secrets": "inherit"}}}
assert stored_secret_faults("synthetic.yml", off_route) == []
gated = {
True: {"pull_request": None},
"jobs": {
"reused": {"if": "github.event_name != 'pull_request'", "uses": "./x.yml", "secrets": "inherit"},
},
}
assert stored_secret_faults("synthetic.yml", gated) == []
# The clause is DOCUMENT-ONLY: `secrets:` is a plain YAML key, so the text half of the
# cross-check has nothing to match and must stay SILENT rather than report a disagreement over a
# reference neither half can name.
as_text = "jobs:\n reused:\n uses: ./x.yml\n secrets: inherit\n"
assert secret_name_counts(yaml.safe_load(as_text)) == Counter()
assert walk_versus_text_faults("synthetic.yml", as_text) == []
def test_the_collector_reports_a_WORKFLOW_SCOPE_reference_no_job_if_can_reach() -> None:
"""The workflow scope is judged even when every job is gated OFF the route.
This is the case a job-only walk gets exactly backwards: the document looks maximally safe (its
one job carries the pinned exclusion) while the root `env:` is materialised into that job's
environment on the head-authored route anyway. The fault must therefore not depend on any job
being in the population.
"""
for scope in (
{"env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}},
{"defaults": {"run": {"shell": "bash -c 'echo ${{ secrets.RENOVATE_TOKEN }}'"}}},
):
doc = {
True: {"pull_request": None},
**scope,
"jobs": {"j": {"if": "github.event_name != 'pull_request'", "steps": [{"run": "true"}]}},
}
faults = stored_secret_faults("synthetic.yml", doc)
assert len(faults) == 1, (scope, faults)
assert "WORKFLOW SCOPE" in faults[0], faults[0]
assert pull_request_jobs(doc) == [], "the job is supposed to be OFF the route in this fixture"
# The injected token is not a stored secret at workflow scope either.
injected = {True: {"pull_request": None}, "env": {"A": "${{ secrets.GITEA_TOKEN }}"}, "jobs": {}}
assert stored_secret_faults("synthetic.yml", injected) == []
# And the scope is only judged on the route: a push-only workflow may hold one.
off_route = {True: {"push": None}, "env": {"A": "${{ secrets.REGISTRY_PASSWORD }}"}, "jobs": {}}
assert stored_secret_faults("synthetic.yml", off_route) == []
def test_MUTATION_a_WORKFLOW_LEVEL_env_in_the_SHIPPED_workflow_is_DETECTED() -> None:
"""Drives the SHIPPED `docker-build.yml`, hoisting the credential to workflow scope.
This is the mutation that a name-set cross-check could not see (measured 2026-09-05 against the
predecessor of this commit: `14 passed`, rc=0) — `build` already names both halves, so a second
copy at root changed no NAME. It is exercised on the real file rather than a synthetic document for the same
reason `test_MUTATION_the_BUILD_job_losing_its_route_EXCLUSION_is_DETECTED` is: `docker-build.yml`
is the file the invariant is about, and a fixture would only prove the collector works on a
document this repo does not ship.
"""
root = Path(__file__).resolve().parents[2]
doc = yaml.safe_load((root / ".gitea/workflows/docker-build.yml").read_text())
assert stored_secret_faults("docker-build.yml", doc) == [], "the unmutated tree must be clean"
assert runs_on_pull_request(doc), "`docker-build.yml` no longer triggers on pull_request"
assert "REGISTRY_PASSWORD" in secret_names(doc["jobs"]["build"]), (
"`build` no longer names `REGISTRY_PASSWORD`, so this mutation no longer reproduces the "
"cancelling name set it was written for — re-point it before trusting it."
)
assert secret_names(outside_jobs(doc)) == set(), "the shipped workflow scope must name no secret"
doc["env"]["ETV_REGISTRY_AUTH"] = "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}"
faults = stored_secret_faults("docker-build.yml", doc)
assert len(faults) == 1, f"expected exactly one fault, got {faults}"
assert "WORKFLOW SCOPE" in faults[0], faults[0]
assert "REGISTRY_PASSWORD" in faults[0] and "REGISTRY_USER" in faults[0], faults[0]
def test_MUTATION_the_BUILD_job_losing_its_route_EXCLUSION_is_DETECTED() -> None:
"""Drives the SHIPPED workflow, one clause changed — not a synthetic document.
`build` is the one job that still holds `REGISTRY_PASSWORD`, and the only thing keeping it off
the head-authored route is its `if: github.event_name != 'pull_request'`. Deleting that clause is
the whole defect this guard exists for, so the guard is required to name it, by job id and by
secret. A synthetic fixture cannot show that: it would prove the collector works on a document
this repo does not ship.
"""
root = Path(__file__).resolve().parents[2]
doc = yaml.safe_load((root / ".gitea/workflows/docker-build.yml").read_text())
assert stored_secret_faults("docker-build.yml", doc) == [], "the unmutated tree must be clean"
build = doc["jobs"]["build"]
assert _unwrap_expression(build["if"]) in PR_EXCLUDING_IFS, (
"`build`'s `if:` is no longer the pinned pull_request exclusion, so this mutation no longer "
f"changes anything — it reads {build.get('if')!r}. Re-point the mutation before trusting it."
)
assert "REGISTRY_PASSWORD" in secret_names(build), "`build` no longer names the secret this mutation is about"
del build["if"]
faults = stored_secret_faults("docker-build.yml", doc)
assert len(faults) == 1, f"expected exactly one fault, got {faults}"
assert "job `build`" in faults[0] and "REGISTRY_PASSWORD" in faults[0], faults[0]