check-secrets.mjs

Credential scan

Tier 1engineeringBlocks the commit

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.

CHECK-SECRETS.MJSBLOCKS THE COMMIT STAGED DIFF COMMIT CREDENTIAL FOUND
Add this to your project
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.

Included in