`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that
run executes contributor-authored YAML and every `secrets.*` it names is materialised into it.
Six jobs held `REGISTRY_PASSWORD` that way — `toolchain-preflight`, `test`, `migrations`,
`functional-e2e`, `api-docs`, `format` — two of them branch-protection required contexts.
The read-only pull PAT the issue asked to cost first was REJECTED, and the measurement is the
reason: this registry already issues an anonymous pull token for `timothy/ersatztv-ci`
(`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200), that token reads the pinned
manifest and its config blob (200/200), and the combined-status GET answers 200 unauthenticated.
A read-only PAT would grant exactly what anonymity grants while adding one more credential to the
store head-supplied YAML reaches. So the stronger form was implemented instead: no PR-route job
names a stored secret at all.
- `.gitea/workflows/docker-build.yml`: the five `container: credentials:` blocks, the
`ETV_REGISTRY_AUTH` step env and the three `ETV_STATUS_AUTH` step envs are gone. `build` keeps
the PAT; it is gated `if: github.event_name != 'pull_request'`.
- `scripts/ci-toolchain-image-resolves.sh`: reads `realm` out of the `Www-Authenticate` challenge,
exchanges it once per run for an anonymous pull token, retries with the bearer. Every refusal
direction is preserved — a 401/403 after the token leg, a token endpoint yielding no token, and
one that cannot be reached all `fail` rather than degrading to could-not-tell — and the message
now names the cause an operator can act on (the repo or package has stopped being public).
- `scripts/ci-detect-already-validated.sh`: the status GET is anonymous. No credential override is
kept: the URL names one instance, that instance is public, and an unusable `":"` would draw a 401
and turn a working read into a permanent skip=false.
- `scripts/tests/test_workflow_persist_credentials.py`: the invariant, derived from the git index by
"every job of a `pull_request`-triggered workflow that names a `secrets.*`" — never the six-name
list, and never "every `container:` job", which names five of six because `toolchain-preflight` is
container-free. Witnessed red against the unfixed workflow naming all six jobs; green after.
Live tag protection applied and read back: `POST /repos/timothy/ersatztv/tag_protections`
`{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}` -> id 1. A non-`v*` probe tag pushed
and deleted proves tag pushes still work at all. The POSITIVE release-cut verification is DEFERRED
to the operator's next real cut: pushing a `v*` tag publishes the `:prod` image, which is a release,
not a verification step.
What this does not close, stated so the records are not cited as a boundary: `REGISTRY_PASSWORD`
stays in the Actions store for `build`, and head YAML can still name it, `RENOVATE_TOKEN` or
`SERVERMGMT_DEPLOY_KEY`. Blast radius, not the route.
New records `ci.pr-route-carries-no-stored-credential` and `release.tag-protection-v-star`;
`ci.workflow-dispatch-ref-unrestricted`, `ci.actions-credential-scoping` and
`release.main-direct-push-disabled` updated to match; catalog regenerated. Closes #885.
Decisions-Edit: yes
Proves: scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
598 lines
32 KiB
Python
598 lines
32 KiB
Python
"""Every `actions/checkout` drops the persisted credential (ersatztv#835).
|
|
|
|
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 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, bounded by the workflow's own `permissions:` block, and a head-authored
|
|
# run receives it whether or not any job names it. Allow-listing it is therefore a statement about a
|
|
# MECHANISM (injected, scoped, unavoidable) 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"
|
|
|
|
_SECRET_REF = re.compile(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)")
|
|
|
|
# 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_names(node: object) -> set[str]:
|
|
"""Every `secrets.NAME` reachable anywhere in a subtree — keys and values, at any depth.
|
|
|
|
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.
|
|
"""
|
|
found: set[str] = set()
|
|
stack: list[object] = [node]
|
|
while stack:
|
|
item = stack.pop()
|
|
if isinstance(item, dict):
|
|
for key, value in item.items():
|
|
stack.append(key)
|
|
stack.append(value)
|
|
elif isinstance(item, list):
|
|
stack.extend(item)
|
|
elif isinstance(item, str):
|
|
found.update(_SECRET_REF.findall(item))
|
|
return found
|
|
|
|
|
|
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
|
|
for job_id, job in pull_request_jobs(doc):
|
|
named = sorted(secret_names(job) - INJECTED_SECRETS)
|
|
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 test_the_job_walk_finds_every_secret_reference_the_TEXT_does() -> None:
|
|
"""The `jobs.<id>` walk and a plain text scan must agree, per file.
|
|
|
|
Without it, a reference the walk cannot reach — a workflow-level `env:`, a `defaults:` block, a
|
|
shape act_runner grows later — is invisible to the assertion above while it still reports clean.
|
|
The walk deliberately covers ALL jobs here, not just the reachable ones, because `build`'s
|
|
`REGISTRY_PASSWORD` is real text in a file the scan reads.
|
|
|
|
COMMENT-ONLY LINES ARE STRIPPED first, so that a comment DISCUSSING a secret name no job uses
|
|
does not read as a reference the walk missed — `review-verdict.yml:104` names
|
|
`secrets.GITEA_TOKEN` in prose. Say plainly what that buys TODAY, which is nothing: both halves
|
|
are SETS, and measured 2026-09-04 every name any comment mentions is also named by a job, so the
|
|
comparison agrees with the strip and without it. It is kept for the case that has not arrived
|
|
yet, not because it is currently load-bearing.
|
|
|
|
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 set 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 set 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.
|
|
"""
|
|
disagreements: list[str] = []
|
|
for path in workflow_files():
|
|
text = path.read_text()
|
|
doc = yaml.safe_load(text)
|
|
jobs = doc.get("jobs") if isinstance(doc, dict) else None
|
|
walked = secret_names(jobs) if isinstance(jobs, dict) else set()
|
|
scanned = set(
|
|
_SECRET_REF.findall("\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")))
|
|
)
|
|
if walked != scanned:
|
|
disagreements.append(
|
|
f"{path.name}: the jobs walk reached {sorted(walked)} but the text scan found "
|
|
f"{sorted(scanned)}. The walk is what the stored-secret assertion runs over, so the "
|
|
f"difference is secret references this guard cannot see — widen `secret_names`' "
|
|
f"entry point, or move the reference inside a job."
|
|
)
|
|
assert not disagreements, "\n".join(disagreements)
|
|
|
|
|
|
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_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]
|