@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.
- package/CHANGELOG.md +125 -0
- package/dist/1336.index.js +16010 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/backward.js +104 -7
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/cross-service-taint.js +154 -11
- package/src/dataflow/engine.js +875 -90
- package/src/dataflow/exploit-prover.js +111 -23
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +29 -6
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +168 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +118 -9
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/balanced-call.js +33 -1
- package/src/ir/callgraph.js +26 -2
- package/src/ir/class-hierarchy.js +261 -7
- package/src/ir/csharp-ir.js +97 -8
- package/src/ir/index.js +21 -1
- package/src/ir/parser-cs.js +706 -24
- package/src/ir/parser-java.js +498 -17
- package/src/ir/parser-js.js +22 -0
- package/src/ir/parser-php.js +446 -43
- package/src/ir/parser-rust.js +1338 -0
- package/src/ir/php-include-merge.js +190 -0
- package/src/lineage/sink-registry.js +18 -0
- package/src/lineage/source-registry.js +23 -0
- package/src/posture/csharp-analysis.js +341 -43
- package/src/posture/finding-defaults.js +5 -1
- package/src/posture/fix-coverage.js +1 -0
- package/src/posture/proof-coverage.js +1 -0
- package/src/report/index.js +12 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/authz.js +144 -1
- package/src/sast/crypto-protocol.js +65 -7
- package/src/sast/csharp-cross-file.js +125 -0
- package/src/sast/csharp-structural.js +78 -2
- package/src/sast/csharp.js +574 -42
- package/src/sast/java-ast-folding.js +268 -11
- package/src/sast/java-bench-extras.js +530 -2
- package/src/sast/java-deserialization.js +31 -2
- package/src/sast/java-structural-cross-file.js +146 -0
- package/src/sast/java-structural.js +499 -6
- package/src/sast/ldap-injection.js +133 -3
- package/src/sast/rust.js +57 -0
- package/src/sast/xss-reflected-multilang.js +252 -3
- package/src/dataflow/smt-feasibility.js +0 -189
package/src/ir/parser-js.js
CHANGED
|
@@ -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',
|
package/src/ir/parser-php.js
CHANGED
|
@@ -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
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
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 `
|
|
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 (
|
|
223
|
-
//
|
|
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).
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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,
|
|
834
|
-
const thenTail = _buildCfg(
|
|
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 (
|
|
837
|
-
const elseStartLine = line + _countNewlines(s,
|
|
838
|
-
const elseTail = _buildCfg(
|
|
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,
|
|
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
|
-
|
|
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
|
}
|