A real token pasted into a script, a doc or a fixture and committed. .gitignore only protects the paths it names, and a token that reaches history is in every clone and may already be in a model’s context.
Add "Credential scan" from ntent to this repo.
Fetch https://ntent.app/r/f/check-secrets as plain text. Write it verbatim to scripts/check-secrets.mjs. Then make exactly these edits and no others: It needs the gitleaks binary (brew install gitleaks). Without it the hook warns and passes, and doctor names the gap. Add one CI step that runs `node scripts/check-secrets.mjs --all` after a full-history checkout (fetch-depth: 0), which fails if gitleaks is missing. Add a .gitleaks.toml only when the default rules read one of your own identifiers as a secret, and allowlist that exact shape, with a description.
Then check it: Stage a file holding a token-shaped string and the commit is refused with the finding redacted, telling you to rotate the credential as well as delete the line. A finding means rotate it, not just remove it: a token that reached a diff may already be in a model context, a log or a clone. Unstage it and the commit goes through. On a machine without gitleaks the commit passes with a warning that the diff was not scanned; in CI the same run fails.Reads https://ntent.app/r/f/check-secrets
Read the code
check-secrets.mjsscripts/check-secrets.mjs
202 lines
#!/usr/bin/env node
/**
* Secrets gate: no credential enters a commit.
*
* Most repos start with .gitignore as the only thing between a real credential
* and history. It holds for the paths it names and for nobody who pastes a
* token into a script, a doc or a fixture. Two people and an assistant commit
* to the same repo, so the check has to sit in the hook, where it holds for all
* of them.
*
* IT WRAPS GITLEAKS AND GROWS NO PATTERNS OF ITS OWN. Detection is gitleaks'
* default ruleset. The only local predicate is an allowlist in the
* `.gitleaks.toml` at the repo root, if there is one. Add one when the default
* rules read one of your own identifiers as a secret: the usual culprit is the
* generic-api-key rule firing on an id that sits beside a word like "key" or
* "auth". Keep an allowlist to the exact shape it admits, because one that is a
* character too loose lets a real token through while the gate still prints
* "clean". Without a config file the defaults apply and nothing is allowed.
*
* TWO LEGS.
*
* --check The staged diff only, for the pre-commit hook. Fast, and it reads
* the index rather than the tree: in the repo this came from,
* every real credential sat in a gitignored file, which is where
* it belongs, and a tree scan would find them on every run.
* --all Every commit the checkout has, for CI, on every push to the main
* branch. It refuses a shallow clone, which holds only the newest
* commits and would report clean. This is the leg that makes the
* warning below safe.
*
* WHERE GITLEAKS IS NOT INSTALLED. Locally, --check warns and passes. A gate a
* fresh clone cannot satisfy is a gate people learn to skip, so it belongs on
* the warn side of the line in chapter 3. In CI, and under --all anywhere, a
* missing binary is a failure: the CI leg exists so a checkout without the
* binary is covered one push later, not never, and it cannot do that if it
* quietly scans nothing. CI is taken from the CI environment variable, which
* most CI runners set. CI=false and CI=0 do not count.
*
* A FINDING MEANS ROTATE THE CREDENTIAL, not just delete the line. A token that
* reached a staged diff may already have reached a model's context, a paste
* buffer or a build log, and a token that reached history is in every clone.
* Removing it from the change is half the fix. Revoking it is the other half.
*
* WHAT IT DOES NOT DO. It does not read an assistant's session: keeping the
* credential files out of the assistant's read and edit tools is a separate
* layer, in the assistant's own settings. It does not rotate anything. And it
* finds what gitleaks' rules describe, so a credential with no recognisable
* shape and no keyword beside it is not seen.
*
* EXIT CODES. 0 clean, or gitleaks absent locally under --check (warned). 1 a
* finding, or gitleaks absent where it is required. 2 a bad argument, or --all on a
* shallow clone (history could not be checked, which is not a finding).
*
* Usage:
* node scripts/check-secrets.mjs --check the staged diff (default)
* node scripts/check-secrets.mjs --all all history
*
* Install gitleaks: brew install gitleaks (or see github.com/gitleaks/gitleaks)
*/
import { spawnSync } from 'node:child_process'
import { appendFileSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const MODES = ['--check', '--all']
/**
* The gitleaks command line for a mode, as an argument list.
*
* Redacted, so a finding never prints the secret it found into a terminal, a
* CI log or a model's context. The config is passed only when the repo has one.
* Left out, gitleaks would look for its own default file name in the target
* path, which is the same file, but naming it makes the run reproducible from
* a different working directory.
*/
export function buildArgs(mode, root, { hasConfig = false } = {}) {
const args = ['git', '--redact', '--no-banner']
if (hasConfig) args.push('--config', join(root, '.gitleaks.toml'))
if (mode === '--check') args.push('--pre-commit', '--staged')
args.push(root)
return args
}
/** Whether a binary is on this PATH, without running it. */
function onPath(name, env) {
const probe = spawnSync(name, ['version'], { env, stdio: 'ignore' })
return !probe.error
}
/**
* Whether this looks like a CI run. Most CI runners set CI=true, and some
* people set CI=false or CI=0 to say the opposite, so those and the empty
* string are not CI.
*/
export function isCi(env) {
const v = (env.CI ?? '').trim().toLowerCase()
return !['', 'false', '0'].includes(v)
}
/** The repo root, or the working directory where this is not a git repository. */
function repoRoot(cwd) {
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
return r.status === 0 ? r.stdout.trim() : cwd
}
export function run(argv, env = process.env, cwd = process.cwd()) {
const [mode = '--check', ...extra] = argv
if (!MODES.includes(mode) || extra.length) {
console.error(`unknown argument ${JSON.stringify(argv.join(' '))}; use --check or --all`)
return 2
}
const required = mode === '--all' || isCi(env)
if (!onPath('gitleaks', env)) {
if (required) {
console.error(
`x secrets: gitleaks is not installed, and ${mode === '--all' ? '--all' : 'CI'} is the leg that must run.\n` +
' Install it in the CI job: https://github.com/gitleaks/gitleaks#installing'
)
return 1
}
console.error(
'! secrets: gitleaks is not installed, so the staged diff was NOT scanned.\n' +
' Install it: brew install gitleaks (other systems: https://github.com/gitleaks/gitleaks#installing)\n' +
' CI scans all history on every push either way.'
)
// Exit 0 so a fresh clone can commit, but nothing was read. The pre-commit
// hook names the file in NTENT_UNCHECKED_FILE and lists this gate as having
// checked nothing, rather than as passed.
if (env.NTENT_UNCHECKED_FILE) {
try {
appendFileSync(env.NTENT_UNCHECKED_FILE, 'no gitleaks\n')
} catch {
// The hook falls back to what it knew before.
}
}
return 0
}
const root = repoRoot(cwd)
// A shallow clone holds the newest commits only, and CI checkouts are shallow
// by default. gitleaks scans what is there and reports no leaks, so a token
// committed earlier passes. Refuse rather than print a clean result for a
// scan that did not see history.
if (mode === '--all') {
const shallow = spawnSync('git', ['rev-parse', '--is-shallow-repository'], { cwd: root, encoding: 'utf8' })
if (shallow.status === 0 && shallow.stdout.trim() === 'true') {
// Exit 2, not 1. Nothing was found: the history was not read. CI reads
// 1 as "a credential is in the repo, rotate it", and a shallow checkout
// is the default on most runners, so reporting it as a finding sent 26
// repos looking for a secret that was never there.
console.error(
'x secrets: could not check history. This is a shallow clone, so --all cannot see all of it and would report clean.\n' +
' In CI, set fetch-depth: 0 on the checkout step. Elsewhere, run: git fetch --unshallow'
)
return 2
}
}
// The findings go to a JSON report so they can be printed as file:line and
// rule id, one per line. Gitleaks' own text block buries both in a wall of
// fields. The match is redacted by --redact and is never printed here either.
const reportDir = mkdtempSync(join(tmpdir(), 'secrets-report-'))
const reportPath = join(reportDir, 'report.json')
const base = buildArgs(mode, root, { hasConfig: existsSync(join(root, '.gitleaks.toml')) })
const args = [...base.slice(0, -1), '--report-format', 'json', '--report-path', reportPath, base.at(-1)]
let result
let leaks = []
try {
result = spawnSync('gitleaks', args, { cwd: root, env, stdio: 'inherit' })
try {
leaks = JSON.parse(readFileSync(reportPath, 'utf8'))
} catch {
// No report: gitleaks failed before it could write one.
}
} finally {
rmSync(reportDir, { recursive: true, force: true })
}
if (result.status !== 0) {
for (const l of leaks) console.error(` ${l.File}:${l.StartLine} ${l.RuleID}`)
console.error(
'x secrets: gitleaks found a credential, or could not finish (its output is above, redacted).\n' +
' Take it out of the change AND rotate it: a token that reached a diff may already be in a\n' +
' model context, a log or a clone. If it is not a credential, allowlist the exact shape in\n' +
' .gitleaks.toml with a description saying why.'
)
return 1
}
console.log('secrets: clean')
return 0
}
// Run only when this file is the entry point, not when a test imports it. Both
// sides go through realpath: the hook runs this from a temp directory, and on
// macOS /var is a link to /private/var, so a plain comparison of the two URLs
// says "imported" and the script exits 0 having scanned nothing.
const entry = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : ''
if (import.meta.url === entry) {
process.exit(run(process.argv.slice(2)))
}
How to tell it works: Stage a file holding a token-shaped string and the commit is refused with the finding redacted, telling you to rotate the credential as well as delete the line. A finding means rotate it, not just remove it: a token that reached a diff may already be in a model context, a log or a clone. Unstage it and the commit goes through. On a machine without gitleaks the commit passes with a warning that the diff was not scanned; in CI the same run fails.