From 95e9b10884aec04f0af2330d9d98395089fcb402 Mon Sep 17 00:00:00 2001 From: Tim White Date: Mon, 12 Oct 2026 14:03:48 -0700 Subject: [PATCH 1/2] feat(workflows): add reusable pinned gitleaks secret scan Callable workflow for every JovieInc repo: pinned, checksum-verified gitleaks, range-scoped on push/PR/merge_group, full-history and shadow-mode inputs, and behavioral tests that parse the YAML and run the step scripts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/secret-scan-self.yml | 23 ++++++ .github/workflows/secret-scan.yml | 88 ++++++++++++++++++++++ README.md | 3 + tests/test_secret_scan_workflow.py | 100 +++++++++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 .github/workflows/secret-scan-self.yml create mode 100644 .github/workflows/secret-scan.yml create mode 100644 tests/test_secret_scan_workflow.py diff --git a/.github/workflows/secret-scan-self.yml b/.github/workflows/secret-scan-self.yml new file mode 100644 index 0000000..25b7492 --- /dev/null +++ b/.github/workflows/secret-scan-self.yml @@ -0,0 +1,23 @@ +name: Secret scan qualification +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +jobs: + behavior: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.12' + - run: python3 -m pip install 'pyyaml>=6,<7' + - run: python3 -m unittest discover -s tests -p test_secret_scan_workflow.py -v + scan: + uses: ./.github/workflows/secret-scan.yml + permissions: + contents: read diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..07c0e47 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,88 @@ +# Shared gitleaks secret scan (workflow_call). Pin consumers to a full commit SHA: +# uses: JovieInc/ci/.github/workflows/secret-scan.yml@ +# Scans the pushed or PR commit range by default; full-history: true scans every ref. +# The consumer's .gitleaks.toml applies when present, otherwise gitleaks defaults. +name: Secret Scan + +on: + workflow_call: + inputs: + full-history: + description: Scan all refs and history instead of the pushed or PR commit range. + required: false + type: boolean + default: false + fail-on-findings: + description: Fail the job on findings. Set false while a consumer qualifies in shadow. + required: false + type: boolean + default: true + +permissions: {} + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install pinned gitleaks + env: + GITLEAKS_ARCHIVE_URL: 'https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz' + GITLEAKS_SHA256: '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb' + run: | + set -euo pipefail + dir="$RUNNER_TEMP/gitleaks" + mkdir -p "$dir" + curl --fail --silent --show-error --location --output "$dir/gitleaks.tar.gz" "$GITLEAKS_ARCHIVE_URL" + printf '%s %s\n' "$GITLEAKS_SHA256" "$dir/gitleaks.tar.gz" | sha256sum --check --strict + tar -xzf "$dir/gitleaks.tar.gz" -C "$dir" gitleaks + echo "$dir" >> "$GITHUB_PATH" + + - name: Resolve scan range + id: range + env: + FULL_HISTORY: ${{ inputs.full-history }} + EVENT_NAME: ${{ github.event_name }} + PUSH_BEFORE: ${{ github.event.before }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + zero=0000000000000000000000000000000000000000 + base="" + case "$EVENT_NAME" in + push) base="$PUSH_BEFORE" ;; + pull_request|pull_request_target) base="$PR_BASE_SHA" ;; + merge_group) base="$MERGE_GROUP_BASE_SHA" ;; + esac + if [ "$FULL_HISTORY" = "true" ]; then + opts="--all" + elif [ -n "$base" ] && [ "$base" != "$zero" ] && git cat-file -e "${base}^{commit}" 2>/dev/null; then + opts="${base}..${HEAD_SHA}" + else + # New branch, force push, schedule or dispatch: no trustworthy base, so scan everything reachable. + opts="$HEAD_SHA" + fi + echo "log-opts=$opts" >> "$GITHUB_OUTPUT" + echo "Scanning: git log $opts" + + - name: Run gitleaks + env: + LOG_OPTS: ${{ steps.range.outputs.log-opts }} + FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} + run: | + set -euo pipefail + args=(git --log-opts="$LOG_OPTS" --redact=100 --no-banner --no-color --exit-code=1) + if [ -f .gitleaks.toml ]; then args+=(--config=.gitleaks.toml); fi + if gitleaks "${args[@]}" .; then exit 0; fi + if [ "$FAIL_ON_FINDINGS" = "true" ]; then exit 1; fi + echo "::warning::gitleaks reported findings; fail-on-findings is false (shadow mode)." diff --git a/README.md b/README.md index 96a93e1..90e313e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ their build commands, tests, required checks, deployment and rollback. ## Implemented on this branch - `.github/workflows/dependabot-auto-merge.yml`: callable dependency mediation. +- `.github/workflows/secret-scan.yml`: callable pinned-gitleaks scan of the pushed or + PR range (`full-history`, shadow via `fail-on-findings: false`); behavior tests in + `tests/test_secret_scan_workflow.py`. - `actions/setup-node-pnpm`, `actions/setup-ios-build`, `actions/asc-api-key`: composite actions; inspect their own inputs and prerequisites before use. - `scripts/repository_docs.py`: offline manifest projection and pinned shared-file diff --git a/tests/test_secret_scan_workflow.py b/tests/test_secret_scan_workflow.py new file mode 100644 index 0000000..b429880 --- /dev/null +++ b/tests/test_secret_scan_workflow.py @@ -0,0 +1,100 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = yaml.safe_load((ROOT / ".github/workflows/secret-scan.yml").read_text()) +STEPS = {step.get("name"): step for step in WORKFLOW["jobs"]["gitleaks"]["steps"]} +ZERO = "0" * 40 + + +def git(cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip() + + +class SecretScanWorkflow(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.repo = self.root / "repo" + self.repo.mkdir() + git(self.repo, "init", "-q") + git(self.repo, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "--allow-empty", "-m", "one") + self.base = git(self.repo, "rev-parse", "HEAD") + git(self.repo, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "--allow-empty", "-m", "two") + self.head = git(self.repo, "rev-parse", "HEAD") + + def run_step(self, name, env): + output = self.root / "output" + output.write_text("") + full_env = {**os.environ, **env, "GITHUB_OUTPUT": str(output)} + result = subprocess.run(["bash", "-c", STEPS[name]["run"]], cwd=self.repo, env=full_env, capture_output=True, text=True) + return result, output.read_text() + + def resolve(self, event, full="false", **shas): + env = {"FULL_HISTORY": full, "EVENT_NAME": event, "HEAD_SHA": self.head, "PUSH_BEFORE": "", "PR_BASE_SHA": "", "MERGE_GROUP_BASE_SHA": "", **shas} + result, output = self.run_step("Resolve scan range", env) + self.assertEqual(result.returncode, 0, result.stderr) + return output.strip() + + def test_push_scans_only_the_pushed_range(self): + self.assertEqual(self.resolve("push", PUSH_BEFORE=self.base), f"log-opts={self.base}..{self.head}") + + def test_pull_request_and_merge_group_scan_from_their_base(self): + self.assertEqual(self.resolve("pull_request", PR_BASE_SHA=self.base), f"log-opts={self.base}..{self.head}") + self.assertEqual(self.resolve("merge_group", MERGE_GROUP_BASE_SHA=self.base), f"log-opts={self.base}..{self.head}") + + def test_new_branch_unknown_base_and_schedule_scan_everything_reachable(self): + self.assertEqual(self.resolve("push", PUSH_BEFORE=ZERO), f"log-opts={self.head}") + self.assertEqual(self.resolve("push", PUSH_BEFORE="1" * 40), f"log-opts={self.head}") + self.assertEqual(self.resolve("schedule"), f"log-opts={self.head}") + + def test_full_history_overrides_the_range(self): + self.assertEqual(self.resolve("push", full="true", PUSH_BEFORE=self.base), "log-opts=--all") + + def fake_gitleaks(self, exit_code): + bin_dir = self.root / "bin" + bin_dir.mkdir(exist_ok=True) + fake = bin_dir / "gitleaks" + fake.write_text(f'#!/usr/bin/env bash\necho "$@" > "{self.root}/args"\nexit {exit_code}\n') + fake.chmod(0o755) + return {"PATH": f"{bin_dir}:{os.environ['PATH']}"} + + def scan(self, exit_code, fail="true"): + env = {**self.fake_gitleaks(exit_code), "LOG_OPTS": f"{self.base}..{self.head}", "FAIL_ON_FINDINGS": fail} + result, _ = self.run_step("Run gitleaks", env) + return result, (self.root / "args").read_text() + + def test_findings_fail_by_default_and_warn_in_shadow(self): + clean, args = self.scan(0) + self.assertEqual(clean.returncode, 0) + self.assertIn(f"--log-opts={self.base}..{self.head}", args) + self.assertIn("--redact=100", args) + self.assertNotIn("--config", args) + self.assertEqual(self.scan(1)[0].returncode, 1) + shadow, _ = self.scan(1, fail="false") + self.assertEqual(shadow.returncode, 0) + self.assertIn("::warning::", shadow.stdout) + + def test_consumer_config_is_used_when_present(self): + (self.repo / ".gitleaks.toml").write_text("[extend]\nuseDefault = true\n") + self.assertIn("--config=.gitleaks.toml", self.scan(0)[1]) + + def test_contract_is_least_privilege_and_pinned(self): + self.assertEqual(WORKFLOW["permissions"], {}) + self.assertEqual(WORKFLOW["jobs"]["gitleaks"]["permissions"], {"contents": "read"}) + inputs = WORKFLOW[True]["workflow_call"]["inputs"] + self.assertEqual((inputs["full-history"]["default"], inputs["fail-on-findings"]["default"]), (False, True)) + for step in WORKFLOW["jobs"]["gitleaks"]["steps"]: + if "uses" in step: + self.assertRegex(step["uses"], r"@[0-9a-f]{40}$") + self.assertRegex(STEPS["Install pinned gitleaks"]["env"]["GITLEAKS_SHA256"], r"^[0-9a-f]{64}$") + + +if __name__ == "__main__": + unittest.main() From c41c61f6b0d093127703b100f37cc063aa705c39 Mon Sep 17 00:00:00 2001 From: Tim White Date: Mon, 12 Oct 2026 14:12:18 -0700 Subject: [PATCH 2/2] fix(workflows): reject pull_request_target in the secret scan Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/secret-scan.yml | 6 +++++- tests/test_secret_scan_workflow.py | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 07c0e47..2c59e47 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -61,7 +61,11 @@ jobs: base="" case "$EVENT_NAME" in push) base="$PUSH_BEFORE" ;; - pull_request|pull_request_target) base="$PR_BASE_SHA" ;; + pull_request) base="$PR_BASE_SHA" ;; + pull_request_target) + # github.sha is the base tip here, so PR commits would never be scanned. + echo "::error::secret-scan does not support pull_request_target; call it from pull_request." + exit 1 ;; merge_group) base="$MERGE_GROUP_BASE_SHA" ;; esac if [ "$FULL_HISTORY" = "true" ]; then diff --git a/tests/test_secret_scan_workflow.py b/tests/test_secret_scan_workflow.py index b429880..22647e4 100644 --- a/tests/test_secret_scan_workflow.py +++ b/tests/test_secret_scan_workflow.py @@ -54,6 +54,13 @@ def test_new_branch_unknown_base_and_schedule_scan_everything_reachable(self): self.assertEqual(self.resolve("push", PUSH_BEFORE="1" * 40), f"log-opts={self.head}") self.assertEqual(self.resolve("schedule"), f"log-opts={self.head}") + def test_pull_request_target_is_rejected(self): + env = {"FULL_HISTORY": "false", "EVENT_NAME": "pull_request_target", "HEAD_SHA": self.head, "PUSH_BEFORE": "", "PR_BASE_SHA": self.base, "MERGE_GROUP_BASE_SHA": ""} + result, output = self.run_step("Resolve scan range", env) + self.assertEqual(result.returncode, 1) + self.assertIn("pull_request_target", result.stdout) + self.assertEqual(output, "") + def test_full_history_overrides_the_range(self): self.assertEqual(self.resolve("push", full="true", PUSH_BEFORE=self.base), "log-opts=--all")