Test that a missing gitleaks warns locally and fails in CI, and that a staged token is refused, so an edit to the checker cannot quietly stop it scanning.
Add "Credential scan tests" from ntent to this repo.
Fetch https://ntent.app/r/f/check-secrets-test as plain text. Write it verbatim to scripts/check-secrets.test.mjs.
It needs check-secrets.mjs. If this repo does not have it, stop and tell me.
Then check it: node --test passes with or without gitleaks installed: the cases that need the binary are skipped, with the reason printed. Stage an edit to check-secrets.mjs that drops --redact and the commit is refused, naming this file.Reads https://ntent.app/r/f/check-secrets-test
Read the code
check-secrets.test.mjsscripts/check-secrets.test.mjs
277 lines
/**
* Tests for the secrets checker.
*
* Two kinds of case, because the checker has two jobs. The first is what it
* does with no gitleaks on the machine, which is most fresh clones and every
* case here can run without it: warn and pass locally, fail in CI and under
* --all, and build the command line it would have run. The second needs the
* binary: a fake credential goes into a staged diff and has to be refused, and
* a clean one has to pass. Those cases are skipped, with the reason printed,
* where gitleaks is absent, so the suite never depends on it being installed.
*
* NOTHING SECRET-SHAPED IS WRITTEN IN THIS FILE. The fake token is built at run
* time from parts, so a scan of this repository, which is what the checker
* exists to run, does not find a credential in its own tests.
*
* Every case writes a throwaway repo in a temp directory. Nothing here touches
* the repository you are running from.
*
* Run: node --test scripts/check-secrets.test.mjs
*/
import test, { after } from 'node:test'
import assert from 'node:assert/strict'
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, readdirSync, writeFileSync, mkdirSync, symlinkSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildArgs, isCi } from './check-secrets.mjs'
// Every temp dir this file makes. They are removed when the file's tests end, pass or fail.
const made = []
const tmp = (prefix) => {
const dir = mkdtempSync(join(tmpdir(), prefix))
made.push(dir)
return dir
}
after(() => {
for (const dir of made) rmSync(dir, { recursive: true, force: true })
})
const CHECK = join(dirname(fileURLToPath(import.meta.url)), 'check-secrets.mjs')
const HAS_GITLEAKS = !spawnSync('gitleaks', ['version'], { stdio: 'ignore' }).error
/** A PATH holding nothing, so `gitleaks` cannot be found whatever is installed. */
const emptyPath = () => tmp('secrets-empty-path-')
/** node by absolute path, because the empty PATH cannot find it by name. */
function run(args, { env = {}, cwd } = {}) {
const r = spawnSync(process.execPath, [CHECK, ...args], {
cwd: cwd ?? tmp('secrets-cwd-'),
env: { PATH: emptyPath(), ...env },
encoding: 'utf8',
})
return { code: r.status, out: `${r.stdout}${r.stderr}` }
}
// ------------------------------------------------------ without gitleaks ---
test('locally, a missing gitleaks warns and passes, and says the diff was not scanned', () => {
const r = run(['--check'])
assert.equal(r.code, 0, r.out)
assert.match(r.out, /NOT scanned/)
assert.match(r.out, /brew install gitleaks/, 'the warning names the fix')
})
test('without gitleaks it says so in the file the hook names, so the hook can keep it off its passed line', () => {
const note = join(tmp('secrets-note-'), 'unchecked')
writeFileSync(note, '')
const r = run(['--check'], { env: { NTENT_UNCHECKED_FILE: note } })
assert.equal(r.code, 0, r.out)
assert.equal(readFileSync(note, 'utf8'), 'no gitleaks\n')
// Unset, nothing is written anywhere.
assert.equal(run(['--check']).code, 0)
})
test('with no argument it is the staged-diff leg', () => {
assert.equal(run([]).code, 0)
})
test('in CI, a missing gitleaks fails', () => {
const r = run(['--check'], { env: { CI: 'true' } })
assert.equal(r.code, 1, r.out)
assert.match(r.out, /gitleaks is not installed/)
})
test('CI=false, CI=0 and an empty CI are not CI, and any other value is', () => {
for (const v of ['false', 'FALSE', '0', '', ' ']) assert.equal(isCi({ CI: v }), false, JSON.stringify(v))
for (const v of ['true', '1', 'yes']) assert.equal(isCi({ CI: v }), true, v)
assert.equal(isCi({}), false)
// And end to end: CI=false with no gitleaks is the local warn-and-pass path.
assert.equal(run(['--check'], { env: { CI: 'false' } }).code, 0)
assert.equal(run(['--check'], { env: { CI: '0' } }).code, 0)
})
test('--all fails without gitleaks, in CI or not: it is the leg that must run', () => {
const r = run(['--all'])
assert.equal(r.code, 1, r.out)
assert.match(r.out, /--all is the leg that must run/)
})
test('an unknown argument is a usage error, not a scan', () => {
assert.equal(run(['--everything']).code, 2)
assert.equal(run(['--check', '--all']).code, 2)
})
test('run through a symlinked path it still runs, rather than exiting 0 having scanned nothing', () => {
// The hook runs the checker from a temp directory, and on macOS /var is a
// link to /private/var. An entry-point test that compared the raw argv path
// with the module URL said "imported, not run", and the gate passed silently.
const link = join(tmp('secrets-link-'), 'scripts')
symlinkSync(dirname(CHECK), link, 'dir')
const r = spawnSync(process.execPath, [join(link, 'check-secrets.mjs'), '--all'], {
cwd: tmp('secrets-cwd-'),
env: { PATH: emptyPath() },
encoding: 'utf8',
})
assert.equal(r.status, 1, `${r.stdout}${r.stderr}`)
assert.match(r.stderr, /gitleaks is not installed/)
})
// ---------------------------------------------------------- the command ---
test('the staged leg scans the index and redacts what it finds', () => {
const args = buildArgs('--check', '/repo')
assert.deepEqual(args, ['git', '--redact', '--no-banner', '--pre-commit', '--staged', '/repo'])
})
test('the history leg has no staged flags, so it walks every commit', () => {
const args = buildArgs('--all', '/repo')
assert.ok(!args.includes('--staged') && !args.includes('--pre-commit'))
assert.ok(args.includes('--redact'), 'a CI log is not a place to print a secret')
assert.equal(args.at(-1), '/repo')
})
test('the repo config is passed when there is one, and only then', () => {
const withConfig = buildArgs('--check', '/repo', { hasConfig: true })
assert.deepEqual(withConfig.slice(withConfig.indexOf('--config'), withConfig.indexOf('--config') + 2), [
'--config',
'/repo/.gitleaks.toml',
])
assert.ok(!buildArgs('--check', '/repo').includes('--config'))
})
// ------------------------------------------------------ with gitleaks ---
const skip = HAS_GITLEAKS ? false : 'gitleaks is not installed here'
const git = (dir, ...args) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
/** A GitHub-shaped personal access token, assembled at run time. */
function fakeToken() {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let body = ''
for (let i = 0; i < 36; i++) body += alphabet[(i * 7 + 13 * ((i * i) % 5)) % alphabet.length]
return ['gh', 'p', '_'].join('') + body
}
function repo() {
const dir = tmp('secrets-repo-')
git(dir, 'init', '--quiet')
git(dir, 'config', 'user.email', 'test@example.com')
git(dir, 'config', 'user.name', 'Test')
git(dir, 'config', 'commit.gpgsign', 'false')
return dir
}
/** The real PATH this time, so gitleaks resolves. */
const runInstalled = (args, cwd) => {
const r = spawnSync(process.execPath, [CHECK, ...args], { cwd, encoding: 'utf8' })
return { code: r.status, out: `${r.stdout}${r.stderr}` }
}
test('a staged token is refused, and the token is not printed', { skip }, () => {
const dir = repo()
const token = fakeToken()
writeFileSync(join(dir, 'deploy.sh'), `export GITHUB_TOKEN=${token}\n`)
git(dir, 'add', 'deploy.sh')
const r = runInstalled(['--check'], dir)
assert.equal(r.code, 1, r.out)
assert.ok(!r.out.includes(token), 'the finding is redacted')
assert.match(r.out, /rotate it/, 'the refusal says a finding means rotating, not only deleting')
assert.match(r.out, /deploy\.sh:1 +github-pat/, 'the finding names the file, the line and the gitleaks rule id')
})
test('--all names the file and rule of a token in history, and does not print it', { skip }, () => {
const dir = repo()
const token = fakeToken()
mkdirSync(join(dir, 'scripts'))
writeFileSync(join(dir, 'scripts', 'old.sh'), `export GITHUB_TOKEN=${token}\n`)
git(dir, 'add', '-A')
git(dir, 'commit', '--quiet', '-m', 'an old mistake')
const r = runInstalled(['--all'], dir)
assert.equal(r.code, 1, r.out)
assert.match(r.out, /scripts\/old\.sh:1 +github-pat/)
assert.ok(!r.out.includes(token))
})
test('a clean staged diff passes', { skip }, () => {
const dir = repo()
writeFileSync(join(dir, 'README.md'), '# hello\n')
git(dir, 'add', 'README.md')
const r = runInstalled(['--check'], dir)
assert.equal(r.code, 0, r.out)
assert.match(r.out, /clean/)
})
test('the report folder is removed, whether the scan is clean or finds a token', { skip }, () => {
// Run by the hook on every commit, so a folder left behind each time adds up.
const owned = tmp('secrets-owned-tmp-')
const scan = (cwd) => spawnSync(process.execPath, [CHECK, '--check'], { cwd, env: { ...process.env, TMPDIR: owned }, encoding: 'utf8' })
const dir = repo()
writeFileSync(join(dir, 'README.md'), '# hello\n')
git(dir, 'add', 'README.md')
assert.equal(scan(dir).status, 0)
assert.deepEqual(readdirSync(owned), [], 'nothing left after a clean scan')
writeFileSync(join(dir, 'deploy.sh'), `export GITHUB_TOKEN=${fakeToken()}\n`)
git(dir, 'add', 'deploy.sh')
assert.equal(scan(dir).status, 1)
assert.deepEqual(readdirSync(owned), [], 'nothing left after a scan that finds a token')
})
test('a token that is unstaged is not the commit, so it does not block it', { skip }, () => {
const dir = repo()
writeFileSync(join(dir, 'README.md'), '# hello\n')
writeFileSync(join(dir, '.env.local'), `TOKEN=${fakeToken()}\n`)
git(dir, 'add', 'README.md')
assert.equal(runInstalled(['--check'], dir).code, 0)
})
test('--all finds a token that is already in history', { skip }, () => {
const dir = repo()
mkdirSync(join(dir, 'scripts'))
writeFileSync(join(dir, 'scripts', 'old.sh'), `export GITHUB_TOKEN=${fakeToken()}\n`)
git(dir, 'add', '-A')
git(dir, 'commit', '--quiet', '-m', 'an old mistake')
const r = runInstalled(['--all'], dir)
assert.equal(r.code, 1, r.out)
})
test('an allowlist in the repo config admits the shape it names', { skip }, () => {
const dir = repo()
const token = fakeToken()
writeFileSync(join(dir, 'deploy.sh'), `export GITHUB_TOKEN=${token}\n`)
writeFileSync(
join(dir, '.gitleaks.toml'),
`[extend]\nuseDefault = true\n\n[[allowlists]]\ndescription = "a test token"\nregexTarget = "secret"\nregexes = ['''^${token}$''']\n`
)
git(dir, 'add', '-A')
assert.equal(runInstalled(['--check'], dir).code, 0, 'the config is read, so the check honours it')
})
test('--all refuses a shallow clone, which would report clean over a token in history', { skip }, () => {
const source = repo()
writeFileSync(join(source, 'old.sh'), `export GITHUB_TOKEN=${fakeToken()}\n`)
git(source, 'add', '-A')
git(source, 'commit', '--quiet', '-m', 'an old mistake')
git(source, 'rm', '--quiet', 'old.sh')
git(source, 'commit', '--quiet', '-m', 'remove it')
const full = tmp('secrets-full-')
git(full, 'clone', '--quiet', source, '.')
assert.equal(runInstalled(['--all'], full).code, 1, 'a full clone sees the token')
const shallow = tmp('secrets-shallow-')
git(shallow, 'clone', '--quiet', '--depth', '1', `file://${source}`, '.')
const r = runInstalled(['--all'], shallow)
// 2, not 1: the history was not checked, which is a different statement from
// "history has a credential in it". CI treats 1 as a finding to rotate.
assert.equal(r.code, 2, r.out)
assert.match(r.out, /shallow clone/)
assert.match(r.out, /could not check/)
assert.match(r.out, /fetch-depth: 0/)
})
How to tell it works: node --test passes with or without gitleaks installed: the cases that need the binary are skipped, with the reason printed. Stage an edit to check-secrets.mjs that drops --redact and the commit is refused, naming this file.