diff --git a/api/tests/test_runner_guard.py b/api/tests/test_runner_guard.py new file mode 100644 index 0000000..f82b740 --- /dev/null +++ b/api/tests/test_runner_guard.py @@ -0,0 +1,70 @@ +"""runner-guard: workflow shapes that hand a self-hosted runner to strangers.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +_spec = importlib.util.spec_from_file_location("runner_guard", ROOT / "scripts" / "runner_guard.py") +rg = importlib.util.module_from_spec(_spec) +sys.modules["runner_guard"] = rg +_spec.loader.exec_module(rg) + + +def rules(text): + return [(r, ln) for _p, ln, r, _m in rg.lint_text("w.yml", text)] + + +def test_pull_request_target_always_fails(): + assert rules("on: pull_request_target\njobs:\n a:\n runs-on: ubuntu-latest\n steps: []\n")[0][0] == "R1" + + +def test_fork_pr_on_self_hosted_fails_and_points_at_runs_on(): + wf = "on:\n pull_request:\njobs:\n t:\n runs-on: [self-hosted, linux, x64]\n steps: []\n" + assert rules(wf) == [("R2", 5)] + + +def test_same_repo_gate_or_environment_passes(): + gated = ("on: [pull_request]\njobs:\n t:\n if: github.event.pull_request.head.repo.full_name == github.repository\n" + " runs-on: [self-hosted]\n steps: []\n") + env = "on: [pull_request]\njobs:\n t:\n environment: ci\n runs-on: self-hosted\n steps: []\n" + assert rules(gated) == [] and rules(env) == [] + + +def test_outsider_events_on_self_hosted_fail_but_writer_events_pass(): + wf = "on:\n issue_comment:\n workflow_run:\n workflows: [x]\njobs:\n t:\n runs-on: self-hosted\n steps: []\n" + assert sorted(r for r, _ in rules(wf)) == ["R3", "R3"] + ok = "on:\n push:\n tags: ['v*']\n workflow_dispatch:\n schedule:\n - cron: '0 3 * * *'\njobs:\n t:\n runs-on: self-hosted\n steps: []\n" + assert rules(ok) == [] + + +def test_expression_runs_on_is_treated_as_self_hosted_and_hosted_runner_is_fine(): + expr = "on: pull_request\njobs:\n t:\n runs-on: ${{ matrix.os }}\n steps: []\n" + hosted = "on: pull_request\njobs:\n t:\n runs-on: ubuntu-latest\n steps: []\n" + assert rules(expr) == [("R2", 4)] and rules(hosted) == [] + + +def test_broken_yaml_is_a_finding_and_non_workflows_are_ignored(): + assert rules("on: [push\njobs: {")[0][0] == "R0" + assert rules("name: just a file\n") == [] + + +def test_pr_mode_posts_each_status_once(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(rg, "STATE", str(tmp_path / "s.json")) + monkeypatch.setattr(rg, "public_repos", lambda owners: [("o/r", "main")]) + monkeypatch.setattr(rg, "file_at", lambda *a: "on: push\\njobs:\\n t:\\n runs-on: self-hosted\\n steps: []\\n") + + def fake_gh(*args, check=True): + if args[0].startswith("repos/o/r/pulls?"): + return "7 abc123 o/r\\n" + if args[0].endswith("/files?per_page=100"): + return ".github/workflows/ci.yml\\n" + calls.append(args[0]) + return "" + monkeypatch.setattr(rg, "gh", fake_gh) + rg.cmd_pr(["o"], post=True) + rg.cmd_pr(["o"], post=True) + assert calls == ["repos/o/r/statuses/abc123"] diff --git a/deploy/windy0/nightly-runner-guard-sweep.sh b/deploy/windy0/nightly-runner-guard-sweep.sh new file mode 100755 index 0000000..79bf3bb --- /dev/null +++ b/deploy/windy0/nightly-runner-guard-sweep.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Nightly (Boss 10-01): runner-guard over the default branch of EVERY public repo in Grant's +# 5 GitHub accounts (runs on Veron: windy-git scripts/runner_guard.py report). Writes +# ~/windy-orchestra/RUNNER_GUARD.md (repo, file:line, rule; no file content), appends ONE +# BOARD line per NEW hit vs the last run, and prints "runner-guard sweep: N hit(s)" last, so +# the windy-job heartbeat (--expect "runner-guard sweep: 0 hit") goes red while any hit exists. +set -euo pipefail +page=~/windy-orchestra/RUNNER_GUARD.md +state=~/.local/state/runner-guard-sweep.txt +mkdir -p "$(dirname "$state")" +out=$(timeout 900 ssh -o BatchMode=yes -o ConnectTimeout=15 ts-veron \ + 'cd /srv/windygit/src && timeout 850 python3 scripts/runner_guard.py report' || true) +summary=$(grep '^# runner-guard sweep:' <<<"$out" || echo "# runner-guard sweep: ERROR (no summary)") +hits=$(grep -v '^#' <<<"$out" | grep . || true) +{ + echo "# Runner guard: stranger-code paths to self-hosted runners ($(date -u '+%Y-%m-%d %H:%MZ'))" + echo "_Nightly; windy-git scripts/runner_guard.py. R1 pull_request_target · R2 fork PR on self-hosted without a same-repo/environment gate · R3 outsider events (issue_comment, workflow_run, ...) on self-hosted · R0 unparseable._" + echo; echo "${summary#\# }"; echo + echo "| repo | file:line | rule | fix |"; echo "|---|---|---|---|" + while IFS=$'\t' read -r repo loc rule msg; do [[ -n $repo ]] && echo "| $repo | $loc | $rule | $msg |"; done <<<"$hits" +} > "$page" +new=$(comm -13 <(sort -u "$state" 2>/dev/null || true) <(cut -f1-3 <<<"$hits" | sort -u)) +cut -f1-3 <<<"$hits" | sort -u > "$state" +if [[ -n "$new" ]]; then + n=$(grep -c . <<<"$new") + echo "$(date -u +%Y-%m-%dT%H:%MZ) Windy Git: 🚨 runner-guard: $n NEW stranger-code path(s) to a self-hosted runner in public repos; see ~/windy-orchestra/RUNNER_GUARD.md" >> ~/windy-orchestra/BOARD.md +fi +echo "${summary#\# }" diff --git a/deploy/windy0/windy-runner-guard-sweep.service b/deploy/windy0/windy-runner-guard-sweep.service new file mode 100644 index 0000000..808b724 --- /dev/null +++ b/deploy/windy0/windy-runner-guard-sweep.service @@ -0,0 +1,7 @@ +[Unit] +Description=Nightly runner-guard sweep of every PUBLIC repo's workflows (Windy Git lane) + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/windy-job windy-runner-guard-sweep 26h --expect "runner-guard sweep: 0 hit" --owner 13 -- %h/bin/nightly-runner-guard-sweep.sh +TimeoutStartSec=1200 diff --git a/deploy/windy0/windy-runner-guard-sweep.timer b/deploy/windy0/windy-runner-guard-sweep.timer new file mode 100644 index 0000000..48c8e72 --- /dev/null +++ b/deploy/windy0/windy-runner-guard-sweep.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Nightly runner-guard sweep + +[Timer] +OnCalendar=*-*-* 09:40:00 UTC +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/runner_guard.py b/scripts/runner_guard.py new file mode 100644 index 0000000..57b7de2 --- /dev/null +++ b/scripts/runner_guard.py @@ -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()) diff --git a/scripts/sync_from_github.sh b/scripts/sync_from_github.sh index 647efb6..4336e1f 100755 --- a/scripts/sync_from_github.sh +++ b/scripts/sync_from_github.sh @@ -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)"