From 7020e910f79bdcc8a071e66fb801a4bef15276ee Mon Sep 17 00:00:00 2001 From: JorisJonkers Agent Date: Fri, 10 Jul 2026 06:55:25 +0000 Subject: [PATCH 1/2] fix(leak-scan): install gitleaks+trufflehog and fail-closed all-refs mode Add SHA-256-pinned install steps for gitleaks v8.30.1 and trufflehog v3.95.9 to actions/leak-scan/action.yml. Each step is idempotent (skips download when the tool is already on PATH) and verifies the release tarball checksum before installation. Harden run.sh: in all-refs mode, gitleaks being absent after the install step now causes an immediate job failure (E_GITLEAKS_MISSING) instead of the previous warn-and-continue behaviour. pr-diff mode retains its graceful warning so callers that rely on deny-list-only fallback are unaffected. Add T-D7 test group (11 tests) covering install step presence, version and SHA pinning, idempotency, checksum verification, and the fail-closed all-refs contract. --- actions/leak-scan/action.yml | 42 +++++++ actions/leak-scan/run.sh | 9 +- tests/test_deploy_platform_d.py | 191 ++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) diff --git a/actions/leak-scan/action.yml b/actions/leak-scan/action.yml index 026830d..3f28096 100644 --- a/actions/leak-scan/action.yml +++ b/actions/leak-scan/action.yml @@ -35,6 +35,48 @@ inputs: runs: using: composite steps: + # Install gitleaks and trufflehog if not already present. + # Pinned to exact release versions with SHA-256 checksums verified before + # the binary is moved into place. The install is idempotent: when the + # tool is already on PATH the download is skipped entirely. + - name: Install gitleaks + shell: bash + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + run: | + if command -v gitleaks >/dev/null 2>&1; then + echo "gitleaks already installed ($(gitleaks version 2>/dev/null || true)), skipping download" + exit 0 + fi + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -sSfL "$url" -o "${tmp}/gitleaks.tar.gz" + echo "${GITLEAKS_SHA256} ${tmp}/gitleaks.tar.gz" | sha256sum --check --strict - + tar -xzf "${tmp}/gitleaks.tar.gz" -C "${tmp}" gitleaks + sudo install -m 0755 "${tmp}/gitleaks" /usr/local/bin/gitleaks + echo "gitleaks $(gitleaks version) installed" + + - name: Install trufflehog + shell: bash + env: + TRUFFLEHOG_VERSION: "3.95.9" + TRUFFLEHOG_SHA256: "f6d1106b85107d79527ed7a5b98b592beadd8b770dc3c9e8c1ad99e1b2cf127e" + run: | + if command -v trufflehog >/dev/null 2>&1; then + echo "trufflehog already installed ($(trufflehog --version 2>/dev/null || true)), skipping download" + exit 0 + fi + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + url="https://github.com/trufflesecurity/trufflehog/releases/download/v${TRUFFLEHOG_VERSION}/trufflehog_${TRUFFLEHOG_VERSION}_linux_amd64.tar.gz" + curl -sSfL "$url" -o "${tmp}/trufflehog.tar.gz" + echo "${TRUFFLEHOG_SHA256} ${tmp}/trufflehog.tar.gz" | sha256sum --check --strict - + tar -xzf "${tmp}/trufflehog.tar.gz" -C "${tmp}" trufflehog + sudo install -m 0755 "${tmp}/trufflehog" /usr/local/bin/trufflehog + echo "trufflehog $(trufflehog --version 2>/dev/null || echo installed)" + - name: Run leak scan shell: bash env: diff --git a/actions/leak-scan/run.sh b/actions/leak-scan/run.sh index a853684..2ab585f 100755 --- a/actions/leak-scan/run.sh +++ b/actions/leak-scan/run.sh @@ -125,6 +125,10 @@ run_all_refs_scan() { local gitleaks_exit=0 local trufflehog_exit=0 + # Fail-closed: all-refs is a pre-flip prerequisite; gitleaks must be + # available. The action.yml install step runs first; if it succeeded, + # gitleaks will be on PATH. If it is still absent (install failed or the + # action was bypassed), the job must fail loudly — not warn-and-degrade. if command -v gitleaks >/dev/null 2>&1; then gitleaks detect \ --source=. \ @@ -133,7 +137,10 @@ run_all_refs_scan() { --report-path=gitleaks-report.json \ 2>&1 || gitleaks_exit=$? else - printf '::warning::gitleaks not found; skipping gitleaks all-refs scan\n' + emit_gate_summary "leak-scan" "Leak Scan" "fail" \ + "E_GITLEAKS_MISSING: gitleaks not found after install step; all-refs scan cannot proceed" \ + "none" --redacted + fail "E_GITLEAKS_MISSING: gitleaks not found; all-refs scan requires gitleaks to be installed" fi if command -v trufflehog >/dev/null 2>&1; then diff --git a/tests/test_deploy_platform_d.py b/tests/test_deploy_platform_d.py index 871738c..348760f 100644 --- a/tests/test_deploy_platform_d.py +++ b/tests/test_deploy_platform_d.py @@ -591,3 +591,194 @@ def test_five_fragments_rendered(self) -> None: if __name__ == "__main__": unittest.main() + + +# --------------------------------------------------------------------------- +# T-D7: Leak-scan tool installation and fail-closed hardening +# --------------------------------------------------------------------------- + + +class LeakScanInstallAndFailClosedTest(unittest.TestCase): + """ + T-D7: action.yml install steps and run.sh fail-closed hardening for all-refs mode. + + Sub-tests: + T-D7a: action.yml declares install steps for gitleaks and trufflehog before run step. + T-D7b: action.yml install steps pin exact version and SHA256 via env vars. + T-D7c: action.yml install steps are idempotent (skip when tool is already on PATH). + T-D7d: action.yml install steps verify checksum before installing. + T-D7e: run.sh fails loudly (E_GITLEAKS_MISSING) in all-refs mode when gitleaks absent. + T-D7f: run.sh pr-diff mode keeps graceful warning when gitleaks absent (not fail-closed). + """ + + @classmethod + def setUpClass(cls) -> None: + cls.action = load_yaml(LEAK_SCAN_ACTION) + cls.run_sh_text = LEAK_SCAN_RUN.read_text(encoding="utf-8") + cls.gw_root = ROOT + cls.script = LEAK_SCAN_RUN + + def _run(self, env_overrides: dict[str, str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + base = { + "GW_ROOT": str(self.gw_root), + "PATHS": ".", + "BASE_REF": "", + "HEAD_REF": "", + "DENY_LIST": "default", + } + base.update(env_overrides) + return run_script(self.script, env=base, cwd=cwd) + + def _get_install_steps(self) -> list[dict]: + steps = self.action["runs"]["steps"] + return [s for s in steps if s.get("name", "").startswith("Install ")] + + # T-D7a: install steps exist before the run step + def test_install_steps_present_before_run_step(self) -> None: + steps = self.action["runs"]["steps"] + step_names = [s.get("name", "") for s in steps] + run_idx = next( + (i for i, n in enumerate(step_names) if n == "Run leak scan"), + None, + ) + self.assertIsNotNone(run_idx, "action.yml must have a 'Run leak scan' step") + install_before_run = [ + n for n in step_names[:run_idx] + if n.startswith("Install ") + ] + self.assertGreaterEqual( + len(install_before_run), + 2, + f"action.yml must install at least 2 tools (gitleaks, trufflehog) before 'Run leak scan'; found: {install_before_run}", + ) + self.assertTrue( + any("gitleaks" in n.lower() for n in install_before_run), + "action.yml must have an install step for gitleaks before 'Run leak scan'", + ) + self.assertTrue( + any("trufflehog" in n.lower() for n in install_before_run), + "action.yml must have an install step for trufflehog before 'Run leak scan'", + ) + + # T-D7b: install steps pin version and SHA256 + def test_gitleaks_install_step_pins_version_and_checksum(self) -> None: + steps = self._get_install_steps() + gitleaks_step = next((s for s in steps if "gitleaks" in s.get("name", "").lower()), None) + self.assertIsNotNone(gitleaks_step, "action.yml must have a gitleaks install step") + env = gitleaks_step.get("env", {}) + self.assertIn("GITLEAKS_VERSION", env, "gitleaks install step must pin GITLEAKS_VERSION") + self.assertIn("GITLEAKS_SHA256", env, "gitleaks install step must pin GITLEAKS_SHA256") + sha = env["GITLEAKS_SHA256"] + self.assertRegex( + sha, + r"^[0-9a-f]{64}$", + f"GITLEAKS_SHA256 must be a 64-char hex string; got: {sha!r}", + ) + + def test_trufflehog_install_step_pins_version_and_checksum(self) -> None: + steps = self._get_install_steps() + th_step = next((s for s in steps if "trufflehog" in s.get("name", "").lower()), None) + self.assertIsNotNone(th_step, "action.yml must have a trufflehog install step") + env = th_step.get("env", {}) + self.assertIn("TRUFFLEHOG_VERSION", env, "trufflehog install step must pin TRUFFLEHOG_VERSION") + self.assertIn("TRUFFLEHOG_SHA256", env, "trufflehog install step must pin TRUFFLEHOG_SHA256") + sha = env["TRUFFLEHOG_SHA256"] + self.assertRegex( + sha, + r"^[0-9a-f]{64}$", + f"TRUFFLEHOG_SHA256 must be a 64-char hex string; got: {sha!r}", + ) + + # T-D7c: install steps are idempotent (skip if already on PATH) + def test_gitleaks_install_step_is_idempotent(self) -> None: + steps = self._get_install_steps() + gitleaks_step = next((s for s in steps if "gitleaks" in s.get("name", "").lower()), None) + self.assertIsNotNone(gitleaks_step) + run_block = gitleaks_step.get("run", "") + self.assertIn( + "command -v gitleaks", + run_block, + "gitleaks install step must check if gitleaks is already on PATH before downloading", + ) + + def test_trufflehog_install_step_is_idempotent(self) -> None: + steps = self._get_install_steps() + th_step = next((s for s in steps if "trufflehog" in s.get("name", "").lower()), None) + self.assertIsNotNone(th_step) + run_block = th_step.get("run", "") + self.assertIn( + "command -v trufflehog", + run_block, + "trufflehog install step must check if trufflehog is already on PATH before downloading", + ) + + # T-D7d: install steps verify checksum + def test_gitleaks_install_step_verifies_checksum(self) -> None: + steps = self._get_install_steps() + gitleaks_step = next((s for s in steps if "gitleaks" in s.get("name", "").lower()), None) + self.assertIsNotNone(gitleaks_step) + run_block = gitleaks_step.get("run", "") + self.assertIn( + "sha256sum", + run_block, + "gitleaks install step must verify the SHA256 checksum before installing", + ) + + def test_trufflehog_install_step_verifies_checksum(self) -> None: + steps = self._get_install_steps() + th_step = next((s for s in steps if "trufflehog" in s.get("name", "").lower()), None) + self.assertIsNotNone(th_step) + run_block = th_step.get("run", "") + self.assertIn( + "sha256sum", + run_block, + "trufflehog install step must verify the SHA256 checksum before installing", + ) + + # T-D7e: run.sh all-refs mode fails loudly when gitleaks is absent + def test_all_refs_fails_loudly_when_gitleaks_absent(self) -> None: + """ + run.sh must exit non-zero with E_GITLEAKS_MISSING in all-refs mode when + gitleaks is not on PATH. This is the fail-closed contract: a missing + tool in all-refs mode must never silently degrade. + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + # Use a PATH that contains only /usr/bin and /bin so gitleaks is absent + # but standard tools (bash, git, jq, grep, etc.) still work. + fake_path = "/usr/bin:/bin" + result = self._run( + {"MODE": "all-refs", "PATH": fake_path}, + cwd=tmppath, + ) + self.assertNotEqual( + result.returncode, + 0, + "run.sh must exit non-zero in all-refs mode when gitleaks is not on PATH", + ) + combined = result.stdout + result.stderr + self.assertIn( + "E_GITLEAKS_MISSING", + combined, + "run.sh must emit E_GITLEAKS_MISSING when gitleaks is absent in all-refs mode", + ) + + def test_all_refs_fail_closed_is_in_script(self) -> None: + """Static drift guard: E_GITLEAKS_MISSING must be present in run.sh.""" + self.assertIn( + "E_GITLEAKS_MISSING", + self.run_sh_text, + "run.sh must contain E_GITLEAKS_MISSING error for all-refs fail-closed behavior", + ) + + # T-D7f: run.sh pr-diff mode keeps graceful warning when gitleaks absent + def test_pr_diff_keeps_graceful_warning_when_gitleaks_absent(self) -> None: + """ + pr-diff mode documented contract: gitleaks missing emits a warning + and falls back to deny-list scan only (graceful degradation). + """ + self.assertIn( + "gitleaks not found; skipping gitleaks pr-diff scan", + self.run_sh_text, + "run.sh pr-diff mode must keep the graceful gitleaks-missing warning (not fail-closed)", + ) From 66875006f3e78f1d56efac33b638dbd31854c039 Mon Sep 17 00:00:00 2001 From: JorisJonkers Agent Date: Fri, 10 Jul 2026 06:58:35 +0000 Subject: [PATCH 2/2] fix(leak-scan): add continue-on-error to install steps; fix __main__ guard position Add continue-on-error: true to both install steps so that a failed download or checksum mismatch does not abort the composite action before run.sh executes. run.sh enforces the fail-closed contract for all-refs (E_GITLEAKS_MISSING) and the graceful warning for pr-diff, so the correct behaviour is preserved regardless of install outcome. Move the if __name__ == "__main__" guard to the physical end of test_deploy_platform_d.py so that the T-D7 test class is not skipped when the file is executed directly. --- actions/leak-scan/action.yml | 2 ++ tests/test_deploy_platform_d.py | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/actions/leak-scan/action.yml b/actions/leak-scan/action.yml index 3f28096..64b5ce1 100644 --- a/actions/leak-scan/action.yml +++ b/actions/leak-scan/action.yml @@ -40,6 +40,7 @@ runs: # the binary is moved into place. The install is idempotent: when the # tool is already on PATH the download is skipped entirely. - name: Install gitleaks + continue-on-error: true shell: bash env: GITLEAKS_VERSION: "8.30.1" @@ -59,6 +60,7 @@ runs: echo "gitleaks $(gitleaks version) installed" - name: Install trufflehog + continue-on-error: true shell: bash env: TRUFFLEHOG_VERSION: "3.95.9" diff --git a/tests/test_deploy_platform_d.py b/tests/test_deploy_platform_d.py index 348760f..1d73227 100644 --- a/tests/test_deploy_platform_d.py +++ b/tests/test_deploy_platform_d.py @@ -589,9 +589,6 @@ def test_five_fragments_rendered(self) -> None: self.assertIn(fragment, self.run_sh, msg=f"Fragment '{fragment}' not rendered in deploy-preview/run.sh") -if __name__ == "__main__": - unittest.main() - # --------------------------------------------------------------------------- # T-D7: Leak-scan tool installation and fail-closed hardening @@ -782,3 +779,7 @@ def test_pr_diff_keeps_graceful_warning_when_gitleaks_absent(self) -> None: self.run_sh_text, "run.sh pr-diff mode must keep the graceful gitleaks-missing warning (not fail-closed)", ) + + +if __name__ == "__main__": + unittest.main()