ship-safe 3.1.0 → 3.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.
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Agent Command
3
+ * =============
4
+ *
5
+ * AI-powered autonomous security audit.
6
+ * Scans for secrets AND code vulnerabilities, classifies findings with Claude,
7
+ * remediates confirmed secrets, and provides specific fix suggestions for
8
+ * confirmed code vulnerabilities.
9
+ *
10
+ * USAGE:
11
+ * npx ship-safe agent [path] Full AI-powered audit
12
+ * npx ship-safe agent . --dry-run Preview without writing files
13
+ * npx ship-safe agent . --model sonnet Use a more capable model
14
+ *
15
+ * REQUIRES:
16
+ * ANTHROPIC_API_KEY in your environment or .env file.
17
+ * Falls back to pattern-only remediation if no key is found.
18
+ *
19
+ * FLOW:
20
+ * scan (secrets + vulns)
21
+ * → classify secrets (REAL/FP) → remediate confirmed secrets
22
+ * → classify vulns (REAL/FP + fix suggestion) → print fix table
23
+ * → re-scan to verify secrets clean
24
+ */
25
+
26
+ import fs from 'fs';
27
+ import path from 'path';
28
+ import fg from 'fast-glob';
29
+ import chalk from 'chalk';
30
+ import ora from 'ora';
31
+ import {
32
+ SECRET_PATTERNS,
33
+ SECURITY_PATTERNS,
34
+ SKIP_DIRS,
35
+ SKIP_EXTENSIONS,
36
+ TEST_FILE_PATTERNS,
37
+ MAX_FILE_SIZE
38
+ } from '../utils/patterns.js';
39
+ import { isHighEntropyMatch, getConfidence } from '../utils/entropy.js';
40
+ import { remediateCommand } from './remediate.js';
41
+ import * as output from '../utils/output.js';
42
+
43
+ const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
44
+ const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/messages';
45
+
46
+ // =============================================================================
47
+ // MAIN COMMAND
48
+ // =============================================================================
49
+
50
+ export async function agentCommand(targetPath = '.', options = {}) {
51
+ const absolutePath = path.resolve(targetPath);
52
+
53
+ if (!fs.existsSync(absolutePath)) {
54
+ output.error(`Path does not exist: ${absolutePath}`);
55
+ process.exit(1);
56
+ }
57
+
58
+ const model = options.model || DEFAULT_MODEL;
59
+
60
+ console.log();
61
+ output.header('Ship Safe — AI Security Agent');
62
+ console.log();
63
+
64
+ // ── 1. Load API key ────────────────────────────────────────────────────────
65
+ const apiKey = loadApiKey(absolutePath);
66
+
67
+ // ── 2. Scan (secrets + vulnerabilities) ────────────────────────────────────
68
+ const scanSpinner = ora({ text: 'Scanning for secrets and vulnerabilities...', color: 'cyan' }).start();
69
+ const allResults = await scanProject(absolutePath);
70
+
71
+ // Separate findings by category
72
+ const secretResults = [];
73
+ const vulnResults = [];
74
+ for (const { file, findings } of allResults) {
75
+ const secrets = findings.filter(f => f.category !== 'vulnerability');
76
+ const vulns = findings.filter(f => f.category === 'vulnerability');
77
+ if (secrets.length > 0) secretResults.push({ file, findings: secrets });
78
+ if (vulns.length > 0) vulnResults.push({ file, findings: vulns });
79
+ }
80
+
81
+ const secretCount = secretResults.reduce((n, r) => n + r.findings.length, 0);
82
+ const vulnCount = vulnResults.reduce((n, r) => n + r.findings.length, 0);
83
+ scanSpinner.stop();
84
+
85
+ // ── 3. Nothing found ───────────────────────────────────────────────────────
86
+ if (secretCount === 0 && vulnCount === 0) {
87
+ output.success('No secrets or vulnerabilities detected — your project is clean!');
88
+ console.log();
89
+ return;
90
+ }
91
+
92
+ if (secretCount > 0) {
93
+ console.log(chalk.red(`\n Found ${secretCount} potential secret(s) in ${secretResults.length} file(s)`));
94
+ }
95
+ if (vulnCount > 0) {
96
+ console.log(chalk.yellow(` Found ${vulnCount} code vulnerability/vulnerabilities in ${vulnResults.length} file(s)`));
97
+ }
98
+ console.log();
99
+
100
+ // ── 4. Fallback: no API key ────────────────────────────────────────────────
101
+ if (!apiKey) {
102
+ console.log(chalk.yellow(' ⚠ No ANTHROPIC_API_KEY found.'));
103
+ console.log(chalk.gray(' Set it in your environment or .env to enable AI classification.'));
104
+ if (secretCount > 0) {
105
+ console.log(chalk.gray(' Falling back to pattern-based remediation for secrets...\n'));
106
+ await remediateCommand(targetPath, { yes: true, dryRun: options.dryRun });
107
+ }
108
+ if (vulnCount > 0) {
109
+ console.log(chalk.gray('\n Code vulnerabilities require manual review.'));
110
+ console.log(chalk.gray(' Run: ') + chalk.cyan('npx ship-safe scan .') + chalk.gray(' to see details.'));
111
+ }
112
+ return;
113
+ }
114
+
115
+ // ── 5. Classify secrets ────────────────────────────────────────────────────
116
+ if (secretCount > 0) {
117
+ const classifySpinner = ora({ text: `Classifying ${secretCount} secret(s) with ${model}...`, color: 'cyan' }).start();
118
+ let classifiedSecrets;
119
+
120
+ try {
121
+ classifiedSecrets = await classifyWithClaude(secretResults, absolutePath, apiKey, model);
122
+ } catch (err) {
123
+ classifySpinner.stop();
124
+ console.log(chalk.yellow(` ⚠ Claude secret classification failed: ${err.message}`));
125
+ console.log(chalk.gray(' Treating all findings as real secrets (safe fallback).\n'));
126
+ classifiedSecrets = secretResults.map(({ file, findings }) => ({
127
+ file,
128
+ findings: findings.map(f => ({ ...f, classification: 'REAL', reason: 'Classification unavailable' }))
129
+ }));
130
+ }
131
+
132
+ classifySpinner.stop();
133
+
134
+ // ── 6. Print secret classification table ──────────────────────────────
135
+ printClassificationTable(classifiedSecrets, absolutePath);
136
+
137
+ const realSecretCount = classifiedSecrets.reduce(
138
+ (n, { findings }) => n + findings.filter(f => f.classification === 'REAL').length, 0
139
+ );
140
+ const fpCount = secretCount - realSecretCount;
141
+
142
+ console.log();
143
+ if (realSecretCount === 0) {
144
+ output.success(`Claude classified all ${secretCount} secret finding(s) as false positives — nothing to fix!`);
145
+ if (fpCount > 0) {
146
+ console.log(chalk.gray(' Tip: Add # ship-safe-ignore on those lines to suppress future warnings.'));
147
+ }
148
+ } else {
149
+ console.log(chalk.cyan(` ${realSecretCount} confirmed secret(s) to remediate.${fpCount > 0 ? chalk.gray(` ${fpCount} false positive(s) skipped.`) : ''}`));
150
+ console.log();
151
+
152
+ // ── 7. Remediate confirmed secrets ──────────────────────────────────
153
+ if (options.dryRun) {
154
+ console.log(chalk.cyan(' Dry run — secrets not modified. Remove --dry-run to apply fixes.'));
155
+ } else {
156
+ await remediateCommand(targetPath, { yes: true });
157
+ }
158
+ }
159
+ }
160
+
161
+ // ── 8. Classify vulnerabilities ────────────────────────────────────────────
162
+ if (vulnCount > 0) {
163
+ console.log();
164
+ const vulnSpinner = ora({
165
+ text: `Analyzing ${vulnCount} vulnerability/vulnerabilities with ${model}...`,
166
+ color: 'cyan'
167
+ }).start();
168
+ let classifiedVulns;
169
+
170
+ try {
171
+ classifiedVulns = await classifyVulnsWithClaude(vulnResults, absolutePath, apiKey, model);
172
+ } catch (err) {
173
+ vulnSpinner.stop();
174
+ console.log(chalk.yellow(` ⚠ Claude vulnerability analysis failed: ${err.message}`));
175
+ console.log(chalk.gray(' Showing raw findings without AI fix suggestions.\n'));
176
+ classifiedVulns = vulnResults.map(({ file, findings }) => ({
177
+ file,
178
+ findings: findings.map(f => ({ ...f, classification: 'REAL', reason: 'Analysis unavailable', fix: null }))
179
+ }));
180
+ }
181
+
182
+ vulnSpinner.stop();
183
+
184
+ // ── 9. Print vulnerability fix table ──────────────────────────────────
185
+ printVulnFixTable(classifiedVulns, absolutePath);
186
+ }
187
+
188
+ // ── 10. Verify secrets clean ───────────────────────────────────────────────
189
+ if (secretCount > 0 && !options.dryRun) {
190
+ console.log();
191
+ const verifySpinner = ora({ text: 'Re-scanning to verify secrets removed...', color: 'cyan' }).start();
192
+ const verifyResults = await scanProject(absolutePath);
193
+ const remainingSecrets = verifyResults.reduce(
194
+ (n, r) => n + r.findings.filter(f => f.category !== 'vulnerability').length, 0
195
+ );
196
+ verifySpinner.stop();
197
+
198
+ if (remainingSecrets === 0) {
199
+ output.success('Secrets verified clean — 0 remain in your codebase!');
200
+ } else {
201
+ output.warning(`${remainingSecrets} secret(s) still remain. Review them manually or run npx ship-safe scan .`);
202
+ }
203
+ }
204
+
205
+ // ── 11. Next steps ─────────────────────────────────────────────────────────
206
+ console.log();
207
+ console.log(chalk.yellow.bold(' Next steps:'));
208
+ let step = 1;
209
+ if (secretCount > 0) {
210
+ console.log(chalk.white(` ${step++}.`) + chalk.gray(' Rotate any exposed keys: ') + chalk.cyan('npx ship-safe rotate'));
211
+ console.log(chalk.white(` ${step++}.`) + chalk.gray(' Commit the fixes: ') + chalk.cyan('git add . && git commit -m "fix: remove hardcoded secrets"'));
212
+ console.log(chalk.white(` ${step++}.`) + chalk.gray(' Fill in .env with fresh values from your providers'));
213
+ }
214
+ if (vulnCount > 0) {
215
+ console.log(chalk.white(` ${step++}.`) + chalk.gray(' Apply the code fixes shown above, then re-run: ') + chalk.cyan('npx ship-safe agent .'));
216
+ }
217
+ console.log();
218
+ }
219
+
220
+ // =============================================================================
221
+ // API KEY LOADING
222
+ // =============================================================================
223
+
224
+ /**
225
+ * Load ANTHROPIC_API_KEY from environment or .env file.
226
+ * Returns the key string or null if not found.
227
+ */
228
+ function loadApiKey(rootPath) {
229
+ if (process.env.ANTHROPIC_API_KEY) {
230
+ return process.env.ANTHROPIC_API_KEY;
231
+ }
232
+
233
+ const envPath = path.join(rootPath, '.env');
234
+ if (fs.existsSync(envPath)) {
235
+ try {
236
+ const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
237
+ for (const line of lines) {
238
+ const trimmed = line.trim();
239
+ if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
240
+ const eqIdx = trimmed.indexOf('=');
241
+ const key = trimmed.slice(0, eqIdx).trim();
242
+ if (key === 'ANTHROPIC_API_KEY') {
243
+ const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
244
+ if (val) return val;
245
+ }
246
+ }
247
+ } catch {
248
+ // ignore read errors
249
+ }
250
+ }
251
+
252
+ return null;
253
+ }
254
+
255
+ // =============================================================================
256
+ // PROJECT SCANNING (secrets + vulnerabilities)
257
+ // =============================================================================
258
+
259
+ async function scanProject(rootPath) {
260
+ const globIgnore = Array.from(SKIP_DIRS).map(dir => `**/${dir}/**`);
261
+
262
+ const allFiles = await fg('**/*', {
263
+ cwd: rootPath,
264
+ absolute: true,
265
+ onlyFiles: true,
266
+ ignore: globIgnore,
267
+ dot: true
268
+ });
269
+
270
+ const files = allFiles.filter(file => {
271
+ const ext = path.extname(file).toLowerCase();
272
+ if (SKIP_EXTENSIONS.has(ext)) return false;
273
+ const basename = path.basename(file);
274
+ if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
275
+ if (TEST_FILE_PATTERNS.some(p => p.test(file))) return false;
276
+ try {
277
+ const stats = fs.statSync(file);
278
+ if (stats.size > MAX_FILE_SIZE) return false;
279
+ } catch {
280
+ return false;
281
+ }
282
+ return true;
283
+ });
284
+
285
+ const results = [];
286
+ for (const file of files) {
287
+ const findings = scanFile(file);
288
+ if (findings.length > 0) {
289
+ results.push({ file, findings });
290
+ }
291
+ }
292
+ return results;
293
+ }
294
+
295
+ function scanFile(filePath) {
296
+ const findings = [];
297
+ try {
298
+ const content = fs.readFileSync(filePath, 'utf-8');
299
+ const lines = content.split('\n');
300
+
301
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
302
+ const line = lines[lineNum];
303
+ if (/ship-safe-ignore/i.test(line)) continue;
304
+
305
+ for (const pattern of [...SECRET_PATTERNS, ...SECURITY_PATTERNS]) {
306
+ pattern.pattern.lastIndex = 0;
307
+ let match;
308
+ while ((match = pattern.pattern.exec(line)) !== null) {
309
+ if (pattern.requiresEntropyCheck && !isHighEntropyMatch(match[0])) continue;
310
+ findings.push({
311
+ line: lineNum + 1,
312
+ column: match.index + 1,
313
+ matched: match[0],
314
+ patternName: pattern.name,
315
+ severity: pattern.severity,
316
+ confidence: getConfidence(pattern, match[0]),
317
+ description: pattern.description,
318
+ category: pattern.category || 'secret'
319
+ });
320
+ }
321
+ }
322
+ }
323
+ } catch {
324
+ // Skip unreadable files
325
+ }
326
+
327
+ // Deduplicate by (line, matched)
328
+ const seen = new Set();
329
+ return findings.filter(f => {
330
+ const key = `${f.line}:${f.matched}`;
331
+ if (seen.has(key)) return false;
332
+ seen.add(key);
333
+ return true;
334
+ });
335
+ }
336
+
337
+ // =============================================================================
338
+ // CLAUDE CLASSIFICATION — SECRETS
339
+ // =============================================================================
340
+
341
+ async function classifyWithClaude(scanResults, rootPath, apiKey, model) {
342
+ const items = [];
343
+ for (const { file, findings } of scanResults) {
344
+ let lines = [];
345
+ try {
346
+ lines = fs.readFileSync(file, 'utf-8').split('\n');
347
+ } catch {
348
+ // include without context
349
+ }
350
+
351
+ for (const finding of findings) {
352
+ const startLine = Math.max(0, finding.line - 3);
353
+ const endLine = Math.min(lines.length - 1, finding.line + 1);
354
+ const context = lines.slice(startLine, endLine + 1).join('\n');
355
+
356
+ // Truncate matched value — don't send real secrets to the API
357
+ const matchedPrefix = finding.matched.length > 12
358
+ ? finding.matched.slice(0, 12) + '...'
359
+ : finding.matched;
360
+
361
+ items.push({
362
+ id: `${path.relative(rootPath, file)}:${finding.line}`,
363
+ file: path.relative(rootPath, file),
364
+ line: finding.line,
365
+ patternName: finding.patternName,
366
+ severity: finding.severity,
367
+ matchedPrefix,
368
+ codeContext: context
369
+ });
370
+ }
371
+ }
372
+
373
+ const prompt = `You are a security expert reviewing potential secret leaks in source code.
374
+
375
+ For each finding below, classify it as REAL or FALSE_POSITIVE:
376
+ - REAL: a genuine hardcoded secret, credential, or API key that should be moved to environment variables
377
+ - FALSE_POSITIVE: a placeholder, example value, test fixture, documentation sample, or non-sensitive identifier
378
+
379
+ Respond with a JSON array ONLY — no markdown, no explanation, just the JSON:
380
+ [{"id":"<id>","classification":"REAL"|"FALSE_POSITIVE","reason":"<brief one-line reason>"}]
381
+
382
+ Findings to classify:
383
+ ${JSON.stringify(items, null, 2)}`;
384
+
385
+ const response = await fetch(ANTHROPIC_API_URL, {
386
+ method: 'POST',
387
+ headers: {
388
+ 'x-api-key': apiKey,
389
+ 'anthropic-version': '2023-06-01',
390
+ 'content-type': 'application/json',
391
+ },
392
+ body: JSON.stringify({
393
+ model,
394
+ max_tokens: 2048,
395
+ messages: [{ role: 'user', content: prompt }]
396
+ })
397
+ });
398
+
399
+ if (!response.ok) {
400
+ const body = await response.text();
401
+ throw new Error(`Anthropic API error ${response.status}: ${body.slice(0, 200)}`);
402
+ }
403
+
404
+ const data = await response.json();
405
+ const text = data.content?.[0]?.text || '[]';
406
+ const jsonText = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
407
+ let classifications;
408
+ try {
409
+ classifications = JSON.parse(jsonText);
410
+ } catch {
411
+ throw new Error('Claude returned non-JSON response');
412
+ }
413
+
414
+ return scanResults.map(({ file, findings }) => ({
415
+ file,
416
+ findings: findings.map(f => {
417
+ const id = `${path.relative(rootPath, file)}:${f.line}`;
418
+ const cl = classifications.find(c => c.id === id);
419
+ return {
420
+ ...f,
421
+ classification: cl?.classification ?? 'REAL',
422
+ reason: cl?.reason ?? ''
423
+ };
424
+ })
425
+ }));
426
+ }
427
+
428
+ // =============================================================================
429
+ // CLAUDE CLASSIFICATION — VULNERABILITIES
430
+ // =============================================================================
431
+
432
+ /**
433
+ * Send vulnerability findings to Claude for classification + specific fix suggestions.
434
+ * Unlike secrets, code context is NOT masked — the pattern itself is the finding.
435
+ */
436
+ async function classifyVulnsWithClaude(vulnResults, rootPath, apiKey, model) {
437
+ const items = [];
438
+ for (const { file, findings } of vulnResults) {
439
+ let lines = [];
440
+ try {
441
+ lines = fs.readFileSync(file, 'utf-8').split('\n');
442
+ } catch {
443
+ // include without context
444
+ }
445
+
446
+ for (const finding of findings) {
447
+ const startLine = Math.max(0, finding.line - 3);
448
+ const endLine = Math.min(lines.length - 1, finding.line + 1);
449
+ const context = lines.slice(startLine, endLine + 1).join('\n');
450
+
451
+ items.push({
452
+ id: `${path.relative(rootPath, file)}:${finding.line}`,
453
+ file: path.relative(rootPath, file),
454
+ line: finding.line,
455
+ type: finding.patternName,
456
+ severity: finding.severity,
457
+ codeContext: context // Not masked — it's a code pattern, not a secret
458
+ });
459
+ }
460
+ }
461
+
462
+ const prompt = `You are a security expert reviewing code vulnerabilities.
463
+
464
+ For each finding below, classify it and provide a specific fix if it's a real issue.
465
+
466
+ - REAL: genuinely exploitable as written (user-controlled input reaches a dangerous sink)
467
+ - FALSE_POSITIVE: safe in this context (static/hardcoded input, internal tool, test code, build script)
468
+
469
+ For REAL findings: provide a concise, specific 1-line code fix showing what to change.
470
+ For FALSE_POSITIVE: briefly explain why it's safe.
471
+
472
+ Respond with a JSON array ONLY — no markdown, no explanation:
473
+ [{"id":"<id>","classification":"REAL"|"FALSE_POSITIVE","reason":"<brief reason>","fix":"<specific fix code, or null>"}]
474
+
475
+ Vulnerabilities to analyze:
476
+ ${JSON.stringify(items, null, 2)}`;
477
+
478
+ const response = await fetch(ANTHROPIC_API_URL, {
479
+ method: 'POST',
480
+ headers: {
481
+ 'x-api-key': apiKey,
482
+ 'anthropic-version': '2023-06-01',
483
+ 'content-type': 'application/json',
484
+ },
485
+ body: JSON.stringify({
486
+ model,
487
+ max_tokens: 4096,
488
+ messages: [{ role: 'user', content: prompt }]
489
+ })
490
+ });
491
+
492
+ if (!response.ok) {
493
+ const body = await response.text();
494
+ throw new Error(`Anthropic API error ${response.status}: ${body.slice(0, 200)}`);
495
+ }
496
+
497
+ const data = await response.json();
498
+ const text = data.content?.[0]?.text || '[]';
499
+ const jsonText = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/i, '').trim();
500
+ let classifications;
501
+ try {
502
+ classifications = JSON.parse(jsonText);
503
+ } catch {
504
+ throw new Error('Claude returned non-JSON response');
505
+ }
506
+
507
+ return vulnResults.map(({ file, findings }) => ({
508
+ file,
509
+ findings: findings.map(f => {
510
+ const id = `${path.relative(rootPath, file)}:${f.line}`;
511
+ const cl = classifications.find(c => c.id === id);
512
+ return {
513
+ ...f,
514
+ classification: cl?.classification ?? 'REAL',
515
+ reason: cl?.reason ?? '',
516
+ fix: cl?.fix ?? null
517
+ };
518
+ })
519
+ }));
520
+ }
521
+
522
+ // =============================================================================
523
+ // OUTPUT
524
+ // =============================================================================
525
+
526
+ function printClassificationTable(classified, rootPath) {
527
+ const SEVERITY_COLOR = {
528
+ critical: chalk.red.bold,
529
+ high: chalk.yellow,
530
+ medium: chalk.blue
531
+ };
532
+
533
+ console.log(chalk.cyan(' Secret Classification'));
534
+ console.log(chalk.cyan(' ' + '─'.repeat(58)));
535
+ console.log();
536
+
537
+ for (const { file, findings } of classified) {
538
+ const relPath = path.relative(rootPath, file);
539
+ for (const f of findings) {
540
+ const isReal = f.classification === 'REAL';
541
+ const icon = isReal ? chalk.red('✗') : chalk.gray('~');
542
+ const label = isReal ? chalk.red('REAL') : chalk.gray('SKIP');
543
+ const sevColor = SEVERITY_COLOR[f.severity] || chalk.white;
544
+ const matchedShort = f.matched.length > 16 ? f.matched.slice(0, 16) + '…' : f.matched;
545
+
546
+ console.log(
547
+ ` ${icon} ${label.padEnd(8)} ${chalk.white(`${relPath}:${f.line}`).padEnd(40)} ` +
548
+ `${sevColor(f.patternName.padEnd(24))} ` +
549
+ chalk.gray(matchedShort)
550
+ );
551
+ if (f.reason) {
552
+ console.log(chalk.gray(` → ${f.reason}`));
553
+ }
554
+ }
555
+ }
556
+ }
557
+
558
+ function printVulnFixTable(classifiedVulns, rootPath) {
559
+ const SEVERITY_COLOR = {
560
+ critical: chalk.red.bold,
561
+ high: chalk.yellow,
562
+ medium: chalk.blue
563
+ };
564
+
565
+ const totalCount = classifiedVulns.reduce((n, { findings }) => n + findings.length, 0);
566
+ const realCount = classifiedVulns.reduce(
567
+ (n, { findings }) => n + findings.filter(f => f.classification === 'REAL').length, 0
568
+ );
569
+ const fpCount = totalCount - realCount;
570
+
571
+ console.log(chalk.yellow(' Code Vulnerability Analysis'));
572
+ console.log(chalk.yellow(' ' + '─'.repeat(58)));
573
+ console.log();
574
+
575
+ for (const { file, findings } of classifiedVulns) {
576
+ const relPath = path.relative(rootPath, file);
577
+ for (const f of findings) {
578
+ const isReal = f.classification === 'REAL';
579
+ const icon = isReal ? chalk.red('✗') : chalk.gray('~');
580
+ const label = isReal ? chalk.red('REAL') : chalk.gray('SKIP');
581
+ const sevColor = SEVERITY_COLOR[f.severity] || chalk.white;
582
+ const snippet = f.matched.length > 55 ? f.matched.slice(0, 55) + '…' : f.matched;
583
+
584
+ console.log(
585
+ ` ${icon} ${label.padEnd(8)} ${chalk.white(`${relPath}:${f.line}`).padEnd(38)} ` +
586
+ sevColor(`[${f.severity.toUpperCase()}]`)
587
+ );
588
+ console.log(chalk.gray(` ${f.patternName}`));
589
+ console.log(chalk.gray(' Code: ') + chalk.cyan(snippet));
590
+ if (f.reason) {
591
+ console.log(chalk.gray(` Reason: ${f.reason}`));
592
+ }
593
+ if (isReal && f.fix) {
594
+ console.log(chalk.gray(' Fix: ') + chalk.green(f.fix));
595
+ }
596
+ console.log();
597
+ }
598
+ }
599
+
600
+ if (realCount === 0) {
601
+ output.success(`Claude classified all ${totalCount} vulnerability/vulnerabilities as false positives!`);
602
+ console.log(chalk.gray(' Tip: Add # ship-safe-ignore on those lines to suppress future warnings.'));
603
+ } else {
604
+ console.log(chalk.yellow(` ${realCount} real vulnerability/vulnerabilities require manual fixes.${fpCount > 0 ? chalk.gray(` ${fpCount} false positive(s) skipped.`) : ''}`));
605
+ }
606
+ }