shipready 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,6 @@
4
4
 
5
5
  [![CI](https://github.com/formalness/shipready/actions/workflows/ci.yml/badge.svg)](https://github.com/formalness/shipready/actions/workflows/ci.yml)
6
6
  [![npm version](https://img.shields.io/npm/v/shipready.svg)](https://www.npmjs.com/package/shipready)
7
- [![tests](https://img.shields.io/badge/tests-179%20passing-brightgreen.svg)](https://github.com/formalness/shipready/tree/main/tests)
8
7
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
8
  [![node >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
10
9
 
@@ -215,7 +214,8 @@ The scanner combines provider-specific patterns with **Shannon entropy analysis*
215
214
  | Stripe (test) | `sk_test_`, `pk_test_` |
216
215
  | JWT | `eyJ...` with valid structure |
217
216
  | Generic credential | `API_KEY=`, `SECRET=`, `PASSWORD=` assignments — only when the value has high entropy |
218
- | Any finding in test/fixture/mock files | automatically downgraded |
217
+ | Generic-pattern findings (opaque assignments, URLs, JWTs) in test/fixture/mock files | automatically downgraded |
218
+ | Provider-prefixed keys (`AKIA...`, `ghp_...`, `sk-ant-...`) in test files | kept at full severity |
219
219
 
220
220
  False-positive protection:
221
221
 
@@ -363,13 +363,26 @@ pnpm dev # run the CLI from source (tsx)
363
363
 
364
364
  See [CONTRIBUTING.md](./CONTRIBUTING.md) for project structure and how to add new checks or secret patterns.
365
365
 
366
+ ## How does it compare to gitleaks?
367
+
368
+ Measured head-to-head on a corpus of 7 real-format leaks and 10 false-positive baits (gitleaks v8.24.3):
369
+
370
+ | | shipready | gitleaks |
371
+ | --- | --- | --- |
372
+ | Real leaks caught | **7 / 7** | 4 / 7 |
373
+ | Error-level false positives | **0** | 1 |
374
+ | Newer key formats (`sk-proj-`, `sk-ant-`) | ✓ | missed |
375
+ | Committed `.env.backup` | ✓ | partial |
376
+
377
+ gitleaks remains more battle-tested for deep git-history forensics with hundreds of rules. shipready covers the leaks that actually happen in modern AI-assisted projects — plus README, env hygiene, and code hygiene that gitleaks doesn't check at all. Full methodology, fairness notes, and reproduction steps: [BENCHMARK.md](./BENCHMARK.md).
378
+
366
379
  ## Roadmap
367
380
 
368
381
  - [x] `shipready staged` for pre-commit hooks
369
382
  - [x] SARIF report output for GitHub code scanning
370
383
  - [x] Git history scanning (`--history`)
371
384
  - [x] Live key verification (`--verify`)
372
- - [ ] Benchmark against gitleaks/trufflehog with published numbers
385
+ - [x] Benchmark against gitleaks with published numbers ([BENCHMARK.md](./BENCHMARK.md))
373
386
  - [ ] Framework-aware scoring (Next.js vs Vite vs Python have different needs)
374
387
  - [ ] Optional AI-powered fix suggestions (bring your own key)
375
388
 
@@ -76,11 +76,13 @@ const PATTERNS = [
76
76
  },
77
77
  {
78
78
  kind: "Twilio credential",
79
+ generic: true,
79
80
  re: /\b(?:AC|SK)[a-f0-9]{32}\b/,
80
81
  confidence: "high",
81
82
  },
82
83
  {
83
84
  kind: "Telegram bot token",
85
+ generic: true,
84
86
  re: /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/,
85
87
  confidence: "high",
86
88
  },
@@ -107,11 +109,13 @@ const PATTERNS = [
107
109
  },
108
110
  {
109
111
  kind: "Mailchimp key",
112
+ generic: true,
110
113
  re: /\b[a-f0-9]{32}-us\d{1,2}\b/,
111
114
  confidence: "high",
112
115
  },
113
116
  {
114
117
  kind: "Mailgun key",
118
+ generic: true,
115
119
  re: /\bkey-[a-f0-9]{32}\b/,
116
120
  confidence: "high",
117
121
  },
@@ -189,6 +193,7 @@ const PATTERNS = [
189
193
  },
190
194
  {
191
195
  kind: "Discord bot token",
196
+ generic: true,
192
197
  re: /\b[MNO][A-Za-z\d_-]{23,25}\.[A-Za-z\d_-]{6}\.[A-Za-z\d_-]{27,}\b/,
193
198
  confidence: "medium",
194
199
  minEntropy: 4.0,
@@ -198,6 +203,7 @@ const PATTERNS = [
198
203
  // documentation hosts like db.example.com don't suppress real leaks.
199
204
  // The host (group 2) downgrades localhost URLs to medium confidence.
200
205
  kind: "Database URL with password",
206
+ generic: true,
201
207
  re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|rediss|amqp):\/\/[^:\s"'@/]+:([^@\s"']+)@([^\s"'/:?]+)/,
202
208
  confidence: "high",
203
209
  group: 1,
@@ -205,6 +211,7 @@ const PATTERNS = [
205
211
  },
206
212
  {
207
213
  kind: "Basic auth in URL",
214
+ generic: true,
208
215
  re: /https?:\/\/[^:\s"'/@]{3,}:([^@\s"']{8,})@([^\s"'/:?]+)/,
209
216
  confidence: "medium",
210
217
  group: 1,
@@ -213,12 +220,14 @@ const PATTERNS = [
213
220
  },
214
221
  {
215
222
  kind: "JWT",
223
+ generic: true,
216
224
  re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
217
225
  confidence: "medium",
218
226
  minEntropy: 4.0,
219
227
  },
220
228
  {
221
229
  kind: "Authorization header",
230
+ generic: true,
222
231
  re: /\b(?:authorization|x-api-key)["']?\s*[:=]\s*["']?(?:Bearer|Basic|token)\s+([A-Za-z0-9+/_.=-]{20,})\b/i,
223
232
  confidence: "medium",
224
233
  group: 1,
@@ -227,11 +236,24 @@ const PATTERNS = [
227
236
  {
228
237
  // Credential-style assignment with a long opaque quoted value
229
238
  kind: "Hardcoded credential",
239
+ generic: true,
230
240
  re: /\b[A-Z0-9_]*(?:API_?KEY|SECRET(?:_KEY)?|ACCESS_TOKEN|AUTH_TOKEN|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*["']([A-Za-z0-9+/_.=-]{16,})["']/i,
231
241
  confidence: "medium",
232
242
  group: 1,
233
243
  minEntropy: 3.8,
234
244
  },
245
+ {
246
+ // Unquoted dotenv-style assignment (KEY=value at line start). Catches
247
+ // committed .env backups where values carry no quotes - a gap found by
248
+ // benchmarking against gitleaks. Anchored to line start so ordinary
249
+ // code assignments (always quoted) don't double-match.
250
+ kind: "Hardcoded credential",
251
+ generic: true,
252
+ re: /^[A-Z0-9_]*(?:API_?KEY|SECRET(?:_KEY)?|ACCESS_TOKEN|AUTH_TOKEN|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*=([A-Za-z0-9+/_.=-]{16,})\s*$/,
253
+ confidence: "medium",
254
+ group: 1,
255
+ minEntropy: 3.8,
256
+ },
235
257
  ];
236
258
  /** Words and shapes that indicate a placeholder, not a real secret. */
237
259
  const PLACEHOLDER_VALUE_RE = /your[-_ ]?|example|sample|placeholder|change[-_ ]?me|dummy|fake|insert|replace|redacted|deadbeef|lorem|goes[-_ ]?here|test[-_ ]?key|x{4,}|\*{3,}|\.\.\.|123456|abcdef/i;
@@ -273,6 +295,15 @@ export function maskSecret(secret) {
273
295
  const suffix = secret.slice(-4);
274
296
  return `${prefix}...${suffix}`;
275
297
  }
298
+ /**
299
+ * Global-flag copies of each pattern for matchAll. Compiled once - the
300
+ * originals stay non-global so .test()/.match() callers keep working.
301
+ */
302
+ const GLOBAL_PATTERN_RES = PATTERNS.map((p) => new RegExp(p.re.source, p.re.flags.includes("g") ? p.re.flags : `${p.re.flags}g`));
303
+ /** Credential-keyword assignment with no value on the same line. */
304
+ const DANGLING_ASSIGNMENT_RE = /\b[A-Z0-9_]*(?:API_?KEY|SECRET(?:_KEY)?|ACCESS_TOKEN|AUTH_TOKEN|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*\s*[=:]\s*$/i;
305
+ /** Quoted opaque value at the start of a continuation line. */
306
+ const CONTINUATION_VALUE_RE = /^\s*["']([A-Za-z0-9+/_.=-]{16,})["']/;
276
307
  /** Scans a single file's content for potential secrets. */
277
308
  export function scanContentForSecrets(content, file, allowlist = []) {
278
309
  const found = [];
@@ -286,43 +317,81 @@ export function scanContentForSecrets(content, file, allowlist = []) {
286
317
  // Documentation/example lines are not real leaks.
287
318
  if (PLACEHOLDER_LINE_RE.test(line))
288
319
  continue;
289
- for (const pattern of PATTERNS) {
290
- const match = line.match(pattern.re);
291
- if (!match)
292
- continue;
293
- const value = match[pattern.group ?? 0];
294
- if (!pattern.skipValueChecks) {
295
- if (isPlaceholderValue(value))
320
+ // Character spans already claimed by a more specific pattern. Patterns
321
+ // are ordered most specific first, so the first pattern to claim a span
322
+ // wins - but distinct secrets elsewhere on the same line (e.g. two keys
323
+ // in one config object) are still reported.
324
+ const claimed = [];
325
+ const overlapsClaimed = (start, end) => claimed.some(([s, e]) => start < e && end > s);
326
+ for (let pi = 0; pi < PATTERNS.length; pi++) {
327
+ const pattern = PATTERNS[pi];
328
+ for (const match of line.matchAll(GLOBAL_PATTERN_RES[pi])) {
329
+ const start = match.index ?? 0;
330
+ const end = start + match[0].length;
331
+ if (overlapsClaimed(start, end))
296
332
  continue;
297
- if (pattern.minEntropy !== undefined &&
298
- shannonEntropy(value) < pattern.minEntropy) {
333
+ const value = match[pattern.group ?? 0];
334
+ if (!pattern.skipValueChecks) {
335
+ if (isPlaceholderValue(value))
336
+ continue;
337
+ if (pattern.minEntropy !== undefined &&
338
+ shannonEntropy(value) < pattern.minEntropy) {
339
+ continue;
340
+ }
341
+ }
342
+ // User-configured false positives (substring match on the value or line)
343
+ if (allowlist.some((a) => match[0].includes(a) || line.includes(a))) {
299
344
  continue;
300
345
  }
346
+ let effective = pattern.confidence;
347
+ if (pattern.hostGroup !== undefined) {
348
+ // Scaffolding defaults like root:password@localhost are not leaks.
349
+ if (COMMON_DEV_PASSWORD_RE.test(value))
350
+ continue;
351
+ const host = match[pattern.hostGroup] ?? "";
352
+ if (LOCAL_HOST_RE.test(host))
353
+ effective = "medium";
354
+ }
355
+ // Test paths only downgrade shape-based patterns. A provider-prefixed
356
+ // key that survived the entropy/placeholder gates looks real, and a
357
+ // real key in a test fixture is just as leaked as one in src/.
358
+ const confidence = isTestPath && pattern.generic ? "medium" : effective;
359
+ claimed.push([start, end]);
360
+ found.push({
361
+ kind: pattern.kind,
362
+ file,
363
+ line: i + 1,
364
+ masked: maskSecret(value),
365
+ confidence,
366
+ // Raw value stays in memory only; used by --verify, never printed.
367
+ raw: value,
368
+ });
301
369
  }
302
- // User-configured false positives (substring match on the value or line)
303
- if (allowlist.some((a) => match[0].includes(a) || line.includes(a))) {
304
- continue;
305
- }
306
- let effective = pattern.confidence;
307
- if (pattern.hostGroup !== undefined) {
308
- // Scaffolding defaults like root:password@localhost are not leaks.
309
- if (COMMON_DEV_PASSWORD_RE.test(value))
310
- continue;
311
- const host = match[pattern.hostGroup] ?? "";
312
- if (LOCAL_HOST_RE.test(host))
313
- effective = "medium";
370
+ }
371
+ // Multiline credential assignment: keyword on one line, quoted value on
372
+ // the next (formatters wrap long lines this way). Only runs when the
373
+ // value line alone matches no pattern - provider-prefixed values are
374
+ // caught by the normal per-line scan on the next iteration.
375
+ if (i + 1 < lines.length && DANGLING_ASSIGNMENT_RE.test(line)) {
376
+ const next = lines[i + 1];
377
+ const valueMatch = next.match(CONTINUATION_VALUE_RE);
378
+ if (valueMatch &&
379
+ !PLACEHOLDER_LINE_RE.test(next) &&
380
+ !PATTERNS.some((p) => p.re.test(valueMatch[1]))) {
381
+ const value = valueMatch[1];
382
+ if (!isPlaceholderValue(value) &&
383
+ shannonEntropy(value) >= 3.8 &&
384
+ !allowlist.some((a) => value.includes(a) || next.includes(a))) {
385
+ found.push({
386
+ kind: "Hardcoded credential",
387
+ file,
388
+ line: i + 2,
389
+ masked: maskSecret(value),
390
+ confidence: "medium",
391
+ raw: value,
392
+ });
393
+ }
314
394
  }
315
- const confidence = isTestPath ? "medium" : effective;
316
- found.push({
317
- kind: pattern.kind,
318
- file,
319
- line: i + 1,
320
- masked: maskSecret(value),
321
- confidence,
322
- // Raw value stays in memory only; used by --verify, never printed.
323
- raw: value,
324
- });
325
- break; // one finding per line is enough
326
395
  }
327
396
  }
328
397
  return found;
package/dist/scanner.js CHANGED
@@ -42,11 +42,18 @@ export function scanFiles(root, files, secretAllowlist = []) {
42
42
  * an example is just as dangerous - but hygiene noise is skipped.
43
43
  */
44
44
  const HYGIENE_EXEMPT_RE = /(?:^|\/)(?:examples?|demos?|benchmarks?|scripts?|playground)\//;
45
+ /**
46
+ * Standard env file names that legitimately hold real values and are
47
+ * expected to be gitignored - the env check owns those. Anything else
48
+ * that merely starts with ".env" (.env.backup, .env.old, .env.prod)
49
+ * is a copy someone made and MUST be scanned: benchmark testing showed
50
+ * gitleaks catches committed .env backups while we used to skip them.
51
+ */
52
+ const STANDARD_ENV_RE = /^\.env(?:\.(?:local|development|production|test|staging))?(?:\.local)?$/;
45
53
  for (const file of files) {
46
54
  const base = path.basename(file);
47
- // Never flag .env files themselves for secrets/todos; they are expected
48
- // to contain real values and are handled by the env check.
49
- const isEnvFile = base.startsWith(".env");
55
+ const isStandardEnv = STANDARD_ENV_RE.test(base);
56
+ const isEnvTemplate = base === ".env.example" || base === ".env.sample" || base === ".env.template";
50
57
  const ext = path.extname(file).toLowerCase();
51
58
  const isCode = CODE_ONLY.has(ext);
52
59
  if (isProbablyBinary(root, file))
@@ -60,7 +67,7 @@ export function scanFiles(root, files, secretAllowlist = []) {
60
67
  todos.push(...scanContentForTodos(content, file));
61
68
  }
62
69
  }
63
- if (!isEnvFile && base !== ".env.example" && base !== ".env.sample") {
70
+ if (!isStandardEnv && !isEnvTemplate) {
64
71
  secrets.push(...scanContentForSecrets(content, file, secretAllowlist));
65
72
  }
66
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipready",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Pre-flight check for your repo before shipping. Scans AI-coded projects for secrets, env issues, debug leftovers, and generates AI-agent instruction files.",
5
5
  "type": "module",
6
6
  "license": "MIT",