From 31a9aefd8d075295c2ad09ac2a9a71949810c9e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 14 Oct 2026 13:30:10 +0900 Subject: [PATCH] ci: expose security bundle through validated reusable PR ingress --- .github/workflows/security-scan.yml | 58 +++++++ ...est_security_bundle_reusable_entrypoint.py | 157 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 tests/test_security_bundle_reusable_entrypoint.py diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 46cddd322e..6e2130453b 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -40,6 +40,7 @@ name: Security Scan on: + workflow_call: {} pull_request: types: [opened, synchronize, reopened, ready_for_review, closed] # Do not restrict the base ref: stacked PRs must receive the same @@ -61,7 +62,63 @@ permissions: contents: read jobs: + admit-security-pr: + name: Admit security PR context + runs-on: + group: CWL CI isolated + labels: [self-hosted, linux, x64, cwlab-ci-isolated] + timeout-minutes: 5 + permissions: {} + steps: + - name: Validate security PR admission + env: + EVENT_NAME: ${{ github.event_name }} + TARGET_REPOSITORY: ${{ github.repository }} + shell: python3 {0} + run: | + import json + import os + import re + import sys + + try: + if os.environ['EVENT_NAME'] not in ('pull_request', 'pull_request_target'): + raise ValueError('unsupported event') + with open(os.environ['GITHUB_EVENT_PATH'], encoding='utf-8') as source: + event = json.load(source) + if not isinstance(event, dict): + raise ValueError('invalid event shape') + action = event.get('action') + if not isinstance(action, str) or not action or any(ord(c) < 32 for c in action): + raise ValueError('invalid PR action') + pr = event['pull_request'] + number = event['number'] + if not isinstance(pr, dict) or type(pr.get('number')) is not int: + raise ValueError('invalid PR shape') + if type(number) is not int or number <= 0 or pr.get('number') != number: + raise ValueError('invalid PR identity') + target = os.environ['TARGET_REPOSITORY'] + repository_pattern = r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+' + for side in ('base', 'head'): + identity = pr[side] + repository = identity['repo']['full_name'] + if not isinstance(repository, str) or not re.fullmatch(repository_pattern, repository): + raise ValueError('invalid repository identity') + if any(part in ('.', '..') for part in repository.split('/')): + raise ValueError('invalid repository component') + if side == 'base' and repository != target: + raise ValueError('target mismatch') + if not isinstance(identity['sha'], str) or not re.fullmatch(r'[0-9a-f]{40}', identity['sha']): + raise ValueError('invalid immutable SHA') + if not isinstance(identity['ref'], str) or not identity['ref'] or any(ord(c) < 32 for c in identity['ref']): + raise ValueError('invalid ref') + except (KeyError, TypeError, ValueError, OSError): + print('Invalid security PR admission; no scanner checkout authorized.', file=sys.stderr) + sys.exit(1) + print('Security PR context admitted.') + changed-scope: + needs: admit-security-pr name: Detect changed scope # The org ruleset IGNORES every `on:` filter (paths, branches, types) when it # runs this workflow in another repository, and a trigger-level skip would @@ -461,6 +518,7 @@ jobs: # repository condition preserves the standalone workflow's previous scope; # push, schedule, and manual backstops remain in secret-scan.yml. gitleaks: + needs: admit-security-pr name: gitleaks (secret scan) if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' runs-on: diff --git a/tests/test_security_bundle_reusable_entrypoint.py b/tests/test_security_bundle_reusable_entrypoint.py new file mode 100644 index 0000000000..a8ceea2989 --- /dev/null +++ b/tests/test_security_bundle_reusable_entrypoint.py @@ -0,0 +1,157 @@ +"""Source-only regression for the central security bundle reusable entrypoint. + +Scanner execution, runner admission and exact consumer context publication are +separate acceptance gates. This test does not manufacture any runtime result. +""" + +from pathlib import Path +import json +import os +import shutil +import subprocess + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/security-scan.yml" +BASE = "acb8b34f9e7b8649fa1478139fa21c2217bdfbc7" + + +def test_reusable_entrypoint_preserves_entire_security_bundle() -> None: + """Expose the existing bundle without changing a scanner or its permissions.""" + original = subprocess.check_output( + ["git", "show", f"{BASE}:.github/workflows/security-scan.yml"], + cwd=ROOT, + ) + current = WORKFLOW.read_bytes() + addition = b" workflow_call: {}\n" + assert current.count(addition) == 1, "missing explicit reusable security ingress" + admission_start = current.index(b" admit-security-pr:\n", current.index(b"jobs:\n")) + admission_end = current.index(b" changed-scope:\n", admission_start) + admission = current[admission_start:admission_end] + assert b" permissions: {}\n" in admission + assert b"uses:" not in admission and b"checkout" not in admission.split(b"run: |", 1)[0] + assert current.count(b" needs: admit-security-pr\n") == 2 + assert b" changed-scope:\n needs: admit-security-pr\n" in current + assert b" gitleaks:\n needs: admit-security-pr\n" in current + restored = current[:admission_start] + current[admission_end:] + restored = restored.replace(addition, b"", 1).replace(b" needs: admit-security-pr\n", b"") + assert restored == original, "scanner bodies, scope and permissions changed" + + +@pytest.mark.parametrize( + "paths,expected_count,expected", + [ + ("README.md\n", "1", {"code": "false", "deps": "false"}), + ("python/example.py\n", "1", {"code": "true", "deps": "false"}), + ("Cargo.lock\n", "1", {"code": "true", "deps": "true"}), + ("README.md\n", "2", {"code": "true", "deps": "true"}), + ], +) +def test_actual_scope_shell_preserves_scanner_applicability( + tmp_path: Path, paths: str, expected_count: str, expected: dict[str, str] +) -> None: + """Characterize actual scope code with synthetic file-list transport only.""" + text = WORKFLOW.read_text(encoding="utf-8") + start = text.index(" run: |\n", text.index("- name: Classify changed paths")) + lines = text[start:].splitlines()[1:] + body = [] + for line in lines: + if line.strip() and not line.startswith(" "): + break + body.append(line[10:]) + assert body and body[0] == "set -uo pipefail" + tools = tmp_path / "bin" + tools.mkdir() + gh = tools / "gh" + gh.write_text('#!/bin/sh\nprintf "%s" "$FIXTURE_PATHS"\n', encoding="utf-8") + gh.chmod(0o700) + output = tmp_path / "outputs" + bash = shutil.which("bash") + assert bash, "Bash is required; do not skip source-shell contracts" + result = subprocess.run( + [bash, "-c", "\n".join(body)], + cwd=tmp_path, + env={ + "PATH": str(tools) + os.pathsep + "/usr/bin:/bin", + "FIXTURE_PATHS": paths, + "REPO": "ContextualWisdomLab/fast-mlsirm", + "PR": "2302", + "EXPECTED_FILES": expected_count, + "GITHUB_OUTPUT": str(output), + }, + text=True, + capture_output=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr + actual = dict(line.split("=", 1) for line in output.read_text().splitlines()) + assert actual == expected + + +def step_body(text: str, name: str) -> str: + """Extract the complete literal run body of a named native workflow step.""" + start = text.index(" run: |\n", text.index(f"- name: {name}")) + body = [] + for line in text[start:].splitlines()[1:]: + if line.strip() and not line.startswith(" "): + break + body.append(line[10:]) + return "\n".join(body) + + +@pytest.mark.parametrize("event_name,action,mutation,allowed", [ + ("issues", "closed", "", False), + ("push", "", "missing_pr", False), + ("workflow_dispatch", "", "missing_pr", False), + ("pull_request", "opened", "missing_pr", False), + ("pull_request", "opened", "sha", False), + ("pull_request", "opened", "repository", False), + ("pull_request", "opened", "number", False), + ("pull_request", "opened", "", True), + ("pull_request_target", "synchronize", "fork", True), + ("pull_request", "closed", "", True), + ("pull_request", "edited", "", True), + ("pull_request", "converted_to_draft", "", True), + ("pull_request", "", "", False), +]) +def test_actual_admission_rejects_unsupported_event_before_scanners( + tmp_path: Path, event_name: str, action: str, mutation: str, allowed: bool +) -> None: + """Execute native admission, or predecessor scope for causal rejection RED.""" + import sys + + repository = "ContextualWisdomLab/fast-mlsirm" + pr = {"number": 2302, + "base": {"repo": {"full_name": repository}, "sha": "a" * 40, "ref": "main"}, + "head": {"repo": {"full_name": repository}, "sha": "b" * 40, "ref": "feature"}} + event = {"number": 2302, "action": action, "pull_request": pr} + if mutation == "missing_pr": + del event["pull_request"] + elif mutation == "sha": + pr["head"]["sha"] = "main" + elif mutation == "repository": + pr["base"]["repo"]["full_name"] = "other/repository" + elif mutation == "number": + event["number"] = 42 + elif mutation == "fork": + pr["head"]["repo"]["full_name"] = "fork-owner/fast-mlsirm" + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps(event), encoding="utf-8") + text = WORKFLOW.read_text(encoding="utf-8") + name = "Validate security PR admission" + if f"- name: {name}" in text: + argv = [sys.executable, "-c", step_body(text, name)] + else: + # Original real scope code with no PR context returns0: not a mock guard. + argv = [shutil.which("bash"), "-c", step_body(text, "Classify changed paths")] + result = subprocess.run(argv, cwd=tmp_path, capture_output=True, text=True, + timeout=10, env={"PATH": "/usr/bin:/bin", "GITHUB_EVENT_PATH": str(event_path), + "EVENT_NAME": event_name, "TARGET_REPOSITORY": repository, + "PR": "", "EXPECTED_FILES": "", "GITHUB_OUTPUT": str(tmp_path / "output")}) + if allowed: + assert result.returncode == 0, result.stderr + else: + assert result.returncode != 0, "unsupported security invocation was admitted" + assert "Invalid security PR admission" in result.stderr