ship-safe 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +200 -307
  2. package/cli/agents/api-fuzzer.js +224 -0
  3. package/cli/agents/auth-bypass-agent.js +326 -0
  4. package/cli/agents/base-agent.js +240 -0
  5. package/cli/agents/cicd-scanner.js +200 -0
  6. package/cli/agents/config-auditor.js +413 -0
  7. package/cli/agents/git-history-scanner.js +167 -0
  8. package/cli/agents/html-reporter.js +363 -0
  9. package/cli/agents/index.js +56 -0
  10. package/cli/agents/injection-tester.js +401 -0
  11. package/cli/agents/llm-redteam.js +251 -0
  12. package/cli/agents/mobile-scanner.js +225 -0
  13. package/cli/agents/orchestrator.js +152 -0
  14. package/cli/agents/policy-engine.js +149 -0
  15. package/cli/agents/recon-agent.js +196 -0
  16. package/cli/agents/sbom-generator.js +176 -0
  17. package/cli/agents/scoring-engine.js +207 -0
  18. package/cli/agents/ssrf-prober.js +130 -0
  19. package/cli/agents/supply-chain-agent.js +274 -0
  20. package/cli/bin/ship-safe.js +119 -2
  21. package/cli/commands/agent.js +606 -0
  22. package/cli/commands/audit.js +565 -0
  23. package/cli/commands/deps.js +447 -0
  24. package/cli/commands/fix.js +3 -3
  25. package/cli/commands/init.js +86 -3
  26. package/cli/commands/mcp.js +2 -2
  27. package/cli/commands/red-team.js +315 -0
  28. package/cli/commands/remediate.js +4 -4
  29. package/cli/commands/rotate.js +6 -6
  30. package/cli/commands/scan.js +64 -23
  31. package/cli/commands/score.js +446 -0
  32. package/cli/commands/watch.js +160 -0
  33. package/cli/index.js +40 -2
  34. package/cli/providers/llm-provider.js +288 -0
  35. package/cli/utils/entropy.js +6 -0
  36. package/cli/utils/output.js +42 -2
  37. package/cli/utils/patterns.js +393 -1
  38. package/package.json +19 -15
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Red Team Command
3
+ * =================
4
+ *
5
+ * Run all security agents against the codebase.
6
+ * This is the main entry point for the multi-agent security audit.
7
+ *
8
+ * USAGE:
9
+ * npx ship-safe red-team [path] Full multi-agent audit
10
+ * npx ship-safe red-team . --agents injection,auth Run specific agents
11
+ * npx ship-safe red-team . --json JSON output
12
+ * npx ship-safe red-team . --html report.html Generate HTML report
13
+ * npx ship-safe red-team . --sarif SARIF output
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import chalk from 'chalk';
19
+ import ora from 'ora';
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 * as output from '../utils/output.js';
28
+
29
+ export async function redTeamCommand(targetPath = '.', options = {}) {
30
+ const absolutePath = path.resolve(targetPath);
31
+
32
+ if (!fs.existsSync(absolutePath)) {
33
+ output.error(`Path does not exist: ${absolutePath}`);
34
+ process.exit(1);
35
+ }
36
+
37
+ console.log();
38
+ output.header('Ship Safe v4.0 — Multi-Agent Security Audit');
39
+ console.log();
40
+
41
+ // ── 1. Run orchestrator ─────────────────────────────────────────────────────
42
+ const orchestrator = buildOrchestrator();
43
+
44
+ const agentFilter = options.agents
45
+ ? options.agents.split(',').map(a => a.trim())
46
+ : null;
47
+
48
+ const results = await orchestrator.runAll(absolutePath, {
49
+ verbose: options.verbose,
50
+ agents: agentFilter,
51
+ });
52
+
53
+ const { recon, findings, agentResults } = results;
54
+
55
+ // ── 2. Dependency audit ─────────────────────────────────────────────────────
56
+ let depVulns = [];
57
+ if (!options.noDeps) {
58
+ const depSpinner = ora({ text: 'Auditing dependencies...', color: 'cyan' }).start();
59
+ try {
60
+ const depResult = await runDepsAudit(absolutePath);
61
+ depVulns = depResult.vulns || [];
62
+ depSpinner.succeed(
63
+ depVulns.length === 0
64
+ ? chalk.green('Dependencies: clean')
65
+ : chalk.yellow(`Dependencies: ${depVulns.length} CVE(s)`)
66
+ );
67
+ } catch {
68
+ depSpinner.succeed(chalk.gray('Dependencies: skipped'));
69
+ }
70
+ }
71
+
72
+ // ── 3. Apply policy ─────────────────────────────────────────────────────────
73
+ const policy = PolicyEngine.load(absolutePath);
74
+ const filteredFindings = policy.applyPolicy(findings);
75
+
76
+ // ── 4. Score ────────────────────────────────────────────────────────────────
77
+ const scoringEngine = new ScoringEngine();
78
+ const scoreResult = scoringEngine.compute(filteredFindings, depVulns);
79
+ scoringEngine.saveToHistory(absolutePath, scoreResult);
80
+
81
+ // ── 5. AI classification (if provider available) ────────────────────────────
82
+ if (options.ai !== false) {
83
+ const provider = autoDetectProvider(absolutePath);
84
+ if (provider && filteredFindings.length > 0 && filteredFindings.length <= 50) {
85
+ const aiSpinner = ora({ text: `Classifying ${filteredFindings.length} finding(s) with ${provider.name}...`, color: 'cyan' }).start();
86
+ try {
87
+ const classifications = await provider.classify(filteredFindings);
88
+ // Merge classifications back into findings
89
+ for (const cl of classifications) {
90
+ const finding = filteredFindings.find(f => `${f.file}:${f.line}` === cl.id);
91
+ if (finding) {
92
+ finding.aiClassification = cl.classification;
93
+ finding.aiReason = cl.reason;
94
+ finding.aiFix = cl.fix;
95
+ }
96
+ }
97
+ aiSpinner.succeed(chalk.green(`AI classification complete (${provider.name})`));
98
+ } catch (err) {
99
+ aiSpinner.fail(chalk.yellow(`AI classification failed: ${err.message}`));
100
+ }
101
+ }
102
+ }
103
+
104
+ // ── 6. Output ───────────────────────────────────────────────────────────────
105
+ if (options.json) {
106
+ outputJSON(scoreResult, filteredFindings, recon, agentResults);
107
+ } else if (options.sarif) {
108
+ outputSARIF(filteredFindings, absolutePath);
109
+ } else if (options.html) {
110
+ const reporter = new HTMLReporter();
111
+ const htmlPath = typeof options.html === 'string' ? options.html : 'ship-safe-report.html';
112
+ reporter.generateToFile(scoreResult, filteredFindings, recon, absolutePath, htmlPath);
113
+ output.success(`HTML report saved to ${htmlPath}`);
114
+ } else {
115
+ printResults(scoreResult, filteredFindings, recon, agentResults, depVulns, absolutePath);
116
+ }
117
+
118
+ // ── 7. SBOM (if requested) ──────────────────────────────────────────────────
119
+ if (options.sbom) {
120
+ const sbomGen = new SBOMGenerator();
121
+ const sbomPath = typeof options.sbom === 'string' ? options.sbom : 'sbom.json';
122
+ sbomGen.generateToFile(absolutePath, sbomPath);
123
+ output.success(`SBOM saved to ${sbomPath}`);
124
+ }
125
+
126
+ // ── 8. Policy evaluation ────────────────────────────────────────────────────
127
+ const violations = policy.evaluate(scoreResult, filteredFindings);
128
+ if (violations.length > 0) {
129
+ console.log();
130
+ console.log(chalk.red.bold(' Policy Violations:'));
131
+ for (const v of violations.slice(0, 10)) {
132
+ console.log(chalk.red(` ✗ ${v.message}`));
133
+ }
134
+ if (violations.length > 10) {
135
+ console.log(chalk.gray(` ... and ${violations.length - 10} more`));
136
+ }
137
+ }
138
+
139
+ // ── 9. Trend ────────────────────────────────────────────────────────────────
140
+ const trend = scoringEngine.getTrend(absolutePath, scoreResult.score);
141
+ if (trend) {
142
+ const arrow = trend.diff > 0 ? chalk.green('↑') : trend.diff < 0 ? chalk.red('↓') : chalk.gray('→');
143
+ console.log();
144
+ console.log(chalk.gray(` Trend: ${trend.previousScore} → ${trend.currentScore} ${arrow} (${trend.diff > 0 ? '+' : ''}${trend.diff})`));
145
+ }
146
+
147
+ console.log();
148
+
149
+ // Exit code
150
+ process.exit(scoreResult.score >= 75 ? 0 : 1);
151
+ }
152
+
153
+ // =============================================================================
154
+ // OUTPUT FORMATTERS
155
+ // =============================================================================
156
+
157
+ function printResults(scoreResult, findings, recon, agentResults, depVulns, rootPath) {
158
+ const GRADE_COLOR = { A: chalk.green.bold, B: chalk.cyan.bold, C: chalk.yellow.bold, D: chalk.red, F: chalk.red.bold };
159
+ const SEV_COLOR = { critical: chalk.red.bold, high: chalk.yellow, medium: chalk.blue, low: chalk.gray };
160
+
161
+ // ── Score ───────────────────────────────────────────────────────────────────
162
+ console.log();
163
+ const gradeColor = GRADE_COLOR[scoreResult.grade.letter] || chalk.white;
164
+ const scoreColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
165
+
166
+ console.log(
167
+ chalk.white.bold(' Security Score: ') +
168
+ scoreColor(`${scoreResult.score}/100 `) +
169
+ gradeColor(scoreResult.grade.letter) +
170
+ chalk.gray(` — ${scoreResult.grade.label}`)
171
+ );
172
+ console.log(chalk.cyan(' ' + '─'.repeat(58)));
173
+ console.log();
174
+
175
+ // ── Category breakdown ──────────────────────────────────────────────────────
176
+ for (const [key, cat] of Object.entries(scoreResult.categories)) {
177
+ const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
178
+ const icon = count === 0 ? chalk.green('✔') : chalk.red('✘');
179
+ const status = count === 0 ? chalk.green('clean') : chalk.red(`${count} issue(s)`);
180
+ const deduction = cat.deduction > 0 ? chalk.red(`-${cat.deduction} pts`) : chalk.gray('+0');
181
+ console.log(` ${icon} ${chalk.white.bold(cat.label.padEnd(22))} ${status.padEnd(25)} ${deduction}`);
182
+ }
183
+
184
+ // Dependencies row
185
+ const depCount = depVulns.length;
186
+ const depIcon = depCount === 0 ? chalk.green('✔') : chalk.red('✘');
187
+ const depDeduction = scoreResult.categories.deps?.deduction || 0;
188
+ console.log(` ${depIcon} ${chalk.white.bold('Dependencies'.padEnd(22))} ${depCount === 0 ? chalk.green('clean') : chalk.red(`${depCount} CVE(s)`)}`);
189
+
190
+ // ── Top findings ────────────────────────────────────────────────────────────
191
+ if (findings.length > 0) {
192
+ console.log();
193
+ console.log(chalk.yellow.bold(` Top Findings (${Math.min(findings.length, 20)} of ${findings.length})`));
194
+ console.log(chalk.yellow(' ' + '─'.repeat(58)));
195
+
196
+ for (const f of findings.slice(0, 20)) {
197
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
198
+ const sevColor = SEV_COLOR[f.severity] || chalk.white;
199
+ const aiTag = f.aiClassification === 'FALSE_POSITIVE'
200
+ ? chalk.gray(' [FP]')
201
+ : f.aiClassification === 'REAL' ? chalk.red(' [REAL]') : '';
202
+
203
+ console.log(` ${sevColor(`[${f.severity.toUpperCase()}]`.padEnd(12))} ${chalk.white(`${relFile}:${f.line}`)}${aiTag}`);
204
+ console.log(` ${chalk.gray(' ')} ${f.title || f.rule}`);
205
+ if (f.aiFix || f.fix) {
206
+ console.log(` ${chalk.gray(' Fix:')} ${chalk.green((f.aiFix || f.fix).slice(0, 80))}`);
207
+ }
208
+ }
209
+ }
210
+
211
+ // ── Attack surface summary ──────────────────────────────────────────────────
212
+ if (recon) {
213
+ console.log();
214
+ console.log(chalk.cyan(' Attack Surface:'));
215
+ if (recon.frameworks?.length) console.log(chalk.gray(` Frameworks: ${recon.frameworks.join(', ')}`));
216
+ if (recon.databases?.length) console.log(chalk.gray(` Databases: ${recon.databases.join(', ')}`));
217
+ if (recon.authPatterns?.length) console.log(chalk.gray(` Auth: ${recon.authPatterns.join(', ')}`));
218
+ if (recon.apiRoutes?.length) console.log(chalk.gray(` API Routes: ${recon.apiRoutes.length} discovered`));
219
+ }
220
+
221
+ // ── Next steps ──────────────────────────────────────────────────────────────
222
+ if (findings.length > 0) {
223
+ console.log();
224
+ console.log(chalk.yellow.bold(' Next steps:'));
225
+ console.log(chalk.gray(' 1. Review and fix findings above'));
226
+ console.log(chalk.gray(' 2. Run again: ') + chalk.cyan('npx ship-safe red-team .'));
227
+ console.log(chalk.gray(' 3. Generate report: ') + chalk.cyan('npx ship-safe red-team . --html report.html'));
228
+ console.log(chalk.gray(' 4. Set policy: ') + chalk.cyan('npx ship-safe policy init'));
229
+ } else {
230
+ console.log();
231
+ output.success('All agents report clean — safe to ship!');
232
+ }
233
+
234
+ console.log(chalk.cyan(' ' + '═'.repeat(58)));
235
+ }
236
+
237
+ function outputJSON(scoreResult, findings, recon, agentResults) {
238
+ console.log(JSON.stringify({
239
+ score: scoreResult.score,
240
+ grade: scoreResult.grade.letter,
241
+ gradeLabel: scoreResult.grade.label,
242
+ totalFindings: findings.length,
243
+ categories: Object.fromEntries(
244
+ Object.entries(scoreResult.categories).map(([k, v]) => [k, {
245
+ label: v.label,
246
+ findingCount: Object.values(v.counts).reduce((a, b) => a + b, 0),
247
+ deduction: v.deduction,
248
+ counts: v.counts,
249
+ }])
250
+ ),
251
+ findings: findings.map(f => ({
252
+ file: f.file,
253
+ line: f.line,
254
+ severity: f.severity,
255
+ category: f.category,
256
+ rule: f.rule,
257
+ title: f.title,
258
+ description: f.description,
259
+ fix: f.fix,
260
+ aiClassification: f.aiClassification,
261
+ aiFix: f.aiFix,
262
+ })),
263
+ recon,
264
+ agents: agentResults,
265
+ }, null, 2));
266
+ }
267
+
268
+ function outputSARIF(findings, rootPath) {
269
+ const rules = {};
270
+ for (const f of findings) {
271
+ if (!rules[f.rule]) {
272
+ rules[f.rule] = {
273
+ id: f.rule,
274
+ name: f.title || f.rule,
275
+ shortDescription: { text: f.title || f.rule },
276
+ fullDescription: { text: f.description || '' },
277
+ defaultConfiguration: {
278
+ level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
279
+ },
280
+ helpUri: 'https://github.com/asamassekou10/ship-safe',
281
+ };
282
+ }
283
+ }
284
+
285
+ const sarif = {
286
+ version: '2.1.0',
287
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
288
+ runs: [{
289
+ tool: {
290
+ driver: {
291
+ name: 'ship-safe',
292
+ version: '4.0.0',
293
+ informationUri: 'https://github.com/asamassekou10/ship-safe',
294
+ rules: Object.values(rules),
295
+ }
296
+ },
297
+ results: findings.map(f => ({
298
+ ruleId: f.rule,
299
+ level: ['critical', 'high'].includes(f.severity) ? 'error' : 'warning',
300
+ message: { text: `${f.title}: ${f.description}` },
301
+ locations: [{
302
+ physicalLocation: {
303
+ artifactLocation: {
304
+ uri: path.relative(rootPath, f.file).replace(/\\/g, '/'),
305
+ uriBaseId: '%SRCROOT%',
306
+ },
307
+ region: { startLine: f.line, startColumn: f.column || 1 },
308
+ }
309
+ }],
310
+ })),
311
+ }],
312
+ };
313
+
314
+ console.log(JSON.stringify(sarif, null, 2));
315
+ }
@@ -39,7 +39,7 @@ import chalk from 'chalk';
39
39
  import ora from 'ora';
40
40
  import pkg from 'write-file-atomic';
41
41
  const { writeFile: writeFileAtomic } = pkg;
42
- import { glob } from 'glob';
42
+ import fg from 'fast-glob';
43
43
  import {
44
44
  SECRET_PATTERNS,
45
45
  SKIP_DIRS,
@@ -415,7 +415,7 @@ function stageFiles(files, rootPath) {
415
415
  if (files.length === 0) return;
416
416
  try {
417
417
  const quoted = files.map(f => `"${f}"`).join(' ');
418
- execSync(`git add ${quoted}`, { cwd: rootPath, stdio: 'inherit' });
418
+ execSync(`git add ${quoted}`, { cwd: rootPath, stdio: 'inherit' }); // ship-safe-ignore — paths come from our own file scan
419
419
  output.success(`Staged ${files.length} file(s) with git add`);
420
420
  } catch {
421
421
  output.warning('Could not stage files — run git add manually.');
@@ -428,8 +428,8 @@ function stageFiles(files, rootPath) {
428
428
 
429
429
  async function findFiles(rootPath) {
430
430
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
431
- const files = await glob('**/*', {
432
- cwd: rootPath, absolute: true, nodir: true, ignore: globIgnore, dot: true
431
+ const files = await fg('**/*', {
432
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
433
433
  });
434
434
 
435
435
  const filtered = [];
@@ -24,7 +24,7 @@ import path from 'path';
24
24
  import { execSync } from 'child_process';
25
25
  import chalk from 'chalk';
26
26
  import ora from 'ora';
27
- import { glob } from 'glob';
27
+ import fg from 'fast-glob';
28
28
  import {
29
29
  SECRET_PATTERNS,
30
30
  SKIP_DIRS,
@@ -390,9 +390,9 @@ async function revokeGitHubToken(token) {
390
390
  function openBrowser(url) {
391
391
  try {
392
392
  const platform = process.platform;
393
- if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' });
394
- else if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
395
- else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
393
+ if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' }); // ship-safe-ignore — url is hardcoded provider dashboard URL
394
+ else if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' }); // ship-safe-ignore
395
+ else execSync(`xdg-open "${url}"`, { stdio: 'ignore' }); // ship-safe-ignore
396
396
  return true;
397
397
  } catch {
398
398
  return false;
@@ -405,8 +405,8 @@ function openBrowser(url) {
405
405
 
406
406
  async function findFiles(rootPath) {
407
407
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
408
- const files = await glob('**/*', {
409
- cwd: rootPath, absolute: true, nodir: true, ignore: globIgnore, dot: true
408
+ const files = await fg('**/*', {
409
+ cwd: rootPath, absolute: true, onlyFiles: true, ignore: globIgnore, dot: true
410
410
  });
411
411
  const filtered = [];
412
412
  for (const file of files) {
@@ -21,11 +21,12 @@
21
21
 
22
22
  import fs from 'fs';
23
23
  import path from 'path';
24
- import { glob } from 'glob';
24
+ import fg from 'fast-glob';
25
25
  import ora from 'ora';
26
26
  import chalk from 'chalk';
27
27
  import {
28
28
  SECRET_PATTERNS,
29
+ SECURITY_PATTERNS,
29
30
  SKIP_DIRS,
30
31
  SKIP_EXTENSIONS,
31
32
  TEST_FILE_PATTERNS,
@@ -94,7 +95,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
94
95
 
95
96
  // Load custom patterns from .ship-safe.json
96
97
  const customPatterns = loadCustomPatterns(absolutePath);
97
- const allPatterns = [...SECRET_PATTERNS, ...customPatterns];
98
+ const allPatterns = [...SECRET_PATTERNS, ...SECURITY_PATTERNS, ...customPatterns];
98
99
 
99
100
  if (customPatterns.length > 0 && options.verbose) {
100
101
  output.info(`Loaded ${customPatterns.length} custom pattern(s) from .ship-safe.json`);
@@ -102,7 +103,7 @@ export async function scanCommand(targetPath = '.', options = {}) {
102
103
 
103
104
  // Start spinner
104
105
  const spinner = ora({
105
- text: 'Scanning for secrets...',
106
+ text: 'Scanning for secrets and vulnerabilities...',
106
107
  color: 'cyan'
107
108
  }).start();
108
109
 
@@ -204,10 +205,10 @@ async function findFiles(rootPath, ignorePatterns, options = {}) {
204
205
  const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
205
206
 
206
207
  // Find all files
207
- const files = await glob('**/*', {
208
+ const files = await fg('**/*', {
208
209
  cwd: rootPath,
209
210
  absolute: true,
210
- nodir: true,
211
+ onlyFiles: true,
211
212
  ignore: globIgnore,
212
213
  dot: true
213
214
  });
@@ -284,7 +285,8 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
284
285
  patternName: pattern.name,
285
286
  severity: pattern.severity,
286
287
  confidence,
287
- description: pattern.description
288
+ description: pattern.description,
289
+ category: pattern.category || 'secret'
288
290
  });
289
291
  }
290
292
  }
@@ -293,7 +295,17 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
293
295
  // Skip files that can't be read (binary, permissions, etc.)
294
296
  }
295
297
 
296
- return findings;
298
+ // Deduplicate: multiple patterns can match the same secret on the same line
299
+ // (e.g. Stripe and Clerk both match sk_live_...). Keep one finding per
300
+ // unique (line, matched-text) pair — first match wins (patterns are ordered
301
+ // by severity: critical → high → medium).
302
+ const seen = new Set();
303
+ return findings.filter(f => {
304
+ const key = `${f.line}:${f.matched}`;
305
+ if (seen.has(key)) return false;
306
+ seen.add(key);
307
+ return true;
308
+ });
297
309
  }
298
310
 
299
311
  // =============================================================================
@@ -301,11 +313,24 @@ async function scanFile(filePath, patterns = SECRET_PATTERNS) {
301
313
  // =============================================================================
302
314
 
303
315
  function outputPretty(results, filesScanned, rootPath) {
316
+ // Separate findings into secrets and code vulnerabilities
317
+ const secretResults = [];
318
+ const vulnResults = [];
319
+
320
+ for (const { file, findings } of results) {
321
+ const secrets = findings.filter(f => f.category !== 'vulnerability');
322
+ const vulns = findings.filter(f => f.category === 'vulnerability');
323
+ if (secrets.length > 0) secretResults.push({ file, findings: secrets });
324
+ if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
325
+ }
326
+
304
327
  const stats = {
305
328
  total: 0,
306
329
  critical: 0,
307
330
  high: 0,
308
331
  medium: 0,
332
+ secretsTotal: 0,
333
+ vulnsTotal: 0,
309
334
  filesScanned
310
335
  };
311
336
 
@@ -313,31 +338,45 @@ function outputPretty(results, filesScanned, rootPath) {
313
338
  for (const f of findings) {
314
339
  stats.total++;
315
340
  stats[f.severity] = (stats[f.severity] || 0) + 1;
341
+ if (f.category === 'vulnerability') stats.vulnsTotal++;
342
+ else stats.secretsTotal++;
316
343
  }
317
344
  }
318
345
 
319
346
  output.header('Scan Results');
320
347
 
321
348
  if (results.length === 0) {
322
- output.success('No secrets detected in your codebase!');
349
+ output.success('No secrets or vulnerabilities detected in your codebase!');
323
350
  console.log();
324
351
  console.log(chalk.gray('Note: Uses pattern matching + entropy scoring. Test files excluded by default.'));
325
352
  console.log(chalk.gray('Tip: Run with --include-tests to also scan test files.'));
326
353
  console.log(chalk.gray('Tip: Add a .ship-safeignore file to exclude paths.'));
327
354
  } else {
328
- for (const { file, findings } of results) {
329
- const relPath = path.relative(rootPath, file);
330
-
331
- for (const f of findings) {
332
- output.finding(
333
- relPath,
334
- f.line,
335
- f.patternName,
336
- f.severity,
337
- f.matched,
338
- f.description,
339
- f.confidence
340
- );
355
+ // ── Secrets section ────────────────────────────────────────────────────
356
+ if (secretResults.length > 0) {
357
+ console.log();
358
+ console.log(chalk.red.bold(` Secrets (${stats.secretsTotal})`));
359
+ console.log(chalk.red(' ' + '─'.repeat(58)));
360
+
361
+ for (const { file, findings } of secretResults) {
362
+ const relPath = path.relative(rootPath, file);
363
+ for (const f of findings) {
364
+ output.finding(relPath, f.line, f.patternName, f.severity, f.matched, f.description, f.confidence);
365
+ }
366
+ }
367
+ }
368
+
369
+ // ── Code Vulnerabilities section ───────────────────────────────────────
370
+ if (vulnResults.length > 0) {
371
+ console.log();
372
+ console.log(chalk.yellow.bold(` Code Vulnerabilities (${stats.vulnsTotal})`));
373
+ console.log(chalk.yellow(' ' + '─'.repeat(58)));
374
+
375
+ for (const { file, findings } of vulnResults) {
376
+ const relPath = path.relative(rootPath, file);
377
+ for (const f of findings) {
378
+ output.vulnerabilityFinding(relPath, f.line, f.patternName, f.severity, f.matched, f.description);
379
+ }
341
380
  }
342
381
  }
343
382
 
@@ -346,7 +385,8 @@ function outputPretty(results, filesScanned, rootPath) {
346
385
  console.log(chalk.gray('Suppress a finding: add # ship-safe-ignore as a comment on that line'));
347
386
  console.log(chalk.gray('Exclude a path: add it to .ship-safeignore'));
348
387
 
349
- output.recommendations();
388
+ if (secretResults.length > 0) output.recommendations();
389
+ if (vulnResults.length > 0) output.vulnRecommendations();
350
390
  }
351
391
 
352
392
  output.summary(stats);
@@ -367,10 +407,11 @@ function outputJSON(results, filesScanned) {
367
407
  file,
368
408
  line: f.line,
369
409
  column: f.column,
410
+ category: f.category || 'secret',
370
411
  severity: f.severity,
371
412
  confidence: f.confidence,
372
413
  type: f.patternName,
373
- matched: output.maskSecret(f.matched),
414
+ matched: f.category === 'vulnerability' ? f.matched : output.maskSecret(f.matched),
374
415
  description: f.description
375
416
  });
376
417
  }