ship-safe 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,22 +16,28 @@ import path from 'path';
16
16
  // SCORING CONFIGURATION
17
17
  // =============================================================================
18
18
 
19
+ // Weights aligned with OWASP Top 10 2025:
20
+ // A01 Broken Access Control (auth: 15), A02 Security Misconfiguration (config: 8),
21
+ // A03 Software Supply Chain Failures (supply-chain: 12, deps: 13),
22
+ // A05 Injection (injection: 15), A07 Auth Failures (secrets: 15),
23
+ // A10 Mishandling of Exceptional Conditions (→ injection category)
24
+ // + API Security (10), AI/LLM Security (12) — weights sum to 100
19
25
  const CATEGORIES = {
20
26
  secrets: { weight: 15, label: 'Secrets', deductions: { critical: 25, high: 15, medium: 5 } },
21
27
  injection: { weight: 15, label: 'Code Vulnerabilities', deductions: { critical: 20, high: 10, medium: 3 } },
22
- deps: { weight: 15, label: 'Dependencies', deductions: { critical: 20, high: 10, medium: 5, moderate: 5 } },
28
+ deps: { weight: 13, label: 'Dependencies', deductions: { critical: 20, high: 10, medium: 5, moderate: 5 } },
23
29
  auth: { weight: 15, label: 'Auth & Access Control', deductions: { critical: 20, high: 10, medium: 3 } },
24
- config: { weight: 10, label: 'Configuration', deductions: { critical: 15, high: 8, medium: 3 } },
25
- 'supply-chain':{ weight: 10, label: 'Supply Chain', deductions: { critical: 15, high: 8, medium: 3 } },
30
+ config: { weight: 8, label: 'Configuration', deductions: { critical: 15, high: 8, medium: 3 } },
31
+ 'supply-chain':{ weight: 12, label: 'Supply Chain', deductions: { critical: 15, high: 8, medium: 3 } },
26
32
  api: { weight: 10, label: 'API Security', deductions: { critical: 15, high: 8, medium: 3 } },
27
- llm: { weight: 10, label: 'AI/LLM Security', deductions: { critical: 15, high: 8, medium: 3 } },
33
+ llm: { weight: 12, label: 'AI/LLM Security', deductions: { critical: 15, high: 8, medium: 3 } },
28
34
  };
29
35
 
30
36
  // Fallback categories for findings that don't match a known category
31
37
  const FALLBACK_CATEGORY_MAP = {
32
38
  'secret': 'secrets',
33
39
  'vulnerability': 'injection',
34
- 'ssrf': 'injection',
40
+ 'ssrf': 'injection', // OWASP 2025: SSRF merged into A01 Broken Access Control
35
41
  'history': 'secrets',
36
42
  'cicd': 'config',
37
43
  'mobile': 'injection',
@@ -39,7 +45,9 @@ const FALLBACK_CATEGORY_MAP = {
39
45
  'mcp': 'llm',
40
46
  'agentic': 'llm',
41
47
  'rag': 'llm',
42
- 'recon': null, // skip recon findings
48
+ 'vibe': 'injection', // Vibe coding findings → Code Vulnerabilities
49
+ 'exception': 'injection', // OWASP A10:2025 — Mishandling of Exceptional Conditions
50
+ 'recon': null, // skip recon findings
43
51
  };
44
52
 
45
53
  const GRADES = [
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Vibe Coding Security Agent
3
+ * ============================
4
+ *
5
+ * Detects security vulnerabilities commonly introduced by
6
+ * AI-generated code (Cursor, Copilot, Claude Code, etc.).
7
+ *
8
+ * 45% of AI-generated code contains security vulnerabilities.
9
+ * This agent targets the most frequent patterns:
10
+ *
11
+ * - Placeholder credentials left in code
12
+ * - Missing input validation on AI-generated routes
13
+ * - Overly permissive defaults (CORS *, DEBUG=True)
14
+ * - Unprotected API endpoints (no auth middleware)
15
+ * - TODO/FIXME security markers left behind
16
+ * - Hardcoded localhost/development URLs in production code
17
+ * - Incomplete error handling (empty catch blocks)
18
+ * - AI-generated boilerplate with known insecure patterns
19
+ *
20
+ * Maps to: OWASP A01 (Broken Access Control), A05 (Security Misconfiguration)
21
+ */
22
+
23
+ import path from 'path';
24
+ import { BaseAgent, createFinding } from './base-agent.js';
25
+
26
+ // =============================================================================
27
+ // VIBE CODING PATTERNS
28
+ // =============================================================================
29
+
30
+ const PATTERNS = [
31
+ // ── Placeholder Credentials ───────────────────────────────────────────────
32
+ {
33
+ rule: 'VIBE_PLACEHOLDER_KEY',
34
+ title: 'Vibe Code: Placeholder API Key Left in Code',
35
+ regex: /(?:api[_-]?key|apikey|secret|token|password)\s*[:=]\s*['"](?:your[_-]?(?:api[_-]?)?key[_-]?here|sk[_-](?:test|live)[_-]x{5,}|xxx+|insert[_-]?(?:your[_-]?)?(?:key|token|secret)[_-]?here|change[_-]?me|replace[_-]?(?:this|me)|put[_-]?your[_-]?(?:key|token))['"]/gi,
36
+ severity: 'critical',
37
+ cwe: 'CWE-798',
38
+ owasp: 'A07:2021',
39
+ description: 'AI-generated placeholder credential left in code. These are commonly generated by Copilot/Cursor and forgotten before deploy.',
40
+ fix: 'Replace with environment variable: process.env.API_KEY or os.environ["API_KEY"]',
41
+ },
42
+ {
43
+ rule: 'VIBE_PLACEHOLDER_URL',
44
+ title: 'Vibe Code: Placeholder URL Left in Code',
45
+ regex: /['"]https?:\/\/(?:your[_-]?(?:domain|api|server|backend)|example\.com\/api|api\.example|placeholder)[^'"]*['"]/gi,
46
+ severity: 'medium',
47
+ cwe: 'CWE-1188',
48
+ owasp: 'A05:2021',
49
+ confidence: 'medium',
50
+ description: 'AI-generated placeholder URL left in code. May cause requests to fail or leak data to unintended endpoints.',
51
+ fix: 'Replace with environment variable for the API URL',
52
+ },
53
+
54
+ // ── Security TODO/FIXME Markers ───────────────────────────────────────────
55
+ {
56
+ rule: 'VIBE_TODO_AUTH',
57
+ title: 'Vibe Code: TODO — Add Authentication',
58
+ regex: /\/\/\s*(?:TODO|FIXME|HACK|XXX)\s*:?\s*(?:add|implement|fix|enable|require)\s+(?:auth(?:entication|orization)?|login|session|jwt|token|middleware|permission|rbac|access.?control)/gi,
59
+ severity: 'high',
60
+ cwe: 'CWE-306',
61
+ owasp: 'A01:2021',
62
+ description: 'Code has a TODO marker to add authentication/authorization. AI-generated code often ships without auth.',
63
+ fix: 'Implement authentication before deploying. This TODO indicates a known security gap.',
64
+ },
65
+ {
66
+ rule: 'VIBE_TODO_VALIDATION',
67
+ title: 'Vibe Code: TODO — Add Input Validation',
68
+ regex: /\/\/\s*(?:TODO|FIXME|HACK|XXX)\s*:?\s*(?:add|implement|fix|enable)\s+(?:valid(?:ation|ate)|sanitiz(?:e|ation)|escap(?:e|ing)|input.?check|type.?check)/gi,
69
+ severity: 'high',
70
+ cwe: 'CWE-20',
71
+ owasp: 'A03:2021',
72
+ description: 'Code has a TODO marker to add input validation. AI-generated endpoints frequently skip validation.',
73
+ fix: 'Implement input validation before deploying. Unvalidated input leads to injection attacks.',
74
+ },
75
+ {
76
+ rule: 'VIBE_TODO_SECURITY',
77
+ title: 'Vibe Code: TODO — Security Fix Needed',
78
+ regex: /\/\/\s*(?:TODO|FIXME|HACK|XXX)\s*:?\s*(?:add|implement|fix|enable|remove)\s+(?:security|encrypt(?:ion)?|hash(?:ing)?|rate.?limit|csrf|xss|cors|https|ssl|tls|firewall)/gi,
79
+ severity: 'high',
80
+ cwe: 'CWE-693',
81
+ owasp: 'A05:2021',
82
+ description: 'Security-related TODO left in code. This marks a known vulnerability the developer intended to fix.',
83
+ fix: 'Address this security TODO before deploying to production.',
84
+ },
85
+
86
+ // ── Missing Auth on Routes ────────────────────────────────────────────────
87
+ {
88
+ rule: 'VIBE_UNPROTECTED_DELETE',
89
+ title: 'Vibe Code: DELETE Endpoint Without Auth Check',
90
+ regex: /(?:app|router)\.(?:delete)\s*\(\s*['"][^'"]+['"]\s*,\s*(?:async\s+)?\(?(?:req|request|ctx)\b/g,
91
+ severity: 'high',
92
+ cwe: 'CWE-306',
93
+ owasp: 'A01:2021',
94
+ confidence: 'medium',
95
+ description: 'DELETE endpoint defined without visible auth middleware. AI often generates CRUD routes without access control.',
96
+ fix: 'Add authentication middleware: router.delete("/resource/:id", authMiddleware, handler)',
97
+ },
98
+ {
99
+ rule: 'VIBE_UNPROTECTED_ADMIN',
100
+ title: 'Vibe Code: Admin Route Without Auth Middleware',
101
+ regex: /(?:app|router)\.(?:get|post|put|patch|delete)\s*\(\s*['"]\/admin[^'"]*['"]\s*,\s*(?:async\s+)?\(?(?:req|request|ctx)\b/g,
102
+ severity: 'critical',
103
+ cwe: 'CWE-306',
104
+ owasp: 'A01:2021',
105
+ confidence: 'medium',
106
+ description: 'Admin route defined without visible auth middleware. AI-generated admin panels often lack access control.',
107
+ fix: 'Add auth + admin role check: router.get("/admin", authMiddleware, requireAdmin, handler)',
108
+ },
109
+
110
+ // ── Insecure Defaults ─────────────────────────────────────────────────────
111
+ {
112
+ rule: 'VIBE_WILDCARD_CORS',
113
+ title: 'Vibe Code: CORS Allow All Origins',
114
+ regex: /cors\s*\(\s*\{?\s*(?:origin\s*:\s*(?:true|['"]?\*['"]?)|(?:origin\s*:\s*\[?\s*['"]?\*['"]?))/g,
115
+ severity: 'high',
116
+ cwe: 'CWE-942',
117
+ owasp: 'A05:2021',
118
+ description: 'CORS configured to allow all origins. AI tools frequently generate cors({ origin: "*" }) or cors({ origin: true }) as defaults.',
119
+ fix: 'Restrict to specific origins: cors({ origin: ["https://yourdomain.com"] })',
120
+ },
121
+ {
122
+ rule: 'VIBE_DEBUG_PRODUCTION',
123
+ title: 'Vibe Code: Debug Mode Enabled',
124
+ regex: /(?:DEBUG|debug)\s*[:=]\s*(?:true|True|1|['"]true['"])\s*(?:,|\n|$|;)/g,
125
+ severity: 'high',
126
+ cwe: 'CWE-489',
127
+ owasp: 'A05:2021',
128
+ confidence: 'medium',
129
+ description: 'Debug mode is enabled. AI-generated configs often leave DEBUG=True from development.',
130
+ fix: 'Set DEBUG=False for production. Use environment variable: DEBUG=os.environ.get("DEBUG", "False")',
131
+ },
132
+ // TLS disabled detection is handled by AuthBypassAgent (NODE_TLS_REJECT_UNAUTHORIZED)
133
+ // and ConfigAuditor — not duplicated here to avoid false positives on regex definitions.
134
+
135
+ // ── Hardcoded Development URLs ────────────────────────────────────────────
136
+ {
137
+ rule: 'VIBE_HARDCODED_LOCALHOST',
138
+ title: 'Vibe Code: Hardcoded localhost URL in Production Code',
139
+ regex: /(?:fetch|axios|http|request|api|endpoint|baseURL|base_url|API_URL|BACKEND_URL)\s*[:=(]\s*['"]https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0):\d+[^'"]*['"]/gi,
140
+ severity: 'medium',
141
+ cwe: 'CWE-1188',
142
+ owasp: 'A05:2021',
143
+ confidence: 'medium',
144
+ description: 'Hardcoded localhost URL found. AI-generated code often hardcodes development URLs that break in production.',
145
+ fix: 'Use environment variable: process.env.API_URL || "http://localhost:3000"',
146
+ },
147
+
148
+ // Empty catch blocks and except: pass are handled by ExceptionHandlerAgent (OWASP A10:2025)
149
+
150
+ // ── AI-Generated Insecure Patterns ────────────────────────────────────────
151
+ {
152
+ rule: 'VIBE_EVAL_INPUT',
153
+ title: 'Vibe Code: eval() with Variable Input',
154
+ regex: /\beval\s*\(\s*(?:req\.|request\.|body\.|params\.|query\.|input|data|payload|args|user)/g,
155
+ severity: 'critical',
156
+ cwe: 'CWE-95',
157
+ owasp: 'A03:2021',
158
+ description: 'eval() called with user-controlled input. AI sometimes generates eval() for "dynamic" functionality.',
159
+ fix: 'Never use eval() with user input. Use a safe parser or allowlist of operations.',
160
+ },
161
+ {
162
+ rule: 'VIBE_INNER_HTML',
163
+ title: 'Vibe Code: innerHTML with Variable',
164
+ regex: /\.innerHTML\s*=\s*(?!['"]<)[^;]+(?:req|request|input|data|user|response|result|body|params|query|payload)/g,
165
+ severity: 'high',
166
+ cwe: 'CWE-79',
167
+ owasp: 'A03:2021',
168
+ description: 'innerHTML set with dynamic content. AI-generated frontend code often skips XSS sanitization.',
169
+ fix: 'Use textContent for plain text, or sanitize with DOMPurify: el.innerHTML = DOMPurify.sanitize(html)',
170
+ },
171
+ {
172
+ rule: 'VIBE_NO_PARAMETERIZED_QUERY',
173
+ title: 'Vibe Code: String Concatenation in SQL',
174
+ regex: /(?:query|execute|raw|exec)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+\s*(?:req|request|body|params|query|input|user|data))/g,
175
+ severity: 'critical',
176
+ cwe: 'CWE-89',
177
+ owasp: 'A03:2021',
178
+ description: 'SQL query built with string concatenation/interpolation. AI-generated database code frequently lacks parameterization.',
179
+ fix: 'Use parameterized queries: db.query("SELECT * FROM users WHERE id = $1", [userId])',
180
+ },
181
+
182
+ // ── Missing Rate Limiting ─────────────────────────────────────────────────
183
+ {
184
+ rule: 'VIBE_NO_RATE_LIMIT',
185
+ title: 'Vibe Code: Auth Endpoint Without Rate Limiting',
186
+ regex: /(?:app|router)\.post\s*\(\s*['"]\/(?:login|signin|sign-in|register|signup|sign-up|forgot-password|reset-password|auth\/login)['"]\s*,\s*(?:async\s+)?\(?(?:req|request|ctx)\b/g,
187
+ severity: 'high',
188
+ cwe: 'CWE-307',
189
+ owasp: 'A07:2021',
190
+ confidence: 'medium',
191
+ description: 'Authentication endpoint without visible rate limiting. AI-generated auth routes rarely include brute-force protection.',
192
+ fix: 'Add rate limiting: app.post("/login", rateLimit({ windowMs: 15*60*1000, max: 5 }), handler)',
193
+ },
194
+
195
+ // ── Insecure File Operations ──────────────────────────────────────────────
196
+ {
197
+ rule: 'VIBE_PATH_TRAVERSAL',
198
+ title: 'Vibe Code: User Input in File Path',
199
+ regex: /(?:readFile|writeFile|createReadStream|createWriteStream|open|access)\s*\(\s*(?:req\.|request\.|body\.|params\.|query\.|`[^`]*\$\{(?:req|request|params|query|body))/g,
200
+ severity: 'critical',
201
+ cwe: 'CWE-22',
202
+ owasp: 'A01:2021',
203
+ description: 'File operation uses user-controlled input without path validation. AI-generated file serving code often allows path traversal.',
204
+ fix: 'Validate and sanitize the path: path.resolve(baseDir, path.basename(userInput))',
205
+ },
206
+
207
+ // ── Exposed Secrets in Client Code ────────────────────────────────────────
208
+ {
209
+ rule: 'VIBE_SECRET_IN_FRONTEND',
210
+ title: 'Vibe Code: Secret Key in Frontend/Client Code',
211
+ regex: /(?:NEXT_PUBLIC_|REACT_APP_|VITE_|NUXT_PUBLIC_|EXPO_PUBLIC_)(?:SECRET|PRIVATE|API_SECRET|DATABASE_URL|DB_PASSWORD|JWT_SECRET|STRIPE_SECRET)/gi,
212
+ severity: 'critical',
213
+ cwe: 'CWE-200',
214
+ owasp: 'A01:2021',
215
+ description: 'Secret exposed via public environment variable prefix. AI tools often use NEXT_PUBLIC_/REACT_APP_ for all env vars.',
216
+ fix: 'Remove the public prefix. Server-side secrets must NOT use NEXT_PUBLIC_, REACT_APP_, or VITE_ prefixes.',
217
+ },
218
+ ];
219
+
220
+ // =============================================================================
221
+ // VIBE CODING AGENT CLASS
222
+ // =============================================================================
223
+
224
+ export class VibeCodingAgent extends BaseAgent {
225
+ constructor() {
226
+ super(
227
+ 'VibeCodingAgent',
228
+ 'Detects security vulnerabilities commonly introduced by AI-generated code',
229
+ 'injection', // maps to Code Vulnerabilities in scoring
230
+ );
231
+ }
232
+
233
+ async analyze(context) {
234
+ const files = this.getFilesToScan(context);
235
+ const findings = [];
236
+
237
+ for (const file of files) {
238
+ const ext = path.extname(file).toLowerCase();
239
+ // Only scan source code files
240
+ if (!['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
241
+ '.py', '.rb', '.go', '.java', '.rs', '.php',
242
+ '.vue', '.svelte', '.astro'].includes(ext)) continue;
243
+
244
+ const results = this.scanFileWithPatterns(file, PATTERNS);
245
+ findings.push(...results);
246
+ }
247
+
248
+ return findings;
249
+ }
250
+ }
@@ -37,6 +37,9 @@ import { auditCommand } from '../commands/audit.js';
37
37
  import { doctorCommand } from '../commands/doctor.js';
38
38
  import { baselineCommand } from '../commands/baseline.js';
39
39
  import { ciCommand } from '../commands/ci.js';
40
+ import { diffCommand } from '../commands/diff.js';
41
+ import { vibeCheckCommand } from '../commands/vibe-check.js';
42
+ import { benchmarkCommand } from '../commands/benchmark.js';
40
43
  import { PolicyEngine } from '../agents/policy-engine.js';
41
44
  import { SBOMGenerator } from '../agents/sbom-generator.js';
42
45
 
@@ -47,7 +50,7 @@ import { SBOMGenerator } from '../agents/sbom-generator.js';
47
50
  const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
48
51
 
49
52
  // Read version from package.json
50
- const __filename = fileURLToPath(import.meta.url);
53
+ const __filename = fileURLToPath(import.meta.url); // ship-safe-ignore — module's own path via import.meta.url, not user input
51
54
  const __dirname = dirname(__filename);
52
55
  const packageJson = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8'));
53
56
  const VERSION = packageJson.version;
@@ -189,7 +192,7 @@ program
189
192
  // -----------------------------------------------------------------------------
190
193
  program
191
194
  .command('audit [path]')
192
- .description('Full security audit: secrets + 16 agents + deps + score + deep analysis + remediation plan')
195
+ .description('Full security audit: secrets + 17 agents + deps + score + deep analysis + remediation plan')
193
196
  .option('--json', 'Output results as JSON')
194
197
  .option('--sarif', 'Output results in SARIF format')
195
198
  .option('--csv', 'Output results as CSV')
@@ -210,12 +213,24 @@ program
210
213
  .option('-v, --verbose', 'Verbose output')
211
214
  .action(auditCommand);
212
215
 
216
+ // -----------------------------------------------------------------------------
217
+ // DIFF COMMAND (v6.0 — Scan only changed files)
218
+ // -----------------------------------------------------------------------------
219
+ program
220
+ .command('diff [ref]')
221
+ .description('Scan only changed files (git diff) — fast pre-commit & PR scanning')
222
+ .option('--staged', 'Scan only staged changes')
223
+ .option('--json', 'Output results as JSON')
224
+ .option('-p, --path <path>', 'Project path (default: cwd)')
225
+ .option('--timeout <ms>', 'Per-agent timeout in milliseconds (default: 30000)', parseInt)
226
+ .action(diffCommand);
227
+
213
228
  // -----------------------------------------------------------------------------
214
229
  // RED TEAM COMMAND (v4.0 — Multi-Agent Security Audit)
215
230
  // -----------------------------------------------------------------------------
216
231
  program
217
232
  .command('red-team [path]')
218
- .description('Multi-agent security audit: 16 agents scan for 80+ attack classes')
233
+ .description('Multi-agent security audit: 17 agents scan for 80+ attack classes')
219
234
  .option('--agents <list>', 'Comma-separated list of agents to run')
220
235
  .option('--json', 'Output results as JSON')
221
236
  .option('--sarif', 'Output results in SARIF format')
@@ -291,8 +306,27 @@ program
291
306
  .option('--json', 'JSON output')
292
307
  .option('--no-deps', 'Skip dependency audit')
293
308
  .option('--baseline', 'Only check new findings (not in baseline)')
309
+ .option('--github-pr', 'Post findings as a GitHub PR comment (requires gh CLI)')
294
310
  .action(ciCommand);
295
311
 
312
+ // -----------------------------------------------------------------------------
313
+ // VIBE CHECK COMMAND
314
+ // -----------------------------------------------------------------------------
315
+ program
316
+ .command('vibe-check [path]')
317
+ .description('Fun security check with emoji output, shareable score, and badge generator')
318
+ .option('--badge', 'Generate a shields.io markdown badge for your README')
319
+ .action(vibeCheckCommand);
320
+
321
+ // -----------------------------------------------------------------------------
322
+ // BENCHMARK COMMAND
323
+ // -----------------------------------------------------------------------------
324
+ program
325
+ .command('benchmark [path]')
326
+ .description('Compare your security score against industry averages')
327
+ .option('--json', 'Output results as JSON')
328
+ .action(benchmarkCommand);
329
+
296
330
  // -----------------------------------------------------------------------------
297
331
  // DOCTOR COMMAND
298
332
  // -----------------------------------------------------------------------------
@@ -309,11 +343,14 @@ program
309
343
  if (process.argv.length === 2) {
310
344
  console.log(banner);
311
345
  console.log(chalk.yellow('\nQuick start:\n'));
312
- console.log(chalk.cyan.bold(' v5.0 — Full Security Audit'));
313
- console.log(chalk.white(' npx ship-safe audit . ') + chalk.gray('# Full audit: secrets + 16 agents + deps + remediation'));
346
+ console.log(chalk.cyan.bold(' v6.0 — Full Security Audit'));
347
+ console.log(chalk.white(' npx ship-safe audit . ') + chalk.gray('# Full audit: secrets + 17 agents + deps + remediation'));
314
348
  console.log(chalk.white(' npx ship-safe audit . --deep') + chalk.gray('# LLM-powered taint analysis (Anthropic/Ollama)'));
315
- console.log(chalk.white(' npx ship-safe red-team . ') + chalk.gray('# 16-agent red team scan (80+ attack classes)'));
349
+ console.log(chalk.white(' npx ship-safe red-team . ') + chalk.gray('# 17-agent red team scan (80+ attack classes)'));
350
+ console.log(chalk.white(' npx ship-safe vibe-check . ') + chalk.gray('# Fun security check with emoji & shareable badge'));
351
+ console.log(chalk.white(' npx ship-safe benchmark . ') + chalk.gray('# Compare score against industry averages'));
316
352
  console.log(chalk.white(' npx ship-safe ci . ') + chalk.gray('# CI/CD mode: scan, score, exit code'));
353
+ console.log(chalk.white(' npx ship-safe diff ') + chalk.gray('# Scan only changed files (fast pre-commit)'));
317
354
  console.log(chalk.white(' npx ship-safe watch . ') + chalk.gray('# Continuous monitoring mode'));
318
355
  console.log(chalk.white(' npx ship-safe sbom . ') + chalk.gray('# Generate CycloneDX SBOM (CRA-ready)'));
319
356
  console.log(chalk.white(' npx ship-safe policy init ') + chalk.gray('# Create security policy template'));
@@ -33,6 +33,7 @@ import {
33
33
  SECURITY_PATTERNS,
34
34
  SKIP_DIRS,
35
35
  SKIP_EXTENSIONS,
36
+ SKIP_FILENAMES,
36
37
  TEST_FILE_PATTERNS,
37
38
  MAX_FILE_SIZE
38
39
  } from '../utils/patterns.js';
@@ -99,7 +100,7 @@ export async function agentCommand(targetPath = '.', options = {}) {
99
100
 
100
101
  // ── 4. Fallback: no API key ────────────────────────────────────────────────
101
102
  if (!apiKey) {
102
- console.log(chalk.yellow(' ⚠ No ANTHROPIC_API_KEY found.'));
103
+ console.log(chalk.yellow(' ⚠ No ANTHROPIC_API_KEY found.')); // ship-safe-ignore — env var name in user-facing message, no key value
103
104
  console.log(chalk.gray(' Set it in your environment or .env to enable AI classification.'));
104
105
  if (secretCount > 0) {
105
106
  console.log(chalk.gray(' Falling back to pattern-based remediation for secrets...\n'));
@@ -226,8 +227,8 @@ export async function agentCommand(targetPath = '.', options = {}) {
226
227
  * Returns the key string or null if not found.
227
228
  */
228
229
  function loadApiKey(rootPath) {
229
- if (process.env.ANTHROPIC_API_KEY) {
230
- return process.env.ANTHROPIC_API_KEY;
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
231
232
  }
232
233
 
233
234
  const envPath = path.join(rootPath, '.env');
@@ -239,7 +240,7 @@ function loadApiKey(rootPath) {
239
240
  if (trimmed.startsWith('#') || !trimmed.includes('=')) continue;
240
241
  const eqIdx = trimmed.indexOf('=');
241
242
  const key = trimmed.slice(0, eqIdx).trim();
242
- if (key === 'ANTHROPIC_API_KEY') {
243
+ if (key === 'ANTHROPIC_API_KEY') { // ship-safe-ignore — parsing .env file to read user's own API key from their project
243
244
  const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
244
245
  if (val) return val;
245
246
  }
@@ -270,6 +271,7 @@ async function scanProject(rootPath) {
270
271
  const files = allFiles.filter(file => {
271
272
  const ext = path.extname(file).toLowerCase();
272
273
  if (SKIP_EXTENSIONS.has(ext)) return false;
274
+ if (SKIP_FILENAMES.has(path.basename(file))) return false;
273
275
  const basename = path.basename(file);
274
276
  if (basename.endsWith('.min.js') || basename.endsWith('.min.css')) return false;
275
277
  if (TEST_FILE_PATTERNS.some(p => p.test(file))) return false;
@@ -29,6 +29,7 @@ import {
29
29
  SECURITY_PATTERNS,
30
30
  SKIP_DIRS,
31
31
  SKIP_EXTENSIONS,
32
+ SKIP_FILENAMES,
32
33
  MAX_FILE_SIZE,
33
34
  loadGitignorePatterns
34
35
  } from '../utils/patterns.js';
@@ -132,11 +133,39 @@ export async function auditCommand(targetPath = '.', options = {}) {
132
133
  description: f.description,
133
134
  matched: f.matched,
134
135
  confidence: f.confidence,
135
- fix: `Move to environment variable or secrets manager`,
136
+ fix: file.match(/\.env(\..*)?$/)
137
+ ? `Ensure .env is in .gitignore and use a secrets manager for production`
138
+ : `Move to environment variable or secrets manager`,
136
139
  });
137
140
  }
138
141
  }
139
142
 
143
+ // Downgrade .env findings if the file is gitignored (properly managed)
144
+ const gitignoreContent = (() => {
145
+ try { return fs.readFileSync(path.join(absolutePath, '.gitignore'), 'utf-8'); } catch { return ''; }
146
+ })();
147
+ const envIsGitignored = gitignoreContent.split('\n')
148
+ .map(l => l.trim())
149
+ .some(l => /^\.env(\s|$)/.test(l) || l === '*.env' || l === '.env*' || l === '.env.local' || l === '.env.production');
150
+
151
+ if (envIsGitignored) {
152
+ for (const f of secretFindings) {
153
+ if (f.file.match(/\.env(\..*)?$/) && !f.file.includes('node_modules')) {
154
+ f.severity = 'low';
155
+ f.confidence = 'low';
156
+ f.fix = 'Already gitignored — ensure secrets manager is used for production deploys';
157
+ }
158
+ }
159
+ }
160
+
161
+ // Downgrade secrets in test files (intentional test fixtures)
162
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/|\/fixtures?\/)/i;
163
+ for (const f of secretFindings) {
164
+ if (TEST_PATH.test(f.file)) {
165
+ f.confidence = 'low';
166
+ }
167
+ }
168
+
140
169
  // Merge with cached findings for unchanged files
141
170
  secretFindings = [...secretFindings, ...cachedSecretFindings];
142
171
 
@@ -154,13 +183,14 @@ export async function auditCommand(targetPath = '.', options = {}) {
154
183
  }
155
184
 
156
185
  // ── Phase 2: Agent Scan ───────────────────────────────────────────────────
157
- const agentSpinner = machineOutput ? null : ora({ text: chalk.white('[Phase 2/4] Running 12 security agents...'), color: 'cyan' }).start();
186
+ const orchestrator = buildOrchestrator();
187
+ const registeredAgentCount = orchestrator.agents?.length || 15;
188
+ const agentSpinner = machineOutput ? null : ora({ text: chalk.white(`[Phase 2/4] Running ${registeredAgentCount} security agents...`), color: 'cyan' }).start();
158
189
  let agentFindings = [];
159
190
  let recon = null;
160
191
  let agentResults = [];
161
192
 
162
193
  try {
163
- const orchestrator = buildOrchestrator();
164
194
  // Suppress individual agent spinners by using quiet mode
165
195
  // Pass changedFiles for incremental scanning if cache is valid
166
196
  const orchestratorOpts = { quiet: true };
@@ -172,7 +202,7 @@ export async function auditCommand(targetPath = '.', options = {}) {
172
202
  if (cacheDiff && cacheDiff.changedFiles.length < allFiles.length) {
173
203
  orchestratorOpts.changedFiles = cacheDiff.changedFiles;
174
204
  }
175
- const results = await orchestrator.runAll(absolutePath, orchestratorOpts);
205
+ const results = await orchestrator.runAll(absolutePath, orchestratorOpts); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
176
206
  recon = results.recon;
177
207
  agentFindings = results.findings;
178
208
  agentResults = results.agentResults;
@@ -232,6 +262,8 @@ export async function auditCommand(targetPath = '.', options = {}) {
232
262
  // Score
233
263
  const scoringEngine = new ScoringEngine();
234
264
  const scoreResult = scoringEngine.compute(filteredFindings, depVulns);
265
+ // Round score to 1 decimal place to avoid floating-point noise (e.g., 63.300000000000004)
266
+ scoreResult.score = Math.round(scoreResult.score * 10) / 10;
235
267
  scoringEngine.saveToHistory(absolutePath, scoreResult, suppressions);
236
268
 
237
269
  const gradeColor = scoreResult.score >= 75 ? chalk.green.bold : scoreResult.score >= 60 ? chalk.yellow.bold : chalk.red.bold;
@@ -391,7 +423,9 @@ export async function auditCommand(targetPath = '.', options = {}) {
391
423
  const trend = scoringEngine.getTrend(absolutePath, scoreResult.score);
392
424
  if (trend) {
393
425
  const arrow = trend.diff > 0 ? chalk.green('↑') : trend.diff < 0 ? chalk.red('↓') : chalk.gray('→');
394
- console.log(chalk.gray(` Trend: ${trend.previousScore} ${trend.currentScore} ${arrow} (${trend.diff > 0 ? '+' : ''}${trend.diff})`));
426
+ const roundedDiff = Math.round(trend.diff * 10) / 10;
427
+ const diffLabel = roundedDiff === 0 ? chalk.gray('no change') : chalk.white(`${roundedDiff > 0 ? '+' : ''}${roundedDiff}`);
428
+ console.log(chalk.gray(` Trend: ${trend.previousScore} → ${trend.currentScore} ${arrow} (`) + diffLabel + chalk.gray(')'));
395
429
  }
396
430
 
397
431
  // ── Detailed Comparison ────────────────────────────────────────────────
@@ -415,14 +449,47 @@ function buildRemediationPlan(findings, depVulns, rootPath) {
415
449
  const plan = [];
416
450
  let priority = 1;
417
451
 
452
+ // Exclude low-confidence findings (test files, docs, comments) from remediation plan
453
+ const actionable = findings.filter(f => f.confidence !== 'low');
454
+
418
455
  // Priority order: secrets first, then by severity
419
- const secretFindings = findings.filter(f => f.category === 'secrets' || f.category === 'secret');
420
- const otherFindings = findings.filter(f => f.category !== 'secrets' && f.category !== 'secret');
456
+ const secretFindings = actionable.filter(f => f.category === 'secrets' || f.category === 'secret');
457
+ const otherFindings = actionable.filter(f => f.category !== 'secrets' && f.category !== 'secret');
421
458
 
422
459
  // Group and sort
423
460
  for (const sev of SEV_ORDER) {
424
- // Secrets at this severity
425
- for (const f of secretFindings.filter(s => s.severity === sev)) {
461
+ // Secrets at this severity — group .env findings by file
462
+ const sevSecrets = secretFindings.filter(s => s.severity === sev);
463
+ const envGroups = new Map();
464
+ const nonEnvSecrets = [];
465
+
466
+ for (const f of sevSecrets) {
467
+ const relFile = path.relative(rootPath, f.file).replace(/\\/g, '/');
468
+ if (f.file.match(/\.env(\..*)?$/)) {
469
+ if (!envGroups.has(relFile)) envGroups.set(relFile, []);
470
+ envGroups.get(relFile).push(f);
471
+ } else {
472
+ nonEnvSecrets.push(f);
473
+ }
474
+ }
475
+
476
+ // One plan item per .env file
477
+ for (const [relFile, envFindings] of envGroups) {
478
+ const names = envFindings.map(f => f.title || f.rule).join(', ');
479
+ plan.push({
480
+ priority: priority++,
481
+ severity: sev,
482
+ category: 'secrets',
483
+ categoryLabel: 'SECRETS',
484
+ title: `${envFindings.length} secret${envFindings.length > 1 ? 's' : ''} in ${relFile} (${names})`,
485
+ file: relFile,
486
+ action: envFindings[0].fix || 'Ensure .env is in .gitignore and use a secrets manager for production',
487
+ effort: 'low',
488
+ });
489
+ }
490
+
491
+ // Individual items for non-.env secrets
492
+ for (const f of nonEnvSecrets) {
426
493
  plan.push({
427
494
  priority: priority++,
428
495
  severity: sev,
@@ -498,14 +565,17 @@ function printReport(scoreResult, findings, depVulns, recon, plan, rootPath, fil
498
565
  const count = Object.values(cat.counts).reduce((a, b) => a + b, 0);
499
566
  const icon = count === 0 ? chalk.green('✔') : chalk.red('✘');
500
567
  const status = count === 0 ? chalk.green('clean') : chalk.red(`${count} issue(s)`);
501
- const deduction = cat.deduction > 0 ? chalk.red(`-${cat.deduction} pts`) : chalk.gray('+0');
568
+ const deduction = cat.deduction > 0 ? chalk.red(`-${Math.round(cat.deduction * 10) / 10} pts`) : chalk.gray('+0');
502
569
  console.log(` ${icon} ${chalk.white(cat.label.padEnd(22))} ${status.padEnd(25)} ${deduction}`);
503
570
  }
504
571
 
505
- // Deps row
506
- const depIcon = depVulns.length === 0 ? chalk.green('✔') : chalk.red('');
507
- const depStatus = depVulns.length === 0 ? chalk.green('clean') : chalk.red(`${depVulns.length} CVE(s)`);
508
- console.log(` ${depIcon} ${chalk.white('Dependencies'.padEnd(22))} ${depStatus}`);
572
+ // Deps row — only print if not already included in scoreResult.categories
573
+ const hasDepsCategory = Object.values(scoreResult.categories).some(c => c.label?.toLowerCase().includes('depend'));
574
+ if (!hasDepsCategory) {
575
+ const depIcon = depVulns.length === 0 ? chalk.green('') : chalk.red('✘');
576
+ const depStatus = depVulns.length === 0 ? chalk.green('clean') : chalk.red(`${depVulns.length} CVE(s)`);
577
+ console.log(` ${depIcon} ${chalk.white('Dependencies'.padEnd(22))} ${depStatus}`);
578
+ }
509
579
 
510
580
  console.log(chalk.gray(`\n Files scanned: ${filesScanned} | Findings: ${findings.length} | CVEs: ${depVulns.length}`));
511
581
 
@@ -694,6 +764,7 @@ async function findFiles(rootPath) {
694
764
  return files.filter(file => {
695
765
  const ext = path.extname(file).toLowerCase();
696
766
  if (SKIP_EXTENSIONS.has(ext)) return false;
767
+ if (SKIP_FILENAMES.has(path.basename(file))) return false;
697
768
  if (path.basename(file).endsWith('.min.js') || path.basename(file).endsWith('.min.css')) return false;
698
769
  try { if (fs.statSync(file).size > MAX_FILE_SIZE) return false; } catch { return false; }
699
770
  return true;
@@ -17,7 +17,7 @@ import path from 'path';
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
19
  import { buildOrchestrator } from '../agents/index.js';
20
- import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, MAX_FILE_SIZE } from '../utils/patterns.js';
20
+ import { SECRET_PATTERNS, SKIP_DIRS, SKIP_EXTENSIONS, SKIP_FILENAMES, MAX_FILE_SIZE } from '../utils/patterns.js';
21
21
  import { isHighEntropyMatch } from '../utils/entropy.js';
22
22
  import fg from 'fast-glob';
23
23
 
@@ -45,6 +45,7 @@ async function quickScan(rootPath) {
45
45
  const filtered = files.filter(f => {
46
46
  const ext = path.extname(f).toLowerCase();
47
47
  if (SKIP_EXTENSIONS.has(ext)) return false;
48
+ if (SKIP_FILENAMES.has(path.basename(f))) return false;
48
49
  try { return fs.statSync(f).size <= MAX_FILE_SIZE; } catch { return false; }
49
50
  });
50
51
 
@@ -77,7 +78,7 @@ async function fullScan(rootPath) {
77
78
  const { findings: secretFindings, files } = await quickScan(rootPath);
78
79
 
79
80
  const orchestrator = buildOrchestrator();
80
- const { findings: agentFindings } = await orchestrator.runAll(rootPath, { quiet: true });
81
+ const { findings: agentFindings } = await orchestrator.runAll(rootPath, { quiet: true }); // ship-safe-ignore — orchestrator result, not LLM output triggering actions
81
82
 
82
83
  return [...secretFindings, ...agentFindings];
83
84
  }