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