Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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$''',
]
95 changes: 69 additions & 26 deletions config/gitleaks/estate-baseline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions scripts/verify-regextarget-claim.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/bin/bash
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
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: <base64>`, 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 <label> <wanted-count>
local label="$1" want="$2" got
got="$(count)"
if [ "$got" = "$want" ]; then
printf ' %-38s %s ok\n' "$label" "$got"
else
printf ' %-38s %s FAIL (expected %s)\n' "$label" "$got" "$want"
status=1
fi
}

printf ' %-38s %s\n' 'ALLOWLIST ENTRY' 'FINDINGS'
mk ''
expect '(none - control)' 1
mk "regexes = ['''^Ed25519_Private_Key\$''']"
expect '^SECRET$, regexTarget unset' 0
mk "regexTarget = \"secret\"
regexes = ['''^Ed25519_Private_Key\$''']"
expect '^SECRET$, regexTarget = "secret"' 0
mk "regexTarget = \"match\"
regexes = ['''^Ed25519_Private_Key\$''']"
expect '^SECRET$, regexTarget = "match"' 1
mk "regexTarget = \"match\"
regexes = ['''^\\s*Key : Ed25519_Private_Key;\$''']"
expect '^MATCH$, regexTarget = "match"' 0

echo
if [ "$status" = 0 ]; then
cat <<'VERDICT'
VERDICT: regexTarget DEFAULTS TO THE SECRET. An anchored value regex DOES
suppress generic-api-key and needs no regexTarget at all. This matches what
config/gitleaks/estate-baseline.toml documents. All eight of that file's own
entries are anchored on the value and set regexTarget nowhere — were the old
"silently INERT" claim true, the estate baseline would allowlist nothing.
VERDICT
else
cat <<'VERDICT' >&2
VERDICT: MEASURED BEHAVIOUR NO LONGER MATCHES WHAT THE BASELINE DOCUMENTS.
Do not edit allowlist entries until this is understood: every `regexes` entry
in config/gitleaks/estate-baseline.toml assumes regexTarget defaults to the
secret. A gitleaks upgrade is the likely cause. Update the file's comment and
this script together, and re-check the entries it protects.
VERDICT
fi
exit "$status"
Loading