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:
70
api/tests/test_runner_guard.py
Normal file
70
api/tests/test_runner_guard.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user