@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
package/src/engine.js CHANGED
@@ -107,6 +107,8 @@ import { scanFileUpload } from './sast/file-upload.js';
107
107
  import { scanHostHeader } from './sast/host-header.js';
108
108
  import { scanPythonSinks } from './sast/python-sinks.js';
109
109
  import { scanCSharp } from './sast/csharp.js';
110
+ import { scanCsharpCrossFile } from './sast/csharp-cross-file.js';
111
+ import { computeJavaStructuralCrossFileSuppressions } from './sast/java-structural-cross-file.js';
110
112
  import { scanCpp } from './sast/cpp.js';
111
113
  import { scanJulietShape, applyJulietJavaSuppressions, applyJulietCsSuppressions } from './sast/juliet-shape.js';
112
114
  import { scanCppDataflow, _parseErrorCount as _cppDataflowParseErrors } from './sast/cpp-dataflow.js';
@@ -1551,7 +1553,11 @@ function _hasSsrfHostGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _SSR
1551
1553
  // before `Path.Combine(..., filename)`) left the finding firing on the FIXED
1552
1554
  // revision. Safe to match on shape because _guardMatchNearSinkIdentifier
1553
1555
  // already requires the guard to name the same identifier as the sink.
1554
- const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b|\b(?:[Ee]nsure|[Vv]alidate|[Aa]ssert|[Rr]equire|[Vv]erify|[Cc]heck)\w{1,40}\s*\(/;
1556
+ // `starts_with`/`strip_prefix` (Rust's own snake_case naming) and
1557
+ // `canonicalize` (Rust's `Path::canonicalize`, the containment check's
1558
+ // resolve-then-compare half) join the existing camelCase/PascalCase forms —
1559
+ // same guard, a different language's naming convention.
1560
+ const _PATH_GUARD_RE = /\b(?:basename|GetFileName|secure_filename|sanitize_filename|send_from_directory|safe_join)\s*\(|\b(?:startsWith|startswith|StartsWith|HasPrefix|starts_with|strip_prefix)\s*\(|\bgetCanonicalPath\b|\btoRealPath\b|\bcanonicalize\s*\(|\bfilepath\s*\.\s*(?:Clean|Base|Abs)\b|\b(?:[Ee]nsure|[Vv]alidate|[Aa]ssert|[Rr]equire|[Vv]erify|[Cc]heck)\w{1,40}\s*\(/;
1555
1561
  function _hasPathGuard(ctx) { return _guardMatchNearSinkIdentifier(ctx, _PATH_GUARD_RE); }
1556
1562
 
1557
1563
  // Reflected-XSS output-encoding guard: an HTML escaper applied near the sink.
@@ -5007,7 +5013,17 @@ function scanJavaSAST(fp, raw) {
5007
5013
  // Per-family file-level predicate (custom logic). Returns truthy to suppress.
5008
5014
  if (typeof rule.fileSafePredicate === 'function' && rule.fileSafePredicate(cleaned, raw)) continue;
5009
5015
 
5010
- const sinkLine = cleaned.substring(0, sinkMatch.index).split('\n').length;
5016
+ // Use the match's END, not its start: every sinkRe here terminates
5017
+ // right at the sink call's own opening `(`, but a few (ldap-injection's
5018
+ // `\bjavax.naming...\b[\s\S]{0,12000}?\w+\.search\(`) anchor on an EARLIER
5019
+ // token (an import statement, or the first `DirContext` mention) and lazily
5020
+ // span forward to the real call, which can be dozens of lines later. Using
5021
+ // the start position reported the anchor's line (e.g. an `import` line) as
5022
+ // the "vulnerable line" — a wrong, misleading finding location/snippet, and
5023
+ // it also broke `dedupeFindingsWithEvidence`'s per-sink-line collapse with
5024
+ // other detectors (ldap-injection.js, the IR-TAINT engine) that correctly
5025
+ // report the real call site, since a stale anchor line never matches it.
5026
+ const sinkLine = cleaned.substring(0, sinkMatch.index + sinkMatch[0].length).split('\n').length;
5011
5027
 
5012
5028
  // Optional precision filter: when the rule is taint-aware, extract the
5013
5029
  // sink call's argument expression and check whether a tainted variable
@@ -6372,6 +6388,9 @@ const _VULN_FAMILY_PREFIX = [
6372
6388
  ['SQL Injection', 'sql-injection'],
6373
6389
  ['Command Injection', 'command-injection'],
6374
6390
  ['Code Injection', 'code-injection'],
6391
+ ['LDAP Injection', 'ldap-injection'],
6392
+ ['XPath Injection', 'xpath-injection'],
6393
+ ['Local/Remote File Inclusion', 'code-injection'],
6375
6394
  ['Reflected XSS', 'xss'],
6376
6395
  ['Stored XSS', 'xss'],
6377
6396
  ['DOM XSS', 'xss'],
@@ -6383,6 +6402,18 @@ const _VULN_FAMILY_PREFIX = [
6383
6402
  ['Prototype Pollution', 'prototype-pollution'],
6384
6403
  ['IDOR', 'idor'],
6385
6404
  ['Potential IDOR', 'idor'],
6405
+ // SARD_80_F1 W5.13 — must precede the generic 'AuthZ:' catch-all below: a
6406
+ // CWE-862 (Missing Authorization) finding from authz.js's PHP IDOR
6407
+ // detector also starts with "AuthZ:", and this array's first-match-wins
6408
+ // iteration would otherwise sweep it into the SAME 'idor' family as every
6409
+ // OTHER authz.js pattern (JWT/OAuth/session-fixation/CWE-639), overriding
6410
+ // finding-defaults.js's own correct CWE-862 -> 'missing-authz' mapping —
6411
+ // confirmed as the exact reason a genuinely firing, correctly-CWE-tagged
6412
+ // finding never scored a tp (dedupeFindingsWithEvidence runs before the
6413
+ // finding-defaults backfill and stamps `f.family` first).
6414
+ ['AuthZ: raw SQL where-by-id from request input without an ownership check', 'missing-authz'],
6415
+ ['AuthZ: fopen() on request-supplied path/id without an ownership check', 'missing-authz'],
6416
+ ['AuthZ: XPath query built from request input without an ownership check', 'missing-authz'],
6386
6417
  ['AuthZ:', 'idor'],
6387
6418
  ['MD5/SHA1', 'weak-crypto'],
6388
6419
  ['Weak Cryptographic Hash', 'weak-crypto'],
@@ -6396,6 +6427,41 @@ const _VULN_FAMILY_PREFIX = [
6396
6427
  ['OpenSSH Private Key', 'hardcoded-secret'],
6397
6428
  ['Password in URL', 'hardcoded-secret'],
6398
6429
  ['OAuth Authorization Code Theft', 'open-redirect'],
6430
+ // SARD_80_F1 W4: every IR-TAINT open-redirect catalog sink names its own
6431
+ // API in the vuln string ("Open Redirect (Controller.Redirect)",
6432
+ // "Open Redirect (response.sendRedirect)", "Open Redirect (flask.redirect)",
6433
+ // "Open Redirect (Rails redirect_to)", ...) — with no prefix entry here,
6434
+ // each one fell through to the generic auto-slug fallback and got its OWN
6435
+ // unique family string, distinct from open-redirect.js's/csharp.js's
6436
+ // explicit `family: 'open-redirect'`. Two detectors correctly finding the
6437
+ // SAME real vulnerability at the SAME line then failed to collapse via
6438
+ // dedupeFindingsWithEvidence's (file, sink-line, family) key, producing an
6439
+ // extra unmatched/scored FP — the exact bug class found in W4.J15 for
6440
+ // Java's LDAP rule, here caused by family-string divergence instead of a
6441
+ // wrong line. Found via the C# public mirror: CWE601_Open_Redirect__Web_
6442
+ // QueryString_Web_01.cs's resp.Redirect(data) produced both a `CSHARP`
6443
+ // finding (family 'open-redirect') and an `IR-TAINT` finding (family
6444
+ // 'open-redirect-controller-redirect') at the identical line.
6445
+ ['Open Redirect', 'open-redirect'],
6446
+ // Same bug, same fix, found by checking every other injection family with
6447
+ // BOTH a structural detector AND IR-TAINT catalog sinks for the identical
6448
+ // divergence: every CWE-113 IR-TAINT sink's vuln string starts with "HTTP
6449
+ // Response Splitting" (12 catalog entries across Java/JS/PHP/Go/C#), with
6450
+ // no prefix entry here — each fell through to its own unique auto-slug
6451
+ // family, distinct from `response-splitting.js`'s explicit
6452
+ // `family: 'response-splitting'`. Verified directly: a Java servlet
6453
+ // `response.setHeader("X-User", request.getParameter(name))` probe produced
6454
+ // both a `RESPONSE-SPLITTING` finding (family 'response-splitting') and an
6455
+ // `IR-TAINT` finding (family 'http-response-splitting-header-injection') at
6456
+ // the identical line. CWE-113 has carried this session's largest persistent
6457
+ // fp counts (399 on Java dev, 348 on C# train, per W4.C1) and this exact
6458
+ // duplicate-detection mechanism was never checked as a contributing cause
6459
+ // during the earlier sanitizer-focused CWE-113 investigation (commit
6460
+ // 4f41daec) — distinct from `test/benchmark/expected.json`'s `_familyMap`
6461
+ // (the SEPARATE table that resolves scoring family from vuln text and
6462
+ // already maps this same prefix to 'header-hardening'; this table only
6463
+ // controls ENGINE-level dedup, not scoring attribution).
6464
+ ['HTTP Response Splitting', 'response-splitting'],
6399
6465
  ['Synchronous Blocking I/O', 'dos-sync-io'],
6400
6466
  ['Missing Timeout', 'dos-no-timeout'],
6401
6467
  ['GraphQL Missing Query', 'graphql-dos'],
@@ -9116,6 +9182,25 @@ function _deterministicFileTimings(timings) {
9116
9182
  }
9117
9183
  const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
9118
9184
  aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
9185
+ aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
9186
+ // SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
9187
+ // this one SUPPRESSES an already-emitted java-structural.js or
9188
+ // java-bench-extras.js finding when every real caller (checked project-
9189
+ // wide within the finding's own directory) agrees the concatenated/
9190
+ // redirected value is a literal. See java-structural-cross-file.js's own
9191
+ // header comment for the full design and W5.40's EXECUTION_STATUS.md
9192
+ // entry for the root-cause investigation.
9193
+ {
9194
+ const _jsCrossSuppress = runDetector(_detectorErrors,'<project>','computeJavaStructuralCrossFileSuppressions',()=>computeJavaStructuralCrossFileSuppressions(fc));
9195
+ if (_jsCrossSuppress && _jsCrossSuppress.size) {
9196
+ for (let _i = aF.length - 1; _i >= 0; _i--) {
9197
+ const _f = aF[_i];
9198
+ if (_f && typeof _f.id === 'string' && (_f.id.startsWith('java-struct-') || _f.id.startsWith('java-extras:open-redirect:')) && _jsCrossSuppress.has(`${_f.file}:${_f.line}`)) {
9199
+ aF.splice(_i, 1);
9200
+ }
9201
+ }
9202
+ }
9203
+ }
9119
9204
  aF.push(...(runDetector(_detectorErrors,'<project>','scanStoredPromptInjectionCrossFile',()=>scanStoredPromptInjectionCrossFile(fc))||[]));
9120
9205
  // Roadmap #8 — tree-sitter sinks for long-tail languages (opt-in,
9121
9206
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
@@ -10276,7 +10361,27 @@ function _deterministicFileTimings(timings) {
10276
10361
  // SCA-SAST correlation: link SAST findings to SCA vulnerable packages
10277
10362
  try{for(const f of finalFindings){if(!f.chain||!f.chain.length)continue;const src=f.chain[0]?.label||'';for(const sc of supplyChain){if(sc.type!=='vulnerable_dep')continue;if(src.includes(sc.name)||f.vuln?.toLowerCase().includes(sc.name)){f.scaCorrelation={osvId:sc.osvId,package:sc.name,version:sc.version,confirmed:true};sc.sastConfirmed=true;break;}}}}catch(_){}
10278
10363
  // Multi-sink chain detection: group findings by source variable
10279
- try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',parser:'MULTI-SINK',confidence:0.85,sinks:group.map(f=>({file:f.file,line:f.line,vuln:f.vuln})),_aggregated:true});}}catch(_){}
10364
+ // SARD_80_F1 W4.J22/J23: this aggregate previously ALWAYS pushed a brand
10365
+ // new finding hardcoding cwe:'CWE-20' with no `family`, regardless of what
10366
+ // the grouped sinks actually were. When every sink in the group shares one
10367
+ // real family/CWE — the common case: several sinks along the same
10368
+ // injection-class chain, e.g. the same tainted var reaching two
10369
+ // db.query() calls — that new finding duplicated a per-sink finding
10370
+ // ALREADY in `finalFindings` at the identical (file, line, family, cwe):
10371
+ // a first attempt here just relabeled the aggregate to the shared
10372
+ // family/CWE, expecting it to collapse via dedupeFindingsWithEvidence's
10373
+ // (file, sink-line, family) key — but this pass runs AFTER dedupe
10374
+ // (line ~9467), so the relabeled duplicate was never actually removed; a
10375
+ // real corpus A/B confirmed zero net fp movement, only a relabel. Fixed
10376
+ // properly: in the homogeneous case, don't push a second finding at all —
10377
+ // attach the chain metadata (source + full sink list) onto the existing
10378
+ // per-sink finding instead, so the "one source, N sinks" information
10379
+ // survives without a duplicate, separately-scored finding. Only a
10380
+ // genuinely mixed-CWE chain (a real, distinct "multiple vulnerability
10381
+ // classes from one source" finding, not a duplicate of any single
10382
+ // existing finding) still gets its own standalone finding, tagged with
10383
+ // the honest generic CWE-20/'multi-sink-taint-chain' label.
10384
+ try{const srcGroups=new Map();for(const f of finalFindings){const src=f.chain?.[0]?.label;if(!src)continue;if(!srcGroups.has(src))srcGroups.set(src,[]);srcGroups.get(src).push(f);}for(const[src,group]of srcGroups){if(group.length<2)continue;const groupId=`multi-sink:${src}:${group.length}`;for(const f of group)f._multiSinkGroupId=groupId;const sharedFamily=group.every(f=>f.family&&f.family===group[0].family)?group[0].family:null;const sharedCwe=group.every(f=>f.cwe&&f.cwe===group[0].cwe)?group[0].cwe:null;const sinks=group.map(f=>({file:f.file,line:f.line,vuln:f.vuln}));if(sharedFamily&&sharedCwe){for(const f of group)f.multiSinkChain={source:src,sinkCount:group.length,sinks};continue;}finalFindings.push({id:groupId,file:group[0].file,line:group[0].line,vuln:`Multi-Sink Taint Chain — ${src} reaches ${group.length} sinks`,severity:group.some(f=>f.severity==='critical')?'critical':'high',cwe:'CWE-20',family:'multi-sink-taint-chain',parser:'MULTI-SINK',confidence:0.85,sinks,_aggregated:true});}}catch(_){}
10280
10385
  // SCA transitive dedup: collapse duplicate CVEs across dep chains
10281
10386
  try{const osvGroups=new Map();for(const sc of supplyChain){if(sc.type!=='vulnerable_dep'||!sc.osvId)continue;if(!osvGroups.has(sc.osvId))osvGroups.set(sc.osvId,[]);osvGroups.get(sc.osvId).push(sc);}for(const[osvId,group]of osvGroups){if(group.length<=1)continue;const primary=group.find(s=>s.isDirect)||group[0];primary.dependents=group.filter(s=>s!==primary).map(s=>({name:s.name,version:s.version,depChain:s.depChain,isDirect:s.isDirect}));primary._transitiveDeduped=group.length-1;for(const dup of group){if(dup!==primary)dup._deduplicatedInto=primary.osvId;}supplyChain.splice(0,supplyChain.length,...supplyChain.filter(s=>!s._deduplicatedInto));}}catch(_){}
10282
10387
  // ── World-class post-scan artifact emitters ──────────────────────────
package/src/ir/CLAUDE.md CHANGED
@@ -31,7 +31,8 @@ consumed by `scanner/src/dataflow/` for taint analysis.
31
31
 
32
32
  **Two further gaps found while building the corpus entry above, NOT fixed (out of scope for trailing-lambda body recursion, routed around instead — same pattern as Java's chained-call CFG bug):** (a) a call with NO args made on a tainted receiver via a dotted callee string — e.g. `it.toString()`, `tainted.trim()` — does not inherit the receiver's taint. `engine.js`'s `exprTaint` for `case 'call'` checks only `expr.args` and the resolved callee's own return-taint summary (`_nestedCallReturnTainted`); a callee string like `"it.toString"` is never parsed back into a member expression to ask whether its OWN receiver (`it`) is tainted. This is not Kotlin-specific (any language whose IR lowers a method call to a bare dotted-string callee has the same exposure), so a real fix belongs in `engine.js`, not here. (b) `call.parameters` (Ktor's own cataloged `member`-type source) does not propagate taint through a subsequent `.getAll(...)` call made on top of it — `call.parameters.getAll("id")` reads as untainted, because the catalog entry matches a bare property READ, not a call whose receiver is that property. Both gaps were discovered via a fixture that initially used these exact shapes and silently produced zero findings; each was routed around (by using a no-transform binding and a `call`-type source instead) rather than fixed, to keep this task scoped to CFG body recursion. **Taint-recall PRD (80%) Tier 5: `kt-file-readtext`/`kt-url-readtext` catalog cross-collision.** Found via `CVE-2022-22965-kt-ssrf`: both entries were unscoped bare `callee: 'readText'` (path-traversal and SSRF sinks sharing the identical last-segment name), so a single `URL(url).readText()` call fired BOTH — the correct SSRF finding and a spurious Path Traversal finding on the same line. Fixed with receiver scoping (`^File` / `^URL`), the same precision pattern `match.receiver` uses throughout this catalog. Guarded by `test/catalog-path-ssrf-p3.test.js`; required updating `test/phase2-scoping.test.js`'s existing `matchSinkOrSanitizer('readText', 'A.java')` call (a bare string with no receiver context, which the new requirement correctly rejects) to a realistic dotted-string shape (`'File.readText'`). |
33
33
  | C / C++ | `parser-cpp.js` | Hand-rolled parser (functions, qualified names, CFG lowering). Dispatched by extension (`c/cc/cpp/cxx/h/hh/hpp/hxx`) in both `buildProjectIR` and `buildProjectIRAsync`. |
34
- | Long-tail (rust/solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. |
34
+ | Rust | `parser-rust.js` | Hand-rolled, the `parser-go.js`/`parser-cs.js` pattern (preprocess: comments blanked, `::` paths flattened to dots, turbofish/`?`/`.await` stripped; then statement-split + recursive CFG build). **SARD 80% F1 push, first-class from day one — no prior IR existed for Rust at all** (only the opt-in, WASM-based `tree-sitter-loader.js` long-tail path below, which nothing in the taint engine consumes). Models: `fn` items (free/impl/trait, qualifiers, generics, `impl Type` methods named `Type.method`), `let`/destructuring/compound-assign, method chains (args accumulated innermost-first, so `Command::new("sh").arg("-c").arg(x)` has the literal program name at arg 0 regardless of the chain's terminal method), macros (`format!`/`write!`/etc. lower to a template with inline `{ident}` and positional args; `vec!` to an array; every other macro keeps its own `name!` callee so `sqlx::query!` — the compile-checked, parameterized macro — never collides with the `query` sink); `if`/`if let`/`while`/`while let`/`loop`/`for`/`match` (pattern bindings assigned from the scrutinee) including expression-position control flow (`let x = match … {}` yields a `union` of the branch tails); trailing closures inlined with their parameter bound to the receiver; extractor-typed parameters (`Query(q): Query<P>`) and route-attributed handlers (`#[get("/<x>")]`) emit `fn.paramAnnotations`. **`.unwrap()`/`.expect(msg)` are popped from the chain, not just taint-transparent** — leaving them in shifts the terminal segment away from the real sink name (`sqlx::query(&sql).fetch_all(pool).await.unwrap()` would otherwise end in `.unwrap`, never `.fetch_all`, and no catalog entry could ever match it), the same "terminal segment shift" defect class documented for Go/Java/Kotlin above; a macro-only statement whose sole continuation is a popped `.unwrap()` (`write!(w, "…", x).unwrap();`) still counts as a call, tracked via a separate `_baseIsCall` flag so it doesn't fall through to "no call happened here" once the passthrough is gone. Three self-scan-flagged regexes (`(?:#\[[^\]]*\]\s*)+`, the attribute-skip pattern, appearing at three call sites) match the "optional/quantified group flanked by `\s*`" ReDoS heuristic shape but are linear by construction — each iteration is bounded by the mandatory literal `#[`…`]`, no shared character class exists for the engine to backtrack across — confirmed by direct timing (50 000 back-to-back attributes, 200 000 non-matching whitespace chars: both single-digit milliseconds), not merely asserted; see the comment at `_parseParams` in `parser-rust.js`. Guarded by `test/parser-rust.test.js` (CFG shape) and `test/rust-taint-flow.test.js` (end-to-end taint flow + precision cases). **Measured `bench/layer-recall` impact: 0 → 5/5 (100%) whole-corpus, 0 → 3/3 (100%) deep-tier** on first measurement — the corpus is small and every entry is annotation-sourced (axum/actix/rocket extractor parameters route directly into `paramAnnotations`), so 100% reflects the corpus's own composition, not general Rust ecosystem coverage; see `docs/METRICS.md`'s own disclosure. |
35
+ | Long-tail (solidity/go/swift/dart) | `tree-sitter-loader.js` | **Optional** `web-tree-sitter` + `tree-sitter-wasms` (ABI-pinned 0.20.8 ↔ 0.1.13), lazy + degrades when absent. Powers `sast/tree-sitter-sinks.js` (opt-in via `AGENTIC_SECURITY_TREE_SITTER=1`). Marked `--external` in the build so the committed bundle never embeds WASM. Rust's own tree-sitter grammar remains loadable here too (unaffected by `parser-rust.js` existing), but the hand-rolled parser above is what feeds the taint engine — this path is now redundant for Rust specifically, kept only because removing a language from `tree-sitter-sinks.js`'s opt-in surface is out of scope for this change. |
35
36
 
36
37
  ## Python parser — dual-path with auto fallback
37
38
 
@@ -22,12 +22,44 @@
22
22
  // simply left unconsumed rather than corrupting anything. Recovering the
23
23
  // first call's real signature is a strict improvement over the prior
24
24
  // choice between silent corruption and no match at all.
25
- export function matchBalancedCall(s, calleeRe) {
25
+ export function matchBalancedCall(s, calleeRe, opts) {
26
26
  if (typeof s !== 'string') return null;
27
27
  const m = calleeRe.exec(s);
28
28
  if (!m || m.index !== 0) return null;
29
29
  let i = m[0].length;
30
30
  while (s[i] === ' ' || s[i] === '\t') i++;
31
+ // SARD_80_F1 W2.3 follow-up (opt-in via `opts.skipGenerics`, used only by
32
+ // parser-cs.js): C#'s `new List<string>()` / `new Dictionary<string,
33
+ // int>()` has a generic type-argument list BETWEEN the class name and the
34
+ // constructor's own `(`. Without this, `new\s+([\w.]+)` matches only
35
+ // "List", the character right after it is `<` (not `(`), and the whole
36
+ // match fails — the constructor call silently lowers to `{kind:'unknown'}`
37
+ // instead of a proper `isNew:true` call, which is why `List<T>` (C#'s
38
+ // dominant MODERN collection idiom, unlike the non-generic `ArrayList`
39
+ // fixed alongside this) never even reached the collection-taint mutator
40
+ // rule at all. Deliberately opt-in, not the default: `<`/`>` are also
41
+ // comparison operators, and Go/PHP/Ruby (this helper's other three
42
+ // callers) have no equivalent "generic-args-before-a-call" shape, so
43
+ // widening the default here would be pure unvalidated risk for them.
44
+ // Scans for BALANCED angle brackets only (handles nested generics like
45
+ // `Dictionary<string, List<int>>`); if it doesn't find a clean balanced
46
+ // `<...>` immediately here, this leaves `i` untouched and falls through
47
+ // to the ordinary `(`-required check below, same as before this existed.
48
+ if (opts && opts.skipGenerics && s[i] === '<') {
49
+ let j = i + 1;
50
+ let depth = 1;
51
+ while (j < s.length && depth > 0) {
52
+ if (s[j] === '<') depth++;
53
+ else if (s[j] === '>') depth--;
54
+ else if (s[j] === '(' || s[j] === ')' || s[j] === ';') { depth = -1; break; } // not a generic-args list — bail
55
+ j++;
56
+ }
57
+ if (depth === 0) {
58
+ let k = j;
59
+ while (s[k] === ' ' || s[k] === '\t') k++;
60
+ if (s[k] === '(') i = k;
61
+ }
62
+ }
31
63
  if (s[i] !== '(') return null;
32
64
  const openIdx = i;
33
65
  let depth = 0;
@@ -59,16 +59,40 @@ export function buildCallGraph(perFileIR, fileContents) {
59
59
  // missing one.
60
60
  const bareTailInFile = new Map(); // file -> Map<bareTail, qid|null>
61
61
 
62
+ // PRD W1 (SARD_80_F1_EXECUTION_PRD.md) — `--scramble-identifiers` renames
63
+ // every class to a lowercase `case_<hash>` token, so the qid's middle
64
+ // `::ClassName::` segment no longer starts with an uppercase letter even
65
+ // though it IS still a real, structurally-declared class. Wherever a
66
+ // file's own parser emits real `ir.classes` facts (Java/C#, today —
67
+ // Python/PHP/Ruby/Go/Kotlin don't yet, tracked as W1.1), trust THAT
68
+ // instead of a capitalization guess: a name is a real class if and only
69
+ // if it was independently parsed as a class declaration, which is exactly
70
+ // as true under scrambled names as under real ones (the class-declaration
71
+ // parser doesn't care about capitalization either). Falls back to the old
72
+ // PascalCase heuristic for files whose language doesn't emit ir.classes
73
+ // yet, so nothing regresses ahead of W1.1 landing per-language.
74
+ const knownClassNamesByFile = new Map();
75
+ for (const file of Object.keys(perFileIR || {})) {
76
+ const ir = perFileIR[file];
77
+ if (ir && Array.isArray(ir.classes) && ir.classes.length) {
78
+ knownClassNamesByFile.set(file, new Set(ir.classes.map(c => c.name)));
79
+ }
80
+ }
81
+
62
82
  for (const file of Object.keys(perFileIR || {})) {
63
83
  const ir = perFileIR[file];
64
84
  if (!ir || !ir.functions) continue;
65
85
  byNameInFile.set(file, new Map());
66
86
  bareTailInFile.set(file, new Map());
87
+ const knownClasses = knownClassNamesByFile.get(file);
67
88
  for (const fn of ir.functions) {
68
89
  functions.set(fn.qid, fn);
69
90
  byNameInFile.get(file).set(fn.name, fn.qid);
70
- const m = fn.qid.match(/::([A-Z]\w*)::(\w+)@/);
71
- if (m) classMethods.set(`${m[1]}.${m[2]}`, fn.qid);
91
+ const m = fn.qid.match(/::(\w+)::(\w+)@/);
92
+ if (m) {
93
+ const isRealClass = knownClasses ? knownClasses.has(m[1]) : /^[A-Z]/.test(m[1]);
94
+ if (isRealClass) classMethods.set(`${m[1]}.${m[2]}`, fn.qid);
95
+ }
72
96
  if (fn.name && fn.name.includes('.')) {
73
97
  const tail = fn.name.split('.').pop();
74
98
  const fileMap = bareTailInFile.get(file);
@@ -44,6 +44,18 @@ export function buildClassHierarchy(perFileIR) {
44
44
  const classes = new Map(); // className -> { file, line, methods, extends }
45
45
  const methodOwners = new Map(); // qid -> className
46
46
  const typeOfVar = new Map(); // file::scope::var -> className
47
+ // A variable assigned two DIFFERENT constructed types within one function
48
+ // must never resolve to either — same "refuse to guess on ambiguity"
49
+ // convention `_localVarConstructedTypes` (parser-java.js/parser-cs.js)
50
+ // already enforces at parse time for the callee-string-rewrite path. This
51
+ // map had NO ambiguity tracking of its own (last assign silently won) —
52
+ // latent since day one, but never exercised for hand-rolled-parser
53
+ // languages because `_resolveMemberCalleeViaCHA` (engine.js) only ever
54
+ // consulted `classOfVar` for the Babel `{kind:'member'}` shape (JS-only)
55
+ // until PRD W2.5 extended it to flat dotted-string callees too. Caught by
56
+ // `test/catalog-cs-p1.test.js`'s existing ambiguity-refusal regression
57
+ // test the moment that extension shipped.
58
+ const ambiguousVarKeys = new Set();
47
59
 
48
60
  if (!perFileIR || typeof perFileIR !== 'object') {
49
61
  return { classes, methodOwners, typeOfVar };
@@ -59,15 +71,37 @@ export function buildClassHierarchy(perFileIR) {
59
71
  if (!c || !c.name) continue;
60
72
  let cls = classes.get(c.name);
61
73
  if (!cls) {
62
- cls = { name: c.name, file, line: c.line || 0, methods: new Set(), extends: null };
74
+ cls = { name: c.name, file, line: c.line || 0, methods: new Set(), extends: null, bases: [], fields: new Set() };
63
75
  classes.set(c.name, cls);
64
76
  }
77
+ if (!cls.bases) cls.bases = [];
78
+ if (!cls.fields) cls.fields = new Set();
65
79
  // v1 keeps a single base: the CHA walk in resolveMethod follows one
66
80
  // chain. Multiple inheritance is flattened to the first base, which is
67
81
  // a deliberate over-simplification recorded in PRD §6.8.
68
82
  if (!cls.extends && Array.isArray(c.bases) && c.bases.length) {
69
83
  cls.extends = c.bases[0];
70
84
  }
85
+ // Full base/interface list (SARD_80_F1_SCANNER_PRD.md — abstract/
86
+ // interface-receiver dispatch, Juliet flow variants 81/82): `extends`
87
+ // above follows exactly one chain for resolveMethod's walk, but a
88
+ // receiver typed as an INTERFACE (`IAction b = new BadImpl();`) needs
89
+ // to know every implemented name, not just the first. Purely additive
90
+ // — nothing previously read `cls.bases`.
91
+ if (Array.isArray(c.bases)) {
92
+ for (const b of c.bases) if (b && !cls.bases.includes(b)) cls.bases.push(b);
93
+ }
94
+ // Declared field names (SARD_80_F1_SCANNER_PRD.md — cross-method field
95
+ // taint, Juliet flow variants 45/65-68): lets the taint engine tell a
96
+ // field write (`sf = data;`, `this.inst = data;`) apart from an
97
+ // ordinary local variable with the same name in a DIFFERENT method of
98
+ // the same class, without which a field write in one method could
99
+ // never be told to taint a read in another. Merged across every file
100
+ // that contributes to this class (partial classes, or a parser that
101
+ // emits one `ir.classes` entry per method's enclosing file).
102
+ if (Array.isArray(c.fields)) {
103
+ for (const f of c.fields) if (f) cls.fields.add(f);
104
+ }
71
105
  }
72
106
  }
73
107
  if (!Array.isArray(ir.functions)) continue;
@@ -94,9 +128,15 @@ export function buildClassHierarchy(perFileIR) {
94
128
  if (dotIdx > 0) {
95
129
  className = tail.slice(0, dotIdx);
96
130
  methodName = tail.slice(dotIdx + 1).replace(/@\d+#[0-9a-f]+$/, '');
97
- } else if (segs.length >= 3 && /^[A-Z]/.test(segs[segs.length - 2])) {
98
- // Gated on the second-to-last segment being PascalCase so this
99
- // doesn't misfire on an ordinary nested-function scope segment.
131
+ } else if (segs.length >= 3 && (classes.has(segs[segs.length - 2]) || /^[A-Z]/.test(segs[segs.length - 2]))) {
132
+ // PRD W1 (SARD_80_F1_EXECUTION_PRD.md) — `classes` was already
133
+ // populated from this file's real `ir.classes` structural facts
134
+ // above (lines 57-94), so `classes.has(...)` recognizes a scrambled
135
+ // class name (`case_<hash>`, no longer PascalCase) exactly as
136
+ // reliably as a real one. The `/^[A-Z]/` fallback stays for
137
+ // languages that don't emit `ir.classes` yet (JS/Python/PHP/Ruby/Go/
138
+ // Kotlin — W1.1) so an ordinary nested-function scope segment still
139
+ // doesn't misfire there, same as before this change.
100
140
  className = segs[segs.length - 2];
101
141
  // Two sequential strips, not one `(#[0-9a-f]+)?` optional group: the
102
142
  // combined form is flagged by this project's own self-scan gate
@@ -113,7 +153,7 @@ export function buildClassHierarchy(perFileIR) {
113
153
  methodOwners.set(fn.qid, className);
114
154
  let cls = classes.get(className);
115
155
  if (!cls) {
116
- cls = { name: className, file, line: fn.line || 0, methods: new Set(), extends: null };
156
+ cls = { name: className, file, line: fn.line || 0, methods: new Set(), extends: null, bases: [], fields: new Set() };
117
157
  classes.set(className, cls);
118
158
  }
119
159
  cls.methods.add(methodName);
@@ -160,10 +200,65 @@ export function buildClassHierarchy(perFileIR) {
160
200
  if (classes.has(className) || /^[A-Z]/.test(className)) {
161
201
  // Convention: PascalCase `new` callees treated as constructors.
162
202
  const target = typeof n.target === 'string' ? n.target : null;
163
- if (target) typeOfVar.set(`${file}::${fn.qid}::${target}`, className);
203
+ if (target) {
204
+ const key = `${file}::${fn.qid}::${target}`;
205
+ if (ambiguousVarKeys.has(key)) continue;
206
+ if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
207
+ ambiguousVarKeys.add(key);
208
+ typeOfVar.delete(key);
209
+ } else {
210
+ typeOfVar.set(key, className);
211
+ }
212
+ }
164
213
  }
165
214
  }
166
215
  }
216
+ // PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — a PARAMETER's declared type is
217
+ // just as real a type binding as a local `let x = new Foo()`, and it's
218
+ // Juliet's OWN dominant abstract/interface dispatch idiom: the "runTest"
219
+ // driver constructs the concrete (bad/good) instance and passes it as a
220
+ // parameter to a helper that invokes the virtual method — `_localVarConstructedTypes`
221
+ // (parser-java.js/parser-cs.js) only ever sees the CONSTRUCTOR site, not
222
+ // this call site, so `b` in `void helper(BaseType b, ...) { b.action(x); }`
223
+ // was completely untyped. Reuses the EXACT same `typeOfVar` map and key
224
+ // shape `classOfVar` already reads — no new lookup mechanism, so every
225
+ // existing consumer (engine.js's `_resolveMemberCalleeViaCHA` once it
226
+ // learns to read a flat dotted-string callee, `receiver-context.js`)
227
+ // benefits with no changes on their end. Only populated when
228
+ // `fn.paramTypes` exists (currently parser-java.js; parser-cs.js is a
229
+ // candidate follow-up, same shape). Deliberately does NOT overwrite an
230
+ // existing binding for the same key — a parameter name can never collide
231
+ // with a local var name in the same scope in Java, so this is purely
232
+ // additive, but the guard costs nothing and documents the intent.
233
+ for (const fn of ir.functions) {
234
+ if (!fn.paramTypes) continue;
235
+ for (const [paramName, paramType] of Object.entries(fn.paramTypes)) {
236
+ const key = `${file}::${fn.qid}::${paramName}`;
237
+ if (!typeOfVar.has(key) && !ambiguousVarKeys.has(key)) typeOfVar.set(key, paramType);
238
+ }
239
+ }
240
+ // SARD_80_F1 W4: a local's DECLARED type (`PrintWriter out =
241
+ // response.getWriter();`, parser-java.js's `declaredType` on the assign
242
+ // node) is the third source of a type binding, alongside a constructor
243
+ // call above and a parameter's declared type. This is Java's dominant
244
+ // "get an object via a factory/getter method, not `new`" idiom — the
245
+ // constructor pass above can never see it (there is no `new` at all).
246
+ // Deliberately consulted via `receiverTypeIn` only (additive — see
247
+ // `catalog.js`'s `java-writer-*` entries), never via the destructive
248
+ // callee-string rewrite `parser-java.js`'s `_localVarConstructedTypes`
249
+ // does for constructor types: that mechanism was tried for declared
250
+ // types too and reverted after it broke an unrelated, name-scoped
251
+ // catalog entry (see its own comment). Populated by both
252
+ // parser-java.js and parser-cs.js (C#'s `Type x = expr;` decl-typeClause
253
+ // capture, same field name and rationale).
254
+ for (const fn of ir.functions) {
255
+ if (!fn.cfg || !fn.cfg.nodes) continue;
256
+ for (const node of Object.values(fn.cfg.nodes)) {
257
+ if (node.kind !== 'assign' || !node.declaredType || typeof node.target !== 'string' || node.target.includes('.')) continue;
258
+ const key = `${file}::${fn.qid}::${node.target}`;
259
+ if (!typeOfVar.has(key) && !ambiguousVarKeys.has(key)) typeOfVar.set(key, node.declaredType);
260
+ }
261
+ }
167
262
  }
168
263
 
169
264
  return { classes, methodOwners, typeOfVar };
@@ -47,6 +47,10 @@ const TYPE_MODIFIERS = new Set(['readonly', 'const', 'static', 'public', 'privat
47
47
 
48
48
  const BUILTIN_TYPES = new Set(['void', 'bool', 'byte', 'sbyte', 'char', 'short', 'ushort', 'int', 'uint', 'long', 'ulong', 'float', 'double', 'decimal', 'string', 'object', 'var', 'dynamic']);
49
49
 
50
+ // SARD_80_F1 W4.C11 — compound assignment operators, tokenized as single
51
+ // 'op' tokens by csharp-tokenizer.js but previously never checked for here.
52
+ const CS_COMPOUND_ASSIGN_OPS = new Set(['+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '<<=', '>>=', '??=']);
53
+
50
54
  function isType(tok) {
51
55
  if (!tok) return false;
52
56
  if (tok.kind === 'kw' && BUILTIN_TYPES.has(tok.value)) return true;
@@ -268,7 +272,7 @@ function readMethodHeader(tokens, i, attachedAttrs) {
268
272
  }
269
273
 
270
274
  function walkMethodBody(method) {
271
- const out = { calls: [], ctors: [], assignments: [], decls: [], strings: [] };
275
+ const out = { calls: [], ctors: [], assignments: [], decls: [], strings: [], returns: [] };
272
276
  const tokens = method.bodyTokens;
273
277
  for (let i = 0; i < tokens.length; i++) {
274
278
  const t = tokens[i];
@@ -277,6 +281,45 @@ function walkMethodBody(method) {
277
281
  out.strings.push({ kind: t.kind, value: t.value || tokenText([t]), line: t.line, parts: t.parts || null });
278
282
  continue;
279
283
  }
284
+ // `return <expr>;` (bare `return;` produces no expr and is skipped —
285
+ // interprocedural return-taint resolution has nothing to check there).
286
+ // SARD_80_F1 W3.x: gives `analyzeMethodFlow` visibility into what a
287
+ // same-file callee actually returns, so a call-shaped assignment RHS
288
+ // (`data = GoodG2BSource(req, resp);`) can be resolved against the
289
+ // callee's OWN return taint instead of blindly matching every
290
+ // identifier NAME in the call text (which wrongly taints on `req`/
291
+ // `resp` merely being passed as arguments — see the taintMap loop
292
+ // below and its `_asBareCall`/`resolveCalleeReturnTaint` usage).
293
+ if (t.kind === 'kw' && t.value === 'return') {
294
+ let j = i + 1;
295
+ let depth = 0;
296
+ while (j < tokens.length && (depth > 0 || tokens[j].kind !== 'semi')) {
297
+ const tk = tokens[j];
298
+ if (tk.kind === 'lparen' || tk.kind === 'lbrace' || tk.kind === 'lbracket') depth++;
299
+ else if (tk.kind === 'rparen' || tk.kind === 'rbrace' || tk.kind === 'rbracket') {
300
+ // SARD_80_F1 W3.x — an unbalanced closer (depth already 0) closes
301
+ // an ENCLOSING construct we didn't open (e.g. this `return` sits
302
+ // inside a `using (...)`/`if (...)` clause with no `;` of its
303
+ // own before the surrounding `)`), not part of our own
304
+ // expression — stop here rather than scanning past it into
305
+ // whatever follows. See the identical fix on the decl/assign/
306
+ // field loops below for the full incident writeup.
307
+ if (depth === 0) break;
308
+ depth--;
309
+ }
310
+ j++;
311
+ }
312
+ const exprTokens = tokens.slice(i + 1, j);
313
+ if (exprTokens.length) {
314
+ out.returns.push({ line: t.line, exprTokens, exprText: tokenText(exprTokens) });
315
+ }
316
+ // Deliberately do NOT skip past the expression tokens (`i = j`): the
317
+ // main loop's own ident/call/assignment branches below still need to
318
+ // walk them, so a statement-level call inside the return
319
+ // (`return string.Format(...)`) is still recorded in `out.calls`
320
+ // exactly as it was before this branch existed.
321
+ continue;
322
+ }
280
323
  // new Type(args)
281
324
  if (t.kind === 'kw' && t.value === 'new') {
282
325
  const tr = readType(tokens, i + 1);
@@ -301,8 +344,27 @@ function walkMethodBody(method) {
301
344
  let j = tr.next + 2;
302
345
  let depth = 0;
303
346
  while (j < tokens.length && (depth > 0 || tokens[j].kind !== 'semi')) {
304
- if (tokens[j].kind === 'lparen' || tokens[j].kind === 'lbrace' || tokens[j].kind === 'lbracket') depth++;
305
- if (tokens[j].kind === 'rparen' || tokens[j].kind === 'rbrace' || tokens[j].kind === 'rbracket') depth--;
347
+ const tk = tokens[j];
348
+ if (tk.kind === 'lparen' || tk.kind === 'lbrace' || tk.kind === 'lbracket') depth++;
349
+ else if (tk.kind === 'rparen' || tk.kind === 'rbrace' || tk.kind === 'rbracket') {
350
+ // SARD_80_F1 W3.x — a declaration with no `;` of its own,
351
+ // sitting inside an enclosing `using (Type x = expr)` / `for
352
+ // (Type x = expr; …)` clause: the FIRST `)` we see here is
353
+ // that outer construct's own close, not one WE opened (depth
354
+ // is already 0), so stop before consuming it. Previously this
355
+ // fell through to `depth--` unconditionally, going negative
356
+ // and then scanning straight through into the following
357
+ // block body looking for a `;` that could be arbitrarily far
358
+ // away — corrupting this decl's rhsText with everything up to
359
+ // that stray semicolon AND silently absorbing whatever real
360
+ // statement lived in between (found via SARD_80_F1 W4.C14:
361
+ // `using (StreamReader sr = new StreamReader("f")) { data =
362
+ // sr.ReadLine(); }` swallowed the `data = sr.ReadLine()`
363
+ // assignment into `sr`'s own rhsText, deleting it from
364
+ // `out.assignments` entirely).
365
+ if (depth === 0) break;
366
+ depth--;
367
+ }
306
368
  j++;
307
369
  }
308
370
  const rhs = tokens.slice(tr.next + 2, j);
@@ -329,12 +391,31 @@ function walkMethodBody(method) {
329
391
  targetParts.push(tokens[j + 1].value);
330
392
  j += 2;
331
393
  }
332
- if (tokens[j] && tokens[j].kind === 'op' && tokens[j].value === '=') {
394
+ // SARD_80_F1 W4.C11 — compound assignment (`+=`, `-=`, …) was
395
+ // previously invisible here (only bare `=` matched), so a statement
396
+ // like `cmd.CommandText += "..." + tainted + "...";` — Juliet's own
397
+ // universal convention for building CommandText — produced NO
398
+ // assignment record at all, silently dropping it from this
399
+ // detector's taint model. This model (`analyzeMethodFlow`, see
400
+ // csharp-analysis.js) has no un-taint step — a taintMap entry is
401
+ // only ever set true, never cleared — so treating the compound RHS
402
+ // exactly like a plain assign's RHS is already correct: if the
403
+ // target was tainted before, that status is never touched (no
404
+ // clearing branch exists); if this RHS is tainted, it becomes
405
+ // tainted now, same as a `+=` should.
406
+ if (tokens[j] && tokens[j].kind === 'op' &&
407
+ (tokens[j].value === '=' || CS_COMPOUND_ASSIGN_OPS.has(tokens[j].value))) {
333
408
  let k = j + 1;
334
409
  let depth = 0;
335
410
  while (k < tokens.length && (depth > 0 || tokens[k].kind !== 'semi')) {
336
- if (tokens[k].kind === 'lparen' || tokens[k].kind === 'lbrace' || tokens[k].kind === 'lbracket') depth++;
337
- if (tokens[k].kind === 'rparen' || tokens[k].kind === 'rbrace' || tokens[k].kind === 'rbracket') depth--;
411
+ const tk = tokens[k];
412
+ if (tk.kind === 'lparen' || tk.kind === 'lbrace' || tk.kind === 'lbracket') depth++;
413
+ else if (tk.kind === 'rparen' || tk.kind === 'rbrace' || tk.kind === 'rbracket') {
414
+ // SARD_80_F1 W3.x — see the decl "typed init" loop above for the
415
+ // full incident writeup; same enclosing-construct boundary fix.
416
+ if (depth === 0) break;
417
+ depth--;
418
+ }
338
419
  k++;
339
420
  }
340
421
  const rhs = tokens.slice(j + 1, k);
@@ -419,6 +500,7 @@ function walkMethodBody(method) {
419
500
  method.ctors = out.ctors;
420
501
  method.assignments = out.assignments;
421
502
  method.decls = out.decls;
503
+ method.returns = out.returns;
422
504
  method.strings = out.strings;
423
505
  return out;
424
506
  }
@@ -555,8 +637,15 @@ export function buildCSharpIR(source) {
555
637
  if (tokens[f] && tokens[f].kind === 'op' && tokens[f].value === '=') {
556
638
  let fj = f + 1, depth = 0;
557
639
  while (fj < tokens.length && (depth > 0 || tokens[fj].kind !== 'semi')) {
558
- if (tokens[fj].kind === 'lparen' || tokens[fj].kind === 'lbrace' || tokens[fj].kind === 'lbracket') depth++;
559
- if (tokens[fj].kind === 'rparen' || tokens[fj].kind === 'rbrace' || tokens[fj].kind === 'rbracket') depth--;
640
+ const tk = tokens[fj];
641
+ if (tk.kind === 'lparen' || tk.kind === 'lbrace' || tk.kind === 'lbracket') depth++;
642
+ else if (tk.kind === 'rparen' || tk.kind === 'rbrace' || tk.kind === 'rbracket') {
643
+ // SARD_80_F1 W3.x — see the decl "typed init" loop's
644
+ // header comment for the full incident writeup; same
645
+ // enclosing-construct boundary fix.
646
+ if (depth === 0) break;
647
+ depth--;
648
+ }
560
649
  fj++;
561
650
  }
562
651
  ir.decls.push({ name: tokens[tr.next].value, type: tr.type, rhsTokens: tokens.slice(f + 1, fj), rhsText: tokenText(tokens.slice(f + 1, fj)), line: tk.line, isVar: false, isField: true });