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