Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .github/workflows/nddev-security-bundle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/private-security-bundle-free.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions catalog/capabilities.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions catalog/python-execution.yml
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,10 @@
"run": {
"count": 1,
"profile": "shell-fixture"
},
"_security_fixture_process": {
"count": 1,
"profile": "external-tool-fixture"
}
},
"generate_android_fixture_provenance.py": {
Expand Down
2 changes: 2 additions & 0 deletions examples/private-free/security-selfhosted.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 2 additions & 0 deletions examples/private-paid-ghas/security-selfhosted.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
132 changes: 132 additions & 0 deletions scripts/check_workflow_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions scripts/prepare_gitleaks_history.sh
Original file line number Diff line number Diff line change
@@ -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'
6 changes: 5 additions & 1 deletion scripts/run_private_security_bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading