ship-safe 6.1.1 → 6.2.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.
Files changed (47) hide show
  1. package/README.md +735 -641
  2. package/cli/agents/api-fuzzer.js +345 -345
  3. package/cli/agents/auth-bypass-agent.js +348 -348
  4. package/cli/agents/base-agent.js +272 -272
  5. package/cli/agents/cicd-scanner.js +236 -201
  6. package/cli/agents/config-auditor.js +521 -521
  7. package/cli/agents/deep-analyzer.js +6 -2
  8. package/cli/agents/git-history-scanner.js +170 -170
  9. package/cli/agents/html-reporter.js +568 -568
  10. package/cli/agents/index.js +84 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/llm-redteam.js +251 -251
  13. package/cli/agents/mobile-scanner.js +231 -231
  14. package/cli/agents/orchestrator.js +322 -322
  15. package/cli/agents/pii-compliance-agent.js +301 -301
  16. package/cli/agents/scoring-engine.js +248 -248
  17. package/cli/agents/supabase-rls-agent.js +154 -154
  18. package/cli/agents/supply-chain-agent.js +650 -507
  19. package/cli/bin/ship-safe.js +452 -426
  20. package/cli/commands/agent.js +608 -608
  21. package/cli/commands/audit.js +986 -980
  22. package/cli/commands/baseline.js +193 -193
  23. package/cli/commands/ci.js +342 -342
  24. package/cli/commands/deps.js +516 -516
  25. package/cli/commands/doctor.js +159 -159
  26. package/cli/commands/fix.js +218 -218
  27. package/cli/commands/hooks.js +268 -0
  28. package/cli/commands/init.js +407 -407
  29. package/cli/commands/mcp.js +304 -304
  30. package/cli/commands/red-team.js +7 -1
  31. package/cli/commands/remediate.js +798 -798
  32. package/cli/commands/rotate.js +571 -571
  33. package/cli/commands/scan.js +569 -569
  34. package/cli/commands/score.js +449 -449
  35. package/cli/commands/watch.js +281 -281
  36. package/cli/hooks/patterns.js +313 -0
  37. package/cli/hooks/post-tool-use.js +140 -0
  38. package/cli/hooks/pre-tool-use.js +186 -0
  39. package/cli/index.js +73 -69
  40. package/cli/providers/llm-provider.js +397 -287
  41. package/cli/utils/autofix-rules.js +74 -74
  42. package/cli/utils/cache-manager.js +311 -311
  43. package/cli/utils/output.js +230 -230
  44. package/cli/utils/patterns.js +1121 -1121
  45. package/cli/utils/pdf-generator.js +94 -94
  46. package/package.json +69 -69
  47. package/configs/supabase/rls-templates.sql +0 -242
@@ -1,569 +1,569 @@
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
- console.log();
461
- console.log(chalk.gray('Track findings over time: ') + chalk.cyan('https://shipsafecli.com'));
462
-
463
- if (secretResults.length > 0) output.recommendations();
464
- if (vulnResults.length > 0) output.vulnRecommendations();
465
- }
466
-
467
- output.summary(stats);
468
- }
469
-
470
- function outputJSON(results, filesScanned) {
471
- const jsonOutput = {
472
- success: results.length === 0,
473
- filesScanned,
474
- totalFindings: 0,
475
- findings: []
476
- };
477
-
478
- for (const { file, findings } of results) {
479
- for (const f of findings) {
480
- jsonOutput.totalFindings++;
481
- jsonOutput.findings.push({
482
- file,
483
- line: f.line,
484
- column: f.column,
485
- category: f.category || 'secret',
486
- severity: f.severity,
487
- confidence: f.confidence,
488
- type: f.patternName,
489
- matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
490
- description: f.description
491
- });
492
- }
493
- }
494
-
495
- console.log(JSON.stringify(jsonOutput, null, 2));
496
- }
497
-
498
- // =============================================================================
499
- // SARIF OUTPUT (GitHub Code Scanning compatible)
500
- // =============================================================================
501
-
502
- /**
503
- * Output findings in SARIF 2.1.0 format.
504
- * Feed this into GitHub's Security tab:
505
- * npx ship-safe scan . --sarif > results.sarif
506
- *
507
- * Then upload via:
508
- * github/codeql-action/upload-sarif@v3
509
- */
510
- function outputSARIF(results, rootPath) {
511
- const rules = {};
512
-
513
- // Build rules from findings
514
- for (const { findings } of results) {
515
- for (const f of findings) {
516
- if (!rules[f.patternName]) {
517
- rules[f.patternName] = {
518
- id: f.patternName.replace(/\s+/g, '-').toLowerCase(),
519
- name: f.patternName,
520
- shortDescription: { text: f.patternName },
521
- fullDescription: { text: f.description },
522
- defaultConfiguration: {
523
- level: f.severity === 'critical' ? 'error'
524
- : f.severity === 'high' ? 'error'
525
- : f.severity === 'medium' ? 'warning'
526
- : 'note'
527
- },
528
- helpUri: 'https://github.com/asamassekou10/ship-safe',
529
- };
530
- }
531
- }
532
- }
533
-
534
- const sarif = {
535
- version: '2.1.0',
536
- $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
537
- runs: [{
538
- tool: {
539
- driver: {
540
- name: 'ship-safe',
541
- version: '2.1.0',
542
- informationUri: 'https://github.com/asamassekou10/ship-safe',
543
- rules: Object.values(rules),
544
- }
545
- },
546
- results: results.flatMap(({ file, findings }) =>
547
- findings.map(f => ({
548
- ruleId: f.patternName.replace(/\s+/g, '-').toLowerCase(),
549
- level: f.severity === 'critical' || f.severity === 'high' ? 'error' : 'warning',
550
- message: { text: f.description },
551
- locations: [{
552
- physicalLocation: {
553
- artifactLocation: {
554
- uri: path.relative(rootPath, file).replace(/\\/g, '/'),
555
- uriBaseId: '%SRCROOT%'
556
- },
557
- region: {
558
- startLine: f.line,
559
- startColumn: f.column,
560
- }
561
- }
562
- }]
563
- }))
564
- )
565
- }]
566
- };
567
-
568
- console.log(JSON.stringify(sarif, null, 2));
569
- }
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
+ console.log();
461
+ console.log(chalk.gray('Track findings over time: ') + chalk.cyan('https://shipsafecli.com'));
462
+
463
+ if (secretResults.length > 0) output.recommendations();
464
+ if (vulnResults.length > 0) output.vulnRecommendations();
465
+ }
466
+
467
+ output.summary(stats);
468
+ }
469
+
470
+ function outputJSON(results, filesScanned) {
471
+ const jsonOutput = {
472
+ success: results.length === 0,
473
+ filesScanned,
474
+ totalFindings: 0,
475
+ findings: []
476
+ };
477
+
478
+ for (const { file, findings } of results) {
479
+ for (const f of findings) {
480
+ jsonOutput.totalFindings++;
481
+ jsonOutput.findings.push({
482
+ file,
483
+ line: f.line,
484
+ column: f.column,
485
+ category: f.category || 'secret',
486
+ severity: f.severity,
487
+ confidence: f.confidence,
488
+ type: f.patternName,
489
+ matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
490
+ description: f.description
491
+ });
492
+ }
493
+ }
494
+
495
+ console.log(JSON.stringify(jsonOutput, null, 2));
496
+ }
497
+
498
+ // =============================================================================
499
+ // SARIF OUTPUT (GitHub Code Scanning compatible)
500
+ // =============================================================================
501
+
502
+ /**
503
+ * Output findings in SARIF 2.1.0 format.
504
+ * Feed this into GitHub's Security tab:
505
+ * npx ship-safe scan . --sarif > results.sarif
506
+ *
507
+ * Then upload via:
508
+ * github/codeql-action/upload-sarif@v3
509
+ */
510
+ function outputSARIF(results, rootPath) {
511
+ const rules = {};
512
+
513
+ // Build rules from findings
514
+ for (const { findings } of results) {
515
+ for (const f of findings) {
516
+ if (!rules[f.patternName]) {
517
+ rules[f.patternName] = {
518
+ id: f.patternName.replace(/\s+/g, '-').toLowerCase(),
519
+ name: f.patternName,
520
+ shortDescription: { text: f.patternName },
521
+ fullDescription: { text: f.description },
522
+ defaultConfiguration: {
523
+ level: f.severity === 'critical' ? 'error'
524
+ : f.severity === 'high' ? 'error'
525
+ : f.severity === 'medium' ? 'warning'
526
+ : 'note'
527
+ },
528
+ helpUri: 'https://github.com/asamassekou10/ship-safe',
529
+ };
530
+ }
531
+ }
532
+ }
533
+
534
+ const sarif = {
535
+ version: '2.1.0',
536
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
537
+ runs: [{
538
+ tool: {
539
+ driver: {
540
+ name: 'ship-safe',
541
+ version: '2.1.0',
542
+ informationUri: 'https://github.com/asamassekou10/ship-safe',
543
+ rules: Object.values(rules),
544
+ }
545
+ },
546
+ results: results.flatMap(({ file, findings }) =>
547
+ findings.map(f => ({
548
+ ruleId: f.patternName.replace(/\s+/g, '-').toLowerCase(),
549
+ level: f.severity === 'critical' || f.severity === 'high' ? 'error' : 'warning',
550
+ message: { text: f.description },
551
+ locations: [{
552
+ physicalLocation: {
553
+ artifactLocation: {
554
+ uri: path.relative(rootPath, file).replace(/\\/g, '/'),
555
+ uriBaseId: '%SRCROOT%'
556
+ },
557
+ region: {
558
+ startLine: f.line,
559
+ startColumn: f.column,
560
+ }
561
+ }
562
+ }]
563
+ }))
564
+ )
565
+ }]
566
+ };
567
+
568
+ console.log(JSON.stringify(sarif, null, 2));
569
+ }