@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
@@ -80,6 +80,28 @@ function exprOf(n) {
80
80
  case 'StringLiteral':
81
81
  case 'BooleanLiteral':
82
82
  case 'NullLiteral': return { kind: 'literal', value: n.value !== undefined ? n.value : null };
83
+ // Next-gen taint capability #2 (string/value abstract domain) — a regex
84
+ // LITERAL (`/^[a-z]+$/`) previously fell through to `{kind:'unknown'}`
85
+ // entirely: no case matched `RegExpLiteral`, so the ENTIRE pattern was
86
+ // lost before any downstream consumer (a guard-narrowing check, the
87
+ // existing `string-domain.js` lattice) ever saw it. This is why a
88
+ // pattern-based validation guard (`if (/^\d+$/.test(x)) sink(x)`) could
89
+ // never be recognized as safety-narrowing at all, regardless of how
90
+ // permissive or restrictive the pattern was — confirmed via a direct IR
91
+ // dump before this fix (the regex literal's own callee-object lowered to
92
+ // `{kind:'unknown'}`, not merely an unrecognized guard shape). Lowered
93
+ // to a `literal` node carrying the ACTUAL RegExp instance (safe here —
94
+ // this parser runs in-process, unlike the five hand-rolled parsers,
95
+ // which have no such runtime and store literal-only VALUES), tagged
96
+ // `isRegex: true` so a consumer can distinguish "this literal's value
97
+ // happens to be a RegExp" from a genuine string/number/boolean literal
98
+ // without doing an `instanceof` check itself. A malformed pattern (rare,
99
+ // Babel would normally reject it upstream) falls back to `unknown`
100
+ // rather than throwing mid-parse.
101
+ case 'RegExpLiteral': {
102
+ try { return { kind: 'literal', value: new RegExp(n.pattern, n.flags || ''), isRegex: true }; }
103
+ catch { return { kind: 'unknown' }; }
104
+ }
83
105
  case 'TemplateLiteral': return { kind: 'tpl', parts: (n.expressions || []).map(exprOf) };
84
106
  case 'MemberExpression': return {
85
107
  kind: 'member',
@@ -86,18 +86,19 @@ const FUNC_RE = new RegExp(
86
86
  // fix (see the `catch`/`finally` regression tests in
87
87
  // `test/parser-php-control-flow.test.js`, which fail without this change
88
88
  // and pass with it) — `_scanTryCatchFinally`'s balanced-brace scanning has
89
- // no competing bug to interact with. For `else` specifically, this
90
- // lookahead fix is still correct and necessary, but its OUTCOME is masked
91
- // in practice by `ifMatch`'s own separate, pre-existing, out-of-scope
92
- // greedy-capture bug (the then-body group unconditionally swallows
93
- // through to the else-body's own closing `}`, dropping the else body
94
- // regardless of whether a comment was ever involved — confirmed by
95
- // testing commit `735ef63`, before this task started, with an identical
96
- // comment-free else fixture: already broken then, for an unrelated
97
- // reason). `ifMatch` and `_scanTryCatchFinally` both already tolerate an
98
- // ordinary `\s*`/whitespace gap between `}` and the keyword; skipping
99
- // comments here too keeps this lookahead in sync with what those
100
- // recognizers can actually parse once the flush is correctly suppressed.
89
+ // no competing bug to interact with. For `else`, this lookahead fix was
90
+ // ORIGINALLY still correct but its outcome was masked by `ifMatch`'s own
91
+ // separate greedy-capture bug — SARD_80_F1 W3.2 replaced that regex with
92
+ // `_scanIfElse`, a balanced-brace scanner mirroring `_scanTryCatchFinally`'s
93
+ // own technique (see that function's header comment for the full incident:
94
+ // the old regex's then-body group unconditionally swallowed through to the
95
+ // else-body's own closing `}`, which was worse than "drops the else body's
96
+ // first statement" — it silently spliced the else body's REMAINING
97
+ // statements onto the then-branch's own CFG chain with no real branch at
98
+ // all, a correctness hazard once branch-scoped taint narrowing existed).
99
+ // `else`/`catch`/`finally` now all parse via balanced-brace scanners with
100
+ // no competing greedy-capture bug; this lookahead's comment-skip fix
101
+ // applies uniformly to all three.
101
102
  function _continuationKeywordAhead(body, i) {
102
103
  let j = i + 1;
103
104
  let moved = true;
@@ -215,13 +216,12 @@ function _splitStatements(body) {
215
216
  //
216
217
  // EXCEPTION: do not flush when the next non-whitespace token is
217
218
  // `else`, `catch`, or `finally` — those must stay glued onto the
218
- // SAME statement as the preceding `}` for `ifMatch` and the
219
+ // SAME statement as the preceding `}` for `_scanIfElse` and the
219
220
  // try/catch/finally recognizer in `_buildCfg` to see
220
221
  // `if (...) { ... } else { ... }` or
221
222
  // `try { ... } catch (...) { ... } finally { ... }` as one
222
- // contiguous blob (`ifMatch` is anchored end-to-end with `$` and
223
- // spans the whole construct; the try/catch/finally recognizer scans
224
- // the whole construct by hand for the same reason). A blind
223
+ // contiguous blob (both scan the whole construct by hand, requiring
224
+ // it to arrive as a single, unsplit statement string). A blind
225
225
  // flush-on-every-`}` here would silently split `if`/`else` and
226
226
  // multi-clause `try` into two statements each, which is a strictly
227
227
  // WORSE regression than the "must be last statement in scope" bug
@@ -294,12 +294,12 @@ function _splitStatements(body) {
294
294
  // are kept, outermost-first — see parser-cs.js's twin function for why
295
295
  // (a first version that kept only the outermost broke a real chain shape
296
296
  // where the tainted value sits on an INNER call).
297
- function _followChain(s, endIdx, calleeSoFar, argsSoFar) {
297
+ function _followChain(s, endIdx, calleeSoFar, argsSoFar, isNew) {
298
298
  const rest = s.slice(endIdx);
299
299
  const m = rest.match(/^(?:->|::)(\w+)/);
300
- if (!m) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
300
+ if (!m) return { kind: 'call', callee: calleeSoFar, args: argsSoFar, isNew };
301
301
  const outer = matchBalancedCall(rest, /^(?:->|::)(\w+)/);
302
- if (!outer) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
302
+ if (!outer) return { kind: 'call', callee: calleeSoFar, args: argsSoFar, isNew };
303
303
  const outerArgs = _splitTopLevelCommas(outer.argsText).map(_lowerExpr);
304
304
  return _followChain(rest, outer.endIdx, `${calleeSoFar}.${outer.callee}`, outerArgs.concat(argsSoFar));
305
305
  }
@@ -307,6 +307,35 @@ function _followChain(s, endIdx, calleeSoFar, argsSoFar) {
307
307
  function _lowerExpr(text) {
308
308
  const s = String(text || '').trim();
309
309
  if (!s) return { kind: 'unknown' };
310
+ // SARD PHP corpus: the backtick shell-execution operator (`` `cmd` ``,
311
+ // equivalent to shell_exec()) is a completely distinct delimiter from a
312
+ // double-quoted string and had NO recognizer at all — it fell through
313
+ // every branch below to {kind:'unknown'}, silently dropping the shell
314
+ // command (and any interpolated taint inside it) entirely. This is this
315
+ // corpus's single most common command-injection shape (83 of 224 bad
316
+ // dev-split cases use it as their first source assignment). Lowered to a
317
+ // synthetic call (`__php_backtick_exec__`, an identifier real PHP code
318
+ // can never actually name a dotted method) carrying the interpolated
319
+ // command as its sole argument — same convention as parser-rb.js's
320
+ // `__ruby_backtick_exec__` and this file's own `__php_echo__`. Reuses the
321
+ // double-quoted interpolation scanner below (backticks interpolate `$var`/
322
+ // `{$expr}` exactly like double-quoted strings do in PHP).
323
+ const btLiteral = s.match(/^`((?:[^`\\]|\\.)*)`$/);
324
+ if (btLiteral) {
325
+ const inner = btLiteral[1];
326
+ const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
327
+ let lastIndex = 0;
328
+ const parts = [];
329
+ let m;
330
+ while ((m = re.exec(inner)) !== null) {
331
+ if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
332
+ parts.push(_lowerExpr(m[1] !== undefined ? m[1] : m[2]));
333
+ lastIndex = re.lastIndex;
334
+ }
335
+ if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
336
+ const cmd = parts.length === 1 ? parts[0] : { kind: 'tpl', parts };
337
+ return { kind: 'call', callee: '__php_backtick_exec__', args: [cmd] };
338
+ }
310
339
  // Stage 3 correctness audit (detection depth, per-language-IR): PHP
311
340
  // double-quoted strings interpolate variables directly
312
341
  // ("SELECT ... WHERE id=$id", "hi {$user->name}") — single-quoted
@@ -375,8 +404,90 @@ function _lowerExpr(text) {
375
404
  }
376
405
  return cur;
377
406
  }
407
+ // General array/subscript READ: `$var[key]`, `$this->prop[key]`, and
408
+ // repeated subscripts (`$arr[0][1]`) — anything reaching here is NOT one
409
+ // of the special-cased superglobals above. SARD_80_F1 W5.9 — before this,
410
+ // ordinary PHP array/property-array access had NO lowering at all
411
+ // (fell through every branch to `{kind:'unknown'}`, discarding whatever
412
+ // taint the subscripted value carried entirely). A literal string/int key
413
+ // becomes that literal `prop`; a computed key (a variable, an expression)
414
+ // widens to `'*'`, the SAME "unknown-key writes widen to the container"
415
+ // convention documented in dataflow/CLAUDE.md — the base object is
416
+ // marked tainted as a whole rather than guessing which element. Mirrors
417
+ // `_subscriptTargetPath` below (the WRITE side), including its `.`-not-
418
+ // `->` separator convention, and its same "no nested brackets inside one
419
+ // key" scope limit.
420
+ {
421
+ const sub = s.match(/^(\$[\w]+(?:->[\w]+)*)((?:\[[^\[\]]*\])+)$/);
422
+ if (sub) {
423
+ let cur = _lowerExpr(sub[1]);
424
+ const segs = sub[2].match(/\[[^\[\]]*\]/g) || [];
425
+ for (const seg of segs) {
426
+ const keyText = seg.slice(1, -1).trim();
427
+ const litKey = keyText.match(/^"([^"]*)"$/) || keyText.match(/^'([^']*)'$/);
428
+ const prop = litKey ? litKey[1] : (/^\d+$/.test(keyText) ? keyText : '*');
429
+ cur = { kind: 'member', object: cur, prop };
430
+ }
431
+ return cur;
432
+ }
433
+ }
378
434
  // Variable
379
435
  if (/^\$[A-Za-z_]\w*$/.test(s)) return { kind: 'ident', name: s };
436
+ // Object creation: `new ClassName(args)` (namespace-qualified names allowed,
437
+ // `\` separator) — lowered to a call so a constructor's own args carry
438
+ // taint AND so `class-hierarchy.js#typeOfVar` can seed `$var`'s declared
439
+ // type from `isNew:true`, the same mechanism parser-cs.js/parser-java.js
440
+ // already rely on. Without this, `$temp = new Input(); $temp->getInput()`
441
+ // lowered `new Input()` to {kind:'unknown'} (this file had NO `new`
442
+ // recognizer at all before this fix — confirmed by IR dump, not assumed),
443
+ // so `$temp`'s type was never known and `$temp->getInput()` could never be
444
+ // resolved to `Input::getInput`'s own return-taint summary — invisible to
445
+ // every PHP corpus case that reads a tainted value back out through an
446
+ // object method (`object/directGet`, `object/classicGet`, and any
447
+ // ARRAY-wrapping variant of the same shape, per the SARD PHP suite's own
448
+ // published generator source, `input.xml`). Zero-arg `new ClassName` (no
449
+ // parens at all — valid PHP) is handled too, matching PHP's actual grammar
450
+ // rather than assuming C#/Java's mandatory-parens convention.
451
+ const newMatch = matchBalancedCall(s, /^new\s+([\w\\]+)/);
452
+ if (newMatch) {
453
+ const callee = newMatch.callee.split(/[\\.]/).pop();
454
+ const args = _splitTopLevelCommas(newMatch.argsText).map(_lowerExpr);
455
+ return _followChain(s, newMatch.endIdx, callee, args, true);
456
+ }
457
+ const bareNew = s.match(/^new\s+([\w\\]+)\s*$/);
458
+ if (bareNew) {
459
+ const callee = bareNew[1].split(/[\\.]/).pop();
460
+ return { kind: 'call', callee, args: [], isNew: true };
461
+ }
462
+ // SARD_80_F1 W5.13 — `include`/`require`(`_once`)? as an EXPRESSION, not a
463
+ // statement. `_lowerStmt` above already recognizes this construct when it
464
+ // IS the entire statement, but the real public SARD PHP suite generator's
465
+ // own CWE-98 (File Inclusion) shape — confirmed via
466
+ // `stivalet/PHP-Vuln-test-suite-generator`'s own `construction.xml`
467
+ // (`$var = include("'". $tainted . ".php'");`) — assigns the include
468
+ // EXPRESSION to a variable, so it's lowered as an ordinary assignment's
469
+ // RHS through `_lowerExpr`, never through `_lowerStmt`'s statement-form
470
+ // branch at all. Without this, the generic function-call matcher below
471
+ // treated `include(...)` as an ordinary call literally named "include" —
472
+ // never matching the catalog's `__php_include__`-keyed sink
473
+ // (`php-include-lfi`) — a total, structural blackout for this exact,
474
+ // real-corpus shape (this benchmark's entire CWE-98 family, tp=0 despite
475
+ // five prior dedicated investigation cycles this session, none of which
476
+ // had yet examined this specific "include used as an expression" shape).
477
+ // Lowered identically to `_lowerStmt`'s own handling so both forms land
478
+ // on the same synthetic callee.
479
+ const includeCall = matchBalancedCall(s, /^(?:include|require)(?:_once)?\b/);
480
+ if (includeCall) {
481
+ const args = _splitTopLevelCommas(includeCall.argsText).map(_lowerExpr);
482
+ return { kind: 'call', callee: '__php_include__', args: args.length ? [args[0]] : [] };
483
+ }
484
+ // Paren-free form used as an expression (rare but valid PHP, e.g.
485
+ // `if (include $page . ".php") { … }`) — mirrors `_lowerStmt`'s own
486
+ // paren-free handling for the statement form.
487
+ const includeBare = s.match(/^(?:include|require)(?:_once)?\s+(.+)$/);
488
+ if (includeBare) {
489
+ return { kind: 'call', callee: '__php_include__', args: [_lowerExpr(includeBare[1])] };
490
+ }
380
491
  // Method call: $obj->method(args) or ClassName::method(args).
381
492
  // matchBalancedCall finds the paren that actually balances the FIRST
382
493
  // '(' — not the greedy-to-end-of-string match the old `/\((.*)\)\s*$/`
@@ -468,6 +579,28 @@ function _splitTopLevelDot(s) {
468
579
  return out;
469
580
  }
470
581
 
582
+ // SARD_80_F1 W5.9 — converts a PHP subscript-assignment LHS text
583
+ // (`$arr[1]`, `$this->input[1]`, `$arr['key']`) into the same dot-joined
584
+ // access-path string `_lowerExpr`'s general-subscript READ branch (and
585
+ // `accessPathOf`) compute for the equivalent read, so a later read of the
586
+ // same location can find the taint this write recorded. Returns null when
587
+ // `lhs` doesn't match the single-non-nested-bracket-key shape this covers
588
+ // (the caller's own regex already filters most of this, but a defensive
589
+ // re-check here keeps this function safe to call standalone).
590
+ function _subscriptTargetPath(lhs) {
591
+ const m = lhs.match(/^(\$[\w]+(?:->[\w]+)*)((?:\[[^\[\]]*\])+)$/);
592
+ if (!m) return null;
593
+ const base = m[1].replace(/->/g, '.');
594
+ const segs = (m[2].match(/\[[^\[\]]*\]/g) || []).map((seg) => {
595
+ const keyText = seg.slice(1, -1).trim();
596
+ const litKey = keyText.match(/^"([^"]*)"$/) || keyText.match(/^'([^']*)'$/);
597
+ if (litKey) return litKey[1];
598
+ if (/^\d+$/.test(keyText)) return keyText;
599
+ return '*';
600
+ });
601
+ return [base, ...segs].join('.');
602
+ }
603
+
471
604
  function _lowerStmt(stmt, line) {
472
605
  const s = stmt.trim();
473
606
  if (!s || s.startsWith('//') || s.startsWith('#')) return null;
@@ -491,10 +624,71 @@ function _lowerStmt(stmt, line) {
491
624
  const rest = s.replace(/^(?:echo|print)\s*/, '');
492
625
  return { kind: 'call', line, callee: '__php_echo__', args: _splitTopLevelCommas(rest).map(_lowerExpr) };
493
626
  }
627
+ // `include`/`require`/`include_once`/`require_once` are PHP LANGUAGE
628
+ // CONSTRUCTS, not function calls — `include $page . ".php";` (the common,
629
+ // paren-free form) has no `(` immediately after the keyword, and even the
630
+ // parenthesized form never reaches the statement-form call regex below
631
+ // because these keywords aren't ordinary identifiers to that regex's own
632
+ // exclusion (PHP itself reserves them). Lowered to a synthetic call
633
+ // (`__php_include__`), same convention as `__php_echo__` above.
634
+ if (/^(?:include|require)(?:_once)?\b/.test(s)) {
635
+ const rest = s.replace(/^(?:include|require)(?:_once)?\s*/, '').replace(/^\((.*)\)$/, '$1');
636
+ return { kind: 'call', line, callee: '__php_include__', args: [_lowerExpr(rest)] };
637
+ }
638
+ // A bare backtick expression as its OWN statement (not assigned to a
639
+ // variable) matches none of the branches below — matchBalancedCall's
640
+ // callee regex requires an identifier/`$var` prefix, which a backtick
641
+ // literal isn't. Delegate straight to _lowerExpr, same fix parser-rb.js
642
+ // needed for the identical shape.
643
+ if (/^`/.test(s)) {
644
+ const call = _lowerExpr(s);
645
+ if (call.kind === 'call') return { kind: 'call', line, callee: call.callee, args: call.args };
646
+ }
647
+ // Array/subscript WRITE: `$arr[key] = value;`, `$this->prop[key] = value;`,
648
+ // repeated subscripts (`$arr[0][1] = value;`). SARD_80_F1 W5.9 — before
649
+ // this, the whole STATEMENT fell through every branch in this function
650
+ // (the plain-assign regex below requires a target of nothing but
651
+ // `[\w]`/`->`, which a trailing `[...]` never satisfies) and vanished
652
+ // from the CFG entirely — not even an `unknown`-kind node, just gone, so
653
+ // `$arr[1] = $_GET['x'];` had NO observable effect on `$arr` at all. Must
654
+ // run BEFORE the plain-assign regex (mutually exclusive targets, but this
655
+ // is the more specific pattern). Builds the SAME dot-joined access-path
656
+ // string as an equivalent READ (`_lowerExpr`'s general-subscript branch,
657
+ // and `accessPathOf` for the member-expression form), via
658
+ // `_subscriptTargetPath` — a literal key becomes that literal path
659
+ // segment; a computed key widens to `'*'` (dataflow/CLAUDE.md's
660
+ // "unknown-key writes widen to the container" convention, applied here
661
+ // at the parser level since there is no other lowering path for this at
662
+ // all). Scoped to a single non-nested-bracket key per subscript (i.e. not
663
+ // `$arr[$obj[0]]`) — the overwhelmingly common shape; anything more
664
+ // exotic simply doesn't match and falls through unchanged.
665
+ {
666
+ const subAssign = s.match(/^(\$[\w]+(?:->[\w]+)*(?:\[[^\[\]]*\])+)\s*=\s*(.+)$/s);
667
+ if (subAssign) {
668
+ const targetPath = _subscriptTargetPath(subAssign[1]);
669
+ if (targetPath) return { kind: 'assign', line, target: targetPath, source: _lowerExpr(subAssign[2]) };
670
+ }
671
+ }
494
672
  // Assignment: $var = expr
673
+ //
674
+ // SARD_80_F1 W5.9 — a member-chain target (`$this->input = …`) kept its
675
+ // literal `->` separator in `target`, but `accessPathOf` (access-paths.js)
676
+ // computes a READ of the same property as a DOT-joined string
677
+ // (`${base}.${prop}`) — `member` is the universal IR expression shape
678
+ // every parser in this tree uses, and `.` is the one separator the whole
679
+ // engine (isCoveredBy, addPath, removePathAndDescendants) understands.
680
+ // So `$this->input = $_GET[...]; $x = $this->input;` taints the WRITE
681
+ // under the key `"$this->input"` but the READ looks up `"$this.input"` —
682
+ // two different strings that can never match. This made every PHP
683
+ // object-property taint round-trip fail, even same-scope, same-line-
684
+ // adjacent ones (found while investigating the SARD PHP corpus's
685
+ // persistent CWE-91/98 tp=0 — its own generator source uses exactly this
686
+ // shape, an object wrapping `$_GET` in a property, for a common `input`
687
+ // source variant). Normalizing `->` to `.` here is the ONLY change
688
+ // needed: every downstream consumer already speaks dot-paths.
495
689
  const assign = s.match(/^(\$[\w]+(?:->[\w]+)*)\s*=\s*(.+)$/s);
496
690
  if (assign) {
497
- return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
691
+ return { kind: 'assign', line, target: assign[1].replace(/->/g, '.'), source: _lowerExpr(assign[2]) };
498
692
  }
499
693
  // Statement-form call
500
694
  const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
@@ -562,9 +756,85 @@ function _lineAt(src, idx) {
562
756
  return line;
563
757
  }
564
758
 
565
- function _qid(file, name, line, body) {
759
+ function _qid(file, name, line, body, className) {
566
760
  const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
567
- return `${file}::${name}@${line}#${sha}`;
761
+ return className ? `${file}::${className}::${name}@${line}#${sha}` : `${file}::${name}@${line}#${sha}`;
762
+ }
763
+
764
+ // SARD 80% F1 push — this parser had NO class-boundary tracking at all
765
+ // (confirmed: no prior `class` recognizer of any kind existed in this
766
+ // file). Every method's `qid` was a flat `file::name@line#sha`, never the
767
+ // two-segment `file::ClassName::name@line` shape `callgraph.js`'s
768
+ // `classMethods` index looks for (via its own `::(\w+)::(\w+)@` regex) —
769
+ // so a call like `$obj->method()` on a variable whose constructed type IS
770
+ // known (via `new ClassName()`, see `_lowerExpr`'s own `new` fix above)
771
+ // could still never resolve to the actual method's taint summary, because
772
+ // there was no class-qualified qid for it to resolve TO. `ir.classes`
773
+ // itself is deliberately NOT populated here (unlike Java/C#): `callgraph.js`
774
+ // already falls back to an uppercase-first-letter heuristic
775
+ // (`/^[A-Z]/.test(m[1])`) when a file has no `ir.classes`, and ordinary PHP
776
+ // class-naming convention satisfies that trivially — building a full
777
+ // `ir.classes` array (bases/fields/etc.) for a heuristic that already
778
+ // works would be scope creep beyond what this specific gap needs.
779
+ // Deliberately narrow, matching this file's own documented scope
780
+ // boundary: plain `class Name { ... }` only — traits, interfaces, and
781
+ // anonymous classes (already out of scope for this whole parser, per the
782
+ // file header) are not tracked, so a method inside one of those still
783
+ // gets an unqualified qid exactly as before this fix (no regression,
784
+ // simply no new resolution for a shape this parser never modeled).
785
+ const CLASS_RE = /\bclass\s+([A-Za-z_]\w*)(?:\s+extends\s+[A-Za-z_][\w\\]*)?(?:\s+implements\s+[\w\\,\s]+)?\s*\{/g;
786
+ function _findClassRegions(code) {
787
+ const regions = [];
788
+ CLASS_RE.lastIndex = 0;
789
+ let cm;
790
+ while ((cm = CLASS_RE.exec(code)) !== null) {
791
+ const openBrace = cm.index + cm[0].length - 1;
792
+ const extracted = _extractBody(code, openBrace);
793
+ if (!extracted) continue;
794
+ regions.push({ name: cm[1], start: openBrace, end: extracted.end });
795
+ }
796
+ return regions;
797
+ }
798
+ // SARD_80_F1 W5.10 — declared field names for one class region, feeding
799
+ // `ir.classes[].fields` so `dataflow/engine.js`'s cross-method field-taint
800
+ // pass (SARD_80_F1_SCANNER_PRD.md Juliet flow variants 45/65-68) can tell a
801
+ // field write (`$this->input = $_GET[...];` in one method) apart from an
802
+ // ordinary same-named LOCAL in a different method, and taint a sibling
803
+ // method's read of it. That pass is entirely language-agnostic and already
804
+ // explicitly designed to cover PHP (per its own comment: "confirmed by
805
+ // direct probing... none of `sf = data;`, `this.inst = data;` ever
806
+ // populated it, for any language") — the ONLY missing piece for PHP was
807
+ // `ir.classes` itself, never emitted at all before this (W1.1 closed PHP's
808
+ // method-CALL-resolution gap WITHOUT it, via a narrower workaround; this is
809
+ // the real thing, needed for a DIFFERENT consumer). `public`/`private`/
810
+ // `protected`/`var`, each an accessibility keyword that is ONLY valid PHP
811
+ // syntax at class-member-declaration position — never as an ordinary
812
+ // statement inside a method body — so a whole-class-body regex scan is
813
+ // safe without needing to separately exclude nested method bodies.
814
+ const CLASS_FIELD_RE = /\b(?:public|private|protected|var)\s+(?:static\s+)?(?:\?[\w\\]+\s+)?\$(\w+)/g;
815
+ function _extractClassFields(code, region) {
816
+ const body = code.slice(region.start, region.end);
817
+ const fields = [];
818
+ let fm;
819
+ CLASS_FIELD_RE.lastIndex = 0;
820
+ // Bare (no `$`) — matches every other language's field-name convention in
821
+ // `ir.classes[].fields` (C#/Java field names never carry a sigil); the
822
+ // `$`-sigil is PHP-specific and is instead accounted for on the
823
+ // consuming side (dataflow/engine.js's `$this.` prefix check), the same
824
+ // place `_this_.`/`this.` are already handled per-language.
825
+ while ((fm = CLASS_FIELD_RE.exec(body)) !== null) fields.push(fm[1]);
826
+ return fields;
827
+ }
828
+ function _classNameAt(regions, idx) {
829
+ // `idx >= r.start`, not `>`: FUNC_RE's own leading boundary alternation
830
+ // (`(?:^|[\n;{}]|<\?php|<\?)`) can match the class's own opening `{`
831
+ // itself as the function match's boundary token when a method is the
832
+ // FIRST statement in the class body (no blank line/comment between
833
+ // `class Foo{` and the method) — confirmed by direct index inspection,
834
+ // not assumed: `m.index` lands exactly ON `r.start` in that shape, and a
835
+ // strict `>` silently excluded the very first method of every class.
836
+ for (const r of regions) if (idx >= r.start && idx < r.end) return r.name;
837
+ return null;
568
838
  }
569
839
 
570
840
  // FUNC_RE's leading alternation `(?:^|[\n;{}]|<\?php|<\?)` matches a single
@@ -797,6 +1067,116 @@ function _scanTryCatchFinally(s) {
797
1067
  // blank line (or, at module level, any blanked-out function span — see
798
1068
  // `_blankSpans`) that preceded a statement, which is exactly what made
799
1069
  // module-level PHP findings report the wrong source line.
1070
+ // Find the ')' that balances the '(' at `openIdx`, string/paren-aware.
1071
+ function _findMatchingParen(s, openIdx) {
1072
+ let depth = 0, inStr = null, escape = false;
1073
+ for (let i = openIdx; i < s.length; i++) {
1074
+ const c = s[i];
1075
+ if (escape) { escape = false; continue; }
1076
+ if (inStr) {
1077
+ if (c === '\\') { escape = true; continue; }
1078
+ if (c === inStr) inStr = null;
1079
+ continue;
1080
+ }
1081
+ if (c === '"' || c === '\'') { inStr = c; continue; }
1082
+ if (c === '(') depth++;
1083
+ else if (c === ')') { depth--; if (depth === 0) return i; }
1084
+ }
1085
+ return -1;
1086
+ }
1087
+
1088
+ // SARD PHP corpus: `if (($value = fgets($handle, 4096)) == false) { ... }` —
1089
+ // an assignment embedded inside an if/while CONDITION, PHP's dominant real-
1090
+ // world idiom for reading a file/stream line by line (238 of 995 dev-split
1091
+ // cases use exactly this shape). `_lowerStmt`'s assignment regex only
1092
+ // matches statement-position assignments, so `$value` never got a real
1093
+ // assign CFG node — the source read was invisible regardless of what
1094
+ // catalog entries exist for the RHS callee. Extracts every `($var = expr)`
1095
+ // sub-expression from a condition's text (balanced-paren aware, so nested
1096
+ // calls like `fgets($handle, 4096)` don't confuse the scan), replacing each
1097
+ // with the bare target variable so the boolean test itself still lowers
1098
+ // normally (`$value == false`), and returns the extracted assignments to be
1099
+ // emitted as real CFG nodes immediately before the if/while node.
1100
+ // SARD_80_F1 W3.2 — `ifMatch`'s regex-based then/else split
1101
+ // (`\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?`) has a genuine, previously
1102
+ // UNDERSTATED greedy-capture bug, already documented in ir/CLAUDE.md as
1103
+ // "drops the else-body's first statement" — but the real behavior is worse
1104
+ // than that description: `[\s\S]*` being greedy with no brace-depth
1105
+ // awareness means the THEN group ALWAYS swallows the ENTIRE remaining text
1106
+ // (including the literal `} else {` text and the whole else body) whenever
1107
+ // such an all-consuming parse is grammatically valid, which it always is
1108
+ // (the else-clause is optional). The else body's statements don't just lose
1109
+ // their first line — they get lowered as a plain SEQUENTIAL CONTINUATION of
1110
+ // the then-branch's own CFG chain, with NO independent branch at all. This
1111
+ // was invisible as long as both branches always carried the SAME taint
1112
+ // state (the common case before branch-scoped narrowing existed), but
1113
+ // becomes a genuine false-negative hazard once a branch CAN carry a
1114
+ // narrower state than its sibling (guard-predicate narrowing, `case 'if'`
1115
+ // in dataflow/engine.js): the else-branch's own sink would incorrectly
1116
+ // inherit the THEN branch's narrowed (guard-passed) taint state instead of
1117
+ // the real, unnarrowed one. Fixed with a balanced-brace scanner mirroring
1118
+ // `_scanTryCatchFinally`'s already-correct technique (that function's own
1119
+ // comment notes it "has no greedy-capture issue" for exactly this reason).
1120
+ // Deliberately does NOT handle `else if (...) { ... }` (no braces around a
1121
+ // chained if) — same pre-existing, documented scope boundary the old regex
1122
+ // never covered either (elseif/else-if chains stay unsupported).
1123
+ function _scanIfElse(s) {
1124
+ const head = /^if\s*\(/.exec(s);
1125
+ if (!head) return null;
1126
+ const parenStart = head[0].length - 1;
1127
+ const parenEnd = _findMatchingParen(s, parenStart);
1128
+ if (parenEnd < 0) return null;
1129
+ const condText = s.slice(parenStart + 1, parenEnd);
1130
+ let i = parenEnd + 1;
1131
+ i += (/^\s*/.exec(s.slice(i)) || [''])[0].length;
1132
+ if (s[i] !== '{') return null;
1133
+ const thenBodyOffset = i + 1;
1134
+ const thenEnd = _matchBraceBlock(s, i);
1135
+ if (thenEnd < 0) return null;
1136
+ const thenBody = s.slice(thenBodyOffset, thenEnd - 1);
1137
+ i = thenEnd;
1138
+
1139
+ let elseBody = null;
1140
+ let elseBodyOffset = -1;
1141
+ {
1142
+ let j = i + (/^\s*/.exec(s.slice(i)) || [''])[0].length;
1143
+ const em = /^else\s*/.exec(s.slice(j));
1144
+ if (em) {
1145
+ const afterElse = j + em[0].length;
1146
+ if (s[afterElse] === '{') {
1147
+ const bodyOffset = afterElse + 1;
1148
+ const bodyEnd = _matchBraceBlock(s, afterElse);
1149
+ if (bodyEnd < 0) return null;
1150
+ elseBody = s.slice(bodyOffset, bodyEnd - 1);
1151
+ elseBodyOffset = bodyOffset;
1152
+ i = bodyEnd;
1153
+ }
1154
+ // else-if (no braces): leave `i` unadvanced — the trailing-content
1155
+ // check below will reject the match and the caller falls back to
1156
+ // whatever the old regex path did for this shape (also unsupported).
1157
+ }
1158
+ }
1159
+ if (s.slice(i).trim() !== '') return null;
1160
+ return { condText, thenBody, thenBodyOffset, elseBody, elseBodyOffset };
1161
+ }
1162
+
1163
+ function _hoistCondAssign(cond) {
1164
+ const assigns = [];
1165
+ let s = cond;
1166
+ const re = /\(\s*(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*)*)\s*=(?!=)\s*/;
1167
+ let guard = 0;
1168
+ let m;
1169
+ while (guard++ < 5 && (m = re.exec(s)) !== null) {
1170
+ const openIdx = m.index;
1171
+ const closeIdx = _findMatchingParen(s, openIdx);
1172
+ if (closeIdx === -1) break;
1173
+ const exprText = s.slice(openIdx + m[0].length, closeIdx);
1174
+ assigns.push({ target: m[1], source: _lowerExpr(exprText) });
1175
+ s = s.slice(0, openIdx) + m[1] + s.slice(closeIdx + 1);
1176
+ }
1177
+ return { assigns, cond: s };
1178
+ }
1179
+
800
1180
  function _buildCfg(bodyText, nodes, prevId, startLine, depth = 0) {
801
1181
  if (depth > 12) return prevId;
802
1182
  const stmts = _splitStatements(bodyText);
@@ -817,25 +1197,31 @@ function _buildCfg(bodyText, nodes, prevId, startLine, depth = 0) {
817
1197
  // back to `s` (fix round 1 also missed that a comment anywhere inside
818
1198
  // `s` — not just inside the header — undercounts newlines the same
819
1199
  // way; that half is fixed by `_splitStatements` now pushing a `\n`
820
- // for every newline a skipped comment displaces, above). A `d`-flagged
821
- // regex exposes each capture group's real character offset within
822
- // `s` via `.indices`, so `_countNewlines(s, offset)` gives the body's
823
- // EXACT absolute line regardless of how many lines the header itself
824
- // spans or how many comments precede the body — the same precise
825
- // per-clause technique the try/catch/finally recognizer below already
826
- // uses (there, offsets come from the balanced-brace scanner instead
827
- // of regex `.indices`, same principle).
828
- const ifMatch = s.match(/^if\s*\((.+?)\)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/ds);
829
- if (ifMatch) {
830
- const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(ifMatch[1]), line });
831
- _linkNodes(nodes, prev, ifNode);
1200
+ // for every newline a skipped comment displaces, above). SARD_80_F1
1201
+ // W3.2 replaced the old regex here with `_scanIfElse` (see its own
1202
+ // header comment for the full incident writeup), a balanced-brace
1203
+ // scanner giving REAL character offsets for the then/else bodies —
1204
+ // `_countNewlines(s, offset)` from those offsets gives the EXACT
1205
+ // absolute line regardless of header/comment line-count, same
1206
+ // per-clause technique the try/catch/finally recognizer below uses.
1207
+ const ifElse = _scanIfElse(s);
1208
+ if (ifElse) {
1209
+ const { assigns: ifAssigns, cond: ifCond } = _hoistCondAssign(ifElse.condText);
1210
+ let condPrev = prev;
1211
+ for (const a of ifAssigns) {
1212
+ const aNode = _addNode(nodes, { kind: 'assign', target: a.target, source: a.source, line });
1213
+ _linkNodes(nodes, condPrev, aNode);
1214
+ condPrev = aNode;
1215
+ }
1216
+ const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(ifCond), line });
1217
+ _linkNodes(nodes, condPrev, ifNode);
832
1218
  const join = _addNode(nodes, { kind: 'noop', line });
833
- const thenStartLine = line + _countNewlines(s, ifMatch.indices[2][0]);
834
- const thenTail = _buildCfg(ifMatch[2], nodes, ifNode, thenStartLine, depth + 1);
1219
+ const thenStartLine = line + _countNewlines(s, ifElse.thenBodyOffset);
1220
+ const thenTail = _buildCfg(ifElse.thenBody, nodes, ifNode, thenStartLine, depth + 1);
835
1221
  _linkNodes(nodes, thenTail, join);
836
- if (ifMatch[3]) {
837
- const elseStartLine = line + _countNewlines(s, ifMatch.indices[3][0]);
838
- const elseTail = _buildCfg(ifMatch[3], nodes, ifNode, elseStartLine, depth + 1);
1222
+ if (ifElse.elseBody !== null) {
1223
+ const elseStartLine = line + _countNewlines(s, ifElse.elseBodyOffset);
1224
+ const elseTail = _buildCfg(ifElse.elseBody, nodes, ifNode, elseStartLine, depth + 1);
839
1225
  _linkNodes(nodes, elseTail, join);
840
1226
  } else {
841
1227
  _linkNodes(nodes, ifNode, join);
@@ -846,10 +1232,17 @@ function _buildCfg(bodyText, nodes, prevId, startLine, depth = 0) {
846
1232
 
847
1233
  const whileMatch = s.match(/^while\s*\((.+?)\)\s*\{([\s\S]*)\}\s*$/ds);
848
1234
  if (whileMatch) {
1235
+ const { assigns: whileAssigns } = _hoistCondAssign(whileMatch[1]);
849
1236
  const header = _addNode(nodes, { kind: 'loop-header', line });
850
1237
  _linkNodes(nodes, prev, header);
1238
+ let condPrev = header;
1239
+ for (const a of whileAssigns) {
1240
+ const aNode = _addNode(nodes, { kind: 'assign', target: a.target, source: a.source, line });
1241
+ _linkNodes(nodes, condPrev, aNode);
1242
+ condPrev = aNode;
1243
+ }
851
1244
  const bodyStartLine = line + _countNewlines(s, whileMatch.indices[2][0]);
852
- const bodyTail = _buildCfg(whileMatch[2], nodes, header, bodyStartLine, depth + 1);
1245
+ const bodyTail = _buildCfg(whileMatch[2], nodes, condPrev, bodyStartLine, depth + 1);
853
1246
  _linkNodes(nodes, bodyTail, header);
854
1247
  const join = _addNode(nodes, { kind: 'noop', line });
855
1248
  _linkNodes(nodes, header, join);
@@ -937,11 +1330,13 @@ export function parsePhpFile(file, code) {
937
1330
 
938
1331
  const functions = [];
939
1332
  const spans = []; // {start, end}: source ranges fully consumed by a matched function (signature through closing brace)
1333
+ const classRegions = _findClassRegions(code);
940
1334
  FUNC_RE.lastIndex = 0;
941
1335
  _nid = 0;
942
1336
  let m;
943
1337
  while ((m = FUNC_RE.exec(code)) !== null) {
944
1338
  const name = m[1];
1339
+ const className = _classNameAt(classRegions, m.index);
945
1340
  const paramsText = m[2] || '';
946
1341
  const params = paramsText.split(',').map(p => {
947
1342
  const t = p.trim();
@@ -979,8 +1374,8 @@ export function parsePhpFile(file, code) {
979
1374
  _linkNodes(nodes, tail, exit);
980
1375
  const cfg = { entry, exit, nodes };
981
1376
  functions.push({
982
- qid: _qid(file, name, startLine, extracted.body),
983
- name, line: startLine, params, file,
1377
+ qid: _qid(file, name, startLine, extracted.body, className),
1378
+ name: className ? `${className}.${name}` : name, line: startLine, params, file,
984
1379
  cfg,
985
1380
  calls: callSitesFromCfg(cfg),
986
1381
  });
@@ -1028,5 +1423,13 @@ export function parsePhpFile(file, code) {
1028
1423
  topLevel = modQid;
1029
1424
  }
1030
1425
 
1031
- return functions.length ? { file, functions, topLevel } : null;
1426
+ // SARD_80_F1 W5.10 — `ir.classes` (declared field names only; no
1427
+ // `bases`/`extends` yet — this codepath's one consumer so far,
1428
+ // dataflow/engine.js's cross-method field-taint pass, doesn't need it,
1429
+ // and PHP's inheritance chain isn't otherwise tracked. Purely additive:
1430
+ // `buildClassHierarchy` already merges `ir.classes` across every parser
1431
+ // that emits it, uniformly).
1432
+ const classes = classRegions.map((r) => ({ name: r.name, line: _lineAt(code, r.start), fields: _extractClassFields(code, r) }));
1433
+
1434
+ return functions.length ? { file, functions, topLevel, classes } : null;
1032
1435
  }