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 +16 -3
- package/dist/checks/secrets.js +101 -32
- package/dist/scanner.js +11 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/formalness/shipready/actions/workflows/ci.yml)
|
|
6
6
|
[](https://www.npmjs.com/package/shipready)
|
|
7
|
-
[](https://github.com/formalness/shipready/tree/main/tests)
|
|
8
7
|
[](./LICENSE)
|
|
9
8
|
[](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
|
-
|
|
|
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
|
-
- [
|
|
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
|
|
package/dist/checks/secrets.js
CHANGED
|
@@ -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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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 (!
|
|
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.
|
|
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",
|