Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
157 changes: 157 additions & 0 deletions tests/test_security_bundle_reusable_entrypoint.py
Original file line number Diff line number Diff line change
@@ -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
Loading