@clear-capabilities/agentic-security-scanner 0.84.1 → 0.118.2

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 (125) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/dist/178.index.js +1 -1
  3. package/dist/384.index.js +1 -1
  4. package/dist/637.index.js +1 -1
  5. package/dist/838.index.js +1 -1
  6. package/dist/agentic-security.mjs +84 -84
  7. package/dist/agentic-security.mjs.sha256 +1 -1
  8. package/package.json +12 -6
  9. package/src/.agentic-security/dpia.md +26 -0
  10. package/src/.agentic-security/pqc-migration-plan.json +80 -0
  11. package/src/.agentic-security/pqc-migration-plan.md +32 -0
  12. package/src/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
  13. package/src/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
  14. package/src/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
  15. package/src/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
  16. package/src/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
  17. package/src/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
  18. package/src/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
  19. package/src/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
  20. package/src/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
  21. package/src/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
  22. package/src/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
  23. package/src/.agentic-security/threat-model.json +3702 -0
  24. package/src/.agentic-security/threat-model.md +118 -0
  25. package/src/dataflow/.agentic-security/dpia.md +26 -0
  26. package/src/dataflow/.agentic-security/sbom-history/15d7c4d20f9d968ebec29445c9e9b3fc6f74e6a1.json +6 -0
  27. package/src/dataflow/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
  28. package/src/dataflow/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
  29. package/src/dataflow/.agentic-security/sbom-history/8869fe64035bec0152d92ece961e9369d8637076.json +6 -0
  30. package/src/dataflow/.agentic-security/sbom-history/9c93b15e8cf3ff2ddb8cdae92371b82e178b463c.json +6 -0
  31. package/src/dataflow/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
  32. package/src/dataflow/.agentic-security/threat-model.json +161 -0
  33. package/src/dataflow/.agentic-security/threat-model.md +43 -0
  34. package/src/dataflow/CLAUDE.md +17 -5
  35. package/src/dataflow/catalog.js +9 -0
  36. package/src/dataflow/engine.js +30 -4
  37. package/src/dataflow/index.js +8 -0
  38. package/src/dataflow/proof-gate.js +95 -0
  39. package/src/dataflow/summaries.js +44 -5
  40. package/src/engine.js +199 -5
  41. package/src/ir/.agentic-security/dpia.md +26 -0
  42. package/src/ir/.agentic-security/exploit-bundles.json +77 -0
  43. package/src/ir/.agentic-security/sbom-history/73166872888d9ddcbe98645af818ecc7d8917b07.json +6 -0
  44. package/src/ir/.agentic-security/sbom-history/b5f468a158f6a145aedd8680841c106b9809f16b.json +6 -0
  45. package/src/ir/.agentic-security/threat-model.json +143 -0
  46. package/src/ir/.agentic-security/threat-model.md +39 -0
  47. package/src/ir/CLAUDE.md +1 -0
  48. package/src/ir/callgraph.js +12 -1
  49. package/src/ir/tree-sitter-loader.js +108 -0
  50. package/src/posture/.agentic-security/dpia.md +26 -0
  51. package/src/posture/.agentic-security/pqc-migration-plan.json +65 -0
  52. package/src/posture/.agentic-security/pqc-migration-plan.md +30 -0
  53. package/src/posture/.agentic-security/sbom-history/0724c133d2943c87555695b5b2adcfcfc3373a2d.json +6 -0
  54. package/src/posture/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +6 -0
  55. package/src/posture/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
  56. package/src/posture/.agentic-security/sbom-history/b7ac2dada4a9216ccfe8969fc57f988c83f46975.json +6 -0
  57. package/src/posture/.agentic-security/threat-model.json +2038 -0
  58. package/src/posture/.agentic-security/threat-model.md +73 -0
  59. package/src/posture/auditor-walkthrough.js +1 -1
  60. package/src/posture/corpus-status.js +74 -0
  61. package/src/posture/coverage-report.js +113 -0
  62. package/src/posture/holdout-eval.js +57 -0
  63. package/src/posture/pr-augment.js +1 -1
  64. package/src/posture/router.js +37 -7
  65. package/src/report/.agentic-security/dpia.md +26 -0
  66. package/src/report/.agentic-security/sbom-history/7d45b5e03804aac084b4a2b4dc8c6f10107d2005.json +6 -0
  67. package/src/report/.agentic-security/sbom-history/f94be839aa6a1011f53da016416a561698b8f338.json +6 -0
  68. package/src/report/.agentic-security/threat-model.json +7 -0
  69. package/src/report/.agentic-security/threat-model.md +22 -0
  70. package/src/report/index.js +18 -1
  71. package/src/sast/.agentic-security/dpia.md +26 -0
  72. package/src/sast/.agentic-security/pqc-migration-plan.json +65 -0
  73. package/src/sast/.agentic-security/pqc-migration-plan.md +30 -0
  74. package/src/sast/.agentic-security/sbom-history/043a7b401c68f5a298c83d4c42e94f118ff2a89a.json +6 -0
  75. package/src/sast/.agentic-security/sbom-history/0775d96ba4cf814520d02760cfb3010760c7684f.json +6 -0
  76. package/src/sast/.agentic-security/sbom-history/20d77ad7363a82777d506dedbbb1ecfed52f094a.json +6 -0
  77. package/src/sast/.agentic-security/sbom-history/29300441d3460832137a8e51a92b74068cad2f15.json +6 -0
  78. package/src/sast/.agentic-security/sbom-history/3852ad2aa0e50951bed0c930c29d1861aa13d048.json +6 -0
  79. package/src/sast/.agentic-security/sbom-history/3c6b3205f04e26f7654943d465281d00e0fa395e.json +6 -0
  80. package/src/sast/.agentic-security/sbom-history/3d96e2daec97e3226c9679bac6e64a89ca10bfe9.json +6 -0
  81. package/src/sast/.agentic-security/sbom-history/5282502e090013219eb006f7e02d8c983bc29c8e.json +6 -0
  82. package/src/sast/.agentic-security/sbom-history/5f0b136622fce09b469a1a6137c04d5b18652ea4.json +6 -0
  83. package/src/sast/.agentic-security/sbom-history/64e30595d447b62344d0d9200760269df2095332.json +6 -0
  84. package/src/sast/.agentic-security/sbom-history/71f3241902c9e7437764e3caa046ba8a3bcdd32a.json +6 -0
  85. package/src/sast/.agentic-security/sbom-history/73514b485d4770f3c5ee57d87338bccd1c61d8b7.json +6 -0
  86. package/src/sast/.agentic-security/sbom-history/96fa584fc3134512237d58aac63d6f3778b0cbcb.json +6 -0
  87. package/src/sast/.agentic-security/sbom-history/99c372cdc33ecc9dde92960b4ffadf7775860e61.json +6 -0
  88. package/src/sast/.agentic-security/sbom-history/a3544abf1863d71776b61302ca74d9ead2b26a9f.json +6 -0
  89. package/src/sast/.agentic-security/sbom-history/a4a34ab8b3d758de2adc8c3dc3e42d4cb0db00a3.json +6 -0
  90. package/src/sast/.agentic-security/sbom-history/ac236ec3602cfc8fb133f389ec3687e409084673.json +6 -0
  91. package/src/sast/.agentic-security/sbom-history/b57b3e1292f1f9f0de6c5a34a436e330c78008a3.json +6 -0
  92. package/src/sast/.agentic-security/sbom-history/dca15abdec43c508aa342db86dc5db8d7f01fbbe.json +6 -0
  93. package/src/sast/.agentic-security/sbom-history/dcbfd4935922dd165093c08a1003c338eed937c5.json +6 -0
  94. package/src/sast/.agentic-security/sbom-history/fa017f725615b0293814344c8ef7dfeb06775ca0.json +6 -0
  95. package/src/sast/.agentic-security/threat-model.json +816 -0
  96. package/src/sast/.agentic-security/threat-model.md +91 -0
  97. package/src/sast/CLAUDE.md +9 -3
  98. package/src/sast/code-injection-multilang.js +117 -0
  99. package/src/sast/crypto-protocol.js +43 -1
  100. package/src/sast/csharp-structural.js +65 -0
  101. package/src/sast/csharp.js +3 -0
  102. package/src/sast/csrf.js +24 -3
  103. package/src/sast/csv-injection.js +84 -0
  104. package/src/sast/frontend-hygiene.js +106 -0
  105. package/src/sast/go-structural.js +60 -0
  106. package/src/sast/java-structural.js +81 -0
  107. package/src/sast/js-framework-structural.js +115 -0
  108. package/src/sast/kotlin.js +72 -0
  109. package/src/sast/ldap-injection.js +73 -17
  110. package/src/sast/open-redirect.js +47 -5
  111. package/src/sast/php.js +35 -0
  112. package/src/sast/python-structural.js +83 -0
  113. package/src/sast/redos-nfa.js +75 -0
  114. package/src/sast/response-splitting.js +48 -19
  115. package/src/sast/ruby.js +38 -0
  116. package/src/sast/secret-concat.js +60 -0
  117. package/src/sast/ssrf-cloud-metadata.js +13 -0
  118. package/src/sast/stored-taint.js +88 -0
  119. package/src/sast/tree-sitter-sinks.js +62 -0
  120. package/src/sast/weak-password-hash.js +28 -0
  121. package/src/sast/weak-randomness.js +46 -5
  122. package/src/sast/wrong-context-sanitizer.js +94 -0
  123. package/src/sast/xpath-injection.js +30 -6
  124. package/src/sast/xss-reflected-multilang.js +116 -0
  125. package/src/sast/xxe.js +67 -0
@@ -0,0 +1,88 @@
1
+ // Stored / second-order taint (roadmap #2) — OPT-IN.
2
+ //
3
+ // Classic SAST blind spot: data written to a persistence store (DB / cache /
4
+ // file) by one request and read back by another is implicitly trusted, but if
5
+ // any user input was ever stored there it is still attacker-controlled. The
6
+ // result is stored XSS, second-order SQLi, etc. The engine's forward taint
7
+ // can't see across the persistence boundary.
8
+ //
9
+ // This detector flags the second-order SHAPE: a value READ from a store that
10
+ // flows into an injection sink without re-validation. It is inherently lower
11
+ // confidence than first-order taint (stored data is not ALWAYS attacker-
12
+ // controlled), so it is OFF by default and enabled with:
13
+ //
14
+ // AGENTIC_SECURITY_STORED_TAINT=1
15
+ //
16
+ // Being opt-in, it cannot change default scan behavior (no default FPs/FNs).
17
+
18
+ import { blankComments } from './_comment-strip.js';
19
+
20
+ const JS_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
21
+ const PY_RE = /\.py$/i;
22
+
23
+ // A read from a persistence store, capturing the assigned variable.
24
+ // const row = await db.query(...) | row = collection.find_one(...)
25
+ const READ_ASSIGN = [
26
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[\w$.]*\b(?:query|find|findOne|findById|findAll|fetchRow|fetchAll|getItem|get|scan|select|aggregate|exec)\s*\(/g,
27
+ /\b([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[\w$.]*\b(?:query|find|find_one|fetchone|fetchall|first|all|get_object_or_404)\s*\(/g,
28
+ ];
29
+
30
+ // Injection sinks that, fed stored data, are second-order vulnerabilities.
31
+ function sinkPatternsFor(varName) {
32
+ const v = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
33
+ return [
34
+ [new RegExp(`\\b(?:eval|exec|execSync|Function)\\s*\\([^)]*\\b${v}\\b`), 'CWE-94', 'code-injection'],
35
+ [new RegExp(`\\.(?:innerHTML|outerHTML)\\s*=\\s*[^;\\n]*\\b${v}\\b`), 'CWE-79', 'xss'],
36
+ [new RegExp(`\\bres(?:ponse)?\\s*\\.\\s*(?:send|write|end)\\s*\\([^)]*\\b${v}\\b`), 'CWE-79', 'xss'],
37
+ [new RegExp(`\\b(?:query|execute|raw)\\s*\\([^)]*\\b${v}\\b[^)]*\\+`), 'CWE-89', 'sql-injection'],
38
+ [new RegExp(`\\b(?:exec|execSync|spawn|system|popen)\\s*\\([^)]*\\b${v}\\b`), 'CWE-78', 'command-injection'],
39
+ ];
40
+ }
41
+
42
+ const REVALIDATED = /\b(?:validate|sanitize|escape|encode|allow(?:list|ed)|schema\.\w*parse)\b/i;
43
+
44
+ function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
45
+
46
+ export function scanStoredTaint(fp, raw) {
47
+ if (process.env.AGENTIC_SECURITY_STORED_TAINT !== '1') return [];
48
+ if (!raw || raw.length > 500_000) return [];
49
+ if (!JS_RE.test(fp) && !PY_RE.test(fp)) return [];
50
+
51
+ const code = blankComments(raw, PY_RE.test(fp) ? 'py' : undefined);
52
+ const lines = code.split('\n');
53
+ const findings = [];
54
+ const seen = new Set();
55
+
56
+ for (const re of READ_ASSIGN) {
57
+ re.lastIndex = 0;
58
+ let m;
59
+ while ((m = re.exec(code))) {
60
+ const varName = m[1];
61
+ if (!varName) continue;
62
+ const readLine = lineOf(code, m.index);
63
+ // Search a forward window for the var reaching a sink, with no
64
+ // re-validation in between.
65
+ const winEnd = Math.min(lines.length, readLine + 20);
66
+ const window = lines.slice(readLine, winEnd).join('\n');
67
+ if (REVALIDATED.test(window)) continue;
68
+ for (const [sinkRe, cwe, family] of sinkPatternsFor(varName)) {
69
+ const sm = sinkRe.exec(window);
70
+ if (!sm) continue;
71
+ const sinkLine = readLine + window.substring(0, sm.index).split('\n').length;
72
+ const key = `${sinkLine}:${family}`;
73
+ if (seen.has(key)) continue;
74
+ seen.add(key);
75
+ findings.push({
76
+ id: `stored-taint:${fp}:${sinkLine}:${family}`,
77
+ severity: 'medium', file: fp, line: sinkLine,
78
+ vuln: `Second-order / stored taint candidate (${family})`,
79
+ cwe, family, parser: 'SAST', confidence: 0.4,
80
+ description: `A value read from a persistence store ('${varName}') flows into a ${family} sink without re-validation. If any user input is ever written to that store, this is a second-order injection (e.g. stored XSS / second-order SQLi).`,
81
+ remediation: 'Treat data read from a store as untrusted: validate/encode it at the sink exactly as you would direct request input. Do not rely on validation performed at write time.',
82
+ });
83
+ break; // one sink class per read is enough
84
+ }
85
+ }
86
+ }
87
+ return findings;
88
+ }
@@ -0,0 +1,62 @@
1
+ // Tree-sitter-backed sinks for long-tail languages (roadmap #8).
2
+ //
3
+ // First AST-accurate detector for a language that has no first-class IR parser.
4
+ // Gated behind AGENTIC_SECURITY_TREE_SITTER=1 and the optional tree-sitter
5
+ // dependency; when either is absent it degrades to a no-op (the existing
6
+ // pattern detectors still run). Anchoring on real AST nodes (not regex over
7
+ // raw text) means comments and string literals can't produce false matches.
8
+ //
9
+ // Rust shell-spawn command injection: a `std::process::Command` that launches a
10
+ // shell (sh/bash/cmd/powershell) with -c / -Command AND a non-literal argument.
11
+ // That is the classic "build a shell string from input" injection; spawning a
12
+ // program directly (no shell) is not flagged.
13
+
14
+ import { getParserFor, treeSitterLangOf, walkNamed } from '../ir/tree-sitter-loader.js';
15
+
16
+ const SHELL_PROG = /Command::new\s*\(\s*[bB]?"(?:sh|bash|zsh|dash|ksh|cmd(?:\.exe)?|powershell(?:\.exe)?)"/;
17
+ const SHELL_FLAG = /\.args?\s*\(\s*\[?\s*[bB]?"(?:-c|\/c|\/C|-Command)"/;
18
+ // An .arg()/.args() whose argument does NOT start with a string literal → dynamic.
19
+ const DYNAMIC_ARG = /\.args?\s*\(\s*(?![bB]?["[])[A-Za-z_&*([]/;
20
+
21
+ export async function scanTreeSitterRust(fp, raw) {
22
+ if (process.env.AGENTIC_SECURITY_TREE_SITTER !== '1') return [];
23
+ if (treeSitterLangOf(fp) !== 'rust') return [];
24
+ if (!raw || raw.length > 500_000) return [];
25
+ if (!/Command::new/.test(raw)) return []; // cheap pre-filter
26
+
27
+ const parser = await getParserFor('rust');
28
+ if (!parser) return []; // optional dep absent → degrade
29
+ let tree;
30
+ try { tree = parser.parse(raw); } catch { return []; }
31
+
32
+ const findings = [];
33
+ const seen = new Set();
34
+ walkNamed(tree.rootNode, (n) => {
35
+ if (n.type !== 'call_expression') return;
36
+ const text = n.text;
37
+ if (!SHELL_PROG.test(text) || !SHELL_FLAG.test(text) || !DYNAMIC_ARG.test(text)) return;
38
+ const line = n.startPosition.row + 1;
39
+ if (seen.has(line)) return;
40
+ seen.add(line);
41
+ findings.push({
42
+ id: `ts-rust-cmdi:${fp}:${line}`,
43
+ severity: 'high', file: fp, line,
44
+ vuln: 'Command injection (Rust shell spawn with dynamic argument)',
45
+ cwe: 'CWE-78', family: 'command-injection', parser: 'TREE-SITTER', confidence: 0.55,
46
+ description: 'A std::process::Command spawns a shell (sh/bash/cmd/powershell) with -c/-Command and a non-literal argument. If that argument is attacker-influenced this is command injection.',
47
+ remediation: 'Do not pass input to a shell. Invoke the program directly — Command::new(prog).arg(userArg) without a shell — and validate inputs.',
48
+ });
49
+ });
50
+ return findings;
51
+ }
52
+
53
+ // Convenience: run every tree-sitter sink detector over a file-content map.
54
+ export async function scanTreeSitterSinks(fileContents) {
55
+ if (process.env.AGENTIC_SECURITY_TREE_SITTER !== '1') return [];
56
+ const out = [];
57
+ for (const [fp, c] of Object.entries(fileContents || {})) {
58
+ if (typeof c !== 'string') continue;
59
+ try { out.push(...await scanTreeSitterRust(fp, c)); } catch { /* never fail the scan */ }
60
+ }
61
+ return out;
62
+ }
@@ -32,6 +32,34 @@ const PATTERNS = {
32
32
  { re: /\bsha1\.(?:Sum|New)\s*\(\s*(?:\[\]byte\s*\(\s*)?(\w+)/g, label: 'sha1.Sum/New' },
33
33
  ],
34
34
  },
35
+ ruby: {
36
+ ext: /\.rb$/i,
37
+ rules: [
38
+ { re: /\bDigest::(?:MD5|SHA1)\s*\.\s*(?:hexdigest|digest|base64digest)\s*\(\s*(\w+)/g, label: 'Digest::MD5/SHA1' },
39
+ { re: /\b(?:Digest::)?(?:MD5|SHA1)\.new[\s\S]{0,40}\.update\s*\(\s*(\w+)/g, label: 'Digest MD5/SHA1 new+update' },
40
+ ],
41
+ },
42
+ csharp: {
43
+ ext: /\.cs$/i,
44
+ rules: [
45
+ // MD5.Create()…ComputeHash(<var>) / MD5.HashData(<var>) — capture hashed var.
46
+ { re: /\b(?:MD5|SHA1)\s*\.\s*(?:Create\s*\(\s*\)\s*\.\s*ComputeHash|HashData)\s*\([\s\S]{0,120}?(\w+)\s*\)/g, label: 'MD5/SHA1 ComputeHash/HashData' },
47
+ { re: /\bnew\s+(?:MD5|SHA1)(?:CryptoServiceProvider|Managed)?\s*\(/g, label: 'MD5/SHA1 provider' },
48
+ ],
49
+ },
50
+ kotlin: {
51
+ ext: /\.kt$/i,
52
+ rules: [
53
+ { re: /\bMessageDigest\.getInstance\s*\(\s*"(?:MD5|SHA-?1)"\s*\)[\s\S]{0,80}?\.digest\s*\(\s*(\w+)/g, label: 'MessageDigest MD5/SHA-1' },
54
+ ],
55
+ },
56
+ php: {
57
+ ext: /\.(?:php|phtml)$/i,
58
+ rules: [
59
+ { re: /\b(?:md5|sha1)\s*\(\s*\$?(\w+)/g, label: 'md5()/sha1()' },
60
+ { re: /\bhash\s*\(\s*['"](?:md5|sha1)['"]\s*,\s*\$?(\w+)/g, label: 'hash("md5"/"sha1", …)' },
61
+ ],
62
+ },
35
63
  };
36
64
 
37
65
  export function scanWeakPasswordHash(fp, raw) {
@@ -9,8 +9,18 @@
9
9
  // Go: rand.Intn(), rand.Int(), rand.Float64(), rand.Int31(), rand.Int63()
10
10
  // Ruby: rand(), Random.rand, Random.new.rand
11
11
  // PHP: rand(), mt_rand(), array_rand(), shuffle()
12
+ // Java: new Random(), Math.random(), ThreadLocalRandom.current().nextInt()
13
+ // Kotlin: Random.next*, kotlin.random.Random, java.util.Random
14
+ // C#: new Random()
12
15
 
13
16
  const SECURITY_CONTEXT = /\b(token|session|nonce|key|secret|password|otp|csrf|salt|code|pin|auth|reset|verify|captcha|challenge|ticket)\b/i;
17
+ // camelCase carrier: a security term as the suffix of an identifier
18
+ // (`newToken`, `genSecret`, `sessionId`) — `\bword\b` misses these. Case
19
+ // sensitive so it only fires on the CamelCase boundary, not generic prose.
20
+ const SECURITY_CONTEXT_CAMEL = /[a-z](?:Token|Secret|Nonce|Salt|Otp|Password|Passwd|ApiKey|SessionId|Csrf|Cookie|Jwt|Nonce)\b/;
21
+ // snake_case carrier: same idea for `session_token`, `reset_token`,
22
+ // `api_secret` — `\btoken\b` misses these because `_` is a word char.
23
+ const SECURITY_CONTEXT_SNAKE = /_(?:token|secret|nonce|salt|otp|password|passwd|key|session|csrf|seed|cookie|jwt)\b/i;
14
24
 
15
25
  function _line(raw, idx) {
16
26
  return raw.slice(0, idx).split('\n').length;
@@ -47,6 +57,28 @@ const LANG_PATTERNS = {
47
57
  { re: /\b(?:rand|mt_rand|array_rand)\s*\(/g, label: 'rand() / mt_rand()' },
48
58
  ],
49
59
  },
60
+ java: {
61
+ ext: /\.java$/i,
62
+ patterns: [
63
+ { re: /\bnew\s+Random\s*\(/g, label: 'java.util.Random' },
64
+ { re: /\bMath\.random\s*\(\s*\)/g, label: 'Math.random()' },
65
+ { re: /\bThreadLocalRandom\.current\s*\(\s*\)\s*\.\s*next\w*\s*\(/g, label: 'ThreadLocalRandom (non-crypto)' },
66
+ ],
67
+ },
68
+ kotlin: {
69
+ ext: /\.kt$/i,
70
+ patterns: [
71
+ { re: /\bRandom\s*\(\s*\)\s*\.\s*next\w*\s*\(/g, label: 'Random() (non-crypto)' },
72
+ { re: /\bRandom\s*\.\s*(?:next\w+|Default)\b/g, label: 'kotlin.random.Random' },
73
+ { re: /\bnew\s+(?:java\.util\.)?Random\s*\(/g, label: 'java.util.Random' },
74
+ ],
75
+ },
76
+ csharp: {
77
+ ext: /\.cs$/i,
78
+ patterns: [
79
+ { re: /\bnew\s+Random\s*\(/g, label: 'System.Random' },
80
+ ],
81
+ },
50
82
  };
51
83
 
52
84
  export function scanWeakRandomness(fp, raw) {
@@ -67,10 +99,16 @@ export function scanWeakRandomness(fp, raw) {
67
99
  const lineStart = raw.lastIndexOf('\n', m.index) + 1;
68
100
  const lineEnd = raw.indexOf('\n', m.index);
69
101
  const lineText = raw.slice(lineStart, lineEnd > 0 ? lineEnd : raw.length);
70
- if (!SECURITY_CONTEXT.test(lineText)) {
71
- const prevLineStart = raw.lastIndexOf('\n', lineStart - 2) + 1;
72
- const prevLine = raw.slice(prevLineStart, lineStart - 1);
73
- if (!SECURITY_CONTEXT.test(prevLine)) continue;
102
+ if (!SECURITY_CONTEXT.test(lineText) && !SECURITY_CONTEXT_CAMEL.test(lineText) && !SECURITY_CONTEXT_SNAKE.test(lineText)) {
103
+ // Widen the lookback to the enclosing block: the security keyword is
104
+ // often on the function signature a few lines up (`function newToken()
105
+ // { … Math.random() … }`), not the line immediately before the call.
106
+ let winStart = lineStart;
107
+ for (let k = 0; k < 6 && winStart > 0; k++) {
108
+ winStart = raw.lastIndexOf('\n', winStart - 2) + 1;
109
+ }
110
+ const window = raw.slice(winStart, lineStart - 1);
111
+ if (!SECURITY_CONTEXT.test(window) && !SECURITY_CONTEXT_CAMEL.test(window) && !SECURITY_CONTEXT_SNAKE.test(window)) continue;
74
112
  }
75
113
  findings.push({
76
114
  id: `weak-rng:${fp}:${line}`,
@@ -79,7 +117,10 @@ export function scanWeakRandomness(fp, raw) {
79
117
  vuln: `Insecure Randomness — ${label} used for security-sensitive value`,
80
118
  severity: 'high',
81
119
  family: 'weak-rng',
82
- cwe: 'CWE-330',
120
+ // CWE-338 (cryptographically weak PRNG) — more specific than the
121
+ // CWE-330 parent; every pattern here (Math.random / random.* / rand /
122
+ // math.rand) is a non-CSPRNG used in a security context.
123
+ cwe: 'CWE-338',
83
124
  parser: 'WEAK-RNG',
84
125
  confidence: 0.80,
85
126
  description: `${label} is not cryptographically secure. An attacker can predict the output and forge tokens, bypass OTP, or guess session identifiers.`,
@@ -0,0 +1,94 @@
1
+ // Wrong-context output encoding (roadmap #1, practical slice) — CWE-79.
2
+ //
3
+ // Context-aware sanitization is the single biggest XSS precision lever, but
4
+ // the full model (context-tagged taint through the lattice) is a core-engine
5
+ // change. This detector ships the most impactful, UNAMBIGUOUS case as a
6
+ // standalone pattern rule:
7
+ //
8
+ // An HTML-ENTITY encoder (escapeHtml / htmlspecialchars / he.encode /
9
+ // lodash.escape …) applied to a value that is then used as a URL
10
+ // (`.href` / `.src` / `location` / a JSX `href=`/`src=` attribute).
11
+ //
12
+ // HTML-entity encoding is the right tool for HTML *body/attribute text*. It
13
+ // does NOT neutralize a dangerous URL SCHEME — `javascript:alert(1)` and
14
+ // `data:text/html,…` survive entity-encoding unchanged, so the value is still
15
+ // XSS-exploitable in a URL context. This is a textbook wrong-context bug and a
16
+ // false sense of safety (the code looks sanitized).
17
+ //
18
+ // Deliberately high precision: only HTML-entity encoders (not generic names
19
+ // like bare `escape`, not `encodeURIComponent`, which is a different — non-XSS
20
+ // — mistake in URLs), and suppressed when a scheme/allow-list check is nearby.
21
+
22
+ import { blankComments } from './_comment-strip.js';
23
+
24
+ // Distinctive HTML-entity encoder callees. Intentionally excludes bare
25
+ // `escape` (too generic) and `encodeURIComponent` (URL-encoding, not XSS).
26
+ const HTML_ENCODER = "(?:escapeHtml|escapeHTML|htmlspecialchars|htmlentities|he\\.encode|he\\.escape|_\\.escape|lodash\\.escape)";
27
+
28
+ const PATTERNS = [
29
+ // Assignment to a URL-bearing property: el.href = …escapeHtml(url)…
30
+ new RegExp("\\b(?:location(?:\\.href)?|[\\w$.]+\\.(?:href|src))\\s*=\\s*([^;\\n]*?\\b" + HTML_ENCODER + "\\s*\\([^;\\n]*)", "g"),
31
+ // JSX / template attribute: href={ escapeHtml(url) } / src="...${he.encode(u)}..."
32
+ new RegExp("\\b(?:href|src)\\s*=\\s*[{\"'`]?[^\"'`>;\\n]*?\\b" + HTML_ENCODER + "\\s*\\(", "g"),
33
+ ];
34
+
35
+ // A nearby URL-scheme allow-list / validation makes the HTML-encoder a
36
+ // belt-and-suspenders choice rather than the sole (wrong) defense → suppress.
37
+ const SCHEME_GUARD = [
38
+ /\bstartsWith\s*\(\s*['"]https?:/i,
39
+ /\^https\?:/i,
40
+ /\bnew\s+URL\s*\(/,
41
+ /\ballow(?:ed|list)?\s*(?:schemes?|protocols?|hosts?)/i,
42
+ /\bprotocol\s*===?\s*['"]https?:/i,
43
+ /\bsanitizeUrl\b|\bisSafeUrl\b|\bvalidateUrl\b/i,
44
+ ];
45
+
46
+ function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
47
+
48
+ function isJsLike(fp) { return /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp); }
49
+ function isPhp(fp) { return /\.php$/i.test(fp); }
50
+
51
+ export function scanWrongContextSanitizer(fp, raw) {
52
+ if (!raw || raw.length > 500_000) return [];
53
+ if (!isJsLike(fp) && !isPhp(fp)) return [];
54
+ // Cheap pre-filter: must contain both an HTML encoder and a URL sink token.
55
+ if (!/escapeHtml|htmlspecialchars|htmlentities|he\.|_\.escape|lodash\.escape/i.test(raw)) return [];
56
+ if (!/\b(?:href|src)\b|location/i.test(raw)) return [];
57
+
58
+ const code = blankComments(raw, isPhp(fp) ? undefined : undefined);
59
+ const lines = code.split('\n');
60
+ const findings = [];
61
+ const seen = new Set();
62
+
63
+ for (const re of PATTERNS) {
64
+ re.lastIndex = 0;
65
+ let m;
66
+ while ((m = re.exec(code))) {
67
+ const line = lineOf(code, m.index);
68
+ if (seen.has(line)) continue;
69
+ // Suppress when a scheme guard appears within 5 lines above.
70
+ const lo = Math.max(0, line - 6);
71
+ const near = lines.slice(lo, line).join('\n');
72
+ if (SCHEME_GUARD.some(g => g.test(near))) continue;
73
+ seen.add(line);
74
+ findings.push({
75
+ id: `wrong-context-sanitizer:${fp}:${line}`,
76
+ severity: 'medium',
77
+ file: fp,
78
+ line,
79
+ vuln: 'Wrong-context output encoding (HTML-encoded value used as a URL)',
80
+ cwe: 'CWE-79',
81
+ family: 'xss',
82
+ parser: 'SAST',
83
+ description:
84
+ 'An HTML-entity encoder is applied to a value used in a URL context (href/src/location). ' +
85
+ 'HTML-entity encoding does not neutralize dangerous URL schemes such as `javascript:` or ' +
86
+ '`data:`, so the value remains XSS-exploitable — while looking sanitized.',
87
+ remediation:
88
+ 'Validate the URL scheme against an allow-list (e.g. only http/https) or build the URL with ' +
89
+ 'the URL API; reserve HTML-entity encoding for HTML body/attribute text, not URLs.',
90
+ });
91
+ }
92
+ }
93
+ return findings;
94
+ }
@@ -3,14 +3,32 @@ import { blankComments } from './_comment-strip.js';
3
3
  //
4
4
  // Same shape as LDAP injection — string concatenation into a query language
5
5
  // that has its own operators. We catch concatenation patterns into:
6
- // - javax.xml.xpath / org.jaxen / org.dom4j (Java)
6
+ // - javax.xml.xpath / org.jaxen / org.dom4j (Java, Kotlin)
7
7
  // - lxml.etree / xml.etree (Python)
8
8
  // - xpath npm pkg (Node)
9
+ // - DOMXPath::query / ::evaluate (PHP)
10
+ // - XPathNavigator.Select / SelectNodes (C#)
11
+ // - Nokogiri xpath / REXML::XPath (Ruby)
12
+ // - xmlpath / htmlquery / antchfx xpath (Go)
13
+
14
+ // A string literal whose body may contain the OTHER quote char — an XPath
15
+ // expression like `"//user[@name='" + x` embeds a single quote, which a naive
16
+ // `[^"']*` body would stop at. Match each quote style with its own body.
17
+ const STR = String.raw`(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')`;
9
18
 
10
19
  const PATTERNS = {
11
- java: /\.\s*(?:compile|evaluate)\s*\(\s*"[^"]*"\s*\+\s*\w+/g,
12
- py: /\.\s*(?:xpath|find|findall)\s*\(\s*["'][^"']*["']\s*[%+]\s*\w+|\.\s*xpath\s*\(\s*f["']/g,
13
- js: /\b(?:xpath|select)\s*\(\s*[`"][^`"]*[`"]\s*\+\s*\w+|\bxpath\.select\s*\(\s*`[^`]*\$\{/g,
20
+ java: new RegExp(String.raw`\.\s*(?:compile|evaluate)\s*\(\s*${STR}\s*\+\s*\w+`, 'g'),
21
+ kt: new RegExp(String.raw`\.\s*(?:compile|evaluate)\s*\(\s*${STR}\s*\+\s*\w+|\.\s*(?:compile|evaluate)\s*\(\s*"[^"\n]*\$\{`, 'g'),
22
+ py: new RegExp(String.raw`\.\s*(?:xpath|find|findall)\s*\(\s*${STR}\s*[%+]\s*\w+|\.\s*xpath\s*\(\s*[fF]["']`, 'g'),
23
+ js: new RegExp(String.raw`\b(?:xpath|select)\s*\(\s*(?:` + '`' + String.raw`[^` + '`' + String.raw`]*` + '`' + String.raw`|${STR})\s*\+\s*\w+|\bxpath\.select\s*\(\s*` + '`' + String.raw`[^` + '`' + String.raw`]*\$\{`, 'g'),
24
+ // PHP DOMXPath: $xpath->query("…" . $u) / ->evaluate("…" . $u)
25
+ php: new RegExp(String.raw`->\s*(?:query|evaluate)\s*\(\s*${STR}\s*\.\s*\$\w+`, 'g'),
26
+ // C#: nav.Select / doc.SelectNodes / SelectSingleNode / XPathSelectElements with concat
27
+ cs: new RegExp(String.raw`\.\s*(?:Select|SelectNodes|SelectSingleNode|XPathSelectElement|XPathSelectElements|Compile)\s*\(\s*\$?${STR}\s*\+\s*\w+|\.\s*(?:Select|SelectNodes|SelectSingleNode)\s*\(\s*\$"[^"\n]*\{`, 'g'),
28
+ // Ruby Nokogiri / REXML: .xpath("…#{u}") / .at_xpath / XPath.match with #{} or concat
29
+ rb: new RegExp(String.raw`\.\s*(?:xpath|at_xpath|search)\s*\(\s*(?:"[^"\n]*#\{|'[^'\n]*#\{)|\.\s*(?:xpath|at_xpath)\s*\(\s*${STR}\s*\+\s*\w+|XPath\s*\.\s*(?:match|first|each)\s*\([^,]+,\s*(?:"[^"\n]*#\{|'[^'\n]*#\{)`, 'g'),
30
+ // Go xmlpath/htmlquery/antchfx: xmlpath.Compile("…"+u) / htmlquery.Find(doc, "…"+u)
31
+ go: new RegExp(String.raw`\b(?:xmlpath\.Compile|htmlquery\.(?:Find|FindOne|QueryAll|Query)|xpath\.Compile|navigator\.Compile)\s*\([^)]*${STR}\s*\+\s*\w+`, 'g'),
14
32
  };
15
33
 
16
34
  function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
@@ -20,11 +38,17 @@ export function scanXPathInjection(fp, raw) {
20
38
  let lang;
21
39
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) lang = 'js';
22
40
  else if (/\.java$/i.test(fp)) lang = 'java';
41
+ else if (/\.kt$/i.test(fp)) lang = 'kt';
23
42
  else if (/\.py$/i.test(fp)) lang = 'py';
43
+ else if (/\.(?:php|phtml)$/i.test(fp)) lang = 'php';
44
+ else if (/\.cs$/i.test(fp)) lang = 'cs';
45
+ else if (/\.rb$/i.test(fp)) lang = 'rb';
46
+ else if (/\.go$/i.test(fp)) lang = 'go';
24
47
  else return [];
25
48
 
26
- const code = blankComments(raw, lang === 'py' ? 'py' : undefined);
27
- if (!/\bxpath|XPath|\.xpath\(/i.test(code)) return [];
49
+ const code = blankComments(raw, (lang === 'py' || lang === 'rb') ? 'py' : undefined);
50
+ // Relevance gate: an XPath-ish API or an XPath expression literal must appear.
51
+ if (!/\bxpath|XPath|\.xpath\(|DOMXPath|SelectNodes|SelectSingleNode|XPathNavigator|xmlpath|htmlquery|REXML|\/\/\w/i.test(code)) return [];
28
52
  const re = new RegExp(PATTERNS[lang].source, PATTERNS[lang].flags);
29
53
  const findings = [];
30
54
  const seen = new Set();
@@ -0,0 +1,116 @@
1
+ // Cross-language reflected-XSS structural detector — PRD Tier 1 (recall).
2
+ //
3
+ // JS/Python reflected XSS is handled by the flow engine + framework structural
4
+ // detectors; the second-tier languages had no XSS coverage. This module adds a
5
+ // taint-independent structural rule per language: user input written into an
6
+ // HTML response via concatenation / interpolation, without an output encoder.
7
+ //
8
+ // Precision: each language carries its own escaper exclusion (htmlspecialchars,
9
+ // HtmlEncode, html_escape, template.HTMLEscapeString, …). When an escaper is
10
+ // applied on the sink line the finding is suppressed — the parameterized/encoded
11
+ // form must NOT match. HTML context is required (an HTML tag literal, an HTML
12
+ // content-type, or a raw-superglobal echo) so plain-text responses don't fire.
13
+
14
+ import { blankComments } from './_comment-strip.js';
15
+
16
+ const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
17
+
18
+ const REMEDIATION =
19
+ 'HTML-encode user input before writing it into a response: htmlspecialchars (PHP), ' +
20
+ 'template/html.EscapeString (Go), ERB::Util.html_escape / avoid .html_safe (Ruby), ' +
21
+ 'HttpUtility.HtmlEncode (C#), or an auto-escaping template engine. Never concatenate ' +
22
+ 'request data straight into an HTML response.';
23
+
24
+ // Per-language: { ext, sinks:[RegExp], escaper:RegExp }. A line matches when a
25
+ // sink RegExp matches and the escaper RegExp does NOT.
26
+ const LANGS = {
27
+ go: {
28
+ ext: /\.go$/i,
29
+ // fmt.Fprint*/io.WriteString to the ResponseWriter, or w.Write([]byte(...)),
30
+ // building an HTML string ("<…") by concatenation.
31
+ sinks: [
32
+ /\b(?:fmt\.Fprintf?|fmt\.Fprintln|io\.WriteString)\s*\(\s*w\b[^)\n]*"<[^"\n]*"\s*\+/,
33
+ /\bw\.Write\s*\(\s*\[\]byte\s*\(\s*"<[^"\n]*"\s*\+/,
34
+ ],
35
+ escaper: /\b(?:template\.HTMLEscapeString|html\.EscapeString|template\.HTMLEscaper)\s*\(/,
36
+ },
37
+ php: {
38
+ ext: /\.(?:php|phtml)$/i,
39
+ // echo/print of a request superglobal (directly or concatenated).
40
+ sinks: [
41
+ /\b(?:echo|print)\b[^;\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
42
+ /\bprintf\s*\([^;\n]*\$_(?:GET|POST|REQUEST|COOKIE)\b/,
43
+ ],
44
+ escaper: /\b(?:htmlspecialchars|htmlentities|strip_tags|filter_var|urlencode|rawurlencode|json_encode|intval|floatval|htmlspecialchars_decode)\s*\(/,
45
+ },
46
+ ruby: {
47
+ ext: /\.rb$/i,
48
+ // render inline:/html: with #{} interpolation, raw(params…), params….html_safe
49
+ sinks: [
50
+ /\brender\s+(?:inline|html):\s*["'][^"'\n]*#\{/,
51
+ /\braw\s*\(\s*(?:params|request|@\w+\.params)\b/,
52
+ /\b(?:params|request)\b[^\n]*\.\s*html_safe\b/,
53
+ ],
54
+ escaper: /\b(?:ERB::Util\.html_escape|CGI\.escapeHTML|h\s*\(|sanitize\s*\()/,
55
+ },
56
+ csharp: {
57
+ ext: /\.cs$/i,
58
+ // Response.Write of Request data, or an HTML string concatenation.
59
+ sinks: [
60
+ /\bResponse\.Write\s*\([^)\n]*\bRequest\b/,
61
+ /\bResponse\.Write\s*\(\s*"<[^"\n]*"\s*\+/,
62
+ ],
63
+ escaper: /\b(?:HttpUtility\.HtmlEncode|HtmlEncoder\.|Server\.HtmlEncode|WebUtility\.HtmlEncode|AntiXss\.)/,
64
+ },
65
+ kotlin: {
66
+ ext: /\.kt$/i,
67
+ // Ktor respondText building HTML by interpolation/concat.
68
+ sinks: [
69
+ /\brespondText\s*\(\s*"<[^"\n]*\$/,
70
+ /\brespondText\s*\(\s*"<[^"\n]*"\s*\+/,
71
+ /\brespondText\s*\(\s*"[^"\n]*\$[^"\n]*"\s*,\s*ContentType\.Text\.Html/,
72
+ ],
73
+ escaper: /\b(?:htmlEscape|escapeHtml|HtmlUtils\.htmlEscape|encodeHTML)\s*\(/,
74
+ },
75
+ java: {
76
+ ext: /\.java$/i,
77
+ // Servlet: response.getWriter().write/print/println(...) or a PrintWriter
78
+ // `out`, building an HTML string ("<…") by concatenation. The concat (`"<…"
79
+ // +`) is the reflected-XSS shape — a static literal has no `+`.
80
+ sinks: [
81
+ /\.\s*(?:getWriter\s*\(\s*\)\s*\.\s*)?(?:write|print|println|append|format|printf)\s*\(\s*"<[^"\n]*"\s*\+/,
82
+ /\b(?:out|writer|pw|w)\s*\.\s*(?:write|print|println|append)\s*\(\s*"<[^"\n]*"\s*\+/,
83
+ ],
84
+ escaper: /\b(?:encodeForHTML|forHtml|forHtmlContent|htmlEscape|escapeHtml4?|StringEscapeUtils|HtmlUtils\.htmlEscape|Encode\.forHtml|ESAPI|OWASP)\b/,
85
+ },
86
+ };
87
+
88
+ export function scanXssReflectedMultilang(fp, raw) {
89
+ if (!raw || raw.length > 500_000) return [];
90
+ let lang = null;
91
+ for (const v of Object.values(LANGS)) { if (v.ext.test(fp)) { lang = v; break; } }
92
+ if (!lang) return [];
93
+
94
+ const code = blankComments(raw, /\.(?:rb|php)$/i.test(fp) ? 'py' : undefined);
95
+ const lines = code.split('\n');
96
+ const findings = [];
97
+ const seen = new Set();
98
+
99
+ for (let i = 0; i < lines.length; i++) {
100
+ const line = lines[i];
101
+ if (lang.escaper.test(line)) continue;
102
+ if (!lang.sinks.some((re) => re.test(line))) continue;
103
+ const ln = i + 1;
104
+ const id = `xss-reflected:${fp}:${ln}`;
105
+ if (seen.has(id)) continue;
106
+ seen.add(id);
107
+ findings.push({
108
+ id, file: fp, line: ln,
109
+ vuln: 'Reflected XSS — user input written into an HTML response without output encoding',
110
+ severity: 'high', cwe: 'CWE-79', family: 'xss', parser: 'XSS-ML', confidence: 0.62,
111
+ snippet: (raw.split('\n')[ln - 1] || '').trim().slice(0, 200),
112
+ remediation: REMEDIATION,
113
+ });
114
+ }
115
+ return findings;
116
+ }
package/src/sast/xxe.js CHANGED
@@ -64,6 +64,28 @@ const PYTHON_DEFUSED_RE = /(?:^|\n)\s*(?:from\s+defusedxml\b|import\s+defusedxml
64
64
  // upstream-recommended safe shape.
65
65
  const PYTHON_LXML_SAFE_RE = /XMLParser\s*\([^)]*\bresolve_entities\s*=\s*False\b[^)]*\)/;
66
66
 
67
+ // ── PHP / Go / Ruby ─────────────────────────────────────────────────────────
68
+ // Each of these XML stacks is XXE-SAFE BY DEFAULT in current versions; the
69
+ // vulnerability appears only when the caller explicitly opts INTO external-
70
+ // entity / DTD-loading behavior. So we flag the opt-in flags, not the parse
71
+ // call itself — the plain parse is correctly clean.
72
+ //
73
+ // PHP: loadXML / simplexml_load_string|file with LIBXML_NOENT, LIBXML_DTDLOAD,
74
+ // or LIBXML_DTDVALID. (PHP >= 8.0 disables entity substitution by
75
+ // default; these flags re-enable it.)
76
+ const PHP_XXE_RE =
77
+ /\b(?:loadXML|simplexml_load_string|simplexml_load_file)\s*\([^;]*\bLIBXML_(?:NOENT|DTDLOAD|DTDVALID)\b/g;
78
+ // Go: encoding/xml Decoder made unsafe by `Strict = false` or a custom
79
+ // `Entity` map that defines expansions. Gated on an xml-decoder hint.
80
+ const GO_XXE_RE =
81
+ /\b\w+\s*\.\s*(?:Strict\s*=\s*false|Entity\s*=\s*(?:map\[string\]string|xml\.HTMLEntity))/g;
82
+ const GO_XML_HINT_RE = /\b(?:encoding\/xml|xml\.NewDecoder|xml\.Decoder)\b/;
83
+ // Ruby: Nokogiri parse whose options enable NOENT / DTDLOAD / replace_entities.
84
+ // Default Nokogiri::XML(xml) is safe. Gated on a Nokogiri/LibXML hint.
85
+ const RUBY_XXE_RE =
86
+ /\.\s*(?:noent|dtdload|dtdvalid|replace_entities)\b/g;
87
+ const RUBY_XML_HINT_RE = /\bNokogiri\s*::\s*XML\b|\bLibXML\b|\bXML::Parser\b/;
88
+
67
89
  import { blankComments } from './_comment-strip.js';
68
90
 
69
91
  function _stripLineComment(s, lang) {
@@ -136,5 +158,50 @@ export function scanXXE(fp, raw) {
136
158
  return findings;
137
159
  }
138
160
 
161
+ // ── PHP / Go / Ruby: flag the explicit opt-in to external entities ──────────
162
+ const _emitOptIn = (code, re, gate, mkVuln, mkRem) => {
163
+ if (gate && !gate.test(code)) return;
164
+ const r = new RegExp(re.source, re.flags);
165
+ let m;
166
+ const seen = new Set();
167
+ while ((m = r.exec(code))) {
168
+ const line = _lineOf(raw, m.index);
169
+ if (seen.has(line)) continue;
170
+ seen.add(line);
171
+ findings.push({
172
+ id: `xxe:${fp}:${line}`,
173
+ file: fp, line,
174
+ vuln: mkVuln(), severity: 'high', cwe: 'CWE-611',
175
+ stride: 'Information Disclosure',
176
+ snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
177
+ remediation: mkRem(), confidence: 0.8, parser: 'XXE',
178
+ });
179
+ }
180
+ };
181
+
182
+ if (/\.(?:php|phtml)$/i.test(fp)) {
183
+ const code = blankComments(raw, 'py');
184
+ _emitOptIn(code, PHP_XXE_RE, null,
185
+ () => 'XXE: XML parsed with LIBXML_NOENT / LIBXML_DTDLOAD (external entities enabled)',
186
+ () => 'Drop the LIBXML_NOENT / LIBXML_DTDLOAD flags — PHP >= 8.0 disables entity substitution by default, so plain loadXML($xml) / simplexml_load_string($xml) is safe. If you must accept DTDs, set libxml_set_external_entity_loader to reject network/file access.');
187
+ return findings;
188
+ }
189
+
190
+ if (/\.go$/i.test(fp)) {
191
+ const code = blankComments(raw);
192
+ _emitOptIn(code, GO_XXE_RE, GO_XML_HINT_RE,
193
+ () => 'XXE: encoding/xml Decoder configured with Strict=false or a custom Entity map',
194
+ () => 'Go\'s encoding/xml ignores external entities by default — do not set Strict=false or populate Decoder.Entity with untrusted input. Leave the decoder at its defaults.');
195
+ return findings;
196
+ }
197
+
198
+ if (/\.rb$/i.test(fp)) {
199
+ const code = blankComments(raw, 'py');
200
+ _emitOptIn(code, RUBY_XXE_RE, RUBY_XML_HINT_RE,
201
+ () => 'XXE: Nokogiri parse options enable external entities (NOENT / DTDLOAD / replace_entities)',
202
+ () => 'Remove noent/dtdload/replace_entities — Nokogiri::XML(xml) is safe by default (entities are not expanded, DTDs are not loaded). Never enable these on untrusted XML.');
203
+ return findings;
204
+ }
205
+
139
206
  return [];
140
207
  }