@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9

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 (170) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +448 -74
  5. package/dist/113.index.js +16 -7
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/207.index.js +5 -4
  9. package/dist/220.index.js +5 -3
  10. package/dist/238.index.js +4 -4
  11. package/dist/317.index.js +300 -0
  12. package/dist/384.index.js +1 -1
  13. package/dist/435.index.js +196 -21
  14. package/dist/444.index.js +20 -11
  15. package/dist/449.index.js +8 -1
  16. package/dist/513.index.js +7 -3
  17. package/dist/526.index.js +6 -6
  18. package/dist/637.index.js +1 -1
  19. package/dist/675.index.js +7 -5
  20. package/dist/839.index.js +4 -3
  21. package/dist/905.index.js +1173 -0
  22. package/dist/agentic-security.mjs +14 -14
  23. package/dist/agentic-security.mjs.sha256 +1 -1
  24. package/dist/compliance-frameworks/ccpa.json +32 -0
  25. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  26. package/dist/compliance-frameworks/gdpr.json +45 -0
  27. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  28. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  29. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  30. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  31. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  32. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  33. package/package.json +24 -12
  34. package/src/badge.js +2 -1
  35. package/src/dataflow/CLAUDE.md +10 -4
  36. package/src/dataflow/builtin-summaries.js +1 -1
  37. package/src/dataflow/cross-service-taint.js +2 -1
  38. package/src/dataflow/engine.js +324 -60
  39. package/src/dataflow/ifds-precise.js +6 -4
  40. package/src/dataflow/implicit-flow.js +68 -36
  41. package/src/dataflow/incremental.js +25 -8
  42. package/src/dataflow/index.js +2 -1
  43. package/src/dataflow/proven-clean.js +41 -0
  44. package/src/dataflow/sanitizer-gate.js +35 -9
  45. package/src/dataflow/sanitizer-proof.js +21 -3
  46. package/src/dataflow/stub-aware-filter.js +36 -13
  47. package/src/dataflow/summaries.js +21 -2
  48. package/src/discovery/CLAUDE.md +10 -0
  49. package/src/discovery/index.js +175 -3
  50. package/src/discovery/llm-invoke.js +90 -1
  51. package/src/discovery/memory.js +163 -0
  52. package/src/engine.js +247 -50
  53. package/src/integrations/tickets.js +7 -6
  54. package/src/ir/CLAUDE.md +4 -1
  55. package/src/ir/balanced-call.js +55 -0
  56. package/src/ir/ir-stats.js +1 -1
  57. package/src/ir/parser-cpp.js +1 -1
  58. package/src/ir/parser-cs.js +62 -9
  59. package/src/ir/parser-go.js +29 -11
  60. package/src/ir/parser-java.js +96 -19
  61. package/src/ir/parser-js.js +151 -20
  62. package/src/ir/parser-php.js +44 -9
  63. package/src/ir/parser-rb.js +37 -7
  64. package/src/ir/ssa.js +6 -1
  65. package/src/leaderboard.js +3 -2
  66. package/src/llm-validator/consistency.js +6 -2
  67. package/src/llm-validator/index.js +1 -2
  68. package/src/lsp/server.js +28 -2
  69. package/src/mcp/CLAUDE.md +9 -2
  70. package/src/mcp/audit.js +2 -1
  71. package/src/mcp/redact.js +26 -0
  72. package/src/mcp/tools.js +159 -17
  73. package/src/posture/CLAUDE.md +45 -8
  74. package/src/posture/accuracy-scorecard.js +67 -1
  75. package/src/posture/agents-memory.js +5 -3
  76. package/src/posture/aibom.js +12 -8
  77. package/src/posture/auditor-walkthrough.js +111 -10
  78. package/src/posture/auth-posture-import.js +5 -4
  79. package/src/posture/autopilot.js +8 -1
  80. package/src/posture/calibration-drift.js +11 -5
  81. package/src/posture/calibration.js +24 -2
  82. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  83. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  84. package/src/posture/compliance-policy.js +40 -10
  85. package/src/posture/confidence.js +44 -10
  86. package/src/posture/corpus-enroll.js +9 -5
  87. package/src/posture/corpus-match.js +19 -0
  88. package/src/posture/csharp-analysis.js +62 -3
  89. package/src/posture/custom-rules.js +7 -5
  90. package/src/posture/cve-alert-daemon.js +6 -5
  91. package/src/posture/dep-add-guard.js +2 -1
  92. package/src/posture/deploy-platform.js +4 -1
  93. package/src/posture/deterministic.js +3 -2
  94. package/src/posture/drift.js +7 -1
  95. package/src/posture/epss.js +13 -1
  96. package/src/posture/evidence-bundle.js +276 -0
  97. package/src/posture/exploitability-probability.js +15 -2
  98. package/src/posture/falsification.js +23 -2
  99. package/src/posture/feature-flags.js +3 -2
  100. package/src/posture/findings-memory.js +3 -3
  101. package/src/posture/fix-history.js +5 -2
  102. package/src/posture/fix-metrics.js +5 -5
  103. package/src/posture/fix-plan.js +2 -1
  104. package/src/posture/fix-verify-loop.js +10 -1
  105. package/src/posture/grader-calibration.js +3 -4
  106. package/src/posture/iac-reachability.js +14 -8
  107. package/src/posture/integrity.js +25 -7
  108. package/src/posture/intent-context.js +2 -1
  109. package/src/posture/learning.js +4 -3
  110. package/src/posture/license-attributions.js +5 -7
  111. package/src/posture/license-graph.js +2 -1
  112. package/src/posture/license-policy.js +2 -1
  113. package/src/posture/model-rescan.js +69 -3
  114. package/src/posture/mttr.js +5 -0
  115. package/src/posture/network-policy-import.js +3 -2
  116. package/src/posture/poc-inprocess.js +27 -8
  117. package/src/posture/pqc-migration-plan.js +7 -5
  118. package/src/posture/pr-augment.js +8 -5
  119. package/src/posture/privacy-framework.js +262 -0
  120. package/src/posture/regression-test-gen.js +23 -8
  121. package/src/posture/reverse-blast-radius.js +5 -1
  122. package/src/posture/risk-dollars.js +20 -3
  123. package/src/posture/router.js +5 -4
  124. package/src/posture/ruleset-version.js +2 -2
  125. package/src/posture/runtime-correlation.js +2 -1
  126. package/src/posture/sbom-diff.js +12 -3
  127. package/src/posture/sca-policy.js +7 -4
  128. package/src/posture/scan-checkpoint.js +15 -0
  129. package/src/posture/secret-history.js +20 -11
  130. package/src/posture/security-trend.js +7 -1
  131. package/src/posture/stack-playbook.js +22 -1
  132. package/src/posture/state-dir.js +34 -0
  133. package/src/posture/telemetry-ingest.js +4 -3
  134. package/src/posture/threat-model-auto.js +4 -1
  135. package/src/posture/threat-model-grounding.js +13 -3
  136. package/src/posture/time-to-fix.js +3 -2
  137. package/src/posture/triage-memory.js +3 -2
  138. package/src/posture/validator-metrics.js +10 -3
  139. package/src/posture/verifier.js +32 -57
  140. package/src/posture/waf-ingest.js +6 -5
  141. package/src/posture/watch-mode.js +4 -3
  142. package/src/report/index.js +183 -14
  143. package/src/runScan.js +1 -1
  144. package/src/sast/_comment-strip.js +15 -4
  145. package/src/sast/_secret-entropy.js +1 -1
  146. package/src/sast/authz.js +6 -4
  147. package/src/sast/bench-shape/index.js +2 -7
  148. package/src/sast/claude-md-prompt-injection.js +14 -3
  149. package/src/sast/cloud-iam.js +60 -7
  150. package/src/sast/code-injection-multilang.js +29 -0
  151. package/src/sast/cpp-bench-extras.js +1 -1
  152. package/src/sast/csrf.js +7 -5
  153. package/src/sast/env-hygiene.js +5 -2
  154. package/src/sast/iac-terraform.js +25 -0
  155. package/src/sast/java-bench-extras.js +1 -1
  156. package/src/sast/java-constant-fold.js +5 -5
  157. package/src/sast/llm-owasp.js +4 -2
  158. package/src/sast/mcp-audit.js +7 -0
  159. package/src/sast/pipeline.js +8 -0
  160. package/src/sast/prompt-template.js +8 -6
  161. package/src/sast/prototype-pollution.js +6 -2
  162. package/src/sast/redos-nfa.js +6 -6
  163. package/src/sast/secret-concat.js +13 -2
  164. package/src/sast/ssrf-cloud-metadata.js +6 -3
  165. package/src/sast/xss-reflected-multilang.js +1 -1
  166. package/src/sast/xxe.js +1 -1
  167. package/src/sca/CLAUDE.md +3 -4
  168. package/src/sca/container.js +35 -3
  169. package/src/sca/dep-confusion.js +9 -1
  170. package/src/sca/sarif-ingest.js +0 -187
@@ -0,0 +1,55 @@
1
+ // Shared by every hand-rolled regex IR parser (parser-cs.js, parser-go.js,
2
+ // parser-php.js, parser-rb.js) for extracting a call's callee + argument
3
+ // text without corruption when the call is followed by a chained member
4
+ // access (`Sanitize(x).Trim()`, `$obj->clean($x)->trim()`).
5
+ //
6
+ // The naive pattern every one of those files used —
7
+ // `/^(calleeRe)\s*\((.*)\)\s*$/s` — matches `(.*)` GREEDILY against the
8
+ // LAST `)` in the string, not the one balancing the FIRST `(`. For a
9
+ // chained call that swallows the chain's own parens into the argument
10
+ // text (`Sanitize(x).Trim()` produced argsText = "x).Trim("), which then
11
+ // fails to parse as any recognized expression shape in `_lowerExpr` and
12
+ // falls through to `{kind:'unknown'}` — silently losing whatever
13
+ // taint-relevant identifiers were inside the FIRST call's real argument
14
+ // list, exactly the shape a wrapped-and-then-methodcalled sanitizer or
15
+ // helper produces in real code.
16
+ //
17
+ // This scans forward from the first `(` tracking paren/bracket/brace
18
+ // depth and string-literal state (so a `)` or `,` inside a nested call or
19
+ // a string literal doesn't miscount) to find the REAL matching close
20
+ // paren. It deliberately does NOT require that close paren to be the end
21
+ // of the string — a trailing chain (`.Trim()`, `->trim()`, `::foo()`) is
22
+ // simply left unconsumed rather than corrupting anything. Recovering the
23
+ // first call's real signature is a strict improvement over the prior
24
+ // choice between silent corruption and no match at all.
25
+ export function matchBalancedCall(s, calleeRe) {
26
+ if (typeof s !== 'string') return null;
27
+ const m = calleeRe.exec(s);
28
+ if (!m || m.index !== 0) return null;
29
+ let i = m[0].length;
30
+ while (s[i] === ' ' || s[i] === '\t') i++;
31
+ if (s[i] !== '(') return null;
32
+ const openIdx = i;
33
+ let depth = 0;
34
+ let inStr = null;
35
+ let escape = false;
36
+ for (; i < s.length; i++) {
37
+ const c = s[i];
38
+ if (escape) { escape = false; continue; }
39
+ if (inStr) {
40
+ if (c === '\\') { escape = true; continue; }
41
+ if (c === inStr) inStr = null;
42
+ continue;
43
+ }
44
+ if (c === '"' || c === '\'') { inStr = c; continue; }
45
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
46
+ if (c === ')' || c === ']' || c === '}') {
47
+ depth--;
48
+ if (depth === 0) break;
49
+ continue;
50
+ }
51
+ }
52
+ if (depth !== 0 || s[i] !== ')') return null; // unbalanced — refuse to guess
53
+ const callee = m[1] !== undefined ? m[1] : m[0];
54
+ return { callee, argsText: s.slice(openIdx + 1, i) };
55
+ }
@@ -4,7 +4,7 @@
4
4
  // language, how many of the files we claim to support did we actually turn
5
5
  // into IR? That is the difference between recognising an extension and
6
6
  // supporting a language, and it is the headline metric of the proof corpus
7
- // bench (docs/PROOF_CORPUS_PRD.md §5.4).
7
+ // bench (the Proof Corpus PRD §5.4 (removed post-implementation)).
8
8
  //
9
9
  // Enable by setting AGENTIC_SECURITY_IR_STATS to an output path. The sidecar
10
10
  // deliberately contains NO timestamp so two runs over identical input produce
@@ -1,7 +1,7 @@
1
1
  // C / C++ IR frontend.
2
2
  //
3
3
  // Hand-rolled, following the parser-cs.js / parser-go.js template. See
4
- // docs/PROOF_CORPUS_PRD.md §6.3 for why this is not tree-sitter or libclang:
4
+ // the Proof Corpus PRD §6.3 (removed post-implementation) for why this is not tree-sitter or libclang:
5
5
  // the build excludes the tree-sitter deps from the bundle, and libclang would
6
6
  // require native bindings plus a compile database we deliberately never build.
7
7
  //
@@ -27,6 +27,7 @@
27
27
 
28
28
  import * as crypto from 'node:crypto';
29
29
  import { callSitesFromCfg } from './call-sites.js';
30
+ import { matchBalancedCall } from './balanced-call.js';
30
31
 
31
32
  const METHOD_RE = new RegExp(
32
33
  '(?:^|[\\s;{}])(?:public|private|protected|internal|static|virtual|override|async|sealed|abstract|new|readonly|partial)' +
@@ -80,17 +81,69 @@ function _lowerExpr(text) {
80
81
  if (parts.length === 1) return { kind: 'ident', name: parts[0] };
81
82
  return _buildMemberChain(parts);
82
83
  }
83
- // Call: foo.bar(args) or Bar(args). Find the LAST '(' at depth 0.
84
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
84
+ // Object creation: `new Type(args)` lowered to a call so taint flows into
85
+ // constructor arguments. Without this branch the expression fell through to
86
+ // the concat heuristic below, and because the `+` sits INSIDE the parens
87
+ // `_splitTopLevelPlus` returned the input unchanged, so that branch recursed
88
+ // on the identical string until the stack blew. `buildProjectIR` catches
89
+ // per-file, so the crash surfaced only as "this file has no IR" — 12 of 21 C#
90
+ // corpus entries, and the catalog's own `cs-sqlcommand` rule ("SQL Injection
91
+ // (new SqlCommand with concatenated user input)") could never fire.
92
+ const newMatch = matchBalancedCall(s, /^new\s+([\w.]+)/);
93
+ if (newMatch) {
94
+ const callee = newMatch.callee.split('.').pop();
95
+ const args = _splitTopLevelCommas(newMatch.argsText).map(_lowerExpr);
96
+ return { kind: 'call', callee, args, isNew: true };
97
+ }
98
+ // Call: foo.bar(args) or Bar(args). matchBalancedCall finds the paren
99
+ // that actually balances the FIRST '(' — not the greedy-to-end-of-string
100
+ // match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
101
+ // for a chained call (`Sanitize(x).Trim()` produced argsText="x).Trim(",
102
+ // which then fell through to {kind:'unknown'} and silently dropped x).
103
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
85
104
  if (callMatch) {
86
- const callee = callMatch[1];
87
- const args = _splitTopLevelCommas(callMatch[2]).map(_lowerExpr);
88
- return { kind: 'call', callee, args };
105
+ const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
106
+ return { kind: 'call', callee: callMatch.callee, args };
89
107
  }
90
108
  // String concat / interpolation — heuristic.
109
+ //
110
+ // The `parts.length > 1` guard is load-bearing, not defensive tidiness: when
111
+ // the `+` is nested inside parens or brackets, `_splitTopLevelPlus` returns
112
+ // the input as a single part, and mapping `_lowerExpr` over it recurses on the
113
+ // identical string forever. Any future expression form that reaches here
114
+ // unsplit would otherwise reintroduce the same stack overflow.
91
115
  if (s.includes('+') && /["']/.test(s)) {
92
- const parts = _splitTopLevelPlus(s).map(_lowerExpr);
93
- return { kind: 'tpl', parts };
116
+ const rawParts = _splitTopLevelPlus(s);
117
+ if (rawParts.length > 1) return { kind: 'tpl', parts: rawParts.map(_lowerExpr) };
118
+ }
119
+ // Stage 3 correctness audit (detection depth, per-language-IR):
120
+ // interpolated strings ($"id={id}", $@"...", @$"...") were entirely
121
+ // unrecognized by every branch above — not even treated as an opaque
122
+ // literal, since nothing here tested for the leading `$` prefix — so
123
+ // they fell all the way through to {kind:'unknown'}, silently dropping
124
+ // any interpolated variable's taint. This is exactly
125
+ // `new SqlCommand($"SELECT ... WHERE id={id}", conn)`, one of the most
126
+ // common real C# SQL-injection shapes. `{expr}` / `{expr:format}` are
127
+ // lowered into a template, same shape as the `+`-concat branch above.
128
+ if (/^\$@?"/.test(s) || /^@\$"/.test(s)) {
129
+ const bodyStart = s.indexOf('"') + 1;
130
+ const inner = s.slice(bodyStart, -1);
131
+ const re = /\{([^{}:]+)(?::[^{}]*)?\}/g;
132
+ let lastIndex = 0;
133
+ const parts = [];
134
+ let matched = false;
135
+ let m;
136
+ while ((m = re.exec(inner)) !== null) {
137
+ matched = true;
138
+ if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
139
+ parts.push(_lowerExpr(m[1].trim()));
140
+ lastIndex = re.lastIndex;
141
+ }
142
+ if (matched) {
143
+ if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
144
+ return { kind: 'tpl', parts };
145
+ }
146
+ return { kind: 'literal', value: s };
94
147
  }
95
148
  if (/^"|^@"/.test(s)) return { kind: 'literal', value: s };
96
149
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
@@ -169,9 +222,9 @@ function _lowerStmt(stmt, line) {
169
222
  return { kind: 'assign', line, target, source: _lowerExpr(sourceText) };
170
223
  }
171
224
  // statement-form call
172
- const cm = s.match(/^([A-Za-z_][\w.]*)\s*\((.*)\)\s*$/s);
225
+ const cm = matchBalancedCall(s, /^([A-Za-z_][\w.]*)/);
173
226
  if (cm) {
174
- return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
227
+ return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
175
228
  }
176
229
  return { kind: 'unknown', line, text: s };
177
230
  }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import * as crypto from 'node:crypto';
24
24
  import { callSitesFromCfg } from './call-sites.js';
25
+ import { matchBalancedCall } from './balanced-call.js';
25
26
 
26
27
  const FUNC_RE = new RegExp(
27
28
  '(?:^|[\\n;{}])\\s*func\\s+' +
@@ -76,7 +77,11 @@ function _lowerExpr(text) {
76
77
  const s = String(text || '').trim();
77
78
  if (!s) return { kind: 'unknown' };
78
79
  if (/^fmt\.Sprintf\s*\(/.test(s)) {
79
- const inner = s.slice(s.indexOf('(') + 1, s.lastIndexOf(')'));
80
+ // Same balanced-paren concern as the call patterns below — a chained
81
+ // call after fmt.Sprintf(...) (rare but not impossible via a wrapper)
82
+ // would otherwise have its args text swallow the chain's own parens.
83
+ const sprintf = matchBalancedCall(s, /^fmt\.Sprintf/);
84
+ const inner = sprintf ? sprintf.argsText : s.slice(s.indexOf('(') + 1, s.lastIndexOf(')'));
80
85
  const parts = _splitTopLevelCommas(inner).map(_lowerExpr);
81
86
  return { kind: 'tpl', parts };
82
87
  }
@@ -91,17 +96,30 @@ function _lowerExpr(text) {
91
96
  if (/^"/.test(s) || /^`/.test(s)) return { kind: 'literal', value: s };
92
97
  if (/^\d/.test(s)) return { kind: 'literal', value: s };
93
98
  if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
94
- // Call: foo.Bar(args) or Bar(args)
95
- const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
99
+ // Call: foo.Bar(args) or Bar(args). matchBalancedCall finds the paren
100
+ // that actually balances the FIRST '(' — not the greedy-to-end-of-string
101
+ // match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
102
+ // for a chained call (`Sanitize(x).Trim()` produced args="x).Trim(",
103
+ // which then fell through to {kind:'unknown'} and silently dropped x).
104
+ const callMatch = matchBalancedCall(s, /^([\w.]+)/);
96
105
  if (callMatch) {
97
- const callee = callMatch[1];
98
- const args = _splitTopLevelCommas(callMatch[2]).map(_lowerExpr);
99
- return { kind: 'call', callee, args };
106
+ const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
107
+ return { kind: 'call', callee: callMatch.callee, args };
100
108
  }
101
- // String concat with +
109
+ // String concat with + — a SECOND occurrence of the same check as above
110
+ // (line ~92), reached when the expression didn't match any branch in
111
+ // between (calls, literals, etc.). It's missing the `parts.length > 1`
112
+ // guard the first occurrence has: when the `+` sits inside an extra
113
+ // pair of parens (`("SELECT " + name)` — e.g. an over-parenthesized
114
+ // argument), `_splitTopLevelPlus` returns the input unchanged as ONE
115
+ // part (depth never returns to 0 at the `+`), and `.map(_lowerExpr)`
116
+ // then recurses on the IDENTICAL string forever — a stack overflow,
117
+ // uncaught here (only caught by buildProjectIR's per-file try/catch, so
118
+ // it surfaces only as "this file has no IR"). Same bug class, same fix,
119
+ // as parser-cs.js's `_splitTopLevelPlus` guard for `new Type(concat)`.
102
120
  if (s.includes('+') && /["'`]/.test(s)) {
103
- const parts = _splitTopLevelPlus(s).map(_lowerExpr);
104
- return { kind: 'tpl', parts };
121
+ const parts = _splitTopLevelPlus(s);
122
+ if (parts.length > 1) return { kind: 'tpl', parts: parts.map(_lowerExpr) };
105
123
  }
106
124
  // Member: a.b.c
107
125
  if (/^[A-Za-z_][\w.]*$/.test(s)) {
@@ -220,9 +238,9 @@ function _lowerStmt(stmt, line) {
220
238
  return { kind: 'assign', line, target: varDecl[1], source: _lowerExpr(varDecl[2]) };
221
239
  }
222
240
  // Statement-form call: obj.Method(args) or Method(args)
223
- const cm = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
241
+ const cm = matchBalancedCall(s, /^([\w.]+)/);
224
242
  if (cm) {
225
- return { kind: 'call', line, callee: cm[1], args: _splitTopLevelCommas(cm[2]).map(_lowerExpr) };
243
+ return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
226
244
  }
227
245
  return null;
228
246
  }
@@ -58,6 +58,27 @@ function exprFromCst(node) {
58
58
  // CST node with named children — recurse into the most informative one.
59
59
  // Method invocation
60
60
  if (node.children.methodInvocation) return _methodInvocation(node.children.methodInvocation[0]);
61
+ // `primary` is how java-parser actually models a call: the name lives in
62
+ // primaryPrefix (an fqnOrRefType) and the invocation itself in
63
+ // primarySuffix.methodInvocationSuffix. Without this branch the fall-through
64
+ // below recursed into the prefix and returned the NAME — so
65
+ // `req.getParameter("q")` lowered to a member access, never a call, and no
66
+ // source or sink in the Java catalog could ever match.
67
+ if (node.children.primaryPrefix) {
68
+ const prefix = node.children.primaryPrefix[0];
69
+ const suffixes = node.children.primarySuffix || [];
70
+ const invocation = suffixes
71
+ .map(s => s.children?.methodInvocationSuffix?.[0])
72
+ .find(Boolean);
73
+ if (invocation) {
74
+ const fqn = prefix?.children?.fqnOrRefType?.[0];
75
+ const callee = fqn ? _flattenFqnToString(fqn) : 'unknown';
76
+ const args = (invocation.children?.argumentList?.[0]?.children?.expression || [])
77
+ .map(exprFromCst);
78
+ return { kind: 'call', callee, args };
79
+ }
80
+ return exprFromCst(prefix);
81
+ }
61
82
  // FQN ref
62
83
  if (node.children.fqnOrRefType) return _fqnExpr(node.children.fqnOrRefType[0]);
63
84
  if (node.children.unqualifiedClassInstanceCreationExpression) {
@@ -89,12 +110,37 @@ function exprFromCst(node) {
89
110
  return { kind: 'unknown' };
90
111
  }
91
112
 
113
+ /**
114
+ * The identifier images of an `fqnOrRefType`, in order.
115
+ *
116
+ * java-parser does NOT put `Identifier` directly on `fqnOrRefType`. The real
117
+ * shape is fqnOrRefTypePartFirst / fqnOrRefTypePartRest → fqnOrRefTypePartCommon
118
+ * → Identifier, so reading `children.Identifier` returned nothing for every
119
+ * real-world expression and both callers below degraded to 'unknown'. The
120
+ * direct-Identifier form is still accepted as a fallback.
121
+ */
122
+ function _fqnIdents(node) {
123
+ if (!node || !node.children) return [];
124
+ const direct = node.children.Identifier;
125
+ if (Array.isArray(direct) && direct.length) return direct.map(t => t.image);
126
+ const out = [];
127
+ const parts = [
128
+ ...(node.children.fqnOrRefTypePartFirst || []),
129
+ ...(node.children.fqnOrRefTypePartRest || []),
130
+ ];
131
+ for (const p of parts) {
132
+ const common = p.children?.fqnOrRefTypePartCommon?.[0];
133
+ const id = common?.children?.Identifier?.[0]?.image;
134
+ if (id) out.push(id);
135
+ }
136
+ return out;
137
+ }
138
+
92
139
  function _fqnExpr(node) {
93
- // java-parser fqnOrRefType has children { Identifier: [...] } sometimes
94
- // and Dot tokens between them.
95
140
  if (!node || !node.children) return { kind: 'unknown' };
96
- const ids = node.children.Identifier;
97
- if (!ids || !ids.length) return { kind: 'unknown' };
141
+ const idImages = _fqnIdents(node);
142
+ if (!idImages.length) return { kind: 'unknown' };
143
+ const ids = idImages.map(image => ({ image }));
98
144
  let cur = { kind: 'ident', name: ids[0].image };
99
145
  for (let i = 1; i < ids.length; i++) {
100
146
  cur = { kind: 'member', object: cur, prop: ids[i].image };
@@ -118,10 +164,28 @@ function _methodInvocation(node) {
118
164
  }
119
165
 
120
166
  function _flattenFqnToString(node) {
121
- if (!node || !node.children) return 'unknown';
122
- const ids = node.children.Identifier;
123
- if (!ids) return 'unknown';
124
- return ids.map(t => t.image).join('.');
167
+ const ids = _fqnIdents(node);
168
+ return ids.length ? ids.join('.') : 'unknown';
169
+ }
170
+
171
+ // Descend to the first leaf token in a CST subtree and return its
172
+ // startLine. java-parser (chevrotain) leaf tokens carry startLine directly;
173
+ // rule nodes only carry `children`. Returns 0 when nothing is found (e.g.
174
+ // an empty node), which callers already treat as "unknown".
175
+ function _lineOf(node) {
176
+ if (!node) return 0;
177
+ if (Array.isArray(node)) {
178
+ for (const n of node) { const l = _lineOf(n); if (l) return l; }
179
+ return 0;
180
+ }
181
+ if (typeof node.startLine === 'number') return node.startLine;
182
+ if (node.children) {
183
+ for (const k of Object.keys(node.children)) {
184
+ const l = _lineOf(node.children[k]);
185
+ if (l) return l;
186
+ }
187
+ }
188
+ return 0;
125
189
  }
126
190
 
127
191
  /**
@@ -132,7 +196,7 @@ function _flattenFqnToString(node) {
132
196
  * `if` / `loop-header` node and the body falls through linearly. This is
133
197
  * coarser than the JS frontend; v2 will branch the succ array.
134
198
  */
135
- function buildCfgFromBody(bodyNode, line) {
199
+ function buildCfgFromBody(bodyNode) {
136
200
  const nodes = {};
137
201
  const entry = nextNodeId();
138
202
  const exit = nextNodeId();
@@ -157,6 +221,14 @@ function buildCfgFromBody(bodyNode, line) {
157
221
  function walkStmts(stmtNode) {
158
222
  if (!stmtNode || !stmtNode.children) return;
159
223
  const kids = stmtNode.children;
224
+ // java-parser nests a `block` as block → blockStatements (PLURAL, an
225
+ // intermediate rule node) → blockStatement. Walking only `blockStatement`
226
+ // from the block node therefore descended into nothing and every method
227
+ // body lowered to an empty CFG — which is why Java had IR functions with
228
+ // entry/exit and no statements, and taint could never fire.
229
+ if (kids.blockStatements) {
230
+ for (const bss of kids.blockStatements) walkStmts(bss);
231
+ }
160
232
  // Block statement children
161
233
  if (kids.blockStatement) {
162
234
  for (const bs of kids.blockStatement) walkStmts(bs);
@@ -170,7 +242,7 @@ function buildCfgFromBody(bodyNode, line) {
170
242
  const target = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
171
243
  const initExpr = d.children?.variableInitializer?.[0]?.children?.expression?.[0];
172
244
  if (target) {
173
- emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: line || 0, succ: [] });
245
+ emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(lv), succ: [] });
174
246
  }
175
247
  }
176
248
  }
@@ -186,34 +258,34 @@ function buildCfgFromBody(bodyNode, line) {
186
258
  const e = kids.expressionStatement[0]?.children?.statementExpression?.[0]?.children?.expression?.[0];
187
259
  if (e) {
188
260
  const expr = exprFromCst(e);
189
- if (expr.kind === 'call') emit({ ...expr, line: line || 0, succ: [] });
261
+ if (expr.kind === 'call') emit({ ...expr, line: _lineOf(kids.expressionStatement[0]), succ: [] });
190
262
  else if (expr.kind === 'binary' && expr.op === '=') {
191
263
  // assignment expr `x = y;`
192
- emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: line || 0, succ: [] });
264
+ emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(kids.expressionStatement[0]), succ: [] });
193
265
  }
194
266
  }
195
267
  }
196
268
  if (kids.returnStatement) {
197
269
  const r = kids.returnStatement[0];
198
270
  const expr = r.children?.expression?.[0];
199
- emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line: line || 0, succ: [] });
271
+ emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line: _lineOf(r), succ: [] });
200
272
  }
201
273
  if (kids.throwStatement) {
202
274
  const t = kids.throwStatement[0];
203
275
  const expr = t.children?.expression?.[0];
204
- emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line: line || 0, succ: [] });
276
+ emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line: _lineOf(t), succ: [] });
205
277
  }
206
278
  if (kids.ifStatement) {
207
279
  const i = kids.ifStatement[0];
208
280
  const cond = i.children?.expression?.[0];
209
- emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: line || 0, succ: [] });
281
+ emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(i), succ: [] });
210
282
  // Then branch body falls through linearly; v1 simplification.
211
283
  for (const sub of (i.children?.statement || [])) walkStmts(sub);
212
284
  }
213
285
  if (kids.whileStatement) {
214
286
  const w = kids.whileStatement[0];
215
287
  const cond = w.children?.expression?.[0];
216
- emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: line || 0, succ: [] });
288
+ emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(w), succ: [] });
217
289
  for (const sub of (w.children?.statement || [])) walkStmts(sub);
218
290
  }
219
291
  }
@@ -266,12 +338,17 @@ export async function parseJavaFile(file, raw) {
266
338
  const params = []; // params extraction deferred
267
339
  const body = md.children?.methodBody?.[0]?.children?.block?.[0];
268
340
  if (body) {
341
+ const methodLine = _lineOf(md);
269
342
  functions.push({
270
- qid: `${file}::${className || 'class'}::${name}`,
343
+ // Sibling frontends (parser-js.js) suffix the qid with `@line`
344
+ // so overloaded/same-named methods don't collide — without it,
345
+ // callgraph.js's `functions.set(fn.qid, fn)` silently drops
346
+ // every overload but the last one declared.
347
+ qid: `${file}::${className || 'class'}::${name}@${methodLine}`,
271
348
  name: className ? `${className}.${name}` : name,
272
- line: 0,
349
+ line: methodLine,
273
350
  params,
274
- cfg: buildCfgFromBody(body, 0),
351
+ cfg: buildCfgFromBody(body),
275
352
  file,
276
353
  });
277
354
  }