ship-safe 6.1.1 → 6.2.0

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