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

1#!/usr/bin/env python3 

2"""Single source for the GitHub Actions SHA-pinning contract. 

3 

4Every third-party ``uses:`` in this repository names a 40-character commit SHA 

5and records the tag it came from in a trailing comment:: 

6 

7 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 

8 

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: 

13 

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. 

26 

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. 

31 

32Usage:: 

33 

34 python .github/scripts/verify_action_pins.py 

35 python .github/scripts/verify_action_pins.py --verify-upstream 

36""" 

37 

38from __future__ import annotations 

39 

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 

51 

52ROOT = Path(__file__).resolve().parents[2] 

53WORKFLOW_DIR = ROOT / ".github" / "workflows" 

54ACTION_DIR = ROOT / ".github" / "actions" 

55 

56GITHUB_API = "https://api.github.com" 

57 

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) 

65 

66#: Anything after the ref must be exactly one comment, nothing else. 

67TRAILER_RE = re.compile(r"^[ \t]*#[ \t]*(?P<body>.*?)[ \t]*$") 

68 

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+)$") 

73 

74SHA_RE = re.compile(r"^[0-9a-f]{40}$") 

75 

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) 

84 

85 

86@dataclass(frozen=True) 

87class Pin: 

88 """One ``uses:`` reference as it appears on disk.""" 

89 

90 path: Path 

91 line: int 

92 ref: str 

93 action: str 

94 sha: str 

95 version: str 

96 trailer: str 

97 

98 @property 

99 def repository(self) -> str: 

100 """``owner/repo``, dropping any subpath. 

101 

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]) 

107 

108 @property 

109 def local(self) -> bool: 

110 """A same-repo composite ref, which travels with the commit itself.""" 

111 return self.action.startswith("./") 

112 

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}" 

120 

121 

122@dataclass(frozen=True) 

123class TagResolution: 

124 """Outcome of asking GitHub what commit a tag points at.""" 

125 

126 sha: str | None = None 

127 error: str | None = None 

128 

129 

130TagResolver = Callable[[str, str], TagResolution] 

131 

132 

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 

139 

140 

141def collect_pins(path: Path) -> list[Pin]: 

142 """Parse every ``uses:`` line in one workflow or composite action. 

143 

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 

170 

171 

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)] 

174 

175 

176def third_party(pins: Iterable[Pin]) -> list[Pin]: 

177 return [pin for pin in pins if not pin.local] 

178 

179 

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 

199 

200 

201def consistency_problems(pins: Iterable[Pin]) -> list[str]: 

202 """Every reference to one repository must agree on SHA and version. 

203 

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) 

213 

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) 

220 

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 

243 

244 

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} 

250 

251 

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 

298 

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 

303 

304 

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``. 

313 

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. 

317 

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. 

325 

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}") 

334 

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 

344 

345 

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. 

351 

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) 

364 

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 

380 

381 

382def _report(title: str, entries: Sequence[str]) -> None: 

383 print(f"\n{title}") 

384 for entry in entries: 

385 print(f" - {entry}") 

386 

387 

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) 

401 

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 ) 

409 

410 problems = format_problems(pins) 

411 problems += consistency_problems(pins) 

412 if problems: 

413 _report("Pinning problems:", problems) 

414 

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) 

433 

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 

439 

440 

441if __name__ == "__main__": 

442 sys.exit(main())