ship-safe 6.1.1 → 6.3.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 (49) hide show
  1. package/README.md +748 -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 +85 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/legal-risk-agent.js +302 -0
  13. package/cli/agents/llm-redteam.js +251 -251
  14. package/cli/agents/mobile-scanner.js +231 -231
  15. package/cli/agents/orchestrator.js +322 -322
  16. package/cli/agents/pii-compliance-agent.js +301 -301
  17. package/cli/agents/scoring-engine.js +248 -248
  18. package/cli/agents/supabase-rls-agent.js +154 -154
  19. package/cli/agents/supply-chain-agent.js +650 -507
  20. package/cli/bin/ship-safe.js +464 -426
  21. package/cli/commands/agent.js +608 -608
  22. package/cli/commands/audit.js +1006 -980
  23. package/cli/commands/baseline.js +193 -193
  24. package/cli/commands/ci.js +342 -342
  25. package/cli/commands/deps.js +516 -516
  26. package/cli/commands/doctor.js +159 -159
  27. package/cli/commands/fix.js +218 -218
  28. package/cli/commands/hooks.js +268 -0
  29. package/cli/commands/init.js +407 -407
  30. package/cli/commands/legal.js +158 -0
  31. package/cli/commands/mcp.js +304 -304
  32. package/cli/commands/red-team.js +7 -1
  33. package/cli/commands/remediate.js +798 -798
  34. package/cli/commands/rotate.js +571 -571
  35. package/cli/commands/scan.js +569 -569
  36. package/cli/commands/score.js +449 -449
  37. package/cli/commands/watch.js +281 -281
  38. package/cli/hooks/patterns.js +313 -0
  39. package/cli/hooks/post-tool-use.js +140 -0
  40. package/cli/hooks/pre-tool-use.js +186 -0
  41. package/cli/index.js +73 -69
  42. package/cli/providers/llm-provider.js +397 -287
  43. package/cli/utils/autofix-rules.js +74 -74
  44. package/cli/utils/cache-manager.js +311 -311
  45. package/cli/utils/output.js +230 -230
  46. package/cli/utils/patterns.js +1121 -1121
  47. package/cli/utils/pdf-generator.js +94 -94
  48. package/package.json +69 -69
  49. package/configs/supabase/rls-templates.sql +0 -242
@@ -1,980 +1,1006 @@
1
- /**
2
- * Audit Command — Full Security Audit
3
- * =====================================
4
- *
5
- * One command to run everything: secrets, agents, deps, score, and
6
- * generate a comprehensive report with a prioritized remediation plan.
7
- *
8
- * USAGE:
9
- * npx ship-safe audit [path] Full audit with HTML report
10
- * npx ship-safe audit . --json JSON output
11
- * npx ship-safe audit . --html report.html Custom report path
12
- * npx ship-safe audit . --no-deps Skip dependency audit
13
- */
14
-
15
- import fs from 'fs';
16
- import path from 'path';
17
- import chalk from 'chalk';
18
- import ora from 'ora';
19
- import fg from 'fast-glob';
20
- import { buildOrchestrator } from '../agents/index.js';
21
- import { ScoringEngine } from '../agents/scoring-engine.js';
22
- import { PolicyEngine } from '../agents/policy-engine.js';
23
- import { HTMLReporter } from '../agents/html-reporter.js';
24
- import { SBOMGenerator } from '../agents/sbom-generator.js';
25
- import { autoDetectProvider } from '../providers/llm-provider.js';
26
- import { runDepsAudit } from './deps.js';
27
- import {
28
- SECRET_PATTERNS,
29
- SECURITY_PATTERNS,
30
- SKIP_DIRS,
31
- SKIP_EXTENSIONS,
32
- SKIP_FILENAMES,
33
- MAX_FILE_SIZE,
34
- loadGitignorePatterns
35
- } from '../utils/patterns.js';
36
- import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
37
- import { CacheManager } from '../utils/cache-manager.js';
38
- import { filterBaseline } from './baseline.js';
39
- import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
40
- import { SecretsVerifier } from '../utils/secrets-verifier.js';
41
-
42
- // =============================================================================
43
- // CONSTANTS
44
- // =============================================================================
45
-
46
- const ALL_PATTERNS = [...SECRET_PATTERNS, ...SECURITY_PATTERNS];
47
-
48
- const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
49
-
50
- const CATEGORY_LABELS = {
51
- secrets: 'Secrets',
52
- injection: 'Code Vulnerabilities',
53
- deps: 'Dependencies',
54
- auth: 'Auth & Access Control',
55
- config: 'Configuration',
56
- 'supply-chain': 'Supply Chain',
57
- api: 'API Security',
58
- llm: 'AI/LLM Security',
59
- };
60
-
61
- const EFFORT_MAP = {
62
- secrets: 'low',
63
- config: 'low',
64
- deps: 'medium',
65
- injection: 'medium',
66
- auth: 'medium',
67
- 'supply-chain': 'medium',
68
- api: 'medium',
69
- llm: 'high',
70
- };
71
-
72
- // =============================================================================
73
- // MAIN COMMAND
74
- // =============================================================================
75
-
76
- export async function auditCommand(targetPath = '.', options = {}) {
77
- const absolutePath = path.resolve(targetPath);
78
- const machineOutput = options.json || options.sarif || options.csv || options.md;
79
-
80
- if (!fs.existsSync(absolutePath)) {
81
- console.error(chalk.red(` Path does not exist: ${absolutePath}`));
82
- process.exit(1);
83
- }
84
-
85
- if (!machineOutput) {
86
- console.log();
87
- console.log(chalk.cyan('═'.repeat(60)));
88
- console.log(chalk.cyan.bold(' Ship Safe — Full Security Audit'));
89
- console.log(chalk.cyan('═'.repeat(60)));
90
- console.log();
91
- }
92
-
93
- // ── Cache Layer ──────────────────────────────────────────────────────────
94
- const useCache = options.cache !== false;
95
- const cache = new CacheManager(absolutePath);
96
- let cacheData = useCache ? cache.load() : null;
97
- let cacheDiff = null;
98
- let allFiles = [];
99
-
100
- // ── Phase 1: Secret Scan ──────────────────────────────────────────────────
101
- const secretSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 1/4] Scanning for secrets...'), color: 'cyan' }).start();
102
- let secretFindings = [];
103
- let filesScanned = 0;
104
-
105
- try {
106
- allFiles = await findFiles(absolutePath);
107
- filesScanned = allFiles.length;
108
-
109
- // Determine which files need scanning (incremental if cache exists)
110
- let filesToScan = allFiles;
111
- let cachedSecretFindings = [];
112
-
113
- if (cacheData) {
114
- cacheDiff = cache.diff(allFiles);
115
- filesToScan = cacheDiff.changedFiles;
116
- // Reuse cached findings for unchanged files (secrets only)
117
- cachedSecretFindings = cacheDiff.cachedFindings.filter(
118
- f => f.category === 'secrets' || f.category === 'secret'
119
- );
120
- }
121
-
122
- for (const file of filesToScan) {
123
- const fileResults = scanFileForSecrets(file);
124
- for (const f of fileResults) {
125
- secretFindings.push({
126
- file,
127
- line: f.line,
128
- column: f.column,
129
- severity: f.severity,
130
- category: f.category || 'secrets',
131
- rule: f.patternName,
132
- title: f.patternName.replace(/_/g, ' '),
133
- description: f.description,
134
- matched: f.matched,
135
- confidence: f.confidence,
136
- fix: file.match(/\.env(\..*)?$/)
137
- ? `Ensure .env is in .gitignore and use a secrets manager for production`
138
- : `Move to environment variable or secrets manager`,
139
- });
140
- }
141
- }
142
-
143
- // Downgrade .env findings if the file is gitignored (properly managed)
144
- const gitignoreContent = (() => {
145
- try { return fs.readFileSync(path.join(absolutePath, '.gitignore'), 'utf-8'); } catch { return ''; }
146
- })();
147
- const envIsGitignored = gitignoreContent.split('\n')
148
- .map(l => l.trim())
149
- .some(l => /^\.env(\s|$)/.test(l) || l === '*.env' || l === '.env*' || l === '.env.local' || l === '.env.production');
150
-
151
- if (envIsGitignored) {
152
- for (const f of secretFindings) {
153
- if (f.file.match(/\.env(\..*)?$/) && !f.file.includes('node_modules')) {
154
- f.severity = 'low';
155
- f.confidence = 'low';
156
- f.fix = 'Already gitignored — ensure secrets manager is used for production deploys';
157
- }
158
- }
159
- }
160
-
161
- // Downgrade secrets in test files (intentional test fixtures)
162
- const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
163
- for (const f of secretFindings) {
164
- if (TEST_PATH.test(f.file)) {
165
- f.confidence = 'low';
166
- }
167
- }
168
-
169
- // Merge with cached findings for unchanged files
170
- secretFindings = [...secretFindings, ...cachedSecretFindings];
171
-
172
- const cacheNote = cacheDiff && cacheDiff.changedFiles.length < allFiles.length
173
- ? ` (${cacheDiff.changedFiles.length} changed, ${cacheDiff.unchangedCount} cached)`
174
- : '';
175
-
176
- if (secretSpinner) secretSpinner.succeed(
177
- secretFindings.length === 0
178
- ? chalk.green(`[Phase 1/4] Secrets: clean${cacheNote}`)
179
- : chalk.red(`[Phase 1/4] Secrets: ${secretFindings.length} found${cacheNote}`)
180
- );
181
- } catch (err) {
182
- if (secretSpinner) secretSpinner.fail(chalk.red(`[Phase 1/4] Secret scan failed: ${err.message}`));
183
- }
184
-
185
- // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
186
- const orchestrator = buildOrchestrator();
187
- const registeredAgentCount = orchestrator.agents?.length || 15;
188
- const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
189
- let agentFindings = [];
190
- let recon = null;
191
- let agentResults = [];
192
-
193
- try {
194
- // Suppress individual agent spinners by using quiet mode
195
- // Pass changedFiles for incremental scanning if cache is valid
196
- const orchestratorOpts = { quiet: true };
197
- if (options.deep) orchestratorOpts.deep = true;
198
- if (options.local) orchestratorOpts.local = true;
199
- if (options.model) orchestratorOpts.model = options.model;
200
- if (options.budget) orchestratorOpts.budget = options.budget;
201
- if (options.verbose) orchestratorOpts.verbose = true;
202
- if (cacheDiff && cacheDiff.changedFiles.length < allFiles.length) {
203
- orchestratorOpts.changedFiles = cacheDiff.changedFiles;
204
- }
205
- const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
206
- recon = results.recon;
207
- agentFindings = results.findings;
208
- agentResults = results.agentResults;
209
-
210
- const totalAgentFindings = agentFindings.length;
211
- const agentCount = agentResults.filter(a => a.success).length;
212
- if (agentSpinner) agentSpinner.succeed(
213
- totalAgentFindings === 0
214
- ? chalk.green(`[Phase 2/4] ${agentCount} agents: clean`)
215
- : chalk.yellow(`[Phase 2/4] ${agentCount} agents: ${totalAgentFindings} finding(s)`)
216
- );
217
- } catch (err) {
218
- if (agentSpinner) agentSpinner.fail(chalk.red(`[Phase 2/4] Agent scan failed: ${err.message}`));
219
- }
220
-
221
- // ── Phase 3: Dependency Audit ─────────────────────────────────────────────
222
- let depVulns = [];
223
- if (options.deps !== false) {
224
- const depSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 3/4] Auditing dependencies...'), color: 'cyan' }).start();
225
- try {
226
- const depResult = await runDepsAudit(absolutePath);
227
- depVulns = depResult.vulns || [];
228
- if (depSpinner) depSpinner.succeed(
229
- depVulns.length === 0
230
- ? chalk.green('[Phase 3/4] Dependencies: clean')
231
- : chalk.red(`[Phase 3/4] Dependencies: ${depVulns.length} CVE(s)`)
232
- );
233
- } catch {
234
- if (depSpinner) depSpinner.succeed(chalk.gray('[Phase 3/4] Dependencies: skipped (no manifest)'));
235
- }
236
- } else if (!machineOutput) {
237
- console.log(chalk.gray(' [Phase 3/4] Dependencies: skipped (--no-deps)'));
238
- }
239
-
240
- // ── Phase 4: Merge, Score, and Build Plan ─────────────────────────────────
241
- const scoreSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 4/4] Computing security score...'), color: 'cyan' }).start();
242
-
243
- // Merge secret findings + agent findings, deduplicate
244
- const allFindings = deduplicateFindings([...secretFindings, ...agentFindings]);
245
-
246
- // Apply policy
247
- const policy = PolicyEngine.load(absolutePath);
248
- let filteredFindings = policy.applyPolicy(allFindings);
249
-
250
- // Apply baseline filter (only show new findings)
251
- if (options.baseline) {
252
- const beforeCount = filteredFindings.length;
253
- filteredFindings = filterBaseline(filteredFindings, absolutePath);
254
- if (!machineOutput && beforeCount !== filteredFindings.length) {
255
- console.log(chalk.gray(` Baseline: ${beforeCount - filteredFindings.length} known finding(s) filtered, ${filteredFindings.length} new`));
256
- }
257
- }
258
-
259
- // Count suppressions (ship-safe-ignore comments)
260
- const suppressions = countSuppressions(allFiles);
261
-
262
- // Score
263
- const scoringEngine = new ScoringEngine();
264
- const scoreResult = scoringEngine.compute(filteredFindings, depVulns);
265
- // Round score to 1 decimal place to avoid floating-point noise (e.g., 63.300000000000004)
266
- scoreResult.score = Math.round(scoreResult.score * 10) / 10;
267
- scoringEngine.saveToHistory(absolutePath, scoreResult, suppressions);
268
-
269
- const gradeColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
270
- if (scoreSpinner) scoreSpinner.succeed(
271
- chalk.white('[Phase 4/4] Score: ') + gradeColor(`${scoreResult.score}/100 ${scoreResult.grade.letter}`)
272
- );
273
-
274
- // ── AI Classification (optional, with LLM cache) ───────────────────────
275
- if (options.ai !== false) {
276
- const provider = autoDetectProvider(absolutePath);
277
- if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
278
- const aiSpinner = machineOutput ? null : ora({ text: `Classifying with ${provider.name}...`, color: 'cyan' }).start();
279
- try {
280
- // Check LLM cache for existing classifications
281
- const llmCache = cache.loadLLMClassifications();
282
- const uncachedFindings = [];
283
- let cachedCount = 0;
284
-
285
- for (const finding of filteredFindings) {
286
- const key = cache.getLLMCacheKey(finding);
287
- const cached = llmCache[key];
288
- if (cached) {
289
- finding.aiClassification = cached.classification;
290
- finding.aiReason = cached.reason;
291
- finding.aiFix = cached.fix;
292
- cachedCount++;
293
- } else {
294
- uncachedFindings.push(finding);
295
- }
296
- }
297
-
298
- // Only send uncached findings to LLM
299
- if (uncachedFindings.length > 0) {
300
- const classifications = await provider.classify(uncachedFindings);
301
- const newCacheEntries = {};
302
- for (const cl of classifications) {
303
- const finding = filteredFindings.find(f => `${f.file}:${f.line}` === cl.id);
304
- if (finding) {
305
- finding.aiClassification = cl.classification;
306
- finding.aiReason = cl.reason;
307
- finding.aiFix = cl.fix;
308
- const key = cache.getLLMCacheKey(finding);
309
- newCacheEntries[key] = {
310
- classification: cl.classification,
311
- reason: cl.reason,
312
- fix: cl.fix,
313
- cachedAt: new Date().toISOString(),
314
- };
315
- }
316
- }
317
- cache.saveLLMClassifications(newCacheEntries);
318
- }
319
-
320
- const cacheNote = cachedCount > 0 ? `, ${cachedCount} cached` : '';
321
- if (aiSpinner) aiSpinner.succeed(chalk.green(`AI classification complete (${provider.name}${cacheNote})`));
322
- } catch (err) {
323
- if (aiSpinner) aiSpinner.fail(chalk.yellow(`AI classification failed: ${err.message}`));
324
- }
325
- }
326
- }
327
-
328
- // ── Secrets Verification (optional, --verify flag) ─────────────────────
329
- if (options.verify) {
330
- const verifySpinner = machineOutput ? null : ora({ text: 'Verifying leaked secrets against provider APIs...', color: 'cyan' }).start();
331
- try {
332
- const verifier = new SecretsVerifier();
333
- const verifyResults = await verifier.verify(filteredFindings);
334
- const activeCount = verifyResults.filter(r => r.result.active === true).length;
335
- const inactiveCount = verifyResults.filter(r => r.result.active === false).length;
336
- if (verifySpinner) {
337
- verifySpinner.succeed(chalk.green(
338
- `Secrets verified: ${activeCount} active, ${inactiveCount} inactive, ${verifyResults.length - activeCount - inactiveCount} unknown`
339
- ));
340
- }
341
- // Show active secrets warning
342
- if (activeCount > 0 && !machineOutput) {
343
- console.log(chalk.red.bold(' ⚠ ACTIVE SECRETS DETECTED — rotate immediately:'));
344
- for (const r of verifyResults.filter(r => r.result.active === true)) {
345
- const rel = path.relative(absolutePath, r.finding.file).replace(/\\/g, '/');
346
- console.log(chalk.red(` ${r.result.provider}: ${rel}:${r.finding.line} ${r.result.info}`));
347
- }
348
- }
349
- } catch (err) {
350
- if (verifySpinner) verifySpinner.fail(chalk.yellow(`Secrets verification failed: ${err.message}`));
351
- }
352
- }
353
-
354
- // ── Save Cache ──────────────────────────────────────────────────────────
355
- if (useCache) {
356
- try {
357
- // Merge agent findings back for cache (secret + agent findings from changed files)
358
- // plus cached findings from unchanged files
359
- const cachedAgentFindings = cacheData && cacheDiff
360
- ? cacheDiff.cachedFindings.filter(f => f.category !== 'secrets' && f.category !== 'secret')
361
- : [];
362
- const allFindingsForCache = [...secretFindings, ...agentFindings, ...cachedAgentFindings];
363
- cache.save(allFiles, deduplicateFindings(allFindingsForCache), recon, scoreResult);
364
- } catch {
365
- // Silent — caching should never break a scan
366
- }
367
- }
368
-
369
- // ── Build Remediation Plan ────────────────────────────────────────────────
370
- const remediationPlan = buildRemediationPlan(filteredFindings, depVulns, absolutePath);
371
-
372
- // ── Output ────────────────────────────────────────────────────────────────
373
- console.log();
374
-
375
- if (options.csv) {
376
- outputCSV(filteredFindings, depVulns, scoreResult, absolutePath);
377
- } else if (options.md) {
378
- outputMarkdown(scoreResult, filteredFindings, depVulns, remediationPlan, absolutePath);
379
- } else if (options.json) {
380
- outputJSON(scoreResult, filteredFindings, depVulns, recon, agentResults, remediationPlan, suppressions, options.compare ? scoringEngine.loadHistory(absolutePath) : null);
381
- } else if (options.sarif) {
382
- outputSARIF(filteredFindings, absolutePath);
383
- } else {
384
- printReport(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, filesScanned);
385
- }
386
-
387
- // ── HTML Report (always generate unless machine output) ───────────────────
388
- if (!options.json && !options.sarif && !options.csv && !options.md) {
389
- const htmlPath = typeof options.html === 'string' ? options.html : 'ship-safe-report.html';
390
- const reporter = new HTMLReporter();
391
- reporter.generateFullReport(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, htmlPath);
392
- console.log();
393
- console.log(chalk.cyan(` Full report: ${chalk.white.bold(htmlPath)}`));
394
- console.log(chalk.gray(` Dashboard: `) + chalk.cyan('https://shipsafecli.com/app'));
395
-
396
- // PDF export
397
- if (options.pdf) {
398
- const pdfPath = typeof options.pdf === 'string' ? options.pdf : 'ship-safe-report.pdf';
399
- const result = generatePDF(path.resolve(htmlPath), path.resolve(pdfPath));
400
- if (result) {
401
- console.log(chalk.cyan(` PDF report: ${chalk.white.bold(pdfPath)}`));
402
- } else {
403
- // Fallback: print-optimized HTML
404
- const fallbackPath = pdfPath.replace(/\.pdf$/, '.print.html');
405
- generatePrintHTML(path.resolve(htmlPath), path.resolve(fallbackPath));
406
- console.log(chalk.yellow(` Chrome not found saved print-optimized HTML: ${fallbackPath}`));
407
- console.log(chalk.gray(' Open in a browser and Print → Save as PDF'));
408
- }
409
- }
410
- }
411
-
412
- if (!machineOutput && !options.csv && !options.md) {
413
- // ── Policy Violations ──────────────────────────────────────────────────
414
- const violations = policy.evaluate(scoreResult, filteredFindings);
415
- if (violations.length > 0) {
416
- console.log();
417
- console.log(chalk.red.bold(' Policy Violations:'));
418
- for (const v of violations.slice(0, 5)) {
419
- console.log(chalk.red(` ${v.message}`));
420
- }
421
- }
422
-
423
- // ── Trend ───────────────────────────────────────────────────────────────
424
- const trend = scoringEngine.getTrend(absolutePath, scoreResult.score);
425
- if (trend) {
426
- const arrow = trend.diff > 0 ? chalk.green('↑') : trend.diff < 0 ? chalk.red('↓') : chalk.gray('→');
427
- const roundedDiff = Math.round(trend.diff * 10) / 10;
428
- const diffLabel = roundedDiff === 0 ? chalk.gray('no change') : chalk.white(`${roundedDiff > 0 ? '+' : ''}${roundedDiff}`);
429
- console.log(chalk.gray(` Trend: ${trend.previousScore} → ${trend.currentScore} ${arrow} (`) + diffLabel + chalk.gray(')'));
430
- }
431
-
432
- // ── Detailed Comparison ────────────────────────────────────────────────
433
- if (options.compare) {
434
- printComparison(scoringEngine, absolutePath, scoreResult);
435
- }
436
-
437
- console.log();
438
- console.log(chalk.cyan('═'.repeat(60)));
439
- console.log();
440
- }
441
-
442
- process.exit(scoreResult.score >= 75 ? 0 : 1);
443
- }
444
-
445
- // =============================================================================
446
- // REMEDIATION PLAN BUILDER
447
- // =============================================================================
448
-
449
- function buildRemediationPlan(findings, depVulns, rootPath) {
450
- const plan = [];
451
- let priority = 1;
452
-
453
- // Exclude low-confidence findings (test files, docs, comments) from remediation plan
454
- const actionable = findings.filter(f => f.confidence !== 'low');
455
-
456
- // Priority order: secrets first, then by severity
457
- const secretFindings = actionable.filter(f => f.category === 'secrets' || f.category === 'secret');
458
- const otherFindings = actionable.filter(f => f.category !== 'secrets' && f.category !== 'secret');
459
-
460
- // Group and sort
461
- for (const sev of SEV_ORDER) {
462
- // Secrets at this severity — group .env findings by file
463
- const sevSecrets = secretFindings.filter(s => s.severity === sev);
464
- const envGroups = new Map();
465
- const nonEnvSecrets = [];
466
-
467
- for (const f of sevSecrets) {
468
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
469
- if (f.file.match(/\.env(\..*)?$/)) {
470
- if (!envGroups.has(relFile)) envGroups.set(relFile, []);
471
- envGroups.get(relFile).push(f);
472
- } else {
473
- nonEnvSecrets.push(f);
474
- }
475
- }
476
-
477
- // One plan item per .env file
478
- for (const [relFile, envFindings] of envGroups) {
479
- const names = envFindings.map(f => f.title || f.rule).join(', ');
480
- plan.push({
481
- priority: priority++,
482
- severity: sev,
483
- category: 'secrets',
484
- categoryLabel: 'SECRETS',
485
- title: `${envFindings.length} secret${envFindings.length > 1 ? 's' : ''} in ${relFile} (${names})`,
486
- file: relFile,
487
- action: envFindings[0].fix || 'Ensure .env is in .gitignore and use a secrets manager for production',
488
- effort: 'low',
489
- });
490
- }
491
-
492
- // Individual items for non-.env secrets
493
- for (const f of nonEnvSecrets) {
494
- plan.push({
495
- priority: priority++,
496
- severity: sev,
497
- category: 'secrets',
498
- categoryLabel: 'SECRETS',
499
- title: f.title || f.rule,
500
- file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
501
- action: f.aiFix || f.fix || f.description,
502
- effort: 'low',
503
- });
504
- }
505
-
506
- // Other findings at this severity
507
- for (const f of otherFindings.filter(s => s.severity === sev)) {
508
- plan.push({
509
- priority: priority++,
510
- severity: sev,
511
- category: f.category,
512
- categoryLabel: (CATEGORY_LABELS[f.category] || f.category).toUpperCase(),
513
- title: f.title || f.rule,
514
- file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
515
- action: f.aiFix || f.fix || f.description,
516
- effort: EFFORT_MAP[f.category] || 'medium',
517
- });
518
- }
519
-
520
- // Dep vulns at this severity
521
- for (const d of depVulns.filter(v => v.severity === sev || (sev === 'medium' && v.severity === 'moderate'))) {
522
- plan.push({
523
- priority: priority++,
524
- severity: sev,
525
- category: 'deps',
526
- categoryLabel: 'DEPENDENCIES',
527
- title: `Vulnerable: ${d.package || d.id}`,
528
- file: 'package.json',
529
- action: d.description ? `${d.description.slice(0, 80)}` : 'Update to patched version',
530
- effort: 'medium',
531
- });
532
- }
533
- }
534
-
535
- return plan;
536
- }
537
-
538
- // =============================================================================
539
- // CONSOLE OUTPUT
540
- // =============================================================================
541
-
542
- function printReport(scoreResult, findings, depVulns, recon, plan, rootPath, filesScanned) {
543
- const GRADE_COLOR = { A: chalk.green.bold, B: chalk.cyan.bold, C: chalk.yellow.bold, D: chalk.red, F: chalk.red.bold };
544
- const SEV_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
545
- const SEV_LABEL = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
546
-
547
- // ── Score ─────────────────────────────────────────────────────────────────
548
- const gradeColor = GRADE_COLOR[scoreResult.grade.letter] || chalk.white;
549
- const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
550
-
551
- console.log(chalk.cyan(' ' + ''.repeat(56)));
552
- console.log(
553
- chalk.white.bold(' Security Score: ') +
554
- scoreColor(`${scoreResult.score}/100 `) +
555
- gradeColor(scoreResult.grade.letter) +
556
- chalk.gray(` — ${scoreResult.grade.label}`)
557
- );
558
- console.log(chalk.cyan(' ' + '═'.repeat(56)));
559
- console.log();
560
-
561
- // ── Category Breakdown ────────────────────────────────────────────────────
562
- console.log(chalk.white.bold(' Category Breakdown'));
563
- console.log(chalk.gray(' ' + '─'.repeat(56)));
564
-
565
- for (const [key, cat] of Object.entries(scoreResult.categories)) {
566
- const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
567
- const icon = count === 0 ? chalk.green('✔') : chalk.red('✘');
568
- const status = count === 0 ? chalk.green('clean') : chalk.red(`${count} issue(s)`);
569
- const deduction = cat.deduction > 0 ? chalk.red(`-${Math.round(cat.deduction * 10) / 10} pts`) : chalk.gray('+0');
570
- console.log(` ${icon} ${chalk.white(cat.label.padEnd(22))} ${status.padEnd(25)} ${deduction}`);
571
- }
572
-
573
- // Deps row — only print if not already included in scoreResult.categories
574
- const hasDepsCategory = Object.values(scoreResult.categories).some(c => c.label?.toLowerCase().includes('depend'));
575
- if (!hasDepsCategory) {
576
- const depIcon = depVulns.length === 0 ? chalk.green('✔') : chalk.red('✘');
577
- const depStatus = depVulns.length === 0 ? chalk.green('clean') : chalk.red(`${depVulns.length} CVE(s)`);
578
- console.log(` ${depIcon} ${chalk.white('Dependencies'.padEnd(22))} ${depStatus}`);
579
- }
580
-
581
- console.log(chalk.gray(`\n Files scanned: ${filesScanned} | Findings: ${findings.length} | CVEs: ${depVulns.length}`));
582
-
583
- // ── Remediation Plan ──────────────────────────────────────────────────────
584
- if (plan.length > 0) {
585
- console.log();
586
- console.log(chalk.cyan(' ' + '═'.repeat(56)));
587
- console.log(chalk.cyan.bold(' Remediation Plan'));
588
- console.log(chalk.cyan(' ' + '═'.repeat(56)));
589
-
590
- let currentSev = null;
591
- let shown = 0;
592
- const maxItems = 30;
593
-
594
- for (const item of plan) {
595
- if (shown >= maxItems) {
596
- console.log(chalk.gray(`\n ... and ${plan.length - maxItems} more items in the full report`));
597
- break;
598
- }
599
-
600
- if (item.severity !== currentSev) {
601
- currentSev = item.severity;
602
- console.log();
603
- console.log(chalk.white.bold(` ${SEV_ICON[currentSev] || ''} ${SEV_LABEL[currentSev] || currentSev.toUpperCase()}`));
604
- console.log(chalk.gray(' ' + '─'.repeat(56)));
605
- }
606
-
607
- console.log(
608
- chalk.white(` ${String(item.priority).padStart(2)}.`) +
609
- chalk.gray(` [${item.categoryLabel}] `) +
610
- chalk.white(item.title)
611
- );
612
- console.log(
613
- chalk.gray(` ${item.file}`) +
614
- chalk.gray(' ') +
615
- chalk.green((item.action || '').slice(0, 70))
616
- );
617
- shown++;
618
- }
619
- } else {
620
- console.log();
621
- console.log(chalk.green.bold(' All clear safe to ship!'));
622
- }
623
-
624
- // ── Attack Surface ────────────────────────────────────────────────────────
625
- if (recon) {
626
- console.log();
627
- console.log(chalk.gray(' Attack Surface:'));
628
- if (recon.frameworks?.length) console.log(chalk.gray(` Frameworks: ${recon.frameworks.join(', ')}`));
629
- if (recon.databases?.length) console.log(chalk.gray(` Databases: ${recon.databases.join(', ')}`));
630
- if (recon.authPatterns?.length) console.log(chalk.gray(` Auth: ${recon.authPatterns.join(', ')}`));
631
- if (recon.apiRoutes?.length) console.log(chalk.gray(` API Routes: ${recon.apiRoutes.length} discovered`));
632
- }
633
- }
634
-
635
- // =============================================================================
636
- // JSON OUTPUT
637
- // =============================================================================
638
-
639
- function outputJSON(scoreResult, findings, depVulns, recon, agentResults, remediationPlan, suppressions, history) {
640
- const output = {
641
- score: scoreResult.score,
642
- grade: scoreResult.grade.letter,
643
- gradeLabel: scoreResult.grade.label,
644
- totalFindings: findings.length,
645
- totalDepVulns: depVulns.length,
646
- categories: Object.fromEntries(
647
- Object.entries(scoreResult.categories).map(([k, v]) => [k, {
648
- label: v.label,
649
- findingCount: Object.values(v.counts).reduce((a, b) => a + b, 0),
650
- deduction: v.deduction,
651
- counts: v.counts,
652
- }])
653
- ),
654
- findings: findings.map(f => ({
655
- file: f.file, line: f.line, severity: f.severity, category: f.category,
656
- rule: f.rule, title: f.title, description: f.description, fix: f.fix,
657
- cwe: f.cwe, owasp: f.owasp,
658
- })),
659
- depVulns: depVulns.map(d => ({
660
- severity: d.severity, package: d.package || d.id, description: d.description,
661
- })),
662
- remediationPlan,
663
- recon,
664
- agents: agentResults,
665
- };
666
- if (scoreResult.compliance) output.compliance = scoreResult.compliance;
667
- if (suppressions) output.suppressions = suppressions;
668
- if (history && history.length >= 2) {
669
- const prev = history[history.length - 2];
670
- output.comparison = {
671
- previousScore: prev.score,
672
- previousGrade: prev.grade,
673
- previousDate: prev.timestamp,
674
- diff: scoreResult.score - prev.score,
675
- categoryComparison: Object.fromEntries(
676
- Object.entries(scoreResult.categories).map(([k, v]) => {
677
- const prevCat = prev.categoryScores?.[k];
678
- return [k, {
679
- label: v.label,
680
- current: -v.deduction,
681
- previous: prevCat ? -prevCat.deduction : 0,
682
- delta: prevCat ? prevCat.deduction - v.deduction : 0,
683
- }];
684
- })
685
- ),
686
- };
687
- }
688
- console.log(JSON.stringify(output, null, 2));
689
- }
690
-
691
- // =============================================================================
692
- // SARIF OUTPUT
693
- // =============================================================================
694
-
695
- function outputSARIF(findings, rootPath) {
696
- const rules = {};
697
- for (const f of findings) {
698
- if (!rules[f.rule]) {
699
- rules[f.rule] = {
700
- id: f.rule,
701
- name: f.title || f.rule,
702
- shortDescription: { text: f.title || f.rule },
703
- fullDescription: { text: f.description || '' },
704
- defaultConfiguration: {
705
- level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
706
- },
707
- };
708
- }
709
- }
710
-
711
- console.log(JSON.stringify({
712
- version: '2.1.0',
713
- $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
714
- runs: [{
715
- tool: {
716
- driver: {
717
- name: 'ship-safe',
718
- version: '4.0.0',
719
- informationUri: 'https://github.com/asamassekou10/ship-safe',
720
- rules: Object.values(rules),
721
- }
722
- },
723
- results: findings.map(f => ({
724
- ruleId: f.rule,
725
- level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
726
- message: { text: `${f.title}: ${f.description}` },
727
- locations: [{
728
- physicalLocation: {
729
- artifactLocation: { uri: path.relative(rootPath, f.file).replace(/\\/g, '/'), uriBaseId: '%SRCROOT%' },
730
- region: { startLine: f.line, startColumn: f.column || 1 },
731
- }
732
- }],
733
- })),
734
- }],
735
- }, null, 2));
736
- }
737
-
738
- // =============================================================================
739
- // FILE SCANNING (inline from scan.js to avoid circular deps)
740
- // =============================================================================
741
-
742
- async function findFiles(rootPath) {
743
- const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
744
-
745
- // Respect .gitignore patterns
746
- const gitignoreGlobs = loadGitignorePatterns(rootPath);
747
- globIgnore.push(...gitignoreGlobs);
748
-
749
- // Load .ship-safeignore
750
- const ignorePath = path.join(rootPath, '.ship-safeignore');
751
- if (fs.existsSync(ignorePath)) {
752
- try {
753
- const patterns = fs.readFileSync(ignorePath, 'utf-8')
754
- .split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
755
- for (const p of patterns) {
756
- if (p.endsWith('/')) { globIgnore.push(`**/${p}**`); }
757
- else { globIgnore.push(`**/${p}`); globIgnore.push(p); }
758
- }
759
- } catch { /* skip */ }
760
- }
761
-
762
- const files = await fg('**/*', {
763
- cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
764
- });
765
-
766
- return files.filter(file => {
767
- const ext = path.extname(file).toLowerCase();
768
- if (SKIP_EXTENSIONS.has(ext)) return false;
769
- if (SKIP_FILENAMES.has(path.basename(file))) return false;
770
- if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
771
- try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
772
- return true;
773
- });
774
- }
775
-
776
- function scanFileForSecrets(filePath) {
777
- const findings = [];
778
- try {
779
- const content = fs.readFileSync(filePath, 'utf-8');
780
- const lines = content.split('\n');
781
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
782
- const line = lines[lineNum];
783
- if (/ship-safe-ignore/i.test(line)) continue;
784
- for (const pattern of SECRET_PATTERNS) {
785
- pattern.pattern.lastIndex = 0;
786
- let match;
787
- while ((match = pattern.pattern.exec(line)) !== null) {
788
- if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
789
- findings.push({
790
- line: lineNum + 1, column: match.index + 1, matched: match[0],
791
- patternName: pattern.name, severity: pattern.severity,
792
- confidence: getConfidence(pattern, match[0]),
793
- description: pattern.description, category: pattern.category || 'secret'
794
- });
795
- }
796
- }
797
- }
798
- } catch { /* skip */ }
799
-
800
- const seen = new Set();
801
- return findings.filter(f => {
802
- const key = `${f.line}:${f.matched}`;
803
- if (seen.has(key)) return false;
804
- seen.add(key);
805
- return true;
806
- });
807
- }
808
-
809
- function deduplicateFindings(findings) {
810
- const seen = new Set();
811
- return findings.filter(f => {
812
- const key = `${f.file}:${f.line}:${f.rule}`;
813
- if (seen.has(key)) return false;
814
- seen.add(key);
815
- return true;
816
- });
817
- }
818
-
819
- // =============================================================================
820
- // SUPPRESSION COUNTING
821
- // =============================================================================
822
-
823
- function countSuppressions(files) {
824
- const suppressions = {};
825
- let total = 0;
826
- for (const file of files) {
827
- try {
828
- const content = fs.readFileSync(file, 'utf-8');
829
- const lines = content.split('\n');
830
- for (const line of lines) {
831
- if (/ship-safe-ignore/i.test(line)) {
832
- total++;
833
- // Try to extract rule name from comment: ship-safe-ignore RULE_NAME
834
- const match = line.match(/ship-safe-ignore\s+(\w+)/i);
835
- const rule = match ? match[1] : '_unspecified';
836
- suppressions[rule] = (suppressions[rule] || 0) + 1;
837
- }
838
- }
839
- } catch { /* skip */ }
840
- }
841
- return total > 0 ? { total, rules: suppressions } : null;
842
- }
843
-
844
- // =============================================================================
845
- // CSV OUTPUT
846
- // =============================================================================
847
-
848
- function outputCSV(findings, depVulns, scoreResult, rootPath) {
849
- const escape = (s) => {
850
- if (!s) return '';
851
- const str = String(s).replace(/"/g, '""');
852
- return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str}"` : str;
853
- };
854
-
855
- console.log('severity,category,rule,file,line,title,description,fix');
856
- for (const f of findings) {
857
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
858
- console.log([
859
- escape(f.severity), escape(f.category), escape(f.rule),
860
- escape(relFile), f.line || '', escape(f.title),
861
- escape(f.description), escape(f.fix),
862
- ].join(','));
863
- }
864
- for (const d of depVulns) {
865
- console.log([
866
- escape(d.severity), 'deps', escape(d.id || d.package),
867
- 'package.json', '', escape(`Vulnerable: ${d.package || d.id}`),
868
- escape(d.description), escape('Update to patched version'),
869
- ].join(','));
870
- }
871
- }
872
-
873
- // =============================================================================
874
- // MARKDOWN OUTPUT
875
- // =============================================================================
876
-
877
- function outputMarkdown(scoreResult, findings, depVulns, remediationPlan, rootPath) {
878
- const lines = [];
879
- lines.push('# Ship Safe Security Report');
880
- lines.push('');
881
- lines.push(`**Score: ${scoreResult.score}/100 (${scoreResult.grade.letter})** — ${scoreResult.grade.label}`);
882
- lines.push('');
883
- lines.push(`> Generated: ${new Date().toISOString()}`);
884
- lines.push('');
885
-
886
- // Category breakdown
887
- lines.push('## Category Breakdown');
888
- lines.push('');
889
- lines.push('| Category | Issues | Deduction |');
890
- lines.push('|----------|--------|-----------|');
891
- for (const [, cat] of Object.entries(scoreResult.categories)) {
892
- const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
893
- lines.push(`| ${cat.label} | ${count} | -${cat.deduction} |`);
894
- }
895
- lines.push('');
896
-
897
- // Findings by severity
898
- for (const sev of SEV_ORDER) {
899
- const sevFindings = findings.filter(f => f.severity === sev);
900
- if (sevFindings.length === 0) continue;
901
-
902
- lines.push(`## ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${sevFindings.length})`);
903
- lines.push('');
904
- lines.push('| File | Rule | Description | Fix |');
905
- lines.push('|------|------|-------------|-----|');
906
- for (const f of sevFindings) {
907
- const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
908
- lines.push(`| ${relFile}:${f.line} | ${f.rule} | ${(f.description || '').slice(0, 80)} | ${(f.fix || '').slice(0, 60)} |`);
909
- }
910
- lines.push('');
911
- }
912
-
913
- // Dep vulns
914
- if (depVulns.length > 0) {
915
- lines.push('## Dependency Vulnerabilities');
916
- lines.push('');
917
- lines.push('| Severity | Package | Description |');
918
- lines.push('|----------|---------|-------------|');
919
- for (const d of depVulns) {
920
- lines.push(`| ${d.severity} | ${d.package || d.id} | ${(d.description || '').slice(0, 80)} |`);
921
- }
922
- lines.push('');
923
- }
924
-
925
- console.log(lines.join('\n'));
926
- }
927
-
928
- // =============================================================================
929
- // COMPARISON OUTPUT
930
- // =============================================================================
931
-
932
- function printComparison(scoringEngine, rootPath, scoreResult) {
933
- const history = scoringEngine.loadHistory(rootPath);
934
- if (history.length < 2) {
935
- console.log(chalk.gray('\n No previous scan to compare against.'));
936
- return;
937
- }
938
-
939
- const prev = history[history.length - 2];
940
- console.log();
941
- console.log(chalk.cyan.bold(' Detailed Comparison'));
942
- console.log(chalk.gray(' ' + '─'.repeat(56)));
943
- console.log(chalk.gray(` Previous scan: ${new Date(prev.timestamp).toLocaleString()}`));
944
- console.log();
945
- console.log(chalk.white(' Category'.padEnd(26)) + chalk.white('Previous'.padEnd(12)) + chalk.white('Current'.padEnd(12)) + chalk.white('Delta'));
946
- console.log(chalk.gray(' ' + ''.repeat(56)));
947
-
948
- for (const [key, cat] of Object.entries(scoreResult.categories)) {
949
- const prevCat = prev.categoryScores?.[key];
950
- const prevDed = prevCat ? prevCat.deduction : 0;
951
- const curDed = cat.deduction;
952
- const delta = prevDed - curDed;
953
-
954
- let deltaStr;
955
- if (delta > 0) deltaStr = chalk.green(`+${delta} improved`);
956
- else if (delta < 0) deltaStr = chalk.red(`${delta} regressed`);
957
- else deltaStr = chalk.gray('→ unchanged');
958
-
959
- console.log(
960
- ` ${chalk.white(cat.label.padEnd(24))}` +
961
- `${chalk.gray(String(-prevDed).padEnd(12))}` +
962
- `${chalk.gray(String(-curDed).padEnd(12))}` +
963
- deltaStr
964
- );
965
- }
966
-
967
- const overallDiff = scoreResult.score - prev.score;
968
- let overallDelta;
969
- if (overallDiff > 0) overallDelta = chalk.green(`+${overallDiff} improved`);
970
- else if (overallDiff < 0) overallDelta = chalk.red(`${overallDiff} regressed`);
971
- else overallDelta = chalk.gray(' unchanged');
972
-
973
- console.log(chalk.gray(' ' + '─'.repeat(56)));
974
- console.log(
975
- ` ${chalk.white.bold('Overall'.padEnd(24))}` +
976
- `${chalk.gray(`${prev.score}/100 ${prev.grade}`.padEnd(12))}` +
977
- `${chalk.gray(`${scoreResult.score}/100 ${scoreResult.grade.letter}`.padEnd(12))}` +
978
- overallDelta
979
- );
980
- }
1
+ /**
2
+ * Audit Command — Full Security Audit
3
+ * =====================================
4
+ *
5
+ * One command to run everything: secrets, agents, deps, score, and
6
+ * generate a comprehensive report with a prioritized remediation plan.
7
+ *
8
+ * USAGE:
9
+ * npx ship-safe audit [path] Full audit with HTML report
10
+ * npx ship-safe audit . --json JSON output
11
+ * npx ship-safe audit . --html report.html Custom report path
12
+ * npx ship-safe audit . --no-deps Skip dependency audit
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import chalk from 'chalk';
18
+ import ora from 'ora';
19
+ import fg from 'fast-glob';
20
+ import { buildOrchestrator } from '../agents/index.js';
21
+ import { LegalRiskAgent } from '../agents/legal-risk-agent.js';
22
+ import { ScoringEngine } from '../agents/scoring-engine.js';
23
+ import { PolicyEngine } from '../agents/policy-engine.js';
24
+ import { HTMLReporter } from '../agents/html-reporter.js';
25
+ import { SBOMGenerator } from '../agents/sbom-generator.js';
26
+ import { autoDetectProvider } from '../providers/llm-provider.js';
27
+ import { runDepsAudit } from './deps.js';
28
+ import {
29
+ SECRET_PATTERNS,
30
+ SECURITY_PATTERNS,
31
+ SKIP_DIRS,
32
+ SKIP_EXTENSIONS,
33
+ SKIP_FILENAMES,
34
+ MAX_FILE_SIZE,
35
+ loadGitignorePatterns
36
+ } from '../utils/patterns.js';
37
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
38
+ import { CacheManager } from '../utils/cache-manager.js';
39
+ import { filterBaseline } from './baseline.js';
40
+ import { generatePDF, generatePrintHTML, isChromeAvailable } from '../utils/pdf-generator.js';
41
+ import { SecretsVerifier } from '../utils/secrets-verifier.js';
42
+
43
+ // =============================================================================
44
+ // CONSTANTS
45
+ // =============================================================================
46
+
47
+ const ALL_PATTERNS = [...SECRET_PATTERNS, ...SECURITY_PATTERNS];
48
+
49
+ const SEV_ORDER = ['critical', 'high', 'medium', 'low'];
50
+
51
+ const CATEGORY_LABELS = {
52
+ secrets: 'Secrets',
53
+ injection: 'Code Vulnerabilities',
54
+ deps: 'Dependencies',
55
+ auth: 'Auth & Access Control',
56
+ config: 'Configuration',
57
+ 'supply-chain': 'Supply Chain',
58
+ api: 'API Security',
59
+ llm: 'AI/LLM Security',
60
+ legal: 'Legal Risk',
61
+ };
62
+
63
+ const EFFORT_MAP = {
64
+ secrets: 'low',
65
+ config: 'low',
66
+ deps: 'medium',
67
+ injection: 'medium',
68
+ auth: 'medium',
69
+ 'supply-chain': 'medium',
70
+ api: 'medium',
71
+ llm: 'high',
72
+ legal: 'low',
73
+ };
74
+
75
+ // =============================================================================
76
+ // MAIN COMMAND
77
+ // =============================================================================
78
+
79
+ export async function auditCommand(targetPath = '.', options = {}) {
80
+ const absolutePath = path.resolve(targetPath);
81
+ const machineOutput = options.json || options.sarif || options.csv || options.md;
82
+
83
+ if (!fs.existsSync(absolutePath)) {
84
+ console.error(chalk.red(` Path does not exist: ${absolutePath}`));
85
+ process.exit(1);
86
+ }
87
+
88
+ if (!machineOutput) {
89
+ console.log();
90
+ console.log(chalk.cyan('═'.repeat(60)));
91
+ console.log(chalk.cyan.bold(' Ship Safe — Full Security Audit'));
92
+ console.log(chalk.cyan('═'.repeat(60)));
93
+ console.log();
94
+ }
95
+
96
+ // ── Cache Layer ──────────────────────────────────────────────────────────
97
+ const useCache = options.cache !== false;
98
+ const cache = new CacheManager(absolutePath);
99
+ let cacheData = useCache ? cache.load() : null;
100
+ let cacheDiff = null;
101
+ let allFiles = [];
102
+
103
+ // ── Phase 1: Secret Scan ──────────────────────────────────────────────────
104
+ const secretSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 1/4] Scanning for secrets...'), color: 'cyan' }).start();
105
+ let secretFindings = [];
106
+ let filesScanned = 0;
107
+
108
+ try {
109
+ allFiles = await findFiles(absolutePath);
110
+ filesScanned = allFiles.length;
111
+
112
+ // Determine which files need scanning (incremental if cache exists)
113
+ let filesToScan = allFiles;
114
+ let cachedSecretFindings = [];
115
+
116
+ if (cacheData) {
117
+ cacheDiff = cache.diff(allFiles);
118
+ filesToScan = cacheDiff.changedFiles;
119
+ // Reuse cached findings for unchanged files (secrets only)
120
+ cachedSecretFindings = cacheDiff.cachedFindings.filter(
121
+ f => f.category === 'secrets' || f.category === 'secret'
122
+ );
123
+ }
124
+
125
+ for (const file of filesToScan) {
126
+ const fileResults = scanFileForSecrets(file);
127
+ for (const f of fileResults) {
128
+ secretFindings.push({
129
+ file,
130
+ line: f.line,
131
+ column: f.column,
132
+ severity: f.severity,
133
+ category: f.category || 'secrets',
134
+ rule: f.patternName,
135
+ title: f.patternName.replace(/_/g, ' '),
136
+ description: f.description,
137
+ matched: f.matched,
138
+ confidence: f.confidence,
139
+ fix: file.match(/\.env(\..*)?$/)
140
+ ? `Ensure .env is in .gitignore and use a secrets manager for production`
141
+ : `Move to environment variable or secrets manager`,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Downgrade .env findings if the file is gitignored (properly managed)
147
+ const gitignoreContent = (() => {
148
+ try { return fs.readFileSync(path.join(absolutePath, '.gitignore'), 'utf-8'); } catch { return ''; }
149
+ })();
150
+ const envIsGitignored = gitignoreContent.split('\n')
151
+ .map(l => l.trim())
152
+ .some(l => /^\.env(\s|$)/.test(l) || l === '*.env' || l === '.env*' || l === '.env.local' || l === '.env.production');
153
+
154
+ if (envIsGitignored) {
155
+ for (const f of secretFindings) {
156
+ if (f.file.match(/\.env(\..*)?$/) && !f.file.includes('node_modules')) {
157
+ f.severity = 'low';
158
+ f.confidence = 'low';
159
+ f.fix = 'Already gitignored — ensure secrets manager is used for production deploys';
160
+ }
161
+ }
162
+ }
163
+
164
+ // Downgrade secrets in test files (intentional test fixtures)
165
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
166
+ for (const f of secretFindings) {
167
+ if (TEST_PATH.test(f.file)) {
168
+ f.confidence = 'low';
169
+ }
170
+ }
171
+
172
+ // Merge with cached findings for unchanged files
173
+ secretFindings = [...secretFindings, ...cachedSecretFindings];
174
+
175
+ const cacheNote = cacheDiff && cacheDiff.changedFiles.length < allFiles.length
176
+ ? ` (${cacheDiff.changedFiles.length} changed, ${cacheDiff.unchangedCount} cached)`
177
+ : '';
178
+
179
+ if (secretSpinner) secretSpinner.succeed(
180
+ secretFindings.length === 0
181
+ ? chalk.green(`[Phase 1/4] Secrets: clean${cacheNote}`)
182
+ : chalk.red(`[Phase 1/4] Secrets: ${secretFindings.length} found${cacheNote}`)
183
+ );
184
+ } catch (err) {
185
+ if (secretSpinner) secretSpinner.fail(chalk.red(`[Phase 1/4] Secret scan failed: ${err.message}`));
186
+ }
187
+
188
+ // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
189
+ const orchestrator = buildOrchestrator();
190
+ const registeredAgentCount = orchestrator.agents?.length || 15;
191
+ const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
192
+ let agentFindings = [];
193
+ let recon = null;
194
+ let agentResults = [];
195
+
196
+ try {
197
+ // Suppress individual agent spinners by using quiet mode
198
+ // Pass changedFiles for incremental scanning if cache is valid
199
+ const orchestratorOpts = { quiet: true };
200
+ if (options.deep) orchestratorOpts.deep = true;
201
+ if (options.local) orchestratorOpts.local = true;
202
+ if (options.model) orchestratorOpts.model = options.model;
203
+ if (options.provider) orchestratorOpts.provider = options.provider;
204
+ if (options.baseUrl) orchestratorOpts.baseUrl = options.baseUrl;
205
+ if (options.budget) orchestratorOpts.budget = options.budget;
206
+ if (options.verbose) orchestratorOpts.verbose = true;
207
+ if (cacheDiff && cacheDiff.changedFiles.length < allFiles.length) {
208
+ orchestratorOpts.changedFiles = cacheDiff.changedFiles;
209
+ }
210
+ const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
211
+ recon = results.recon;
212
+ agentFindings = results.findings;
213
+ agentResults = results.agentResults;
214
+
215
+ const totalAgentFindings = agentFindings.length;
216
+ const agentCount = agentResults.filter(a => a.success).length;
217
+ if (agentSpinner) agentSpinner.succeed(
218
+ totalAgentFindings === 0
219
+ ? chalk.green(`[Phase 2/4] ${agentCount} agents: clean`)
220
+ : chalk.yellow(`[Phase 2/4] ${agentCount} agents: ${totalAgentFindings} finding(s)`)
221
+ );
222
+ } catch (err) {
223
+ if (agentSpinner) agentSpinner.fail(chalk.red(`[Phase 2/4] Agent scan failed: ${err.message}`));
224
+ }
225
+
226
+ // ── Phase 3: Dependency Audit ─────────────────────────────────────────────
227
+ let depVulns = [];
228
+ if (options.deps !== false) {
229
+ const depSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 3/4] Auditing dependencies...'), color: 'cyan' }).start();
230
+ try {
231
+ const depResult = await runDepsAudit(absolutePath);
232
+ depVulns = depResult.vulns || [];
233
+ if (depSpinner) depSpinner.succeed(
234
+ depVulns.length === 0
235
+ ? chalk.green('[Phase 3/4] Dependencies: clean')
236
+ : chalk.red(`[Phase 3/4] Dependencies: ${depVulns.length} CVE(s)`)
237
+ );
238
+ } catch {
239
+ if (depSpinner) depSpinner.succeed(chalk.gray('[Phase 3/4] Dependencies: skipped (no manifest)'));
240
+ }
241
+ } else if (!machineOutput) {
242
+ console.log(chalk.gray(' [Phase 3/4] Dependencies: skipped (--no-deps)'));
243
+ }
244
+
245
+ // ── Phase 3b: Legal Risk Scan (opt-in) ───────────────────────────────────
246
+ let legalFindings = [];
247
+ if (options.includeLegal) {
248
+ const legalSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 3b] Legal risk scan…'), color: 'cyan' }).start();
249
+ try {
250
+ const legalAgent = new LegalRiskAgent();
251
+ legalFindings = await legalAgent.analyze({ rootPath: absolutePath, files: allFiles });
252
+ if (legalSpinner) legalSpinner.succeed(
253
+ legalFindings.length === 0
254
+ ? chalk.green('[Phase 3b] Legal: clean')
255
+ : chalk.yellow(`[Phase 3b] Legal: ${legalFindings.length} finding(s)`)
256
+ );
257
+ } catch {
258
+ if (legalSpinner) legalSpinner.succeed(chalk.gray('[Phase 3b] Legal: skipped'));
259
+ }
260
+ }
261
+
262
+ // ── Phase 4: Merge, Score, and Build Plan ─────────────────────────────────
263
+ const scoreSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 4/4] Computing security score...'), color: 'cyan' }).start();
264
+
265
+ // Merge secret findings + agent findings + legal findings, deduplicate
266
+ const allFindings = deduplicateFindings([...secretFindings, ...agentFindings, ...legalFindings]);
267
+
268
+ // Apply policy
269
+ const policy = PolicyEngine.load(absolutePath);
270
+ let filteredFindings = policy.applyPolicy(allFindings);
271
+
272
+ // Apply baseline filter (only show new findings)
273
+ if (options.baseline) {
274
+ const beforeCount = filteredFindings.length;
275
+ filteredFindings = filterBaseline(filteredFindings, absolutePath);
276
+ if (!machineOutput && beforeCount !== filteredFindings.length) {
277
+ console.log(chalk.gray(` Baseline: ${beforeCount - filteredFindings.length} known finding(s) filtered, ${filteredFindings.length} new`));
278
+ }
279
+ }
280
+
281
+ // Count suppressions (ship-safe-ignore comments)
282
+ const suppressions = countSuppressions(allFiles);
283
+
284
+ // Score
285
+ const scoringEngine = new ScoringEngine();
286
+ const scoreResult = scoringEngine.compute(filteredFindings, depVulns);
287
+ // Round score to 1 decimal place to avoid floating-point noise (e.g., 63.300000000000004)
288
+ scoreResult.score = Math.round(scoreResult.score * 10) / 10;
289
+ scoringEngine.saveToHistory(absolutePath, scoreResult, suppressions);
290
+
291
+ const gradeColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
292
+ if (scoreSpinner) scoreSpinner.succeed(
293
+ chalk.white('[Phase 4/4] Score: ') + gradeColor(`${scoreResult.score}/100 ${scoreResult.grade.letter}`)
294
+ );
295
+
296
+ // ── AI Classification (optional, with LLM cache) ───────────────────────
297
+ if (options.ai !== false) {
298
+ const provider = autoDetectProvider(absolutePath, {
299
+ provider: options.provider,
300
+ baseUrl: options.baseUrl,
301
+ model: options.model,
302
+ });
303
+ if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
304
+ const aiSpinner = machineOutput ? null : ora({ text: `Classifying with ${provider.name}...`, color: 'cyan' }).start();
305
+ try {
306
+ // Check LLM cache for existing classifications
307
+ const llmCache = cache.loadLLMClassifications();
308
+ const uncachedFindings = [];
309
+ let cachedCount = 0;
310
+
311
+ for (const finding of filteredFindings) {
312
+ const key = cache.getLLMCacheKey(finding);
313
+ const cached = llmCache[key];
314
+ if (cached) {
315
+ finding.aiClassification = cached.classification;
316
+ finding.aiReason = cached.reason;
317
+ finding.aiFix = cached.fix;
318
+ cachedCount++;
319
+ } else {
320
+ uncachedFindings.push(finding);
321
+ }
322
+ }
323
+
324
+ // Only send uncached findings to LLM
325
+ if (uncachedFindings.length > 0) {
326
+ const classifications = await provider.classify(uncachedFindings);
327
+ const newCacheEntries = {};
328
+ for (const cl of classifications) {
329
+ const finding = filteredFindings.find(f => `${f.file}:${f.line}` === cl.id);
330
+ if (finding) {
331
+ finding.aiClassification = cl.classification;
332
+ finding.aiReason = cl.reason;
333
+ finding.aiFix = cl.fix;
334
+ const key = cache.getLLMCacheKey(finding);
335
+ newCacheEntries[key] = {
336
+ classification: cl.classification,
337
+ reason: cl.reason,
338
+ fix: cl.fix,
339
+ cachedAt: new Date().toISOString(),
340
+ };
341
+ }
342
+ }
343
+ cache.saveLLMClassifications(newCacheEntries);
344
+ }
345
+
346
+ const cacheNote = cachedCount > 0 ? `, ${cachedCount} cached` : '';
347
+ if (aiSpinner) aiSpinner.succeed(chalk.green(`AI classification complete (${provider.name}${cacheNote})`));
348
+ } catch (err) {
349
+ if (aiSpinner) aiSpinner.fail(chalk.yellow(`AI classification failed: ${err.message}`));
350
+ }
351
+ }
352
+ }
353
+
354
+ // ── Secrets Verification (optional, --verify flag) ─────────────────────
355
+ if (options.verify) {
356
+ const verifySpinner = machineOutput ? null : ora({ text: 'Verifying leaked secrets against provider APIs...', color: 'cyan' }).start();
357
+ try {
358
+ const verifier = new SecretsVerifier();
359
+ const verifyResults = await verifier.verify(filteredFindings);
360
+ const activeCount = verifyResults.filter(r => r.result.active === true).length;
361
+ const inactiveCount = verifyResults.filter(r => r.result.active === false).length;
362
+ if (verifySpinner) {
363
+ verifySpinner.succeed(chalk.green(
364
+ `Secrets verified: ${activeCount} active, ${inactiveCount} inactive, ${verifyResults.length - activeCount - inactiveCount} unknown`
365
+ ));
366
+ }
367
+ // Show active secrets warning
368
+ if (activeCount > 0 && !machineOutput) {
369
+ console.log(chalk.red.bold(' ACTIVE SECRETS DETECTED rotate immediately:'));
370
+ for (const r of verifyResults.filter(r => r.result.active === true)) {
371
+ const rel = path.relative(absolutePath, r.finding.file).replace(/\\/g, '/');
372
+ console.log(chalk.red(` ${r.result.provider}: ${rel}:${r.finding.line} ${r.result.info}`));
373
+ }
374
+ }
375
+ } catch (err) {
376
+ if (verifySpinner) verifySpinner.fail(chalk.yellow(`Secrets verification failed: ${err.message}`));
377
+ }
378
+ }
379
+
380
+ // ── Save Cache ──────────────────────────────────────────────────────────
381
+ if (useCache) {
382
+ try {
383
+ // Merge agent findings back for cache (secret + agent findings from changed files)
384
+ // plus cached findings from unchanged files
385
+ const cachedAgentFindings = cacheData && cacheDiff
386
+ ? cacheDiff.cachedFindings.filter(f => f.category !== 'secrets' && f.category !== 'secret')
387
+ : [];
388
+ const allFindingsForCache = [...secretFindings, ...agentFindings, ...cachedAgentFindings];
389
+ cache.save(allFiles, deduplicateFindings(allFindingsForCache), recon, scoreResult);
390
+ } catch {
391
+ // Silent caching should never break a scan
392
+ }
393
+ }
394
+
395
+ // ── Build Remediation Plan ────────────────────────────────────────────────
396
+ const remediationPlan = buildRemediationPlan(filteredFindings, depVulns, absolutePath);
397
+
398
+ // ── Output ────────────────────────────────────────────────────────────────
399
+ console.log();
400
+
401
+ if (options.csv) {
402
+ outputCSV(filteredFindings, depVulns, scoreResult, absolutePath);
403
+ } else if (options.md) {
404
+ outputMarkdown(scoreResult, filteredFindings, depVulns, remediationPlan, absolutePath);
405
+ } else if (options.json) {
406
+ outputJSON(scoreResult, filteredFindings, depVulns, recon, agentResults, remediationPlan, suppressions, options.compare ? scoringEngine.loadHistory(absolutePath) : null);
407
+ } else if (options.sarif) {
408
+ outputSARIF(filteredFindings, absolutePath);
409
+ } else {
410
+ printReport(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, filesScanned);
411
+ }
412
+
413
+ // ── HTML Report (always generate unless machine output) ───────────────────
414
+ if (!options.json && !options.sarif && !options.csv && !options.md) {
415
+ const htmlPath = typeof options.html === 'string' ? options.html : 'ship-safe-report.html';
416
+ const reporter = new HTMLReporter();
417
+ reporter.generateFullReport(scoreResult, filteredFindings, depVulns, recon, remediationPlan, absolutePath, htmlPath);
418
+ console.log();
419
+ console.log(chalk.cyan(` Full report: ${chalk.white.bold(htmlPath)}`));
420
+ console.log(chalk.gray(` Dashboard: `) + chalk.cyan('https://shipsafecli.com/app'));
421
+
422
+ // PDF export
423
+ if (options.pdf) {
424
+ const pdfPath = typeof options.pdf === 'string' ? options.pdf : 'ship-safe-report.pdf';
425
+ const result = generatePDF(path.resolve(htmlPath), path.resolve(pdfPath));
426
+ if (result) {
427
+ console.log(chalk.cyan(` PDF report: ${chalk.white.bold(pdfPath)}`));
428
+ } else {
429
+ // Fallback: print-optimized HTML
430
+ const fallbackPath = pdfPath.replace(/\.pdf$/, '.print.html');
431
+ generatePrintHTML(path.resolve(htmlPath), path.resolve(fallbackPath));
432
+ console.log(chalk.yellow(` Chrome not found saved print-optimized HTML: ${fallbackPath}`));
433
+ console.log(chalk.gray(' Open in a browser and Print → Save as PDF'));
434
+ }
435
+ }
436
+ }
437
+
438
+ if (!machineOutput && !options.csv && !options.md) {
439
+ // ── Policy Violations ──────────────────────────────────────────────────
440
+ const violations = policy.evaluate(scoreResult, filteredFindings);
441
+ if (violations.length > 0) {
442
+ console.log();
443
+ console.log(chalk.red.bold(' Policy Violations:'));
444
+ for (const v of violations.slice(0, 5)) {
445
+ console.log(chalk.red(` ✗ ${v.message}`));
446
+ }
447
+ }
448
+
449
+ // ── Trend ───────────────────────────────────────────────────────────────
450
+ const trend = scoringEngine.getTrend(absolutePath, scoreResult.score);
451
+ if (trend) {
452
+ const arrow = trend.diff > 0 ? chalk.green('↑') : trend.diff < 0 ? chalk.red('↓') : chalk.gray('→');
453
+ const roundedDiff = Math.round(trend.diff * 10) / 10;
454
+ const diffLabel = roundedDiff === 0 ? chalk.gray('no change') : chalk.white(`${roundedDiff > 0 ? '+' : ''}${roundedDiff}`);
455
+ console.log(chalk.gray(` Trend: ${trend.previousScore} → ${trend.currentScore} ${arrow} (`) + diffLabel + chalk.gray(')'));
456
+ }
457
+
458
+ // ── Detailed Comparison ────────────────────────────────────────────────
459
+ if (options.compare) {
460
+ printComparison(scoringEngine, absolutePath, scoreResult);
461
+ }
462
+
463
+ console.log();
464
+ console.log(chalk.cyan('═'.repeat(60)));
465
+ console.log();
466
+ }
467
+
468
+ process.exit(scoreResult.score >= 75 ? 0 : 1);
469
+ }
470
+
471
+ // =============================================================================
472
+ // REMEDIATION PLAN BUILDER
473
+ // =============================================================================
474
+
475
+ function buildRemediationPlan(findings, depVulns, rootPath) {
476
+ const plan = [];
477
+ let priority = 1;
478
+
479
+ // Exclude low-confidence findings (test files, docs, comments) from remediation plan
480
+ const actionable = findings.filter(f => f.confidence !== 'low');
481
+
482
+ // Priority order: secrets first, then by severity
483
+ const secretFindings = actionable.filter(f => f.category === 'secrets' || f.category === 'secret');
484
+ const otherFindings = actionable.filter(f => f.category !== 'secrets' && f.category !== 'secret');
485
+
486
+ // Group and sort
487
+ for (const sev of SEV_ORDER) {
488
+ // Secrets at this severity — group .env findings by file
489
+ const sevSecrets = secretFindings.filter(s => s.severity === sev);
490
+ const envGroups = new Map();
491
+ const nonEnvSecrets = [];
492
+
493
+ for (const f of sevSecrets) {
494
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
495
+ if (f.file.match(/\.env(\..*)?$/)) {
496
+ if (!envGroups.has(relFile)) envGroups.set(relFile, []);
497
+ envGroups.get(relFile).push(f);
498
+ } else {
499
+ nonEnvSecrets.push(f);
500
+ }
501
+ }
502
+
503
+ // One plan item per .env file
504
+ for (const [relFile, envFindings] of envGroups) {
505
+ const names = envFindings.map(f => f.title || f.rule).join(', ');
506
+ plan.push({
507
+ priority: priority++,
508
+ severity: sev,
509
+ category: 'secrets',
510
+ categoryLabel: 'SECRETS',
511
+ title: `${envFindings.length} secret${envFindings.length > 1 ? 's' : ''} in ${relFile} (${names})`,
512
+ file: relFile,
513
+ action: envFindings[0].fix || 'Ensure .env is in .gitignore and use a secrets manager for production',
514
+ effort: 'low',
515
+ });
516
+ }
517
+
518
+ // Individual items for non-.env secrets
519
+ for (const f of nonEnvSecrets) {
520
+ plan.push({
521
+ priority: priority++,
522
+ severity: sev,
523
+ category: 'secrets',
524
+ categoryLabel: 'SECRETS',
525
+ title: f.title || f.rule,
526
+ file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
527
+ action: f.aiFix || f.fix || f.description,
528
+ effort: 'low',
529
+ });
530
+ }
531
+
532
+ // Other findings at this severity
533
+ for (const f of otherFindings.filter(s => s.severity === sev)) {
534
+ plan.push({
535
+ priority: priority++,
536
+ severity: sev,
537
+ category: f.category,
538
+ categoryLabel: (CATEGORY_LABELS[f.category] || f.category).toUpperCase(),
539
+ title: f.title || f.rule,
540
+ file: `${path.relative(rootPath, f.file).replace(/\\/g, '/')}:${f.line}`,
541
+ action: f.aiFix || f.fix || f.description,
542
+ effort: EFFORT_MAP[f.category] || 'medium',
543
+ });
544
+ }
545
+
546
+ // Dep vulns at this severity
547
+ for (const d of depVulns.filter(v => v.severity === sev || (sev === 'medium' && v.severity === 'moderate'))) {
548
+ plan.push({
549
+ priority: priority++,
550
+ severity: sev,
551
+ category: 'deps',
552
+ categoryLabel: 'DEPENDENCIES',
553
+ title: `Vulnerable: ${d.package || d.id}`,
554
+ file: 'package.json',
555
+ action: d.description ? `${d.description.slice(0, 80)}` : 'Update to patched version',
556
+ effort: 'medium',
557
+ });
558
+ }
559
+ }
560
+
561
+ return plan;
562
+ }
563
+
564
+ // =============================================================================
565
+ // CONSOLE OUTPUT
566
+ // =============================================================================
567
+
568
+ function printReport(scoreResult, findings, depVulns, recon, plan, rootPath, filesScanned) {
569
+ const GRADE_COLOR = { A: chalk.green.bold, B: chalk.cyan.bold, C: chalk.yellow.bold, D: chalk.red, F: chalk.red.bold };
570
+ const SEV_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' };
571
+ const SEV_LABEL = { critical: 'CRITICAL — fix immediately', high: 'HIGH — fix before deploy', medium: 'MEDIUM — fix soon', low: 'LOW — review when possible' };
572
+
573
+ // ── Score ─────────────────────────────────────────────────────────────────
574
+ const gradeColor = GRADE_COLOR[scoreResult.grade.letter] || chalk.white;
575
+ const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
576
+
577
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
578
+ console.log(
579
+ chalk.white.bold(' Security Score: ') +
580
+ scoreColor(`${scoreResult.score}/100 `) +
581
+ gradeColor(scoreResult.grade.letter) +
582
+ chalk.gray(` — ${scoreResult.grade.label}`)
583
+ );
584
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
585
+ console.log();
586
+
587
+ // ── Category Breakdown ────────────────────────────────────────────────────
588
+ console.log(chalk.white.bold(' Category Breakdown'));
589
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
590
+
591
+ for (const [key, cat] of Object.entries(scoreResult.categories)) {
592
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
593
+ const icon = count === 0 ? chalk.green('✔') : chalk.red('✘');
594
+ const status = count === 0 ? chalk.green('clean') : chalk.red(`${count} issue(s)`);
595
+ const deduction = cat.deduction > 0 ? chalk.red(`-${Math.round(cat.deduction * 10) / 10} pts`) : chalk.gray('+0');
596
+ console.log(` ${icon} ${chalk.white(cat.label.padEnd(22))} ${status.padEnd(25)} ${deduction}`);
597
+ }
598
+
599
+ // Deps row — only print if not already included in scoreResult.categories
600
+ const hasDepsCategory = Object.values(scoreResult.categories).some(c => c.label?.toLowerCase().includes('depend'));
601
+ if (!hasDepsCategory) {
602
+ const depIcon = depVulns.length === 0 ? chalk.green('✔') : chalk.red('✘');
603
+ const depStatus = depVulns.length === 0 ? chalk.green('clean') : chalk.red(`${depVulns.length} CVE(s)`);
604
+ console.log(` ${depIcon} ${chalk.white('Dependencies'.padEnd(22))} ${depStatus}`);
605
+ }
606
+
607
+ console.log(chalk.gray(`\n Files scanned: ${filesScanned} | Findings: ${findings.length} | CVEs: ${depVulns.length}`));
608
+
609
+ // ── Remediation Plan ──────────────────────────────────────────────────────
610
+ if (plan.length > 0) {
611
+ console.log();
612
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
613
+ console.log(chalk.cyan.bold(' Remediation Plan'));
614
+ console.log(chalk.cyan(' ' + '═'.repeat(56)));
615
+
616
+ let currentSev = null;
617
+ let shown = 0;
618
+ const maxItems = 30;
619
+
620
+ for (const item of plan) {
621
+ if (shown >= maxItems) {
622
+ console.log(chalk.gray(`\n ... and ${plan.length - maxItems} more items in the full report`));
623
+ break;
624
+ }
625
+
626
+ if (item.severity !== currentSev) {
627
+ currentSev = item.severity;
628
+ console.log();
629
+ console.log(chalk.white.bold(` ${SEV_ICON[currentSev] || '⚪'} ${SEV_LABEL[currentSev] || currentSev.toUpperCase()}`));
630
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
631
+ }
632
+
633
+ console.log(
634
+ chalk.white(` ${String(item.priority).padStart(2)}.`) +
635
+ chalk.gray(` [${item.categoryLabel}] `) +
636
+ chalk.white(item.title)
637
+ );
638
+ console.log(
639
+ chalk.gray(` ${item.file}`) +
640
+ chalk.gray(' ') +
641
+ chalk.green((item.action || '').slice(0, 70))
642
+ );
643
+ shown++;
644
+ }
645
+ } else {
646
+ console.log();
647
+ console.log(chalk.green.bold(' All clear safe to ship!'));
648
+ }
649
+
650
+ // ── Attack Surface ────────────────────────────────────────────────────────
651
+ if (recon) {
652
+ console.log();
653
+ console.log(chalk.gray(' Attack Surface:'));
654
+ if (recon.frameworks?.length) console.log(chalk.gray(` Frameworks: ${recon.frameworks.join(', ')}`));
655
+ if (recon.databases?.length) console.log(chalk.gray(` Databases: ${recon.databases.join(', ')}`));
656
+ if (recon.authPatterns?.length) console.log(chalk.gray(` Auth: ${recon.authPatterns.join(', ')}`));
657
+ if (recon.apiRoutes?.length) console.log(chalk.gray(` API Routes: ${recon.apiRoutes.length} discovered`));
658
+ }
659
+ }
660
+
661
+ // =============================================================================
662
+ // JSON OUTPUT
663
+ // =============================================================================
664
+
665
+ function outputJSON(scoreResult, findings, depVulns, recon, agentResults, remediationPlan, suppressions, history) {
666
+ const output = {
667
+ score: scoreResult.score,
668
+ grade: scoreResult.grade.letter,
669
+ gradeLabel: scoreResult.grade.label,
670
+ totalFindings: findings.length,
671
+ totalDepVulns: depVulns.length,
672
+ categories: Object.fromEntries(
673
+ Object.entries(scoreResult.categories).map(([k, v]) => [k, {
674
+ label: v.label,
675
+ findingCount: Object.values(v.counts).reduce((a, b) => a + b, 0),
676
+ deduction: v.deduction,
677
+ counts: v.counts,
678
+ }])
679
+ ),
680
+ findings: findings.map(f => ({
681
+ file: f.file, line: f.line, severity: f.severity, category: f.category,
682
+ rule: f.rule, title: f.title, description: f.description, fix: f.fix,
683
+ cwe: f.cwe, owasp: f.owasp,
684
+ })),
685
+ depVulns: depVulns.map(d => ({
686
+ severity: d.severity, package: d.package || d.id, description: d.description,
687
+ })),
688
+ remediationPlan,
689
+ recon,
690
+ agents: agentResults,
691
+ };
692
+ if (scoreResult.compliance) output.compliance = scoreResult.compliance;
693
+ if (suppressions) output.suppressions = suppressions;
694
+ if (history && history.length >= 2) {
695
+ const prev = history[history.length - 2];
696
+ output.comparison = {
697
+ previousScore: prev.score,
698
+ previousGrade: prev.grade,
699
+ previousDate: prev.timestamp,
700
+ diff: scoreResult.score - prev.score,
701
+ categoryComparison: Object.fromEntries(
702
+ Object.entries(scoreResult.categories).map(([k, v]) => {
703
+ const prevCat = prev.categoryScores?.[k];
704
+ return [k, {
705
+ label: v.label,
706
+ current: -v.deduction,
707
+ previous: prevCat ? -prevCat.deduction : 0,
708
+ delta: prevCat ? prevCat.deduction - v.deduction : 0,
709
+ }];
710
+ })
711
+ ),
712
+ };
713
+ }
714
+ console.log(JSON.stringify(output, null, 2));
715
+ }
716
+
717
+ // =============================================================================
718
+ // SARIF OUTPUT
719
+ // =============================================================================
720
+
721
+ function outputSARIF(findings, rootPath) {
722
+ const rules = {};
723
+ for (const f of findings) {
724
+ if (!rules[f.rule]) {
725
+ rules[f.rule] = {
726
+ id: f.rule,
727
+ name: f.title || f.rule,
728
+ shortDescription: { text: f.title || f.rule },
729
+ fullDescription: { text: f.description || '' },
730
+ defaultConfiguration: {
731
+ level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
732
+ },
733
+ };
734
+ }
735
+ }
736
+
737
+ console.log(JSON.stringify({
738
+ version: '2.1.0',
739
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
740
+ runs: [{
741
+ tool: {
742
+ driver: {
743
+ name: 'ship-safe',
744
+ version: '4.0.0',
745
+ informationUri: 'https://github.com/asamassekou10/ship-safe',
746
+ rules: Object.values(rules),
747
+ }
748
+ },
749
+ results: findings.map(f => ({
750
+ ruleId: f.rule,
751
+ level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
752
+ message: { text: `${f.title}: ${f.description}` },
753
+ locations: [{
754
+ physicalLocation: {
755
+ artifactLocation: { uri: path.relative(rootPath, f.file).replace(/\\/g, '/'), uriBaseId: '%SRCROOT%' },
756
+ region: { startLine: f.line, startColumn: f.column || 1 },
757
+ }
758
+ }],
759
+ })),
760
+ }],
761
+ }, null, 2));
762
+ }
763
+
764
+ // =============================================================================
765
+ // FILE SCANNING (inline from scan.js to avoid circular deps)
766
+ // =============================================================================
767
+
768
+ async function findFiles(rootPath) {
769
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
770
+
771
+ // Respect .gitignore patterns
772
+ const gitignoreGlobs = loadGitignorePatterns(rootPath);
773
+ globIgnore.push(...gitignoreGlobs);
774
+
775
+ // Load .ship-safeignore
776
+ const ignorePath = path.join(rootPath, '.ship-safeignore');
777
+ if (fs.existsSync(ignorePath)) {
778
+ try {
779
+ const patterns = fs.readFileSync(ignorePath, 'utf-8')
780
+ .split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
781
+ for (const p of patterns) {
782
+ if (p.endsWith('/')) { globIgnore.push(`**/${p}**`); }
783
+ else { globIgnore.push(`**/${p}`); globIgnore.push(p); }
784
+ }
785
+ } catch { /* skip */ }
786
+ }
787
+
788
+ const files = await fg('**/*', {
789
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
790
+ });
791
+
792
+ return files.filter(file => {
793
+ const ext = path.extname(file).toLowerCase();
794
+ if (SKIP_EXTENSIONS.has(ext)) return false;
795
+ if (SKIP_FILENAMES.has(path.basename(file))) return false;
796
+ if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
797
+ try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
798
+ return true;
799
+ });
800
+ }
801
+
802
+ function scanFileForSecrets(filePath) {
803
+ const findings = [];
804
+ try {
805
+ const content = fs.readFileSync(filePath, 'utf-8');
806
+ const lines = content.split('\n');
807
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
808
+ const line = lines[lineNum];
809
+ if (/ship-safe-ignore/i.test(line)) continue;
810
+ for (const pattern of SECRET_PATTERNS) {
811
+ pattern.pattern.lastIndex = 0;
812
+ let match;
813
+ while ((match = pattern.pattern.exec(line)) !== null) {
814
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
815
+ findings.push({
816
+ line: lineNum + 1, column: match.index + 1, matched: match[0],
817
+ patternName: pattern.name, severity: pattern.severity,
818
+ confidence: getConfidence(pattern, match[0]),
819
+ description: pattern.description, category: pattern.category || 'secret'
820
+ });
821
+ }
822
+ }
823
+ }
824
+ } catch { /* skip */ }
825
+
826
+ const seen = new Set();
827
+ return findings.filter(f => {
828
+ const key = `${f.line}:${f.matched}`;
829
+ if (seen.has(key)) return false;
830
+ seen.add(key);
831
+ return true;
832
+ });
833
+ }
834
+
835
+ function deduplicateFindings(findings) {
836
+ const seen = new Set();
837
+ return findings.filter(f => {
838
+ const key = `${f.file}:${f.line}:${f.rule}`;
839
+ if (seen.has(key)) return false;
840
+ seen.add(key);
841
+ return true;
842
+ });
843
+ }
844
+
845
+ // =============================================================================
846
+ // SUPPRESSION COUNTING
847
+ // =============================================================================
848
+
849
+ function countSuppressions(files) {
850
+ const suppressions = {};
851
+ let total = 0;
852
+ for (const file of files) {
853
+ try {
854
+ const content = fs.readFileSync(file, 'utf-8');
855
+ const lines = content.split('\n');
856
+ for (const line of lines) {
857
+ if (/ship-safe-ignore/i.test(line)) {
858
+ total++;
859
+ // Try to extract rule name from comment: ship-safe-ignore RULE_NAME
860
+ const match = line.match(/ship-safe-ignore\s+(\w+)/i);
861
+ const rule = match ? match[1] : '_unspecified';
862
+ suppressions[rule] = (suppressions[rule] || 0) + 1;
863
+ }
864
+ }
865
+ } catch { /* skip */ }
866
+ }
867
+ return total > 0 ? { total, rules: suppressions } : null;
868
+ }
869
+
870
+ // =============================================================================
871
+ // CSV OUTPUT
872
+ // =============================================================================
873
+
874
+ function outputCSV(findings, depVulns, scoreResult, rootPath) {
875
+ const escape = (s) => {
876
+ if (!s) return '';
877
+ const str = String(s).replace(/"/g, '""');
878
+ return str.includes(',') || str.includes('"') || str.includes('\n') ? `"${str}"` : str;
879
+ };
880
+
881
+ console.log('severity,category,rule,file,line,title,description,fix');
882
+ for (const f of findings) {
883
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
884
+ console.log([
885
+ escape(f.severity), escape(f.category), escape(f.rule),
886
+ escape(relFile), f.line || '', escape(f.title),
887
+ escape(f.description), escape(f.fix),
888
+ ].join(','));
889
+ }
890
+ for (const d of depVulns) {
891
+ console.log([
892
+ escape(d.severity), 'deps', escape(d.id || d.package),
893
+ 'package.json', '', escape(`Vulnerable: ${d.package || d.id}`),
894
+ escape(d.description), escape('Update to patched version'),
895
+ ].join(','));
896
+ }
897
+ }
898
+
899
+ // =============================================================================
900
+ // MARKDOWN OUTPUT
901
+ // =============================================================================
902
+
903
+ function outputMarkdown(scoreResult, findings, depVulns, remediationPlan, rootPath) {
904
+ const lines = [];
905
+ lines.push('# Ship Safe Security Report');
906
+ lines.push('');
907
+ lines.push(`**Score: ${scoreResult.score}/100 (${scoreResult.grade.letter})** — ${scoreResult.grade.label}`);
908
+ lines.push('');
909
+ lines.push(`> Generated: ${new Date().toISOString()}`);
910
+ lines.push('');
911
+
912
+ // Category breakdown
913
+ lines.push('## Category Breakdown');
914
+ lines.push('');
915
+ lines.push('| Category | Issues | Deduction |');
916
+ lines.push('|----------|--------|-----------|');
917
+ for (const [, cat] of Object.entries(scoreResult.categories)) {
918
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
919
+ lines.push(`| ${cat.label} | ${count} | -${cat.deduction} |`);
920
+ }
921
+ lines.push('');
922
+
923
+ // Findings by severity
924
+ for (const sev of SEV_ORDER) {
925
+ const sevFindings = findings.filter(f => f.severity === sev);
926
+ if (sevFindings.length === 0) continue;
927
+
928
+ lines.push(`## ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${sevFindings.length})`);
929
+ lines.push('');
930
+ lines.push('| File | Rule | Description | Fix |');
931
+ lines.push('|------|------|-------------|-----|');
932
+ for (const f of sevFindings) {
933
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
934
+ lines.push(`| ${relFile}:${f.line} | ${f.rule} | ${(f.description || '').slice(0, 80)} | ${(f.fix || '').slice(0, 60)} |`);
935
+ }
936
+ lines.push('');
937
+ }
938
+
939
+ // Dep vulns
940
+ if (depVulns.length > 0) {
941
+ lines.push('## Dependency Vulnerabilities');
942
+ lines.push('');
943
+ lines.push('| Severity | Package | Description |');
944
+ lines.push('|----------|---------|-------------|');
945
+ for (const d of depVulns) {
946
+ lines.push(`| ${d.severity} | ${d.package || d.id} | ${(d.description || '').slice(0, 80)} |`);
947
+ }
948
+ lines.push('');
949
+ }
950
+
951
+ console.log(lines.join('\n'));
952
+ }
953
+
954
+ // =============================================================================
955
+ // COMPARISON OUTPUT
956
+ // =============================================================================
957
+
958
+ function printComparison(scoringEngine, rootPath, scoreResult) {
959
+ const history = scoringEngine.loadHistory(rootPath);
960
+ if (history.length < 2) {
961
+ console.log(chalk.gray('\n No previous scan to compare against.'));
962
+ return;
963
+ }
964
+
965
+ const prev = history[history.length - 2];
966
+ console.log();
967
+ console.log(chalk.cyan.bold(' Detailed Comparison'));
968
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
969
+ console.log(chalk.gray(` Previous scan: ${new Date(prev.timestamp).toLocaleString()}`));
970
+ console.log();
971
+ console.log(chalk.white(' Category'.padEnd(26)) + chalk.white('Previous'.padEnd(12)) + chalk.white('Current'.padEnd(12)) + chalk.white('Delta'));
972
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
973
+
974
+ for (const [key, cat] of Object.entries(scoreResult.categories)) {
975
+ const prevCat = prev.categoryScores?.[key];
976
+ const prevDed = prevCat ? prevCat.deduction : 0;
977
+ const curDed = cat.deduction;
978
+ const delta = prevDed - curDed;
979
+
980
+ let deltaStr;
981
+ if (delta > 0) deltaStr = chalk.green(`+${delta} improved`);
982
+ else if (delta < 0) deltaStr = chalk.red(`${delta} regressed`);
983
+ else deltaStr = chalk.gray('→ unchanged');
984
+
985
+ console.log(
986
+ ` ${chalk.white(cat.label.padEnd(24))}` +
987
+ `${chalk.gray(String(-prevDed).padEnd(12))}` +
988
+ `${chalk.gray(String(-curDed).padEnd(12))}` +
989
+ deltaStr
990
+ );
991
+ }
992
+
993
+ const overallDiff = scoreResult.score - prev.score;
994
+ let overallDelta;
995
+ if (overallDiff > 0) overallDelta = chalk.green(`+${overallDiff} improved`);
996
+ else if (overallDiff < 0) overallDelta = chalk.red(`${overallDiff} regressed`);
997
+ else overallDelta = chalk.gray('→ unchanged');
998
+
999
+ console.log(chalk.gray(' ' + '─'.repeat(56)));
1000
+ console.log(
1001
+ ` ${chalk.white.bold('Overall'.padEnd(24))}` +
1002
+ `${chalk.gray(`${prev.score}/100 ${prev.grade}`.padEnd(12))}` +
1003
+ `${chalk.gray(`${scoreResult.score}/100 ${scoreResult.grade.letter}`.padEnd(12))}` +
1004
+ overallDelta
1005
+ );
1006
+ }