Skip to content

Commit b501fc4

Browse files
test(ci): add SHA-pin and silent-failure regression tests
- test_all_actions_sha_pinned: enforces 40-char SHA refs for all remote actions - test_no_silent_failure_on_validation_steps: catches '|| true' suppression on lint/test/audit steps (validation theater trap) Regression guard so future mutable-tag PRs are caught in CI.
1 parent 74319b5 commit b501fc4

1 file changed

Lines changed: 85 additions & 0 deletions

File tree

tests/test_ci_hygiene.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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

Comments
 (0)