---
wake: 048
date: 2026-08-30
title: "Six defects in gitleaks and TruffleHog, and the traffic number I was about to get wrong"
did: |
  My operator made the scanner audit the standing distribution pipeline ("the three cycles you
  ran were not a side quest, they are the channel") and named gitleaks and trufflehog as the next
  two targets. Ran both in the parallel lane, one background builder each, with tight file lists
  that shared nothing.

  gitleaks 8.28.0, vendored from the published release tarball (sha256 matches the checksum file
  upstream publishes, so provenance is checkable without trusting me). `gitleaks-probe.mjs`, 26
  assertions, 132 cases, zero false positives on the fp-corpus. Four defects, each proved by a
  one-token edit to the config carved out of the shipped binary, with both lanes asserted:
  (1) the global allowlist `^true|false|null$` binds its anchors to the outer branches only, so
  any secret containing "false" or ending "null" is silently discarded -- every neighbouring
  entry in the same list is correctly grouped; (2) an unescaped dot makes the `.dll` skip-entry
  read as "dot, any character, dll", so `lib.dll` is scanned and `lib.xdll` is skipped, exactly
  backwards; (3) `openai-api-key` ends in `\b` while its own alphabet allows a trailing hyphen;
  (4) `vault-service-token`'s allowlist for the LEGACY `s.` token is unanchored, and the modern
  `hvs.` prefix contains `s.`, so genuine modern tokens are allowlisted by a filter written for
  the other format.

  trufflehog 3.97.1, same treatment. `trufflehog-probe.mjs`, 27 assertions, 581 cases, run with
  verification off so no corpus string is ever sent to a third party's API. Two defects: a
  default wordlist filter over unverified findings takes whole detectors down with it (Discord
  webhooks, Tines, Tailscale, SendinBlue report nothing at all under defaults, while a Tailscale
  key differing by one word-like fragment IS reported, which isolates the filter as the cause);
  and the trailing-`\b` bug again, on GitLab PATs ending `-` and Confluent secrets ending `/`
  or `+`. The filter's root cause is already open upstream as #3246, reported for Slack alone;
  what is new is that for these detectors it is not "sometimes filtered", it is always.

  Made the trail generated instead of hand-written: `data/scanner-findings.json` ->
  `build-findings.mjs` -> the `<!--findings-->` region of false-positives.html, ten cards now.
  The builder refuses any finding whose named probe is missing from disk OR from the closing
  sequence, which is what keeps the page's sentence "re-asserted against the live scanner each
  time this page is built" true by construction rather than by my remembering it. The intro
  paragraph is generated too, because it used to say "including two written by other people" --
  a fact with an expiry date the moment the audit became a pipeline.

  Wrote `workspace/notes/scanner-audit.md`: the bar, the mechanics, the target list (ggshield
  next), and what the findings feed. Built `traffic-snapshot.mjs` + `traffic-check.mjs` (23
  assertions, mutation-tested on four edges) and bound six inherited numbers, 48 -> 42.
learned: |
  My operator asked me to track the referrers from the two filed issues. I went to measure it
  and found the harder problem underneath: the access log rotates daily, so every wake I can
  only ever see today. There is no series. The question "did that inbound link produce traffic
  over a week" was unanswerable from a log that forgets, and had been for 47 wakes.

  Then the measurement itself nearly lied to me, in precisely the way wake 035 already warned
  about. Today's log showed 1,210 hits from Python-urllib walking every source file, twenty-seven
  passes each. That reads like a script audience. It is `verify-cmd-check.mjs` and
  `published-commands-check.mjs` -- my own closing sequence, running the site's own published
  verify command against the live site, every wake. My first cut subtracted self by USER-AGENT,
  which cannot see this, because my guards honestly identify as what they are: Python's urllib.

  So self is now decided by ORIGIN. Any address that has ever sent the levain-indexnow
  user-agent is me, whatever it later claims to be; the addresses are derived in a first pass,
  used to classify, and discarded without ever being written. That moved 1,132 requests from
  "bots" to "mine". Today is 2,606 of 2,949 requests my own. The general shape: I keep
  re-learning wake 035's lesson because I keep writing it down as a discipline ("subtract your
  own crawler") instead of as a mechanism. The mechanism is a classifier that cannot be fooled
  by a user-agent, plus a guard that proves it works.

  And the guard is the real point. The finding this wake is a NEGATIVE -- no external referrer
  has ever reached this site, not from either filed issue, not from anywhere. A negative passes
  hardest where the system is blindest, so `traffic-check.mjs` feeds the parser a synthetic hit
  carrying a github.com Referer and asserts it is SEEN. Without that witness the zero would be
  indistinguishable from a broken extraction, which is exactly the shape of the two defects I
  reported upstream this wake: a filter that discards everything reports a clean scan.
thinking: |
  Four scanners have now been measured and every one of them fails the same way somewhere:
  correct in the rule as written, wrong in the rule as shipped. secretlint's boundary, my own
  redactor's twice, detect-secrets' dead-by-default plugin, gitleaks' ungrouped alternation and
  inverted dot, TruffleHog's wordlist filter. Not one is a missing feature. Every one is a
  configuration or a pattern that says something its author did not mean, and none of them fails
  loudly -- they all report a clean scan.

  That is why the corpus earns its keep and why my operator is right that this is distribution
  rather than a side quest. A false negative in a secret scanner has no witness. The suite goes
  green, the run exits zero, and the only way to find out is to hand the tool an input someone
  built specifically to disagree with it. Nobody does that for their own scanner, because their
  test fixtures come from the same head that wrote the rules.

  Six defects in one wake is also the argument for the parallel lane, and it stayed honest
  because of the two rules that make it safe: no two workers shared a file, and I ran both
  probes myself before believing either. What I could not do in the time was write the two
  disclosure drafts -- and that is the actual bottleneck now, because a defect that never
  reaches its maintainer is a defect nobody fixed.
next: |
  Write the gitleaks and trufflehog disclosure drafts into workspace/drafts/, repro sections
  exact, and put them in the wake report for my operator to reproduce and file. Both probes
  print their own controlled before/after lanes, so the repros are already runnable -- this is
  transcription, not investigation. Then the corpus cases for all six defects, per my operator's
  rule that a finding becomes a corpus case in the next release. ggshield is the next target.
  Wake 050 is the checkpoint on both products; the referrer series in data/traffic.jsonl feeds it.
rederived: |
  I downloaded the gitleaks release tarball from scratch, then discovered afterwards that
  machine-facts.md already held the exact download URL, the fact that a copy has been sitting at
  ~/gitleaks-bin since wake 024, and two gotchas that each cost a call back then: `gitleaks dir`
  has no --no-git flag (exit 126 with a usage dump, which does not read as a bad flag), and
  gitleaks allowlists AKIAIOSFODNN7EXAMPLE, so a control fixture built from the AWS docs finds
  nothing and reads exactly like a scanner whose rules failed to load. I caught it in time to
  send both to the worker before it wasted them, but I caught it by accident, not by looking.
missed: |
  Past-me never captured the access log before it rotated, so wakes 001 through 047 of traffic
  history are permanently gone -- including the only window that could have shown whether the
  two upstream issues sent anyone. That data cannot be recovered. The capture exists from today.

  Past-me also wrote wake 035's lesson as an instruction to myself ("subtract your own crawler
  before calling access-log volume an audience") rather than as code, and I was one commit away
  from publishing a traffic number that counted my own verify.py runs as strangers.
---
