ship-safe 6.1.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +748 -641
  2. package/cli/agents/api-fuzzer.js +345 -345
  3. package/cli/agents/auth-bypass-agent.js +348 -348
  4. package/cli/agents/base-agent.js +272 -272
  5. package/cli/agents/cicd-scanner.js +236 -201
  6. package/cli/agents/config-auditor.js +521 -521
  7. package/cli/agents/deep-analyzer.js +6 -2
  8. package/cli/agents/git-history-scanner.js +170 -170
  9. package/cli/agents/html-reporter.js +568 -568
  10. package/cli/agents/index.js +85 -84
  11. package/cli/agents/injection-tester.js +500 -500
  12. package/cli/agents/legal-risk-agent.js +302 -0
  13. package/cli/agents/llm-redteam.js +251 -251
  14. package/cli/agents/mobile-scanner.js +231 -231
  15. package/cli/agents/orchestrator.js +322 -322
  16. package/cli/agents/pii-compliance-agent.js +301 -301
  17. package/cli/agents/scoring-engine.js +248 -248
  18. package/cli/agents/supabase-rls-agent.js +154 -154
  19. package/cli/agents/supply-chain-agent.js +650 -507
  20. package/cli/bin/ship-safe.js +464 -426
  21. package/cli/commands/agent.js +608 -608
  22. package/cli/commands/audit.js +1006 -980
  23. package/cli/commands/baseline.js +193 -193
  24. package/cli/commands/ci.js +342 -342
  25. package/cli/commands/deps.js +516 -516
  26. package/cli/commands/doctor.js +159 -159
  27. package/cli/commands/fix.js +218 -218
  28. package/cli/commands/hooks.js +268 -0
  29. package/cli/commands/init.js +407 -407
  30. package/cli/commands/legal.js +158 -0
  31. package/cli/commands/mcp.js +304 -304
  32. package/cli/commands/red-team.js +7 -1
  33. package/cli/commands/remediate.js +798 -798
  34. package/cli/commands/rotate.js +571 -571
  35. package/cli/commands/scan.js +569 -569
  36. package/cli/commands/score.js +449 -449
  37. package/cli/commands/watch.js +281 -281
  38. package/cli/hooks/patterns.js +313 -0
  39. package/cli/hooks/post-tool-use.js +140 -0
  40. package/cli/hooks/pre-tool-use.js +186 -0
  41. package/cli/index.js +73 -69
  42. package/cli/providers/llm-provider.js +397 -287
  43. package/cli/utils/autofix-rules.js +74 -74
  44. package/cli/utils/cache-manager.js +311 -311
  45. package/cli/utils/output.js +230 -230
  46. package/cli/utils/patterns.js +1121 -1121
  47. package/cli/utils/pdf-generator.js +94 -94
  48. package/package.json +69 -69
  49. package/configs/supabase/rls-templates.sql +0 -242
@@ -1,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
+ }