From b019b354b47d42369e930825d5f2fcf8d8a22abe Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Tue, 8 Sep 2026 22:32:17 +0900 Subject: [PATCH 1/2] Harden synchronized launcher publication --- .github/workflows/ci.yml | 12 ++-- .github/workflows/release.yml | 35 ++++++----- scripts/check_workflow_security.py | 65 +++++++++++++++++++++ scripts/test_verify_release.py | 94 ++++++++++++++++++++++++++++++ scripts/verify_release.py | 84 ++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 18 deletions(-) create mode 100644 scripts/check_workflow_security.py create mode 100644 scripts/test_verify_release.py create mode 100644 scripts/verify_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 145a2db..a80e8cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,10 @@ jobs: python-version: "3.13" - run: python -m pip install --require-hashes -r scripts/requirements-family-connection-v1-vectors.txt - run: python3 scripts/validate-family-connection-v1-vectors.py + - name: Release safety tests + run: | + python3 -m unittest scripts/test_verify_release.py + python3 scripts/check_workflow_security.py python: name: python launcher @@ -42,11 +46,11 @@ jobs: working-directory: rust steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable 2026-09-03 with: toolchain: stable components: clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: rust - name: MSRV metadata @@ -74,10 +78,10 @@ jobs: working-directory: rust steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable 2026-09-03 with: toolchain: 1.81.0 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: rust key: msrv-1.81 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d53b7dd..b1dbddf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,20 +2,28 @@ name: release # Tag-triggered release for the determa launcher. # -# PyPI publish uses Trusted Publishing (OIDC) — no stored token. Configure a PyPI -# Trusted Publisher for project `determa` (repo fruwehq/determa, workflow -# release.yml, environment `pypi`) before the first tag-driven release. -# -# npm publish also uses Trusted Publishing (OIDC) with no stored token. -# All jobs share the same `v*` tag; workflow_dispatch runs only the crates job (used -# for a first crates.io publish without re-tagging). +# PyPI and npm publish through Trusted Publishing (OIDC) with no stored token. +# Tag releases publish all ecosystems; workflow_dispatch runs only the crates job. on: push: tags: ["v*"] workflow_dispatch: jobs: + preflight: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Verify synchronized package versions and release tag + run: python3 scripts/verify_release.py --event-name "$GITHUB_EVENT_NAME" --tag "$RELEASE_TAG" + env: + RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || '' }} + build: + needs: preflight if: github.event_name == 'push' runs-on: ubuntu-24.04 defaults: @@ -48,9 +56,10 @@ jobs: with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 crates: + needs: preflight runs-on: ubuntu-latest permissions: contents: read @@ -60,19 +69,17 @@ jobs: working-directory: rust steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@stable - - uses: rust-lang/crates-io-auth-action@v1 + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable 2026-09-03 + - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 id: auth - name: Publish to crates.io run: cargo publish env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} - # npm Trusted Publishing (OIDC) — no stored token. Requires the `determa` npm - # package to already exist with a configured trusted publisher (repo fruwehq/determa, - # workflow release.yml). The very first 0.1.0 is published manually to create the - # package; every release after that runs here token-lessly (with provenance). npm: + needs: preflight + if: github.event_name == 'push' runs-on: ubuntu-latest defaults: run: diff --git a/scripts/check_workflow_security.py b/scripts/check_workflow_security.py new file mode 100644 index 0000000..41cb95f --- /dev/null +++ b/scripts/check_workflow_security.py @@ -0,0 +1,65 @@ +"""Keep workflow action pins and required launcher CI contexts fail-closed.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ACTION_PIN = re.compile( + r"^\s*- uses: (?P[^\s@]+)@(?P[0-9a-f]{40}) # (?P\S.*)$" +) +REQUIRED_CI_JOBS = { + "family-connection-v1-vectors", + "python", + "rust", + "rust-msrv", + "node", +} +REQUIRED_RELEASE_JOBS = {"preflight", "build", "pypi", "crates", "npm"} + + +def workflow_jobs(text: str) -> set[str]: + _, separator, jobs = text.partition("\njobs:\n") + if not separator: + return set() + return set(re.findall(r"^ ([a-z][a-z0-9-]*):\n", jobs, re.MULTILINE)) + + +def verify_workflows(repository: Path) -> None: + workflow_dir = repository / ".github" / "workflows" + failures: list[str] = [] + for workflow in sorted(workflow_dir.glob("*.yml")): + for line_number, line in enumerate(workflow.read_text().splitlines(), 1): + if "uses:" in line and ACTION_PIN.fullmatch(line) is None: + failures.append(f"{workflow}:{line_number}: mutable or uncommented action: {line.strip()}") + + ci = (workflow_dir / "ci.yml").read_text() + if workflow_jobs(ci) != REQUIRED_CI_JOBS: + failures.append(f"ci.yml job contexts changed: {sorted(workflow_jobs(ci))}") + for required in ('python-version: "3.11"', 'toolchain: 1.81.0', 'node-version: "22"'): + if required not in ci: + failures.append(f"ci.yml is missing required setting: {required}") + + release = (workflow_dir / "release.yml").read_text() + if workflow_jobs(release) != REQUIRED_RELEASE_JOBS: + failures.append(f"release.yml job set changed: {sorted(workflow_jobs(release))}") + required_release_fragments = ( + " preflight:\n", + " build:\n needs: preflight\n if: github.event_name == 'push'\n", + " pypi:\n needs: build\n if: github.event_name == 'push'\n", + " crates:\n needs: preflight\n", + " npm:\n needs: preflight\n if: github.event_name == 'push'\n", + "python3 scripts/verify_release.py", + 'node-version: "22"', + ) + for required in required_release_fragments: + if required not in release: + failures.append(f"release.yml is missing required publication guard: {required!r}") + + if failures: + raise SystemExit("\n".join(failures)) + + +if __name__ == "__main__": + verify_workflows(Path(__file__).resolve().parents[1]) + print("workflow action pins and required contexts are valid") diff --git a/scripts/test_verify_release.py b/scripts/test_verify_release.py new file mode 100644 index 0000000..535b31f --- /dev/null +++ b/scripts/test_verify_release.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from verify_release import ReleaseVerificationError, verify_release + + +class VerifyReleaseTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.repository = Path(self.temporary_directory.name) + for directory in ("python", "rust", "node"): + (self.repository / directory).mkdir() + self.write_versions("1.2.3", "1.2.3", "1.2.3") + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def write_versions( + self, + python_version: str, + rust_version: str, + node_version: str, + *, + lock_version: str | None = None, + lock_root_version: str | None = None, + ) -> None: + (self.repository / "python" / "pyproject.toml").write_text( + f'[project]\nversion = "{python_version}"\n' + ) + (self.repository / "rust" / "Cargo.toml").write_text( + f'[package]\nversion = "{rust_version}"\n' + ) + (self.repository / "node" / "package.json").write_text( + json.dumps({"version": node_version}) + ) + (self.repository / "node" / "package-lock.json").write_text( + json.dumps( + { + "version": lock_version or node_version, + "packages": {"": {"version": lock_root_version or node_version}}, + } + ) + ) + + def test_matching_stable_tag_passes(self) -> None: + self.assertEqual(verify_release(self.repository, "push", "v1.2.3"), "v1.2.3") + + def test_manual_crates_release_still_requires_coherent_versions(self) -> None: + self.assertEqual(verify_release(self.repository, "workflow_dispatch", None), "v1.2.3") + + def test_tag_must_match_exactly(self) -> None: + for tag in ("1.2.3", "v1.2.4", "v01.2.3", "v1.2.3rc1", "v1.2.3\n"): + with self.subTest(tag=tag), self.assertRaises(ReleaseVerificationError): + verify_release(self.repository, "push", tag) + + def test_every_package_version_must_match(self) -> None: + variants = ( + ("1.2.4", "1.2.3", "1.2.3", None, None), + ("1.2.3", "1.2.4", "1.2.3", None, None), + ("1.2.3", "1.2.3", "1.2.4", None, None), + ("1.2.3", "1.2.3", "1.2.3", "1.2.4", None), + ("1.2.3", "1.2.3", "1.2.3", None, "1.2.4"), + ) + for python, rust, node, lock, lock_root in variants: + with self.subTest(versions=(python, rust, node, lock, lock_root)): + self.write_versions( + python, rust, node, lock_version=lock, lock_root_version=lock_root + ) + with self.assertRaises(ReleaseVerificationError): + verify_release(self.repository, "push", "v1.2.3") + + def test_only_canonical_stable_versions_pass(self) -> None: + for version in ("1.2", "1.2.3.4", "01.2.3", "1.02.3", "1.2.03", "1.2.3rc1", "1.2.3"): + with self.subTest(version=version): + self.write_versions(version, version, version) + with self.assertRaises(ReleaseVerificationError): + verify_release(self.repository, "push", f"v{version}") + + def test_unknown_event_and_manual_tag_fail_closed(self) -> None: + with self.assertRaises(ReleaseVerificationError): + verify_release(self.repository, "schedule", None) + with self.assertRaises(ReleaseVerificationError): + verify_release(self.repository, "workflow_dispatch", "v1.2.3") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_release.py b/scripts/verify_release.py new file mode 100644 index 0000000..27913a5 --- /dev/null +++ b/scripts/verify_release.py @@ -0,0 +1,84 @@ +"""Fail-closed release metadata and tag verification for all launchers.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +import tomllib + +STABLE_VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", re.ASCII) + + +class ReleaseVerificationError(ValueError): + """Release metadata or invocation is unsafe to publish.""" + + +def read_versions(repository: Path) -> dict[str, str]: + with (repository / "python" / "pyproject.toml").open("rb") as file: + python_version = tomllib.load(file)["project"]["version"] + with (repository / "rust" / "Cargo.toml").open("rb") as file: + rust_version = tomllib.load(file)["package"]["version"] + with (repository / "node" / "package.json").open(encoding="utf-8") as file: + node_version = json.load(file)["version"] + with (repository / "node" / "package-lock.json").open(encoding="utf-8") as file: + node_lock = json.load(file) + + versions = { + "Python": python_version, + "Rust": rust_version, + "Node": node_version, + "Node lock": node_lock["version"], + "Node lock root package": node_lock["packages"][""]["version"], + } + if not all(isinstance(version, str) for version in versions.values()): + raise ReleaseVerificationError("every package version must be a string") + return versions + + +def verify_release(repository: Path, event_name: str, tag: str | None) -> str: + versions = read_versions(repository) + distinct_versions = set(versions.values()) + if len(distinct_versions) != 1: + details = ", ".join(f"{name}={version!r}" for name, version in versions.items()) + raise ReleaseVerificationError(f"package versions differ: {details}") + + version = distinct_versions.pop() + if STABLE_VERSION.fullmatch(version) is None: + raise ReleaseVerificationError(f"package version is not canonical stable SemVer: {version!r}") + + expected_tag = f"v{version}" + if event_name == "push": + if tag != expected_tag: + raise ReleaseVerificationError( + f"release tag {tag!r} does not exactly match package versions ({expected_tag!r})" + ) + elif event_name == "workflow_dispatch": + if tag: + raise ReleaseVerificationError("manual release verification does not accept a tag") + else: + raise ReleaseVerificationError(f"unsupported release event: {event_name!r}") + + return expected_tag + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repository", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--event-name", required=True) + parser.add_argument("--tag") + args = parser.parse_args() + + try: + expected_tag = verify_release(args.repository, args.event_name, args.tag) + except (KeyError, OSError, json.JSONDecodeError, tomllib.TOMLDecodeError, ReleaseVerificationError) as error: + parser.error(str(error)) + + print(f"release metadata is coherent for {expected_tag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 69f99d2ba6d83765113cfbd60722b0912bfb4ce9 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Tue, 8 Sep 2026 23:18:49 +0900 Subject: [PATCH 2/2] Validate workflow security structurally --- .github/workflows/ci.yml | 2 +- scripts/check_workflow_security.py | 260 +++++++++++++++--- ...uirements-family-connection-v1-vectors.txt | 3 + scripts/test_workflow_security.py | 80 ++++++ 4 files changed, 299 insertions(+), 46 deletions(-) create mode 100644 scripts/test_workflow_security.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a80e8cf..5f8ff4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - run: python3 scripts/validate-family-connection-v1-vectors.py - name: Release safety tests run: | - python3 -m unittest scripts/test_verify_release.py + python3 -m unittest discover -s scripts -p 'test_*.py' python3 scripts/check_workflow_security.py python: diff --git a/scripts/check_workflow_security.py b/scripts/check_workflow_security.py index 41cb95f..508fff6 100644 --- a/scripts/check_workflow_security.py +++ b/scripts/check_workflow_security.py @@ -2,64 +2,234 @@ from __future__ import annotations -import re from pathlib import Path +from typing import Any -ACTION_PIN = re.compile( - r"^\s*- uses: (?P[^\s@]+)@(?P[0-9a-f]{40}) # (?P\S.*)$" -) -REQUIRED_CI_JOBS = { - "family-connection-v1-vectors", - "python", - "rust", - "rust-msrv", - "node", +import yaml +from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode + +APPROVED_ACTIONS = { + "actions/checkout": ("3d3c42e5aac5ba805825da76410c181273ba90b1", "v7.0.1"), + "actions/download-artifact": ("3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "v8.0.1"), + "actions/setup-node": ("820762786026740c76f36085b0efc47a31fe5020", "v7.0.0"), + "actions/setup-python": ("5fda3b95a4ea91299a34e894583c3862153e4b97", "v7.0.0"), + "actions/upload-artifact": ("043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", "v7.0.1"), + "dtolnay/rust-toolchain": ("6bed0761d98439e5a578e2877258200ad565ba87", "stable 2026-09-03"), + "pypa/gh-action-pypi-publish": ("dc37677b2e1c63e2034f94d8a5b11f265b73ba33", "v1.14.2"), + "rust-lang/crates-io-auth-action": ("c6f97d42243bad5fab37ca0427f495c86d5b1a18", "v1.0.5"), + "Swatinem/rust-cache": ("6323deb102c322ba6fcbdcafc7e3dddab59af2b6", "v2.9.2"), +} +CI_NAMES = { + "family-connection-v1-vectors": "family connection v1 vectors", + "python": "python launcher", + "rust": "rust launcher", + "rust-msrv": "rust launcher MSRV", + "node": "node launcher", } -REQUIRED_RELEASE_JOBS = {"preflight", "build", "pypi", "crates", "npm"} +RELEASE_JOBS = {"preflight", "build", "pypi", "crates", "npm"} +PUSH_ONLY = "github.event_name == 'push'" + + +class UniqueBaseLoader(yaml.BaseLoader): + """Load workflow scalars as strings while rejecting duplicate keys.""" + + +def _construct_unique_mapping( + loader: UniqueBaseLoader, node: MappingNode, deep: bool = False +) -> dict[str, Any]: + result: dict[str, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in result: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + f"found duplicate key {key!r}", + key_node.start_mark, + ) + result[key] = loader.construct_object(value_node, deep=deep) + return result + + +UniqueBaseLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping +) + + +def _load_workflow(path: Path, failures: list[str]) -> tuple[dict[str, Any], Node | None, str]: + text = path.read_text() + try: + parsed = yaml.load(text, Loader=UniqueBaseLoader) + document = yaml.compose(text, Loader=UniqueBaseLoader) + except yaml.YAMLError as error: + failures.append(f"{path}: invalid YAML: {error}") + return {}, None, text + if not isinstance(parsed, dict): + failures.append(f"{path}: workflow must be a mapping") + return {}, document, text + return parsed, document, text + + +def _action_nodes(node: Node | None) -> list[ScalarNode]: + if node is None: + return [] + found: list[ScalarNode] = [] + if isinstance(node, MappingNode): + for key_node, value_node in node.value: + if isinstance(key_node, ScalarNode) and key_node.value == "uses": + if isinstance(value_node, ScalarNode): + found.append(value_node) + found.extend(_action_nodes(value_node)) + elif isinstance(node, SequenceNode): + for child in node.value: + found.extend(_action_nodes(child)) + return found + + +def _verify_actions(path: Path, document: Node | None, text: str, failures: list[str]) -> None: + lines = text.splitlines() + for node in _action_nodes(document): + reference = node.value + repository, separator, revision = reference.partition("@") + approved = APPROVED_ACTIONS.get(repository) + if not separator or approved is None or revision != approved[0]: + failures.append(f"{path}:{node.start_mark.line + 1}: unapproved action: {reference}") + continue + line = lines[node.start_mark.line] + _, comment_separator, comment = line.partition("#") + if not comment_separator or comment.strip() != approved[1]: + failures.append( + f"{path}:{node.start_mark.line + 1}: action metadata must be {approved[1]!r}" + ) + + +def _steps(job: dict[str, Any]) -> list[dict[str, Any]]: + value = job.get("steps", []) + return value if isinstance(value, list) else [] -def workflow_jobs(text: str) -> set[str]: - _, separator, jobs = text.partition("\njobs:\n") - if not separator: - return set() - return set(re.findall(r"^ ([a-z][a-z0-9-]*):\n", jobs, re.MULTILINE)) +def _uses_step(job: dict[str, Any], repository: str) -> dict[str, Any] | None: + prefix = f"{repository}@" + return next((step for step in _steps(job) if step.get("uses", "").startswith(prefix)), None) + + +def _run_commands(job: dict[str, Any]) -> list[str]: + return [step["run"].strip() for step in _steps(job) if "run" in step] + + +def _expect_equal(failures: list[str], label: str, actual: Any, expected: Any) -> None: + if actual != expected: + failures.append(f"{label}: expected {expected!r}, got {actual!r}") + + +def _verify_ci(workflow: dict[str, Any], failures: list[str]) -> None: + jobs = workflow.get("jobs", {}) + if not isinstance(jobs, dict): + failures.append("ci.yml jobs must be a mapping") + return + _expect_equal(failures, "ci.yml job IDs", set(jobs), set(CI_NAMES)) + for job_id, expected_name in CI_NAMES.items(): + job = jobs.get(job_id, {}) + _expect_equal(failures, f"ci.yml {job_id} name", job.get("name"), expected_name) + _expect_equal(failures, f"ci.yml {job_id} dependencies", job.get("needs"), None) + _expect_equal(failures, f"ci.yml {job_id} condition", job.get("if"), None) + + python_setup = _uses_step(jobs.get("python", {}), "actions/setup-python") or {} + _expect_equal( + failures, "ci.yml python runtime", python_setup.get("with", {}).get("python-version"), "3.11" + ) + node_setup = _uses_step(jobs.get("node", {}), "actions/setup-node") or {} + _expect_equal(failures, "ci.yml node runtime", node_setup.get("with", {}).get("node-version"), "22") + for job_id, toolchain in (("rust", "stable"), ("rust-msrv", "1.81.0")): + setup = _uses_step(jobs.get(job_id, {}), "dtolnay/rust-toolchain") or {} + _expect_equal( + failures, f"ci.yml {job_id} toolchain", setup.get("with", {}).get("toolchain"), toolchain + ) + + expected_commands = { + "family-connection-v1-vectors": [ + "python -m pip install --require-hashes -r scripts/requirements-family-connection-v1-vectors.txt", + "python3 scripts/validate-family-connection-v1-vectors.py", + "python3 -m unittest discover -s scripts -p 'test_*.py'\npython3 scripts/check_workflow_security.py", + ], + "python": ["pip install -e '.[dev]'", "ruff check .", "pytest -q"], + "rust": [ + "python3 ../scripts/check-rust-msrv.py .", + "cargo build --release --locked", + "cargo clippy --release --all-targets --all-features --locked -- -D warnings", + "cargo test --locked", + "cargo run --locked --example family-connection-context-v1-vectors --features repository-fixtures", + "cargo package --locked\nmkdir -p target/package-test\ntar -xzf target/package/determa-*.crate -C target/package-test\ncargo test --locked --all-features --manifest-path target/package-test/determa-*/Cargo.toml", + ], + "rust-msrv": [ + "python3 ../scripts/check-rust-msrv.py .", + "cargo build --release --locked", + "cargo test --locked", + "cargo run --locked --example family-connection-context-v1-vectors --features repository-fixtures", + ], + "node": ["npm ci", "npm test"], + } + for job_id, commands in expected_commands.items(): + _expect_equal(failures, f"ci.yml {job_id} commands", _run_commands(jobs.get(job_id, {})), commands) + + +def _verify_release(workflow: dict[str, Any], failures: list[str]) -> None: + jobs = workflow.get("jobs", {}) + if not isinstance(jobs, dict): + failures.append("release.yml jobs must be a mapping") + return + _expect_equal(failures, "release.yml job IDs", set(jobs), RELEASE_JOBS) + topology = { + "preflight": (None, None), + "build": ("preflight", PUSH_ONLY), + "pypi": ("build", PUSH_ONLY), + "crates": ("preflight", None), + "npm": ("preflight", PUSH_ONLY), + } + for job_id, (needs, condition) in topology.items(): + job = jobs.get(job_id, {}) + _expect_equal(failures, f"release.yml {job_id} dependencies", job.get("needs"), needs) + _expect_equal(failures, f"release.yml {job_id} condition", job.get("if"), condition) + + _expect_equal( + failures, + "release.yml preflight commands", + _run_commands(jobs.get("preflight", {})), + ['python3 scripts/verify_release.py --event-name "$GITHUB_EVENT_NAME" --tag "$RELEASE_TAG"'], + ) + preflight_steps = _steps(jobs.get("preflight", {})) + verify_step = next( + ( + step + for step in preflight_steps + if step.get("name") == "Verify synchronized package versions and release tag" + ), + {}, + ) + _expect_equal( + failures, + "release.yml preflight tag source", + verify_step.get("env", {}).get("RELEASE_TAG"), + "${{ github.event_name == 'push' && github.ref_name || '' }}", + ) + npm_setup = _uses_step(jobs.get("npm", {}), "actions/setup-node") or {} + _expect_equal(failures, "release.yml node runtime", npm_setup.get("with", {}).get("node-version"), "22") def verify_workflows(repository: Path) -> None: workflow_dir = repository / ".github" / "workflows" failures: list[str] = [] - for workflow in sorted(workflow_dir.glob("*.yml")): - for line_number, line in enumerate(workflow.read_text().splitlines(), 1): - if "uses:" in line and ACTION_PIN.fullmatch(line) is None: - failures.append(f"{workflow}:{line_number}: mutable or uncommented action: {line.strip()}") - - ci = (workflow_dir / "ci.yml").read_text() - if workflow_jobs(ci) != REQUIRED_CI_JOBS: - failures.append(f"ci.yml job contexts changed: {sorted(workflow_jobs(ci))}") - for required in ('python-version: "3.11"', 'toolchain: 1.81.0', 'node-version: "22"'): - if required not in ci: - failures.append(f"ci.yml is missing required setting: {required}") - - release = (workflow_dir / "release.yml").read_text() - if workflow_jobs(release) != REQUIRED_RELEASE_JOBS: - failures.append(f"release.yml job set changed: {sorted(workflow_jobs(release))}") - required_release_fragments = ( - " preflight:\n", - " build:\n needs: preflight\n if: github.event_name == 'push'\n", - " pypi:\n needs: build\n if: github.event_name == 'push'\n", - " crates:\n needs: preflight\n", - " npm:\n needs: preflight\n if: github.event_name == 'push'\n", - "python3 scripts/verify_release.py", - 'node-version: "22"', - ) - for required in required_release_fragments: - if required not in release: - failures.append(f"release.yml is missing required publication guard: {required!r}") - + parsed: dict[str, dict[str, Any]] = {} + for name in ("ci.yml", "release.yml"): + workflow, document, text = _load_workflow(workflow_dir / name, failures) + parsed[name] = workflow + _verify_actions(workflow_dir / name, document, text, failures) + _verify_ci(parsed["ci.yml"], failures) + _verify_release(parsed["release.yml"], failures) if failures: raise SystemExit("\n".join(failures)) if __name__ == "__main__": verify_workflows(Path(__file__).resolve().parents[1]) - print("workflow action pins and required contexts are valid") + print("workflow topology, action pins, and required contexts are valid") diff --git a/scripts/requirements-family-connection-v1-vectors.txt b/scripts/requirements-family-connection-v1-vectors.txt index 47a7a2a..fc53f9d 100644 --- a/scripts/requirements-family-connection-v1-vectors.txt +++ b/scripts/requirements-family-connection-v1-vectors.txt @@ -1,2 +1,5 @@ idna==3.10 \ --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 +PyYAML==6.0.3 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f diff --git a/scripts/test_workflow_security.py b/scripts/test_workflow_security.py new file mode 100644 index 0000000..055f155 --- /dev/null +++ b/scripts/test_workflow_security.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from check_workflow_security import verify_workflows + + +class WorkflowSecurityTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.repository = Path(self.temporary_directory.name) + source = Path(__file__).resolve().parents[1] / ".github" / "workflows" + shutil.copytree(source, self.repository / ".github" / "workflows") + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def mutate(self, workflow: str, old: str, new: str) -> None: + path = self.repository / ".github" / "workflows" / workflow + text = path.read_text() + self.assertIn(old, text) + path.write_text(text.replace(old, new, 1)) + + def assert_rejected(self) -> None: + with self.assertRaises(SystemExit): + verify_workflows(self.repository) + + def test_current_workflows_pass(self) -> None: + verify_workflows(self.repository) + + def test_equivalent_inline_jobs_comments_are_accepted(self) -> None: + self.mutate("ci.yml", "jobs:\n", "jobs: # launcher checks\n") + self.mutate("release.yml", "jobs:\n", "jobs: # publication graph\n") + verify_workflows(self.repository) + + def test_renamed_status_context_is_rejected(self) -> None: + self.mutate("ci.yml", "name: node launcher", "name: renamed node launcher") + self.assert_rejected() + + def test_runtime_change_hidden_by_comment_is_rejected(self) -> None: + self.mutate( + "ci.yml", + 'python-version: "3.11"', + 'python-version: "3.12" # python-version: "3.11"', + ) + self.assert_rejected() + + def test_false_action_version_metadata_is_rejected(self) -> None: + self.mutate("ci.yml", "# v7.0.1", "# v6.0.0") + self.assert_rejected() + + def test_mutable_action_reference_is_rejected(self) -> None: + self.mutate( + "ci.yml", + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/checkout@v7", + ) + self.assert_rejected() + + def test_preflight_dependency_removal_is_rejected(self) -> None: + self.mutate("release.yml", " build:\n needs: preflight\n", " build:\n") + self.assert_rejected() + + def test_manual_dispatch_condition_bypass_is_rejected(self) -> None: + self.mutate( + "release.yml", + " npm:\n needs: preflight\n if: github.event_name == 'push'\n", + " npm:\n needs: preflight\n", + ) + self.assert_rejected() + + +if __name__ == "__main__": + unittest.main()