|
| 1 | +"""CI hygiene regression tests. |
| 2 | +
|
| 3 | +Ensures workflow files follow security best practices: |
| 4 | +- All GitHub Actions are SHA-pinned (no mutable tags like @v4) |
| 5 | +- No silent-failure traps (|| true on validation steps) |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import pytest |
| 11 | +import re |
| 12 | +from pathlib import Path |
| 13 | + |
| 14 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 15 | +WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" |
| 16 | + |
| 17 | +# Pattern: uses: OWNER/ACTION@REF |
| 18 | +# SHA-pinned refs are exactly 40 hex chars. |
| 19 | +# Mutable tags look like @v4, @v4.2.2, @main, @release/v1, etc. |
| 20 | +USES_PATTERN = re.compile(r"uses:\s*([^@\s]+)@(\S+)") |
| 21 | +SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$") |
| 22 | + |
| 23 | +# Local/composite actions (e.g. ./.github/actions/foo) don't need SHA pins. |
| 24 | +LOCAL_ACTION_PREFIX = "./" |
| 25 | + |
| 26 | + |
| 27 | +class TestWorkflowHygiene: |
| 28 | + """Regression guards for CI workflow security and correctness.""" |
| 29 | + |
| 30 | + @pytest.fixture |
| 31 | + def workflow_files(self) -> list[Path]: |
| 32 | + files = list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml")) |
| 33 | + if not files: |
| 34 | + pytest.skip("No workflow files found") |
| 35 | + return files |
| 36 | + |
| 37 | + def test_all_actions_sha_pinned(self, workflow_files: list[Path]) -> None: |
| 38 | + """Every remote action reference must use a 40-char SHA, not a mutable tag. |
| 39 | +
|
| 40 | + Mutable tags like @v4 can be silently moved to point at different commits, |
| 41 | + creating a supply-chain attack vector. SHA pins lock the exact commit. |
| 42 | + """ |
| 43 | + violations: list[str] = [] |
| 44 | + for wf in workflow_files: |
| 45 | + for lineno, line in enumerate(wf.read_text(encoding="utf-8").splitlines(), 1): |
| 46 | + match = USES_PATTERN.search(line) |
| 47 | + if not match: |
| 48 | + continue |
| 49 | + action, ref = match.group(1), match.group(2) |
| 50 | + # Strip inline comments (e.g. "# v4.2.2") |
| 51 | + ref = ref.split("#")[0].strip() |
| 52 | + if action.startswith(LOCAL_ACTION_PREFIX): |
| 53 | + continue |
| 54 | + if not SHA_PATTERN.match(ref): |
| 55 | + violations.append(f"{wf.name}:{lineno} {action}@{ref}") |
| 56 | + |
| 57 | + assert not violations, ( |
| 58 | + f"Found {len(violations)} mutable action reference(s). " |
| 59 | + "Pin to a 40-char SHA instead:\n" + "\n".join(violations) |
| 60 | + ) |
| 61 | + |
| 62 | + def test_no_silent_failure_on_validation_steps(self, workflow_files: list[Path]) -> None: |
| 63 | + """Validation/lint/test steps must not suppress failures with '|| true'. |
| 64 | +
|
| 65 | + A step whose purpose is to fail the build on defects (linters, type |
| 66 | + checkers, security scanners) must not hide failures. This catches the |
| 67 | + 'validation theater' trap where a real check is neutered. |
| 68 | + """ |
| 69 | + validation_keywords = ("lint", "check", "test", "audit", "scan", "format", "typecheck") |
| 70 | + violations: list[str] = [] |
| 71 | + for wf in workflow_files: |
| 72 | + lines = wf.read_text(encoding="utf-8").splitlines() |
| 73 | + for lineno, line in enumerate(lines, 1): |
| 74 | + stripped = line.strip() |
| 75 | + if "|| true" not in stripped: |
| 76 | + continue |
| 77 | + # Check if this line or the step name above contains a validation keyword |
| 78 | + context = " ".join(lines[max(0, lineno - 5) : lineno]).lower() |
| 79 | + if any(kw in context for kw in validation_keywords): |
| 80 | + violations.append(f"{wf.name}:{lineno} {stripped[:80]}") |
| 81 | + |
| 82 | + assert not violations, ( |
| 83 | + f"Found {len(violations)} validation step(s) with '|| true' suppression. " |
| 84 | + "Remove the suppression so failures are visible:\n" + "\n".join(violations) |
| 85 | + ) |
0 commit comments