Files
cacti/.github/scripts/check-workflow-policy.py
Thomas Vincent 2e372e5b88 Make PHP 8.1 dependency builds reproducible (#7746)
* Make PHP 8.1 dependency builds reproducible

* Tighten integration runner permissions

* Harden and standardize GitHub Actions

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* Restore ripgrep for security guards

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* Wait for Cacti daemon readiness in CI

* Satisfy strict shell policy in diagnostics

* Preserve Cacti config group ownership

* Run stateful Cacti checks as the web user

* Run privileged page-crawl harness as root

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-08-16 22:06:36 +01:00

217 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Enforce workflow hygiene policy for selected GitHub Actions workflows."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
import yaml
PINNED_REF_RE = re.compile(r"^[0-9a-f]{40}$")
CURL_PIPE_RE = re.compile(r"curl\b[^\n|]*\|\s*(?:sudo\s+)?(?:/(?:usr/)?bin/)?(?:sh|bash)\b")
STRICT_LINE = "set -euo pipefail"
DEFAULT_WORKFLOW_GLOB = ".github/workflows/*"
DEFAULT_ACTION_GLOB = ".github/actions/**/action.y*ml"
ALLOWED_RUNNERS = {"ubuntu-24.04", "ubuntu-slim"}
CHECKOUT_WRITE_WORKFLOWS = {
".github/workflows/security-baseline-refresh.yml",
".github/workflows/update-contributors.yml",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument(
"--workflows",
nargs="*",
default=[],
help="Explicit workflow file paths to validate (relative to repo root).",
)
return parser.parse_args()
def normalize_steps(job: dict) -> list[dict]:
steps = job.get("steps")
return steps if isinstance(steps, list) else []
def check_uses(path: str, step_name: str, uses_value: str, violations: list[str]) -> None:
# Local composite actions (./...) ship in-repo and need no pin.
if uses_value.startswith("./"):
return
# Container actions must be pinned to an immutable @sha256: digest, not a tag.
if uses_value.startswith("docker://"):
if "@sha256:" not in uses_value:
violations.append(f"{path}:{step_name}: docker:// reference must be pinned by @sha256 digest: {uses_value}")
return
if "@" not in uses_value:
violations.append(f"{path}:{step_name}: uses reference is missing @ref: {uses_value}")
return
ref = uses_value.split("@", 1)[1]
if not PINNED_REF_RE.fullmatch(ref):
violations.append(f"{path}:{step_name}: action ref must be a pinned SHA: {uses_value}")
def check_run(path: str, step_name: str, run_value: str, violations: list[str]) -> None:
lines = [ln.strip() for ln in run_value.splitlines() if ln.strip()]
if not lines:
return
if len(run_value.splitlines()) > 1 and lines[0] != STRICT_LINE:
violations.append(f"{path}:{step_name}: multiline run must start with '{STRICT_LINE}'")
if CURL_PIPE_RE.search(run_value):
violations.append(f"{path}:{step_name}: curl|sh is disallowed")
def check_container_image(path: str, location: str, image: object, violations: list[str]) -> None:
if not isinstance(image, str):
return
if "@sha256:" not in image:
violations.append(
f"{path}:{location}: container image must be pinned by digest: {image}"
)
def resolve_workflow_files(root: Path, explicit_paths: list[str]) -> list[Path]:
if explicit_paths:
files: list[Path] = []
for rel in explicit_paths:
path = (root / rel).resolve()
if not path.is_file():
print(f"Error: Workflow file not found: {rel}")
raise SystemExit(1)
files.append(path)
return sorted(files)
candidates = [
*root.glob(DEFAULT_WORKFLOW_GLOB),
*root.glob(DEFAULT_ACTION_GLOB),
]
return sorted(
path for path in candidates if path.suffix in (".yml", ".yaml")
)
def main() -> int:
args = parse_args()
root = Path(__file__).resolve().parents[2]
workflow_files = resolve_workflow_files(root, args.workflows)
violations: list[str] = []
if not workflow_files:
print("No workflow files found to validate.")
return 0
for wf in workflow_files:
rel = str(wf.relative_to(root))
try:
doc = yaml.safe_load(wf.read_text(encoding="utf-8"))
except Exception as exc: # pragma: no cover
violations.append(f"{rel}: failed to parse YAML: {exc}")
continue
if rel.startswith(".github/actions/"):
runs = doc.get("runs", {}) if isinstance(doc, dict) else {}
action_steps = runs.get("steps", []) if isinstance(runs, dict) else []
for idx, step in enumerate(action_steps, start=1):
if not isinstance(step, dict):
continue
step_name = str(step.get("name", f"action.step{idx}"))
uses_value = step.get("uses")
if isinstance(uses_value, str):
check_uses(rel, step_name, uses_value.strip(), violations)
run_value = step.get("run")
if isinstance(run_value, str):
check_run(rel, step_name, run_value, violations)
continue
jobs = doc.get("jobs", {}) if isinstance(doc, dict) else {}
if not isinstance(jobs, dict):
continue
for job_name, job in jobs.items():
if not isinstance(job, dict):
continue
if "uses" not in job:
runner = job.get("runs-on")
if runner not in ALLOWED_RUNNERS:
violations.append(
f"{rel}:{job_name}: runs-on must use a fixed approved image: {runner}"
)
if "timeout-minutes" not in job:
violations.append(
f"{rel}:{job_name}: job must define timeout-minutes"
)
container = job.get("container")
if isinstance(container, str):
check_container_image(rel, f"{job_name}.container", container, violations)
elif isinstance(container, dict):
check_container_image(
rel, f"{job_name}.container", container.get("image"), violations
)
services = job.get("services")
if isinstance(services, dict):
for service_name, service in services.items():
if isinstance(service, dict):
check_container_image(
rel,
f"{job_name}.services.{service_name}",
service.get("image"),
violations,
)
# Reusable-workflow calls live at the job level (jobs.<id>.uses) and
# must be pinned just like step actions.
job_uses = job.get("uses")
if isinstance(job_uses, str):
check_uses(rel, f"{job_name} (job)", job_uses.strip(), violations)
for idx, step in enumerate(normalize_steps(job), start=1):
if not isinstance(step, dict):
continue
step_name = str(step.get("name", f"{job_name}.step{idx}"))
uses_value = step.get("uses")
if isinstance(uses_value, str):
check_uses(rel, step_name, uses_value.strip(), violations)
if (
uses_value.startswith("actions/checkout@")
and rel not in CHECKOUT_WRITE_WORKFLOWS
):
checkout_with = step.get("with")
persisted = (
checkout_with.get("persist-credentials")
if isinstance(checkout_with, dict)
else None
)
if persisted is not False:
violations.append(
f"{rel}:{step_name}: read-only checkout must set persist-credentials: false"
)
run_value = step.get("run")
if isinstance(run_value, str):
check_run(rel, step_name, run_value, violations)
if violations:
print("Workflow policy violations:")
for violation in violations:
print(f"- {violation}")
return 1
print("Workflow policy checks passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())