diff --git a/.gitleaks.toml b/.gitleaks.toml index b8e323c10..b25e3aa12 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -16,3 +16,44 @@ [extend] path = "config/gitleaks/estate-baseline.toml" + +# REPO-LOCAL SUPPRESSION — scripts/verify-regextarget-claim.sh +# +# That script is the standing gate for the claim this repo's estate baseline +# documents. Its fixture is the canonical estate false positive, the Ada +# declaration `Key : Ed25519_Private_Key;`, where the MATCH is the whole +# declaration and the SECRET is the type name. `generic-api-key` cannot tell +# that from a real key and flags it at lines 50, 55 and 115 — so the gate's own +# fixture is a true instance of the false positive it exists to demonstrate. +# Nothing here is a credential. +# +# WHY A VALUE ENTRY AND NOT A `paths` ENTRY. estate-baseline.toml prescribes the +# value entry in terms — "PREFER A VALUE ENTRY OVER A PATH ENTRY… a `paths` +# entry blinds the whole file to everything" — and reserves path entries for +# values whose SHAPE genuinely is a credential's. Its separate instruction that +# Ada snake_case names "belong in the owning repository's own .gitleaks.toml, +# beside the code" is about WHERE the entry lives, not WHICH instrument it uses. +# +# MEASURED 2026-09-04, CI-pinned gitleaks 8.18.4, planting a real `ghp_` token +# into that script and rescanning: +# +# paths = ['''^scripts/verify-regextarget-claim\.sh$'''] -> 0 findings, MISSED +# regexes = ['''^Ed25519_Private_Key$'''] -> 1 finding, CAUGHT +# +# Both reach 0 on the clean tree, so the naive check cannot tell them apart. +# The path entry would make this the one file in the repository where a real +# credential could be committed unnoticed, and it is a file about credentials. +# +# Also verified: a top-level [allowlist] here MERGES with the extended +# baseline's rather than replacing it — the baseline's CamelCase crypto-type +# coverage stays live alongside this entry. +# +# NOT in the estate baseline, deliberately. The baseline's crypto-type entry is +# `[A-Za-z]*` with no `_` and its comment says that narrowness "is not an +# oversight to be widened"; widening it would suppress this shape across 400+ +# repositories, including findings pending owner review in cerro-torre. +[allowlist] +description = "standards-local: Ada crypto type name in the gitleaks regexTarget verification gate" +regexes = [ + '''^Ed25519_Private_Key$''', +] 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