Env secrets check
@shipcheck/env-secrets-check
Check your repo for leaked secrets before you push or launch: .env files git tracks or will pick up, .env files hiding in git history, server keys behind NEXT_PUBLIC_ or VITE_ prefixes that ship to the browser, provider key shapes (Stripe, AWS, GitHub, OpenAI, Anthropic, Supabase service_role), and passwords hardcoded in code. Reports names and redacted hints, never the values.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Scan a project for committed secrets before you push or launch.
- 3// - .env files that git tracks (or would pick up with `git add .`)
- 4// - .env files that were committed at any point in git history
- 5// - browser-exposed env vars (NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_, ...) that hold server secrets
- 6// - provider key shapes (Stripe, AWS, GitHub, OpenAI, Anthropic, Slack, private keys,
- 7// database URLs with passwords, Supabase service_role JWTs)
- 8// - secret-named variables with a hardcoded value in source code
- 9// Values are never printed: reports show the variable name, file, line, and a redacted hint.
- 10// Node built-ins only. Uses git when it is available and falls back to .gitignore rules.
- 11//
- 12// Usage: node scripts/secrets-scan.mjs [project-dir] [--json out/secrets-report.json] [--md out/secrets-report.md]
- 14import { execFileSync } from "node:child_process";
- 15import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
- 16import { basename, dirname, join, relative, sep } from "node:path";
- 18const SEVERITY = ["high", "medium", "low"];
- 19const SKIP_DIRS = new Set([
- 20 ".git",
- 21 "node_modules",
- 22 ".next",
- 23 ".nuxt",
- 24 ".svelte-kit",
- 25 ".vercel",
- 26 ".netlify",
- 27 ".turbo",
- 28 ".cache",
- 29 "dist",
- 30 "build",
- 31 "coverage",
- 32 ".expo",
- 33 "vendor",
- 34 "out",
- 35]);
- 36// The kit's own folder is skipped so an installed kit never reports itself.
- 37const SKIP_FILES = /(?:^|\/)(?:env-secrets-check\/.*|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|.*\.min\.js|.*\.map)$/;
- 38const BINARY_EXT = /\.(?:png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tgz|woff2?|ttf|eot|mp4|mov|mp3|wav|sqlite|db|wasm|lockb)$/i;
- 39const MAX_BYTES = 1024 * 1024;
- 41const ENV_FILE = /^\.env(?:\..+)?$/;
- 42const ENV_TEMPLATE = /^\.env\.(?:example|sample|template|defaults|dist)$/;
- 44const PUBLIC_PREFIX = /\b(NEXT_PUBLIC_|VITE_|EXPO_PUBLIC_|REACT_APP_|NUXT_PUBLIC_|GATSBY_|PUBLIC_)([A-Z0-9_]+)\b/g;
- 45const PUBLIC_OK =
- 46 /(?:ANON_KEY|PUBLISHABLE|PUBLIC_KEY|SITE_URL|APP_URL|BASE_URL|API_URL|SUPABASE_URL|POSTHOG|MAPBOX|SENTRY_DSN|GA_|GTM_|MEASUREMENT_ID|FIREBASE_|ALGOLIA_APP_ID|ALGOLIA_SEARCH|CLERK_PUBLISHABLE|PLAUSIBLE|AMPLITUDE|MIXPANEL|SEGMENT_WRITE_KEY|VAPID_PUBLIC)/;
- 47const PUBLIC_HIGH = /(?:SECRET|SERVICE_ROLE|SERVICE_KEY|PRIVATE|PASSWORD|PASSWD|DATABASE_URL|DB_URL|POSTGRES|MONGO|REDIS_URL|WEBHOOK|ADMIN)/;
- 48const SERVER_ONLY_PROVIDER =
- 49 /(?:OPENAI|ANTHROPIC|CLAUDE|GEMINI|GROQ|MISTRAL|REPLICATE|COHERE|RESEND|SENDGRID|POSTMARK|MAILGUN|TWILIO|GITHUB|AWS|STRIPE_(?:KEY|SK)|ELEVENLABS|PINECONE|UPSTASH|NOTION|AIRTABLE|SLACK)/;
- 51// Provider key shapes.
- 52const PROVIDERS = [
- 53 { rule: "stripe-live-key", severity: "high", re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/g, what: "Stripe live secret key" },
- 54 { rule: "stripe-webhook-secret", severity: "high", re: /\bwhsec_[0-9a-zA-Z]{24,}\b/g, what: "Stripe webhook signing secret" },
- 55 { rule: "stripe-test-key", severity: "low", re: /\b(?:sk|rk)_test_[0-9a-zA-Z]{24,}\b/g, what: "Stripe test secret key" },
- 56 { rule: "aws-access-key", severity: "high", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, what: "AWS access key ID" },
- 57 { rule: "github-token", severity: "high", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g, what: "GitHub token" },
- 58 { rule: "anthropic-key", severity: "high", re: /\bsk-ant-[a-z]+\d*-[A-Za-z0-9_-]{30,}/g, what: "Anthropic API key" },
- 59 { rule: "openai-key", severity: "high", re: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{30,}/g, what: "OpenAI API key" },
- 60 { rule: "slack-token", severity: "high", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}/g, what: "Slack token" },
- 61 { rule: "google-api-key", severity: "medium", re: /\bAIza[0-9A-Za-z_-]{35}\b/g, what: "Google API key (often public for Firebase; restrict it by domain)" },
- 62 { rule: "private-key", severity: "high", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g, what: "private key" },
- 63 {
- 64 rule: "database-url-password",
- 65 severity: "high",
- 66 re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?):\/\/[^\s:/@"'`]+:[^\s@/"'`]+@[^\s"'`]+/g,
- 67 what: "database URL with a password",
- 68 },
- 69];
- 70const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
- 72const SECRET_NAME = /(?:secret|password|passwd|pwd|api_?key|apikey|auth_?token|access_?token|private_?key|service_?role)/i;
- 73const ASSIGN =
- 74 /\b([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[A-Za-z<>[\]| ]+)?\s*[:=]\s*(["'`])([^"'`\s]{8,})\2/g;
- 75const PLACEHOLDER =
- 76 /^(?:your[-_]|<|\$\{|process\.env|import\.meta|xxx|\*\*\*|changeme|change[-_]me|replace[-_]?me|placeholder|example|dummy|todo|none|null|undefined|sk_test_x+|pk_)|example|placeholder|your[-_]|x{6,}/i;
- 78function parseArgs(argv) {
- 79 const args = { root: ".", json: null, md: null };
- 80 const rest = [];
- 81 for (let i = 0; i < argv.length; i++) {
- 82 const a = argv[i];
- 83 if (a === "--json") args.json = argv[++i];
- 84 else if (a === "--md") args.md = argv[++i];
- 85 else if (a === "-h" || a === "--help") {
- 86 console.log("Usage: node scripts/secrets-scan.mjs [project-dir] [--json file] [--md file]");
- 87 process.exit(0);
- 88 } else rest.push(a);
- 89 }
- 90 if (rest[0]) args.root = rest[0];
- 91 return args;
- 92}
- 94function git(root, args) {
- 95 try {
- 96 return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
- 97 } catch {
- 98 return null;
- 99 }
- 100}
- 102// Minimal .gitignore support for projects without git (or without git installed).
- 103function gitignoreMatcher(root) {
- 104 const rules = [];
- 105 const file = join(root, ".gitignore");
- 106 if (existsSync(file)) {
- 107 for (let line of readFileSync(file, "utf8").split(/\r?\n/)) {
- 108 line = line.trim();
- 109 if (!line || line.startsWith("#")) continue;
- 110 const negate = line.startsWith("!");
- 111 if (negate) line = line.slice(1);
- 112 const dirOnly = line.endsWith("/");
- 113 if (dirOnly) line = line.slice(0, -1);
- 114 const anchored = line.startsWith("/") || line.includes("/");
- 115 line = line.replace(/^\//, "");
- 116 let src = "";
- 117 for (let i = 0; i < line.length; i++) {
- 118 const c = line[i];
- 119 if (c === "*" && line[i + 1] === "*") {
- 120 src += ".*";
- 121 i++;
- 122 if (line[i + 1] === "/") i++;
- 123 } else if (c === "*") src += "[^/]*";
- 124 else if (c === "?") src += "[^/]";
- 125 else src += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
- 126 }
- 127 const re = anchored ? new RegExp(`^${src}(?:/.*)?$`) : new RegExp(`(?:^|/)${src}(?:/.*)?$`);
- 128 rules.push({ re, negate, dirOnly });
- 129 }
- 130 }
- 131 return {
- 132 hasRules: rules.length > 0,
- 133 ignored(path) {
- 134 let result = false;
- 135 for (const r of rules) if (r.re.test(path)) result = !r.negate;
- 136 return result;
- 137 },
- 138 };
- 139}
- 141function walk(root, dir = root, out = []) {
- 142 for (const entry of readdirSync(dir, { withFileTypes: true })) {
- 143 const full = join(dir, entry.name);
- 144 if (entry.isDirectory()) {
- 145 if (!SKIP_DIRS.has(entry.name)) walk(root, full, out);
- 146 } else if (entry.isFile()) out.push(relative(root, full).split(sep).join("/"));
- 147 }
- 148 return out;
- 149}
- 151function listFiles(root) {
- 152 const tracked = git(root, ["ls-files", "-z"]);
- 153 if (tracked !== null) {
- 154 const untracked = git(root, ["ls-files", "-z", "--others", "--exclude-standard"]) ?? "";
- 155 const ignoredEnv = git(root, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--", ".env*", "*/.env*"]) ?? "";
- 156 const history = git(root, ["log", "--all", "--diff-filter=A", "--name-only", "--relative", "--format=", "--", ".env", ".env.*", "*/.env", "*/.env.*"]) ?? "";
- 157 const split = (s) => s.split("\0").filter(Boolean);
- 158 return {
- 159 mode: "git",
- 160 tracked: new Set(split(tracked)),
- 161 untracked: new Set(split(untracked)),
- 162 ignoredEnv: new Set(split(ignoredEnv)),
- 163 history: new Set(history.split("\n").map((l) => l.trim()).filter(Boolean)),
- 164 hasGitignore: existsSync(join(root, ".gitignore")),
- 165 };
- 166 }
- 167 const matcher = gitignoreMatcher(root);
- 168 const all = walk(root);
- 169 return {
- 170 mode: "files",
- 171 tracked: new Set(),
- 172 untracked: new Set(all.filter((f) => !matcher.ignored(f))),
- 173 ignoredEnv: new Set(all.filter((f) => matcher.ignored(f) && ENV_FILE.test(basename(f)))),
- 174 history: new Set(),
- 175 hasGitignore: matcher.hasRules,
- 176 };
- 177}
- 179function redact(value) {
- 180 const shown = value.slice(0, Math.min(6, Math.floor(value.length / 4)));
- 181 return `${shown}... (${value.length} chars)`;
- 182}
- 184function jwtRole(token) {
- 185 try {
- 186 const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
- 187 return typeof payload.role === "string" ? payload.role : null;
- 188 } catch {
- 189 return null;
- 190 }
- 191}
- 193export function scan(root) {
- 194 const files = listFiles(root);
- 195 const findings = [];
- 196 const add = (f) => findings.push(f);
- 197 const committed = (f) => files.tracked.has(f);
- 199 // 1. env files
- 200 const envSeen = new Set();
- 201 for (const f of [...files.tracked, ...files.untracked]) {
- 202 const name = basename(f);
- 203 if (!ENV_FILE.test(name) || ENV_TEMPLATE.test(name) || envSeen.has(f)) continue;
- 204 envSeen.add(f);
- 205 if (committed(f))
- 206 add({
- 207 severity: "high",
- 208 rule: "env-file-committed",
- 209 file: f,
- 210 line: 0,
- 211 message: "This env file is committed to git. Anyone with the repo has every value in it.",
- 212 fix: `git rm --cached ${f}, add it to .gitignore, then rotate every key in it.`,
- 213 });
- 214 else
- 215 add({
- 216 severity: "high",
- 217 rule: "env-file-not-ignored",
- 218 file: f,
- 219 line: 0,
- 220 message: "This env file is not in .gitignore, so the next `git add .` commits it.",
- 221 fix: "Add .env* (and !.env.example) to .gitignore.",
- 222 });
- 223 }
- 224 for (const f of files.history) {
- 225 const name = basename(f);
- 226 if (!ENV_FILE.test(name) || ENV_TEMPLATE.test(name)) continue;
- 227 if (committed(f)) continue;
- 228 add({
- 229 severity: "high",
- 230 rule: "env-file-in-history",
- 231 file: f,
- 232 line: 0,
- 233 message: "This env file was committed in the past. Deleting it later does not remove it from git history.",
- 234 fix: "Rotate every key that was in it. Rewriting history is optional; rotating is not.",
- 235 });
- 236 }
- 237 if (!files.hasGitignore)
- 238 add({
- 239 severity: "medium",
- 240 rule: "no-gitignore",
- 241 file: ".gitignore",
- 242 line: 0,
- 243 message: "No .gitignore with rules. Env files, build output, and node_modules can all end up committed.",
- 244 fix: "Create .gitignore with at least: .env*, !.env.example, node_modules, .next, .vercel",
- 245 });
- 247 // 2. file contents: tracked and about-to-be-tracked files, plus local env files for public-prefix checks only
- 248 const contentFiles = new Set([...files.tracked, ...files.untracked]);
- 249 const localEnv = new Set([...files.ignoredEnv]);
- 250 for (const f of [...contentFiles, ...localEnv]) {
- 251 if (SKIP_FILES.test(f) || BINARY_EXT.test(f)) continue;
- 252 const full = join(root, f);
- 253 let text;
- 254 try {
- 255 if (!statSync(full).isFile() || statSync(full).size > MAX_BYTES) continue;
- 256 text = readFileSync(full, "utf8");
- 257 } catch {
- 258 continue;
- 259 }
- 260 if (text.includes("\0")) continue;
- 261 const ignoredLocal = localEnv.has(f) && !contentFiles.has(f);
- 262 const isEnv = ENV_FILE.test(basename(f));
- 263 const isDoc = /\.(?:md|mdx|txt|rst)$/i.test(f);
- 264 const lines = text.split(/\r?\n/);
- 265 lines.forEach((line, i) => {
- 266 const lineNo = i + 1;
- 267 // Browser-exposed names: these leak at build time even from an ignored .env.local.
- 268 for (const m of isDoc ? [] : line.matchAll(PUBLIC_PREFIX)) {
- 269 const [full, prefix, rest] = m;
- 270 if (PUBLIC_OK.test(rest)) continue;
- 271 let severity = null;
- 272 if (PUBLIC_HIGH.test(rest)) severity = "high";
- 273 else if (SERVER_ONLY_PROVIDER.test(rest) && /(?:KEY|TOKEN|SECRET)/.test(rest)) severity = "high";
- 274 else if (/(?:API_KEY|_TOKEN|_KEY$)/.test(rest)) severity = "low";
- 275 if (!severity) continue;
- 276 if (findings.some((x) => x.rule === "public-env-secret" && x.target === full && x.file === f)) continue;
- 277 add({
- 278 severity,
- 279 rule: "public-env-secret",
- 280 target: full,
- 281 file: f,
- 282 line: lineNo,
- 283 message:
- 284 severity === "high"
- 285 ? `${full} is bundled into the browser (the ${prefix} prefix does that). Anyone can read it in the page source.`
- 286 : `${full} is bundled into the browser. Fine only if this key is meant to be public.`,
- 287 fix: `Rename to ${rest} (no ${prefix} prefix), read it only in server code, and rotate it if it was ever deployed.`,
- 288 });
- 289 }
- 290 if (ignoredLocal) return; // values in ignored local env files are where secrets belong
- 292 for (const p of PROVIDERS) {
- 293 for (const m of line.matchAll(p.re)) {
- 294 add({
- 295 severity: p.severity,
- 296 rule: p.rule,
- 297 file: f,
- 298 line: lineNo,
- 299 hint: redact(m[0]),
- 300 message:
- 301 p.severity === "low"
- 302 ? `Found a ${p.what}. It cannot move real money, but keys belong in env vars, not code.`
- 303 : `Found a ${p.what} ${committed(f) ? "in a committed file" : "in a file git will commit"}.`,
- 304 fix: "Move it to an env var, then rotate it in the provider's dashboard. Assume a committed key is already copied.",
- 305 });
- 306 }
- 307 }
- 308 for (const m of line.matchAll(JWT)) {
- 309 const role = jwtRole(m[0]);
- 310 if (role !== "service_role") continue;
- 311 add({
- 312 severity: "high",
- 313 rule: "supabase-service-role-jwt",
- 314 file: f,
- 315 line: lineNo,
- 316 hint: redact(m[0]),
- 317 message: "A Supabase service_role key. It bypasses every RLS policy.",
- 318 fix: "Keep it in a server-only env var (no public prefix) and rotate it.",
- 319 });
- 320 }
- 321 if (isEnv) {
- 322 const em = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*["']?([^"'#\s]*)/.exec(line);
- 323 if (em && em[2] && SECRET_NAME.test(em[1]) && !PLACEHOLDER.test(em[2]) && !ENV_TEMPLATE.test(basename(f)))
- 324 add({
- 325 severity: committed(f) ? "high" : "medium",
- 326 rule: "env-value",
- 327 target: em[1],
- 328 file: f,
- 329 line: lineNo,
- 330 hint: redact(em[2]),
- 331 message: `${em[1]} has a value in an env file git ${committed(f) ? "tracks" : "will pick up"}.`,
- 332 fix: "Keep real values only in ignored files (.env.local) and in your host's env settings.",
- 333 });
- 334 else if (em && em[2] && SECRET_NAME.test(em[1]) && !PLACEHOLDER.test(em[2]) && ENV_TEMPLATE.test(basename(f)))
- 335 add({
- 336 severity: "medium",
- 337 rule: "template-has-value",
- 338 target: em[1],
- 339 file: f,
- 340 line: lineNo,
- 341 hint: redact(em[2]),
- 342 message: `${em[1]} has what looks like a real value in a template file. Templates should hold empty values or placeholders.`,
- 343 fix: `Set it to ${em[1]}= and rotate the value if it was real.`,
- 344 });
- 345 return;
- 346 }
- 347 for (const m of line.matchAll(ASSIGN)) {
- 348 const [, name, , value] = m;
- 349 if (!SECRET_NAME.test(name) || PLACEHOLDER.test(value)) continue;
- 350 if (PROVIDERS.some((p) => new RegExp(p.re.source).test(value))) continue; // already reported
- 351 if (/^(?:https?:|\/|\.\/|[A-Z_]+$)/.test(value)) continue; // URLs, paths, env var names
- 352 add({
- 353 severity: "medium",
- 354 rule: "hardcoded-secret",
- 355 target: name,
- 356 file: f,
- 357 line: lineNo,
- 358 hint: redact(value),
- 359 message: `${name} is set to a hardcoded value in code.`,
- 360 fix: `Read it from an env var instead, for example process.env.${name.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase()}.`,
- 361 });
- 362 }
- 363 });
- 364 }
- 366 findings.sort(
- 367 (a, b) => SEVERITY.indexOf(a.severity) - SEVERITY.indexOf(b.severity) || a.file.localeCompare(b.file) || a.line - b.line,
- 368 );
- 369 const counts = Object.fromEntries(SEVERITY.map((s) => [s, findings.filter((f) => f.severity === s).length]));
- 370 return {
- 371 summary: { mode: files.mode, files_scanned: contentFiles.size, ...counts },
- 372 findings,
- 373 };
- 374}
- 376function toMarkdown(report) {
- 377 const s = report.summary;
- 378 const lines = [
- 379 "# Secrets check",
- 380 "",
- 381 `Scanned ${s.files_scanned} files (${s.mode === "git" ? "git-tracked and new files" : "no git found, used .gitignore rules"}).`,
- 382 `High: ${s.high} | Medium: ${s.medium} | Low: ${s.low}`,
- 383 "",
- 384 ];
- 385 for (const sev of SEVERITY) {
- 386 const list = report.findings.filter((f) => f.severity === sev);
- 387 if (!list.length) continue;
- 388 lines.push(`## ${sev[0].toUpperCase()}${sev.slice(1)}`, "");
- 389 for (const f of list) {
- 390 const where = f.line ? `${f.file}:${f.line}` : f.file;
- 391 lines.push(`- **${where}** (${f.rule}${f.hint ? `, ${f.hint}` : ""}): ${f.message}`);
- 392 if (f.fix) lines.push(` - Fix: ${f.fix}`);
- 393 }
- 394 lines.push("");
- 395 }
- 396 if (!report.findings.length) lines.push("No findings. Nothing secret-shaped is headed for git.", "");
- 397 return lines.join("\n");
- 398}
- 400function write(path, text) {
- 401 mkdirSync(dirname(path), { recursive: true });
- 402 writeFileSync(path, text);
- 403}
- 405function main() {
- 406 const args = parseArgs(process.argv.slice(2));
- 407 if (!existsSync(args.root) || !statSync(args.root).isDirectory()) {
- 408 console.error(`No folder at ${args.root}.`);
- 409 process.exit(2);
- 410 }
- 411 const report = scan(args.root);
- 412 const md = toMarkdown(report);
- 413 if (args.json) write(args.json, JSON.stringify(report, null, 2) + "\n");
- 414 if (args.md) write(args.md, md);
- 415 if (!args.json && !args.md) console.log(md);
- 416 else console.log(`High ${report.summary.high}, medium ${report.summary.medium}, low ${report.summary.low}.`);
- 417}
- 419main();
