Skip to content

Commit 22ed6d7

Browse files
raylee-hawkinsRaylee
andauthored
ci: split trusted runner proof loop (#63)
* ci: split trusted runner proof loop * ci: install workflow parser for validation tests --------- Co-authored-by: Raylee <raylee@local.invalid>
1 parent 2b82cdc commit 22ed6d7

6 files changed

Lines changed: 397 additions & 2 deletions

.github/workflows/baseline-validation-contract.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ jobs:
1616
- uses: actions/setup-python@v5
1717
with:
1818
python-version: '3.11'
19+
- name: Install workflow parser dependency
20+
run: python -m pip install PyYAML
1921
- name: Validate Registry and Baseline Contract
2022
run: |
2123
python -B scripts/verify_validation_registry.py

.github/workflows/governance-gate.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,40 @@ jobs:
3333
exit 1
3434
}
3535
36+
trusted-runner-public-pr-boundary:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- name: Checkout validation repository
40+
uses: actions/checkout@v4
41+
with:
42+
path: hawkinsoperations-validation
43+
- name: Checkout .github repository
44+
uses: actions/checkout@v4
45+
with:
46+
repository: HawkinsOperations/.github
47+
path: .github
48+
- name: Checkout detections repository
49+
uses: actions/checkout@v4
50+
with:
51+
repository: HawkinsOperations/hawkinsoperations-detections
52+
path: hawkinsoperations-detections
53+
- name: Checkout platform repository
54+
uses: actions/checkout@v4
55+
with:
56+
repository: HawkinsOperations/hawkinsoperations-platform
57+
path: hawkinsoperations-platform
58+
- name: Checkout proof repository
59+
uses: actions/checkout@v4
60+
with:
61+
repository: HawkinsOperations/hawkinsoperations-proof
62+
path: hawkinsoperations-proof
63+
- name: Checkout website repository
64+
uses: actions/checkout@v4
65+
with:
66+
repository: HawkinsOperations/hawkinsoperations-website
67+
path: hawkinsoperations-website
68+
- name: Install workflow parser dependency
69+
run: python -m pip install PyYAML
70+
- name: Verify self-hosted runners are not public PR reachable
71+
run: python hawkinsoperations-validation/scripts/verify-trusted-runner-workflows.py
72+
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: HO-DET-001 Proof Loop Trusted
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
ho-det-001-proof-loop-trusted:
11+
runs-on: [self-hosted, Linux, X64, ho-runner-01]
12+
env:
13+
PYTHONDONTWRITEBYTECODE: "1"
14+
defaults:
15+
run:
16+
working-directory: hawkinsoperations-validation
17+
steps:
18+
- name: Checkout validation repository
19+
uses: actions/checkout@v4
20+
with:
21+
path: hawkinsoperations-validation
22+
23+
- name: Checkout detections repository
24+
uses: actions/checkout@v4
25+
with:
26+
repository: HawkinsOperations/hawkinsoperations-detections
27+
path: hawkinsoperations-detections
28+
29+
- name: Checkout proof repository
30+
uses: actions/checkout@v4
31+
with:
32+
repository: HawkinsOperations/hawkinsoperations-proof
33+
path: hawkinsoperations-proof
34+
35+
- name: Setup Python
36+
uses: actions/setup-python@v5
37+
with:
38+
python-version: "3.11"
39+
40+
- name: Run HO-DET-001 deterministic triage boundary verifier
41+
run: python scripts/verify-ho-det-001-triage-boundary.py
42+
43+
- name: Run HO-DET-001 AI triage schema verifier
44+
run: python scripts/verify-ho-det-001-ai-triage-schemas.py
45+
46+
- name: Run HO-DET-001 claim-boundary scanner
47+
run: python scripts/scan-ho-det-001-claim-boundaries.py
48+
49+
- name: Run HO-DET-001 result parity verifier
50+
run: python scripts/verify-ho-det-001-result-parity.py
51+
52+
- name: Run case packet builder in check mode
53+
run: python scripts/build-ho-det-001-case-packet.py --check
54+
55+
- name: Verify case packet builder produced no tracked drift
56+
run: git diff --exit-code -- validation/successor/ho-det-001/case-packet.json
57+
58+
- name: Run case packet contract verifier
59+
run: python scripts/verify_case_packet_contract.py
60+
61+
- name: Run AutoSOC runner evidence manifest verifier
62+
run: python scripts/verify-autosoc-runner-evidence-manifest.py
63+
64+
- name: Run controlled-test validation in check mode
65+
run: python scripts/validate-ho-det-001.py
66+
67+
- name: Run HO-DET-001 backend adapter verifier
68+
run: python scripts/verify-ho-det-001-backend-adapter.py
69+
70+
- name: Run Closed AutoSOC Loop 001 verifier
71+
run: python scripts/verify-closed-autosoc-loop-001.py
72+
73+
- name: Run proof-record parity verifier
74+
run: python scripts/verify-proof-record-parity.py
75+
76+
- name: Run HO-DET-001 reproducible proof pack verifier
77+
run: python scripts/verify-ho-det-001-reproducible-proof-pack.py
78+
79+
- name: Run whitespace diff check
80+
run: git diff --check

.github/workflows/ho-det-001-proof-loop.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@ name: HO-DET-001 Proof Loop
22

33
on:
44
pull_request:
5-
workflow_dispatch:
65

76
permissions:
87
contents: read
98

109
jobs:
1110
ho-det-001-proof-loop:
12-
runs-on: [self-hosted, Linux, X64, ho-runner-01]
11+
runs-on: ubuntu-latest
1312
env:
1413
PYTHONDONTWRITEBYTECODE: "1"
1514
defaults:
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Verify self-hosted runners are not reachable from public PR workflows."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import json
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import yaml
11+
12+
13+
CANONICAL_REPOS = (
14+
".github",
15+
"hawkinsoperations-detections",
16+
"hawkinsoperations-validation",
17+
"hawkinsoperations-platform",
18+
"hawkinsoperations-proof",
19+
"hawkinsoperations-website",
20+
)
21+
PUBLIC_PR_TRIGGERS = {"pull_request", "pull_request_target"}
22+
23+
24+
def workflow_trigger_keys(workflow: dict[str, Any]) -> set[str]:
25+
on_value = workflow.get("on", workflow.get(True))
26+
if isinstance(on_value, str):
27+
return {on_value}
28+
if isinstance(on_value, list):
29+
return {str(item) for item in on_value}
30+
if isinstance(on_value, dict):
31+
return {str(key) for key in on_value}
32+
return set()
33+
34+
35+
def runs_on_values(runs_on: Any) -> list[str]:
36+
if isinstance(runs_on, str):
37+
return [runs_on]
38+
if isinstance(runs_on, list):
39+
return [str(item) for item in runs_on]
40+
return []
41+
42+
43+
def is_self_hosted(runs_on: Any) -> bool:
44+
return any(value == "self-hosted" for value in runs_on_values(runs_on))
45+
46+
47+
def load_workflow(path: Path) -> dict[str, Any]:
48+
with path.open("r", encoding="utf-8") as handle:
49+
parsed = yaml.safe_load(handle) or {}
50+
if not isinstance(parsed, dict):
51+
return {}
52+
return parsed
53+
54+
55+
def workflow_files(repo_path: Path) -> list[Path]:
56+
workflow_dir = repo_path / ".github" / "workflows"
57+
if not workflow_dir.exists():
58+
return []
59+
return sorted(
60+
path
61+
for pattern in ("*.yml", "*.yaml")
62+
for path in workflow_dir.glob(pattern)
63+
if path.is_file()
64+
)
65+
66+
67+
def scan_repo(repo_name: str, repo_path: Path) -> list[dict[str, str]]:
68+
findings: list[dict[str, str]] = []
69+
for workflow_path in workflow_files(repo_path):
70+
workflow = load_workflow(workflow_path)
71+
triggers = workflow_trigger_keys(workflow)
72+
public_triggers = sorted(triggers & PUBLIC_PR_TRIGGERS)
73+
if not public_triggers:
74+
continue
75+
76+
jobs = workflow.get("jobs", {})
77+
if not isinstance(jobs, dict):
78+
continue
79+
80+
for job_name, job in jobs.items():
81+
if not isinstance(job, dict):
82+
continue
83+
runs_on = job.get("runs-on")
84+
if not is_self_hosted(runs_on):
85+
continue
86+
findings.append(
87+
{
88+
"repo": repo_name,
89+
"workflow_path": str(workflow_path),
90+
"job_name": str(job_name),
91+
"trigger": ",".join(public_triggers),
92+
"runs_on": json.dumps(runs_on),
93+
}
94+
)
95+
return findings
96+
97+
98+
def scan_canonical_repos(org_root: Path) -> tuple[list[dict[str, str]], list[str]]:
99+
findings: list[dict[str, str]] = []
100+
missing: list[str] = []
101+
for repo_name in CANONICAL_REPOS:
102+
repo_path = org_root / repo_name
103+
if not repo_path.exists():
104+
missing.append(str(repo_path))
105+
continue
106+
findings.extend(scan_repo(repo_name, repo_path))
107+
return findings, missing
108+
109+
110+
def default_org_root() -> Path:
111+
return Path(__file__).resolve().parents[2]
112+
113+
114+
def main() -> int:
115+
parser = argparse.ArgumentParser(
116+
description="Verify public PR workflows do not run on self-hosted runners."
117+
)
118+
parser.add_argument(
119+
"--org-root",
120+
type=Path,
121+
default=default_org_root(),
122+
help="Path containing the six canonical HawkinsOperations repositories.",
123+
)
124+
parser.add_argument(
125+
"--allow-missing",
126+
action="store_true",
127+
help="Do not fail if one or more canonical repositories are absent.",
128+
)
129+
args = parser.parse_args()
130+
131+
findings, missing = scan_canonical_repos(args.org_root)
132+
if missing and not args.allow_missing:
133+
print("CANONICAL_REPO_SCAN=FAIL")
134+
for repo_path in missing:
135+
print(f"missing_repo={repo_path}")
136+
return 1
137+
138+
if findings:
139+
print("PUBLIC_PR_SELF_HOSTED_EXPOSURE=FAIL")
140+
for finding in findings:
141+
print(
142+
"exposure="
143+
f"repo={finding['repo']};"
144+
f"workflow={finding['workflow_path']};"
145+
f"job={finding['job_name']};"
146+
f"trigger={finding['trigger']};"
147+
f"runs_on={finding['runs_on']}"
148+
)
149+
return 1
150+
151+
print("PUBLIC_PR_SELF_HOSTED_EXPOSURE=PASS")
152+
print(f"canonical_repos_scanned={len(CANONICAL_REPOS) - len(missing)}")
153+
return 0
154+
155+
156+
if __name__ == "__main__":
157+
raise SystemExit(main())

0 commit comments

Comments
 (0)