Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/.publication/lib/gates.py

299 lines
12 KiB
Python

"""Required-gate evaluator against the Gitea 1.25.5 Actions API shape.
Gate identity is (workflow_path, job_name). A gate passes only when exactly one
completed, successful job matches it at the exact candidate SHA, on the
permitted ref, under an allowed event, in the correct workflow.
Every other outcome denies. See docs/DESIGN.md for the attack list this shape
answers and the one it cannot (continue-on-error).
"""
RUN_FIELDS = ("id", "path", "head_sha", "head_branch", "event",
"status", "conclusion", "run_number", "run_attempt")
JOB_FIELDS = ("id", "run_id", "name", "head_sha", "head_branch",
"status", "conclusion", "run_attempt")
CONTINUE_ON_ERROR_CAVEAT = (
"a job failing under continue-on-error reports conclusion=success and is "
"indistinguishable from a genuine pass when the API returns no steps")
class Denied(Exception):
def __init__(self, reasons):
self.reasons = list(reasons)
super().__init__("; ".join(self.reasons))
class PublicationRequest:
__slots__ = ("internal_repository", "destination_host",
"destination_repository", "sha", "ref")
def __init__(self, internal_repository, destination_host,
destination_repository, sha, ref):
self.internal_repository = internal_repository
self.destination_host = destination_host
self.destination_repository = destination_repository
self.sha = sha
self.ref = ref
class Report:
def __init__(self):
self.denials = []
self.passed = []
self.unproven = []
@property
def allowed(self):
return not self.denials
def deny(self, code, detail):
self.denials.append(f"{code}: {detail}")
def note_unproven(self, detail):
if detail not in self.unproven:
self.unproven.append(detail)
def summary(self):
verdict = "ALLOW" if self.allowed else "DENY"
lines = [f"{verdict} ({len(self.passed)} gate(s) proven, "
f"{len(self.denials)} denial(s))"]
lines.extend(" proven: " + g for g in self.passed)
lines.extend(" DENY " + d for d in self.denials)
lines.extend(" unproven: " + u for u in self.unproven)
return "\n".join(lines)
def _require_fields(obj, fields, kind):
if not isinstance(obj, dict):
return [f"{kind} is not an object"]
return [f"{kind} is missing field '{f}'" for f in fields if obj.get(f) is None]
def _check_request(policy, request, report):
if request.internal_repository != policy.internal_repository:
report.deny("wrong-repository",
f"request names {request.internal_repository}, policy authorizes "
f"{policy.internal_repository}")
if request.destination_repository != policy.destination_repository:
report.deny("wrong-destination",
f"request targets {request.destination_repository}, policy permits "
f"{policy.destination_repository}")
if request.destination_host != policy.destination_host:
report.deny("wrong-destination-host",
f"request targets {request.destination_host}, policy permits "
f"{policy.destination_host}")
if request.ref not in policy.permitted_refs:
report.deny("wrong-ref",
f"ref {request.ref!r} is not in permitted_refs "
f"{list(policy.permitted_refs)}")
sha = request.sha
if not isinstance(sha, str) or len(sha) != 40 or any(
c not in "0123456789abcdef" for c in sha):
report.deny("malformed-sha", f"{sha!r} is not a full lowercase 40-hex SHA")
def _candidate_runs(policy, request, client, report):
try:
raw_runs = client.list_runs(policy.internal_repository, head_sha=request.sha)
except Exception as exc:
report.deny("api-unreachable", f"could not list runs ({exc})")
return []
runs = []
for r in raw_runs:
missing = _require_fields(r, RUN_FIELDS, "run")
if missing:
report.deny("malformed-run", "; ".join(missing))
continue
if r["head_sha"] != request.sha:
continue
if r["head_branch"] != request.ref:
continue
if r["event"] not in policy.allowed_events:
continue
runs.append(r)
return runs
def _select_runs(policy, gate, runs, report):
matching = [r for r in runs if r["path"] == gate.workflow_path]
if not matching:
report.deny("gate-missing",
f"{gate.workflow_path}::{gate.job_name} has no run at this "
f"SHA/ref/event")
return []
if policy.rerun_policy == "all-attempts-must-succeed":
return matching
top = max(r["run_number"] for r in matching)
latest = [r for r in matching if r["run_number"] == top]
if len(latest) > 1:
report.deny("ambiguous-run",
f"{gate.workflow_path} has {len(latest)} runs sharing run_number "
f"{top}; cannot choose")
return []
return latest
def _jobs_for(client, policy, run, report):
try:
raw = client.list_jobs(policy.internal_repository, run["id"])
except Exception as exc:
report.deny("api-unreachable", f"could not list jobs for run {run['id']} ({exc})")
return None
jobs = []
for j in raw:
missing = _require_fields(j, JOB_FIELDS, "job")
if missing:
report.deny("malformed-job", "; ".join(missing))
return None
jobs.append(j)
return jobs
def _judge_job(gate, job, report, label):
ident = f"{gate.workflow_path}::{gate.job_name} ({label})"
if job["status"] != "completed":
report.deny("gate-incomplete", f"{ident} status={job['status']}")
return False
if job["conclusion"] != "success":
report.deny(f"gate-{job['conclusion']}", f"{ident} conclusion={job['conclusion']}")
return False
steps = job.get("steps")
if not steps:
report.note_unproven(f"{ident}: no steps returned; {CONTINUE_ON_ERROR_CAVEAT}")
return True
failed = [s.get("name") for s in steps if s.get("conclusion") == "failure"]
if failed:
report.deny("gate-step-failed-under-success",
f"{ident} reports success but step(s) failed: {failed}")
return False
return True
def _matrix_drift(gate, jobs, report):
"""Tripwire, not a selector.
Selection is the exact list in matrix_jobs. This counts how many jobs in
the run look like legs of the same declaration and denies on a mismatch, so
a leg added upstream and never approved cannot pass unnoticed. It decides
nothing about which jobs are trusted -- only that the policy is stale.
"""
shaped = [j for j in jobs if j["name"].startswith(gate.job_name + " (")]
if len(shaped) != len(gate.matrix_jobs):
report.deny("matrix-drift",
f"{gate.workflow_path}::{gate.job_name} declares "
f"{len(gate.matrix_jobs)} legs, the run carries {len(shaped)}; "
"approve the change in policy rather than around it")
return False
return True
def _evaluate_matrix(policy, request, gate, runs, client, report):
selected = _select_runs(policy, gate, runs, report)
if not selected:
return
proven = True
for run in selected:
jobs = _jobs_for(client, policy, run, report)
if jobs is None:
return
at_sha = [j for j in jobs
if j["head_sha"] == request.sha and j["head_branch"] == request.ref]
if not _matrix_drift(gate, at_sha, report):
return
for leg in gate.matrix_jobs:
named = [j for j in at_sha if j["name"] == leg]
if not named:
report.deny("gate-missing",
f"{gate.workflow_path}::{leg} absent from run {run['id']}")
return
if policy.rerun_policy == "latest-attempt-must-succeed":
top = max(j["run_attempt"] for j in named)
candidates = [j for j in named if j["run_attempt"] == top]
label = f"run {run['id']} attempt {top}"
if len(candidates) != 1:
report.deny("gate-ambiguous",
f"{gate.workflow_path}::{leg} matched "
f"{len(candidates)} jobs at {label}; exactly one required")
return
else:
candidates = named
label = f"run {run['id']} all attempts"
for job in candidates:
if not _judge_job(gate, job, report, f"{leg}, {label}"):
proven = False
if proven:
report.passed.append(f"{gate.workflow_path}::{gate.job_name} "
f"({len(gate.matrix_jobs)} legs)")
def _evaluate_gate(policy, request, gate, runs, client, report):
if gate.cardinality == "matrix":
return _evaluate_matrix(policy, request, gate, runs, client, report)
if gate.cardinality != "exactly-one":
report.deny("unsupported-cardinality",
f"{gate.workflow_path}::{gate.job_name} declares "
f"{gate.cardinality!r}; not supported in this iteration")
return
selected = _select_runs(policy, gate, runs, report)
if not selected:
return
proven = True
for run in selected:
jobs = _jobs_for(client, policy, run, report)
if jobs is None:
return
named = [j for j in jobs
if j["name"] == gate.job_name
and j["head_sha"] == request.sha
and j["head_branch"] == request.ref]
if not named:
report.deny("gate-missing",
f"{gate.workflow_path}::{gate.job_name} absent from run "
f"{run['id']} (attempt {run['run_attempt']})")
return
if policy.rerun_policy == "latest-attempt-must-succeed":
top_attempt = max(j["run_attempt"] for j in named)
candidates = [j for j in named if j["run_attempt"] == top_attempt]
label = f"run {run['id']} attempt {top_attempt}"
else:
candidates = named
label = f"run {run['id']} all attempts"
if policy.rerun_policy == "latest-attempt-must-succeed" and len(candidates) != 1:
report.deny("gate-ambiguous",
f"{gate.workflow_path}::{gate.job_name} matched "
f"{len(candidates)} jobs at {label}; exactly one required")
return
for job in candidates:
if not _judge_job(gate, job, report, label):
proven = False
if proven:
report.passed.append(f"{gate.workflow_path}::{gate.job_name}")
def evaluate(policy, request, client):
"""Return a Report. Report.allowed is True only if every gate is proven."""
report = Report()
_check_request(policy, request, report)
if report.denials:
return report
runs = _candidate_runs(policy, request, client, report)
if report.denials:
return report
if not runs:
report.deny("no-runs",
f"no run at {request.sha} on {request.ref} under events "
f"{sorted(policy.allowed_events)}")
return report
for gate in policy.required_gates:
_evaluate_gate(policy, request, gate, runs, client, report)
report.note_unproven(
"gate proof shows a green run exists at this SHA on this ref; it does not "
"prove which push produced it")
return report