@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
@@ -39,11 +39,30 @@ const _AST_CACHE = new WeakMap();
39
39
  * Build the CHA over a perFileIR map (file → parsed IR with raw AST attached
40
40
  * under `_ast`). When AST isn't attached, fall back to the IR's own
41
41
  * structural hints (class names appearing in qids).
42
+ *
43
+ * `callGraph` (optional, from `../ir/callgraph.js#buildCallGraph`) enables
44
+ * one additional, otherwise-skipped pass: cross-call-site parameter type
45
+ * inference from the argument a caller actually passes (see that pass's own
46
+ * header comment below for the full rationale). Every existing caller that
47
+ * only ever passed `perFileIR` keeps working unchanged — the pass simply
48
+ * doesn't run without it.
42
49
  */
43
- export function buildClassHierarchy(perFileIR) {
50
+ export function buildClassHierarchy(perFileIR, callGraph) {
44
51
  const classes = new Map(); // className -> { file, line, methods, extends }
45
52
  const methodOwners = new Map(); // qid -> className
46
53
  const typeOfVar = new Map(); // file::scope::var -> className
54
+ // A variable assigned two DIFFERENT constructed types within one function
55
+ // must never resolve to either — same "refuse to guess on ambiguity"
56
+ // convention `_localVarConstructedTypes` (parser-java.js/parser-cs.js)
57
+ // already enforces at parse time for the callee-string-rewrite path. This
58
+ // map had NO ambiguity tracking of its own (last assign silently won) —
59
+ // latent since day one, but never exercised for hand-rolled-parser
60
+ // languages because `_resolveMemberCalleeViaCHA` (engine.js) only ever
61
+ // consulted `classOfVar` for the Babel `{kind:'member'}` shape (JS-only)
62
+ // until PRD W2.5 extended it to flat dotted-string callees too. Caught by
63
+ // `test/catalog-cs-p1.test.js`'s existing ambiguity-refusal regression
64
+ // test the moment that extension shipped.
65
+ const ambiguousVarKeys = new Set();
47
66
 
48
67
  if (!perFileIR || typeof perFileIR !== 'object') {
49
68
  return { classes, methodOwners, typeOfVar };
@@ -59,15 +78,37 @@ export function buildClassHierarchy(perFileIR) {
59
78
  if (!c || !c.name) continue;
60
79
  let cls = classes.get(c.name);
61
80
  if (!cls) {
62
- cls = { name: c.name, file, line: c.line || 0, methods: new Set(), extends: null };
81
+ cls = { name: c.name, file, line: c.line || 0, methods: new Set(), extends: null, bases: [], fields: new Set() };
63
82
  classes.set(c.name, cls);
64
83
  }
84
+ if (!cls.bases) cls.bases = [];
85
+ if (!cls.fields) cls.fields = new Set();
65
86
  // v1 keeps a single base: the CHA walk in resolveMethod follows one
66
87
  // chain. Multiple inheritance is flattened to the first base, which is
67
88
  // a deliberate over-simplification recorded in PRD §6.8.
68
89
  if (!cls.extends && Array.isArray(c.bases) && c.bases.length) {
69
90
  cls.extends = c.bases[0];
70
91
  }
92
+ // Full base/interface list (SARD_80_F1_SCANNER_PRD.md — abstract/
93
+ // interface-receiver dispatch, Juliet flow variants 81/82): `extends`
94
+ // above follows exactly one chain for resolveMethod's walk, but a
95
+ // receiver typed as an INTERFACE (`IAction b = new BadImpl();`) needs
96
+ // to know every implemented name, not just the first. Purely additive
97
+ // — nothing previously read `cls.bases`.
98
+ if (Array.isArray(c.bases)) {
99
+ for (const b of c.bases) if (b && !cls.bases.includes(b)) cls.bases.push(b);
100
+ }
101
+ // Declared field names (SARD_80_F1_SCANNER_PRD.md — cross-method field
102
+ // taint, Juliet flow variants 45/65-68): lets the taint engine tell a
103
+ // field write (`sf = data;`, `this.inst = data;`) apart from an
104
+ // ordinary local variable with the same name in a DIFFERENT method of
105
+ // the same class, without which a field write in one method could
106
+ // never be told to taint a read in another. Merged across every file
107
+ // that contributes to this class (partial classes, or a parser that
108
+ // emits one `ir.classes` entry per method's enclosing file).
109
+ if (Array.isArray(c.fields)) {
110
+ for (const f of c.fields) if (f) cls.fields.add(f);
111
+ }
71
112
  }
72
113
  }
73
114
  if (!Array.isArray(ir.functions)) continue;
@@ -94,9 +135,15 @@ export function buildClassHierarchy(perFileIR) {
94
135
  if (dotIdx > 0) {
95
136
  className = tail.slice(0, dotIdx);
96
137
  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.
138
+ } else if (segs.length >= 3 && (classes.has(segs[segs.length - 2]) || /^[A-Z]/.test(segs[segs.length - 2]))) {
139
+ // PRD W1 (SARD_80_F1_EXECUTION_PRD.md) — `classes` was already
140
+ // populated from this file's real `ir.classes` structural facts
141
+ // above (lines 57-94), so `classes.has(...)` recognizes a scrambled
142
+ // class name (`case_<hash>`, no longer PascalCase) exactly as
143
+ // reliably as a real one. The `/^[A-Z]/` fallback stays for
144
+ // languages that don't emit `ir.classes` yet (JS/Python/PHP/Ruby/Go/
145
+ // Kotlin — W1.1) so an ordinary nested-function scope segment still
146
+ // doesn't misfire there, same as before this change.
100
147
  className = segs[segs.length - 2];
101
148
  // Two sequential strips, not one `(#[0-9a-f]+)?` optional group: the
102
149
  // combined form is flagged by this project's own self-scan gate
@@ -113,7 +160,7 @@ export function buildClassHierarchy(perFileIR) {
113
160
  methodOwners.set(fn.qid, className);
114
161
  let cls = classes.get(className);
115
162
  if (!cls) {
116
- cls = { name: className, file, line: fn.line || 0, methods: new Set(), extends: null };
163
+ cls = { name: className, file, line: fn.line || 0, methods: new Set(), extends: null, bases: [], fields: new Set() };
117
164
  classes.set(className, cls);
118
165
  }
119
166
  cls.methods.add(methodName);
@@ -160,7 +207,214 @@ export function buildClassHierarchy(perFileIR) {
160
207
  if (classes.has(className) || /^[A-Z]/.test(className)) {
161
208
  // Convention: PascalCase `new` callees treated as constructors.
162
209
  const target = typeof n.target === 'string' ? n.target : null;
163
- if (target) typeOfVar.set(`${file}::${fn.qid}::${target}`, className);
210
+ if (target) {
211
+ const key = `${file}::${fn.qid}::${target}`;
212
+ if (ambiguousVarKeys.has(key)) continue;
213
+ if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
214
+ ambiguousVarKeys.add(key);
215
+ typeOfVar.delete(key);
216
+ } else {
217
+ typeOfVar.set(key, className);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ // PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — a PARAMETER's declared type is
224
+ // just as real a type binding as a local `let x = new Foo()`, and it's
225
+ // Juliet's OWN dominant abstract/interface dispatch idiom: the "runTest"
226
+ // driver constructs the concrete (bad/good) instance and passes it as a
227
+ // parameter to a helper that invokes the virtual method — `_localVarConstructedTypes`
228
+ // (parser-java.js/parser-cs.js) only ever sees the CONSTRUCTOR site, not
229
+ // this call site, so `b` in `void helper(BaseType b, ...) { b.action(x); }`
230
+ // was completely untyped. Reuses the EXACT same `typeOfVar` map and key
231
+ // shape `classOfVar` already reads — no new lookup mechanism, so every
232
+ // existing consumer (engine.js's `_resolveMemberCalleeViaCHA` once it
233
+ // learns to read a flat dotted-string callee, `receiver-context.js`)
234
+ // benefits with no changes on their end. Only populated when
235
+ // `fn.paramTypes` exists (currently parser-java.js; parser-cs.js is a
236
+ // candidate follow-up, same shape). Deliberately does NOT overwrite an
237
+ // existing binding for the same key — a parameter name can never collide
238
+ // with a local var name in the same scope in Java, so this is purely
239
+ // additive, but the guard costs nothing and documents the intent.
240
+ for (const fn of ir.functions) {
241
+ if (!fn.paramTypes) continue;
242
+ for (const [paramName, paramType] of Object.entries(fn.paramTypes)) {
243
+ const key = `${file}::${fn.qid}::${paramName}`;
244
+ if (!typeOfVar.has(key) && !ambiguousVarKeys.has(key)) typeOfVar.set(key, paramType);
245
+ }
246
+ }
247
+ // SARD_80_F1 W4: a local's DECLARED type (`PrintWriter out =
248
+ // response.getWriter();`, parser-java.js's `declaredType` on the assign
249
+ // node) is the third source of a type binding, alongside a constructor
250
+ // call above and a parameter's declared type. This is Java's dominant
251
+ // "get an object via a factory/getter method, not `new`" idiom — the
252
+ // constructor pass above can never see it (there is no `new` at all).
253
+ // Deliberately consulted via `receiverTypeIn` only (additive — see
254
+ // `catalog.js`'s `java-writer-*` entries), never via the destructive
255
+ // callee-string rewrite `parser-java.js`'s `_localVarConstructedTypes`
256
+ // does for constructor types: that mechanism was tried for declared
257
+ // types too and reverted after it broke an unrelated, name-scoped
258
+ // catalog entry (see its own comment). Populated by both
259
+ // parser-java.js and parser-cs.js (C#'s `Type x = expr;` decl-typeClause
260
+ // capture, same field name and rationale).
261
+ for (const fn of ir.functions) {
262
+ if (!fn.cfg || !fn.cfg.nodes) continue;
263
+ for (const node of Object.values(fn.cfg.nodes)) {
264
+ if (node.kind !== 'assign' || !node.declaredType || typeof node.target !== 'string' || node.target.includes('.')) continue;
265
+ const key = `${file}::${fn.qid}::${node.target}`;
266
+ if (!typeOfVar.has(key) && !ambiguousVarKeys.has(key)) typeOfVar.set(key, node.declaredType);
267
+ }
268
+ }
269
+ }
270
+
271
+ // Next-gen taint capability #4b — factory-function RETURN-type inference,
272
+ // the sibling gap this file's OWN header comment names and explicitly
273
+ // marks unimplemented ("`function buildFoo(): Foo { ... }` typed-return
274
+ // inference — NOT implemented. `const x = buildFoo()` is untyped
275
+ // (correctly: it is a plain call, not a `new`)"). Confirmed still real via
276
+ // a direct reproduction: `function makeHelper() { return new Helper(); }
277
+ // const h = makeHelper(); h.getValue(req);` misses the vulnerability
278
+ // entirely, the identical failure shape 4a (above) closes for the
279
+ // parameter case. `_inferredFactoryReturnClass` is deliberately
280
+ // conservative, same "refuse rather than guess" discipline as every other
281
+ // pass in this file: a function qualifies ONLY when every one of its own
282
+ // `return` statements is a `new ClassName(...)` literal of the SAME
283
+ // class — a function with no return, a non-constructor return on any
284
+ // path, or disagreeing classes across returns all correctly infer
285
+ // nothing, rather than picking one arbitrarily.
286
+ function _inferredFactoryReturnClass(fn) {
287
+ if (!fn || !fn.cfg || !fn.cfg.nodes) return null;
288
+ let className = null;
289
+ for (const node of Object.values(fn.cfg.nodes)) {
290
+ if (node.kind !== 'return' || !node.value) continue;
291
+ const v = node.value;
292
+ if (v.kind !== 'call' || !v.isNew) return null;
293
+ const callee = v.callee;
294
+ const cn = callee?.kind === 'ident' ? callee.name
295
+ : typeof callee === 'string' ? (callee.includes('.') ? callee.slice(callee.lastIndexOf('.') + 1) : callee)
296
+ : null;
297
+ if (!cn || !(classes.has(cn) || /^[A-Z]/.test(cn))) return null;
298
+ if (className === null) className = cn;
299
+ else if (className !== cn) return null;
300
+ }
301
+ return className;
302
+ }
303
+ if (callGraph && typeof callGraph.resolveKnownCallee === 'function' && callGraph.functions) {
304
+ const factoryReturnCache = new Map(); // qid -> className|null, memoized
305
+ const factoryReturnClassOf = (qid, fn) => {
306
+ if (factoryReturnCache.has(qid)) return factoryReturnCache.get(qid);
307
+ const cn = _inferredFactoryReturnClass(fn);
308
+ factoryReturnCache.set(qid, cn);
309
+ return cn;
310
+ };
311
+ for (const [callerFile, ir] of Object.entries(perFileIR)) {
312
+ if (!ir || !Array.isArray(ir.functions)) continue;
313
+ for (const fn of ir.functions) {
314
+ if (!fn.cfg || !fn.cfg.nodes) continue;
315
+ for (const node of Object.values(fn.cfg.nodes)) {
316
+ if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.')) continue;
317
+ const src = node.source;
318
+ // A plain call (isNew must be FALSY — a real `new Foo()` is
319
+ // already handled by the constructor-assignment pass above, and
320
+ // conflating the two would just redundantly re-derive the same
321
+ // answer through a slower path).
322
+ if (!src || src.kind !== 'call' || src.isNew) continue;
323
+ const calleeName = typeof src.callee === 'string' ? src.callee
324
+ : src.callee && src.callee.kind === 'ident' ? src.callee.name : null;
325
+ if (!calleeName) continue;
326
+ const calleeQid = callGraph.resolveKnownCallee(calleeName, callerFile);
327
+ if (!calleeQid) continue;
328
+ const calleeFn = callGraph.functions.get(calleeQid);
329
+ if (!calleeFn) continue;
330
+ const className = factoryReturnClassOf(calleeQid, calleeFn);
331
+ if (!className) continue;
332
+ const key = `${callerFile}::${fn.qid}::${node.target}`;
333
+ if (ambiguousVarKeys.has(key)) continue;
334
+ if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
335
+ ambiguousVarKeys.add(key);
336
+ typeOfVar.delete(key);
337
+ } else {
338
+ typeOfVar.set(key, className);
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ // Next-gen taint capability #4 (a narrow, call-site-scoped slice of
346
+ // "Andersen-style points-to", not the full thing): cross-call-site
347
+ // PARAMETER type inference from the ARGUMENT a caller actually passes, for
348
+ // JS/TS — which has no declared-type mechanism at all (`fn.paramTypes`
349
+ // just above only ever fires for Java/C#). Juliet's own dominant
350
+ // "driver constructs the concrete instance, passes it as a parameter to a
351
+ // helper that invokes the virtual method" idiom (already solved for
352
+ // Java/C# via fn.paramTypes) has no JS/TS equivalent: `function
353
+ // wrapper(h, req) { return h.getValue(req); }`, called only as `const h =
354
+ // new Helper(); wrapper(h, req);`, leaves `h` permanently untyped inside
355
+ // wrapper's own body, so `h.getValue()` can never resolve via CHA —
356
+ // confirmed via a direct reproduction (cold-cache AND warm-incremental-
357
+ // cache both miss it identically, ruling out a caching-specific cause)
358
+ // showing a real cross-file taint-recall false negative for exactly this
359
+ // shape. Requires `callGraph` (optional, backward-compatible — every
360
+ // existing caller that only ever passed `perFileIR` simply skips this
361
+ // pass, matching this file's own established graceful-degradation
362
+ // convention). Deliberately runs as a LAST, separate pass over every file
363
+ // (not merged into the per-file loop above) because it needs BOTH a
364
+ // cross-file callee lookup (`callGraph.resolveKnownCallee`) AND the
365
+ // CALLER's own already-inferred `typeOfVar` entries (an identifier
366
+ // argument's type, established by the constructor-assignment pass above)
367
+ // — neither is available mid-way through that loop for an arbitrary file
368
+ // ordering.
369
+ if (callGraph && typeof callGraph.resolveKnownCallee === 'function' && callGraph.functions) {
370
+ for (const [callerFile, ir] of Object.entries(perFileIR)) {
371
+ if (!ir || !Array.isArray(ir.functions)) continue;
372
+ for (const callerFn of ir.functions) {
373
+ if (!Array.isArray(callerFn.calls)) continue;
374
+ for (const call of callerFn.calls) {
375
+ const calleeName = typeof call.callee === 'string' ? call.callee
376
+ : call.callee && call.callee.kind === 'ident' ? call.callee.name : null;
377
+ if (!calleeName) continue;
378
+ const calleeQid = callGraph.resolveKnownCallee(calleeName, callerFile);
379
+ if (!calleeQid) continue;
380
+ const calleeFn = callGraph.functions.get(calleeQid);
381
+ if (!calleeFn || !Array.isArray(calleeFn.params) || !calleeFn.params.length || !calleeFn.file) continue;
382
+ const args = Array.isArray(call.args) ? call.args : [];
383
+ for (let i = 0; i < args.length && i < calleeFn.params.length; i++) {
384
+ const arg = args[i];
385
+ let className = null;
386
+ if (arg && arg.kind === 'call' && arg.isNew) {
387
+ // A literal `new ClassName(...)` passed directly as the argument.
388
+ const argCallee = arg.callee;
389
+ className = argCallee?.kind === 'ident' ? argCallee.name
390
+ : typeof argCallee === 'string' ? (argCallee.includes('.') ? argCallee.slice(argCallee.lastIndexOf('.') + 1) : argCallee)
391
+ : null;
392
+ } else if (arg && arg.kind === 'ident' && arg.name) {
393
+ // Juliet's own dominant idiom: `const h = new Helper(); f(h,
394
+ // ...)`. `h`'s type was already established by the
395
+ // constructor-assignment pass above (it runs first, in the
396
+ // SAME outer file loop) — reuse it directly rather than
397
+ // re-deriving, so this stays in sync with that pass's own
398
+ // ambiguity handling by construction.
399
+ className = typeOfVar.get(`${callerFile}::${callerFn.qid}::${arg.name}`) || null;
400
+ }
401
+ if (!className || !(classes.has(className) || /^[A-Z]/.test(className))) continue;
402
+ const paramName = calleeFn.params[i];
403
+ if (typeof paramName !== 'string' || !paramName) continue;
404
+ // Same collision-refusal discipline as every other typeOfVar-
405
+ // populating pass in this file: a wrong "confidently resolved"
406
+ // type is worse than staying unknown, so a SECOND call site
407
+ // disagreeing with an already-recorded type for this exact
408
+ // parameter permanently refuses it, never picks a side.
409
+ const key = `${calleeFn.file}::${calleeQid}::${paramName}`;
410
+ if (ambiguousVarKeys.has(key)) continue;
411
+ if (typeOfVar.has(key) && typeOfVar.get(key) !== className) {
412
+ ambiguousVarKeys.add(key);
413
+ typeOfVar.delete(key);
414
+ } else {
415
+ typeOfVar.set(key, className);
416
+ }
417
+ }
164
418
  }
165
419
  }
166
420
  }
@@ -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 });
package/src/ir/index.js CHANGED
@@ -18,7 +18,9 @@ import {
18
18
  import { parseJavaFile } from './parser-java.js';
19
19
  import { parseGoFile } from './parser-go.js';
20
20
  import { parsePhpFile } from './parser-php.js';
21
+ import { mergePhpIncludes } from './php-include-merge.js';
21
22
  import { parseRubyFile } from './parser-rb.js';
23
+ import { parseRustFile } from './parser-rust.js';
22
24
  import { parseCppFile, cppExtRe } from './parser-cpp.js';
23
25
  import { buildCallGraph } from './callgraph.js';
24
26
  import { buildClassHierarchy } from './class-hierarchy.js';
@@ -164,6 +166,9 @@ export function buildProjectIR(fileContents) {
164
166
  } else if (/\.rb$/i.test(file)) {
165
167
  const ir = parseRubyFile(file, code);
166
168
  if (ir) perFile[file] = ir;
169
+ } else if (/\.rs$/i.test(file)) {
170
+ const ir = parseRustFile(file, code);
171
+ if (ir) perFile[file] = ir;
167
172
  } else if (cppExtRe().test(file)) {
168
173
  const ir = parseCppFile(file, code);
169
174
  if (ir) perFile[file] = ir;
@@ -175,6 +180,12 @@ export function buildProjectIR(fileContents) {
175
180
  if (ir && ir.file) perFile[ir.file] = ir;
176
181
  }
177
182
  }
183
+ // SARD_80_F1 W5.7 — merge PHP include/require targets' top-level
184
+ // assignments into the includer's own scope before anything else touches
185
+ // the CFGs (SSA numbering included), since PHP's `include` genuinely
186
+ // executes in the caller's variable namespace. See php-include-merge.js's
187
+ // module header for the full rationale and scope.
188
+ try { mergePhpIncludes(perFile); } catch {}
178
189
  if (isSSAEnabled()) {
179
190
  for (const ir of Object.values(perFile)) {
180
191
  for (const fn of (ir.functions || [])) {
@@ -226,6 +237,9 @@ export async function buildProjectIRAsync(fileContents) {
226
237
  } else if (/\.rb$/i.test(file)) {
227
238
  const ir = parseRubyFile(file, code);
228
239
  if (ir) perFile[file] = ir;
240
+ } else if (/\.rs$/i.test(file)) {
241
+ const ir = parseRustFile(file, code);
242
+ if (ir) perFile[file] = ir;
229
243
  } else if (cppExtRe().test(file)) {
230
244
  const ir = parseCppFile(file, code);
231
245
  if (ir) perFile[file] = ir;
@@ -237,6 +251,12 @@ export async function buildProjectIRAsync(fileContents) {
237
251
  if (ir && ir.file) perFile[ir.file] = ir;
238
252
  }
239
253
  }
254
+ // SARD_80_F1 W5.7 — merge PHP include/require targets' top-level
255
+ // assignments into the includer's own scope before anything else touches
256
+ // the CFGs (SSA numbering included), since PHP's `include` genuinely
257
+ // executes in the caller's variable namespace. See php-include-merge.js's
258
+ // module header for the full rationale and scope.
259
+ try { mergePhpIncludes(perFile); } catch {}
240
260
  if (isSSAEnabled()) {
241
261
  for (const ir of Object.values(perFile)) {
242
262
  for (const fn of (ir.functions || [])) {
@@ -269,4 +289,4 @@ export function parsePythonFile(file, code) {
269
289
  return parsePythonFileRegex(file, code);
270
290
  }
271
291
 
272
- export { parseJsFile, parseJavaFile, parseCSharpFile, parseKotlinFile, parseGoFile, parsePhpFile, parseRubyFile, parseCppFile, buildCallGraph, buildClassHierarchy, computeSSA, isSSAEnabled, probePythonAvailable, pythonParserDegradation, resetPythonParserDegradation };
292
+ export { parseJsFile, parseJavaFile, parseCSharpFile, parseKotlinFile, parseGoFile, parsePhpFile, parseRubyFile, parseRustFile, parseCppFile, buildCallGraph, buildClassHierarchy, computeSSA, isSSAEnabled, probePythonAvailable, pythonParserDegradation, resetPythonParserDegradation };