From 9eb11c21635c6a630a7b21bed657c53d20632738 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Wed, 7 Oct 2026 00:52:42 +0500 Subject: [PATCH] fix(security): isolate bundle checkouts and materialize selected history Signed-off-by: rldyourmnd --- .github/workflows/nddev-security-bundle.yml | 3 +- .../private-security-bundle-free.yml | 3 +- CHANGELOG.md | 3 + catalog/capabilities.yml | 6 +- catalog/python-execution.yml | 4 + examples/private-free/security-selfhosted.yml | 2 + .../private-paid-ghas/security-selfhosted.yml | 2 + scripts/check_workflow_contracts.py | 132 ++++++++++++++++++ scripts/prepare_gitleaks_history.sh | 34 +++++ scripts/run_private_security_bundle.sh | 6 +- 10 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 scripts/prepare_gitleaks_history.sh diff --git a/.github/workflows/nddev-security-bundle.yml b/.github/workflows/nddev-security-bundle.yml index 82d5b7d..a8badcf 100644 --- a/.github/workflows/nddev-security-bundle.yml +++ b/.github/workflows/nddev-security-bundle.yml @@ -91,7 +91,8 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail - source_root="$RUNNER_TEMP/nddev-security-bundle-source" + umask 077 + source_root="$(mktemp -d "$RUNNER_TEMP/nddev-security-bundle-source.XXXXXXXX")" git init --quiet "$source_root" git -C "$source_root" remote add origin \ "https://github.com/${WORKFLOW_REPOSITORY}.git" diff --git a/.github/workflows/private-security-bundle-free.yml b/.github/workflows/private-security-bundle-free.yml index 23e8b99..ddc0e12 100644 --- a/.github/workflows/private-security-bundle-free.yml +++ b/.github/workflows/private-security-bundle-free.yml @@ -83,7 +83,8 @@ jobs: FETCH_TOKEN: ${{ github.token }} run: | set -euo pipefail - source_root="$RUNNER_TEMP/private-security-bundle-source" + umask 077 + source_root="$(mktemp -d "$RUNNER_TEMP/private-security-bundle-source.XXXXXXXX")" git init --quiet "$source_root" git -C "$source_root" remote add origin "https://github.com/${WORKFLOW_REPOSITORY}.git" # Authenticate the fetch with the ambient token. The repository is diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e033e..40cca97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The project follows Semantic Versioning. ## [Unreleased] +- Give both private security bundles a fresh implementation checkout for each + invocation, preserving stale runner files. Materialize the selected Git + history before Gitleaks and fail when any required object is unavailable. - **Bump urllib3 to 2.8.0 in `requirements-qt.txt`** (recompiled via `uv pip compile --upgrade-package urllib3`): the osv-scan runtime fixture flagged PYSEC-2026-4176 and PYSEC-2026-4177 on the pinned 2.7.0. diff --git a/catalog/capabilities.yml b/catalog/capabilities.yml index 16d1ceb..3f387ea 100644 --- a/catalog/capabilities.yml +++ b/catalog/capabilities.yml @@ -272,7 +272,8 @@ capabilities: - "Requires GitHub Code Security because zizmor SARIF upload is preserved; private-free callers keep the no-SARIF workflows" - "Four gates share one failure domain, but the script executes and summarizes every gate before returning failure" - "Artifact upload failure requires a complete bounded redacted ZIP with SHA-256 and run identity in the run log; failure of both transports fails the job" - - "The called workflow fetches its implementation from job.workflow_repository at exact job.workflow_sha outside the caller workspace" + - "The called workflow fetches its implementation from job.workflow_repository at exact job.workflow_sha into a fresh invocation-specific directory outside the caller workspace" + - "Selected Git history is materialized with process-only authentication before Gitleaks; unavailable objects fail the scan" - "Downloaded actionlint, OSV-Scanner, and gitleaks artifacts are checksum-pinned; zizmor is uvx version-pinned" deprecations: null last_verified: "2026-08-21" @@ -301,7 +302,8 @@ capabilities: - "SARIF remains an artifact rather than a code-scanning upload because private-free repositories have no security-events entitlement" - "Four gates share one failure domain, but every gate executes before aggregate failure" - "Optional check_name preserves the inner job display name during required-check migration; caller prefix and scanner enforcement remain separate" - - "Called-workflow implementation is fetched at exact job.workflow_sha outside the caller workspace" + - "Called-workflow implementation is fetched at exact job.workflow_sha into a fresh invocation-specific directory outside the caller workspace" + - "Selected Git history is materialized with process-only authentication before Gitleaks; unavailable objects fail the scan" - "Gitleaks evidence is fully redacted before the one-day artifact upload; a failed upload requires a complete bounded ZIP with SHA-256 and run identity in the run log" - "The tool-cache action is vendored at actions/tool-cache from NDDev-Archive/github-actions-garm 468af475; an archived dependency cannot receive fixes, so the live copy lives beside the workflow that runs it" deprecations: null diff --git a/catalog/python-execution.yml b/catalog/python-execution.yml index da49238..749d076 100644 --- a/catalog/python-execution.yml +++ b/catalog/python-execution.yml @@ -787,6 +787,10 @@ "run": { "count": 1, "profile": "shell-fixture" + }, + "_security_fixture_process": { + "count": 1, + "profile": "external-tool-fixture" } }, "generate_android_fixture_provenance.py": { diff --git a/examples/private-free/security-selfhosted.yml b/examples/private-free/security-selfhosted.yml index e7bef84..b6dadc9 100644 --- a/examples/private-free/security-selfhosted.yml +++ b/examples/private-free/security-selfhosted.yml @@ -1,3 +1,5 @@ +# Each security call uses a fresh implementation directory and verifies that +# the selected Git history is available before scanning. # If artifact storage is unavailable, complete redacted reports are preserved # as a checksummed ZIP in the security evidence fallback step log. # Private security suite with zero GitHub Actions compute meter. diff --git a/examples/private-paid-ghas/security-selfhosted.yml b/examples/private-paid-ghas/security-selfhosted.yml index c2fd808..18c98a1 100644 --- a/examples/private-paid-ghas/security-selfhosted.yml +++ b/examples/private-paid-ghas/security-selfhosted.yml @@ -1,3 +1,5 @@ +# Each security call uses a fresh implementation directory and verifies that +# the selected Git history is available before scanning. # If artifact storage is unavailable, complete redacted reports are preserved # as a checksummed ZIP in the security evidence fallback step log. # Private repository WITH GitHub Code Security, on a caller-owned self-hosted diff --git a/scripts/check_workflow_contracts.py b/scripts/check_workflow_contracts.py index c0a8849..5d0aedc 100644 --- a/scripts/check_workflow_contracts.py +++ b/scripts/check_workflow_contracts.py @@ -202,6 +202,8 @@ def check() -> list[str]: and step.get("run") == "exit 1" for step in steps): problems.append(f"{filename}: deferred scanner failure must still be enforced") problems.extend(_probe_security_evidence(workflow_root.parents[1])) + problems.extend(_probe_security_bundle_source(workflow_root.parents[1])) + problems.extend(_probe_gitleaks_history(workflow_root.parents[1])) for filename in sorted(isolated_checkout_workflows): workflow = load_yaml(workflow_root / filename) @@ -677,6 +679,136 @@ def run(): return problems +def _security_fixture_process(args: list[str], cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, + timeout=30, check=False) + + +def _security_fixture_repo(target: Path, env: dict[str, str]) -> str: + target.mkdir() + for args in (["git", "init", "--quiet"], + ["git", "config", "uploadpack.allowFilter", "true"], + ["git", "config", "uploadpack.allowAnySHA1InWant", "true"]): + result = _security_fixture_process(args, target, env) + if result.returncode: + raise ValueError(result.stderr) + (target / "fixture.txt").write_text("synthetic history fixture\n") + (target / "scripts").mkdir() + (target / "scripts" / "run_private_security_bundle.sh").write_text("#!/bin/bash\nexit 0\n") + for args in (["git", "add", "."], + ["git", "commit", "--quiet", "--no-gpg-sign", "-m", "fixture"], + ["git", "rev-parse", "HEAD"]): + result = _security_fixture_process(args, target, env) + if result.returncode: + raise ValueError(result.stderr) + return result.stdout.strip() + + +def _security_fixture_env(target: Path) -> dict[str, str]: + config = target / "gitconfig" + config.touch() + return clean_environment({ + "GIT_CONFIG_GLOBAL": str(config), "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Fixture", "GIT_COMMITTER_NAME": "Fixture", + "GIT_AUTHOR_EMAIL": "fixture@example.invalid", + "GIT_COMMITTER_EMAIL": "fixture@example.invalid", + "GIT_TERMINAL_PROMPT": "0", "GH_TOKEN": "synthetic-fixture-only", + "RUNNER_TEMP": str(target), + }) + + +def _probe_security_bundle_source(root: Path) -> list[str]: + """Execute both YAML steps twice against one runner temp with stale files.""" + problems: list[str] = [] + try: + with tempfile.TemporaryDirectory(prefix="security-source-probe-") as directory: + target = Path(directory) + env = _security_fixture_env(target) + fixture = target / "origin" + oid = _security_fixture_repo(fixture, env) + rewritten = _security_fixture_process( + ["git", "config", "--global", f"url.{fixture.as_uri()}.insteadOf", + "https://github.com/example-org/fixture.git"], target, env) + if rewritten.returncode: + return ["security source fixture could not configure its local origin"] + for filename, prefix, variable in ( + ("private-security-bundle-free.yml", "private-security-bundle-source", "PRIVATE_SECURITY_BUNDLE_SCRIPT"), + ("nddev-security-bundle.yml", "nddev-security-bundle-source", "NDDEV_SECURITY_BUNDLE_SCRIPT"), + ): + workflow = load_yaml(root / ".github" / "workflows" / filename) + step = next(s for s in workflow["jobs"]["security-bundle"]["steps"] + if s.get("name") == "Fetch exact called-workflow implementation") + stale = target / prefix + stale.mkdir() + sentinel = stale / "fixture.txt" + sentinel.write_text("preserve stale runner file\n") + output = target / (prefix + ".env") + run_env = dict(env, GITHUB_ENV=str(output), WORKFLOW_REPOSITORY="example-org/fixture", + WORKFLOW_SHA=oid, FETCH_TOKEN="synthetic-fixture-only", + GITHUB_TOKEN="synthetic-fixture-only") + for _ in range(2): + result = _security_fixture_process(["bash", "-c", step["run"]], target, run_env) + if result.returncode: + problems.append(f"{filename}: exact checkout failed with stale runner files") + break + else: + paths = [line.split("=", 1)[1] for line in output.read_text().splitlines() + if line.startswith(variable + "=")] + if len(paths) != 2 or len(set(paths)) != 2 or not all(Path(p).is_file() for p in paths): + problems.append(f"{filename}: repeated calls did not get distinct complete checkouts") + original = re.sub(r'^source_root=.*$', f'source_root="$RUNNER_TEMP/{prefix}"', + step["run"], count=1, flags=re.MULTILINE) + failed = _security_fixture_process(["bash", "-c", original], target, run_env) + if failed.returncode == 0: + problems.append(f"{filename}: shared-directory negative control did not fail") + if sentinel.read_text() != "preserve stale runner file\n": + problems.append(f"{filename}: stale runner file was modified") + except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error: + problems.append(f"security source probe failed: {error}") + return problems + + +def _probe_gitleaks_history(root: Path) -> list[str]: + """Hydrate a real partial clone; reject missing history without fake green.""" + problems: list[str] = [] + try: + with tempfile.TemporaryDirectory(prefix="security-history-probe-") as directory: + target = Path(directory) + env = _security_fixture_env(target) + fixture = target / "origin" + _security_fixture_repo(fixture, env) + helper = root / "scripts" / "prepare_gitleaks_history.sh" + for case in ("available", "unavailable"): + checkout = target / case + cloned = _security_fixture_process( + ["git", "clone", "--quiet", "--filter=blob:none", "--no-checkout", + fixture.as_uri(), str(checkout)], target, env) + if cloned.returncode: + return ["gitleaks history fixture could not create a partial clone"] + missing = _security_fixture_process( + ["git", "rev-list", "--objects", "--missing=print", "HEAD"], checkout, env) + if not any(line.startswith("?") for line in missing.stdout.splitlines()): + return ["gitleaks history fixture did not contain missing blobs"] + if case == "unavailable": + _security_fixture_process(["git", "remote", "set-url", "origin", + (target / "absent.git").as_uri()], checkout, env) + result = _security_fixture_process(["bash", str(helper), "ref-history"], checkout, env) + if (result.returncode == 0) != (case == "available"): + problems.append(f"gitleaks history: wrong outcome for {case} promisor origin") + if case == "available": + after = _security_fixture_process( + ["git", "rev-list", "--objects", "--missing=print", "HEAD"], checkout, env) + if any(line.startswith("?") for line in after.stdout.splitlines()): + problems.append("gitleaks history: successful hydration left missing blobs") + stored = _security_fixture_process( + ["git", "config", "--local", "--get-regexp", "extraheader"], checkout, env) + if stored.stdout or "synthetic-fixture-only" in result.stdout + result.stderr: + problems.append("gitleaks history: authorization escaped its process environment") + except (OSError, ValueError, subprocess.TimeoutExpired) as error: + problems.append(f"gitleaks history probe failed: {error}") + return problems + + def main() -> int: problems = check() if problems: diff --git a/scripts/prepare_gitleaks_history.sh b/scripts/prepare_gitleaks_history.sh new file mode 100644 index 0000000..e60bd7b --- /dev/null +++ b/scripts/prepare_gitleaks_history.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Materialize and verify the selected history without printing file contents. +set -Eeuo pipefail +set +x +umask 077 + +case "${1:-}" in + ref-history) scope=HEAD ;; + all-refs) scope=--all ;; + *) echo 'Expected ref-history or all-refs' >&2; exit 2 ;; +esac +test -n "${RUNNER_TEMP:?}" +test -n "${GH_TOKEN:?}" + +# Partial clones can need a late authenticated fetch after checkout has +# deliberately removed its credentials. Keep this header in this process's +# environment only; never persist it in the caller's repository or argv. +index=${GIT_CONFIG_COUNT:-0} +[[ "$index" =~ ^[0-9]+$ ]] +authorization="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" +export "GIT_CONFIG_KEY_${index}=http.https://github.com/.extraheader" +export "GIT_CONFIG_VALUE_${index}=$authorization" +export GIT_CONFIG_COUNT=$((index + 1)) + +objects=$(mktemp "$RUNNER_TEMP/gitleaks-history-objects.XXXXXXXX") +inventory=$(mktemp "$RUNNER_TEMP/gitleaks-history-inventory.XXXXXXXX") +trap 'rm -f -- "$objects" "$inventory"' EXIT +git rev-list --objects --no-object-names "$scope" > "$objects" +git cat-file --batch-check='%(objectname) %(objecttype)' < "$objects" > "$inventory" +if grep -q ' missing$' "$inventory"; then + echo 'Cannot scan the selected history: Git objects are unavailable' >&2 + exit 1 +fi +echo 'Selected Git history objects are available for Gitleaks' diff --git a/scripts/run_private_security_bundle.sh b/scripts/run_private_security_bundle.sh index 04f3f1e..3cac72b 100755 --- a/scripts/run_private_security_bundle.sh +++ b/scripts/run_private_security_bundle.sh @@ -131,10 +131,14 @@ run_osv() { } cd "$GITHUB_WORKSPACE" +run_gitleaks() { + bash "$(dirname "${BASH_SOURCE[0]}")/prepare_gitleaks_history.sh" "$gitleaks_scan_scope" || return + gitleaks "${gitleaks_args[@]}" +} run_gate actionlint run_actionlint run_gate zizmor run_zizmor run_gate osv-scanner run_osv -run_gate gitleaks gitleaks "${gitleaks_args[@]}" +run_gate gitleaks run_gitleaks for report in "$zizmor_sarif" "$osv_sarif" "$gitleaks_sarif"; do if ! jq -e '(.version == "2.1.0") and (.runs | type == "array")' "$report" >/dev/null; then