shipready 1.5.1 → 1.5.3

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
 
@@ -61,7 +61,7 @@ export function findRealLookingExampleValues(content) {
61
61
  if (eq <= 0)
62
62
  continue;
63
63
  const key = line.slice(0, eq).trim();
64
- let value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
64
+ const value = line.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
65
65
  if (!value)
66
66
  continue;
67
67
  const lower = value.toLowerCase();
@@ -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,6 +236,7 @@ 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,
@@ -238,6 +248,7 @@ const PATTERNS = [
238
248
  // benchmarking against gitleaks. Anchored to line start so ordinary
239
249
  // code assignments (always quoted) don't double-match.
240
250
  kind: "Hardcoded credential",
251
+ generic: true,
241
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*$/,
242
253
  confidence: "medium",
243
254
  group: 1,
@@ -284,6 +295,15 @@ export function maskSecret(secret) {
284
295
  const suffix = secret.slice(-4);
285
296
  return `${prefix}...${suffix}`;
286
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,})["']/;
287
307
  /** Scans a single file's content for potential secrets. */
288
308
  export function scanContentForSecrets(content, file, allowlist = []) {
289
309
  const found = [];
@@ -297,43 +317,81 @@ export function scanContentForSecrets(content, file, allowlist = []) {
297
317
  // Documentation/example lines are not real leaks.
298
318
  if (PLACEHOLDER_LINE_RE.test(line))
299
319
  continue;
300
- for (const pattern of PATTERNS) {
301
- const match = line.match(pattern.re);
302
- if (!match)
303
- continue;
304
- const value = match[pattern.group ?? 0];
305
- if (!pattern.skipValueChecks) {
306
- 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))
307
332
  continue;
308
- if (pattern.minEntropy !== undefined &&
309
- 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))) {
310
344
  continue;
311
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
+ });
312
369
  }
313
- // User-configured false positives (substring match on the value or line)
314
- if (allowlist.some((a) => match[0].includes(a) || line.includes(a))) {
315
- continue;
316
- }
317
- let effective = pattern.confidence;
318
- if (pattern.hostGroup !== undefined) {
319
- // Scaffolding defaults like root:password@localhost are not leaks.
320
- if (COMMON_DEV_PASSWORD_RE.test(value))
321
- continue;
322
- const host = match[pattern.hostGroup] ?? "";
323
- if (LOCAL_HOST_RE.test(host))
324
- 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
+ }
325
394
  }
326
- const confidence = isTestPath ? "medium" : effective;
327
- found.push({
328
- kind: pattern.kind,
329
- file,
330
- line: i + 1,
331
- masked: maskSecret(value),
332
- confidence,
333
- // Raw value stays in memory only; used by --verify, never printed.
334
- raw: value,
335
- });
336
- break; // one finding per line is enough
337
395
  }
338
396
  }
339
397
  return found;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipready",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
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",
@@ -59,9 +59,12 @@
59
59
  "picocolors": "^1.1.1"
60
60
  },
61
61
  "devDependencies": {
62
+ "@eslint/js": "^10.0.1",
62
63
  "@types/node": "^22.10.2",
64
+ "eslint": "^10.7.0",
63
65
  "tsx": "^4.19.2",
64
66
  "typescript": "^5.7.2",
67
+ "typescript-eslint": "^8.63.0",
65
68
  "vitest": "^2.1.8"
66
69
  }
67
70
  }