check-secrets.test.mjs

Credential scan tests

Tier 1engineering

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.

CHECK-SECRETS.TEST.MJS CREDENTIAL SCAN STAGED TOKEN TESTS PASS
Add this to your project
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.

Included in