From 8e77d7966b79429e8622370d067987b65d7e29b9 Mon Sep 17 00:00:00 2001 From: Mudwood Labs Date: Tue, 1 Sep 2026 00:11:50 -0700 Subject: [PATCH] chore(ci): SHA-pin the last three floating action refs, and keep them pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes Dependabot #550, which proposed `actions/checkout@v4` -> `@v7` in mutation.yml. That bump would have been an improvement in version and a REGRESSION IN POSTURE. Every other checkout in this repo is pinned to a full commit SHA; #550 would have left two pinned to a movable tag. Whoever controls a tag controls what executes in CI, with the repository checked out and the job's secrets in scope — which is the thing SHA pinning exists to prevent, and which this repo already runs Scorecard over. Pinning them to the SHA the rest of the repo already uses gets the version bump AND the consistency, so #550 is closed rather than merged. A THIRD STRAGGLER turned up only because fixing the two prompted a sweep for the whole set instead of the two the PR named: `setup-python@v7` in the same file was the last floating ref in the repository. Fixing the class, not the instance. THE CHECK IS A TEST, NOT A SCRIPT, and that is deliberate. This repo has already learned twice that a checker wired to nothing is not a checker — two mutation groups were added to `scripts/mutation_groups.py` and never to the CI matrix, and `check_coverage_omissions.py` sat as a taskipy task nothing invoked. The weakest link in "detect" is the wiring; `pytest tests/` runs in CI unconditionally, so a test has no separate wiring to forget. Two assertions, each verified by its own plant: * a floating ref anywhere in .github/workflows/ fails * the same action pinned to TWO different SHAs fails — the drift that leaves a stale version running in whichever lane nobody looked at plus a guard-the-guard check that the glob actually matched some workflows, since a glob matching nothing passes every assertion after it. The first plant attempt did not land (wrong target string) and the suite stayed green — which looks identical to a test that fails to catch. Re-planted against the real line before trusting either assertion. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/mutation.yml | 6 +- tests/test_workflow_actions_are_sha_pinned.py | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 tests/test_workflow_actions_are_sha_pinned.py diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index d5e2d09e..b2e6c6aa 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -74,8 +74,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - run: pip install -r ci/poetry-pin.txt --require-hashes @@ -94,7 +94,7 @@ jobs: outputs: matrix: ${{ steps.groups.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: groups # The `groups` dispatch input was DECLARED and read nowhere: `-f groups=fee` was # silently ignored and the operator got the whole matrix. Same class as a CLI flag diff --git a/tests/test_workflow_actions_are_sha_pinned.py b/tests/test_workflow_actions_are_sha_pinned.py new file mode 100644 index 00000000..222c75e1 --- /dev/null +++ b/tests/test_workflow_actions_are_sha_pinned.py @@ -0,0 +1,80 @@ +"""Every `uses:` in every GitHub workflow must be pinned to a full commit SHA. + +A floating tag (`actions/checkout@v7`) resolves at run time to whatever that tag points at THEN. +Whoever controls the tag controls what executes in CI, with the repository checked out and the +job's secrets in scope. A SHA cannot be moved. + +WHY THIS IS A TEST AND NOT A SCRIPT. The repo already learned that a checker wired to nothing is +not a checker: two mutation groups were added to `scripts/mutation_groups.py` and not to the CI +matrix, and a coverage-omission script sat as a taskipy task nothing invoked. `pytest tests/` runs +in CI unconditionally, so a test has no separate wiring to forget — the weakest link in +"detect" is the wiring, and this removes it. + +HOW IT WAS FOUND. Dependabot #550 proposed bumping `actions/checkout@v4` -> `@v7` in +`mutation.yml`. Merging it would have been an improvement in version and a regression in posture: +every other checkout in the repo is SHA-pinned, and that PR would have left two floating. Pinning +them to the SHA the rest of the repo already uses achieves the bump AND the consistency. A third +straggler — `setup-python@v7`, the last floating tag in the repository — turned up only because +fixing the two prompted a sweep for the whole set rather than the two the PR named. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +_WORKFLOWS = sorted((Path(__file__).resolve().parent.parent / ".github" / "workflows").glob("*.yml")) + +#: `uses: owner/repo@ref` or `uses: ./local/path`. Docker refs (`docker://`) are out of scope. +_USES = re.compile(r"^\s*-?\s*uses:\s*(?P\S+)") +_SHA = re.compile(r"^[0-9a-f]{40}$") + + +def test_there_are_workflows_to_check() -> None: + """Guards the guard. A glob that silently matches nothing passes every assertion below.""" + assert len(_WORKFLOWS) >= 5, f"only found {len(_WORKFLOWS)} workflow files; the glob is wrong" + + +@pytest.mark.parametrize("path", _WORKFLOWS, ids=lambda p: p.name) +def test_every_action_reference_is_sha_pinned(path: Path) -> None: + floating: list[tuple[int, str]] = [] + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + m = _USES.match(line) + if not m: + continue + ref = m.group("ref") + if ref.startswith("./") or ref.startswith("docker://"): + continue # a local composite action is this repo's own code; a docker ref is digest-pinned separately + _, _, version = ref.partition("@") + if not _SHA.match(version): + floating.append((lineno, line.strip())) + + assert not floating, ( + f"{path.name} references actions by a MOVABLE ref, so whoever controls the tag controls " + f"what runs in CI with this repo checked out:\n" + + "\n".join(f" {path.name}:{n} {text}" for n, text in floating) + + "\n\nPin to the full commit SHA and keep the version in a trailing comment, e.g.\n" + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1" + ) + + +def test_the_same_action_is_pinned_to_ONE_sha_everywhere() -> None: + """Two SHAs for one action means a bump landed in some workflows and not others — the drift + that leaves a stale, possibly-vulnerable version running in the lane nobody looked at.""" + seen: dict[str, set[str]] = {} + for path in _WORKFLOWS: + for line in path.read_text(encoding="utf-8").splitlines(): + m = _USES.match(line) + if not m: + continue + ref = m.group("ref") + if ref.startswith("./") or ref.startswith("docker://"): + continue + name, _, version = ref.partition("@") + if _SHA.match(version): + seen.setdefault(name, set()).add(version) + + split = {name: shas for name, shas in seen.items() if len(shas) > 1} + assert not split, f"these actions are pinned to more than one SHA across workflows: {split}"