ship-safe 5.0.0 → 6.0.0
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/cli/agents/agentic-security-agent.js +1 -1
- package/cli/agents/api-fuzzer.js +1 -1
- package/cli/agents/auth-bypass-agent.js +2 -2
- package/cli/agents/base-agent.js +2 -1
- package/cli/agents/config-auditor.js +3 -11
- package/cli/agents/exception-handler-agent.js +187 -0
- package/cli/agents/html-reporter.js +511 -370
- package/cli/agents/index.js +6 -0
- package/cli/agents/mcp-security-agent.js +182 -0
- package/cli/agents/pii-compliance-agent.js +301 -301
- package/cli/agents/scoring-engine.js +14 -6
- package/cli/agents/vibe-coding-agent.js +250 -0
- package/cli/bin/ship-safe.js +43 -6
- package/cli/commands/agent.js +6 -4
- package/cli/commands/audit.js +85 -14
- package/cli/commands/baseline.js +3 -2
- package/cli/commands/benchmark.js +327 -0
- package/cli/commands/ci.js +342 -260
- package/cli/commands/deps.js +73 -4
- package/cli/commands/diff.js +200 -0
- package/cli/commands/doctor.js +14 -4
- package/cli/commands/fix.js +218 -216
- package/cli/commands/init.js +349 -349
- package/cli/commands/mcp.js +304 -303
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/remediate.js +155 -7
- package/cli/commands/scan.js +567 -565
- package/cli/commands/score.js +2 -0
- package/cli/commands/vibe-check.js +276 -0
- package/cli/commands/watch.js +161 -160
- package/cli/index.js +8 -1
- package/cli/utils/cache-manager.js +1 -1
- package/cli/utils/output.js +5 -2
- package/cli/utils/patterns.js +1121 -1104
- package/cli/utils/pdf-generator.js +1 -1
- package/package.json +2 -2
package/cli/commands/scan.js
CHANGED
|
@@ -1,565 +1,567 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scan Command
|
|
3
|
-
* ============
|
|
4
|
-
*
|
|
5
|
-
* Scans a directory for leaked secrets using pattern matching + entropy scoring.
|
|
6
|
-
*
|
|
7
|
-
* USAGE:
|
|
8
|
-
* ship-safe scan [path] Scan specified path (default: current directory)
|
|
9
|
-
* ship-safe scan . -v Verbose mode (show files being scanned)
|
|
10
|
-
* ship-safe scan . --json Output as JSON (for CI integration)
|
|
11
|
-
* ship-safe scan . --include-tests Also scan test files (excluded by default)
|
|
12
|
-
*
|
|
13
|
-
* SUPPRESSING FALSE POSITIVES:
|
|
14
|
-
* Add # ship-safe-ignore as a comment on the same line to suppress a finding.
|
|
15
|
-
* Create a .ship-safeignore file (same syntax as .gitignore) to exclude paths.
|
|
16
|
-
*
|
|
17
|
-
* EXIT CODES:
|
|
18
|
-
* 0 - No secrets found
|
|
19
|
-
* 1 - Secrets found (or error)
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import fs from 'fs';
|
|
23
|
-
import path from 'path';
|
|
24
|
-
import fg from 'fast-glob';
|
|
25
|
-
import ora from 'ora';
|
|
26
|
-
import chalk from 'chalk';
|
|
27
|
-
import {
|
|
28
|
-
SECRET_PATTERNS,
|
|
29
|
-
SECURITY_PATTERNS,
|
|
30
|
-
SKIP_DIRS,
|
|
31
|
-
SKIP_EXTENSIONS,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
import
|
|
38
|
-
import
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* "
|
|
53
|
-
* "
|
|
54
|
-
* "
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
let
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
cachedByFile[f.file].
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
*
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
.
|
|
237
|
-
.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
*
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
.replace(
|
|
261
|
-
.replace(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// =============================================================================
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
// =============================================================================
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
console.log(
|
|
424
|
-
console.log(chalk.gray('
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
console.log(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
console.log(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
console.log(
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
// =============================================================================
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
: '
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Scan Command
|
|
3
|
+
* ============
|
|
4
|
+
*
|
|
5
|
+
* Scans a directory for leaked secrets using pattern matching + entropy scoring.
|
|
6
|
+
*
|
|
7
|
+
* USAGE:
|
|
8
|
+
* ship-safe scan [path] Scan specified path (default: current directory)
|
|
9
|
+
* ship-safe scan . -v Verbose mode (show files being scanned)
|
|
10
|
+
* ship-safe scan . --json Output as JSON (for CI integration)
|
|
11
|
+
* ship-safe scan . --include-tests Also scan test files (excluded by default)
|
|
12
|
+
*
|
|
13
|
+
* SUPPRESSING FALSE POSITIVES:
|
|
14
|
+
* Add # ship-safe-ignore as a comment on the same line to suppress a finding.
|
|
15
|
+
* Create a .ship-safeignore file (same syntax as .gitignore) to exclude paths.
|
|
16
|
+
*
|
|
17
|
+
* EXIT CODES:
|
|
18
|
+
* 0 - No secrets found
|
|
19
|
+
* 1 - Secrets found (or error)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import fg from 'fast-glob';
|
|
25
|
+
import ora from 'ora';
|
|
26
|
+
import chalk from 'chalk';
|
|
27
|
+
import {
|
|
28
|
+
SECRET_PATTERNS,
|
|
29
|
+
SECURITY_PATTERNS,
|
|
30
|
+
SKIP_DIRS,
|
|
31
|
+
SKIP_EXTENSIONS,
|
|
32
|
+
SKIP_FILENAMES,
|
|
33
|
+
TEST_FILE_PATTERNS,
|
|
34
|
+
MAX_FILE_SIZE,
|
|
35
|
+
loadGitignorePatterns
|
|
36
|
+
} from '../utils/patterns.js';
|
|
37
|
+
import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
|
|
38
|
+
import * as output from '../utils/output.js';
|
|
39
|
+
import { CacheManager } from '../utils/cache-manager.js';
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// CUSTOM PATTERNS (.ship-safe.json)
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load custom patterns from .ship-safe.json in the project root.
|
|
47
|
+
*
|
|
48
|
+
* Format:
|
|
49
|
+
* {
|
|
50
|
+
* "patterns": [
|
|
51
|
+
* {
|
|
52
|
+
* "name": "My Internal Key",
|
|
53
|
+
* "pattern": "MYAPP_[A-Z0-9]{32}",
|
|
54
|
+
* "severity": "high",
|
|
55
|
+
* "description": "Internal API key for myapp services."
|
|
56
|
+
* }
|
|
57
|
+
* ]
|
|
58
|
+
* }
|
|
59
|
+
*/
|
|
60
|
+
function loadCustomPatterns(rootPath) {
|
|
61
|
+
const configPath = path.join(rootPath, '.ship-safe.json');
|
|
62
|
+
if (!fs.existsSync(configPath)) return [];
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
66
|
+
if (!Array.isArray(config.patterns)) return [];
|
|
67
|
+
|
|
68
|
+
return config.patterns
|
|
69
|
+
.filter(p => p.name && p.pattern)
|
|
70
|
+
.map(p => ({
|
|
71
|
+
name: `[custom] ${p.name}`,
|
|
72
|
+
pattern: new RegExp(p.pattern, 'g'),
|
|
73
|
+
severity: p.severity || 'high',
|
|
74
|
+
description: p.description || `Custom pattern: ${p.name}`,
|
|
75
|
+
custom: true,
|
|
76
|
+
}));
|
|
77
|
+
} catch (err) {
|
|
78
|
+
output.warning(`.ship-safe.json parse error: ${err.message}`);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// =============================================================================
|
|
84
|
+
// MAIN SCAN FUNCTION
|
|
85
|
+
// =============================================================================
|
|
86
|
+
|
|
87
|
+
export async function scanCommand(targetPath = '.', options = {}) {
|
|
88
|
+
const absolutePath = path.resolve(targetPath);
|
|
89
|
+
|
|
90
|
+
// Validate path exists
|
|
91
|
+
if (!fs.existsSync(absolutePath)) {
|
|
92
|
+
output.error(`Path does not exist: ${absolutePath}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Load .ship-safeignore patterns
|
|
97
|
+
const ignorePatterns = loadIgnoreFile(absolutePath);
|
|
98
|
+
|
|
99
|
+
// Load custom patterns from .ship-safe.json
|
|
100
|
+
const customPatterns = loadCustomPatterns(absolutePath);
|
|
101
|
+
const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
|
|
102
|
+
|
|
103
|
+
if (customPatterns.length > 0 && options.verbose) {
|
|
104
|
+
output.info(`Loaded ${customPatterns.length} custom pattern(s) from .ship-safe.json`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Start spinner
|
|
108
|
+
const spinner = ora({
|
|
109
|
+
text: 'Scanning for secrets and vulnerabilities...',
|
|
110
|
+
color: 'cyan'
|
|
111
|
+
}).start();
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Find all files
|
|
115
|
+
const files = await findFiles(absolutePath, ignorePatterns, options);
|
|
116
|
+
|
|
117
|
+
// Cache: determine which files changed
|
|
118
|
+
const useCache = options.cache !== false;
|
|
119
|
+
const cache = new CacheManager(absolutePath);
|
|
120
|
+
const cacheData = useCache ? cache.load() : null;
|
|
121
|
+
let filesToScan = files;
|
|
122
|
+
let cacheDiff = null;
|
|
123
|
+
const cachedResults = [];
|
|
124
|
+
|
|
125
|
+
if (cacheData) {
|
|
126
|
+
cacheDiff = cache.diff(files);
|
|
127
|
+
filesToScan = cacheDiff.changedFiles;
|
|
128
|
+
|
|
129
|
+
// Group cached findings by file
|
|
130
|
+
const cachedByFile = {};
|
|
131
|
+
for (const f of cacheDiff.cachedFindings) {
|
|
132
|
+
if (!cachedByFile[f.file]) cachedByFile[f.file] = [];
|
|
133
|
+
cachedByFile[f.file].push({
|
|
134
|
+
line: f.line,
|
|
135
|
+
column: f.column,
|
|
136
|
+
matched: f.matched,
|
|
137
|
+
patternName: f.rule || f.title,
|
|
138
|
+
severity: f.severity,
|
|
139
|
+
confidence: f.confidence,
|
|
140
|
+
description: f.description,
|
|
141
|
+
category: f.category,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
for (const [file, findings] of Object.entries(cachedByFile)) {
|
|
145
|
+
cachedResults.push({ file, findings });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const cacheNote = cacheDiff && filesToScan.length < files.length
|
|
150
|
+
? ` (${filesToScan.length} changed, ${cacheDiff.unchangedCount} cached)`
|
|
151
|
+
: '';
|
|
152
|
+
spinner.text = `Scanning ${filesToScan.length} files${cacheNote}...`;
|
|
153
|
+
|
|
154
|
+
// Scan each file
|
|
155
|
+
const results = [];
|
|
156
|
+
let scannedCount = 0;
|
|
157
|
+
|
|
158
|
+
for (const file of filesToScan) {
|
|
159
|
+
const findings = await scanFile(file, allPatterns);
|
|
160
|
+
if (findings.length > 0) {
|
|
161
|
+
results.push({ file, findings });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
scannedCount++;
|
|
165
|
+
if (options.verbose) {
|
|
166
|
+
spinner.text = `Scanned ${scannedCount}/${filesToScan.length}: ${path.relative(absolutePath, file)}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Merge with cached results
|
|
171
|
+
const allResults = [...results, ...cachedResults];
|
|
172
|
+
|
|
173
|
+
// Save cache
|
|
174
|
+
if (useCache) {
|
|
175
|
+
try {
|
|
176
|
+
const allFindings = [];
|
|
177
|
+
for (const { file, findings } of allResults) {
|
|
178
|
+
for (const f of findings) {
|
|
179
|
+
allFindings.push({
|
|
180
|
+
file,
|
|
181
|
+
line: f.line,
|
|
182
|
+
column: f.column,
|
|
183
|
+
severity: f.severity,
|
|
184
|
+
category: f.category || 'secrets',
|
|
185
|
+
rule: f.patternName,
|
|
186
|
+
title: f.patternName,
|
|
187
|
+
description: f.description,
|
|
188
|
+
matched: f.matched,
|
|
189
|
+
confidence: f.confidence,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
cache.save(files, allFindings, null, null);
|
|
194
|
+
} catch {
|
|
195
|
+
// Silent
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
spinner.stop();
|
|
200
|
+
|
|
201
|
+
// Output results
|
|
202
|
+
if (options.sarif) {
|
|
203
|
+
outputSARIF(allResults, absolutePath);
|
|
204
|
+
} else if (options.json) {
|
|
205
|
+
outputJSON(allResults, files.length);
|
|
206
|
+
} else {
|
|
207
|
+
outputPretty(allResults, files.length, absolutePath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Exit with appropriate code
|
|
211
|
+
const hasFindings = allResults.length > 0;
|
|
212
|
+
process.exit(hasFindings ? 1 : 0);
|
|
213
|
+
|
|
214
|
+
} catch (err) {
|
|
215
|
+
spinner.fail('Scan failed');
|
|
216
|
+
output.error(err.message);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// =============================================================================
|
|
222
|
+
// .SHIP-SAFEIGNORE LOADING
|
|
223
|
+
// =============================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Load ignore patterns from .ship-safeignore file.
|
|
227
|
+
* Same syntax as .gitignore — glob patterns, one per line, # for comments.
|
|
228
|
+
*/
|
|
229
|
+
function loadIgnoreFile(rootPath) {
|
|
230
|
+
const ignorePath = path.join(rootPath, '.ship-safeignore');
|
|
231
|
+
|
|
232
|
+
if (!fs.existsSync(ignorePath)) return [];
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return fs.readFileSync(ignorePath, 'utf-8')
|
|
236
|
+
.split('\n')
|
|
237
|
+
.map(line => line.trim())
|
|
238
|
+
.filter(line => line && !line.startsWith('#'));
|
|
239
|
+
} catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if a file path matches any ignore pattern.
|
|
246
|
+
* Supports: exact paths, glob patterns, and directory prefixes.
|
|
247
|
+
*/
|
|
248
|
+
function isIgnoredByFile(filePath, rootPath, ignorePatterns) {
|
|
249
|
+
if (ignorePatterns.length === 0) return false;
|
|
250
|
+
|
|
251
|
+
const relPath = path.relative(rootPath, filePath).replace(/\\/g, '/');
|
|
252
|
+
|
|
253
|
+
return ignorePatterns.some(pattern => {
|
|
254
|
+
// Directory prefix match: "tests/" ignores everything under tests/
|
|
255
|
+
if (pattern.endsWith('/')) {
|
|
256
|
+
return relPath.startsWith(pattern) || relPath.includes('/' + pattern);
|
|
257
|
+
}
|
|
258
|
+
// Simple glob: "**/fixtures/**" or "src/secrets.js"
|
|
259
|
+
const escaped = pattern
|
|
260
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
261
|
+
.replace(/\*/g, '[^/]*')
|
|
262
|
+
.replace(/\?/g, '[^/]');
|
|
263
|
+
return new RegExp(`(^|/)${escaped}($|/)`).test(relPath);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// =============================================================================
|
|
268
|
+
// FILE DISCOVERY
|
|
269
|
+
// =============================================================================
|
|
270
|
+
|
|
271
|
+
async function findFiles(rootPath, ignorePatterns, options = {}) {
|
|
272
|
+
// Build ignore patterns from SKIP_DIRS
|
|
273
|
+
const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
|
|
274
|
+
|
|
275
|
+
// Respect .gitignore patterns
|
|
276
|
+
const gitignoreGlobs = loadGitignorePatterns(rootPath);
|
|
277
|
+
globIgnore.push(...gitignoreGlobs);
|
|
278
|
+
|
|
279
|
+
// Find all files
|
|
280
|
+
const files = await fg('**/*', {
|
|
281
|
+
cwd: rootPath,
|
|
282
|
+
absolute: true,
|
|
283
|
+
onlyFiles: true,
|
|
284
|
+
ignore: globIgnore,
|
|
285
|
+
dot: true
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const filtered = [];
|
|
289
|
+
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
// Skip by extension
|
|
292
|
+
const ext = path.extname(file).toLowerCase();
|
|
293
|
+
if (SKIP_EXTENSIONS.has(ext)) continue;
|
|
294
|
+
if (SKIP_FILENAMES.has(path.basename(file))) continue;
|
|
295
|
+
|
|
296
|
+
// Handle compound extensions like .min.js
|
|
297
|
+
const basename = path.basename(file);
|
|
298
|
+
if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) continue;
|
|
299
|
+
|
|
300
|
+
// Skip test files by default (--include-tests to override)
|
|
301
|
+
if (!options.includeTests && isTestFile(file)) continue;
|
|
302
|
+
|
|
303
|
+
// Skip files matching .ship-safeignore
|
|
304
|
+
if (isIgnoredByFile(file, rootPath, ignorePatterns)) continue;
|
|
305
|
+
|
|
306
|
+
// Skip by size
|
|
307
|
+
try {
|
|
308
|
+
const stats = fs.statSync(file);
|
|
309
|
+
if (stats.size > MAX_FILE_SIZE) continue;
|
|
310
|
+
} catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
filtered.push(file);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return filtered;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isTestFile(filePath) {
|
|
321
|
+
return TEST_FILE_PATTERNS.some(pattern => pattern.test(filePath));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// =============================================================================
|
|
325
|
+
// FILE SCANNING
|
|
326
|
+
// =============================================================================
|
|
327
|
+
|
|
328
|
+
async function scanFile(filePath, patterns = SECRET_PATTERNS) {
|
|
329
|
+
const findings = [];
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
333
|
+
const lines = content.split('\n');
|
|
334
|
+
|
|
335
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
336
|
+
const line = lines[lineNum];
|
|
337
|
+
|
|
338
|
+
// Inline suppression: # ship-safe-ignore on the same line
|
|
339
|
+
if (/ship-safe-ignore/i.test(line)) continue;
|
|
340
|
+
|
|
341
|
+
for (const pattern of patterns) {
|
|
342
|
+
// Reset regex state (important for global regexes)
|
|
343
|
+
pattern.pattern.lastIndex = 0;
|
|
344
|
+
|
|
345
|
+
let match;
|
|
346
|
+
while ((match = pattern.pattern.exec(line)) !== null) {
|
|
347
|
+
// For generic patterns, apply entropy check to filter placeholders
|
|
348
|
+
if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const confidence = getConfidence(pattern, match[0]);
|
|
353
|
+
|
|
354
|
+
findings.push({
|
|
355
|
+
line: lineNum + 1,
|
|
356
|
+
column: match.index + 1,
|
|
357
|
+
matched: match[0],
|
|
358
|
+
patternName: pattern.name,
|
|
359
|
+
severity: pattern.severity,
|
|
360
|
+
confidence,
|
|
361
|
+
description: pattern.description,
|
|
362
|
+
category: pattern.category || 'secret'
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Skip files that can't be read (binary, permissions, etc.)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Deduplicate: multiple patterns can match the same secret on the same line
|
|
372
|
+
// (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
|
|
373
|
+
// unique (line, matched-text) pair — first match wins (patterns are ordered
|
|
374
|
+
// by severity: critical → high → medium).
|
|
375
|
+
const seen = new Set();
|
|
376
|
+
return findings.filter(f => {
|
|
377
|
+
const key = `${f.line}:${f.matched}`;
|
|
378
|
+
if (seen.has(key)) return false;
|
|
379
|
+
seen.add(key);
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// =============================================================================
|
|
385
|
+
// OUTPUT FORMATTING
|
|
386
|
+
// =============================================================================
|
|
387
|
+
|
|
388
|
+
function outputPretty(results, filesScanned, rootPath) {
|
|
389
|
+
// Separate findings into secrets and code vulnerabilities
|
|
390
|
+
const secretResults = [];
|
|
391
|
+
const vulnResults = [];
|
|
392
|
+
|
|
393
|
+
for (const { file, findings } of results) {
|
|
394
|
+
const secrets = findings.filter(f => f.category !== 'vulnerability');
|
|
395
|
+
const vulns = findings.filter(f => f.category === 'vulnerability');
|
|
396
|
+
if (secrets.length > 0) secretResults.push({ file, findings: secrets });
|
|
397
|
+
if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const stats = {
|
|
401
|
+
total: 0,
|
|
402
|
+
critical: 0,
|
|
403
|
+
high: 0,
|
|
404
|
+
medium: 0,
|
|
405
|
+
secretsTotal: 0,
|
|
406
|
+
vulnsTotal: 0,
|
|
407
|
+
filesScanned
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
for (const { findings } of results) {
|
|
411
|
+
for (const f of findings) {
|
|
412
|
+
stats.total++;
|
|
413
|
+
stats[f.severity] = (stats[f.severity] || 0) + 1;
|
|
414
|
+
if (f.category === 'vulnerability') stats.vulnsTotal++;
|
|
415
|
+
else stats.secretsTotal++;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
output.header('Scan Results');
|
|
420
|
+
|
|
421
|
+
if (results.length === 0) {
|
|
422
|
+
output.success('No secrets or vulnerabilities detected in your codebase!');
|
|
423
|
+
console.log();
|
|
424
|
+
console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
|
|
425
|
+
console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
|
|
426
|
+
console.log(chalk.gray('Tip: Add a .ship-safeignore file to exclude paths.'));
|
|
427
|
+
} else {
|
|
428
|
+
// ── Secrets section ────────────────────────────────────────────────────
|
|
429
|
+
if (secretResults.length > 0) {
|
|
430
|
+
console.log();
|
|
431
|
+
console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
|
|
432
|
+
console.log(chalk.red(' ' + '─'.repeat(58)));
|
|
433
|
+
|
|
434
|
+
for (const { file, findings } of secretResults) {
|
|
435
|
+
const relPath = path.relative(rootPath, file);
|
|
436
|
+
for (const f of findings) {
|
|
437
|
+
output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Code Vulnerabilities section ───────────────────────────────────────
|
|
443
|
+
if (vulnResults.length > 0) {
|
|
444
|
+
console.log();
|
|
445
|
+
console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
|
|
446
|
+
console.log(chalk.yellow(' ' + '─'.repeat(58)));
|
|
447
|
+
|
|
448
|
+
for (const { file, findings } of vulnResults) {
|
|
449
|
+
const relPath = path.relative(rootPath, file);
|
|
450
|
+
for (const f of findings) {
|
|
451
|
+
output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Remind about suppressions
|
|
457
|
+
console.log();
|
|
458
|
+
console.log(chalk.gray('Suppress a finding: add # ship-safe-ignore as a comment on that line'));
|
|
459
|
+
console.log(chalk.gray('Exclude a path: add it to .ship-safeignore'));
|
|
460
|
+
|
|
461
|
+
if (secretResults.length > 0) output.recommendations();
|
|
462
|
+
if (vulnResults.length > 0) output.vulnRecommendations();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
output.summary(stats);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function outputJSON(results, filesScanned) {
|
|
469
|
+
const jsonOutput = {
|
|
470
|
+
success: results.length === 0,
|
|
471
|
+
filesScanned,
|
|
472
|
+
totalFindings: 0,
|
|
473
|
+
findings: []
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
for (const { file, findings } of results) {
|
|
477
|
+
for (const f of findings) {
|
|
478
|
+
jsonOutput.totalFindings++;
|
|
479
|
+
jsonOutput.findings.push({
|
|
480
|
+
file,
|
|
481
|
+
line: f.line,
|
|
482
|
+
column: f.column,
|
|
483
|
+
category: f.category || 'secret',
|
|
484
|
+
severity: f.severity,
|
|
485
|
+
confidence: f.confidence,
|
|
486
|
+
type: f.patternName,
|
|
487
|
+
matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
|
|
488
|
+
description: f.description
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// =============================================================================
|
|
497
|
+
// SARIF OUTPUT (GitHub Code Scanning compatible)
|
|
498
|
+
// =============================================================================
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Output findings in SARIF 2.1.0 format.
|
|
502
|
+
* Feed this into GitHub's Security tab:
|
|
503
|
+
* npx ship-safe scan . --sarif > results.sarif
|
|
504
|
+
*
|
|
505
|
+
* Then upload via:
|
|
506
|
+
* github/codeql-action/upload-sarif@v3
|
|
507
|
+
*/
|
|
508
|
+
function outputSARIF(results, rootPath) {
|
|
509
|
+
const rules = {};
|
|
510
|
+
|
|
511
|
+
// Build rules from findings
|
|
512
|
+
for (const { findings } of results) {
|
|
513
|
+
for (const f of findings) {
|
|
514
|
+
if (!rules[f.patternName]) {
|
|
515
|
+
rules[f.patternName] = {
|
|
516
|
+
id: f.patternName.replace(/\s+/g, '-').toLowerCase(),
|
|
517
|
+
name: f.patternName,
|
|
518
|
+
shortDescription: { text: f.patternName },
|
|
519
|
+
fullDescription: { text: f.description },
|
|
520
|
+
defaultConfiguration: {
|
|
521
|
+
level: f.severity === 'critical' ? 'error'
|
|
522
|
+
: f.severity === 'high' ? 'error'
|
|
523
|
+
: f.severity === 'medium' ? 'warning'
|
|
524
|
+
: 'note'
|
|
525
|
+
},
|
|
526
|
+
helpUri: 'https://github.com/asamassekou10/ship-safe',
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const sarif = {
|
|
533
|
+
version: '2.1.0',
|
|
534
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
535
|
+
runs: [{
|
|
536
|
+
tool: {
|
|
537
|
+
driver: {
|
|
538
|
+
name: 'ship-safe',
|
|
539
|
+
version: '2.1.0',
|
|
540
|
+
informationUri: 'https://github.com/asamassekou10/ship-safe',
|
|
541
|
+
rules: Object.values(rules),
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
results: results.flatMap(({ file, findings }) =>
|
|
545
|
+
findings.map(f => ({
|
|
546
|
+
ruleId: f.patternName.replace(/\s+/g, '-').toLowerCase(),
|
|
547
|
+
level: f.severity === 'critical' || f.severity === 'high' ? 'error' : 'warning',
|
|
548
|
+
message: { text: f.description },
|
|
549
|
+
locations: [{
|
|
550
|
+
physicalLocation: {
|
|
551
|
+
artifactLocation: {
|
|
552
|
+
uri: path.relative(rootPath, file).replace(/\\/g, '/'),
|
|
553
|
+
uriBaseId: '%SRCROOT%'
|
|
554
|
+
},
|
|
555
|
+
region: {
|
|
556
|
+
startLine: f.line,
|
|
557
|
+
startColumn: f.column,
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}]
|
|
561
|
+
}))
|
|
562
|
+
)
|
|
563
|
+
}]
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
console.log(JSON.stringify(sarif, null, 2));
|
|
567
|
+
}
|