Skip to content

Add secret_scan_gate.sh: block a live credential from becoming tracked - #4

Merged
scott-wueschinski-GTMify merged 2 commits into
mainfrom
secret-scan-gate
Aug 8, 2026
Merged

Add secret_scan_gate.sh: block a live credential from becoming tracked#4
scott-wueschinski-GTMify merged 2 commits into
mainfrom
secret-scan-gate

Conversation

@scott-wueschinski-GTMify

Copy link
Copy Markdown
Contributor

Why

The junk-file gate objects to junk being tracked. It never reads content. So on 2026-08-06 and 08-07 the same mechanism leaked credentials into gtmify-config three times, and a gate reported success each time:

Incident What the gate saw
354 memory files, one quoting a full Supabase token, tracked since 2026-05-11 not junk, passed
.env.backup-20260428-004952, plaintext .env with six live keys, tracked three months not junk, passed
.n8n_onboarding_env_snapshot.json, swept in by a cc-sync git add -A, holding a live Stripe sk_live_ key and the production onboarding bridge HMAC not junk, passed

Nothing in the pipeline looked at content. This closes that.

Two layers, because either alone misses a real case

VALUE layer. Compares staged content against the actual values in ~/.claude/.env, ~/.claude/.env.local, and hooks/.env. Near-zero false positives, and it catches a secret no pattern would recognize. Local only; in CI there is no env file, so it reports itself unavailable rather than pretending to have run.

PATTERN layer. Vendor prefixes (Stripe, Slack, Supabase, GitHub, GitLab, AWS, age, PEM, Resend, n8n, JWT) plus a generic NAME_KEY = <long string> detector. That generic rule is what catches a credential absent from your env, which is exactly how the Stripe key arrived inside a JSON file.

It never prints a secret

Findings name file, line, and detector only. A scanner that echoes what it found turns terminal scrollback and CI logs into the next copy of the leak.

Conventions match junk_file_gate.sh

--staged for the pre-commit hook, --audit for cleanups, base-ref diff by default. Only tracked or staged content can fail, so a gitignored file on disk is invisible by construction. sops files and binaries are skipped. Escape hatches: .secret-scan-allow per repo, or git commit --no-verify.

Verified before committing

14 cases, per the standing rule that an untriggered hook is not evidence:

  • Blocks: Stripe sk_live_ inside JSON (the exact case that got through), Slack xoxb, Supabase sbp_, GitHub ghp_, PEM private key, generic NAME_SECRET=, and a real value read from a local env file
  • Does not fire on: <REDACTED> placeholders, YOUR_ placeholders, documented examples, short values, allowlisted paths, encrypted sops files
  • Confirmed the synthetic secret appears zero times in the output

Implementation note worth keeping

The python body is delivered through a quoted heredoc. The first version used python3 -c '...' and the regexes contain single quotes, which closed the shell string and made every invocation a syntax error. A gate that cannot parse itself is worse than no gate, since a caller can read the crash as a pass. The test suite caught it immediately.

🤖 Generated with Claude Code

Scott Wueschinski added 2 commits August 7, 2026 23:12
The junk-file gate objects to junk being tracked. It does not read content, so
on 2026-08-06 and 08-07 the same mechanism leaked credentials into
gtmify-config three times and a gate reported success every time:

  1. 354 memory files, one quoting a full Supabase token, tracked since
     2026-05-11 and readable by five collaborators
  2. .env.backup-20260428-004952, a plaintext .env with six live keys, tracked
     for three months
  3. .n8n_onboarding_env_snapshot.json, swept in by a cc-sync `git add -A`,
     holding a LIVE Stripe sk_live_ key and the PRODUCTION onboarding bridge HMAC

Two detection layers, because either alone misses a real case here. The VALUE
layer compares staged content against the actual values in the local env files,
which is near-zero false positive and catches a secret no pattern knows. The
PATTERN layer covers vendor prefixes plus a generic NAME_KEY=<long string>
detector, and that is the layer that catches a credential absent from your env,
which is precisely how the Stripe key arrived.

It never prints a secret. Findings name file, line, and detector, because a
scanner that echoes its findings turns scrollback and CI logs into the next
copy of the leak.

Same conventions as junk_file_gate.sh: --staged, --audit, or a base-ref diff;
only tracked or staged content can fail; sops files and binaries are skipped;
per-repo escape hatch via .secret-scan-allow, and `git commit --no-verify`.

Verified with 14 cases before committing, per the standing rule that an
untriggered hook is not evidence: it blocks Stripe, Slack, Supabase, GitHub,
PEM and generic assignments, blocks a real value from a local env file, and
does NOT fire on <REDACTED>, YOUR_, documented examples, short values,
allowlisted paths, or encrypted sops files.

One implementation note worth keeping: the python body is delivered through a
QUOTED heredoc. The first version used `python3 -c '...'`, and the regexes
contain single quotes, which closed the shell string and made every invocation
a syntax error. A gate that cannot parse itself is worse than no gate, because
a caller can read the crash as a pass.
Ran --audit against the real repo before enabling this as a blocking hook,
which is the whole point of having an audit mode. It returned 18 findings and
only one was real. A gate that fires on legitimate commits earns a permanent
--no-verify, so the noise had to go first.

Three exclusions, each from a concrete case in that audit:

  Non-secret env names. SUPABASE_URL and SUPERGROW_WORKSPACE_ID tripped the
  value layer: a URL and an identifier, both over 20 chars, neither a secret.
  Names ending in _URL, _ID, _HOST, _WORKSPACE and similar are now skipped, as
  is any value containing "://" or "@", since a locator is not a credential.

  Publishable keys. A Supabase anon key ships in browser JavaScript by design;
  row-level security protects that data, not secrecy. _ANON_KEY,
  _PUBLISHABLE_KEY, _PUBLIC_KEY and CLIENT_ID are excluded.

  Presigned S3 URLs. Nine hits on AKIA/ASIA turned out to be one temporary STS
  credential inside X-Amz-Credential in bank-statement attachment links, scoped
  to a single object and long expired. Lines carrying X-Amz-Credential or
  X-Amz-Signature are skipped.

Audit now returns 1 finding on gtmify-config, a hardcoded key in
scripts/n8n_fix_wfemail_scaledmail.py that matches no current env value, so a
dead credential worth reviewing rather than noise.

All 14 original test cases still pass, including the Stripe-in-JSON case.
@scott-wueschinski-GTMify
scott-wueschinski-GTMify merged commit 95025b8 into main Aug 8, 2026
1 check passed
@scott-wueschinski-GTMify
scott-wueschinski-GTMify deleted the secret-scan-gate branch August 8, 2026 03:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant