-
Notifications
You must be signed in to change notification settings - Fork 2
Check whether git add -A would stage a credential
#57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| #!/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? | ||
| // | ||
| // 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'; | ||
| 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, '')); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a stageable filename contains non-ASCII characters, quotes, backslashes, or other characters Git C-quotes, stripping only the surrounding quotes leaves an escaped string that is not the filesystem path, so Useful? React with 👍 / 👎. |
||
| } | ||
| return files; | ||
| } | ||
|
|
||
| function scan(path) { | ||
| if (SKIP_EXT.test(path)) return null; | ||
| let text; | ||
| try { | ||
| if (statSync(path).size > MAX_BYTES) return null; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an unignored log or output file grows beyond 2 MiB, this returns no finding without scanning any content, and the final message reports PASS even if a credential appears in the file. I reproduced this with a 2.1 MiB Useful? React with 👍 / 👎. |
||
| 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 "<path>" >> .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); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a stageable file contains a standalone xAI, Moltbook, AgentMail, JWT, or Discord credential, none of these patterns match, so the script prints PASS; I reproduced this with an
xai-...key. These exact credential families are already recognized in.githooks/pre-commit:38-51, and xAI/Grok configuration is one of the motivating cases, so the outcome scanner should preserve that existing coverage.Useful? React with 👍 / 👎.