unsloth/scripts/lint_workflow_triggers.py
Daniel Han 187144d4e7
Reduce and tighten code comments and docstrings repo-wide (#6095)
Trim and tighten code comments and docstrings across the repository. Comment-only: every changed file verified code-identical to main via AST/token comparison.
2026-06-08 23:09:51 -07:00

150 lines
5 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Refuse dangerous GitHub Actions trigger patterns at PR time.
Bans patterns behind the TanStack GHSA-g7cv-rxg3-hmpx compromise:
1. `pull_request_target` -- runs a fork's workflow against the base
repo's secrets/permissions; use `pull_request` instead.
2. `workflow_run` chained to a PR-triggered workflow -- same trust
boundary problem one hop later (poisoned artifacts/caches run with
elevated permissions).
3. Cache keys shared between PR-triggered and publish/release/push
workflows -- a fork PR could poison a cache the publish workflow
restores. Partition the key namespaces.
Exit codes: 0 = no findings, 1 = findings (listed on stderr).
Run from repo root: python3 scripts/lint_workflow_triggers.py
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("ERROR: PyYAML is required. Install with 'pip install pyyaml'", file = sys.stderr)
sys.exit(2)
REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
BANNED_TRIGGERS: tuple[str, ...] = ("pull_request_target",)
RESTRICTED_TRIGGERS: tuple[str, ...] = ("workflow_run",)
PUBLISH_WORKFLOW_NAMES: tuple[str, ...] = ("release-desktop.yml",)
def _normalise_on(on_field):
if isinstance(on_field, str):
return {on_field}
if isinstance(on_field, list):
return set(on_field)
if isinstance(on_field, dict):
return set(on_field.keys())
return set()
def _load_workflow(path: Path):
try:
return yaml.safe_load(path.read_text())
except Exception as exc:
print(f"ERROR: failed to parse {path}: {exc}", file = sys.stderr)
sys.exit(2)
def _extract_cache_keys(path: Path) -> list[str]:
text = path.read_text()
keys: list[str] = []
for m in re.finditer(r"(?:^|\n)\s*key:\s*([^\n]+)", text):
keys.append(m.group(1).strip())
return keys
def _trigger_set(yaml_doc) -> set[str]:
on = yaml_doc.get(True)
if on is None:
on = yaml_doc.get("on")
return _normalise_on(on)
def main() -> int:
parser = argparse.ArgumentParser(description = __doc__)
parser.add_argument(
"--workflows-dir",
type = Path,
default = DEFAULT_WORKFLOWS_DIR,
help = "Override the workflows directory (used by tests).",
)
args = parser.parse_args()
workflows_dir = args.workflows_dir
findings: list[str] = []
workflows = sorted(workflows_dir.glob("*.yml"))
pr_triggered: list[tuple[Path, list[str]]] = []
publish_triggered: list[tuple[Path, list[str]]] = []
for path in workflows:
doc = _load_workflow(path)
triggers = _trigger_set(doc)
for t in BANNED_TRIGGERS:
if t in triggers:
findings.append(
f"{path.name}: BANNED trigger '{t}' (GHSA-g7cv-rxg3-hmpx "
"pattern: fork PRs run in base-repo context). Switch to "
"'pull_request' and use a deploy-on-merge workflow for "
"any privileged step."
)
for t in RESTRICTED_TRIGGERS:
if t in triggers:
text = path.read_text()
if "lint:workflow_triggers-allow-workflow_run" not in text:
findings.append(
f"{path.name}: RESTRICTED trigger '{t}' requires an "
"explicit `# lint:workflow_triggers-allow-workflow_run` "
"comment somewhere in the file, with a justification."
)
if "pull_request" in triggers:
pr_triggered.append((path, _extract_cache_keys(path)))
is_dispatch_only = "workflow_dispatch" in triggers and not (
"push" in triggers or "pull_request" in triggers
)
if path.name in PUBLISH_WORKFLOW_NAMES or is_dispatch_only:
publish_triggered.append((path, _extract_cache_keys(path)))
pr_keys = {key for _, keys in pr_triggered for key in keys}
for pub_path, pub_keys in publish_triggered:
for k in pub_keys:
if k in pr_keys:
findings.append(
f"{pub_path.name}: cache key {k!r} is also declared in a "
"PR-triggered workflow. A fork PR could poison this cache "
"and the publish workflow would restore it on next run. "
"Add a unique suffix (e.g. '-publish-only') to partition "
"the namespaces."
)
if findings:
print("Workflow trigger lint failed with the following issues:", file = sys.stderr)
for f in findings:
print(f" - {f}", file = sys.stderr)
return 1
print(
f"OK: scanned {len(workflows)} workflow file(s); "
f"no pull_request_target, no unjustified workflow_run, "
f"no PR/publish cache-key collision."
)
return 0
if __name__ == "__main__":
sys.exit(main())