From 9ead554f5cc229687d3a27934a023fb1dddaef15 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 23 Aug 2026 19:51:06 +0100 Subject: [PATCH] feat(security): secret-scan via gitleaks in CI + pre-push/pre-commit hooks (#142) --- .github/workflows/test.yml | 32 ++++++++++++++++ scripts/githooks/install.sh | 26 +++++++++++++ scripts/githooks/pre-commit | 20 ++++++++++ scripts/githooks/pre-push | 66 +++++++++++++++++++++++++++++++++ scripts/githooks/secret-scan.sh | 55 +++++++++++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100755 scripts/githooks/install.sh create mode 100755 scripts/githooks/pre-commit create mode 100755 scripts/githooks/pre-push create mode 100755 scripts/githooks/secret-scan.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5561c8..b5bff99 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,38 @@ permissions: contents: read jobs: + secret-scan: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute scan range (diff of new commits only) + id: range + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + RANGE="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" + else + BEFORE="${{ github.event.before }}" + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + RANGE="$(git rev-list --max-parents=0 HEAD | tail -1)..$GITHUB_SHA" + else + RANGE="$BEFORE..$GITHUB_SHA" + fi + fi + echo "RANGE=$RANGE" >> "$GITHUB_ENV" + echo "Scanning range: $RANGE" + + - name: Gitleaks (diff-only, fail on leak) + uses: docker://zricethezav/gitleaks:latest + env: + GITLEAKS_RANGE: ${{ env.RANGE }} + with: + args: detect --source "${{ github.workspace }}" --log-opts="$GITLEAKS_RANGE" --redact --verbose + test: name: Test (Go ${{ matrix.go }}) runs-on: ubuntu-latest diff --git a/scripts/githooks/install.sh b/scripts/githooks/install.sh new file mode 100755 index 0000000..0d15c21 --- /dev/null +++ b/scripts/githooks/install.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# install.sh — symlinks the shared githooks (pre-push, pre-commit) into .git/hooks +# for this repository. Safe to run repeatedly. +# +# Usage: +# ./scripts/githooks/install.sh + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +SRC="$ROOT/scripts/githooks" +HOOKS="$ROOT/.git/hooks" + +mkdir -p "$HOOKS" +chmod +x "$SRC"/secret-scan.sh "$SRC"/pre-push "$SRC"/pre-commit + +for h in pre-push pre-commit; do + if [[ -e "$HOOKS/$h" ]] && [[ ! -L "$HOOKS/$h" ]]; then + echo "error: $HOOKS/$h already exists and is not a symlink; remove it first" >&2 + exit 1 + fi + ln -sfn "$SRC/$h" "$HOOKS/$h" + echo "installed $h -> $SRC/$h" +done +echo "githooks installed for $(basename "$ROOT")" diff --git a/scripts/githooks/pre-commit b/scripts/githooks/pre-commit new file mode 100755 index 0000000..6d6258c --- /dev/null +++ b/scripts/githooks/pre-commit @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# pre-commit git hook — blocks a commit if staged changes contain a secret. +# Scans only the staged (index) diff with gitleaks (via secret-scan.sh). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +ROOT="$(git rev-parse --show-toplevel)" +if [[ "$SCRIPT_DIR" == "$ROOT/scripts/githooks" ]]; then + SCAN="$SCRIPT_DIR/secret-scan.sh" +else + SCAN="$ROOT/scripts/githooks/secret-scan.sh" +fi + +if ! "$SCAN" --staged; then + echo "pre-commit: LEAK FOUND in staged changes; commit BLOCKED. Remove the secret first." >&2 + exit 1 +fi +exit 0 diff --git a/scripts/githooks/pre-push b/scripts/githooks/pre-push new file mode 100755 index 0000000..9e6d2d4 --- /dev/null +++ b/scripts/githooks/pre-push @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# pre-push git hook — blocks a push if any NEW commit leaks a secret. +# +# Reads the refs being pushed from stdin (format: +# +# per ref). Scans only the new commits with gitleaks (via secret-scan.sh) and +# aborts (exit 1) if anything is found. +# +# Install: ln -s ../../scripts/githooks/pre-push .git/hooks/pre-push +# (or run scripts/githooks/install.sh) + +set -euo pipefail + +# Resolve symlinks so the hook works whether copied into .git/hooks or +# symlinked from scripts/githooks (and in worktrees sharing the main repo hooks). +SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +ROOT="$(git rev-parse --show-toplevel)" +if [[ "$SCRIPT_DIR" == "$ROOT/scripts/githooks" ]]; then + SCAN="$SCRIPT_DIR/secret-scan.sh" +else + SCAN="$ROOT/scripts/githooks/secret-scan.sh" +fi + +zero=0000000000000000000000000000000000000000 +failed=0 + +while read -r local_ref local_sha remote_ref remote_sha; do + [[ -n "$local_sha" ]] || continue + + # Deletion push — nothing to scan. + if [[ "$local_sha" == "$zero" ]]; then + continue + fi + + # New branch (no remote ref yet): scan only the commits this branch ADDS over + # its merge-base with the integration branch (PR target), NOT all history. + # This keeps pre-existing historical leaks (see #140) from blocking new work. + if [[ "$remote_sha" == "$zero" ]]; then + base="" + for base_ref in origin/release/v1 origin/release/v2 origin/master origin/main; do + if git rev-parse --verify "$base_ref" >/dev/null 2>&1; then + base="$(git merge-base "$base_ref" "$local_sha" 2>/dev/null)" + break + fi + done + if [[ -z "$base" ]] && git rev-parse --verify origin/HEAD >/dev/null 2>&1; then + base="$(git merge-base origin/HEAD "$local_sha" 2>/dev/null)" + fi + [[ -z "$base" ]] && base="$(git rev-list --max-parents=0 "$local_sha" 2>/dev/null | tail -1)" + range="${base}..${local_sha}" + else + range="${remote_sha}..${local_sha}" + fi + + echo "secret-scan: scanning new commits ${range} (ref ${local_ref})" + if ! "$SCAN" --range "$range"; then + echo "secret-scan: LEAK FOUND in ${local_ref}; push BLOCKED. Remove the secret before pushing." >&2 + failed=1 + fi +done + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi +exit 0 diff --git a/scripts/githooks/secret-scan.sh b/scripts/githooks/secret-scan.sh new file mode 100755 index 0000000..4531676 --- /dev/null +++ b/scripts/githooks/secret-scan.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# secret-scan.sh — shared secret scanner used by git hooks (pre-push, pre-commit) +# and by SE agents before any push. +# +# Scans ONLY the diff of new commits (or staged changes) with gitleaks, never the +# full history. Fails (exit non-zero) on any finding, so a leaking push is blocked. +# +# Uses the gitleaks Docker image (gitleaks/gitleaks) if docker is available, +# otherwise a locally installed `gitleaks` binary. No secret VALUES are ever +# printed: findings are emitted redacted. +# +# Usage: +# secret-scan.sh scan a git log range, e.g. origin/release/v1..HEAD +# secret-scan.sh --staged scan staged (index) changes +# secret-scan.sh --all scan full history (warning: not for normal use) +# +# Exit codes: 0 = clean, 1 = leaks found (caller should abort), 2 = scan failed. + +set -euo pipefail + +GITLEAKS_IMAGE="zricethezav/gitleaks:latest" + +run_gitleaks() { + # $@ = gitleaks args; runs in current dir (a git repo). + if command -v gitleaks >/dev/null 2>&1; then + gitleaks "$@" + elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker run --rm -v "$PWD:/repo" -w /repo "$GITLEAKS_IMAGE" "$@" + else + echo "error: secret-scan: neither 'gitleaks' binary nor docker image available" >&2 + exit 2 + fi +} + +mode="${1:---range}" +shift || true + +case "$mode" in + --range) + range="${1:?usage: secret-scan.sh }" + run_gitleaks detect --source "$PWD" --no-banner --redact --log-opts="$range" >&2 + ;; + --staged) + # Scan only staged (index) content: pipe `git diff --cached` through gitleaks --pipe. + git diff --cached --binary | run_gitleaks detect --pipe --no-banner --redact >&2 + ;; + --all) + run_gitleaks detect --source "$PWD" --no-banner --redact >&2 + ;; + *) + echo "error: secret-scan: unknown mode '$mode'" >&2 + exit 2 + ;; +esac -- 2.54.0