Files
pulse/scripts/check_workflow_trust.py
T
pulse-triage[bot] d8986c139a Enforce workflow data trust boundaries
Contract-Neutral: Moves workflow expressions into environment data flow without changing deployment interfaces or behavior.
2026-08-30 04:56:34 +01:00

240 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Fail closed when GitHub Actions trust inputs become mutable or implicit."""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
ACTION_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
CONTAINER_DIGEST_RE = re.compile(r"^docker://.+@sha256:[0-9a-f]{64}$")
HOSTED_LATEST_RE = re.compile(r"\b(?:ubuntu|windows|macos)-latest\b")
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)")
RUN_RE = re.compile(r"^(\s*)(?:-\s*)?run:\s*(.*)$")
EXPRESSION_RE = re.compile(r"\$\{\{(.*?)\}\}")
# Workflow-call and dispatch inputs are data, not shell source. Secrets include
# github.token because Actions makes that credential available independently of
# an explicit secrets.GITHUB_TOKEN reference.
SHELL_DATA_CONTEXT_RE = re.compile(
r"(?<![\w.])(?:inputs|secrets)\.|(?<![\w.])github\.token\b"
)
CHECKOUT_PREFIX = "actions/checkout@"
WRITE_CREDENTIAL_RATIONALE = "# required: authenticated git writes"
@dataclass(frozen=True)
class Finding:
path: Path
line: int
message: str
def render(self) -> str:
return f"{self.path}:{self.line}: {self.message}"
def _indent(line: str) -> int:
return len(line) - len(line.lstrip())
def _checkout_block(lines: list[str], uses_index: int) -> list[tuple[int, str]]:
"""Return lines belonging to the checkout step after its uses declaration."""
uses_indent = _indent(lines[uses_index])
block: list[tuple[int, str]] = []
for index in range(uses_index + 1, len(lines)):
line = lines[index]
stripped = line.strip()
if stripped and (
_indent(line) < uses_indent
or (_indent(line) == uses_indent and stripped.startswith("- "))
):
break
block.append((index, line))
return block
def _run_script_lines(lines: list[str], run_index: int) -> list[tuple[int, str]]:
"""Return the source lines GitHub will materialize as a run script."""
match = RUN_RE.match(lines[run_index])
if not match:
return []
run_indent = len(match.group(1))
value = match.group(2).strip()
if value not in {"|", "|-", "|+", ">", ">-", ">+"}:
return [(run_index, match.group(2))]
script: list[tuple[int, str]] = []
for index in range(run_index + 1, len(lines)):
line = lines[index]
if line.strip() and _indent(line) <= run_indent:
break
script.append((index, line))
return script
def audit_workflow(path: Path) -> list[Finding]:
lines = path.read_text(encoding="utf-8").splitlines()
findings: list[Finding] = []
permission_declarations = [
index
for index, line in enumerate(lines)
if re.match(r"^permissions\s*:", line)
]
if len(permission_declarations) != 1:
findings.append(
Finding(
path,
1,
"workflow must declare top-level permissions explicitly exactly once",
)
)
elif lines[permission_declarations[0]].split("#", 1)[0].strip() not in {
"permissions:",
"permissions: {}",
}:
findings.append(
Finding(
path,
permission_declarations[0] + 1,
"workflow permissions must use a scope mapping or explicit empty mapping",
)
)
for index, line in enumerate(lines):
line_number = index + 1
code = line.split("#", 1)[0]
if HOSTED_LATEST_RE.search(code):
findings.append(
Finding(
path,
line_number,
"mutable hosted runner label; use an explicit dated image",
)
)
if RUN_RE.match(code):
for script_index, script_line in _run_script_lines(lines, index):
for expression in EXPRESSION_RE.findall(script_line):
if SHELL_DATA_CONTEXT_RE.search(expression):
findings.append(
Finding(
path,
script_index + 1,
"workflow inputs and secrets must enter run scripts through env",
)
)
match = USES_RE.search(code)
if not match:
continue
dependency = match.group(1).strip("'\"")
if dependency.startswith("./"):
continue
if dependency.startswith("docker://"):
if not CONTAINER_DIGEST_RE.fullmatch(dependency):
findings.append(
Finding(
path,
line_number,
"container action is not pinned to a sha256 digest",
)
)
continue
owner_and_action, separator, ref = dependency.rpartition("@")
if not separator or "/" not in owner_and_action or not ACTION_SHA_RE.fullmatch(ref):
findings.append(
Finding(
path,
line_number,
"remote action or reusable workflow is not pinned to a full commit SHA",
)
)
continue
# GitHub repository names are case-insensitive, so normalize before
# applying checkout-specific credential controls.
if not dependency.lower().startswith(CHECKOUT_PREFIX):
continue
credential_settings = [
(block_index, block_line)
for block_index, block_line in _checkout_block(lines, index)
if re.match(r"^\s*persist-credentials\s*:", block_line)
]
if len(credential_settings) != 1:
findings.append(
Finding(
path,
line_number,
"checkout must set persist-credentials explicitly exactly once",
)
)
continue
setting_index, setting = credential_settings[0]
value_match = re.match(
r"^\s*persist-credentials\s*:\s*(true|false)\b", setting
)
if not value_match:
findings.append(
Finding(
path,
setting_index + 1,
"persist-credentials must be the literal true or false",
)
)
elif value_match.group(1) == "true" and WRITE_CREDENTIAL_RATIONALE not in setting:
findings.append(
Finding(
path,
setting_index + 1,
f"persisted checkout credentials require {WRITE_CREDENTIAL_RATIONALE}",
)
)
return findings
def audit_directory(workflow_directory: Path) -> list[Finding]:
findings: list[Finding] = []
paths = sorted(workflow_directory.glob("*.yml")) + sorted(
workflow_directory.glob("*.yaml")
)
for path in paths:
findings.extend(audit_workflow(path))
return findings
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"workflow_directory",
nargs="?",
type=Path,
default=Path(__file__).resolve().parents[1] / ".github" / "workflows",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if not args.workflow_directory.is_dir():
print(f"workflow directory not found: {args.workflow_directory}", file=sys.stderr)
return 2
findings = audit_directory(args.workflow_directory)
if findings:
for finding in findings:
print(finding.render(), file=sys.stderr)
print(f"GitHub Actions trust validation failed ({len(findings)} finding(s)).", file=sys.stderr)
return 1
print("GitHub Actions trust validation passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())