299 lines
12 KiB
Python
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
|