From 23639b2114e7dfdbda79828bb23de69494d88595 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:50:44 +0100 Subject: [PATCH 1/4] fix(gitleaks): correct the false "anchored value regexes are inert" claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estate baseline told every consumer that gitleaks matches allowlist regexes against a finding's MATCH rather than its SECRET, and concluded that an anchored `^value$` entry is silently INERT for `generic-api-key`. On that basis it steered the estate toward PATH entries. The claim is false, and it was steering repositories toward the blunter instrument. The two are not equivalent. A `regexes` entry suppresses exactly one string, so a credential planted in the same file is still caught. A `paths` entry blinds the whole file to everything. MEASURED 2026-09-04 on the CI-pinned gitleaks 8.18.4, config and JSON report both kept OUTSIDE the scanned tree: fixture Key : Ed25519_Private_Key; MATCH = `Key : Ed25519_Private_Key;` SECRET = `Ed25519_Private_Key` ^SECRET$, regexTarget unset 0 findings SUPPRESSED ^SECRET$, regexTarget = "secret" 0 findings SUPPRESSED ^SECRET$, regexTarget = "match" 1 finding not suppressed ^MATCH$, regexTarget = "match" 0 findings SUPPRESSED `regexTarget` DEFAULTS TO THE SECRET. The file was also self-refuting: all eight of its own entries are anchored on the value and set `regexTarget` nowhere, so were the claim true this baseline would have been allowlisting nothing at all. Field evidence agrees — one anchored value regex took metadatastician/cerro-torre from 19 findings to 12. Three things land: * the corrected explanation, with the measurement table, the instrument trap that most likely produced the false reading (config or report inside `--source` gets scanned, so a working entry reads as inert), and the caveat that specific rules outrank `generic-api-key` and extract a NARROWER secret — anchor on the JSON `Secret` field, not on the string as written; * a header note that this file is LIVE PRODUCTION CONFIG and, for every consumer without its own `.gitleaks.toml`, UNPINNED: the reusable's first staging step fetches it from this repository's default branch, so an edit here reaches those repositories on their next run; * `scripts/verify-regextarget-claim.sh` — the standing proof, written as a gate rather than a printer, so a gitleaks upgrade that changes these semantics fails loudly instead of silently invalidating the entries. Comment-only for scanning purposes, and asserted as such rather than assumed: old and new configs produce byte-identical finding sets on a fixture that exercises the allowlist classes (4 findings, identical) and on standards' own 2,565-file tree, and a planted credential is still detected under the new config. Co-Authored-By: Claude Opus 5 (1M context) --- config/gitleaks/estate-baseline.toml | 95 ++++++++++++++----- scripts/verify-regextarget-claim.sh | 136 +++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 26 deletions(-) create mode 100755 scripts/verify-regextarget-claim.sh diff --git a/config/gitleaks/estate-baseline.toml b/config/gitleaks/estate-baseline.toml index 1423f3e73..d1f67c003 100644 --- a/config/gitleaks/estate-baseline.toml +++ b/config/gitleaks/estate-baseline.toml @@ -18,6 +18,20 @@ # repository in the estate. If a suppression is only correct for one repo, it # belongs in that repo's own `.gitleaks.toml`, beside the code it describes. # +# THIS FILE IS LIVE PRODUCTION CONFIG, AND FOR MOST CONSUMERS IT IS UNPINNED. +# The reusable stages this baseline by TWO paths. A consumer that HAS its own +# `.gitleaks.toml` receives the copy fetched at `${{ job.workflow_sha }}` — +# pinned to whichever reusable SHA that consumer pins. A consumer with NO +# `.gitleaks.toml`, which is the large majority, is served by an earlier and +# ungated step that fetches from this repository's DEFAULT BRANCH. For those +# repositories an edit here takes effect on their very next run, with no pin +# standing between them and it. +# +# Two things follow. Edit this file as you would edit something already in +# production — a broadened pattern blinds 400+ repositories the same day. And +# a deliberate change arrives UNEVENLY: instantly for no-config consumers, and +# only at pin-bump speed for the rest. Stage behaviour changes accordingly. +# # WHY THIS FILE EXISTS # -------------------- # Until #500 the estate's gitleaks step carried `continue-on-error: true`, so @@ -57,32 +71,61 @@ useDefault = true [allowlist] description = "Estate baseline: dependency metadata, anchored placeholder shapes, published test vectors" -# ⚠ KNOWN LIMIT OF THE `regexes` SECTION BELOW — READ BEFORE ADDING ONE. -# -# gitleaks 8.18.4 matches these allowlist regexes against the finding's MATCH -# (the whole matched span, including surrounding context), NOT against the -# extracted secret. Setting `regexTarget` does not change this — measured with -# all three documented values, "secret", "match" and unset, on an EXACT-value -# entry: -# -# regexTarget unset / "secret" / "match", regex matching the SECRET -# -> NOT suppressed -# regexTarget "match", regex matching the MATCH -# -> suppressed -# -# CONSEQUENCE. An anchored `^value$` entry only works for rules whose match -# IS the secret — `aws-access-token` and the other prefix rules. For -# `generic-api-key`, whose match spans the surrounding assignment (the key -# name, the separator and the quotes) and not just the value, an anchored -# entry is silently INERT: it neither errors nor warns, it simply never fires. -# `generic-api-key` is the estate's single largest source of false positives -# (62 of the 140 measured on 2026-08-06), so this is exactly the rule the -# regexes are most wanted for. -# -# So: for a `generic-api-key` false positive, prefer a PATH entry in the -# owning repository's own `.gitleaks.toml`, where the exemption is local and -# its justification sits beside the code. Do not add an anchored value regex -# here and assume it took effect — verify it, the failure is silent. +# HOW THE `regexes` SECTION BELOW ACTUALLY BEHAVES — MEASURED. READ BEFORE EDITING. +# +# An earlier revision of this comment claimed that gitleaks matches these +# allowlist regexes against a finding's MATCH rather than its SECRET, and +# concluded that an anchored `^value$` entry is silently INERT for +# `generic-api-key`. THAT CLAIM IS FALSE, and every entry below depends on it +# being false — none of them sets `regexTarget`, and all eight are anchored on +# the value. Re-measured 2026-09-04 on the CI-pinned gitleaks 8.18.4, with the +# config and the JSON report both kept OUTSIDE the scanned tree: +# +# fixture Key : Ed25519_Private_Key; +# MATCH = `Key : Ed25519_Private_Key;` SECRET = `Ed25519_Private_Key` +# +# allowlist entry under test findings (control, no entry = 1) +# ^SECRET$, regexTarget unset 0 SUPPRESSED +# ^SECRET$, regexTarget = "secret" 0 SUPPRESSED +# ^SECRET$, regexTarget = "match" 1 not suppressed +# ^MATCH$, regexTarget = "match" 0 SUPPRESSED +# +# `regexTarget` DEFAULTS TO THE SECRET. An anchored value regex is the normal, +# working way to exempt a `generic-api-key` false positive and needs no +# `regexTarget` at all. Field evidence agrees: one anchored value regex took +# metadatastician/cerro-torre from 19 findings to 12. +# +# ANCHOR ON THE SECRET AS THE TOOL EXTRACTS IT, NOT ON THE STRING YOU SEE. A +# specific rule outranks `generic-api-key` and may extract a NARROWER secret +# than the quoted value. Measured on the same fixture: `tok = "AKIA…EXAMPLE..."` +# is reported by `aws-access-token` with the trailing `...` NOT part of the +# secret, so the placeholder regex below — which requires it — does not fire; +# `sk_test_…` is reported by `stripe-access-token`, not by the `_test_` rule. +# Read the finding's `Secret` field (`--report-format json`) and anchor on +# that. Guessing from the source line is how a correct-looking entry misses. +# +# WHY THE FALSE CLAIM WAS BELIEVABLE — THE INSTRUMENT TRAP. If the gitleaks +# config or the JSON report lives INSIDE `--source`, gitleaks scans them too; +# their own credential-shaped content trips `generic-api-key`, the count never +# reaches zero, and a WORKING allowlist entry reads as inert. That is the most +# likely way the original measurement went wrong — it is easy to hit, silent, +# and it produces exactly the observation the old comment recorded. Reproduce +# correctly with `scripts/verify-regextarget-claim.sh`, which aborts unless the +# control shows exactly one finding. +# +# CONSEQUENCE FOR EDITORS — PREFER A VALUE ENTRY OVER A PATH ENTRY. A `regexes` +# entry suppresses exactly one string, so a credential planted in the same file +# is STILL caught. A `paths` entry blinds the whole file to everything. Reach +# for a path entry only where the value's SHAPE genuinely is a credential's — +# 64 lowercase hex, say, which is byte-for-byte a hex-encoded Ed25519 private +# key, so exempting the class would blind every such key in the tree. (Scoping +# a regex to a path — `[[allowlists]]` with `condition = "AND"` — would answer +# that case, but is SILENTLY IGNORED at 8.18.4; it arrives at 8.19+.) +# +# The character classes below are deliberately narrow, and that is not an +# oversight to be widened. `[A-Za-z]*` has no `_`, so the CamelCase crypto-type +# entry does NOT match Ada snake_case names such as `Ed25519_Private_Key`; +# those belong in the owning repository's own `.gitleaks.toml`, beside the code. paths = [ # This file, at its canonical path and under the name it is staged as in a diff --git a/scripts/verify-regextarget-claim.sh b/scripts/verify-regextarget-claim.sh new file mode 100755 index 000000000..f887cfc8a --- /dev/null +++ b/scripts/verify-regextarget-claim.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +set -euo pipefail + +# verify-regextarget-claim.sh — does an anchored VALUE regex suppress a +# `generic-api-key` finding? Measure it; do not reason about it. +# +# ── Why this exists ──────────────────────────────────────────────────────── +# `config/gitleaks/estate-baseline.toml` once told the estate that anchored +# value regexes are "silently INERT" for `generic-api-key`, and on that basis +# steered every repository toward PATH entries instead. The two are not +# equivalent: a `regexes` entry suppresses exactly ONE string, so a credential +# planted in the same file is still caught, whereas a `paths` entry blinds the +# whole file to everything. So the claim decided how surgical every repo's +# allowlist was allowed to be — and it was false. This script is the standing +# proof, and a gate: if a future gitleaks changes these semantics, it fails. +# +# ── The instrument trap this script is built to avoid ────────────────────── +# If the gitleaks CONFIG or the JSON REPORT lives inside `--source`, gitleaks +# scans them too. Their own credential-shaped content trips `generic-api-key`, +# the finding count never reaches zero, and a WORKING allowlist entry reads as +# inert. That is the most likely origin of the original false claim. Here both +# live outside the scanned tree, and the control asserts exactly one finding — +# so a contaminated instrument aborts instead of reporting a wrong answer. +# +# Usage: scripts/verify-regextarget-claim.sh +# GITLEAKS=/path/to/gitleaks scripts/verify-regextarget-claim.sh +# GITLEAKS_ALLOW_VERSION_DRIFT=1 ... (measure on a non-pinned build) + +PINNED_VERSION=8.18.4 +GL="${GITLEAKS:-$(command -v gitleaks || true)}" + +fail() { echo "ABORT: $*" >&2; exit 2; } + +[ -n "$GL" ] && [ -x "$GL" ] \ + || fail "gitleaks not found. Set GITLEAKS=/path/to/gitleaks (CI pins $PINNED_VERSION)." +have_version="$("$GL" version 2>&1 | head -1)" +if [ "$have_version" != "$PINNED_VERSION" ]; then + if [ "${GITLEAKS_ALLOW_VERSION_DRIFT:-0}" != "1" ]; then + fail "gitleaks is $have_version, CI pins $PINNED_VERSION. These results are version-specific; set GITLEAKS_ALLOW_VERSION_DRIFT=1 to measure anyway." + fi + echo "WARNING: measuring on $have_version, not the CI-pinned $PINNED_VERSION." >&2 +fi + +WORK="$(mktemp -d)"; OUT="$(mktemp -d)" +trap 'rm -rf "$WORK" "$OUT"' EXIT + +# An Ada declaration is the canonical estate false positive: `generic-api-key` +# cannot tell `Key : Ed25519_Private_Key;` from `api_key: `, and the +# MATCH (whole declaration) differs from the SECRET (the type name) — which is +# precisely the case the old claim was about. +cat > "$WORK/sample.ads" <<'ADA' +package Crypto is + Key : Ed25519_Private_Key; +end Crypto; +ADA + +CFG="$OUT/cfg.toml" +mk() { + { + echo '[extend]' + echo 'useDefault = true' + echo + echo '[allowlist]' + echo 'description = "verify-regextarget-claim fixture"' + printf '%s\n' "$1" + } > "$CFG" +} +count() { + if ! "$GL" detect --source "$WORK" --no-git --no-banner --config "$CFG" \ + --report-format json --report-path "$OUT/o.json" --exit-code 0 >/dev/null 2>&1; then + fail "gitleaks failed to run (config load error?)" + fi + jq 'length' "$OUT/o.json" +} + +mk '' +base="$(count)" +[ "$base" = "1" ] \ + || fail "control expected exactly 1 finding, got $base — the instrument is contaminated or the fixture no longer measures generic-api-key." +rule="$(jq -r '.[0].RuleID' "$OUT/o.json")" +[ "$rule" = "generic-api-key" ] || fail "control fired '$rule', not generic-api-key" +echo "gitleaks $have_version" +echo "control: rule=$rule" +echo " MATCH = [$(jq -r '.[0].Match' "$OUT/o.json")]" +echo " SECRET = [$(jq -r '.[0].Secret' "$OUT/o.json")]" +echo " (match != secret — this is the case the false claim was about)" +echo + +status=0 +expect() { # expect