runner-guard: block workflows that hand a self-hosted runner to strangers (Boss 10-01)
R1 pull_request_target; R2 fork pull_request on self-hosted without a same-repo/environment gate; R3 outsider events (issue_comment, workflow_run, ...) on self-hosted; R0 unparseable. PR mode in the 5-min sync posts windy-git/runner-guard on open PRs of public sneakyfree repos that change a workflow (each status once). Nightly sweep (Windy 0 timer) over every public repo: page + BOARD line on new hits + red windy-job heartbeat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
209
scripts/runner_guard.py
Normal file
209
scripts/runner_guard.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runner guard: no workflow may let a STRANGER's code reach a self-hosted runner (Boss 10-01).
|
||||
|
||||
Our self-hosted GitHub runners run on Veron as `github-runner`, which is in the docker group
|
||||
(= root on Veron). Until ephemeral containerised runners exist (after launch), the cheap
|
||||
guard is to refuse the workflow shapes that hand a self-hosted runner to outsiders:
|
||||
|
||||
R1 pull_request_target : runs with secrets/write token in the BASE repo context
|
||||
R2 pull_request on self-hosted : fork PRs run their own code, unless the job is gated to
|
||||
same-repo heads (`if:` on head.repo.full_name == github.repository
|
||||
or head.repo.fork == false) or an `environment:`
|
||||
R3 issue_comment / workflow_run / issues / discussion* / pull_request_review* / fork / watch
|
||||
: anyone can fire these; never on self-hosted without an environment gate
|
||||
(push, tags, schedule, workflow_dispatch, repository_dispatch, workflow_call: writers only, fine)
|
||||
|
||||
A job counts as self-hosted when its runs-on names `self-hosted`, or is an expression we
|
||||
can't resolve (conservative). Findings carry file:line, never file content beyond that.
|
||||
|
||||
python3 scripts/runner_guard.py lint FILE... # local files
|
||||
python3 scripts/runner_guard.py report [--owners a,b] # default branch of every PUBLIC repo
|
||||
python3 scripts/runner_guard.py pr [--owners a,b] [--post] # open PRs on public repos: changed workflows
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
OWNERS = ["sneakyfree", "VERONTECH", "Windstorm-Institute", "Windstorm-Labs", "Public-Streamer"]
|
||||
CTX = "windy-git/runner-guard"
|
||||
OUTSIDE = {"issue_comment", "workflow_run", "issues", "discussion", "discussion_comment",
|
||||
"pull_request_review", "pull_request_review_comment", "fork", "watch"}
|
||||
SAME_REPO_GATES = ("head.repo.full_name == github.repository", "github.repository == github.event.pull_request.head.repo.full_name",
|
||||
"head.repo.fork == false", "!github.event.pull_request.head.repo.fork")
|
||||
|
||||
|
||||
def _node_map(node):
|
||||
"""{key: (value_node, line)} for a YAML mapping node."""
|
||||
if not isinstance(node, yaml.MappingNode):
|
||||
return {}
|
||||
return {k.value: (v, k.start_mark.line + 1) for k, v in node.value if isinstance(k, yaml.ScalarNode)}
|
||||
|
||||
|
||||
def _triggers(on_node) -> dict[str, int]:
|
||||
"""{event: line}."""
|
||||
if isinstance(on_node, yaml.ScalarNode):
|
||||
return {on_node.value: on_node.start_mark.line + 1}
|
||||
if isinstance(on_node, yaml.SequenceNode):
|
||||
return {n.value: n.start_mark.line + 1 for n in on_node.value if isinstance(n, yaml.ScalarNode)}
|
||||
return {k: line for k, (_v, line) in _node_map(on_node).items()}
|
||||
|
||||
|
||||
def _self_hosted(runs_on) -> bool:
|
||||
if runs_on is None:
|
||||
return False
|
||||
text = yaml.serialize(runs_on) if isinstance(runs_on, yaml.Node) else str(runs_on)
|
||||
return "self-hosted" in text or "${{" in text
|
||||
|
||||
|
||||
def lint_text(path: str, text: str) -> list[tuple[str, int, str, str]]:
|
||||
"""[(path, line, rule, message)]. Unparseable YAML is a finding (it cannot be reviewed)."""
|
||||
try:
|
||||
root = yaml.compose(text)
|
||||
except yaml.YAMLError as e:
|
||||
line = getattr(getattr(e, "problem_mark", None), "line", 0) + 1
|
||||
return [(path, line, "R0", "workflow YAML does not parse; cannot be checked")]
|
||||
top = _node_map(root)
|
||||
if "on" not in top or "jobs" not in top:
|
||||
return []
|
||||
trig = _triggers(top["on"][0])
|
||||
jobs = _node_map(top["jobs"][0])
|
||||
out = []
|
||||
if "pull_request_target" in trig:
|
||||
out.append((path, trig["pull_request_target"], "R1",
|
||||
"pull_request_target runs fork code with base-repo secrets; not allowed"))
|
||||
for name, (jnode, jline) in jobs.items():
|
||||
j = _node_map(jnode)
|
||||
ro = j.get("runs-on", (None, jline))
|
||||
if not _self_hosted(ro[0]):
|
||||
continue
|
||||
has_env = "environment" in j
|
||||
cond = j["if"][0].value if "if" in j and isinstance(j["if"][0], yaml.ScalarNode) else ""
|
||||
same_repo = any(g in cond.replace(" ", " ") for g in SAME_REPO_GATES)
|
||||
if "pull_request" in trig and not (has_env or same_repo):
|
||||
out.append((path, ro[1], "R2", f"job '{name}' runs fork PR code on a self-hosted runner "
|
||||
"(gate it: if: github.event.pull_request.head.repo.full_name == github.repository, or an environment)"))
|
||||
for ev in sorted(OUTSIDE & trig.keys()):
|
||||
if not has_env:
|
||||
out.append((path, trig[ev], "R3", f"'{ev}' can be fired by anyone and job '{name}' is self-hosted "
|
||||
"without an environment gate"))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- GitHub side
|
||||
def gh(*args: str, check=True) -> str:
|
||||
r = subprocess.run(["gh", "api", *args], capture_output=True, text=True, timeout=60)
|
||||
if check and r.returncode != 0:
|
||||
raise RuntimeError(f"gh api {args[0]} failed")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def public_repos(owners) -> list[tuple[str, str]]:
|
||||
out = []
|
||||
for o in owners:
|
||||
txt = gh(f"users/{o}/repos?per_page=100&type=owner", "--paginate",
|
||||
"--jq", '.[]|select(.private==false and .archived==false)|.full_name+" "+.default_branch', check=False)
|
||||
out += [tuple(row.split()) for row in txt.splitlines() if row.strip()]
|
||||
return out
|
||||
|
||||
|
||||
def workflows_at(full: str, ref: str) -> list[tuple[str, str]]:
|
||||
txt = gh(f"repos/{full}/contents/.github/workflows?ref={ref}", "--jq",
|
||||
'.[]|select(.type=="file")|.path', check=False)
|
||||
files = [p for p in txt.splitlines() if p.endswith((".yml", ".yaml"))]
|
||||
return [(p, file_at(full, p, ref)) for p in files]
|
||||
|
||||
|
||||
def file_at(full: str, path: str, ref: str) -> str:
|
||||
raw = gh(f"repos/{full}/contents/{path}?ref={ref}", "--jq", ".content", check=False).strip()
|
||||
return base64.b64decode(raw).decode("utf-8", "replace") if raw else ""
|
||||
|
||||
|
||||
def cmd_report(owners) -> int:
|
||||
hits = 0
|
||||
repos = public_repos(owners)
|
||||
for full, branch in repos:
|
||||
for path, text in workflows_at(full, branch):
|
||||
for p, line, rule, msg in lint_text(path, text):
|
||||
hits += 1
|
||||
print(f"{full}\t{p}:{line}\t{rule}\t{msg}")
|
||||
print(f"# runner-guard sweep: {hits} hit(s) in {len(repos)} public repos")
|
||||
return 1 if hits else 0
|
||||
|
||||
|
||||
STATE = os.environ.get("RUNNER_GUARD_STATE", "/var/lib/windy-git/runner-guard-posted.json")
|
||||
|
||||
|
||||
def cmd_pr(owners, post: bool) -> int:
|
||||
# Post each (repo, sha, state, description) ONCE: the sync runs every 5 min and GitHub caps
|
||||
# statuses per sha+context at 1000.
|
||||
try:
|
||||
with open(STATE) as fh:
|
||||
posted = set(json.load(fh))
|
||||
except (OSError, ValueError):
|
||||
posted = set()
|
||||
seen = set()
|
||||
for full, _branch in public_repos(owners):
|
||||
prs = gh(f"repos/{full}/pulls?state=open&per_page=50", "--jq",
|
||||
'.[]|(.number|tostring)+" "+.head.sha+" "+.head.repo.full_name', check=False)
|
||||
for line in prs.splitlines():
|
||||
num, sha, head_repo = line.split(" ", 2)
|
||||
files = gh(f"repos/{full}/pulls/{num}/files?per_page=100", "--jq",
|
||||
'.[]|select(.status!="removed")|.filename', check=False).split()
|
||||
wf = [f for f in files if f.startswith(".github/workflows/") and f.endswith((".yml", ".yaml"))]
|
||||
# a fork's own content is read from the head repo at the head sha
|
||||
src = head_repo if head_repo and head_repo != "null" else full
|
||||
found = [h for f in wf for h in lint_text(f, file_at(src, f, sha))]
|
||||
if found:
|
||||
p, ln, rule, msg = found[0]
|
||||
state, desc = "failure", f"BLOCKED: {rule} {p}:{ln}: {msg}"[:140]
|
||||
else:
|
||||
state, desc = "success", ("OK: no workflow changes" if not wf else
|
||||
"OK: no stranger-code path to a self-hosted runner")
|
||||
key = f"{full}@{sha}:{state}:{desc}"
|
||||
seen.add(key)
|
||||
if key in posted:
|
||||
continue
|
||||
print(f"{full}#{num}@{sha[:7]} {state} {desc}")
|
||||
if post:
|
||||
gh(f"repos/{full}/statuses/{sha}", "-f", f"state={state}", "-f", f"context={CTX}",
|
||||
"-f", f"description={desc}", check=False)
|
||||
posted.add(key)
|
||||
if post: # keep only keys for PRs still open, so the file never grows without bound
|
||||
os.makedirs(os.path.dirname(STATE), exist_ok=True)
|
||||
with open(STATE, "w") as fh:
|
||||
json.dump(sorted(posted & seen), fh)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(prog="runner_guard")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
lint_p = sub.add_parser("lint")
|
||||
lint_p.add_argument("files", nargs="+")
|
||||
rep = sub.add_parser("report")
|
||||
rep.add_argument("--owners", default=",".join(OWNERS))
|
||||
prp = sub.add_parser("pr")
|
||||
prp.add_argument("--owners", default="sneakyfree") # self-hosted runners exist only there
|
||||
prp.add_argument("--post", action="store_true")
|
||||
a = ap.parse_args(argv)
|
||||
if a.cmd == "lint":
|
||||
hits = []
|
||||
for f in a.files:
|
||||
with open(f, errors="replace") as fh:
|
||||
hits += lint_text(f, fh.read())
|
||||
for p_, ln, rule, msg in hits:
|
||||
print(f"{p_}:{ln}\t{rule}\t{msg}")
|
||||
return 1 if hits else 0
|
||||
owners = a.owners.split(",")
|
||||
return cmd_report(owners) if a.cmd == "report" else cmd_pr(owners, a.post)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -94,6 +94,11 @@ if ! python3 "$(dirname "$0")/pr_status_bridge.py"; then
|
||||
log "FAILED pr status bridge"; FAILED=1
|
||||
fi
|
||||
|
||||
# Runner guard (Boss 10-01): PUBLIC sneakyfree repos have self-hosted GitHub runners on Veron.
|
||||
# A PR that changes a workflow so a stranger's code could reach one gets a red
|
||||
# windy-git/runner-guard status. Each status is posted once; never fails the sync.
|
||||
timeout -k 10 120 python3 "$(dirname "$0")/runner_guard.py" pr --post || log "runner-guard failed or timed out (non-fatal)"
|
||||
|
||||
# CI telemetry -> admin.windyword.ai (shapes declared with Windy Telemetry 40).
|
||||
# Sends nothing until WINDYGIT_TELEMETRY_TOKEN is set; never fails the sync.
|
||||
timeout -k 10 180 python3 "$(dirname "$0")/telemetry_emit.py" || log "telemetry emit failed or timed out (non-fatal)"
|
||||
|
||||
Reference in New Issue
Block a user