@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.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 (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -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
  51. package/src/dataflow/smt-feasibility.js +0 -189
@@ -46,7 +46,20 @@ const FILTER_INLINE_RE = {
46
46
  go: new RegExp(String.raw`\b(?:NewSearchRequest|SearchRequest|Search)\s*\([^)]*"\(\s*` + ATTR + String.raw`\s*=[^"]*"\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
47
47
  // C#: DirectorySearcher.Filter assigned a concat ("(uid=" + u) or an
48
48
  // interpolated string ($"(uid={u})").
49
- cs: new RegExp(String.raw`\bFilter\s*=\s*\$?@?"[^"]*\(\s*` + ATTR + String.raw`\s*=[^"]*(?:"\s*\+|\{)`, 'g'),
49
+ //
50
+ // SARD_80_F1 W4.C13 — this is the shape C#'s REAL Juliet corpus actually
51
+ // uses (`search.Filter = "(...) " + data + "))";`, a property ASSIGNMENT,
52
+ // not a call argument), confirmed via the public mirror
53
+ // (CWE90_LDAP_Injection__Connect_tcp_01.cs) — meaning it's THIS regex,
54
+ // not FILTER_VAR_RE.cs below, that needs the literal-suppression capture
55
+ // group: the identical filter line appears verbatim in bad() and
56
+ // GoodG2B(), only `data`'s source differs, same convention as every
57
+ // other language's Path B fix. Split into two top-level alternatives
58
+ // (concat form with a capturing group + trailing lookahead, vs.
59
+ // interpolation form uncaptured) instead of the previous single regex
60
+ // with an internal `"\s*\+|\{` alternation, since only the concat form's
61
+ // trailing identifier is safely checkable this way.
62
+ cs: new RegExp(String.raw`\bFilter\s*=\s*\$?@?"[^"]*\(\s*` + ATTR + String.raw`\s*=[^"]*"\s*\+\s*([A-Za-z_][\w.]*)(?![\w.]*\s*\()(?=\s*(?:\+\s*["'][^"'\n]*["']\s*)?[);,])|\bFilter\s*=\s*\$?@?"[^"]*\(\s*` + ATTR + String.raw`\s*=[^"]*\{`, 'g'),
50
63
  // Ruby net-ldap: a filter built with #{} interpolation inside a search/
51
64
  // construct/filter call.
52
65
  rb: new RegExp(String.raw`\.(?:search|filter|construct|equals)\s*\([^)]*\(\s*` + ATTR + String.raw`\s*=[^)]*#\{`, 'g'),
@@ -62,11 +75,30 @@ const FILTER_INLINE_RE = {
62
75
  // which is the *escaped* (safe) form — must not match.
63
76
  const FILTER_VAR_RE = {
64
77
  js: new RegExp(String.raw`["'` + '`' + String.raw`]\s*\(\s*` + ATTR + String.raw`\s*=\s*["'` + '`' + String.raw`]?\s*(?:\+|\$\{)\s*[A-Za-z_$][\w.]*(?![\w.]*\s*\()`, 'g'),
65
- java: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
78
+ // Captures the trailing identifier (group 1) so scanLdapInjection can check
79
+ // whether it's PROVABLY a hardcoded literal — Juliet's own convention
80
+ // (confirmed via the public mirror, CWE90_LDAP_Injection__Environment_01
81
+ // .java) keeps the IDENTICAL `"(cn=" + data + ")"` filter line in both
82
+ // bad() and goodG2B(), only swapping `data`'s source. The trailing
83
+ // lookahead mirrors java-structural.js's CWE-89 fix: only a SINGLE
84
+ // trailing term (optionally followed by one more literal segment, e.g.
85
+ // the closing `+ ")"`) is eligible for suppression — a second variable
86
+ // after it is left alone.
87
+ java: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*([A-Za-z_][\w.]*)(?![\w.]*\s*\()(?=\s*(?:\+\s*["'][^"'\n]*["']\s*)?[);,])`, 'g'),
66
88
  py: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()|[fF]["']\s*\(\s*` + ATTR + String.raw`\s*=\s*\{`, 'g'),
67
89
  php: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\.\s*\$[A-Za-z_]\w*`, 'g'),
68
90
  go: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()`, 'g'),
69
- cs: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*[A-Za-z_][\w.]*(?![\w.]*\s*\()|\$"[^"]*\(\s*` + ATTR + String.raw`\s*=\s*\{`, 'g'),
91
+ // SARD_80_F1 W4.C13 — capture group added to the concat alternative
92
+ // (mirroring `java`'s) so scanLDAPInjection can run the same
93
+ // hardcoded-literal check; the C# public mirror confirms Juliet's
94
+ // identical-line convention holds here too (CWE90_LDAP_Injection__
95
+ // Connect_tcp_01.cs keeps `search.Filter = "(...employeename=" + data +
96
+ // "))";` verbatim in both bad() and GoodG2B(), only data's source
97
+ // differs). The interpolation alternative (`$"...{`) is left uncaptured
98
+ // (m[1] undefined there is a no-op for `_nearestAssignIsLiteral`, which
99
+ // returns false on a falsy varName) since Juliet's own corpus uses the
100
+ // concat form exclusively for this shape.
101
+ cs: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*\+\s*([A-Za-z_][\w.]*)(?![\w.]*\s*\()(?=\s*(?:\+\s*["'][^"'\n]*["']\s*)?[);,])|\$"[^"]*\(\s*` + ATTR + String.raw`\s*=\s*\{`, 'g'),
70
102
  rb: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=[^"']*#\{`, 'g'),
71
103
  kt: new RegExp(String.raw`["']\s*\(\s*` + ATTR + String.raw`\s*=\s*["']?\s*(?:\+|\$\{)\s*[A-Za-z_$][\w.]*(?![\w.]*\s*\()`, 'g'),
72
104
  };
@@ -88,6 +120,99 @@ const LDAP_ESCAPE_RE =
88
120
  /\b(?:ldap_escape|EscapeFilter|escape_filter_chars|escapeForLDAP|encodeForLDAP|escapeLDAPSearchFilter|LDAP_ESCAPE_FILTER)\b|\bNet::LDAP::Filter\b|\bEqualityFilter\b|\bfilters\.\w+\b/;
89
121
 
90
122
  function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
123
+
124
+ // True when EVERY assignment to `varName` before `beforeIdx` (source order)
125
+ // is a plain string-literal RHS, and at least one such assignment exists.
126
+ // Same "backward assignment scan" shape as java-structural.js's
127
+ // `_trailingIdentIsLiteral` (itself modeled on java-bench-extras.js's
128
+ // CWE-259 check) — duplicated rather than imported, matching this
129
+ // codebase's established per-module convention for this small a helper.
130
+ //
131
+ // SARD_80_F1 W4.C13 — this USED to check only the TEXTUALLY NEAREST
132
+ // assignment, which is wrong for an if/else where each branch assigns the
133
+ // same variable: `if (cond) data = Environment.GetEnvironmentVariable(...);
134
+ // else data = "foo";` has its literal assignment (the else branch) textually
135
+ // LAST, so the old "nearest" check wrongly concluded `data` was provably a
136
+ // literal and suppressed a REAL vulnerability — found via a real corpus
137
+ // regression during THIS EXACT fix's own verification (C# CWE90_LDAP_
138
+ // Injection__Environment_12.cs's Bad() lost its true positive). Checking
139
+ // EVERY preceding assignment (failing closed the moment any one of them is
140
+ // non-literal) fixes that — but naively scanning the WHOLE FILE for prior
141
+ // assignments introduced a SECOND regression the same verification pass
142
+ // caught: Juliet's universal convention re-declares the source variable
143
+ // FRESH in every method (`string data;` in Bad(), a completely separate
144
+ // `string data;` in GoodG2B()), so a same-named variable in an EARLIER
145
+ // method (e.g. Bad()'s own non-literal source assignment) would incorrectly
146
+ // poison GoodG2B()'s own all-literal check. Scoping the scan to start at
147
+ // the variable's most recent DECLARATION before `beforeIdx` (a `Type
148
+ // varName;` or `Type varName = …;` statement) fixes both at once: within
149
+ // one method's scope, every assignment must be literal (catches the
150
+ // if/else case); across method boundaries, each method's own `data` is
151
+ // scanned independently (catches the cross-method case). Falls back to
152
+ // scanning from file-start when no declaration is found — strictly SAFER
153
+ // than before (fewer assignments end up in scope, never more).
154
+ // SARD_80_F1 W4.C17 — a bare-identifier RHS (`data = dataCopy;`) previously
155
+ // failed this check outright, even when `dataCopy` was itself provably a
156
+ // literal at that point — confirmed on the real corpus (C# Juliet's own
157
+ // "make a copy of data within the same method" flow variant,
158
+ // CWE90_LDAP_Injection__Environment_31.cs: GoodG2B() sets `data = "foo"` in
159
+ // one block, copies it to `dataCopy`, then re-declares `data = dataCopy` in
160
+ // a SECOND block before the sink — the one-hop copy defeated the
161
+ // literal-RHS check, which only recognized a DIRECT `"literal"` string).
162
+ // Now recurses ONE LEVEL (capped, cycle-guarded via `rhs !== varName`) into
163
+ // a bare-identifier RHS, applying the exact same fail-closed "every
164
+ // assignment must be literal" policy to the copied-from variable — this can
165
+ // only ADD suppression where the old code returned false, never introduce a
166
+ // new false negative the old code didn't already risk (a genuinely tainted
167
+ // `dataCopy` still fails its own scan and propagates the non-literal verdict
168
+ // back up).
169
+ function _nearestAssignIsLiteral(code, varName, beforeIdx, _depth) {
170
+ if (!varName || varName.includes('.')) return false;
171
+ const depth = _depth || 0;
172
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
173
+ const declRe = new RegExp(`\\b[\\w<>[\\],.?]+\\s+${escaped}\\s*(?:=|;)`, 'g');
174
+ let scopeStart = 0, dm;
175
+ while ((dm = declRe.exec(code)) && dm.index < beforeIdx) scopeStart = dm.index;
176
+ const anyAssignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
177
+ anyAssignRe.lastIndex = scopeStart;
178
+ const literalRhsRe = /^"[^"]*"$/;
179
+ const bareIdentRe = /^[A-Za-z_]\w*$/;
180
+ let sawAny = false, m;
181
+ while ((m = anyAssignRe.exec(code)) && m.index < beforeIdx) {
182
+ sawAny = true;
183
+ const rhs = m[1].trim();
184
+ if (literalRhsRe.test(rhs)) continue;
185
+ // SARD_80_F1 W4.C37 — `null` is never attacker-controlled data (it is
186
+ // the ABSENCE of a value, not a value), so an assignment of `data =
187
+ // null;` can never make a later sink call injectable — treating it as a
188
+ // disqualifying non-literal was always wrong, independent of any dead-
189
+ // code/control-flow question. This exact shape is Juliet's own "if
190
+ // (CONST) { data = <literal-or-source> } else { data = null; }" dead-
191
+ // code idiom (confirmed via the public C# Juliet mirror's own
192
+ // CWE90_LDAP_Injection__Connect_tcp_04.cs), but the fix is sound as a
193
+ // general rule, not merely a benchmark-shape accommodation: BEFORE
194
+ // this fix, `_nearestAssignIsLiteral` recursed into "null" as if it
195
+ // might be a traceable bare-identifier variable (`bareIdentRe` matches
196
+ // it — "null" is alphabetic), found no declaration or assignment for a
197
+ // variable literally named `null`, and returned `false` from that dead
198
+ // end — which then disqualified the WHOLE candidate as "not provably
199
+ // literal" even when every OTHER real assignment was a hardcoded
200
+ // literal. This bug PRE-DATES and is INDEPENDENT of this session's
201
+ // parser-cs.js CFG work (confirmed via a direct, standalone call to
202
+ // `scanLDAPInjection` bypassing runScan/parser-cs.js entirely) — it was
203
+ // simply never exercised on a real corpus file until an unrelated CFG
204
+ // fix elsewhere changed which findings survive `engine.js`'s own
205
+ // downstream dedup, surfacing it. Skipping (not disqualifying, not
206
+ // affirming) a `null` RHS can only ever ADD suppression where the old
207
+ // code returned false, never remove a genuine tainted-source detection
208
+ // — a real source assignment (`data = sr.ReadLine();`, `data =
209
+ // Environment.GetEnvironmentVariable(...)`) is never itself `null`.
210
+ if (rhs === 'null') continue;
211
+ if (depth < 3 && rhs !== varName && bareIdentRe.test(rhs) && _nearestAssignIsLiteral(code, rhs, m.index, depth + 1)) continue;
212
+ return false;
213
+ }
214
+ return sawAny;
215
+ }
91
216
  function _lang(fp) {
92
217
  if (/\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(fp)) return 'js';
93
218
  if (/\.java$/i.test(fp)) return 'java';
@@ -140,6 +265,10 @@ export function scanLDAPInjection(fp, raw) {
140
265
  const re = new RegExp(FILTER_INLINE_RE[lang].source, FILTER_INLINE_RE[lang].flags);
141
266
  let m;
142
267
  while ((m = re.exec(code))) {
268
+ // SARD_80_F1 W4.C13 — C#'s DirectorySearcher.Filter shape goes
269
+ // through THIS path (a property assignment), not FILTER_VAR_RE, so
270
+ // the hardcoded-literal check belongs here for `cs`.
271
+ if (lang === 'cs' && _nearestAssignIsLiteral(code, m[1], m.index)) continue;
143
272
  const line = lineOf(raw, m.index);
144
273
  const key = `inline:${line}`;
145
274
  if (seen.has(key)) continue;
@@ -154,6 +283,7 @@ export function scanLDAPInjection(fp, raw) {
154
283
  const re = new RegExp(FILTER_VAR_RE[lang].source, FILTER_VAR_RE[lang].flags);
155
284
  let m;
156
285
  while ((m = re.exec(code))) {
286
+ if ((lang === 'java' || lang === 'cs') && _nearestAssignIsLiteral(code, m[1], m.index)) continue;
157
287
  const line = lineOf(raw, m.index);
158
288
  const key = `var:${line}`;
159
289
  if (seen.has(key)) continue;
package/src/sast/rust.js CHANGED
@@ -63,6 +63,59 @@ const FINDINGS = [
63
63
  remediation: 'Define a typed struct with serde for the extractor: `#[derive(Deserialize)] struct UserPath { id: i64 }`. Typed extractors reject malformed input at the framework boundary instead of bubbling raw strings into your handlers.',
64
64
  infoOnly: true,
65
65
  },
66
+ {
67
+ id: 'rust-tls-verify-disabled', severity: 'high', cwe: 'CWE-295', family: 'tls-verification',
68
+ // reqwest::ClientBuilder::danger_accept_invalid_certs(true) /
69
+ // danger_accept_invalid_hostnames(true) — the method name itself says
70
+ // "danger"; this only fires on the literal `true` argument, since a
71
+ // variable/computed argument might legitimately gate it off in prod.
72
+ re: /\bdanger_accept_invalid_(?:certs|hostnames)\s*\(\s*true\s*\)/g,
73
+ vuln: 'TLS certificate/hostname verification disabled',
74
+ remediation: 'Remove `danger_accept_invalid_certs`/`danger_accept_invalid_hostnames` (or pass a variable that is `false` in production). If a private CA is the real problem, add its root with `Certificate::from_pem` + `.add_root_certificate(...)` instead of disabling verification.',
75
+ },
76
+ {
77
+ id: 'rust-weak-hash-md5', severity: 'medium', cwe: 'CWE-327', family: 'weak-crypto',
78
+ re: /\b(?:md5::compute|Md5::new|Md5::default)\s*\(/g,
79
+ vuln: 'Weak cryptographic hash — MD5',
80
+ remediation: 'MD5 is broken for any security purpose (collision-resistance, password storage, integrity). Use `sha2::Sha256` for integrity/fingerprinting or `argon2`/`bcrypt` for password hashing.',
81
+ },
82
+ {
83
+ id: 'rust-weak-hash-sha1', severity: 'medium', cwe: 'CWE-327', family: 'weak-crypto',
84
+ re: /\b(?:Sha1::new|Sha1::default|sha1::Sha1)\s*[:(]/g,
85
+ vuln: 'Weak cryptographic hash — SHA-1',
86
+ remediation: 'SHA-1 has practical collision attacks. Use `sha2::Sha256`/`Sha512` (or `blake3`) for anything security-relevant.',
87
+ },
88
+ {
89
+ id: 'rust-weak-cipher', severity: 'high', cwe: 'CWE-327', family: 'weak-crypto',
90
+ // des::Des / des::TdesEde3 (DES/3DES) and rc4::Rc4 — broken/deprecated
91
+ // ciphers still published as crates.
92
+ re: /\b(?:des::Des\b|des::TdesEde[23]\b|Rc4::new)\s*[:(<]/g,
93
+ vuln: 'Weak/broken cipher — DES/3DES/RC4',
94
+ remediation: 'Use an AEAD cipher from the `aes-gcm` or `chacha20poly1305` crates (`Aes256Gcm`, `ChaCha20Poly1305`). DES, 3DES and RC4 are all broken or deprecated for new use.',
95
+ },
96
+ {
97
+ id: 'rust-weak-rng-token', severity: 'high', cwe: 'CWE-338', family: 'weak-rng',
98
+ // rand::random()/thread_rng() feeding an identifier that names a
99
+ // token/secret/key/session — `rand`'s default RNG is NOT a CSPRNG-grade
100
+ // guarantee for security tokens the way `getrandom`/`OsRng` are treated
101
+ // in the ecosystem's own guidance; flag the security-sensitive naming
102
+ // context, not `rand::random()` in general (rolling dice is fine).
103
+ re: /\b(?:let|const)\s+(?:mut\s+)?\w*(?:token|secret|session|api_?key|password|nonce|salt|csrf)\w*\s*(?::[^=]+)?=\s*(?:rand::random|rand::thread_rng\s*\(\s*\)\.gen)\b/gi,
104
+ vuln: 'Weak randomness for a security-sensitive value (rand::random/thread_rng)',
105
+ remediation: 'Use `rand::rngs::OsRng` (or the `getrandom` crate) to seed tokens/session ids/keys — `rand::thread_rng()`/`rand::random()` are not documented as cryptographically secure. `let mut buf = [0u8; 32]; OsRng.fill_bytes(&mut buf);`',
106
+ },
107
+ {
108
+ id: 'rust-unsafe-raw-memory', severity: 'medium', cwe: 'CWE-119', family: 'memory-safety',
109
+ // transmute / from_raw_parts(_mut) / get_unchecked(_mut) — the classic
110
+ // unsafe memory-reinterpretation and bounds-skipping primitives. Scoped
111
+ // to occurrences that are themselves already inside an `unsafe` block
112
+ // in practice (transmute/from_raw_parts require it to compile at all;
113
+ // get_unchecked does not, so it's included for the same reason: it
114
+ // skips the bounds check the safe `[]` indexing performs).
115
+ re: /\b(?:std::mem::transmute|mem::transmute|std::slice::from_raw_parts(?:_mut)?|slice::from_raw_parts(?:_mut)?)\s*[:(<]|\.get_unchecked(?:_mut)?\s*\(/g,
116
+ vuln: 'Unsafe raw-memory operation (transmute/from_raw_parts/get_unchecked)',
117
+ remediation: 'Prefer safe alternatives where possible: `as` casts or `bytemuck`/`zerocopy` instead of `transmute`; `<[T]>::get(i)`/checked slicing instead of `get_unchecked`. If unsafe is unavoidable, document the exact invariant (length, alignment, lifetime) that makes it sound at this call site.',
118
+ },
66
119
  ];
67
120
 
68
121
  function lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
@@ -93,7 +146,11 @@ export function scanRust(fp, raw) {
93
146
  stride: rule.family === 'sql-injection' ? 'Tampering'
94
147
  : rule.family === 'command-injection' ? 'Elevation of Privilege'
95
148
  : rule.family === 'weak-rng' ? 'Spoofing'
149
+ : rule.family === 'weak-crypto' ? 'Information Disclosure'
150
+ : rule.family === 'tls-verification' ? 'Spoofing'
151
+ : rule.family === 'memory-safety' ? 'Denial of Service'
96
152
  : 'Information Disclosure',
153
+ family: rule.family,
97
154
  snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
98
155
  remediation: rule.remediation,
99
156
  confidence: 0.85,
@@ -12,6 +12,7 @@
12
12
  // content-type, or a raw-superglobal echo) so plain-text responses don't fire.
13
13
 
14
14
  import { blankComments } from './_comment-strip.js';
15
+ import { deadBranchRanges, isLineInDeadRange } from './java-ast-folding.js';
15
16
 
16
17
  const lineOf = (raw, idx) => raw.substring(0, idx).split('\n').length;
17
18
 
@@ -77,14 +78,242 @@ const LANGS = {
77
78
  // Servlet: response.getWriter().write/print/println(...) or a PrintWriter
78
79
  // `out`, building an HTML string ("<…") by concatenation. The concat (`"<…"
79
80
  // +`) is the reflected-XSS shape — a static literal has no `+`.
81
+ // Each pattern additionally captures a trailing `+ identifier)`/`+
82
+ // identifier;` tail — the SINGLE, LAST concatenated term, when the
83
+ // concatenation ends right there — mirroring java-structural.js's own
84
+ // SQL/cmd-injection capture shape, so `_nearestAssignIsLiteral` (below)
85
+ // can tell a real tainted variable from a hardcoded one. The capture
86
+ // also tolerates ONE simple method call chained directly onto that
87
+ // identifier (`data.replaceAll(...)`) — Juliet's own real corpus shape
88
+ // (confirmed via the public Java mirror,
89
+ // CWE80_XSS__CWE182_Servlet_File_01.java) is exactly this: `data =
90
+ // readerBuffered.readLine()` (tainted) vs `data = "foo"` (literal) in
91
+ // bad()/goodG2B() respectively, both then passed through the IDENTICAL
92
+ // `data.replaceAll("(<script>)", "")` before the sink — a call on a
93
+ // provably-literal receiver is still provably literal. The inner
94
+ // `(?:"[^"]*"|[^()])*` is quote-aware specifically so a literal paren
95
+ // INSIDE a string argument (like `"(<script>)"` above) doesn't
96
+ // terminate the match early — this taint-independent detector, having
97
+ // no taint model of its own, previously fired on both bad() and
98
+ // goodG2B() identically.
80
99
  sinks: [
81
- /\.\s*(?:getWriter\s*\(\s*\)\s*\.\s*)?(?:write|print|println|append|format|printf)\s*\(\s*"<[^"\n]*"\s*\+/,
82
- /\b(?:out|writer|pw|w)\s*\.\s*(?:write|print|println|append)\s*\(\s*"<[^"\n]*"\s*\+/,
100
+ /\.\s*(?:getWriter\s*\(\s*\)\s*\.\s*)?(?:write|print|println|append|format|printf)\s*\(\s*"<[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)(?:\s*\.\s*[A-Za-z_]\w*\s*\((?:"[^"]*"|[^()])*\))?\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/,
101
+ /\b(?:out|writer|pw|w)\s*\.\s*(?:write|print|println|append)\s*\(\s*"<[^"\n]*"\s*\+(?:\s*([A-Za-z_]\w*)(?:\s*\.\s*[A-Za-z_]\w*\s*\((?:"[^"]*"|[^()])*\))?\s*(?=(?:\s*\+\s*"[^"\n]*"\s*)?[);]))?/,
83
102
  ],
84
103
  escaper: /\b(?:encodeForHTML|forHtml|forHtmlContent|htmlEscape|escapeHtml4?|StringEscapeUtils|HtmlUtils\.htmlEscape|Encode\.forHtml|ESAPI|OWASP)\b/,
85
104
  },
86
105
  };
87
106
 
107
+ // SARD_80_F1 W4.J28 — Juliet's own "Control flow: if(true) and if(false)"
108
+ // flow variant (its own file header names this explicitly) writes
109
+ // `if (true) { data = "foo"; } else { /* CWE 561 Dead Code */ data = null; }`
110
+ // (or the mirror image for `if (false)`) — the ELSE branch is PROVABLY
111
+ // unreachable (a constant-condition, not a genuine runtime-dependent
112
+ // branch), but a plain backward-scan just finds
113
+ // "the textually nearest assignment", which is the DEAD branch's `data =
114
+ // null;` here (textually last), so it never recognizes `data` as the
115
+ // literal it always actually is.
116
+ //
117
+ // SARD_80_F1 W5.32 — originally patched with this file's OWN narrow regex
118
+ // (matching only `if(true)`/`if(false)`, then widened to also match
119
+ // `if(5==5)`/`if(5!=5)`) — found investigating a NEW fp this exact gap
120
+ // exposed once an unrelated fix (a Java parser bug, W5.32's own
121
+ // StringTokenizer fix) let the deep taint engine correctly detect `bad()`'s
122
+ // real vulnerability for the first time: the resulting dedup/clustering
123
+ // reshuffle stopped accidentally folding this ALREADY-PRESENT, independent
124
+ // XSS-ML false positive into the genuine finding, exposing a real,
125
+ // pre-existing gap rather than introducing one. That narrow regex approach
126
+ // only ever covered 2 of the many dead-code idioms this real corpus uses
127
+ // (checked directly: 04-14/21/22a/31/41/42/45/51b/52c/54e/61a/66b-74b all
128
+ // remained as fps) — rather than keep extending a second, independent
129
+ // regex-based dead-branch detector, this now consumes `java-ast-folding.js`'s
130
+ // existing `deadBranchRanges`/`isLineInDeadRange` — the SAME shared,
131
+ // AST-based (not regex-based) mechanism `java-bench-extras.js` and
132
+ // `java-structural.js` already use, which ALREADY resolves literal
133
+ // true/false, private-static-final fields, effectively-final fields,
134
+ // literal-vs-literal int comparisons, and zero-arg/single-return same-class
135
+ // helper calls (W4.J33-36/W5.27's own incrementally-built capability) — one
136
+ // shared mechanism, extended once, every consumer benefits, matching this
137
+ // whole session's established pattern. The backward-scan now simply SKIPS any assignment whose line falls in a dead
138
+ // range, rather than relying on a pre-blanked copy of the source text.
139
+ // Cross-file variants (the lettered-suffix families) remain correctly
140
+ // out of scope — `deadBranchRanges` is single-file, same boundary as
141
+ // every other interprocedural mechanism in this codebase.
142
+
143
+ // SARD_80_F1 W5.33 — this file's OWN backward-only "nearest assignment"
144
+ // check (formerly `_trailingIdentIsLiteral`) had none of the interprocedural
145
+ // resolution `java-bench-extras.js` already built for the identical Juliet
146
+ // idioms (W4.J37-39): a value returned from a same-file helper
147
+ // (`data = goodG2BSource();`), a same-method copy chain (`dataCopy = data;`
148
+ // … `data = dataCopy;`), or an argument passed to a helper that receives it
149
+ // as a PARAMETER rather than a local assignment. Confirmed via a real-corpus
150
+ // sweep of this file's own remaining CWE-80 fp list (Flow Variants 31/41/42/
151
+ // 45 — no lettered suffix, genuinely same-file) that these are exactly the
152
+ // gap. Rather than re-derive a THIRD independent copy of this logic (a
153
+ // second copy, in java-structural.js, was already ported once at W5.23),
154
+ // this ports the same three functions verbatim in their already-ReDoS-fixed
155
+ // form (W4.J39 — the "return type prefix" lazy `\s`-inclusive character
156
+ // class that caused a confirmed hang was dropped entirely in the source
157
+ // this was copied from; reintroducing it here would reintroduce the exact
158
+ // same hang against Juliet's own large blanked-comment header blocks).
159
+ // `_nearestAssignIsLiteral` is the direct, same-signature replacement for
160
+ // this file's own former `_trailingIdentIsLiteral` at the one call site
161
+ // below. Same-file only, matching every other interprocedural mechanism in
162
+ // this codebase — the lettered-suffix (cross-file) variants remain
163
+ // correctly out of scope.
164
+ const _CALLEE_RETURN_LITERAL_CACHE_DEPTH = 4;
165
+ function _resolveCalleeReturnIsLiteral(content, calleeName, deadRanges, _depth) {
166
+ const depth = _depth || 0;
167
+ if (depth >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
168
+ const escapedCallee = calleeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
169
+ const declRe = new RegExp(`\\b${escapedCallee}\\s*\\([^)]*\\)\\s*(?:throws\\s+[\\w.,\\s]+)?\\s*\\{`, 'g');
170
+ const dm = declRe.exec(content);
171
+ if (!dm) return false;
172
+ const bodyStart = dm.index + dm[0].length;
173
+ let braceDepth = 1, i = bodyStart;
174
+ while (i < content.length && braceDepth > 0) {
175
+ if (content[i] === '{') braceDepth++;
176
+ else if (content[i] === '}') braceDepth--;
177
+ i++;
178
+ }
179
+ const body = content.slice(bodyStart, i - 1);
180
+ const returnRe = /\breturn\s+([^;]+);/g;
181
+ const returns = [];
182
+ let rm;
183
+ while ((rm = returnRe.exec(body))) returns.push({ expr: rm[1].trim(), idx: bodyStart + rm.index });
184
+ if (returns.length !== 1) return false; // ambiguous (0 or 2+ returns) — fail closed
185
+ const { expr, idx } = returns[0];
186
+ if (/^"[^"]*"$/.test(expr)) return true;
187
+ if (/^[A-Za-z_]\w*$/.test(expr)) {
188
+ return _nearestAssignIsLiteral(content, expr, idx, deadRanges, depth + 1);
189
+ }
190
+ return false;
191
+ }
192
+
193
+ // Balanced top-level comma split (paren/bracket/brace-aware, quote-aware).
194
+ function _splitTopLevelCommas(text) {
195
+ const parts = [];
196
+ let depth = 0, cur = '', inStr = null;
197
+ for (let i = 0; i < text.length; i++) {
198
+ const c = text[i];
199
+ if (inStr) {
200
+ cur += c;
201
+ if (c === '\\') { cur += text[++i] || ''; continue; }
202
+ if (c === inStr) inStr = null;
203
+ continue;
204
+ }
205
+ if (c === '"' || c === "'") { inStr = c; cur += c; continue; }
206
+ if (c === '(' || c === '[' || c === '{') depth++;
207
+ else if (c === ')' || c === ']' || c === '}') depth--;
208
+ if (c === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
209
+ cur += c;
210
+ }
211
+ if (cur.trim() !== '' || parts.length) parts.push(cur);
212
+ return parts;
213
+ }
214
+
215
+ function _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _depth) {
216
+ const depth0 = _depth || 0;
217
+ if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
218
+ const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
219
+ 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;
220
+ let enclosing = null, dm;
221
+ while ((dm = methodDeclRe.exec(content)) && dm.index < beforeIdx) enclosing = dm;
222
+ if (!enclosing) return false;
223
+ const methodName = enclosing[1];
224
+ const params = _splitTopLevelCommas(enclosing[2]).map(p => p.trim()).filter(Boolean);
225
+ const paramIdx = params.findIndex((p) => new RegExp(`\\b${escapedVar}$`).test(p));
226
+ if (paramIdx === -1) return false; // varName isn't actually a param of the enclosing method
227
+ const escapedMethod = methodName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
228
+ const callRe = new RegExp(`\\b${escapedMethod}\\s*\\(`, 'g');
229
+ let cm, sawRealCallSite = false;
230
+ while ((cm = callRe.exec(content))) {
231
+ const argsStart = cm.index + cm[0].length;
232
+ let braceDepth = 1, i = argsStart;
233
+ while (i < content.length && braceDepth > 0) {
234
+ if (content[i] === '(') braceDepth++;
235
+ else if (content[i] === ')') braceDepth--;
236
+ i++;
237
+ }
238
+ const afterClose = content.slice(i).match(/^\s*(\{|throws)/);
239
+ if (afterClose) continue; // this is the method's OWN declaration, not a call site
240
+ const args = _splitTopLevelCommas(content.slice(argsStart, i - 1)).map((a) => a.trim());
241
+ if (paramIdx >= args.length) return false; // arity mismatch — bail conservatively
242
+ sawRealCallSite = true;
243
+ const argExpr = args[paramIdx];
244
+ if (/^"[^"]*"$/.test(argExpr)) continue; // this caller's own argument is a direct literal
245
+ if (/^[A-Za-z_]\w*$/.test(argExpr) && _nearestAssignIsLiteral(content, argExpr, cm.index, deadRanges, depth0 + 1)) continue;
246
+ return false; // a non-literal (or unresolvable) caller exists
247
+ }
248
+ return sawRealCallSite;
249
+ }
250
+
251
+ // SARD_80_F1 W5.34 — Juliet Flow Variant 45: a value passed as a private
252
+ // class member variable between two methods of the same class. See
253
+ // java-bench-extras.js's own header comment on the identical, canonical
254
+ // copy of this function for the full idiom and precision reasoning. Only
255
+ // resolves when `varName` is confirmed to be an actual field declaration
256
+ // (access-modifier-qualified — never valid on a local variable or
257
+ // parameter).
258
+ function _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _depth) {
259
+ const depth0 = _depth || 0;
260
+ if (depth0 >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
261
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
262
+ const fieldDeclRe = new RegExp(`\\b(?:private|protected|public)\\s+[\\w<>\\[\\],.\\s]+\\b${escaped}\\s*(?:=\\s*[^;]+)?;`);
263
+ if (!fieldDeclRe.test(content)) return false;
264
+ const ranges = deadRanges || [];
265
+ const assignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
266
+ const lineOf_ = (idx) => content.substring(0, idx).split('\n').length;
267
+ let sawAssignment = false, m;
268
+ while ((m = assignRe.exec(content))) {
269
+ if (ranges.length && isLineInDeadRange(lineOf_(m.index), ranges)) continue;
270
+ sawAssignment = true;
271
+ const rhs = m[1].trim();
272
+ if (/^"[^"]*"$/.test(rhs)) continue;
273
+ if (/^[A-Za-z_]\w*$/.test(rhs) && rhs !== varName
274
+ && _nearestAssignIsLiteral(content, rhs, m.index, ranges, depth0 + 1)) continue;
275
+ return false;
276
+ }
277
+ return sawAssignment;
278
+ }
279
+
280
+ function _nearestAssignIsLiteral(content, varName, beforeIdx, deadRanges, _calleeDepth) {
281
+ if (!varName) return false;
282
+ if ((_calleeDepth || 0) >= _CALLEE_RETURN_LITERAL_CACHE_DEPTH) return false;
283
+ const ranges = deadRanges || [];
284
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
285
+ const literalRe = new RegExp(`\\b${escaped}\\s*=\\s*"[^"]*"\\s*;`, 'g');
286
+ const anyAssignRe = new RegExp(`\\b${escaped}\\s*=\\s*([^;]+);`, 'g');
287
+ const lineOf = (idx) => content.substring(0, idx).split('\n').length;
288
+ let lastLiteralEnd = -1, m;
289
+ while ((m = literalRe.exec(content)) && m.index < beforeIdx) {
290
+ if (ranges.length && isLineInDeadRange(lineOf(m.index), ranges)) continue;
291
+ lastLiteralEnd = m.index + m[0].length;
292
+ }
293
+ let lastAnyEnd = -1, lastAnyRhs = null, lastAnyIdx = -1;
294
+ while ((m = anyAssignRe.exec(content)) && m.index < beforeIdx) {
295
+ if (ranges.length && isLineInDeadRange(lineOf(m.index), ranges)) continue;
296
+ lastAnyEnd = m.index + m[0].length;
297
+ lastAnyRhs = m[1].trim();
298
+ lastAnyIdx = m.index;
299
+ }
300
+ if (lastAnyRhs && /^[A-Za-z_]\w*\s*\([^()]*\)$/.test(lastAnyRhs)) {
301
+ const calleeName = lastAnyRhs.slice(0, lastAnyRhs.indexOf('(')).trim();
302
+ if (_resolveCalleeReturnIsLiteral(content, calleeName, ranges, _calleeDepth)) return true;
303
+ }
304
+ if (lastAnyRhs && lastAnyRhs !== 'null' && /^[A-Za-z_]\w*$/.test(lastAnyRhs) && lastAnyRhs !== varName) {
305
+ if (_nearestAssignIsLiteral(content, lastAnyRhs, lastAnyIdx, ranges, (_calleeDepth || 0) + 1)) return true;
306
+ }
307
+ if (lastLiteralEnd === -1 || lastLiteralEnd !== lastAnyEnd) {
308
+ return _resolveParamLiteralViaAllCallSites(content, varName, beforeIdx, deadRanges, _calleeDepth)
309
+ || _resolveFieldLiteralViaAllAssignments(content, varName, deadRanges, _calleeDepth);
310
+ }
311
+ const paramRe = new RegExp(`\\([^()]*\\b[\\w.<>\\[\\]]+\\s+${escaped}\\s*[,)]`, 'g');
312
+ let lastParamEnd = -1;
313
+ while ((m = paramRe.exec(content)) && m.index < beforeIdx) lastParamEnd = m.index + m[0].length;
314
+ return lastParamEnd <= lastLiteralEnd;
315
+ }
316
+
88
317
  export function scanXssReflectedMultilang(fp, raw) {
89
318
  if (!raw || raw.length > 500_000) return [];
90
319
  let lang = null;
@@ -93,13 +322,33 @@ export function scanXssReflectedMultilang(fp, raw) {
93
322
 
94
323
  const code = blankComments(raw, /\.rb$/i.test(fp) ? 'py' : (/\.(?:php|phtml)$/i.test(fp) ? 'php' : undefined));
95
324
  const lines = code.split('\n');
325
+ let deadRanges = [];
326
+ if (lang === LANGS.java) { try { deadRanges = deadBranchRanges(code); } catch { deadRanges = []; } }
96
327
  const findings = [];
97
328
  const seen = new Set();
98
329
 
330
+ let lineStartOffset = 0;
99
331
  for (let i = 0; i < lines.length; i++) {
100
332
  const line = lines[i];
333
+ const thisLineStart = lineStartOffset;
334
+ lineStartOffset += line.length + 1; // +1 for the '\n' _splitStatements-style join consumed
101
335
  if (lang.escaper.test(line)) continue;
102
- if (!lang.sinks.some((re) => re.test(line))) continue;
336
+ let sinkMatch = null;
337
+ for (const re of lang.sinks) {
338
+ const m = line.match(re);
339
+ if (m) { sinkMatch = m; break; }
340
+ }
341
+ if (!sinkMatch) continue;
342
+ // SARD_80_F1 W5.35 — a sink call sitting INSIDE a provably-dead branch
343
+ // (Juliet's "switch(8){case 7: <tainted sink>}" idiom) is unreachable
344
+ // regardless of whether the value reaching it is tainted — the
345
+ // literal-check below can never suppress this shape, since the value
346
+ // genuinely IS tainted, just unreachable.
347
+ if (lang === LANGS.java && deadRanges.length && isLineInDeadRange(i + 1, deadRanges)) continue;
348
+ // SARD_80_F1 W3.x — a captured trailing identifier (Java's sink patterns
349
+ // only) that's provably a hardcoded literal at this point is not a real
350
+ // XSS flow; see `_nearestAssignIsLiteral`'s header comment.
351
+ if (lang === LANGS.java && sinkMatch[1] && _nearestAssignIsLiteral(code, sinkMatch[1], thisLineStart + sinkMatch.index, deadRanges)) continue;
103
352
  const ln = i + 1;
104
353
  const id = `xss-reflected:${fp}:${ln}`;
105
354
  if (seen.has(id)) continue;