diff --git a/scripts/verify_supply_chain_pins.py b/scripts/verify_supply_chain_pins.py new file mode 100644 index 00000000..11fc8a52 --- /dev/null +++ b/scripts/verify_supply_chain_pins.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ACTION_SHA_RE = re.compile( + r"^\s*(?:-\s*)?uses:\s*([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?)@([0-9a-f]{40})\s*(?:#.*)?$" +) +ACTION_USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(\S+)") +DOCKER_FROM_RE = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)", re.IGNORECASE) +COMPOSE_IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\"'\s]+)") + + +def _workflow_files() -> list[Path]: + files: list[Path] = [] + for root in (ROOT / ".gitea/workflows", ROOT / ".github/workflows"): + if root.is_dir(): + files.extend(sorted(root.glob("*.yml"))) + files.extend(sorted(root.glob("*.yaml"))) + return files + + +def _check_workflows(violations: list[str]) -> None: + for path in _workflow_files(): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + match = ACTION_USES_RE.match(line) + if not match: + continue + value = match.group(1) + if value.startswith("./"): + continue + if value.startswith("docker://"): + image = value.removeprefix("docker://") + if "@sha256:" not in image: + violations.append( + f"{path.relative_to(ROOT)}:{number}: docker action is not digest-pinned" + ) + continue + if not ACTION_SHA_RE.match(line): + violations.append( + f"{path.relative_to(ROOT)}:{number}: action must use a full 40-char commit SHA: {value}" + ) + + +def _check_dockerfiles(violations: list[str]) -> None: + dockerfiles = ( + ROOT / "backend/Dockerfile", + ROOT / "frontend/Dockerfile", + ROOT / "deploy/unraid/Dockerfile.all-in-one", + ) + for path in dockerfiles: + if not path.is_file(): + continue + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + match = DOCKER_FROM_RE.match(line) + if not match: + continue + image = match.group(1) + if image == "scratch": + continue + if "${" in image: + violations.append( + f"{path.relative_to(ROOT)}:{number}: dynamic external base image is not allowed" + ) + continue + if "@sha256:" not in image: + violations.append( + f"{path.relative_to(ROOT)}:{number}: base image must be digest-pinned: {image}" + ) + + +def _check_compose(violations: list[str]) -> None: + for relative in ("docker-compose.yml", "docker-compose.unraid.yml"): + path = ROOT / relative + if not path.is_file(): + continue + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + match = COMPOSE_IMAGE_RE.match(line) + if not match: + continue + image = match.group(1) + if image.startswith(("geointel-", "${")): + continue + if "@sha256:" not in image: + violations.append( + f"{relative}:{number}: external image must be digest-pinned: {image}" + ) + + +def main() -> int: + violations: list[str] = [] + _check_workflows(violations) + _check_dockerfiles(violations) + _check_compose(violations) + if violations: + print("Supply-chain pin violations:", file=sys.stderr) + for violation in violations: + print(f" - {violation}", file=sys.stderr) + return 1 + print("Supply-chain pins passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())