agentaudit 3.12.10 → 3.12.12

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.
package/cli.mjs CHANGED
@@ -36,6 +36,19 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
36
  const SKILL_DIR = path.resolve(__dirname);
37
37
  const REGISTRY_URL = 'https://agentaudit.dev';
38
38
 
39
+ // ── Global error handlers — catch unhandled errors and exit cleanly ────
40
+ process.on('uncaughtException', (err) => {
41
+ process.stderr.write(`\nagentaudit: fatal error — ${err.message || err}\n`);
42
+ if (process.argv.includes('--debug')) process.stderr.write(`${err.stack || ''}\n`);
43
+ process.exit(2);
44
+ });
45
+ process.on('unhandledRejection', (reason) => {
46
+ const msg = reason instanceof Error ? reason.message : String(reason);
47
+ process.stderr.write(`\nagentaudit: unhandled promise rejection — ${msg}\n`);
48
+ if (process.argv.includes('--debug') && reason instanceof Error) process.stderr.write(`${reason.stack || ''}\n`);
49
+ process.exit(2);
50
+ });
51
+
39
52
  // ── Global flags (set in main before command routing) ────
40
53
  let jsonMode = false;
41
54
  let quietMode = false;
@@ -367,21 +380,23 @@ function multiSelect(items, { title = 'Select items', hint = 'Space=toggle ↑
367
380
  process.stdin.resume();
368
381
  process.stdin.setEncoding('utf8');
369
382
 
383
+ const cleanup = () => {
384
+ try { process.stdin.setRawMode(false); } catch {}
385
+ process.stdin.pause();
386
+ process.stdin.removeListener('data', onData);
387
+ };
388
+
370
389
  const onData = (key) => {
371
- // Ctrl+C
390
+ // Ctrl+C — restore terminal state and exit cleanly
372
391
  if (key === '\x03') {
373
- process.stdin.setRawMode(false);
374
- process.stdin.pause();
375
- process.stdin.removeListener('data', onData);
392
+ cleanup();
376
393
  console.log();
377
- process.exitCode = 0; return;
394
+ process.exit(0);
378
395
  }
379
396
 
380
397
  // Enter
381
398
  if (key === '\r' || key === '\n') {
382
- process.stdin.setRawMode(false);
383
- process.stdin.pause();
384
- process.stdin.removeListener('data', onData);
399
+ cleanup();
385
400
  resolve(items.filter((_, i) => selected.has(i)).map(i => i.value));
386
401
  return;
387
402
  }
@@ -1001,23 +1016,34 @@ function formatApiError(error, provider, statusCode) {
1001
1016
  return null;
1002
1017
  }
1003
1018
 
1019
+ /**
1020
+ * Validate that a parsed object looks like a valid audit report.
1021
+ * Must have at least: findings (array) and one of skill_slug/risk_score/result.
1022
+ */
1023
+ function isValidReportSchema(obj) {
1024
+ if (!obj || typeof obj !== 'object') return false;
1025
+ if (!Array.isArray(obj.findings)) return false;
1026
+ // Must have at least one identifying field
1027
+ if (!('skill_slug' in obj) && !('risk_score' in obj) && !('result' in obj)) return false;
1028
+ return true;
1029
+ }
1030
+
1004
1031
  function extractJSON(text) {
1005
1032
  // 1. Try parsing the entire text as JSON directly
1006
- try { return JSON.parse(text.trim()); } catch {}
1007
-
1033
+ try {
1034
+ const parsed = JSON.parse(text.trim());
1035
+ if (isValidReportSchema(parsed)) return parsed;
1036
+ } catch {}
1037
+
1008
1038
  // 2. Strip markdown code fences — try last fence first (report is usually at the end)
1009
1039
  const fenceMatches = [...text.matchAll(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/g)];
1010
1040
  for (let i = fenceMatches.length - 1; i >= 0; i--) {
1011
- try {
1041
+ try {
1012
1042
  const parsed = JSON.parse(fenceMatches[i][1].trim());
1013
- if (parsed && typeof parsed === 'object' && ('risk_score' in parsed || 'findings' in parsed || 'result' in parsed)) return parsed;
1043
+ if (isValidReportSchema(parsed)) return parsed;
1014
1044
  } catch {}
1015
1045
  }
1016
- // Try any fence even without report keys
1017
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
1018
- try { return JSON.parse(fenceMatches[i][1].trim()); } catch {}
1019
- }
1020
-
1046
+
1021
1047
  // 3. Find ALL balanced top-level { ... } blocks, try each (prefer largest valid one)
1022
1048
  const blocks = [];
1023
1049
  let searchFrom = 0;
@@ -1045,9 +1071,12 @@ function extractJSON(text) {
1045
1071
  // Try largest block first (the report JSON is usually the biggest)
1046
1072
  blocks.sort((a, b) => b.length - a.length);
1047
1073
  for (const block of blocks) {
1048
- try { return JSON.parse(block); } catch {}
1074
+ try {
1075
+ const parsed = JSON.parse(block);
1076
+ if (isValidReportSchema(parsed)) return parsed;
1077
+ } catch {}
1049
1078
  }
1050
-
1079
+
1051
1080
  return null;
1052
1081
  }
1053
1082
 
@@ -1067,8 +1096,15 @@ const SKIP_EXTENSIONS = new Set([
1067
1096
  '.dylib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite',
1068
1097
  ]);
1069
1098
 
1070
- function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }) {
1099
+ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0 }, _visitedPaths = new Set()) {
1071
1100
  if (totalSize.bytes >= MAX_TOTAL_SIZE) return collected;
1101
+
1102
+ // Symlink loop protection: resolve real path and track visited directories
1103
+ let realDir;
1104
+ try { realDir = fs.realpathSync(dir); } catch { return collected; }
1105
+ if (_visitedPaths.has(realDir)) return collected;
1106
+ _visitedPaths.add(realDir);
1107
+
1072
1108
  let entries;
1073
1109
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
1074
1110
  catch { return collected; }
@@ -1077,15 +1113,24 @@ function collectFiles(dir, basePath = '', collected = [], totalSize = { bytes: 0
1077
1113
  if (totalSize.bytes >= MAX_TOTAL_SIZE) break;
1078
1114
  const relPath = basePath ? `${basePath}/${entry.name}` : entry.name;
1079
1115
  const fullPath = path.join(dir, entry.name);
1116
+
1117
+ // Skip symlinks that point to directories (prevent symlink traversal attacks)
1118
+ if (entry.isSymbolicLink()) {
1119
+ try {
1120
+ const target = fs.realpathSync(fullPath);
1121
+ if (fs.statSync(target).isDirectory()) continue; // skip symlinked dirs entirely
1122
+ } catch { continue; }
1123
+ }
1124
+
1080
1125
  if (entry.isDirectory()) {
1081
1126
  // Special: scan .github/workflows/ (security-critical CI/CD files)
1082
1127
  if (entry.name === '.github') {
1083
1128
  const wfDir = path.join(fullPath, 'workflows');
1084
- try { if (fs.statSync(wfDir).isDirectory()) collectFiles(wfDir, relPath + '/workflows', collected, totalSize); } catch {}
1129
+ try { if (fs.statSync(wfDir).isDirectory()) collectFiles(wfDir, relPath + '/workflows', collected, totalSize, _visitedPaths); } catch {}
1085
1130
  continue;
1086
1131
  }
1087
1132
  if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
1088
- collectFiles(fullPath, relPath, collected, totalSize);
1133
+ collectFiles(fullPath, relPath, collected, totalSize, _visitedPaths);
1089
1134
  } else {
1090
1135
  const ext = path.extname(entry.name).toLowerCase();
1091
1136
  if (SKIP_EXTENSIONS.has(ext)) continue;
@@ -2745,6 +2790,30 @@ function checkContextLimit(model, systemPrompt, userMessage) {
2745
2790
  return null;
2746
2791
  }
2747
2792
 
2793
+ /**
2794
+ * Safely parse JSON from a fetch response. If the response is not JSON
2795
+ * (e.g. HTML error page from a 502/503), returns {error: {message: ...}}
2796
+ * which the callLlm error handling paths already handle.
2797
+ */
2798
+ async function safeJsonParse(res, llmConfig) {
2799
+ const contentType = res.headers.get('content-type') || '';
2800
+ // Read body as text first — we can only consume the stream once
2801
+ let body;
2802
+ try { body = await res.text(); } catch { body = ''; }
2803
+
2804
+ if (!res.ok && !contentType.includes('application/json')) {
2805
+ // Non-JSON error response (e.g. HTML from a proxy/gateway)
2806
+ const preview = body.slice(0, 200).replace(/<[^>]+>/g, '').trim();
2807
+ return { error: { message: `HTTP ${res.status} from ${llmConfig.provider}${preview ? ': ' + preview : ''}` } };
2808
+ }
2809
+ try {
2810
+ return JSON.parse(body);
2811
+ } catch (parseErr) {
2812
+ const preview = body.slice(0, 200).replace(/<[^>]+>/g, '').trim();
2813
+ return { error: { message: `Invalid JSON from ${llmConfig.provider} (HTTP ${res.status}): ${preview || parseErr.message}` } };
2814
+ }
2815
+ }
2816
+
2748
2817
  async function callLlm(llmConfig, systemPrompt, userMessage) {
2749
2818
  const apiKey = process.env[llmConfig.key];
2750
2819
  if (!apiKey) return { error: `Missing API key: ${llmConfig.key}` };
@@ -2769,7 +2838,7 @@ async function callLlm(llmConfig, systemPrompt, userMessage) {
2769
2838
  body: JSON.stringify({ model: llmConfig.model, max_tokens: 16384, system: systemPrompt, messages: [{ role: 'user', content: userMessage }] }),
2770
2839
  signal: AbortSignal.timeout(180_000),
2771
2840
  });
2772
- data = await res.json();
2841
+ data = await safeJsonParse(res, llmConfig);
2773
2842
  if (data.error) {
2774
2843
  const friendly = formatApiError(data.error, llmConfig.provider, res.status);
2775
2844
  return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
@@ -2789,7 +2858,10 @@ async function callLlm(llmConfig, systemPrompt, userMessage) {
2789
2858
  }
2790
2859
  return { report, text: _text, duration: Date.now() - start, truncated: data.stop_reason === 'max_tokens' };
2791
2860
  } else if (llmConfig.type === 'gemini') {
2792
- const res = await fetch(`${llmConfig.url}/${llmConfig.model}:generateContent?key=${apiKey}`, {
2861
+ // NOTE: Google's Gemini API requires the API key as a URL query parameter.
2862
+ // This is by design (their auth model). We never log the full URL to avoid key leakage.
2863
+ const geminiUrl = `${llmConfig.url}/${llmConfig.model}:generateContent?key=${apiKey}`;
2864
+ const res = await fetch(geminiUrl, {
2793
2865
  method: 'POST',
2794
2866
  headers: { 'Content-Type': 'application/json' },
2795
2867
  body: JSON.stringify({
@@ -2799,7 +2871,7 @@ async function callLlm(llmConfig, systemPrompt, userMessage) {
2799
2871
  }),
2800
2872
  signal: AbortSignal.timeout(180_000),
2801
2873
  });
2802
- data = await res.json();
2874
+ data = await safeJsonParse(res, llmConfig);
2803
2875
  if (data.error) {
2804
2876
  const friendly = formatApiError(data.error, llmConfig.provider, res.status);
2805
2877
  return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
@@ -2827,7 +2899,7 @@ async function callLlm(llmConfig, systemPrompt, userMessage) {
2827
2899
  body: JSON.stringify({ model: llmConfig.model, max_tokens: 16384, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userMessage }] }),
2828
2900
  signal: AbortSignal.timeout(180_000),
2829
2901
  });
2830
- data = await res.json();
2902
+ data = await safeJsonParse(res, llmConfig);
2831
2903
  if (data.error) {
2832
2904
  const friendly = formatApiError(data.error, llmConfig.provider, res.status);
2833
2905
  return { error: friendly?.text || data.error.message || JSON.stringify(data.error), hint: friendly?.hint, duration: Date.now() - start };
@@ -2919,7 +2991,23 @@ function enrichFindings(report, files, pkgInfo) {
2919
2991
  report.max_severity = report.findings.length > 0 ? maxSev : 'none';
2920
2992
  }
2921
2993
 
2994
+ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']);
2995
+
2922
2996
  for (const finding of report.findings) {
2997
+ // 0. Validate & sanitize finding fields
2998
+ // Severity: must be one of the known values
2999
+ const sev = (finding.severity || '').toLowerCase();
3000
+ finding.severity = VALID_SEVERITIES.has(sev) ? sev : 'medium';
3001
+ // Line number: must be a positive integer
3002
+ if (finding.line != null) {
3003
+ const lineNum = parseInt(finding.line, 10);
3004
+ finding.line = (Number.isFinite(lineNum) && lineNum > 0) ? lineNum : undefined;
3005
+ }
3006
+ // File path: reject suspicious characters (null bytes, .., protocol schemes)
3007
+ if (finding.file && (/[\x00]|\.\.[\\/]|^[a-z]+:\/\//i.test(finding.file))) {
3008
+ finding.file = undefined;
3009
+ }
3010
+
2923
3011
  // 1. Fill cwe_id from pattern_id lookup
2924
3012
  if (!finding.cwe_id || finding.cwe_id === '') {
2925
3013
  const prefix = (finding.pattern_id || '').replace(/_\d+$/, '');
@@ -3648,12 +3736,19 @@ async function remoteAudit(url) {
3648
3736
 
3649
3737
  for (const part of parts) {
3650
3738
  const eventMatch = part.match(/^event:\s*(.+)/m);
3651
- const dataMatch = part.match(/^data:\s*(.+)/m);
3652
- if (!eventMatch || !dataMatch) continue;
3739
+ if (!eventMatch) continue;
3740
+ // Accumulate all data: lines per SSE spec (data fields can span multiple lines)
3741
+ const dataLines = [];
3742
+ for (const line of part.split('\n')) {
3743
+ const dm = line.match(/^data:\s?(.*)/);
3744
+ if (dm) dataLines.push(dm[1]);
3745
+ }
3746
+ if (dataLines.length === 0) continue;
3747
+ const dataStr = dataLines.join('\n');
3653
3748
 
3654
3749
  const event = eventMatch[1].trim();
3655
3750
  let data;
3656
- try { data = JSON.parse(dataMatch[1]); } catch { continue; }
3751
+ try { data = JSON.parse(dataStr); } catch { continue; }
3657
3752
 
3658
3753
  switch (event) {
3659
3754
  case 'step': {
@@ -4864,13 +4959,23 @@ async function main() {
4864
4959
  ` agentaudit consensus fastmcp --json`,
4865
4960
  ],
4866
4961
  history: [
4867
- `${c.bold}agentaudit history${c.reset} [options]`,
4962
+ `${c.bold}agentaudit history${c.reset} [show|upload] [n]`,
4868
4963
  ``,
4869
4964
  `Show your local audit history. Results are stored in ~/.config/agentaudit/history/`,
4870
4965
  `after every audit run. No internet connection required.`,
4871
4966
  ``,
4967
+ `${c.bold}Subcommands:${c.reset}`,
4968
+ ` history List all local audits (numbered)`,
4969
+ ` history show <n> Show full report details for entry #n`,
4970
+ ` history upload <n> Retry upload of entry #n to agentaudit.dev`,
4971
+ ``,
4872
4972
  `${c.bold}Options:${c.reset}`,
4873
4973
  ` --json Machine-readable JSON output`,
4974
+ ``,
4975
+ `${c.bold}Examples:${c.reset}`,
4976
+ ` agentaudit history`,
4977
+ ` agentaudit history show 1`,
4978
+ ` agentaudit history upload 1`,
4874
4979
  ],
4875
4980
  activity: [
4876
4981
  `${c.bold}agentaudit activity${c.reset} [options]`,
@@ -5033,13 +5138,96 @@ async function main() {
5033
5138
  }
5034
5139
  if (command === 'history') {
5035
5140
  banner();
5141
+ const subCmd = targets[0];
5036
5142
  const entries = loadHistory(30);
5037
- if (entries.length === 0) {
5143
+
5144
+ if (entries.length === 0 && !subCmd) {
5038
5145
  console.log(` ${c.dim}No local audit history yet. Run ${c.cyan}agentaudit audit <url>${c.dim} to start.${c.reset}`);
5039
5146
  console.log();
5040
5147
  return;
5041
5148
  }
5042
5149
 
5150
+ // history show <n> — show full report details
5151
+ if (subCmd === 'show') {
5152
+ const idx = parseInt(targets[1], 10) - 1;
5153
+ if (isNaN(idx) || idx < 0 || idx >= entries.length) {
5154
+ console.log(` ${c.red}Invalid index.${c.reset} Use a number from 1 to ${entries.length}.`);
5155
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit history${c.dim} to see the list.${c.reset}`);
5156
+ return;
5157
+ }
5158
+ const entry = entries[idx];
5159
+ if (jsonMode) {
5160
+ console.log(JSON.stringify(entry, null, 2));
5161
+ return;
5162
+ }
5163
+ console.log(sectionHeader(`Report: ${entry.skill_slug || 'unknown'}`));
5164
+ console.log();
5165
+ console.log(` Source ${c.bold}${entry.source_url || '?'}${c.reset}`);
5166
+ console.log(` Model ${c.bold}${entry.audit_model || '?'}${c.reset} ${c.dim}(${entry.audit_provider || '?'})${c.reset}`);
5167
+ console.log(` Risk ${riskBadge(entry.risk_score ?? 0)}`);
5168
+ console.log(` Result ${entry.result || '?'}`);
5169
+ console.log(` Files ${entry.files_scanned || '?'} ${c.dim}Duration: ${entry.audit_duration_ms ? (entry.audit_duration_ms / 1000).toFixed(1) + 's' : '?'}${c.reset}`);
5170
+ console.log(` Tokens ${c.dim}in: ${entry.input_tokens || '?'} out: ${entry.output_tokens || '?'}${c.reset}`);
5171
+ console.log(` File ${c.dim}${entry._file}${c.reset}`);
5172
+ console.log();
5173
+ if (entry.findings && entry.findings.length > 0) {
5174
+ console.log(sectionHeader(`Findings (${entry.findings.length})`));
5175
+ console.log();
5176
+ for (const f of entry.findings) {
5177
+ const sc = severityColor(f.severity);
5178
+ console.log(` ${sc}┃${c.reset} ${sc}${(f.severity || '').toUpperCase().padEnd(8)}${c.reset} ${c.bold}${f.title}${c.reset}`);
5179
+ if (f.file) console.log(` ${sc}┃${c.reset} ${c.dim}${f.file}${f.line ? ':' + f.line : ''}${c.reset}`);
5180
+ if (f.description) console.log(` ${sc}┃${c.reset} ${c.dim}${f.description.slice(0, 200)}${c.reset}`);
5181
+ console.log();
5182
+ }
5183
+ } else {
5184
+ console.log(` ${c.green}No findings.${c.reset}`);
5185
+ console.log();
5186
+ }
5187
+ return;
5188
+ }
5189
+
5190
+ // history upload <n> — retry upload of a local report
5191
+ if (subCmd === 'upload') {
5192
+ const idx = parseInt(targets[1], 10) - 1;
5193
+ if (isNaN(idx) || idx < 0 || idx >= entries.length) {
5194
+ console.log(` ${c.red}Invalid index.${c.reset} Use a number from 1 to ${entries.length}.`);
5195
+ console.log(` ${c.dim}Run ${c.cyan}agentaudit history${c.dim} to see the list.${c.reset}`);
5196
+ return;
5197
+ }
5198
+ const entry = entries[idx];
5199
+ const creds = loadCredentials();
5200
+ if (!creds) {
5201
+ console.log(` ${c.red}Not logged in.${c.reset} Run ${c.cyan}agentaudit login${c.reset} first.`);
5202
+ return;
5203
+ }
5204
+ process.stdout.write(` Uploading ${c.bold}${entry.skill_slug}${c.reset} (${entry.audit_model || '?'})...`);
5205
+ try {
5206
+ const reportCopy = { ...entry };
5207
+ delete reportCopy._file;
5208
+ const res = await fetch(`${REGISTRY_URL}/api/reports`, {
5209
+ method: 'POST',
5210
+ headers: { 'Authorization': `Bearer ${creds.api_key}`, 'Content-Type': 'application/json' },
5211
+ body: JSON.stringify(reportCopy),
5212
+ signal: AbortSignal.timeout(30_000),
5213
+ });
5214
+ if (res.ok) {
5215
+ const data = await res.json();
5216
+ console.log(` ${c.green}done${c.reset} ${c.dim}(report #${data.report_id})${c.reset}`);
5217
+ console.log(` ${c.dim}${REGISTRY_URL}/packages/${entry.skill_slug}${c.reset}`);
5218
+ } else {
5219
+ const errBody = await res.text().catch(() => '');
5220
+ console.log(` ${c.red}failed (HTTP ${res.status})${c.reset}`);
5221
+ if (errBody) console.log(` ${c.dim}${errBody.slice(0, 300)}${c.reset}`);
5222
+ }
5223
+ } catch (e) {
5224
+ console.log(` ${c.red}failed: ${e.message}${c.reset}`);
5225
+ }
5226
+ console.log();
5227
+ return;
5228
+ }
5229
+
5230
+ // Default: list all entries
5043
5231
  if (jsonMode) {
5044
5232
  console.log(JSON.stringify(entries, null, 2));
5045
5233
  return;
@@ -5048,7 +5236,8 @@ async function main() {
5048
5236
  console.log(sectionHeader(`Local History (${entries.length})`));
5049
5237
  console.log();
5050
5238
 
5051
- for (const entry of entries) {
5239
+ for (let i = 0; i < entries.length; i++) {
5240
+ const entry = entries[i];
5052
5241
  const slug = entry.skill_slug || 'unknown';
5053
5242
  const risk = entry.risk_score ?? '?';
5054
5243
  const sev = entry.max_severity || 'none';
@@ -5056,10 +5245,13 @@ async function main() {
5056
5245
  const model = entry.audit_model || '?';
5057
5246
  const fc = entry.findings?.length || 0;
5058
5247
  const ts = entry._file?.slice(0, 10) || '';
5059
- console.log(` ${sc}┃${c.reset} ${c.bold}${slug.padEnd(30)}${c.reset} ${riskBadge(risk)} ${c.dim}${model}${c.reset}`);
5060
- console.log(` ${sc}┃${c.reset} ${c.dim}${ts} ${fc} findings ${sev.toUpperCase()}${c.reset}`);
5248
+ const num = `${c.dim}${String(i + 1).padStart(2)}.${c.reset}`;
5249
+ console.log(` ${num} ${sc}┃${c.reset} ${c.bold}${slug.padEnd(30)}${c.reset} ${riskBadge(risk)} ${c.dim}${model}${c.reset}`);
5250
+ console.log(` ${sc}┃${c.reset} ${c.dim}${ts} ${fc} findings ${sev.toUpperCase()}${c.reset}`);
5061
5251
  console.log();
5062
5252
  }
5253
+ console.log(` ${c.dim}Tip: ${c.cyan}agentaudit history show <n>${c.dim} for details, ${c.cyan}history upload <n>${c.dim} to retry upload${c.reset}`);
5254
+ console.log();
5063
5255
  return;
5064
5256
  }
5065
5257
  if (command === 'activity' || command === 'my') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentaudit",
3
- "version": "3.12.10",
3
+ "version": "3.12.12",
4
4
  "description": "Security scanner for AI agent packages — CLI + MCP server",
5
5
  "type": "module",
6
6
  "bin": {
@@ -237,6 +237,35 @@ A package that integrates multiple APIs requiring multiple credentials is a feat
237
237
  - Test files with deliberate vulnerabilities
238
238
  - Negation contexts ("never use eval"), install docs (`sudo apt`)
239
239
 
240
+ ### ❌ Opt-In Features with Safety Warnings ≠ Default Vulnerabilities
241
+ If a feature must be EXPLICITLY enabled (via env var, config flag, CLI option) AND the naming/docs warn about risks, this is NOT a vulnerability in the default configuration.
242
+ ```
243
+ ❌ FALSE POSITIVE: MCP server has ENABLE_UNSAFE_SSE_TRANSPORT env var (default: unset/disabled) → NOT Critical (at most LOW/by_design)
244
+ ❌ FALSE POSITIVE: Helm chart has useLegacyRules: false with documented "not recommended for production" → NOT a finding (defaults are safe)
245
+ ❌ FALSE POSITIVE: Debug mode available via DEBUG=true env var → NOT a finding (operator must enable it)
246
+ ✅ TRUE POSITIVE: SSE transport enabled by default without authentication → IS a finding (default is insecure)
247
+ ✅ TRUE POSITIVE: Admin panel accessible without auth unless DISABLE_ADMIN=true → IS a finding (default is insecure)
248
+ ```
249
+ **Key distinction:** "Vulnerable if operator explicitly opts in" (LOW/by_design) vs "Vulnerable by default" (HIGH/CRITICAL). Count the prerequisites — each explicit opt-in step REDUCES severity.
250
+
251
+ ### ❌ Secure Code Patterns ≠ Injection Vulnerabilities
252
+ These code patterns are SECURE and must NOT be flagged:
253
+ ```
254
+ ❌ FALSE POSITIVE: execFileSync("kubectl", cmdArgs) where cmdArgs is an array → NOT shell injection (array args bypass shell)
255
+ ❌ FALSE POSITIVE: execFile(command, [arg1, arg2]) → NOT command injection (no shell interpolation)
256
+ ❌ FALSE POSITIVE: subprocess.run(["git", "clone", url]) → NOT injection (list form, no shell=True)
257
+ ✅ TRUE POSITIVE: exec(`kubectl ${userInput}`) → IS command injection (string concatenation with shell)
258
+ ✅ TRUE POSITIVE: execSync("git clone " + url) → IS command injection (string concatenation)
259
+ ```
260
+ **Key distinction:** Array-based process spawning (`execFile`/`execFileSync` with args array, `subprocess.run` with list) does NOT use a shell and CANNOT be injected. Only string-based execution (`exec`, `execSync`, `shell=True`) is vulnerable.
261
+
262
+ ### ❌ Never Fabricate Code That Doesn't Exist
263
+ If you cannot find the EXACT code pattern in the provided source files, do NOT report it. Specifically:
264
+ - Do NOT invent HTTP headers (e.g., `Access-Control-Allow-Origin: *`) that are not in the source code
265
+ - Do NOT assume a file contains code based on its name — VERIFY by reading it
266
+ - Do NOT report line numbers you haven't verified against actual file content
267
+ - If a vulnerability would exist in a dependency (e.g., Express defaults, MCP SDK) but NOT in the scanned package's code, it is NOT a finding for this package
268
+
240
269
  ## 3.3 Core-Functionality-Exemption (Hard Rule)
241
270
 
242
271
  If the pattern is in the Package Profile's "Expected Behaviors" list:
@@ -272,8 +301,9 @@ For each candidate finding, evaluate:
272
301
  - **None** (requires code modification) → likely NOT a finding
273
302
 
274
303
  ### Attack Complexity
275
- - **Low**: No special conditions, works out of the box
304
+ - **Low**: No special conditions, works out of the box with default configuration
276
305
  - **High**: Requires specific config, race conditions, chained exploits → cap at MEDIUM unless catastrophic impact
306
+ - **Opt-in required**: Vulnerability only exists if operator explicitly enables a feature (env var, config flag) → cap at LOW. Each required opt-in step reduces severity by one level.
277
307
 
278
308
  ### Privileges & Interaction Required
279
309
  - More prerequisites → lower realistic severity