diff --git a/.github/scripts/gitleaks-changes.sh b/.github/scripts/gitleaks-changes.sh new file mode 100755 index 0000000..fb3a619 --- /dev/null +++ b/.github/scripts/gitleaks-changes.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Scan only the changes being introduced for secrets, not content that was already +# scanned elsewhere. Run by the gitleaks hook in .pre-commit-config.yaml. +# +# - GITLEAKS_BASE set (CI): the commits in GITLEAKS_BASE..HEAD, plus the lines that +# merge commits in that range introduced themselves (e.g. conflict resolutions), +# not what they brought in from the merged branch. Set but empty: an error, so a +# run without a base cannot pass without scanning anything. +# - Merge commit in progress: what differs from the branch being merged in (this +# branch's changes and the conflict resolutions), not everything merged in. +# - Otherwise: the staged changes (the upstream hook's behavior). +set -euo pipefail + +# Lines of a combined diff (git show --cc) that are in none of the merge's parents, +# with the file headers. With N parents those lines start with N "+". +merge_introduced_lines() { + local merge=$1 parents + parents=$(($(git rev-list --parents -n 1 "$merge" | wc -w) - 1)) + git show --cc --format= "$merge" | awk -v n="$parents" ' + BEGIN { plus = sprintf("%" n "s", ""); gsub(/ /, "+", plus) } + /^diff --cc / { print; next } + /^\+\+\+ (b\/|\/dev\/null)/ { next } + substr($0, 1, n) == plus { print } + ' +} + +if [[ -n "${GITLEAKS_BASE+set}" ]]; then + if [[ -z "${GITLEAKS_BASE}" ]]; then + echo "gitleaks: GITLEAKS_BASE is set but empty, refusing to pass without scanning" >&2 + exit 1 + elif git rev-parse --quiet --verify "${GITLEAKS_BASE}^{commit}" >/dev/null; then + range=("${GITLEAKS_BASE}..HEAD") + else + # e.g. first push of a branch (all zeros) or a base that was force-pushed away + echo "gitleaks: base ${GITLEAKS_BASE} not found, scanning the last commit only" >&2 + range=(-1 HEAD) + fi + + status=0 + echo "gitleaks: scanning commits ${range[*]}" + gitleaks git --redact --verbose --log-opts="${range[*]}" || status=1 + + # git log -p (used by gitleaks git) shows no diff for merge commits + for merge in $(git rev-list --merges "${range[@]}"); do + echo "gitleaks: scanning the lines merge commit ${merge} introduced" + merge_introduced_lines "$merge" | gitleaks stdin --redact --verbose || status=1 + done + exit "$status" +fi + +if git rev-parse --quiet --verify MERGE_HEAD >/dev/null; then + # Added lines only (with the file headers), removed lines are not introduced + git diff --cached --unified=0 MERGE_HEAD | { grep -E '^(diff --git |\+)' || true; } | gitleaks stdin --redact --verbose + exit +fi + +exec gitleaks git --pre-commit --redact --staged --verbose diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..1861d25 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,44 @@ +name: Gitleaks + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + name: gitleaks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # gitleaks scans the commits since the base, which needs the history + fetch-depth: 0 + + - name: Install gitleaks + env: + GITLEAKS_VERSION: 8.30.1 + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + run: | + archive="$RUNNER_TEMP/gitleaks.tar.gz" + curl -sSfL -o "$archive" "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_SHA256} ${archive}" | sha256sum --check --strict - + mkdir "$RUNNER_TEMP/gitleaks-bin" + tar -xzf "$archive" -C "$RUNNER_TEMP/gitleaks-bin" gitleaks + echo "$RUNNER_TEMP/gitleaks-bin" >> "$GITHUB_PATH" + + # The commits of the PR (or push), plus the lines merge commits introduced + # themselves. A manual run has no base and scans the whole history. + - name: Scan the commits of the PR (or push) for secrets + env: + GITLEAKS_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: .github/scripts/gitleaks-changes.sh