Skip to content

A Mechanical Security Net Designed for Silence

Adityo Guni Waluyo

A secret-scanning hook that separates findings from errors, non-blocking CI scanners, and a weekly ZAP baseline that skips gracefully.

TL;DR

gitleaks uses exit code 1 for both real leaks and scan errors, so naive hooks block commits over environment failures and get disabled. The fix: block only when exit 1 comes with a valid JSON report containing findings, warn otherwise. CI and weekly ZAP scans collect findings as reports instead of failing builds.

A commit command runs, and the screen shows an error message from the secret scanner. Not because an API key leaked: the tool failed to read a temporary config or its binary is missing from the local environment. If the hook is configured to block on every nonzero exit code, developers quickly learn to switch that guard off to keep working. The fix is not removing the scan; it is building a safety net that reacts only to real evidence of a leak and stays calm in every other case.

The root of the problem sits in how tools like gitleaks report results. Its documentation defines exit code 0 as no leaks present, 126 as an unknown flag, while exit code 1 covers both leaks encountered and errors encountered [4]. That 1 is ambiguous: a detected leak or a scan failure produce the same result. Treating every 1 as a leak is a design that fails twice: once by blocking a commit on an environment error, once again as users pick up the habit of disabling the hook.

Separated Exit Semantics

The hook in this repository closes that ambiguity with one rule: block only on evidence, warn on everything else. The scan runs over staged content through gitleaks protect, then the JSON report gets validated. Blocking with exit 2 happens only when the scanner exits 1 and the report is a valid JSON containing findings. Every other path (input parse failure, empty command, missing git root, binary absent from PATH, exit 1 without a valid report, or any other exit code) prints a warning to stderr and allows the commit.

REPORT_FILE=$(mktemp)
gitleaks protect --staged --redact --no-banner \
  --report-format json --report-path "$REPORT_FILE" >/dev/null 2>&1
rc=$?
n="$(jq 'if type == "array" then length else -1 end' "$REPORT_FILE" 2>/dev/null)"
if [ "$rc" -eq 1 ] && [ -n "$n" ] && [ "$n" -gt 0 ] 2>/dev/null; then
  echo "BLOCKED (gitleaks): $n potential secret(s) in staged content" >&2
  exit 2
fi
if [ "$rc" -eq 1 ]; then
  echo "WARN (gitleaks): exit 1 tanpa laporan valid (scan error, bukan temuan)" >&2
fi
exit 0

The jq validation makes sure the report is machine-readable before it counts as evidence, and mktemp keeps the report at an unpredictable path. Reviewed exceptions live in a single .gitleaksignore file at the repo root, so findings already declared safe do not flood reports over and over. The residual gap is documented openly in the hook header: shell aliases and newline-split commands go undetected; the backstop is manual scanning and CI.

CI That Collects Instead of Stopping

The same philosophy continues in the pipeline. A security scanning job should not automatically redden the build, especially while adoption is early. The configuration: run on version tags only, continue-on-error enabled, then three scanners in sequence: Semgrep for code pattern defects, Trivy for dependency vulnerabilities, and gitleaks for full commit history. The exit-code input on trivy-action defaults to 0, meaning findings do not fail the job unless explicitly set [6].

Scan results land as an artifact plus a step summary, not as a red build. Disposition runs through a centralized QA findings file: each finding is read, classified, fixed or excluded with a reason. The evidence chain stays intact without blocking anyone, and releases are not snarled by findings that were waiting to be reviewed anyway.

A Weekly ZAP That Knows It Can Fail

Dynamic scanning faces a different problem: the target can be down. The weekly ZAP baseline schedule (Monday 02:00 UTC or 09:00 WIB) starts from a simple reachability check against the staging environment. When the target does not answer within the timeout, the workflow records a clean skip and exits successfully, not red over routine maintenance. One more scheduling reality: scheduled events can be delayed during periods of high GitHub Actions load, and the default timezone is UTC [5].

Inside the ZAP baseline action itself, the container exits with a failure code as soon as any alert appears. The behavior hinge is the action's fail_action option: leaving it unset keeps the GitHub Scan status green even with alerts present [7]. Findings remain complete in the report and artifacts for review. A stable baseline carrying a few known WARNs therefore does not scream every Monday morning; its voice comes out when a genuinely new finding appears.

A trustworthy net works in both directions: it never swallows a real leak, and it never cries wolf over a transient error. The verification is immediate. A commit with staged secrets is held at exit 2 complete with file, line, and rule; ordinary commits pass unnoticed; the weekly CI produces a report people actually open.

Sources

[4] gitleaks README, Exit Codes, accessed 13 October 2026.
[5] GitHub Docs, Events that trigger workflows, accessed 13 October 2026.
[6] aquasecurity/trivy-action README, accessed 13 October 2026.
[7] zaproxy/action-baseline README, accessed 13 October 2026.

Related articles