@clear-capabilities/agentic-security-scanner 0.151.2 → 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 (50) hide show
  1. package/CHANGELOG.md +184 -0
  2. package/dist/1905.index.js +30 -2
  3. package/dist/3499.index.js +20 -1
  4. package/dist/agentic-security.mjs +80 -80
  5. package/dist/agentic-security.mjs.sha256 +1 -1
  6. package/package.json +8 -4
  7. package/src/dataflow/CLAUDE.md +1 -1
  8. package/src/dataflow/catalog.js +842 -17
  9. package/src/dataflow/engine.js +865 -90
  10. package/src/dataflow/incremental.js +13 -0
  11. package/src/dataflow/index.js +18 -5
  12. package/src/dataflow/path-feasibility.js +86 -9
  13. package/src/dataflow/string-domain.js +132 -0
  14. package/src/dataflow/summaries.js +69 -8
  15. package/src/discovery/THREAT_MODEL.md +122 -0
  16. package/src/discovery/disprove.js +10 -1
  17. package/src/discovery/lenses.js +20 -1
  18. package/src/engine.js +108 -3
  19. package/src/ir/CLAUDE.md +2 -1
  20. package/src/ir/balanced-call.js +33 -1
  21. package/src/ir/callgraph.js +26 -2
  22. package/src/ir/class-hierarchy.js +119 -7
  23. package/src/ir/csharp-ir.js +97 -8
  24. package/src/ir/index.js +21 -1
  25. package/src/ir/parser-cs.js +706 -24
  26. package/src/ir/parser-java.js +527 -24
  27. package/src/ir/parser-js.js +22 -0
  28. package/src/ir/parser-php.js +446 -43
  29. package/src/ir/parser-rust.js +1338 -0
  30. package/src/ir/php-include-merge.js +190 -0
  31. package/src/lineage/sink-registry.js +18 -0
  32. package/src/lineage/source-registry.js +23 -0
  33. package/src/posture/csharp-analysis.js +341 -43
  34. package/src/posture/finding-defaults.js +5 -1
  35. package/src/posture/fix-coverage.js +1 -0
  36. package/src/posture/proof-coverage.js +1 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
@@ -54,7 +54,9 @@ const SETX_RE = /\.\s*set(?:String|Int|Long|Object|Date|Timestamp|Boolean|Float|
54
54
  // ─── New-rule patterns ────────────────────────────────────────────────────
55
55
 
56
56
  // CWE-601: response.sendRedirect(<tainted-or-non-literal>)
57
- const SEND_REDIRECT_RE = /\b(?:response|resp|res)\s*\.\s*sendRedirect\s*\(\s*([^)]+)\)/g;
57
+ // Exported for java-structural-cross-file.js (W5.42) — the same sink
58
+ // matching, reused to re-derive candidate findings for cross-file checking.
59
+ export const SEND_REDIRECT_RE = /\b(?:response|resp|res)\s*\.\s*sendRedirect\s*\(\s*([^)]+)\)/g;
58
60
 
59
61
  // CWE-319: cleartext transmission of sensitive information.
60
62
  //
@@ -75,6 +77,18 @@ const SEND_REDIRECT_RE = /\b(?:response|resp|res)\s*\.\s*sendRedirect\s*\(\s*([^
75
77
  const INSECURE_URL_LITERAL_RE = /\bnew\s+URL\s*\(\s*"http:\/\/[^"]*"\s*\)/g;
76
78
  const INSECURE_URL_CONCAT_RE = /\bnew\s+URL\s*\(\s*"http:\/\/[^"]*"\s*\+\s*\w/g;
77
79
  const RAW_SOCKET_RE = /\bnew\s+Socket\s*\(\s*[^)]+\)/g;
80
+ // SARD_80_F1 W4.J29 — `RAW_SOCKET_RE` only matches the CLIENT side
81
+ // (`new Socket(host, port)`); Juliet's `listen_tcp_*` descriptor family
82
+ // (confirmed against the public mirror,
83
+ // `CWE319_Cleartext_Tx_Sensitive_Info__listen_tcp_driverManager_01.java`)
84
+ // is the SERVER side instead — `ServerSocket listener = new
85
+ // ServerSocket(port); Socket socket = listener.accept();` — which never
86
+ // constructs a `Socket` directly at all, so it was a total blackout for
87
+ // this whole descriptor family (82 of 176 CWE-319 test-split entries,
88
+ // confirmed by grouping the false negatives by descriptor base name).
89
+ // `.accept()` always returns a `Socket`, the identical "got a raw,
90
+ // unencrypted socket" moment `new Socket(...)` already fires on.
91
+ const SERVERSOCKET_ACCEPT_RE = /\.accept\s*\(\s*\)/g;
78
92
 
79
93
  // "Sensitive-data context" — file contains any of these identifiers.
80
94
  // Variable names like `password`, `passwd`, `secret`, `token`, `cred`, etc.
@@ -93,6 +107,467 @@ const SENSITIVE_COOKIE_RE = /\bnew\s+Cookie\s*\(\s*"(?:session|sess|token|auth|j
93
107
  // Match `new Cookie(literal, NON_LITERAL_VAR)` regardless of cookie name.
94
108
  const RESPONSE_SPLITTING_COOKIE_RE = /\bnew\s+Cookie\s*\(\s*"[^"]*"\s*,\s*([A-Za-z_]\w*)\s*\)/g;
95
109
 
110
+ // CWE-259: hard-coded password reaching a credential parameter. Juliet's
111
+ // canonical shape (confirmed via the public mirror, all three sink variants):
112
+ // a String variable is set to a HARDCODED LITERAL, then used — by NAME, not
113
+ // inline — as the password/credential argument of one of these APIs. This is
114
+ // the inverse of ordinary taint detection: the finding fires when the value
115
+ // is PROVABLY a constant, not when it's tainted, so the taint engine's own
116
+ // sink-matching (which fires on TAINTED args) structurally cannot express it.
117
+ //
118
+ // DriverManager.getConnection(url, user, data) — data used bare
119
+ // new KerberosKey(principal, data.toCharArray(), ...) — data.toCharArray()
120
+ // new PasswordAuthentication(user, data.toCharArray())— data.toCharArray()
121
+ //
122
+ // Each regex captures the credential identifier (stripping a trailing
123
+ // `.toCharArray()` where present).
124
+ const HARDCODED_PW_DRIVERMANAGER_RE = /\bDriverManager\s*\.\s*getConnection\s*\([^,]+,[^,]+,\s*([A-Za-z_]\w*)\s*\)/g;
125
+ const HARDCODED_PW_KERBEROSKEY_RE = /\bnew\s+KerberosKey\s*\([^,]+,\s*([A-Za-z_]\w*)\s*\.\s*toCharArray\s*\(\s*\)/g;
126
+ const HARDCODED_PW_PASSWORDAUTH_RE = /\bnew\s+PasswordAuthentication\s*\([^,]+,\s*([A-Za-z_]\w*)\s*\.\s*toCharArray\s*\(\s*\)/g;
127
+
128
+ /**
129
+ * True when the NEAREST assignment to `varName` before `beforeIdx` (source
130
+ * order, not lexical scope — see the header comment above `scanJavaBenchExtras`'s
131
+ * CWE-259 block for why a backward nearest-assignment scan is enough to stay
132
+ * correctly scoped to the enclosing method without a full method-boundary
133
+ * parse) is a string-literal assignment, not a call/variable/concat RHS.
134
+ * Deliberately conservative: a variable never assigned at all (`beforeIdx`
135
+ * before any assignment) returns false, same direction as every other
136
+ * evidence-required check in this codebase.
137
+ */
138
+ // SARD_80_F1 W4.J33 — Juliet's own "if(true){…} else{ x = null; }" / "if
139
+ // (false){ x = null; } else {…}" dead-code idiom (its own in-source comment:
140
+ // "INCIDENTAL: CWE 561 Dead Code, the code below will never run but ensure
141
+ // [x] is initialized before the Sink to avoid compiler errors") is used
142
+ // across dozens of CWEs and flow variants (confirmed via the public mirror
143
+ // — the SAME idiom this session already fixed for C#'s CFG at W4.C28,
144
+ // manifesting here as a text-scan bug instead of a CFG bug). This backward,
145
+ // text-order-only scan for "the nearest assignment before the sink" has no
146
+ // notion of dead code: `if (true) { data = "foo"; } else { data = null; }`
147
+ // puts the LITERAL assignment first and the DEAD `data = null;` second —
148
+ // textually LATER, so `lastAnyEnd` (computed from ANY assignment) lands on
149
+ // the dead one, `lastLiteralEnd !== lastAnyEnd` fails, and a genuinely safe
150
+ // goodG2B() variant using this exact idiom was never recognized as
151
+ // literal-only, leaving a real false positive uncorrected (confirmed via
152
+ // the public mirror's own `CWE601_Open_Redirect__Servlet_connect_tcp_02
153
+ // .java`'s `goodG2B2()`). Fixed by reusing the ALREADY-EXISTING, real
154
+ // AST-based `deadBranchRanges`/`isLineInDeadRange` (java-ast-folding.js,
155
+ // already used by `applyJavaBenchSuppressions` for a different purpose) to
156
+ // skip any assignment whose line falls inside a provably-dead branch when
157
+ // computing `lastAnyEnd` — the literal assignment inside the LIVE branch is
158
+ // then correctly recognized as the value that actually reaches the sink.
159
+ // `deadRanges` is optional (empty array when the caller has none, or on a
160
+ // parse failure) so every existing call site keeps its old, more
161
+ // conservative behavior unless it opts in.
162
+ // SARD_80_F1 W4.J37 — Juliet's "data returned from one method to another in
163
+ // the same class" flow variants (its own template naming: sources-sink-41+,
164
+ // the exact sibling idiom to the argument-passing one W4.J33's own header
165
+ // comment already documents) route a PROVABLY-literal value through a
166
+ // same-file helper's RETURN value instead of a direct assignment:
167
+ // `private String goodG2BSource() { return "foo"; } ... String data =
168
+ // goodG2BSource(); response.sendRedirect(data);` — confirmed against the
169
+ // public mirror's own `CWE601_Open_Redirect__Servlet_connect_tcp_42.java`.
170
+ // `_nearestAssignIsLiteral`'s own backward scan sees `data = goodG2BSource()`
171
+ // as a NON-literal "any assignment" (the RHS is a call, not a quoted
172
+ // string), so `lastLiteralEnd !== lastAnyEnd` correctly-by-its-own-rules
173
+ // fails closed — except the callee ALWAYS returns a literal, so the value
174
+ // genuinely is safe. Deliberately narrow and bounded, matching this file's
175
+ // existing conservative-evidence-required convention: only a callee with
176
+ // EXACTLY ONE `return` statement in its own body is resolved (multiple
177
+ // returns are ambiguous — fails closed, never guessed); the returned
178
+ // expression must itself be a literal OR a bare identifier that recurses
179
+ // through this SAME resolution (bounded by `_depth`, matching the codebase's
180
+ // established recursion-guard convention elsewhere) — never a call, concat,
181
+ // or ternary, which all fail closed. Same-file only: `content` is always
182
+ // this one file's own text, so there is no cross-file resolution risk here.
183
+ const _CALLEE_RETURN_LITERAL_CACHE_DEPTH = 4;
184
+ function _resolveCalleeReturnIsLiteral(content, calleeName, deadRanges, _depth) {
185
+ const depth = _depth || 0;
186
+ if (depth >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
187
+ const escapedCallee = calleeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
188
+ // W4.J39 — a confirmed, severe ReDoS: the ORIGINAL pattern here matched a
189
+ // "return type" prefix via a LAZY character class that itself included
190
+ // `\s` (`[\w.<>\[\],\s]+?`). Comment-blanked content (`scanJavaBenchExtras`
191
+ // always calls `_resolveCalleeReturnIsLiteral` — and everything downstream
192
+ // of it — with `blankComments(raw)`, never `raw`) turns Juliet's own
193
+ // large header comment blocks into a LONG run of blanked whitespace, and
194
+ // a lazy quantifier whose OWN character class includes `\s` backtracks
195
+ // catastrophically trying every possible split point inside that run —
196
+ // confirmed via direct reproduction (hangs indefinitely on a real, small
197
+ // 8KB corpus file; the exact same shape hung a live full-corpus scan for
198
+ // 30+ minutes on a single small CWE before it was caught and killed).
199
+ // Fixed by dropping the "return type" prefix match ENTIRELY: since the
200
+ // callee NAME is already known, a bare `\bcalleeName\s*(...) {` search
201
+ // is exactly as identifying (a call site is never immediately followed
202
+ // by `{`) without ever touching a `\s`-inclusive lazy quantifier.
203
+ const declRe = new RegExp(`\\b${escapedCallee}\\s*\\([^)]*\\)\\s*(?:throws\\s+[\\w.,\\s]+)?\\s*\\{`, 'g');
204
+ const dm = declRe.exec(content);
205
+ if (!dm) return false;
206
+ const bodyStart = dm.index + dm[0].length;
207
+ let braceDepth = 1, i = bodyStart;
208
+ while (i < content.length && braceDepth > 0) {
209
+ if (content[i] === '{') braceDepth++;
210
+ else if (content[i] === '}') braceDepth--;
211
+ i++;
212
+ }
213
+ const body = content.slice(bodyStart, i - 1);
214
+ const returnRe = /\breturn\s+([^;]+);/g;
215
+ const returns = [];
216
+ let rm;
217
+ while ((rm = returnRe.exec(body))) returns.push({ expr: rm[1].trim(), idx: bodyStart + rm.index });
218
+ if (returns.length !== 1) return false; // ambiguous (0 or 2+ returns) — fail closed
219
+ const { expr, idx } = returns[0];
220
+ if (/^"[^"]*"$/.test(expr)) return true;
221
+ if (/^[A-Za-z_]\w*$/.test(expr)) {
222
+ return _nearestAssignIsLiteral(content, expr, idx, deadRanges, depth + 1);
223
+ }
224
+ return false;
225
+ }
226
+
227
+ // Balanced top-level comma split (paren/bracket/brace-aware, quote-aware —
228
+ // an argument like `"a, b"` or `foo(1, 2)` must not be split on its OWN
229
+ // internal commas). Deliberately simple: no need to track Java generics'
230
+ // `<...>` nesting specifically, since angle brackets never appear in a
231
+ // plain call argument list the way they do in a type declaration.
232
+ function _splitTopLevelCommas(text) {
233
+ const parts = [];
234
+ let depth = 0, cur = '', inStr = null;
235
+ for (let i = 0; i < text.length; i++) {
236
+ const c = text[i];
237
+ if (inStr) {
238
+ cur += c;
239
+ if (c === '\\') { cur += text[++i] || ''; continue; }
240
+ if (c === inStr) inStr = null;
241
+ continue;
242
+ }
243
+ if (c === '"' || c === "'") { inStr = c; cur += c; continue; }
244
+ if (c === '(' || c === '[' || c === '{') depth++;
245
+ else if (c === ')' || c === ']' || c === '}') depth--;
246
+ if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
247
+ cur += c;
248
+ }
249
+ if (cur.trim() !== '' || parts.length) parts.push(cur);
250
+ return parts;
251
+ }
252
+
253
+ // SARD_80_F1 W4.J38 — the sibling gap to W4.J33's own parameter-boundary
254
+ // guard (see that guard's own header comment on the "data passed as an
255
+ // argument from one method to another" idiom). That guard assumes the
256
+ // CALLER's own literal assignment appears textually BEFORE the callee
257
+ // method in the file — the common case — but Juliet's own file layout does
258
+ // NOT guarantee this: confirmed via the public mirror's own
259
+ // `CWE601_Open_Redirect__Servlet_connect_tcp_41.java`, where `goodG2BSink`
260
+ // (the method actually containing the sink call) is DEFINED BEFORE its own
261
+ // caller `goodG2B` (which supplies the literal argument) — so `goodG2B`'s
262
+ // `data = "foo";` sits AFTER `goodG2BSink`'s own sink call in raw file
263
+ // text, structurally invisible to ANY backward-only scan, however it's
264
+ // tuned. Rather than trying to special-case "look forward too" inside the
265
+ // backward scan above, this takes a completely different, WHOLE-FILE
266
+ // approach once the backward scan has already failed: find the METHOD that
267
+ // encloses `beforeIdx`, determine `varName`'s own PARAMETER POSITION in
268
+ // that method's signature, then check every real call site of that method
269
+ // (matched by bare name — Juliet's own same-class private-helper
270
+ // convention, mirroring `_resolveCalleeReturnIsLiteral`'s identical same-
271
+ // file-only scope) for a literal at the SAME argument position. Fails
272
+ // closed at every step: zero real call sites found, an arity mismatch, or
273
+ // ANY call site supplying a non-literal at that position all return
274
+ // `false` — this only ever ADDS a positive signal when literally every
275
+ // known caller agrees, never guesses from a single ambiguous data point.
276
+ // The declared class name of `content` itself — see java-structural.js's
277
+ // own identical copy of this helper (W5.41/W5.42) for the full rationale.
278
+ function _ownClassName(content) {
279
+ const m = /\bclass\s+([A-Za-z_]\w*)/.exec(content);
280
+ return m ? m[1] : null;
281
+ }
282
+
283
+ // See java-structural.js's own identical copy of this helper (W5.45) for
284
+ // the full rationale.
285
+ function _enclosingMethodSpan(text, idx) {
286
+ 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;
287
+ let best = null, m;
288
+ while ((m = methodDeclRe.exec(text)) && m.index < idx) best = m;
289
+ if (!best) return null;
290
+ const openIdx = best.index + best[0].length - 1;
291
+ let depth = 1, i = openIdx + 1;
292
+ while (i < text.length && depth > 0) {
293
+ if (text[i] === '{') depth++;
294
+ else if (text[i] === '}') depth--;
295
+ i++;
296
+ }
297
+ if (i <= idx) return null;
298
+ return { start: openIdx, end: i };
299
+ }
300
+
301
+ // SARD_80_F1 W5.42 — the SAME cross-file gap W5.41 fixed in java-structural.js,
302
+ // ported here: Juliet's Flow Variant 51+ ("data passed as an argument from
303
+ // one method to another in a DIFFERENT class") splits caller/callee across
304
+ // two physical files in the same directory, invisible to a same-file-only
305
+ // search. `siblingFiles` (optional; `{code, deadRanges}` for other `.java`
306
+ // files in the same directory) is a fallback consulted ONLY when the
307
+ // same-file search finds zero real call sites — `scanJavaBenchExtras`'s own
308
+ // ordinary call never supplies it, so its behavior is unchanged. The
309
+ // cross-file fallback ALSO requires the call site to construct THIS sink's
310
+ // own declared class (`new ClassName()).method(`), not just call a
311
+ // same-named method — see java-structural.js's own W5.41 header comment for
312
+ // why a bare method-name match is unsound here (Juliet reuses the same
313
+ // generic method names identically across thousands of unrelated files in
314
+ // the same directory, a collision blind scrambling preserves by design).
315
+ function _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _depth, siblingFiles) {
316
+ const depth0 = _depth || 0;
317
+ if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
318
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
319
+ // Excludes Java's control-flow keywords, which have the IDENTICAL
320
+ // `keyword (...) {` textual shape as a method declaration (`if (x) {`,
321
+ // `for (...) {`, `while (...) {`, `switch (...) {`, `catch (...) {`,
322
+ // `synchronized (...) {`) and would otherwise be picked up as a fake
323
+ // "enclosing method" — confirmed the hard way: a bare `if (data != null)
324
+ // {` immediately inside a real sink method matched this regex FIRST,
325
+ // silently replacing the genuine enclosing method as `enclosing`.
326
+ //
327
+ // W4.J39 — a confirmed, severe ReDoS, the SAME defect class as
328
+ // `_resolveCalleeReturnIsLiteral`'s own `declRe` (see that function's own
329
+ // fix comment for the full reproduction): the ORIGINAL pattern here ALSO
330
+ // matched a "return type" prefix via a LAZY, `\s`-inclusive character
331
+ // class (`[\w.<>[\],\s]+?`), which backtracks catastrophically over the
332
+ // long blanked-whitespace runs `blankComments()` leaves where Juliet's
333
+ // own large header comments used to be. Fixed the same way: the "return
334
+ // type" prefix is never actually READ by this function (only the
335
+ // captured NAME and PARAMS matter), so it's dropped entirely — a bare
336
+ // `\b(?!keyword)IDENT\s*(...) {` search identifies a method declaration
337
+ // exactly as well, without ever touching a `\s`-inclusive lazy quantifier.
338
+ 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;
339
+ let enclosing = null, dm;
340
+ while ((dm = methodDeclRe.exec(content)) && dm.index < beforeIdx) enclosing = dm;
341
+ if (!enclosing) return false;
342
+ const methodName = enclosing[1];
343
+ const params = _splitTopLevelCommas(enclosing[2]).map(p => p.trim()).filter(Boolean);
344
+ const paramIdx = params.findIndex((p) => new RegExp(`\\b${escapedVar}$`).test(p));
345
+ if (paramIdx === -1) return false; // varName isn't actually a param of the enclosing method
346
+ const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
347
+
348
+ function _checkCallAt(searchText, searchDeadRanges, matchIndex, matchEnd) {
349
+ const argsStart = matchEnd;
350
+ let braceDepth = 1, i = argsStart;
351
+ while (i < searchText.length && braceDepth > 0) {
352
+ if (searchText[i] === '(') braceDepth++;
353
+ else if (searchText[i] === ')') braceDepth--;
354
+ i++;
355
+ }
356
+ const afterClose = searchText.slice(i).match(/^\s*(\{|throws)/);
357
+ if (afterClose) return 'skip'; // this is the method's OWN declaration, not a call site
358
+ const args = _splitTopLevelCommas(searchText.slice(argsStart, i - 1)).map((a) => a.trim());
359
+ if (paramIdx >= args.length) return 'fail'; // arity mismatch — bail conservatively
360
+ const argExpr = args[paramIdx];
361
+ if (/^"[^"]*"$/.test(argExpr)) return 'ok'; // this caller's own argument is a direct literal
362
+ // The caller's argument is itself a bare identifier (Juliet's own
363
+ // idiom: `data = "foo"; goodG2BSink(data, ...);` — the LITERAL sits on
364
+ // the variable one step back from the call site, not at the call site
365
+ // itself). Resolve it the SAME way any other candidate literal is
366
+ // resolved — recursing through `_nearestAssignIsLiteral` itself,
367
+ // scoped to the CALL SITE's own position. Bounded by the shared
368
+ // recursion-depth guard.
369
+ if (/^[A-Za-z_]\w*$/.test(argExpr) && _nearestAssignIsLiteral(searchText, argExpr, matchIndex, searchDeadRanges, depth0 + 1, siblingFiles)) return 'ok';
370
+ return 'fail'; // a non-literal (or unresolvable) caller exists
371
+ }
372
+
373
+ function scanCallSitesIn(searchText, searchDeadRanges, classNameFilter) {
374
+ let sawRealCallSite = false;
375
+ if (classNameFilter) {
376
+ const inlineRe = new RegExp(`\\bnew\\s+${classNameFilter}\\s*\\(\\s*\\)\\s*\\)?\\s*\\.\\s*${escapedMethod}\\s*\\(`, 'g');
377
+ let cm;
378
+ while ((cm = inlineRe.exec(searchText))) {
379
+ const r = _checkCallAt(searchText, searchDeadRanges, cm.index, cm.index + cm[0].length);
380
+ if (r === 'skip') continue;
381
+ if (r === 'fail') return 'fail';
382
+ sawRealCallSite = true;
383
+ }
384
+ // SARD_80_F1 W5.45 — see java-structural.js's own identical fix for
385
+ // the full incident writeup: Juliet's abstract-dispatch caller
386
+ // convention is a TWO-STATEMENT pattern (`<BaseType> <var> = new
387
+ // <ConcreteClass>();` then later `<var>.<method>(...)`), not the
388
+ // inline one above. The variable's own call search is bounded to its
389
+ // DECLARING method's own body span (via `_enclosingMethodSpan`)
390
+ // since Juliet routinely reuses the same local variable name
391
+ // ("baseObject") across sibling bad()/goodG2B()/goodB2G() methods.
392
+ const declRe = new RegExp(`\\b[A-Za-z_]\\w*(?:<[^;()]*>)?\\s+([A-Za-z_]\\w*)\\s*=\\s*new\\s+${classNameFilter}\\s*\\(\\s*\\)\\s*;`, 'g');
393
+ let dm;
394
+ while ((dm = declRe.exec(searchText))) {
395
+ const varName2 = dm[1];
396
+ const span = _enclosingMethodSpan(searchText, dm.index);
397
+ const searchWithin = span ? searchText.slice(span.start, span.end) : searchText;
398
+ const offset = span ? span.start : 0;
399
+ const escapedVarName = varName2.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
400
+ const varCallRe = new RegExp(`\\b${escapedVarName}\\s*\\.\\s*${escapedMethod}\\s*\\(`, 'g');
401
+ let vm;
402
+ while ((vm = varCallRe.exec(searchWithin))) {
403
+ const r = _checkCallAt(searchText, searchDeadRanges, offset + vm.index, offset + vm.index + vm[0].length);
404
+ if (r === 'skip') continue;
405
+ if (r === 'fail') return 'fail';
406
+ sawRealCallSite = true;
407
+ }
408
+ }
409
+ } else {
410
+ const callRe = new RegExp(`\\b${escapedMethod}\\s*\\(`, 'g');
411
+ let cm;
412
+ while ((cm = callRe.exec(searchText))) {
413
+ const r = _checkCallAt(searchText, searchDeadRanges, cm.index, cm.index + cm[0].length);
414
+ if (r === 'skip') continue;
415
+ if (r === 'fail') return 'fail';
416
+ sawRealCallSite = true;
417
+ }
418
+ }
419
+ return sawRealCallSite ? 'ok' : 'none';
420
+ }
421
+
422
+ const sameFileResult = scanCallSitesIn(content, deadRanges, null);
423
+ if (sameFileResult === 'fail') return false;
424
+ let sawRealCallSite = sameFileResult === 'ok';
425
+ if (siblingFiles && siblingFiles.length) {
426
+ const ownClass = _ownClassName(content);
427
+ if (ownClass) {
428
+ const escapedClass = ownClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
429
+ for (const sib of siblingFiles) {
430
+ const r = scanCallSitesIn(sib.code, sib.deadRanges, escapedClass);
431
+ if (r === 'fail') return false;
432
+ if (r === 'ok') sawRealCallSite = true;
433
+ }
434
+ }
435
+ }
436
+ return sawRealCallSite;
437
+ }
438
+
439
+ // SARD_80_F1 W5.34 — see the call site's own header comment for the full
440
+ // idiom (Juliet Flow Variant 45: a value passed as a private class member
441
+ // variable between two methods of the same class). Only resolves when
442
+ // `varName` is confirmed to be an actual FIELD declaration — matched via an
443
+ // access-modifier prefix (`private`/`protected`/`public`), syntax that is
444
+ // valid ONLY on a class/interface member in Java, never on a local variable
445
+ // or a method parameter, so this can't misfire on an unrelated same-named
446
+ // local. Requires EVERY assignment to the field anywhere in the file
447
+ // (statement order doesn't matter here — unlike a backward scan, this reads
448
+ // the field as a whole-file invariant) to resolve to a literal, recursing
449
+ // through the same `_nearestAssignIsLiteral` machinery for a bare-identifier
450
+ // RHS (`dataGoodG2B = data;`, itself resolved at ITS OWN position so the
451
+ // recursive scan only sees what was visible at the time of that assignment).
452
+ // Fails closed: no recognizable field declaration, zero assignments found,
453
+ // or ANY assignment resolving to something other than a literal all return
454
+ // false.
455
+ function _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _depth) {
456
+ const depth0 = _depth || 0;
457
+ if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
458
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
459
+ const fieldDeclRe = new RegExp(`\\b(?:private|protected|public)\\s+[\\w<>\\[\\],.\\s]+\\b${escaped}\\s*(?:=\\s*[^;]+)?;`);
460
+ if (!fieldDeclRe.test(content)) return false; // not a recognizable field declaration
461
+ const ranges = deadRanges || [];
462
+ const assignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
463
+ const lineOf = (idx) => content.substring(0, idx).split('\n').length;
464
+ let sawAssignment = false, m;
465
+ while ((m = assignRe.exec(content))) {
466
+ if (ranges.length && isLineInDeadRange(lineOf(m.index), ranges)) continue;
467
+ sawAssignment = true;
468
+ const rhs = m[1].trim();
469
+ if (/^"[^"]*"$/.test(rhs)) continue; // this assignment is a direct literal
470
+ if (/^[A-Za-z_]\w*$/.test(rhs) && rhs !== varName
471
+ && _nearestAssignIsLiteral(content, rhs, m.index, ranges, depth0 + 1)) continue;
472
+ return false; // a non-literal (or unresolvable) assignment exists somewhere
473
+ }
474
+ return sawAssignment;
475
+ }
476
+
477
+ // Exported for java-structural-cross-file.js (W5.42) — see
478
+ // _resolveParamLiteralViaAllCallSites's own header comment for `siblingFiles`.
479
+ export function _nearestAssignIsLiteral(content, varName, beforeIdx, deadRanges, _calleeDepth, siblingFiles) {
480
+ if ((_calleeDepth || 0) >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
481
+ const ranges = deadRanges || [];
482
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
483
+ const literalRe = new RegExp(`\\b${escaped}\\s*=\\s*"[^"]*"\\s*;`, 'g');
484
+ const anyAssignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
485
+ const lineOf = (idx) => content.substring(0, idx).split('\n').length;
486
+ let lastLiteralEnd = -1, m;
487
+ while ((m = literalRe.exec(content)) && m.index < beforeIdx) {
488
+ if (ranges.length && isLineInDeadRange(lineOf(m.index), ranges)) continue;
489
+ lastLiteralEnd = m.index + m[0].length;
490
+ }
491
+ let lastAnyEnd = -1, lastAnyRhs = null, lastAnyIdx = -1;
492
+ while ((m = anyAssignRe.exec(content)) && m.index < beforeIdx) {
493
+ if (ranges.length && isLineInDeadRange(lineOf(m.index), ranges)) continue;
494
+ lastAnyEnd = m.index + m[0].length;
495
+ lastAnyRhs = m[1].trim();
496
+ lastAnyIdx = m.index;
497
+ }
498
+ // W4.J37 — the nearest assignment's own RHS is a bare same-file call
499
+ // (`data = goodG2BSource();`, no receiver, no args needing evaluation):
500
+ // resolve whether that callee ALWAYS returns a literal before falling
501
+ // through to the stricter "must equal the nearest literal assignment"
502
+ // check below, which a call-shaped RHS can never satisfy on its own.
503
+ if (lastAnyRhs && /^[A-Za-z_]\w*\s*\([^()]*\)$/.test(lastAnyRhs)) {
504
+ const calleeName = lastAnyRhs.slice(0, lastAnyRhs.indexOf('(')).trim();
505
+ if (_resolveCalleeReturnIsLiteral(content, calleeName, ranges, _calleeDepth)) return true;
506
+ }
507
+ // W4.J38 — the nearest assignment's own RHS is a bare identifier "copy"
508
+ // of ANOTHER variable (`dataCopy = data;` … later `String data =
509
+ // dataCopy;`) — Juliet's own "make a copy of data within the same
510
+ // method" flow variant. Resolve the COPIED-FROM variable the same way,
511
+ // recursively, at THIS assignment's own position (not `beforeIdx`) so the
512
+ // recursive scan sees only what was visible AT THE TIME of the copy, not
513
+ // anything assigned later. Bounded by the shared recursion-depth guard;
514
+ // excludes `null` explicitly (never a candidate literal — mirrors
515
+ // ldap-injection.js's own W4.C37 precedent for the identical reasoning).
516
+ if (lastAnyRhs && lastAnyRhs !== 'null' && /^[A-Za-z_]\w*$/.test(lastAnyRhs) && lastAnyRhs !== varName) {
517
+ if (_nearestAssignIsLiteral(content, lastAnyRhs, lastAnyIdx, ranges, (_calleeDepth || 0) + 1, siblingFiles)) return true;
518
+ }
519
+ if (lastLiteralEnd === -1 || lastLiteralEnd !== lastAnyEnd) {
520
+ // W4.J38 — before failing closed, check whether `varName` is a
521
+ // PARAMETER of the method enclosing `beforeIdx` and, if so, whether
522
+ // EVERY real call site of that method supplies a literal at the
523
+ // matching argument position. See _resolveParamLiteralViaAllCallSites's
524
+ // own header comment for why this is a DIFFERENT idiom than the
525
+ // paramRe guard below (a caller whose own literal sits AFTER the sink
526
+ // method in raw file text — invisible to any backward-only scan).
527
+ //
528
+ // W5.34 — a SIBLING idiom to both W4.J38 shapes above: Juliet's own
529
+ // "Flow Variant 45: data passed as a private class member variable from
530
+ // one function to another in the same class" (confirmed via the public
531
+ // mirror's own CWE80_XSS__CWE182_Servlet_getQueryString_Servlet_45.java)
532
+ // writes `dataGoodG2B = data;` in ONE method and reads it back via
533
+ // `String data = dataGoodG2B;` in a DIFFERENT method. The read's own
534
+ // recursive resolution correctly identifies `dataGoodG2B` as a bare
535
+ // identifier and recurses into it — but if the WRITER method
536
+ // (`goodG2B()`) is textually declared AFTER the reader method
537
+ // (`goodG2BSink()`), which Juliet's own file layout does not guarantee
538
+ // either way, the field's only assignment sits AFTER `beforeIdx` and is
539
+ // invisible to a backward-only scan, exactly the same "forward
540
+ // reference" problem `_resolveParamLiteralViaAllCallSites` exists to
541
+ // solve for parameters — just for a FIELD instead of an argument.
542
+ // `_resolveFieldLiteralViaAllAssignments` is that same fix for fields:
543
+ // it only fires when `varName` is confirmed to be an actual field
544
+ // declaration (an access-modifier-qualified declaration, syntax no
545
+ // local variable or parameter can carry), then requires EVERY
546
+ // assignment to it anywhere in the file to resolve to a literal.
547
+ return _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _calleeDepth, siblingFiles)
548
+ || _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _calleeDepth);
549
+ }
550
+ // Juliet's "data passed as an argument from one method to another" flow
551
+ // variants (its own template naming: sources-sink-41+) sink INSIDE A
552
+ // HELPER method that receives the value as a formal PARAMETER, not a
553
+ // local assignment — `private void goodG2BSink(String data) { …
554
+ // DriverManager.getConnection(url, user, data); }`. A backward scan for
555
+ // "nearest assignment to `data`" then crosses OUT of that helper and into
556
+ // whichever CALLER happens to have last assigned a same-named `data`
557
+ // textually earlier in the file — frequently `bad()`'s own hardcoded
558
+ // literal, even when the ACTUAL call reaching this specific helper came
559
+ // from the SAFE `goodG2B()` path. A parameter declaration for `varName`
560
+ // that is MORE RECENT than the literal assignment means we've crossed a
561
+ // method boundary the assignment can't have followed us through, so the
562
+ // value here is actually unknown (determined by whichever caller this
563
+ // is) — fails closed, same direction as every other evidence-required
564
+ // check in this file.
565
+ const paramRe = new RegExp(`\\([^()]*\\b[\\w.<>\\[\\]]+\\s+${escaped}\\s*[,)]`, 'g');
566
+ let lastParamEnd = -1;
567
+ while ((m = paramRe.exec(content)) && m.index < beforeIdx) lastParamEnd = m.index + m[0].length;
568
+ return lastParamEnd <= lastLiteralEnd;
569
+ }
570
+
96
571
  // Generic tainted-context indicator: file contains a known source.
97
572
  // Includes Juliet's connect_tcp / Environment / Property variants.
98
573
  const TAINTED_CONTEXT_RE = /\bSystem\.getenv\s*\(|\bSystem\.getProperty\s*\(|\brequest\s*\.\s*get(?:Parameter|Header|InputStream|Reader|QueryString|Cookies)\b|\bnew\s+Socket\s*\(|\b\w+\s*\.\s*getInputStream\s*\(\s*\)|\.readLine\s*\(\s*\)/;
@@ -511,6 +986,11 @@ export function scanJavaBenchExtras(file, raw) {
511
986
  if (!JAVA_EXT.test(file) || !raw || raw.length > 500_000) return [];
512
987
  const content = blankComments(raw);
513
988
  const findings = [];
989
+ // See _nearestAssignIsLiteral's own header comment (W4.J33): lets that
990
+ // helper skip an assignment inside a provably-dead if(true)/if(false)
991
+ // branch when deciding whether the value reaching a sink is literal-only.
992
+ let deadRanges = [];
993
+ try { deadRanges = deadBranchRanges(raw); } catch { /* parse error → no AST info */ }
514
994
 
515
995
  function lineOf(idx) { return content.substring(0, idx).split('\n').length; }
516
996
  function isTainted(arg) { return TAINTED_HINT.test(arg); }
@@ -521,8 +1001,24 @@ export function scanJavaBenchExtras(file, raw) {
521
1001
  let m;
522
1002
  while ((m = SEND_REDIRECT_RE.exec(content))) {
523
1003
  const arg = (m[1] || '').trim();
1004
+ // SARD_80_F1 W5.35 — a sink call sitting INSIDE a provably-dead branch
1005
+ // (Juliet's "switch(8){case 7: <tainted sendRedirect>}" idiom, Flow
1006
+ // Variant 15's goodB2G shapes — case 7 never matches scrutinee 8) is
1007
+ // unreachable regardless of whether the argument is tainted: the
1008
+ // literal-check below can never suppress it, since the value genuinely
1009
+ // IS tainted, just unreachable. `deadRanges` was already computed for
1010
+ // that literal check; nothing had checked it against the SINK's own
1011
+ // line until now.
1012
+ if (deadRanges.length && isLineInDeadRange(lineOf(m.index), deadRanges)) continue;
524
1013
  // Literal-only arg: suppress. Tainted-looking arg: flag.
525
1014
  if (/^"[^"]*"$/.test(arg)) continue; // pure literal — safe
1015
+ // Same literal-blindness class already fixed for SQLi/LDAP/XSS
1016
+ // (W4.J12/J13/J21), never ported here: Juliet's own convention keeps the
1017
+ // IDENTICAL `response.sendRedirect(data)` sink line in both `bad()` and
1018
+ // `goodG2B()`, only swapping `data`'s source — confirmed against the
1019
+ // public mirror (`CWE601_Open_Redirect__Servlet_PropertiesFile_01.java`:
1020
+ // `data = "foo";` in `goodG2B()`, then `response.sendRedirect(data);`).
1021
+ if (/^[A-Za-z_]\w*$/.test(arg) && _nearestAssignIsLiteral(content, arg, m.index, deadRanges)) continue;
526
1022
  findings.push({
527
1023
  id: id('java-extras:open-redirect', lineOf(m.index), m.index),
528
1024
  kind: 'sast',
@@ -571,12 +1067,18 @@ export function scanJavaBenchExtras(file, raw) {
571
1067
  }
572
1068
 
573
1069
  // Pattern C: raw outbound Socket reading sensitive data. Matches Juliet's
574
- // connect_tcp_* / listen_tcp_* / send_* CWE-319 variants.
1070
+ // connect_tcp_* / send_* CWE-319 variants.
575
1071
  if (fileHasSensitiveContext && fileHasSocketRead) {
576
1072
  RAW_SOCKET_RE.lastIndex = 0;
577
1073
  while ((m = RAW_SOCKET_RE.exec(content))) {
578
1074
  emitCwe319(lineOf(m.index), m.index, 'cleartext Socket with sensitive-data context');
579
1075
  }
1076
+ // Pattern C2: server-side ServerSocket.accept() — Juliet's listen_tcp_*
1077
+ // variants (see SERVERSOCKET_ACCEPT_RE's header comment).
1078
+ SERVERSOCKET_ACCEPT_RE.lastIndex = 0;
1079
+ while ((m = SERVERSOCKET_ACCEPT_RE.exec(content))) {
1080
+ emitCwe319(lineOf(m.index), m.index, 'cleartext ServerSocket.accept() with sensitive-data context');
1081
+ }
580
1082
  }
581
1083
 
582
1084
  // CWE-315 — sensitive Cookie without secure flag
@@ -653,6 +1155,32 @@ export function scanJavaBenchExtras(file, raw) {
653
1155
  }
654
1156
  }
655
1157
 
1158
+ // CWE-259 — hardcoded password reaching a credential parameter. See the
1159
+ // header comment above the regex constants for the three sink shapes and
1160
+ // why this fires on PROVABLE-CONSTANT values rather than tainted ones.
1161
+ const cwe259Lines = new Set();
1162
+ function emitHardcodedPassword(varName, matchIndex) {
1163
+ const L = lineOf(matchIndex);
1164
+ if (cwe259Lines.has(L)) return;
1165
+ if (!_nearestAssignIsLiteral(content, varName, matchIndex, deadRanges)) return;
1166
+ cwe259Lines.add(L);
1167
+ findings.push({
1168
+ id: id('java-extras:hardcoded-password', L, matchIndex),
1169
+ kind: 'sast',
1170
+ severity: 'high',
1171
+ vuln: 'Hardcoded Password used as credential',
1172
+ cwe: 'CWE-259', stride: 'Information Disclosure',
1173
+ file, line: L,
1174
+ snippet: content.substring(content.lastIndexOf('\n', matchIndex)+1, content.indexOf('\n', matchIndex)).trim().slice(0, 200),
1175
+ remediation: 'Never hardcode credentials. Load them from a secrets manager or environment variable at runtime.',
1176
+ });
1177
+ }
1178
+ for (const re of [HARDCODED_PW_DRIVERMANAGER_RE, HARDCODED_PW_KERBEROSKEY_RE, HARDCODED_PW_PASSWORDAUTH_RE]) {
1179
+ re.lastIndex = 0;
1180
+ let hm;
1181
+ while ((hm = re.exec(content))) emitHardcodedPassword(hm[1], hm.index);
1182
+ }
1183
+
656
1184
  return findings;
657
1185
  }
658
1186
 
@@ -81,6 +81,29 @@ import { blankComments } from './_comment-strip.js';
81
81
 
82
82
  function _lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
83
83
 
84
+ // SARD_80_F1_SCANNER_PRD.md Juliet Java audit: `hasOIS` (file-wide) plus a
85
+ // bare `\w+.readObject()` anywhere in the same file was measured at 122 FPs
86
+ // on real corpus fixtures — the file-wide check does not require the CALL's
87
+ // own receiver to be the tracked ObjectInputStream variable, so a file that
88
+ // merely constructs an OIS somewhere (even for a genuinely unrelated
89
+ // CWE's own source idiom) makes every OTHER bare `.readObject()` call in
90
+ // the file match too, regardless of that call's own receiver type. Track
91
+ // the actual variable name(s) assigned from `new ObjectInputStream(...)`
92
+ // and require the receiver to be one of them — real correlation between
93
+ // the construction and the call, not file-wide co-occurrence.
94
+ const OIS_VAR_RE = /\bObjectInputStream\s+(\w+)\s*=\s*new\s+ObjectInputStream\b|\b(\w+)\s*=\s*new\s+ObjectInputStream\b/g;
95
+
96
+ function _objectInputStreamVars(code) {
97
+ const vars = new Set();
98
+ let m;
99
+ const re = new RegExp(OIS_VAR_RE.source, OIS_VAR_RE.flags);
100
+ while ((m = re.exec(code))) {
101
+ const name = m[1] || m[2];
102
+ if (name) vars.add(name);
103
+ }
104
+ return vars;
105
+ }
106
+
84
107
  export function scanJavaDeserialization(fp, raw) {
85
108
  if (!/\.(?:java|kt|kts|scala|groovy|gradle)$/i.test(fp)) return [];
86
109
  if (!raw || raw.length > 500_000) return [];
@@ -89,6 +112,7 @@ export function scanJavaDeserialization(fp, raw) {
89
112
  // one, a bare .readObject() call is almost certainly RMI/JDBC/socket noise
90
113
  // unrelated to native serialization gadgets.
91
114
  const hasOIS = /\bnew\s+ObjectInputStream\b/.test(code);
115
+ const oisVars = _objectInputStreamVars(code);
92
116
  // Pre-pass for XStream — gate the broad `.fromXML(` regex.
93
117
  const hasXStream = /\bXStream\b|\bxstream\.|com\.thoughtworks\.xstream/.test(code);
94
118
  const findings = [];
@@ -104,8 +128,13 @@ export function scanJavaDeserialization(fp, raw) {
104
128
  if (p.requireOIS && !hasOIS) continue;
105
129
  // For the ObjectInputStream pattern, the second branch (bare \w+.readObject)
106
130
  // only fires when there's an OIS in the file AND the method receiver isn't
107
- // obviously a JDBC ResultSet (rs/results/rset names).
108
- if (p.requireOIS && m[1] && /^(?:rs|result|results|rset|resultset)$/i.test(m[1])) continue;
131
+ // obviously a JDBC ResultSet (rs/results/rset names) AND the receiver is
132
+ // actually a variable this file constructed as an ObjectInputStream —
133
+ // real correlation, not just file-wide co-occurrence (see oisVars above).
134
+ if (p.requireOIS && m[1]) {
135
+ if (/^(?:rs|result|results|rset|resultset)$/i.test(m[1])) continue;
136
+ if (!oisVars.has(m[1])) continue;
137
+ }
109
138
  const line = _lineOf(raw, m.index);
110
139
  push({
111
140
  id: `java-deser:${fp}:${line}:${p.name.replace(/\s+/g, '_')}`,