Coverage for .github / scripts / verify_action_pins.py: 100.00%
184 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1#!/usr/bin/env python3
2"""Single source for the GitHub Actions SHA-pinning contract.
4Every third-party ``uses:`` in this repository names a 40-character commit SHA
5and records the tag it came from in a trailing comment::
7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
9Three things have to be true for that shape to be worth anything, and this
10module owns all three so the PR-time pytest contract
11(``tests/test_workflow_security_contract.py``) and the CI job that additionally
12calls GitHub cannot drift apart about what "pinned" means:
141. **Format** — the ref is a commit SHA and the comment is an exact ``vX.Y.Z``
15 semantic version. A bare ``# v7`` would reintroduce the ambiguity SHA
16 pinning exists to remove: nobody reading the diff could tell which release
17 the hash is supposed to be, so nobody could catch a wrong one.
182. **Agreement** — every occurrence of an action resolves to the same SHA and
19 claims the same version. Subpath actions (``github/codeql-action/init`` and
20 ``…/analyze``) are checked per *repository*, because they are one repo and
21 therefore one commit.
223. **Truth** (``--verify-upstream``, needs network) — the tag in the comment
23 really does point at the pinned SHA on GitHub. Without this the comment is
24 an unverified claim, and a typo'd or copy-pasted version silently misleads
25 every future reviewer and every Dependabot bump.
27Exit status is 1 only for a *definitive* problem. A lookup that could not be
28completed (rate limit, timeout, deleted tag) is reported and tolerated, because
29failing every pull request on an api.github.com blip would train people to
30ignore this check.
32Usage::
34 python .github/scripts/verify_action_pins.py
35 python .github/scripts/verify_action_pins.py --verify-upstream
36"""
38from __future__ import annotations
40import argparse
41import json
42import os
43import re
44import sys
45import urllib.error
46import urllib.request
47from collections import defaultdict
48from collections.abc import Callable, Iterable, Sequence
49from dataclasses import dataclass
50from pathlib import Path
52ROOT = Path(__file__).resolve().parents[2]
53WORKFLOW_DIR = ROOT / ".github" / "workflows"
54ACTION_DIR = ROOT / ".github" / "actions"
56GITHUB_API = "https://api.github.com"
58#: ``uses:`` as a step key: optional list dash, then the ref, then whatever
59#: trails it. The ref stops at whitespace or ``#`` so the comment is parsed
60#: separately. A line that starts with ``#`` cannot match, so prose that
61#: mentions the unsafe form is not mistaken for a real ref.
62USES_LINE_RE = re.compile(
63 r"^(?P<indent>[ \t]*)(?:-[ \t]+)?uses:[ \t]+(?P<ref>[^\s#]+)(?P<trailer>.*)$"
64)
66#: Anything after the ref must be exactly one comment, nothing else.
67TRAILER_RE = re.compile(r"^[ \t]*#[ \t]*(?P<body>.*?)[ \t]*$")
69#: Deliberately strict: three numeric components, no pre-release or build
70#: metadata, no bare major. Actions publish ``vX.Y.Z`` releases; accepting
71#: less makes the comment unfalsifiable.
72SEMVER_TAG_RE = re.compile(r"^v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$")
74SHA_RE = re.compile(r"^[0-9a-f]{40}$")
76#: ``owner/repo`` in GitHub's own character set. Both halves are interpolated
77#: into an API path, and the strings come from workflow files — which, on a pull
78#: request from a fork, are attacker-authored. Anchoring the shape here means a
79#: crafted ``uses:`` cannot smuggle ``../``, a query string, a credential, or a
80#: second scheme into the request URL.
81REPOSITORY_RE = re.compile(
82 r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?/[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$"
83)
86@dataclass(frozen=True)
87class Pin:
88 """One ``uses:`` reference as it appears on disk."""
90 path: Path
91 line: int
92 ref: str
93 action: str
94 sha: str
95 version: str
96 trailer: str
98 @property
99 def repository(self) -> str:
100 """``owner/repo``, dropping any subpath.
102 ``github/codeql-action/init`` and ``github/codeql-action/analyze`` are
103 two entry points into one repository at one commit, so agreement and
104 upstream lookups are keyed here rather than on the full action path.
105 """
106 return "/".join(self.action.split("/")[:2])
108 @property
109 def local(self) -> bool:
110 """A same-repo composite ref, which travels with the commit itself."""
111 return self.action.startswith("./")
113 @property
114 def location(self) -> str:
115 try:
116 relative: Path | str = self.path.relative_to(ROOT)
117 except ValueError: # pragma: no cover - only when called out of tree
118 relative = self.path
119 return f"{relative}:{self.line}"
122@dataclass(frozen=True)
123class TagResolution:
124 """Outcome of asking GitHub what commit a tag points at."""
126 sha: str | None = None
127 error: str | None = None
130TagResolver = Callable[[str, str], TagResolution]
133def reference_files(root: Path | None = None) -> list[Path]:
134 """Every file in which this repository may name an action to run."""
135 base = root or ROOT
136 workflows = sorted((base / ".github" / "workflows").glob("*.yml"))
137 actions = sorted((base / ".github" / "actions").glob("*/action.yml"))
138 return workflows + actions
141def collect_pins(path: Path) -> list[Pin]:
142 """Parse every ``uses:`` line in one workflow or composite action.
144 Line-based on purpose: YAML parsing discards comments, and the comment is
145 half of the contract. ``tests/test_workflow_security_contract.py``
146 cross-checks the count found here against a structural walk, so a ref
147 cannot hide from this parser behind unusual formatting.
148 """
149 pins: list[Pin] = []
150 for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
151 match = USES_LINE_RE.match(line)
152 if not match:
153 continue
154 ref = match.group("ref").strip("\"'")
155 trailer = match.group("trailer")
156 comment = TRAILER_RE.match(trailer)
157 action, _, revision = ref.partition("@")
158 pins.append(
159 Pin(
160 path=path,
161 line=number,
162 ref=ref,
163 action=action,
164 sha=revision if SHA_RE.match(revision) else "",
165 version=comment.group("body") if comment else "",
166 trailer=trailer,
167 )
168 )
169 return pins
172def collect_all_pins(root: Path | None = None) -> list[Pin]:
173 return [pin for path in reference_files(root) for pin in collect_pins(path)]
176def third_party(pins: Iterable[Pin]) -> list[Pin]:
177 return [pin for pin in pins if not pin.local]
180def format_problems(pins: Iterable[Pin]) -> list[str]:
181 """Each third-party ref must be a commit SHA plus an exact ``vX.Y.Z``."""
182 problems: list[str] = []
183 for pin in third_party(pins):
184 if not pin.sha:
185 problems.append(f"{pin.location}: {pin.ref} is not pinned to a 40-character commit SHA")
186 continue
187 if not pin.version:
188 problems.append(
189 f"{pin.location}: {pin.action} is pinned to {pin.sha[:12]}… with no "
190 f"trailing '# vX.Y.Z' comment"
191 )
192 continue
193 if not SEMVER_TAG_RE.match(pin.version):
194 problems.append(
195 f"{pin.location}: {pin.action} version comment {pin.version!r} is not an "
196 f"exact vX.Y.Z semantic version"
197 )
198 return problems
201def consistency_problems(pins: Iterable[Pin]) -> list[str]:
202 """Every reference to one repository must agree on SHA and version.
204 Two SHAs for one repository means two different builds of the same action
205 run in the same pipeline — the drift `test_repeated_workflow_pins_agree`
206 forbids for tool versions, applied to actions. Two *versions* for one SHA
207 (or vice versa) means at least one comment is lying, which is the failure
208 this check exists to make loud.
209 """
210 by_repository: dict[str, set[str]] = defaultdict(set)
211 by_version: dict[str, set[str]] = defaultdict(set)
212 locations: dict[tuple[str, str, str], list[str]] = defaultdict(list)
214 for pin in third_party(pins):
215 if not pin.sha or not pin.version:
216 continue # format_problems already reports these
217 by_repository[pin.repository].add(pin.sha)
218 by_version[pin.repository].add(pin.version)
219 locations[(pin.repository, pin.sha, pin.version)].append(pin.location)
221 problems: list[str] = []
222 for repository in sorted(by_repository):
223 shas = sorted(by_repository[repository])
224 versions = sorted(by_version[repository])
225 if len(shas) > 1:
226 detail = ", ".join(
227 f"{sha[:12]}… at {', '.join(sorted(sites))}"
228 for sha in shas
229 for version in versions
230 for sites in [locations.get((repository, sha, version), [])]
231 if sites
232 )
233 problems.append(
234 f"{repository} is pinned to {len(shas)} different commits ({detail}); "
235 f"every reference to one action must resolve to one commit"
236 )
237 if len(versions) > 1:
238 problems.append(
239 f"{repository} claims {len(versions)} different versions "
240 f"({', '.join(versions)}); the comment must match the pinned commit"
241 )
242 return problems
245#: HTTP codes that mean "this credential is not welcome here" rather than
246#: "this tag is wrong". An org can block the GitHub Actions app, in which case a
247#: workflow's GITHUB_TOKEN is refused for that org's public repositories even
248#: though anonymous reads of them succeed.
249_CREDENTIAL_REJECTED = {401, 403}
252def _fetch_commit(
253 repository: str,
254 version: str,
255 token: str | None,
256 timeout: float,
257) -> tuple[TagResolution, int | None]:
258 """One attempt. Returns the outcome and the HTTP status, when there was one."""
259 url = f"{GITHUB_API}/repos/{repository}/commits/{version}"
260 # Both path components were shape-checked by resolve_tag, so this holds by
261 # construction; it is asserted anyway because it is the property that makes
262 # the urlopen below safe, and a future caller reaching _fetch_commit
263 # directly should fail loudly rather than issue an unconstrained request.
264 if not url.startswith(f"{GITHUB_API}/repos/"): # pragma: no cover - defensive
265 return TagResolution(error="refusing to request a non-GitHub URL"), None
266 request = urllib.request.Request( # noqa: S310 - constant https host, validated path
267 url,
268 headers={
269 "Accept": "application/vnd.github+json",
270 "X-GitHub-Api-Version": "2022-11-28",
271 "User-Agent": "gco-verify-action-pins",
272 **({"Authorization": f"Bearer {token}"} if token else {}),
273 },
274 )
275 # dynamic-urllib-use-detected below is suppressed because its premise does
276 # not hold here. The rule guards against a dynamic value choosing the scheme
277 # (``file://`` and friends): the scheme and host are the GITHUB_API constant,
278 # and the only interpolated values are an ``owner/repo`` matching
279 # REPOSITORY_RE and a tag matching SEMVER_TAG_RE, both rejected by
280 # resolve_tag before this runs, with the prefix re-asserted above. Switching
281 # to ``requests`` (the rule's own suggestion) would put a third-party import
282 # in a script that has to run with no dependency install.
283 try:
284 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
285 with urllib.request.urlopen(request, timeout=timeout) as response: # nosec B310 # noqa: S310
286 payload = json.loads(response.read().decode("utf-8"))
287 except urllib.error.HTTPError as error:
288 if error.code == 404:
289 return TagResolution(error=f"tag {version} not found upstream (HTTP 404)"), 404
290 if error.code in _CREDENTIAL_REJECTED or error.code == 429:
291 return (
292 TagResolution(error=f"rate limited or forbidden (HTTP {error.code})"),
293 error.code,
294 )
295 return TagResolution(error=f"HTTP {error.code}"), error.code
296 except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as error:
297 return TagResolution(error=f"lookup failed ({error.__class__.__name__})"), None
299 sha = payload.get("sha") if isinstance(payload, dict) else None
300 if not isinstance(sha, str) or not SHA_RE.match(sha):
301 return TagResolution(error="response carried no commit sha"), None
302 return TagResolution(sha=sha), None
305def resolve_tag(
306 repository: str,
307 version: str,
308 *,
309 token: str | None = None,
310 timeout: float = 15.0,
311) -> TagResolution:
312 """Ask GitHub which commit ``version`` points at in ``repository``.
314 ``/commits/{ref}`` dereferences annotated tags to the commit, which is the
315 same thing ``gh api repos/<owner>/<repo>/commits/<tag> --jq .sha`` returns
316 and therefore the same thing the pins were produced from.
318 A token that is *refused* (401/403) falls back to an anonymous read. Some
319 organizations block the GitHub Actions app, so a workflow's ``GITHUB_TOKEN``
320 gets 403 on their public repositories while an unauthenticated request to
321 the same URL returns 200 — observed with ``aquasecurity/setup-trivy``.
322 Without the fallback that pin would be permanently unverified while CI still
323 reported success, which is the worst outcome available: a check that looks
324 green precisely where it has stopped looking.
326 Both arguments are shape-checked before any request is made. They originate
327 in workflow files, which on a fork pull request are attacker-authored, so a
328 crafted ``uses:`` must not be able to steer the URL.
329 """
330 if not REPOSITORY_RE.match(repository):
331 return TagResolution(error=f"refusing to look up malformed repository {repository!r}")
332 if not SEMVER_TAG_RE.match(version):
333 return TagResolution(error=f"refusing to look up malformed version {version!r}")
335 resolution, status = _fetch_commit(repository, version, token, timeout)
336 if resolution.sha is None and token and status in _CREDENTIAL_REJECTED:
337 anonymous, _ = _fetch_commit(repository, version, None, timeout)
338 if anonymous.sha is not None:
339 return anonymous
340 return TagResolution(
341 error=f"{resolution.error}; anonymous retry also failed ({anonymous.error})"
342 )
343 return resolution
346def upstream_problems(
347 pins: Iterable[Pin],
348 resolver: TagResolver,
349) -> tuple[list[str], list[str]]:
350 """Confirm each version comment names the commit it is pinned to.
352 Returns ``(mismatches, unresolved)``. A mismatch is definitive: GitHub
353 answered, and the tag points somewhere other than the pinned SHA — either
354 the comment is wrong or the tag was moved, and both need a human. An
355 unresolved lookup is reported but not fatal.
356 """
357 wanted: dict[tuple[str, str], set[str]] = defaultdict(set)
358 sites: dict[tuple[str, str], list[str]] = defaultdict(list)
359 for pin in third_party(pins):
360 if not pin.sha or not SEMVER_TAG_RE.match(pin.version):
361 continue
362 wanted[(pin.repository, pin.version)].add(pin.sha)
363 sites[(pin.repository, pin.version)].append(pin.location)
365 mismatches: list[str] = []
366 unresolved: list[str] = []
367 for repository, version in sorted(wanted):
368 resolution = resolver(repository, version)
369 if resolution.sha is None:
370 unresolved.append(f"{repository}@{version}: {resolution.error}")
371 continue
372 for sha in sorted(wanted[(repository, version)]):
373 if sha != resolution.sha:
374 where = ", ".join(sorted(sites[(repository, version)]))
375 mismatches.append(
376 f"{repository} is pinned to {sha} but its comment says {version}, "
377 f"which upstream resolves to {resolution.sha} ({where})"
378 )
379 return mismatches, unresolved
382def _report(title: str, entries: Sequence[str]) -> None:
383 print(f"\n{title}")
384 for entry in entries:
385 print(f" - {entry}")
388def main(argv: Sequence[str] | None = None) -> int:
389 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
390 parser.add_argument(
391 "--verify-upstream",
392 action="store_true",
393 help="also resolve each version comment against GitHub (requires network)",
394 )
395 parser.add_argument(
396 "--require-complete",
397 action="store_true",
398 help="treat an unresolved upstream lookup as a failure instead of a warning",
399 )
400 args = parser.parse_args(argv)
402 pins = collect_all_pins()
403 external = third_party(pins)
404 print(
405 f"verify-action-pins: {len(external)} third-party ref(s) across "
406 f"{len(reference_files())} file(s); "
407 f"{len(pins) - len(external)} local composite ref(s) exempt"
408 )
410 problems = format_problems(pins)
411 problems += consistency_problems(pins)
412 if problems:
413 _report("Pinning problems:", problems)
415 unresolved: list[str] = []
416 if args.verify_upstream:
417 token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
418 if not token:
419 print(
420 "verify-action-pins: no GH_TOKEN/GITHUB_TOKEN; using unauthenticated "
421 "API (60 requests/hour)"
422 )
423 mismatches, unresolved = upstream_problems(
424 pins, lambda repository, version: resolve_tag(repository, version, token=token)
425 )
426 repositories = {pin.repository for pin in external}
427 print(f"verify-action-pins: resolved {len(repositories)} action repositor(y|ies) upstream")
428 if mismatches:
429 _report("Version comments that do not match the pinned commit:", mismatches)
430 problems += mismatches
431 if unresolved:
432 _report("Incomplete lookups (not treated as failures):", unresolved)
434 if problems or (args.require_complete and unresolved):
435 print(f"\nverify-action-pins: FAILED ({len(problems)} problem(s))")
436 return 1
437 print("\nverify-action-pins: OK")
438 return 0
441if __name__ == "__main__":
442 sys.exit(main())