#!/usr/bin/env python3 """Pre-commit hook: keep release-gate.yml's two needs lists in sync. release-gate.yml has TWO jobs that must wait on every security scan which uploads a SARIF report to GitHub code scanning: - ``release-gate-summary`` — fails the gate if any scan job failed. - ``check-code-scanning-alerts`` — waits for the SARIF uploads to be indexed, then fails the gate if any open critical/high/medium alert exists. Both ``needs:`` lists are maintained by hand, with nothing keeping them in sync. That drift is a real, shipped bug: ``grype-scan`` was in the summary's needs but was omitted from ``check-code-scanning-alerts`` for ~1.5 years (fixed in #4817). Because Grype runs ``fail-build: false`` (a findings-only run "succeeds"), its findings silently bypassed the alert gate the whole time — the alert query could run before Grype's SARIF was even uploaded. This hook recomputes, from source, the set of release-gate jobs that upload a SARIF to code scanning, and fails if any of them is missing from EITHER needs list. So the next scanner added to one list but forgotten in the other is caught at commit time instead of silently not gating a release. A job is treated as a SARIF uploader if a ``SARIF_UPLOAD_MARKERS`` substring appears in either its OWN inline steps or the LOCAL reusable workflow it calls (``uses: ./…``). Limits worth knowing: a uploader pulled in via a REMOTE reusable workflow (``uses: org/repo/…@ref``) cannot be inspected here, and a new upload mechanism needs a new entry in ``SARIF_UPLOAD_MARKERS``. None exist today; revisit this hook if either changes. """ import sys from pathlib import Path import yaml REPO_ROOT = Path(__file__).resolve().parent.parent RELEASE_GATE = REPO_ROOT / ".github" / "workflows" / "release-gate.yml" # Substrings whose presence means a job uploads a SARIF report to GitHub code # scanning (so its findings become gating alerts). SARIF_UPLOAD_MARKERS = ( "codeql-action/upload-sarif", # grype, trivy, bearer, semgrep, devskim, … "codeql-action/analyze", # codeql (uploads internally) "zizmor-action", # zizmor (uploads internally) ) # The two jobs that must each `needs:` every SARIF-uploading scan. CONSUMER_JOBS = ("check-code-scanning-alerts", "release-gate-summary") def text_uploads_sarif(text: str) -> bool: return any(marker in text for marker in SARIF_UPLOAD_MARKERS) def job_uploads_sarif(job: dict, errors: list[str]) -> bool: """True if a job uploads SARIF via its own inline steps or a local reusable workflow it calls. A ``uses: ./…`` reference to a file that does not exist is recorded as a loud error (appended to ``errors``) rather than silently treated as a non-uploader — a typo'd workflow reference should fail the hook, not slip a scanner past it. """ # Inline steps defined directly on the job. if text_uploads_sarif(yaml.safe_dump(job)): return True # Local reusable workflow the job calls. uses = job.get("uses") if isinstance(uses, str) and uses.startswith("./"): workflow = REPO_ROOT / uses[2:] # strip leading "./" if not workflow.is_file(): errors.append( f"missing reusable workflow referenced by a job: {uses}" ) return False return text_uploads_sarif(workflow.read_text(encoding="utf-8")) return False def needs_of(job: object) -> set[str]: """Normalize a job's `needs` (str | list | missing) to a set.""" if not isinstance(job, dict): return set() needs = job.get("needs", []) if isinstance(needs, str): return {needs} if isinstance(needs, list): return set(needs) return set() def main() -> int: try: gate = yaml.safe_load(RELEASE_GATE.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError) as exc: print(f"❌ Could not parse {RELEASE_GATE}: {exc}") return 1 jobs = gate.get("jobs", {}) if isinstance(gate, dict) else {} # SARIF-uploading jobs = release-gate jobs that upload SARIF (inline or via # the local reusable workflow they call). errors: list[str] = [] sarif_jobs: dict[str, str] = {} for job_id, job in jobs.items(): if not isinstance(job, dict): continue if job_uploads_sarif(job, errors): uses = job.get("uses") sarif_jobs[job_id] = ( uses[2:] if isinstance(uses, str) and uses.startswith("./") else "inline steps" ) if errors: print( "❌ release-gate.yml references a workflow file that does not exist" ) print("=" * 64) for err in errors: print(f" - {err}") print("=" * 64) print( "FIX: correct the `uses:` path in .github/workflows/release-gate.yml" ) return 1 if not sarif_jobs: print("❌ No SARIF-uploading jobs detected in release-gate.yml.") print(" The detector is likely broken — check SARIF_UPLOAD_MARKERS.") return 1 violations: list[tuple[str, str, str]] = [] # (consumer, job, source) for consumer in CONSUMER_JOBS: needs = needs_of(jobs.get(consumer, {})) for job_id, source in sorted(sarif_jobs.items()): if job_id not in needs: violations.append((consumer, job_id, source)) if violations: print("❌ SARIF SCANNER MISSING FROM A release-gate.yml needs LIST") print("=" * 64) print("Every scan that uploads a SARIF to code scanning must be in the") print("`needs:` of BOTH check-code-scanning-alerts and") print("release-gate-summary. A job missing from check-code-scanning-") print("alerts can let its findings race the indexing query and bypass") print( "the gate (this silently happened to grype-scan for ~1.5y, #4817)." ) print("=" * 64) for consumer, job_id, source in violations: print(f"\n job '{job_id}' ({source}) uploads SARIF") print(f" but is NOT in {consumer}.needs") print("\n" + "=" * 64) print("FIX: add the job id to that job's `needs:` list in") print(" .github/workflows/release-gate.yml") print("=" * 64) return 1 return 0 if __name__ == "__main__": sys.exit(main())