From fc28a39d412a5476703cdc11f727ca1cfbdbaad8 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Mon, 3 Aug 2026 16:04:09 +0200 Subject: [PATCH 1/2] Check whether `git add -A` would stage a credential Today we found live keys sitting unignored in clones of this PUBLIC repo on BOTH machines, within an hour of each other: MacBook config/grok.env `config/*.json` was scoped to .json Mac mini logs/start-all.out `*.log` did not cover .out Both are being fixed by adding the missing pattern (#55, #56), and that is worth doing, but on its own it is the wrong lesson. Neither rule was WRONG. Both were incomplete, and the same day produced a third instance of the identical shape - `*.bak.*` matches foo.bak.1 but not foo.bak - and a fourth in an unrelated repo. Every time, the rule that existed stayed correct, so nothing looked broken. It was always the sibling nobody thought to name. You cannot enumerate your way out of that. The next one is .out2, or .tmp, or a directory that does not exist yet. So this checks the OUTCOME instead of the filenames: whatever `git add -A` would actually stage, does any of it look like a credential? It asks git for that set rather than reimplementing ignore matching, which is the whole point - a hand-rolled matcher would inherit exactly the blind spots that let these two through. Verified by execution, all four behaviours, not by reading: 1. clean tree -> PASS, exit 0 2. planted xfb_ key in logs/start-all.out, the real file from the Mini -> FAIL, exit 1, correct file and line 3. same file then ignored -> PASS again, so it respects .gitignore and will not cry wolf about properly-ignored keys 4. a DIFFERENT extension nobody has a rule for (.out2, sk-ant- key) -> still caught, which is the entire point It reports the file, the line and the credential TYPE, never the value: a scanner that prints the secret it found has only moved the leak into your terminal scrollback and CI logs. One detail worth keeping: sk-ant- is matched before the general sk- rule, because the broad pattern also matches an Anthropic key and mislabelling it would send someone rotating the wrong credential. Caught that in test 4. --- scripts/check-stageable-secrets.mjs | 114 ++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100755 scripts/check-stageable-secrets.mjs diff --git a/scripts/check-stageable-secrets.mjs b/scripts/check-stageable-secrets.mjs new file mode 100755 index 0000000..f1b7890 --- /dev/null +++ b/scripts/check-stageable-secrets.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Fail if `git add -A` would stage a credential. +// +// On 2026-08-03 we found live keys sitting unignored in PUBLIC repo clones on +// BOTH machines, within an hour of each other, and neither was caught by a +// rule: +// +// MacBook config/grok.env `config/*.json` was scoped to .json +// Mac mini logs/start-all.out `*.log` did not cover .out +// +// Both were fixed by adding the missing pattern. That is the wrong lesson. +// The rules were not wrong, they were INCOMPLETE, and the same day gave us a +// third instance of the identical shape (`*.bak.*` matched foo.bak.1 but not +// foo.bak) and a fourth in a different repo entirely. Each time the rule that +// existed stayed correct, so nothing looked broken — it was always the +// sibling nobody thought to name. +// +// You cannot enumerate your way out of that. The next one will be .out2, or +// .tmp, or a directory nobody has created yet. So this checks the OUTCOME +// rather than the filenames: whatever `git add -A` would actually stage, does +// any of it look like a credential? +// +// Run: node scripts/check-stageable-secrets.mjs (exit 1 on any finding) + +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; + +// Shapes worth stopping for. Deliberately narrow: a scanner that cries wolf +// gets disabled, and a disabled scanner is worse than none. Every pattern +// here is a real credential format we use or plausibly would. +const PATTERNS = [ + [/xfb_[a-f0-9]{32,}/i, 'GroupMind agent key'], + // sk-ant- BEFORE the general sk- rule: the broad one also matches an + // Anthropic key and would mislabel it, and a wrong label sends someone + // rotating the wrong credential. + [/sk-ant-[A-Za-z0-9_-]{20,}/, 'Anthropic API key'], + [/sk-[A-Za-z0-9_-]{20,}/, 'OpenAI-style secret key'], + [/AIza[0-9A-Za-z_-]{35}/, 'Google API key'], + [/gh[pousr]_[A-Za-z0-9]{36,}/, 'GitHub token'], + [/github_pat_[A-Za-z0-9_]{50,}/, 'GitHub fine-grained PAT'], + [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, 'private key'], + [/\b(?:api[_-]?key|secret|password|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{24,}/i, + 'assigned secret-looking value'], +]; + +// Binaries and lockfiles produce noise, not credentials. +const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|jar|aab|apk|keystore|jks|woff2?|ttf|mp[34]|mov|wav)$/i; +const MAX_BYTES = 2 * 1024 * 1024; + +const git = (args) => + execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + +// Exactly the set `git add -A` would stage: tracked-and-modified plus +// untracked-and-not-ignored. Asking git rather than reimplementing its ignore +// logic is the point — a hand-rolled matcher would inherit the same blind +// spots as the .gitignore rules that missed these files. +function stageableFiles() { + const out = git(['status', '--porcelain=v1', '--untracked-files=all']); + const files = []; + for (const line of out.split('\n')) { + if (!line.trim()) continue; + const status = line.slice(0, 2); + let path = line.slice(3); + if (status.includes('R')) path = path.split(' -> ').pop(); // renames + if (status === 'D ' || status === ' D') continue; // going away + files.push(path.replace(/^"|"$/g, '')); + } + return files; +} + +function scan(path) { + if (SKIP_EXT.test(path)) return null; + let text; + try { + if (statSync(path).size > MAX_BYTES) return null; + text = readFileSync(path, 'utf8'); + } catch { + return null; // unreadable, gone, or a directory: not our problem + } + if (text.includes('\0')) return null; // binary + for (const [re, label] of PATTERNS) { + const m = text.match(re); + if (m) { + // Report WHERE and WHAT, never the value itself. This output ends up in + // CI logs and terminal scrollback, and a scanner that prints the secret + // it found has simply moved the leak. + const line = text.slice(0, m.index).split('\n').length; + return { label, line, hint: `${m[0].slice(0, 6)}…(${m[0].length} chars)` }; + } + } + return null; +} + +const findings = []; +for (const f of stageableFiles()) { + const hit = scan(f); + if (hit) findings.push({ file: f, ...hit }); +} + +if (findings.length === 0) { + console.log('PASS: nothing `git add -A` would stage looks like a credential.'); + process.exit(0); +} + +console.error(`FAIL: ${findings.length} stageable file(s) contain credential-shaped data\n`); +for (const f of findings) { + console.error(` ${f.file}:${f.line}`); + console.error(` ${f.label} — ${f.hint}\n`); +} +console.error('These are NOT committed yet, and this repo is public.'); +console.error('Fix by ignoring the file, not by deleting it — something may be using it:'); +console.error(' echo "" >> .git/info/exclude # this machine, immediate'); +console.error(' then add the pattern to .gitignore in a PR, so every user gets it.'); +process.exit(1); From 4b1903559b3ebffdb4a086eb9949b004ef57d53b Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Mon, 3 Aug 2026 16:10:24 +0200 Subject: [PATCH 2/2] Document the local-exclude blind spot in the script itself A PASS means nothing dangerous is stageable ON THIS MACHINE, because git honours .git/info/exclude and that file is machine-local and never committed. It does NOT mean the repo's ignore rules are complete - a fresh clone or CI has none of your local excludes. Conflating those two claims is how both of today's leaks survived. The local exclude is the tourniquet; the .gitignore change is the fix; this script cannot tell you whether you did the second one. Found by claudemm, who tested whether his own exclude hid a planted file from the scanner rather than taking it at its word. Putting it in the header rather than leaving it in a PR comment, because the comment stops being visible the moment this merges. --- scripts/check-stageable-secrets.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/check-stageable-secrets.mjs b/scripts/check-stageable-secrets.mjs index f1b7890..7e00895 100755 --- a/scripts/check-stageable-secrets.mjs +++ b/scripts/check-stageable-secrets.mjs @@ -20,6 +20,20 @@ // rather than the filenames: whatever `git add -A` would actually stage, does // any of it look like a credential? // +// WHAT A PASS DOES AND DOES NOT MEAN. This asks git what is stageable, and +// git honours .git/info/exclude — which is machine-local and never committed. +// So a PASS means "nothing dangerous is stageable ON THIS MACHINE RIGHT NOW". +// It does NOT mean the repo's ignore rules are complete: a fresh clone, a new +// user, or CI has none of your local excludes, and if .gitignore is still +// missing the pattern then the same file is stageable there with nothing to +// warn them. +// +// Those are two different claims and conflating them is how both of today's +// leaks survived. Closing a hole with .git/info/exclude is the tourniquet; +// the .gitignore change is the fix. This script cannot tell you whether you +// did the second one. (Blind spot found by claudemm, who tested exactly this +// rather than taking the script at its word.) +// // Run: node scripts/check-stageable-secrets.mjs (exit 1 on any finding) import { execFileSync } from 'node:child_process';