"""Regression tests for scripts/lint_workflow_triggers.py, guarding GHSA-g7cv-rxg3-hmpx vectors.""" from __future__ import annotations import shutil import subprocess import sys from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] SCRIPT = REPO_ROOT / "scripts" / "lint_workflow_triggers.py" def _run(workflows_dir: Path) -> subprocess.CompletedProcess: return subprocess.run( [sys.executable, str(SCRIPT), "--workflows-dir", str(workflows_dir)], capture_output = True, text = True, ) def test_lint_passes_on_current_workflows(): """The live `.github/workflows/` tree must pass the lint.""" live = REPO_ROOT / ".github" / "workflows" proc = _run(live) assert ( proc.returncode == 0 ), f"live tree failed lint:\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}" def test_lint_rejects_pull_request_target(tmp_path): """Synthetic PR_TARGET trigger must produce rc=1 with a named finding.""" wf = tmp_path / "wf" wf.mkdir() (wf / "bad.yml").write_text( "name: bad\n" "on:\n" " pull_request_target:\n" " branches: [main]\n" "jobs:\n" " build:\n" " runs-on: ubuntu-latest\n" " steps:\n" " - run: echo evil\n" ) proc = _run(wf) assert proc.returncode == 1 assert "BANNED trigger 'pull_request_target'" in proc.stderr assert "GHSA-g7cv-rxg3-hmpx" in proc.stderr def test_lint_rejects_unjustified_workflow_run(tmp_path): """`workflow_run` requires an explicit allow-comment in the YAML.""" wf = tmp_path / "wf" wf.mkdir() (wf / "chained.yml").write_text( "name: chained\n" "on:\n" " workflow_run:\n" " workflows: ['CI']\n" " types: [completed]\n" "jobs:\n" " build:\n" " runs-on: ubuntu-latest\n" " steps:\n" " - run: echo elevated\n" ) proc = _run(wf) assert proc.returncode == 1 assert "RESTRICTED trigger 'workflow_run'" in proc.stderr def test_lint_allows_justified_workflow_run(tmp_path): """With the allow-comment, workflow_run is permitted.""" wf = tmp_path / "wf" wf.mkdir() (wf / "chained.yml").write_text( "# lint:workflow_triggers-allow-workflow_run -- justified by ticket #1234\n" "name: chained\n" "on:\n" " workflow_run:\n" " workflows: ['CI']\n" " types: [completed]\n" "jobs:\n" " build:\n" " runs-on: ubuntu-latest\n" " steps:\n" " - run: echo elevated\n" ) proc = _run(wf) assert proc.returncode == 0, f"justified workflow_run rejected:\n{proc.stderr}" def test_lint_rejects_shared_cache_key_between_pr_and_publish(tmp_path): """A cache key declared in both a PR-triggered workflow and the publish workflow is the TanStack cache-poisoning vector.""" wf = tmp_path / "wf" wf.mkdir() # PR-triggered: writes a cache the publish job will also restore. (wf / "pr-build.yml").write_text( "name: pr-build\n" "on:\n" " pull_request:\n" "jobs:\n" " build:\n" " runs-on: ubuntu-latest\n" " steps:\n" " - uses: actions/cache@v4\n" " with:\n" " path: node_modules\n" " key: shared-cache-v1\n" ) # Publish workflow with the IDENTICAL cache key (the attack pattern). (wf / "release-desktop.yml").write_text( "name: release-desktop\n" "on:\n" " workflow_dispatch:\n" "jobs:\n" " publish:\n" " runs-on: ubuntu-latest\n" " steps:\n" " - uses: actions/cache@v4\n" " with:\n" " path: node_modules\n" " key: shared-cache-v1\n" ) proc = _run(wf) assert proc.returncode == 1 assert "cache-key" in proc.stderr.lower() or "cache key" in proc.stderr.lower() assert "shared-cache-v1" in proc.stderr