@clear-capabilities/agentic-security-scanner 0.151.3 → 0.152.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/agentic-security.mjs +80 -80
  3. package/dist/agentic-security.mjs.sha256 +1 -1
  4. package/package.json +5 -5
  5. package/src/dataflow/CLAUDE.md +1 -1
  6. package/src/dataflow/catalog.js +802 -13
  7. package/src/dataflow/engine.js +852 -89
  8. package/src/dataflow/incremental.js +13 -0
  9. package/src/dataflow/index.js +18 -5
  10. package/src/dataflow/path-feasibility.js +86 -9
  11. package/src/dataflow/string-domain.js +132 -0
  12. package/src/dataflow/summaries.js +69 -8
  13. package/src/engine.js +108 -3
  14. package/src/ir/CLAUDE.md +2 -1
  15. package/src/ir/balanced-call.js +33 -1
  16. package/src/ir/callgraph.js +26 -2
  17. package/src/ir/class-hierarchy.js +101 -6
  18. package/src/ir/csharp-ir.js +97 -8
  19. package/src/ir/index.js +21 -1
  20. package/src/ir/parser-cs.js +706 -24
  21. package/src/ir/parser-java.js +498 -17
  22. package/src/ir/parser-js.js +22 -0
  23. package/src/ir/parser-php.js +446 -43
  24. package/src/ir/parser-rust.js +1338 -0
  25. package/src/ir/php-include-merge.js +190 -0
  26. package/src/lineage/sink-registry.js +18 -0
  27. package/src/lineage/source-registry.js +23 -0
  28. package/src/posture/csharp-analysis.js +341 -43
  29. package/src/posture/finding-defaults.js +5 -1
  30. package/src/posture/fix-coverage.js +1 -0
  31. package/src/posture/proof-coverage.js +1 -0
  32. package/src/sast/CLAUDE.md +1 -1
  33. package/src/sast/authz.js +144 -1
  34. package/src/sast/crypto-protocol.js +65 -7
  35. package/src/sast/csharp-cross-file.js +125 -0
  36. package/src/sast/csharp-structural.js +78 -2
  37. package/src/sast/csharp.js +574 -42
  38. package/src/sast/java-ast-folding.js +268 -11
  39. package/src/sast/java-bench-extras.js +530 -2
  40. package/src/sast/java-deserialization.js +31 -2
  41. package/src/sast/java-structural-cross-file.js +146 -0
  42. package/src/sast/java-structural.js +499 -6
  43. package/src/sast/ldap-injection.js +133 -3
  44. package/src/sast/rust.js +57 -0
  45. package/src/sast/xss-reflected-multilang.js +252 -3
@@ -8,14 +8,470 @@
8
8
  // guarded URLs do not match, keeping this high-precision.
9
9
 
10
10
  import { blankComments } from './_comment-strip.js';
11
+ import { deadBranchRanges, isLineInDeadRange } from './java-ast-folding.js';
11
12
 
12
13
  // Concat-into-sink families with no guard needed (parameterized form has no
13
- // string-concat argument, so it auto-clears).
14
- const RE = {
15
- sqlInjection: /\b(?:executeQuery|executeUpdate|execute|createQuery|createNativeQuery|prepareStatement|prepareCall)\s*\(\s*"[^"\n]*"\s*\+/g,
16
- cmdInjection: /\b(?:Runtime\.getRuntime\(\)\s*\.\s*exec|ProcessBuilder)\s*\(\s*(?:new\s+String\s*\[\s*\]\s*\{\s*)?"[^"\n]*"\s*\+/g,
14
+ // string-concat argument, so it auto-clears). Each pattern additionally
15
+ // captures a trailing `+ identifier)` / `+ identifier;` tail — the SINGLE,
16
+ // LAST concatenated term, when the concatenation ends right there — so the
17
+ // caller can check whether that one identifier is PROVABLY a hardcoded
18
+ // literal (see `_trailingIdentIsLiteral` below). Juliet's own convention
19
+ // (confirmed against the public Java mirror's CWE-89 execute()/executeQuery()
20
+ // variants) keeps the IDENTICAL sink code in both bad() and goodG2B(), only
21
+ // swapping the source of one bare local variable (`data = System.getenv(…)`
22
+ // vs. `data = "foo"`) — this taint-independent detector, having no taint
23
+ // model of its own, previously could not tell the two apart and fired on
24
+ // both, a large fraction of this family's real corpus false positives.
25
+ // Exported for java-structural-cross-file.js (W5.41) — the SAME sink
26
+ // matching used to re-derive candidate findings when checking whether a
27
+ // sibling-file caller resolves the concatenated variable to a literal.
28
+ export const RE = {
29
+ sqlInjection: /\b(?:executeQuery|executeUpdate|execute|createQuery|createNativeQuery|prepareStatement|prepareCall)\s*\(\s*"[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/g,
30
+ cmdInjection: /\b(?:Runtime\.getRuntime\(\)\s*\.\s*exec|ProcessBuilder)\s*\(\s*(?:new\s+String\s*\[\s*\]\s*\{\s*)?"[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/g,
17
31
  };
18
32
 
33
+ // True when `varName`'s NEAREST assignment before `beforeIdx` (source order)
34
+ // is a plain string-literal RHS — same "backward nearest-assignment" shape
35
+ // as java-bench-extras.js's `_nearestAssignIsLiteral` (CWE-259), reused here
36
+ // for the opposite purpose: SUPPRESSING a structural finding instead of
37
+ // creating one. Deliberately narrow: only fires when the regex above
38
+ // captured a SINGLE trailing identifier immediately before the sink call's
39
+ // closing `)`/`;` — a concatenation with more terms after it
40
+ // (`"…" + a + b`) is left alone, since a safe `a` says nothing about `b`.
41
+ //
42
+ // SARD_80_F1 W4.J35 — the SAME dead-code-blindness bug already fixed twice
43
+ // this session in sibling files (W4.J33's `java-bench-extras.js`
44
+ // `_nearestAssignIsLiteral`; W4.J34's own `deadBranchRanges` extension),
45
+ // found here independently while sweeping Java's largest CWE-89 fp bucket
46
+ // (300+ files, all `_02`..`_51b`-numbered `connect_tcp_executeUpdate_XX`
47
+ // variants sharing this ONE detector). Confirmed via the public Java Juliet
48
+ // mirror's own `CWE89_SQL_Injection__connect_tcp_executeUpdate_02.java`:
49
+ // `goodG2B2()` does `if (true) { data = "foo"; } else { data = null; }`
50
+ // immediately before the identical `executeUpdate("…"+data+"'")` sink line
51
+ // `bad()` also uses — the textually-LATER dead `data = null;` in the else
52
+ // branch made `lastAnyEnd` land there instead of on the live literal
53
+ // assignment, so `lastLiteralEnd === lastAnyEnd` failed and this entire
54
+ // flow-variant family (Juliet's own Flow Variant 02, used across dozens of
55
+ // CWEs per W4.C28's original C#-side discovery of the same idiom) was never
56
+ // recognized as literal-only. Fixed by reusing the ALREADY-EXISTING,
57
+ // AST-based `deadBranchRanges`/`isLineInDeadRange` (the SAME mechanism
58
+ // W4.J33 already wired into `java-bench-extras.js`, and which W4.J34
59
+ // separately extended to also resolve `private static final` field
60
+ // constants — this fix inherits BOTH capabilities for free) to skip an
61
+ // assignment whose line falls inside a provably-dead branch when computing
62
+ // `lastAnyEnd`.
63
+ // SARD_80_F1 W5.23 — ports java-bench-extras.js's W4.J37/W4.J38 cross-method
64
+ // literal resolution (same-file callee-return, same-method copy-chain,
65
+ // argument-passing via all call sites) into this file's OWN independent copy
66
+ // of the "nearest assignment" heuristic. Confirmed via a real-corpus sweep
67
+ // that CWE-89's own remaining fp bucket includes an even ~11-file share for
68
+ // EACH of Juliet's Flow Variant 31 (copy)/41 (argument)/42 (return) suffixes,
69
+ // the exact three idioms java-bench-extras.js's CWE-259/601 detectors already
70
+ // needed this same fix for — this file never received the port. Kept as a
71
+ // SEPARATE copy rather than a shared import, matching this file's own
72
+ // established convention (see `_trailingIdentIsLiteral`'s header comment)
73
+ // of independently-tuned duplicates rather than a cross-file dependency.
74
+ // Regexes below are copied VERBATIM in their already-ReDoS-fixed form (see
75
+ // java-bench-extras.js's own W4.J39 fix comments) — never reintroduce a
76
+ // lazy, `\s`-inclusive "return type" prefix match; neither function below
77
+ // ever reads that prefix, so it was never needed for correctness.
78
+ const _CALLEE_RETURN_LITERAL_CACHE_DEPTH = 4;
79
+
80
+ function _resolveCalleeReturnIsLiteral(content, calleeName, deadRanges, _depth) {
81
+ const depth = _depth || 0;
82
+ if (depth >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
83
+ const escapedCallee = calleeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
84
+ const declRe = new RegExp(`\\b${escapedCallee}\\s*\\([^)]*\\)\\s*(?:throws\\s+[\\w.,\\s]+)?\\s*\\{`, 'g');
85
+ const dm = declRe.exec(content);
86
+ if (!dm) return false;
87
+ const bodyStart = dm.index + dm[0].length;
88
+ let braceDepth = 1, i = bodyStart;
89
+ while (i < content.length && braceDepth > 0) {
90
+ if (content[i] === '{') braceDepth++;
91
+ else if (content[i] === '}') braceDepth--;
92
+ i++;
93
+ }
94
+ const body = content.slice(bodyStart, i - 1);
95
+ const returnRe = /\breturn\s+([^;]+);/g;
96
+ const returns = [];
97
+ let rm;
98
+ while ((rm = returnRe.exec(body))) returns.push({ expr: rm[1].trim(), idx: bodyStart + rm.index });
99
+ if (returns.length !== 1) return false;
100
+ const { expr, idx } = returns[0];
101
+ if (/^"[^"]*"$/.test(expr)) return true;
102
+ if (/^[A-Za-z_]\w*$/.test(expr)) {
103
+ return _trailingIdentIsLiteral(content, expr, idx, deadRanges, depth + 1);
104
+ }
105
+ return false;
106
+ }
107
+
108
+ function _splitTopLevelCommas(text) {
109
+ const parts = [];
110
+ let depth = 0, cur = '', inStr = null;
111
+ for (let i = 0; i < text.length; i++) {
112
+ const c = text[i];
113
+ if (inStr) {
114
+ cur += c;
115
+ if (c === '\\') { cur += text[++i] || ''; continue; }
116
+ if (c === inStr) inStr = null;
117
+ continue;
118
+ }
119
+ if (c === '"' || c === "'") { inStr = c; cur += c; continue; }
120
+ if (c === '(' || c === '[' || c === '{') depth++;
121
+ else if (c === ')' || c === ']' || c === '}') depth--;
122
+ if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
123
+ cur += c;
124
+ }
125
+ if (cur.trim() !== '' || parts.length) parts.push(cur);
126
+ return parts;
127
+ }
128
+
129
+ // SARD_80_F1 W5.41 — Juliet's own Flow Variant 51+ ("data passed as an
130
+ // argument from one method to another in a DIFFERENT class, in the same or
131
+ // a different package") splits this idiom's caller and callee across TWO
132
+ // PHYSICAL FILES living in the SAME DIRECTORY (confirmed via W5.40's direct
133
+ // fetch of the public mirror's own `CWE89_SQL_Injection__
134
+ // database_executeQuery_51a.java`/`_51b.java` pair — see that ledger entry
135
+ // for the full investigation) — the same-file-only search below can never
136
+ // see a caller that lives in a sibling file, since `content` is always just
137
+ // the one file being scanned. `siblingFiles` (optional; an array of
138
+ // `{code, deadRanges}` for OTHER `.java` files in the same directory —
139
+ // each with its OWN correctly-computed deadRanges, since a dead-branch
140
+ // range computed for THIS file's line numbers would be meaningless applied
141
+ // to a different file's text) is a fallback search space consulted ONLY
142
+ // when the same-file search finds ZERO real call sites at all for this
143
+ // method name — a real, non-literal call site in THIS file remains
144
+ // authoritative and siblingFiles is never consulted, preserving this
145
+ // function's own pre-existing "fail closed on any real caller passing a
146
+ // non-literal" discipline exactly. Only `scanJavaStructural`'s own ordinary
147
+ // per-file call never supplies this (so its behavior is byte-identical to
148
+ // before this change) — it is supplied exclusively by the dedicated
149
+ // project-wide suppression pass, `java-structural-cross-file.js`.
150
+ // The declared class name of `content` itself — Juliet's own universal
151
+ // one-public-class-per-file convention (confirmed across every fetched
152
+ // corpus file this session), used ONLY to disambiguate a cross-file call
153
+ // site (see below); irrelevant to same-file resolution, which never needs
154
+ // it since a bare, unqualified call in the same file is unambiguous already.
155
+ function _ownClassName(content) {
156
+ const m = /\bclass\s+([A-Za-z_]\w*)/.exec(content);
157
+ return m ? m[1] : null;
158
+ }
159
+
160
+ // The smallest method body span (character offsets, `{`..matching `}`)
161
+ // enclosing `idx` — used to bound a cross-file variable's own call search to
162
+ // its DECLARING method only (see W5.45's own call site for why: Juliet
163
+ // routinely reuses the same local variable name across sibling methods in
164
+ // one file, e.g. `bad()`/`goodG2B()`/`goodB2G()` each declaring their own
165
+ // `baseObject`). Returns null if no enclosing method is found or `idx` falls
166
+ // in a gap between methods (a same-name field/local outside any method).
167
+ function _enclosingMethodSpan(text, idx) {
168
+ const methodDeclRe = /\b(?!if\b|for\b|while\b|switch\b|catch\b|synchronized\b|do\b|else\b|return\b|new\b)([A-Za-z_]\w*)\s*\([^)]*\)\s*(?:throws\s+[\w.,\s]+)?\s*\{/g;
169
+ let best = null, m;
170
+ while ((m = methodDeclRe.exec(text)) && m.index < idx) best = m;
171
+ if (!best) return null;
172
+ const openIdx = best.index + best[0].length - 1;
173
+ let depth = 1, i = openIdx + 1;
174
+ while (i < text.length && depth > 0) {
175
+ if (text[i] === '{') depth++;
176
+ else if (text[i] === '}') depth--;
177
+ i++;
178
+ }
179
+ if (i <= idx) return null; // the nearest preceding method already closed before idx — not actually enclosing
180
+ return { start: openIdx, end: i, name: best[1] };
181
+ }
182
+
183
+ function _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _depth, siblingFiles) {
184
+ const depth0 = _depth || 0;
185
+ if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
186
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
187
+ const methodDeclRe = /\b(?!if\b|for\b|while\b|switch\b|catch\b|synchronized\b|do\b|else\b|return\b|new\b)([A-Za-z_]\w*)\s*\(([^)]*)\)\s*(?:throws\s+[\w.,\s]+)?\s*\{/g;
188
+ let enclosing = null, dm;
189
+ while ((dm = methodDeclRe.exec(content)) && dm.index < beforeIdx) enclosing = dm;
190
+ if (!enclosing) return false;
191
+ const methodName = enclosing[1];
192
+ const params = _splitTopLevelCommas(enclosing[2]).map(p => p.trim()).filter(Boolean);
193
+ const paramIdx = params.findIndex((p) => new RegExp(`\\b${escapedVar}$`).test(p));
194
+ if (paramIdx === -1) return false;
195
+ const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
196
+
197
+ // `classNameFilter` (only ever set for a SIBLING-file search — see below)
198
+ // requires the match to be a `(new ClassName()).method(` / `new
199
+ // ClassName().method(` construction naming THIS sink's own declared
200
+ // class, not just any call to a method sharing this method's bare name.
201
+ // Juliet reuses the SAME generic method names (bad/badSink/goodG2B/
202
+ // goodG2BSink/goodB2G/goodB2GSink/…) identically across thousands of
203
+ // otherwise-unrelated flow-variant files, and `--scramble-identifiers`
204
+ // preserves this collision BY DESIGN (the same original word always
205
+ // hashes to the same opaque token, everywhere) — confirmed the hard way
206
+ // via W5.41's own real-corpus debugging: a bare method-name search across
207
+ // a directory holding ~980 sibling files found hundreds of unrelated
208
+ // classes' own same-named methods, and picking up even ONE unrelated
209
+ // caller with a different arity or a genuinely different argument value
210
+ // wrongly failed this check closed for the ACTUAL intended pair. Requiring
211
+ // the class name too disambiguates correctly, since Juliet's own
212
+ // `CWEnnn_Descriptor__variant` → `case_<hash>` class-name scrambling is
213
+ // ALSO consistent per distinct original name (a DIFFERENT class name in a
214
+ // DIFFERENT file hashes to a DIFFERENT token). Same-file resolution never
215
+ // sets this — an unqualified call within the declaring file itself is
216
+ // already unambiguous.
217
+ function _checkCallAt(searchText, searchDeadRanges, matchIndex, matchEnd) {
218
+ const argsStart = matchEnd;
219
+ let braceDepth = 1, i = argsStart;
220
+ while (i < searchText.length && braceDepth > 0) {
221
+ if (searchText[i] === '(') braceDepth++;
222
+ else if (searchText[i] === ')') braceDepth--;
223
+ i++;
224
+ }
225
+ const afterClose = searchText.slice(i).match(/^\s*(\{|throws)/);
226
+ if (afterClose) return 'skip'; // this is the method's OWN declaration, not a call site
227
+ const args = _splitTopLevelCommas(searchText.slice(argsStart, i - 1)).map((a) => a.trim());
228
+ if (paramIdx >= args.length) return 'fail';
229
+ const argExpr = args[paramIdx];
230
+ if (/^"[^"]*"$/.test(argExpr)) return 'ok';
231
+ if (/^[A-Za-z_]\w*$/.test(argExpr) && _trailingIdentIsLiteral(searchText, argExpr, matchIndex, searchDeadRanges, depth0 + 1, siblingFiles)) return 'ok';
232
+ return 'fail';
233
+ }
234
+
235
+ function scanCallSitesIn(searchText, searchDeadRanges, classNameFilter) {
236
+ let sawRealCallSite = false;
237
+ if (classNameFilter) {
238
+ // Inline construction: `(new ClassName()).method(` / `new ClassName().method(`.
239
+ const inlineRe = new RegExp(`\\bnew\\s+${classNameFilter}\\s*\\(\\s*\\)\\s*\\)?\\s*\\.\\s*${escapedMethod}\\s*\\(`, 'g');
240
+ let cm;
241
+ while ((cm = inlineRe.exec(searchText))) {
242
+ const r = _checkCallAt(searchText, searchDeadRanges, cm.index, cm.index + cm[0].length);
243
+ if (r === 'skip') continue;
244
+ if (r === 'fail') return 'fail';
245
+ sawRealCallSite = true;
246
+ }
247
+ // SARD_80_F1 W5.45 — Juliet's OWN abstract-dispatch caller convention
248
+ // (confirmed via a direct fetch of the public mirror's own
249
+ // `CWE89_SQL_Injection__Environment_executeQuery_81a.java`) is a TWO
250
+ // STATEMENT pattern, not the inline one above: `<BaseType> <var> = new
251
+ // <ConcreteClass>();` immediately followed (same or a later line,
252
+ // arbitrary intervening text) by `<var>.<method>(...)` — the inline
253
+ // regex above can never match this shape at all. Found by re-
254
+ // investigating a real-corpus regression this SAME session's own
255
+ // W5.43/44 scorer fix exposed: 30 Java files where our own detector
256
+ // fires on Juliet's "good source, structurally bad-shaped sink" test
257
+ // convention (a deliberate imprecision trap, not a real vulnerability
258
+ // — see buildJulietExpected's own header comment on why crediting a
259
+ // goodG2B() firing "rewarded exactly the imprecision Juliet is
260
+ // designed to expose") because the cross-file literal check couldn't
261
+ // see this SPECIFIC calling convention. A declared variable's type is
262
+ // NOT required to match `classNameFilter` (Java allows assigning a
263
+ // subclass instance to a base-typed variable, exactly what this
264
+ // idiom does — `CWE89_..._81_base baseObject = new CWE89_..._goodG2B();`)
265
+ // — only the CONSTRUCTOR call's own class name matters.
266
+ const declRe = new RegExp(`\\b[A-Za-z_]\\w*(?:<[^;()]*>)?\\s+([A-Za-z_]\\w*)\\s*=\\s*new\\s+${classNameFilter}\\s*\\(\\s*\\)\\s*;`, 'g');
267
+ let dm;
268
+ while ((dm = declRe.exec(searchText))) {
269
+ const varName = dm[1];
270
+ // Bound the variable's own call search to its DECLARING METHOD's own
271
+ // body span — Juliet's own `bad()`/`goodG2B()`/`goodB2G()` sibling
272
+ // methods routinely reuse the SAME local variable name
273
+ // ("baseObject") for THREE DIFFERENT concrete-class constructions,
274
+ // one per method; an unbounded whole-file search for
275
+ // `baseObject.action(` would also match the OTHER two methods' own
276
+ // unrelated calls (one of them genuinely tainted, in `bad()`),
277
+ // wrongly failing this check closed for the correct pair.
278
+ const span = _enclosingMethodSpan(searchText, dm.index);
279
+ const searchWithin = span ? searchText.slice(span.start, span.end) : searchText;
280
+ const offset = span ? span.start : 0;
281
+ const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
282
+ const varCallRe = new RegExp(`\\b${escapedVarName}\\s*\\.\\s*${escapedMethod}\\s*\\(`, 'g');
283
+ let vm;
284
+ while ((vm = varCallRe.exec(searchWithin))) {
285
+ const r = _checkCallAt(searchText, searchDeadRanges, offset + vm.index, offset + vm.index + vm[0].length);
286
+ if (r === 'skip') continue;
287
+ if (r === 'fail') return 'fail';
288
+ sawRealCallSite = true;
289
+ }
290
+ }
291
+ } else {
292
+ const callRe = new RegExp(`\\b${escapedMethod}\\s*\\(`, 'g');
293
+ let cm;
294
+ while ((cm = callRe.exec(searchText))) {
295
+ const r = _checkCallAt(searchText, searchDeadRanges, cm.index, cm.index + cm[0].length);
296
+ if (r === 'skip') continue;
297
+ if (r === 'fail') return 'fail';
298
+ sawRealCallSite = true;
299
+ }
300
+ }
301
+ return sawRealCallSite ? 'ok' : 'none';
302
+ }
303
+
304
+ // Every known real call site — same file AND every sibling — must agree
305
+ // the argument is a literal; a single non-literal caller ANYWHERE fails
306
+ // this closed, so every sibling is checked even after an earlier one
307
+ // already resolved 'ok' (returning early on the first 'ok' would let a
308
+ // LATER sibling's genuinely-tainted caller go unseen).
309
+ const sameFileResult = scanCallSitesIn(content, deadRanges, null);
310
+ if (sameFileResult === 'fail') return false;
311
+ let sawRealCallSite = sameFileResult === 'ok';
312
+ if (siblingFiles && siblingFiles.length) {
313
+ const ownClass = _ownClassName(content);
314
+ if (ownClass) {
315
+ const escapedClass = ownClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
316
+ for (const sib of siblingFiles) {
317
+ const r = scanCallSitesIn(sib.code, sib.deadRanges, escapedClass);
318
+ if (r === 'fail') return false;
319
+ if (r === 'ok') sawRealCallSite = true;
320
+ }
321
+ }
322
+ }
323
+ return sawRealCallSite;
324
+ }
325
+
326
+ // SARD_80_F1 W5.34 — Juliet Flow Variant 45: a value passed as a private
327
+ // class member variable between two methods of the same class (`dataX = y;`
328
+ // in one method, `String data = dataX;` in another, in either textual
329
+ // order). See java-bench-extras.js's own header comment on the identical,
330
+ // canonical copy of this function for the full idiom and precision
331
+ // reasoning. Only resolves when `varName` is confirmed to be an actual
332
+ // field declaration (access-modifier-qualified — never valid on a local
333
+ // variable or parameter).
334
+ function _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _depth) {
335
+ const depth0 = _depth || 0;
336
+ if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
337
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
338
+ const fieldDeclRe = new RegExp(`\\b(?:private|protected|public)\\s+[\\w<>\\[\\],.\\s]+\\b${escaped}\\s*(?:=\\s*[^;]+)?;`);
339
+ if (!fieldDeclRe.test(content)) return false;
340
+ const ranges = deadRanges || [];
341
+ const assignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
342
+ const lineOf_ = (idx) => content.substring(0, idx).split('\n').length;
343
+ let sawAssignment = false, m;
344
+ while ((m = assignRe.exec(content))) {
345
+ if (ranges.length && isLineInDeadRange(lineOf_(m.index), ranges)) continue;
346
+ sawAssignment = true;
347
+ const rhs = m[1].trim();
348
+ if (/^"[^"]*"$/.test(rhs)) continue;
349
+ if (/^[A-Za-z_]\w*$/.test(rhs) && rhs !== varName
350
+ && _trailingIdentIsLiteral(content, rhs, m.index, ranges, depth0 + 1)) continue;
351
+ return false;
352
+ }
353
+ return sawAssignment;
354
+ }
355
+
356
+ // SARD_80_F1 W5.47 — Juliet Flow Variant 65-68 ("data passed as a member
357
+ // variable in the 'a' class, used by a method in another class in the same
358
+ // package"): a PUBLIC STATIC field is declared and written in one file
359
+ // (`_68a.java`), then read via a QUALIFIED reference (`OtherClass.field`)
360
+ // in a SIBLING file's sink method (`_68b.java`) — confirmed via a direct
361
+ // fetch of the public mirror's own `CWE89_SQL_Injection__
362
+ // Environment_executeBatch_68a.java`/`_68b.java` pair. This was
363
+ // independently identified THREE times this session (W3.4/PHP,
364
+ // W4.C24/C#, W4.J27/Java) as needing "call-site-sensitive field-value
365
+ // tracking" — a real context-sensitivity dimension this codebase's taint
366
+ // engine deliberately does not model (`dataflow/CLAUDE.md`'s own
367
+ // documented scope). Re-deriving from the actual fetched files shows the
368
+ // SPECIFIC shape this idiom always takes is narrower than that framing
369
+ // suggested: Juliet names each sink method UNIQUELY per flow branch
370
+ // (`badSink`/`goodG2BSink`/`goodB2GSink`, never one shared method called
371
+ // from multiple places), and each has EXACTLY ONE real caller, whose own
372
+ // write to the field happens immediately before its own call to that
373
+ // EXACT sink method — the same "declare-then-call, one writer per callee"
374
+ // shape W5.45 already solved for method arguments, just via a qualified
375
+ // static field instead. `sinkMethodName` is the ENCLOSING method's own
376
+ // name in the file containing the read (found via `_enclosingMethodSpan`);
377
+ // for EACH candidate write to the field anywhere in the field-declaring
378
+ // sibling file, this checks whether THAT write's own enclosing method is
379
+ // the one that calls `sinkMethodName` — a write in an UNRELATED method
380
+ // (e.g. `bad()`'s own write, when resolving `goodG2BSink()`'s read) is
381
+ // correctly never consulted at all, not merely outvoted. Fails closed
382
+ // exactly like every other mechanism here: a relevant writer supplying a
383
+ // non-literal, or NO relevant writer found at all, both return false.
384
+ function _resolveStaticFieldLiteralAcrossFiles(className, fieldName, sinkMethodName, siblingFiles, _depth) {
385
+ const depth0 = _depth || 0;
386
+ if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
387
+ if (!siblingFiles || !siblingFiles.length || !sinkMethodName) return false;
388
+ const escapedClass = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
389
+ const escapedField = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
390
+ const escapedSinkMethod = sinkMethodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
391
+
392
+ for (const sib of siblingFiles) {
393
+ // Cheap plain-substring pre-filter before paying for RegExp construction
394
+ // + execution against a whole (up to 500KB) sibling file: the vast
395
+ // majority of a Juliet directory's ~980 siblings never mention this
396
+ // class name at all, and String#includes rejects those almost for free.
397
+ // Never a false negative — a real match always contains the class name
398
+ // as a substring, so this can only skip files the regex would also skip.
399
+ if (!sib.code.includes(className)) continue;
400
+ if (!new RegExp(`\\bclass\\s+${escapedClass}\\b`).test(sib.code)) continue;
401
+ const assignRe = new RegExp(`\\b${escapedField}\\s*=\\s*([^;]+);`, 'g');
402
+ const callsSinkRe = new RegExp(`\\.\\s*${escapedSinkMethod}\\s*\\(|\\b${escapedSinkMethod}\\s*\\(`);
403
+ let sawRelevantWrite = false, m;
404
+ while ((m = assignRe.exec(sib.code))) {
405
+ const span = _enclosingMethodSpan(sib.code, m.index);
406
+ if (!span || !span.name) continue;
407
+ const methodBody = sib.code.slice(span.start, span.end);
408
+ if (!callsSinkRe.test(methodBody)) continue; // this write's own method never reaches this specific sink — irrelevant
409
+ sawRelevantWrite = true;
410
+ const rhs = m[1].trim();
411
+ if (/^"[^"]*"$/.test(rhs)) continue;
412
+ if (/^[A-Za-z_]\w*$/.test(rhs) && _trailingIdentIsLiteral(sib.code, rhs, m.index, [], depth0 + 1)) continue;
413
+ return false; // this writer's own value is non-literal (or unresolvable) — fail closed
414
+ }
415
+ return sawRelevantWrite;
416
+ }
417
+ return false;
418
+ }
419
+
420
+ // Exported for java-structural-cross-file.js (W5.41) — see
421
+ // `_resolveParamLiteralViaAllCallSites`'s own header comment for what
422
+ // `siblingFiles` is and why it's threaded through here too (the same-method
423
+ // copy-chain branch below must be able to recurse into a cross-file-
424
+ // resolved parameter if it is then copied to another local before use).
425
+ export function _trailingIdentIsLiteral(code, varName, beforeIdx, deadRanges, _calleeDepth, siblingFiles) {
426
+ if (!varName) return false;
427
+ if ((_calleeDepth || 0) >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
428
+ const ranges = deadRanges || [];
429
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
430
+ const literalRe = new RegExp(`\\b${escaped}\\s*=\\s*"[^"]*"\\s*;`, 'g');
431
+ const anyAssignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
432
+ const lineOfIdx = (idx) => code.substring(0, idx).split('\n').length;
433
+ let lastLiteralEnd = -1, m;
434
+ while ((m = literalRe.exec(code)) && m.index < beforeIdx) {
435
+ if (ranges.length && isLineInDeadRange(lineOfIdx(m.index), ranges)) continue;
436
+ lastLiteralEnd = m.index + m[0].length;
437
+ }
438
+ let lastAnyEnd = -1, lastAnyRhs = null, lastAnyIdx = -1;
439
+ while ((m = anyAssignRe.exec(code)) && m.index < beforeIdx) {
440
+ if (ranges.length && isLineInDeadRange(lineOfIdx(m.index), ranges)) continue;
441
+ lastAnyEnd = m.index + m[0].length;
442
+ lastAnyRhs = m[1].trim();
443
+ lastAnyIdx = m.index;
444
+ }
445
+ // Same-file callee-return resolution (Flow Variant 42).
446
+ if (lastAnyRhs && /^[A-Za-z_]\w*\s*\([^()]*\)$/.test(lastAnyRhs)) {
447
+ const calleeName = lastAnyRhs.slice(0, lastAnyRhs.indexOf('(')).trim();
448
+ if (_resolveCalleeReturnIsLiteral(code, calleeName, ranges, _calleeDepth)) return true;
449
+ }
450
+ // Same-method copy-chain resolution (Flow Variant 31).
451
+ if (lastAnyRhs && lastAnyRhs !== 'null' && /^[A-Za-z_]\w*$/.test(lastAnyRhs) && lastAnyRhs !== varName) {
452
+ if (_trailingIdentIsLiteral(code, lastAnyRhs, lastAnyIdx, ranges, (_calleeDepth || 0) + 1, siblingFiles)) return true;
453
+ }
454
+ // Cross-file static-field resolution (Flow Variant 65-68, W5.47): the
455
+ // nearest assignment is a QUALIFIED `ClassName.field` reference — resolve
456
+ // via every write to that field in its own declaring (sibling) file whose
457
+ // enclosing method is the one real caller of THIS sink method.
458
+ if (lastAnyRhs && siblingFiles && siblingFiles.length) {
459
+ const fieldRefMatch = /^([A-Za-z_]\w*)\s*\.\s*([A-Za-z_]\w*)$/.exec(lastAnyRhs);
460
+ if (fieldRefMatch) {
461
+ const sinkSpan = _enclosingMethodSpan(code, beforeIdx);
462
+ const sinkMethodName = sinkSpan && sinkSpan.name;
463
+ if (_resolveStaticFieldLiteralAcrossFiles(fieldRefMatch[1], fieldRefMatch[2], sinkMethodName, siblingFiles, _calleeDepth)) return true;
464
+ }
465
+ }
466
+ if (lastLiteralEnd === -1 || lastLiteralEnd !== lastAnyEnd) {
467
+ // Argument-passing resolution via all call sites (Flow Variant 41), then
468
+ // class-member-variable resolution via all assignments (Flow Variant 45).
469
+ return _resolveParamLiteralViaAllCallSites(code, varName, beforeIdx, deadRanges, _calleeDepth, siblingFiles)
470
+ || _resolveFieldLiteralViaAllAssignments(code, varName, deadRanges, _calleeDepth);
471
+ }
472
+ return true;
473
+ }
474
+
19
475
  const META = {
20
476
  sqlInjection: {
21
477
  vuln: 'SQL Injection — query built with string concatenation (Java)',
@@ -44,11 +500,29 @@ export function scanJavaStructural(fp, raw) {
44
500
  snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
45
501
  remediation: meta.remediation, parser: 'JAVA', confidence: 0.78,
46
502
  });
503
+ // See _trailingIdentIsLiteral's own header comment (W4.J35).
504
+ let deadRanges = [];
505
+ try { deadRanges = deadBranchRanges(raw); } catch { /* parse error → no AST info */ }
47
506
 
48
507
  for (const [key, re] of Object.entries(RE)) {
49
508
  const r = new RegExp(re.source, re.flags);
50
509
  let m;
51
- while ((m = r.exec(code))) emit(key, lineOf(code, m.index), META[key]);
510
+ while ((m = r.exec(code))) {
511
+ const line = lineOf(code, m.index);
512
+ // SARD_80_F1 W5.35 — a sink call sitting INSIDE a provably-dead branch
513
+ // (Juliet's own "switch(8){case 7: <tainted sink>}" idiom, Flow
514
+ // Variant 15's goodB2G shapes — case 7 never matches scrutinee 8) is
515
+ // unreachable regardless of whether the value reaching it is tainted:
516
+ // `deadRanges` was already computed for the LITERAL-CHECK below, but
517
+ // nothing checked it against the SINK'S OWN line. Confirmed via a
518
+ // real-corpus fp (CWE89_SQL_Injection__*_15.java's goodB2G1/2) that
519
+ // `_trailingIdentIsLiteral` correctly found the tainted socket-read
520
+ // value (so the literal check alone could never suppress this — the
521
+ // value genuinely IS tainted, just unreachable) before this fix.
522
+ if (deadRanges.length && isLineInDeadRange(line, deadRanges)) continue;
523
+ if (_trailingIdentIsLiteral(code, m[1], m.index, deadRanges)) continue;
524
+ emit(key, line, META[key]);
525
+ }
52
526
  }
53
527
 
54
528
  // Path traversal (CWE-22): a file path built by concatenation, unless the
@@ -66,9 +540,28 @@ export function scanJavaStructural(fp, raw) {
66
540
 
67
541
  // SSRF (CWE-918): new URL/URI opened from a non-literal/templated value,
68
542
  // unless a host allow/deny guard is present.
543
+ //
544
+ // SARD_80_F1 W4.J22/W4.J24 — SSRF_SINK previously matched the mere
545
+ // CONSTRUCTION of a URL/URI object, with no check that it's ever actually
546
+ // used to open an outbound connection. `new URI(data)` is a completely
547
+ // ordinary way to VALIDATE a string's syntax (catch URISyntaxException)
548
+ // before doing something else entirely with it — e.g. Juliet's own
549
+ // CWE-601 (Open Redirect) test cases build a `URI` purely to reject a
550
+ // malformed redirect target, then call `response.sendRedirect(data)`,
551
+ // never opening any connection — confirmed directly against the public
552
+ // mirror (UnitTestBot/juliet-java-test-suite,
553
+ // CWE601_Open_Redirect__Servlet_File_53d.java). That construction-only
554
+ // shape was scoring as unrelated-CWE noise on every Juliet CWE-601 file
555
+ // that validates a URI this way. Now requires evidence of an outbound
556
+ // connection call somewhere in the file — file-scoped (like SSRF_GUARD
557
+ // already is) rather than proximity-scoped, since a real SSRF sink and
558
+ // its `new URL(...)` construction are frequently on the very same line
559
+ // anyway (`new URL(url).openStream()...`, this detector's own positive
560
+ // test fixture).
69
561
  const SSRF_SINK = /\bnew\s+(?:URL|URI)\s*\(\s*(?:[A-Za-z_]\w*\s*\)|"[^"\n]*"\s*\+)/;
562
+ const SSRF_CONNECT = /\.\s*openConnection\s*\(|\.\s*openStream\s*\(|\.\s*getContent\s*\(|\.\s*connect\s*\(\s*\)|\bHttpURLConnection\b|\bHttpClient\b/;
70
563
  const SSRF_GUARD = /169\.254\.169\.254|getHost\s*\(\s*\)|allow(?:ed)?Hosts?|isLoopback|isSiteLocal|isLinkLocal|InetAddress|\bDENY\b|deny(?:list)?|block(?:list|ed)/i;
71
- if (SSRF_SINK.test(code) && !SSRF_GUARD.test(code)) {
564
+ if (SSRF_SINK.test(code) && SSRF_CONNECT.test(code) && !SSRF_GUARD.test(code)) {
72
565
  const line = lineOf(code, code.search(SSRF_SINK));
73
566
  emit('ssrf', line, {
74
567
  vuln: 'SSRF — URL/URI opened from a non-literal value (Java)',