@clear-capabilities/agentic-security-scanner 0.136.2 → 0.137.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 +880 -0
- package/bin/agentic-security.js +189 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +192 -15
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/clustering.js +12 -1
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/sbom.js +2 -2
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
package/src/ir/parser-php.js
CHANGED
|
@@ -10,15 +10,39 @@
|
|
|
10
10
|
// - return
|
|
11
11
|
// - foreach as loop-header + assign
|
|
12
12
|
// - PHP superglobals ($_GET, $_POST, $_REQUEST, etc.) as ident sources
|
|
13
|
+
// - control flow (R8): `if`/`else`/`while`/`foreach`/`try`/`catch`/
|
|
14
|
+
// `finally`/`switch` bodies are recursed into — the statement splitter
|
|
15
|
+
// now flushes on a closing `}` (see the `}`-flush comment near
|
|
16
|
+
// `_splitStatements` below) instead of only on `;`, so a sink nested
|
|
17
|
+
// inside a braced control-flow body is reachable rather than being
|
|
18
|
+
// dropped or folded into a bogus call node. This took three fix rounds
|
|
19
|
+
// to get line-number-exact (see the PRD R8 status entry in
|
|
20
|
+
// `docs/DETECTION_GAP_REMEDIATION_PRD.md` for the full history) — the
|
|
21
|
+
// CFG shape itself was correct from the first round. NOTE: C-style
|
|
22
|
+
// `for` is deliberately NOT in this list — PHP has no `for`-loop
|
|
23
|
+
// recognizer at all (never in R8's scope, still true today); a sink
|
|
24
|
+
// inside `for ($i=0; $i<3; $i++) { ... }` still folds into a bogus
|
|
25
|
+
// `call:for` node and is lost entirely, the exact failure mode R8
|
|
26
|
+
// exists to fix, unfixed for this one construct.
|
|
13
27
|
//
|
|
14
28
|
// What we do NOT model:
|
|
15
29
|
// - arrow functions (fn($x) => expr)
|
|
16
30
|
// - traits / interfaces
|
|
17
31
|
// - anonymous classes
|
|
18
|
-
// -
|
|
32
|
+
// - C-style `for` loops (see the note above — no recognizer exists)
|
|
33
|
+
// - the PHP 8 `match` expression (analogous gap to Java's arrow-form
|
|
34
|
+
// `switch` — this is the one modern control-flow SHAPE R8 did not
|
|
35
|
+
// cover, as opposed to the R8 fix's own scope, which is bodies of
|
|
36
|
+
// control-flow statements PHP already recognized)
|
|
37
|
+
// - `elseif`/`else if` chains (pre-existing, not touched by R8)
|
|
38
|
+
// - `if`/`else`'s pre-existing greedy-capture-group bug: the then-body
|
|
39
|
+
// capture group unconditionally swallows through to the else-body's
|
|
40
|
+
// own closing `}`, dropping the else-body's first statement
|
|
41
|
+
// (pre-existing, confirmed present before R8, not fixed by it)
|
|
19
42
|
|
|
20
43
|
import * as crypto from 'node:crypto';
|
|
21
44
|
import { callSitesFromCfg } from './call-sites.js';
|
|
45
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
22
46
|
|
|
23
47
|
const FUNC_RE = new RegExp(
|
|
24
48
|
'(?:^|[\\n;{}]|<\\?php|<\\?)\\s*' +
|
|
@@ -29,43 +53,256 @@ const FUNC_RE = new RegExp(
|
|
|
29
53
|
'(?:\\s*:\\s*\\??[A-Za-z_]\\w*)?' + // optional return type
|
|
30
54
|
'\\s*\\{', 'g');
|
|
31
55
|
|
|
56
|
+
// Returns `{ text, line }[]` — `line` is the 1-indexed line, relative to the
|
|
57
|
+
// START of `body`, of the first non-whitespace character of that statement.
|
|
58
|
+
// This is computed from the ACTUAL scan position (a running `curLine`
|
|
59
|
+
// incremented on every physical `\n` encountered, including ones skipped
|
|
60
|
+
// inside a `//` comment) rather than by re-counting newlines inside the
|
|
61
|
+
// already-trimmed statement text afterwards — that reconstruction is lossy:
|
|
62
|
+
// `.trim()` discards any leading blank lines (or blanked-out characters —
|
|
63
|
+
// see parser-php.js's `_blankSpans`/`_buildCfg` module-level lowering) before
|
|
64
|
+
// a statement's real content, so a caller that tried to recover the line by
|
|
65
|
+
// counting embedded newlines would silently undercount by exactly the
|
|
66
|
+
// number of blank lines that preceded the statement. This was the root
|
|
67
|
+
// cause of PHP module-level findings reporting the wrong line and, in turn,
|
|
68
|
+
// making the line-scoped `agentic-security-ignore` suppression pragma inert
|
|
69
|
+
// for them (Finding 2 of the R14(b) final whole-branch review).
|
|
70
|
+
// True when the next non-whitespace, non-comment token starting at
|
|
71
|
+
// `body[i + 1]` is one of the `else`/`catch`/`finally` continuation
|
|
72
|
+
// keywords — the ones that must stay glued to a preceding `}` rather than
|
|
73
|
+
// starting a new split entry (see the R8 comment at the `}`-flush call
|
|
74
|
+
// site below). Both whitespace AND comments (`//` and `/* */`) are
|
|
75
|
+
// skipped, mirroring `_splitStatements`' own comment-skip logic.
|
|
76
|
+
//
|
|
77
|
+
// R8 fix round 3: whitespace-only skipping was a real regression this
|
|
78
|
+
// task introduced (not a pre-existing limitation, as an earlier version of
|
|
79
|
+
// this comment incorrectly claimed). `} /* mid */ else { ... }` or
|
|
80
|
+
// `}\n// explain\nelse { ... }` are unremarkable, real PHP shapes — a
|
|
81
|
+
// comment explaining WHY an else/catch/finally branch exists is a normal
|
|
82
|
+
// thing to write immediately above it. With whitespace-only skipping, the
|
|
83
|
+
// comment defeated the lookahead, the `}`-flush fired anyway, and the
|
|
84
|
+
// entire `else`/`catch`/`finally` body was silently dropped from the CFG.
|
|
85
|
+
// For `catch`/`finally` specifically this is confirmed a clean, provable
|
|
86
|
+
// fix (see the `catch`/`finally` regression tests in
|
|
87
|
+
// `test/parser-php-control-flow.test.js`, which fail without this change
|
|
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.
|
|
101
|
+
function _continuationKeywordAhead(body, i) {
|
|
102
|
+
let j = i + 1;
|
|
103
|
+
let moved = true;
|
|
104
|
+
while (moved) {
|
|
105
|
+
moved = false;
|
|
106
|
+
while (j < body.length && /\s/.test(body[j])) { j++; moved = true; }
|
|
107
|
+
if (body[j] === '/' && body[j + 1] === '/') {
|
|
108
|
+
while (j < body.length && body[j] !== '\n') j++;
|
|
109
|
+
moved = true;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (body[j] === '/' && body[j + 1] === '*') {
|
|
113
|
+
j += 2;
|
|
114
|
+
while (j < body.length && !(body[j] === '*' && body[j + 1] === '/')) j++;
|
|
115
|
+
if (j < body.length) j += 2; // past the closing '*/'
|
|
116
|
+
moved = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const kw of ['else', 'catch', 'finally']) {
|
|
121
|
+
if (body.startsWith(kw, j)) {
|
|
122
|
+
const after = body[j + kw.length];
|
|
123
|
+
if (after === undefined || !/\w/.test(after)) return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
32
129
|
function _splitStatements(body) {
|
|
33
130
|
const out = [];
|
|
34
131
|
let buf = '';
|
|
132
|
+
let bufLine = null; // line of the first non-whitespace char seen in `buf` so far
|
|
133
|
+
let curLine = 1; // line of body[i], the character currently under the cursor
|
|
35
134
|
let depth = 0;
|
|
36
135
|
let inStr = null;
|
|
37
136
|
let escape = false;
|
|
137
|
+
const push = (c) => {
|
|
138
|
+
buf += c;
|
|
139
|
+
if (bufLine === null && !/\s/.test(c)) bufLine = curLine;
|
|
140
|
+
};
|
|
38
141
|
for (let i = 0; i < body.length; i++) {
|
|
39
142
|
const c = body[i];
|
|
40
|
-
if (escape) {
|
|
143
|
+
if (escape) { push(c); escape = false; if (c === '\n') curLine++; continue; }
|
|
41
144
|
if (inStr) {
|
|
42
|
-
|
|
43
|
-
if (c === '\\') { escape = true; continue; }
|
|
145
|
+
push(c);
|
|
146
|
+
if (c === '\\') { escape = true; if (c === '\n') curLine++; continue; }
|
|
44
147
|
if (c === inStr) inStr = null;
|
|
148
|
+
if (c === '\n') curLine++;
|
|
45
149
|
continue;
|
|
46
150
|
}
|
|
47
|
-
if (c === '"' || c === '\'') { inStr = c;
|
|
151
|
+
if (c === '"' || c === '\'') { inStr = c; push(c); continue; }
|
|
48
152
|
if (c === '/' && body[i + 1] === '/') {
|
|
49
153
|
while (i < body.length && body[i] !== '\n') i++;
|
|
154
|
+
// R8 fix round 2: push the newline that terminated the comment into
|
|
155
|
+
// `buf` (not just bump `curLine`). Comment text itself is still
|
|
156
|
+
// dropped (never pushed) — only the LINE it displaced is preserved,
|
|
157
|
+
// same principle `_blankSpans`/`_blank` already use elsewhere in
|
|
158
|
+
// this file (blank content out, keep line structure intact, don't
|
|
159
|
+
// delete it outright). Without this, the flushed statement text
|
|
160
|
+
// this comment lived inside ends up with FEWER newlines than the
|
|
161
|
+
// real source has, so `_buildCfg`'s newline-counting line
|
|
162
|
+
// computation for a nested body silently undercounts by exactly the
|
|
163
|
+
// number of newlines lost to comments — the sink line reported to
|
|
164
|
+
// the caller (and therefore the `agentic-security-ignore` pragma
|
|
165
|
+
// line it must match) is wrong for any control-flow body containing
|
|
166
|
+
// an otherwise-unrelated `//` comment.
|
|
167
|
+
if (i < body.length) { push('\n'); curLine++; }
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (c === '/' && body[i + 1] === '*') {
|
|
171
|
+
// Block comment (incl. PHPDoc, e.g. `/** @param string $x */`).
|
|
172
|
+
// Skip to the matching `*/`, counting any newlines crossed so line
|
|
173
|
+
// tracking stays accurate for whatever follows — same contract the
|
|
174
|
+
// `//` handling above upholds. Bounded even when unterminated (`i`
|
|
175
|
+
// simply runs to `body.length` and the outer `for` loop ends).
|
|
176
|
+
// R8 fix round 2: same newline-preservation fix as the `//` handler
|
|
177
|
+
// above, but a block comment can span MANY lines — push one `\n`
|
|
178
|
+
// into `buf` for every newline it displaces, not just one, or a
|
|
179
|
+
// multi-line block comment inside a control-flow body would still
|
|
180
|
+
// undercount by (newlines - 1).
|
|
181
|
+
i += 2; // past the opening '/*'
|
|
182
|
+
while (i < body.length && !(body[i] === '*' && body[i + 1] === '/')) {
|
|
183
|
+
if (body[i] === '\n') { curLine++; push('\n'); }
|
|
184
|
+
i++;
|
|
185
|
+
}
|
|
186
|
+
if (i < body.length) i++; // land on the '/' of '*/'; skipped, contributing no statement text
|
|
50
187
|
continue;
|
|
51
188
|
}
|
|
52
189
|
if (c === '{' || c === '(' || c === '[') depth++;
|
|
53
|
-
if (c === '}' || c === ')' || c === ']')
|
|
190
|
+
if (c === '}' || c === ')' || c === ']') {
|
|
191
|
+
depth--;
|
|
192
|
+
// R8: a `}` that returns the shared depth counter to 0 ends a
|
|
193
|
+
// braced control-flow body (if/while/foreach/try/switch) — flush a
|
|
194
|
+
// statement boundary here too, not just on `;` at depth 0. PHP has
|
|
195
|
+
// no `{}`-based object/array-literal syntax (arrays use `[...]`,
|
|
196
|
+
// tracked by the same counter but not this trigger), so this cannot
|
|
197
|
+
// mis-fire mid-expression the way it would for a language with `{}`
|
|
198
|
+
// object initializers. A `}` that closes a lambda/closure body
|
|
199
|
+
// passed as a call argument (`usort($arr, function($a,$b){...})`)
|
|
200
|
+
// does NOT trigger this — that `}` returns depth from 2 to 1 (still
|
|
201
|
+
// inside usort's outer `(`), not to 0.
|
|
202
|
+
//
|
|
203
|
+
// EXCEPTION: do not flush when the next non-whitespace token is
|
|
204
|
+
// `else`, `catch`, or `finally` — those must stay glued onto the
|
|
205
|
+
// SAME statement as the preceding `}` for `ifMatch` and the
|
|
206
|
+
// try/catch/finally recognizer in `_buildCfg` to see
|
|
207
|
+
// `if (...) { ... } else { ... }` or
|
|
208
|
+
// `try { ... } catch (...) { ... } finally { ... }` as one
|
|
209
|
+
// contiguous blob (`ifMatch` is anchored end-to-end with `$` and
|
|
210
|
+
// spans the whole construct; the try/catch/finally recognizer scans
|
|
211
|
+
// the whole construct by hand for the same reason). A blind
|
|
212
|
+
// flush-on-every-`}` here would silently split `if`/`else` and
|
|
213
|
+
// multi-clause `try` into two statements each, which is a strictly
|
|
214
|
+
// WORSE regression than the "must be last statement in scope" bug
|
|
215
|
+
// this task fixes — an
|
|
216
|
+
// `else`/`catch`/`finally` body would be dropped from the CFG
|
|
217
|
+
// entirely, not just occasionally mis-split.
|
|
218
|
+
if (c === '}' && depth === 0 && !_continuationKeywordAhead(body, i)) {
|
|
219
|
+
push(c);
|
|
220
|
+
const t = buf.trim();
|
|
221
|
+
if (t) out.push({ text: t, line: bufLine ?? curLine });
|
|
222
|
+
buf = '';
|
|
223
|
+
bufLine = null;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
54
227
|
if (c === ';' && depth === 0) {
|
|
55
228
|
const t = buf.trim();
|
|
56
|
-
if (t) out.push(t);
|
|
229
|
+
if (t) out.push({ text: t, line: bufLine ?? curLine });
|
|
57
230
|
buf = '';
|
|
231
|
+
bufLine = null;
|
|
58
232
|
continue;
|
|
59
233
|
}
|
|
60
|
-
|
|
234
|
+
// R8: a `switch` body's `case <expr>:` / `default:` labels are
|
|
235
|
+
// terminated by `:`, not `;` or `}` — without this, the label text
|
|
236
|
+
// stays glued onto whatever real statement follows it (no `;` or `}`
|
|
237
|
+
// separates them), and that combined blob then fails BOTH the
|
|
238
|
+
// assignment and call-statement shapes in `_lowerStmt` (neither
|
|
239
|
+
// starts with `$var =` nor a bare identifier-call), silently
|
|
240
|
+
// dropping the case body's first statement entirely — worse than the
|
|
241
|
+
// brief's anticipated "label falls through to `_lowerStmt` and
|
|
242
|
+
// returns null harmlessly" behavior. Scoped tightly (buf must be
|
|
243
|
+
// EXACTLY `case <expr>` or `default`) so it can't mis-fire on a
|
|
244
|
+
// ternary's `:`, which leaves buf holding something that never
|
|
245
|
+
// matches either pattern.
|
|
246
|
+
//
|
|
247
|
+
// Excludes `::` (PHP's static-access / class-constant / PHP 8.1
|
|
248
|
+
// enum-case operator, e.g. `case Foo::BAR:` or `case Status::Active:`)
|
|
249
|
+
// via the adjacent-char check below — without it, `case Foo::BAR:`'s
|
|
250
|
+
// FIRST `:` already makes buf read "case Foo", which matches the case
|
|
251
|
+
// pattern just as eagerly as the real terminating `:` after `BAR`
|
|
252
|
+
// does, mis-splitting mid-token and dropping that case's body. Both
|
|
253
|
+
// colons of a `::` pair are skipped (checking one neighbor character
|
|
254
|
+
// each is enough: the first colon sees `body[i+1] === ':'`, the
|
|
255
|
+
// second sees `body[i-1] === ':'`), so only a genuine lone `:` can
|
|
256
|
+
// ever terminate a label.
|
|
257
|
+
if (c === ':' && depth === 0 && body[i + 1] !== ':' && body[i - 1] !== ':') {
|
|
258
|
+
const t = buf.trim();
|
|
259
|
+
if (/^case\s+[\s\S]+$/.test(t) || t === 'default') {
|
|
260
|
+
out.push({ text: t, line: bufLine ?? curLine });
|
|
261
|
+
buf = '';
|
|
262
|
+
bufLine = null;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
push(c);
|
|
267
|
+
if (c === '\n') curLine++;
|
|
61
268
|
}
|
|
62
|
-
|
|
269
|
+
const t = buf.trim();
|
|
270
|
+
if (t) out.push({ text: t, line: bufLine ?? curLine });
|
|
63
271
|
return out;
|
|
64
272
|
}
|
|
65
273
|
|
|
66
274
|
function _lowerExpr(text) {
|
|
67
275
|
const s = String(text || '').trim();
|
|
68
276
|
if (!s) return { kind: 'unknown' };
|
|
277
|
+
// Stage 3 correctness audit (detection depth, per-language-IR): PHP
|
|
278
|
+
// double-quoted strings interpolate variables directly
|
|
279
|
+
// ("SELECT ... WHERE id=$id", "hi {$user->name}") — single-quoted
|
|
280
|
+
// strings never do. This must run BEFORE the generic string-literal
|
|
281
|
+
// fallback just below, which otherwise treated ANY quoted string
|
|
282
|
+
// (including a double-quoted one containing a live variable) as an
|
|
283
|
+
// opaque clean literal — silently dropping the interpolated variable's
|
|
284
|
+
// taint. This is exactly `"SELECT ... WHERE id=$id"`, one of the most
|
|
285
|
+
// common real PHP SQL-injection shapes. Simple (`$var`, `$var->prop`,
|
|
286
|
+
// `$var[key]`) and complex (`{$expr}`) interpolation forms are both
|
|
287
|
+
// lowered into a template, same shape as the `.`-concat branch below.
|
|
288
|
+
if (/^"/.test(s) && s.includes('$')) {
|
|
289
|
+
const inner = s.slice(1, -1);
|
|
290
|
+
const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
|
|
291
|
+
let lastIndex = 0;
|
|
292
|
+
const parts = [];
|
|
293
|
+
let matched = false;
|
|
294
|
+
let m;
|
|
295
|
+
while ((m = re.exec(inner)) !== null) {
|
|
296
|
+
matched = true;
|
|
297
|
+
if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
|
|
298
|
+
parts.push(_lowerExpr(m[1] !== undefined ? m[1] : m[2]));
|
|
299
|
+
lastIndex = re.lastIndex;
|
|
300
|
+
}
|
|
301
|
+
if (matched) {
|
|
302
|
+
if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
|
|
303
|
+
return { kind: 'tpl', parts };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
69
306
|
if (/^"/.test(s) || /^'/.test(s)) return { kind: 'literal', value: s };
|
|
70
307
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
71
308
|
if (/^(true|false|null|NULL)\b/.test(s)) return { kind: 'literal', value: s };
|
|
@@ -81,22 +318,39 @@ function _lowerExpr(text) {
|
|
|
81
318
|
}
|
|
82
319
|
// Variable
|
|
83
320
|
if (/^\$[A-Za-z_]\w*$/.test(s)) return { kind: 'ident', name: s };
|
|
84
|
-
// Method call: $obj->method(args) or ClassName::method(args)
|
|
85
|
-
|
|
321
|
+
// Method call: $obj->method(args) or ClassName::method(args).
|
|
322
|
+
// matchBalancedCall finds the paren that actually balances the FIRST
|
|
323
|
+
// '(' — not the greedy-to-end-of-string match the old `/\((.*)\)\s*$/`
|
|
324
|
+
// used, which corrupted the argument text for a chained call
|
|
325
|
+
// (`sanitize($x)->trim()` produced args="$x)->trim(", which then fell
|
|
326
|
+
// through to {kind:'unknown'} and silently dropped $x).
|
|
327
|
+
const methodCall = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
|
|
86
328
|
if (methodCall) {
|
|
87
|
-
const callee = methodCall
|
|
88
|
-
const args = _splitTopLevelCommas(methodCall
|
|
329
|
+
const callee = methodCall.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
330
|
+
const args = _splitTopLevelCommas(methodCall.argsText).map(_lowerExpr);
|
|
89
331
|
return { kind: 'call', callee, args };
|
|
90
332
|
}
|
|
91
333
|
// Function call: func(args)
|
|
92
|
-
const funcCall = s
|
|
334
|
+
const funcCall = matchBalancedCall(s, /^([A-Za-z_][\w]*)/);
|
|
93
335
|
if (funcCall) {
|
|
94
|
-
return { kind: 'call', callee: funcCall
|
|
336
|
+
return { kind: 'call', callee: funcCall.callee, args: _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr) };
|
|
95
337
|
}
|
|
96
338
|
// Concat with .
|
|
339
|
+
//
|
|
340
|
+
// The `parts.length > 1` guard is load-bearing, not defensive tidiness:
|
|
341
|
+
// when every `.` in `s` is nested inside brackets or strings (e.g.
|
|
342
|
+
// `s = '"y.z"'` in `["health" => "check.status"]`), `_splitTopLevelDot`
|
|
343
|
+
// returns `[s]` — the input unchanged, as a single part — and mapping
|
|
344
|
+
// `_lowerExpr` over it recurses on the IDENTICAL string forever (stack
|
|
345
|
+
// overflow). Before R14(b) this was only reachable from inside function
|
|
346
|
+
// bodies; the module-level lowering now feeds every top-level statement
|
|
347
|
+
// through here too, so real files (e.g. a top-level array literal with a
|
|
348
|
+
// dotted string value) hit it and the per-file catch in `ir/index.js`
|
|
349
|
+
// silently dropped the whole file from Layer-2 analysis. Same shape as
|
|
350
|
+
// parser-cs.js's `_splitTopLevelPlus` guard.
|
|
97
351
|
if (s.includes('.') && /["'\$]/.test(s)) {
|
|
98
|
-
const
|
|
99
|
-
return { kind: 'tpl', parts };
|
|
352
|
+
const rawParts = _splitTopLevelDot(s);
|
|
353
|
+
if (rawParts.length > 1) return { kind: 'tpl', parts: rawParts.map(_lowerExpr) };
|
|
100
354
|
}
|
|
101
355
|
// Member: $obj->prop
|
|
102
356
|
if (/^\$[\w]+(?:->[\w]+)+$/.test(s)) {
|
|
@@ -170,10 +424,10 @@ function _lowerStmt(stmt, line) {
|
|
|
170
424
|
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
171
425
|
}
|
|
172
426
|
// Statement-form call
|
|
173
|
-
const call = s
|
|
427
|
+
const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
|
|
174
428
|
if (call) {
|
|
175
|
-
const callee = call
|
|
176
|
-
return { kind: 'call', line, callee, args: _splitTopLevelCommas(call
|
|
429
|
+
const callee = call.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
430
|
+
return { kind: 'call', line, callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
|
|
177
431
|
}
|
|
178
432
|
return null;
|
|
179
433
|
}
|
|
@@ -211,6 +465,70 @@ function _qid(file, name, line, body) {
|
|
|
211
465
|
return `${file}::${name}@${line}#${sha}`;
|
|
212
466
|
}
|
|
213
467
|
|
|
468
|
+
// FUNC_RE's leading alternation `(?:^|[\n;{}]|<\?php|<\?)` matches a single
|
|
469
|
+
// "boundary" character/token that belongs to whatever precedes the function
|
|
470
|
+
// (a statement terminator, a brace, or the PHP open tag) — not to the
|
|
471
|
+
// function itself. `m.index` always points at the START of that boundary,
|
|
472
|
+
// so including it verbatim in the function's span would blank away e.g. the
|
|
473
|
+
// `;` that terminates the PRECEDING top-level statement once the whole file
|
|
474
|
+
// is lowered in a single _buildCfg pass (see _blankSpans below), silently
|
|
475
|
+
// merging that statement with whatever gap text follows the function. This
|
|
476
|
+
// returns how many characters of the boundary token were actually consumed
|
|
477
|
+
// so the function's span can be made to start right after it.
|
|
478
|
+
function _funcBoundaryLen(code, idx) {
|
|
479
|
+
if (idx > 0) {
|
|
480
|
+
const c = code[idx];
|
|
481
|
+
if (c === '\n' || c === ';' || c === '{' || c === '}') return 1;
|
|
482
|
+
}
|
|
483
|
+
if (/^<\?php/i.test(code.slice(idx, idx + 5))) return 5;
|
|
484
|
+
if (/^<\?/.test(code.slice(idx, idx + 2))) return 2;
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Blank out every real function's span in a COPY of the full source
|
|
489
|
+
// (replace its characters with spaces, preserving every newline exactly).
|
|
490
|
+
// This lets the WHOLE file be lowered in a single _buildCfg call at
|
|
491
|
+
// startLine=1 for the module-level CFG, which keeps every remaining
|
|
492
|
+
// statement's reported line number exactly equal to its real source line —
|
|
493
|
+
// no character is ever deleted, only turned into a space, so nothing can
|
|
494
|
+
// shift. This replaces the old per-gap slicing + per-gap startLine
|
|
495
|
+
// re-derivation, which mis-tracked lines whenever a gap slice started with
|
|
496
|
+
// leading blank/newline characters and broke the line-scoped
|
|
497
|
+
// `agentic-security-ignore` suppression pragma for module-level findings.
|
|
498
|
+
function _blankSpans(code, spans) {
|
|
499
|
+
let out = '';
|
|
500
|
+
let cursor = 0;
|
|
501
|
+
for (const span of spans) {
|
|
502
|
+
if (span.start > cursor) out += code.slice(cursor, span.start);
|
|
503
|
+
out += _blank(code.slice(span.start, span.end));
|
|
504
|
+
cursor = span.end;
|
|
505
|
+
}
|
|
506
|
+
out += code.slice(cursor);
|
|
507
|
+
return out;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function _blank(text) {
|
|
511
|
+
return text.replace(/[^\n]/g, ' ');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Blanks a leading PHP open tag (`<?php` or `<?`) if the file starts with
|
|
515
|
+
// one. Only the true start of the file is handled (matching the previous
|
|
516
|
+
// behavior's `cursor === 0` scope) — this is a real fix, not dead code:
|
|
517
|
+
// when top-level content sits between the open tag and the first function
|
|
518
|
+
// declaration (e.g. `<?php\n$x = [...];\nfunction f(){}`), the tag is NOT
|
|
519
|
+
// part of any function's span (the boundary FUNC_RE actually consumed
|
|
520
|
+
// before `function f` is the `;`, not the tag — see _funcBoundaryLen), so
|
|
521
|
+
// it survives into the blanked text as literal `<?php` characters. Left
|
|
522
|
+
// unblanked, that text glues onto the front of the first top-level
|
|
523
|
+
// statement (`<?php\n$x = [...]`), which then fails every `_lowerStmt`
|
|
524
|
+
// pattern (all anchored at the true start of the statement) and silently
|
|
525
|
+
// drops it. Blanking (not deleting) the tag keeps line numbers exact.
|
|
526
|
+
function _blankLeadingOpenTag(text) {
|
|
527
|
+
const m = text.match(/^<\?(?:php)?/i);
|
|
528
|
+
if (!m) return text;
|
|
529
|
+
return _blank(m[0]) + text.slice(m[0].length);
|
|
530
|
+
}
|
|
531
|
+
|
|
214
532
|
let _nid = 0;
|
|
215
533
|
function _nextId() { return `pn${++_nid}`; }
|
|
216
534
|
|
|
@@ -228,66 +546,284 @@ function _linkNodes(nodes, src, dst) {
|
|
|
228
546
|
if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
|
|
229
547
|
}
|
|
230
548
|
|
|
231
|
-
|
|
549
|
+
// R8 fix round 1: counts `\n` in `s` up to (not including) `upTo`. `s` here
|
|
550
|
+
// is the RAW, untrimmed statement text `_buildCfg` is currently processing
|
|
551
|
+
// (not a `.trim()`-mangled reconstruction — the exact lossy pattern the
|
|
552
|
+
// module header comment above `_splitStatements` warns against), so a
|
|
553
|
+
// direct count is safe and exact, not an approximation.
|
|
554
|
+
function _countNewlines(s, upTo) {
|
|
555
|
+
let n = 0;
|
|
556
|
+
const end = Math.min(upTo, s.length);
|
|
557
|
+
for (let i = 0; i < end; i++) if (s[i] === '\n') n++;
|
|
558
|
+
return n;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Scans a balanced `{ ... }` block starting at `s[openIdx] === '{'`.
|
|
562
|
+
// String-aware (a `{`/`}` inside a quoted string doesn't perturb depth),
|
|
563
|
+
// mirroring `_splitStatements`' own string handling. Returns the index
|
|
564
|
+
// just past the matching `}`, or -1 if unbalanced/not found.
|
|
565
|
+
function _matchBraceBlock(s, openIdx) {
|
|
566
|
+
if (s[openIdx] !== '{') return -1;
|
|
567
|
+
let depth = 0;
|
|
568
|
+
let inStr = null;
|
|
569
|
+
let escape = false;
|
|
570
|
+
for (let i = openIdx; i < s.length; i++) {
|
|
571
|
+
const c = s[i];
|
|
572
|
+
if (escape) { escape = false; continue; }
|
|
573
|
+
if (inStr) {
|
|
574
|
+
if (c === '\\') { escape = true; continue; }
|
|
575
|
+
if (c === inStr) inStr = null;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (c === '"' || c === '\'') { inStr = c; continue; }
|
|
579
|
+
if (c === '{') depth++;
|
|
580
|
+
else if (c === '}') {
|
|
581
|
+
depth--;
|
|
582
|
+
if (depth === 0) return i + 1;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return -1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// R8 fix round 1 (Important): manually scans a
|
|
589
|
+
// `try { ... } (catch (...) { ... })* (finally { ... })?` construct by hand
|
|
590
|
+
// rather than delegating to one greedy-capture-group regex. The regex this
|
|
591
|
+
// replaced (`^try\s*\{([\s\S]*)\}\s*catch\s*\(([^)]*)\)\s*\{([\s\S]*)\}
|
|
592
|
+
// (?:\s*finally\s*\{([\s\S]*)\})?\s*$`) has two real bugs: its catch-body
|
|
593
|
+
// group is GREEDY, so for `try{}catch(E $e){A}finally{B}` it swallows
|
|
594
|
+
// through to finally's OWN closing `}` (not catch's), making the trailing
|
|
595
|
+
// `(?:\s*finally...)?` group match nothing — `finally` bodies were dead
|
|
596
|
+
// code, never actually reachable. And it unconditionally REQUIRES a catch
|
|
597
|
+
// clause, so the equally-valid `try { ... } finally { ... }` (no catch) PHP
|
|
598
|
+
// shape was invisible entirely, both bodies dropped. Neither is fixable by
|
|
599
|
+
// tweaking the regex (lazy-matching the catch group just breaks nested-
|
|
600
|
+
// brace bodies the other way); balanced-brace scanning is the correct fix.
|
|
601
|
+
//
|
|
602
|
+
// Only the FIRST catch clause is modeled in the CFG (matches this task's
|
|
603
|
+
// brief scope — multi-catch BLOCK form, `catch(A){}catch(B){}`, was
|
|
604
|
+
// already an accepted, documented partial-capture degrade before this fix,
|
|
605
|
+
// confirmed non-crashing; union-type catches, `catch (A|B $e) {}`, are
|
|
606
|
+
// unaffected and fully supported, same as before). Every catch clause
|
|
607
|
+
// present is still scanned past correctly (not just the first) so a
|
|
608
|
+
// trailing `finally` is located at its real position rather than
|
|
609
|
+
// mismatched against a later catch's own content.
|
|
610
|
+
//
|
|
611
|
+
// Returns null if `s` isn't `try { ... }` shaped at all. Otherwise:
|
|
612
|
+
// { tryBody, tryBodyOffset, catchBody, catchBodyOffset,
|
|
613
|
+
// finallyBody, finallyBodyOffset }
|
|
614
|
+
// A body that isn't present is `null` with `-1` for its offset. Each
|
|
615
|
+
// `*Offset` is `s`'s own character offset of that body's first character —
|
|
616
|
+
// paired with `_countNewlines`, this gives the caller (`_buildCfg`) the
|
|
617
|
+
// EXACT absolute source line for each clause, not just the try-body's
|
|
618
|
+
// (which happens to share `line`'s value for ordinary K&R style, but
|
|
619
|
+
// catch/finally clauses generally start several lines later).
|
|
620
|
+
function _scanTryCatchFinally(s) {
|
|
621
|
+
const head = /^try\s*/.exec(s);
|
|
622
|
+
if (!head) return null;
|
|
623
|
+
let i = head[0].length;
|
|
624
|
+
if (s[i] !== '{') return null;
|
|
625
|
+
const tryBodyOffset = i + 1;
|
|
626
|
+
const tryEnd = _matchBraceBlock(s, i);
|
|
627
|
+
if (tryEnd < 0) return null;
|
|
628
|
+
const tryBody = s.slice(tryBodyOffset, tryEnd - 1);
|
|
629
|
+
i = tryEnd;
|
|
630
|
+
|
|
631
|
+
let catchBody = null;
|
|
632
|
+
let catchBodyOffset = -1;
|
|
633
|
+
let sawCatch = false;
|
|
634
|
+
for (;;) {
|
|
635
|
+
const rest = s.slice(i);
|
|
636
|
+
const ws = /^\s*/.exec(rest)[0];
|
|
637
|
+
i += ws.length;
|
|
638
|
+
const cm = /^catch\s*\(/.exec(s.slice(i));
|
|
639
|
+
if (!cm) break;
|
|
640
|
+
const parenStart = i + cm[0].length - 1;
|
|
641
|
+
const parenEnd = s.indexOf(')', parenStart);
|
|
642
|
+
if (parenEnd < 0) return null;
|
|
643
|
+
let j = parenEnd + 1;
|
|
644
|
+
const ws2 = /^\s*/.exec(s.slice(j))[0];
|
|
645
|
+
j += ws2.length;
|
|
646
|
+
if (s[j] !== '{') return null;
|
|
647
|
+
const bodyOffset = j + 1;
|
|
648
|
+
const bodyEnd = _matchBraceBlock(s, j);
|
|
649
|
+
if (bodyEnd < 0) return null;
|
|
650
|
+
if (!sawCatch) {
|
|
651
|
+
catchBody = s.slice(bodyOffset, bodyEnd - 1);
|
|
652
|
+
catchBodyOffset = bodyOffset;
|
|
653
|
+
sawCatch = true;
|
|
654
|
+
}
|
|
655
|
+
i = bodyEnd;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let finallyBody = null;
|
|
659
|
+
let finallyBodyOffset = -1;
|
|
660
|
+
{
|
|
661
|
+
const ws = /^\s*/.exec(s.slice(i))[0];
|
|
662
|
+
let j = i + ws.length;
|
|
663
|
+
const fm = /^finally\s*/.exec(s.slice(j));
|
|
664
|
+
if (fm) {
|
|
665
|
+
j += fm[0].length;
|
|
666
|
+
if (s[j] === '{') {
|
|
667
|
+
const bodyOffset = j + 1;
|
|
668
|
+
const bodyEnd = _matchBraceBlock(s, j);
|
|
669
|
+
if (bodyEnd < 0) return null;
|
|
670
|
+
finallyBody = s.slice(bodyOffset, bodyEnd - 1);
|
|
671
|
+
finallyBodyOffset = bodyOffset;
|
|
672
|
+
i = bodyEnd;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// PHP requires at least one of catch/finally; a bare `try {}` alone
|
|
678
|
+
// isn't valid PHP and isn't a shape this recognizer should claim.
|
|
679
|
+
if (!sawCatch && finallyBody === null) return null;
|
|
680
|
+
// Whatever remains must be only trailing whitespace, or this wasn't a
|
|
681
|
+
// clean try/catch/finally statement after all — fall through to the
|
|
682
|
+
// generic `_lowerStmt` path (safe no-op) rather than claiming a bad
|
|
683
|
+
// match.
|
|
684
|
+
if (s.slice(i).trim() !== '') return null;
|
|
685
|
+
|
|
686
|
+
return { tryBody, tryBodyOffset, catchBody, catchBodyOffset, finallyBody, finallyBodyOffset };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// `startLine` is the absolute source line of the FIRST character of
|
|
690
|
+
// `bodyText`. Each statement's absolute line is `startLine + stmt.line - 1`
|
|
691
|
+
// (`stmt.line` from _splitStatements is already 1-indexed and relative to
|
|
692
|
+
// `bodyText`), so — unlike the old incremental `line++`/`line += newlines+1`
|
|
693
|
+
// bookkeeping this replaced — no line is ever derived by re-counting
|
|
694
|
+
// newlines in already-trimmed text. That old scheme silently dropped any
|
|
695
|
+
// blank line (or, at module level, any blanked-out function span — see
|
|
696
|
+
// `_blankSpans`) that preceded a statement, which is exactly what made
|
|
697
|
+
// module-level PHP findings report the wrong source line.
|
|
698
|
+
function _buildCfg(bodyText, nodes, prevId, startLine, depth = 0) {
|
|
699
|
+
if (depth > 12) return prevId;
|
|
232
700
|
const stmts = _splitStatements(bodyText);
|
|
233
701
|
let prev = prevId;
|
|
234
|
-
let line = startLine;
|
|
235
702
|
for (const stmt of stmts) {
|
|
236
|
-
const s = stmt.
|
|
237
|
-
|
|
703
|
+
const s = stmt.text;
|
|
704
|
+
const line = startLine + stmt.line - 1;
|
|
705
|
+
if (!s || s.startsWith('//') || s.startsWith('#')) continue;
|
|
238
706
|
|
|
239
|
-
|
|
707
|
+
// R8 fix round 2: `line` (the absolute source line of `s`'s FIRST
|
|
708
|
+
// character — the `if`/`while`/`foreach`/`switch` keyword itself) is
|
|
709
|
+
// NOT a safe stand-in for a body's own start line in general. Fix
|
|
710
|
+
// round 1 used flat `line` for these four recognizers on the
|
|
711
|
+
// assumption that K&R-style `{` on the same physical line as the
|
|
712
|
+
// keyword makes the body start on that same line — true only when
|
|
713
|
+
// the header between the keyword and `{` is a single physical line
|
|
714
|
+
// AND contains no comment that ate a newline without contributing one
|
|
715
|
+
// back to `s` (fix round 1 also missed that a comment anywhere inside
|
|
716
|
+
// `s` — not just inside the header — undercounts newlines the same
|
|
717
|
+
// way; that half is fixed by `_splitStatements` now pushing a `\n`
|
|
718
|
+
// for every newline a skipped comment displaces, above). A `d`-flagged
|
|
719
|
+
// regex exposes each capture group's real character offset within
|
|
720
|
+
// `s` via `.indices`, so `_countNewlines(s, offset)` gives the body's
|
|
721
|
+
// EXACT absolute line regardless of how many lines the header itself
|
|
722
|
+
// spans or how many comments precede the body — the same precise
|
|
723
|
+
// per-clause technique the try/catch/finally recognizer below already
|
|
724
|
+
// uses (there, offsets come from the balanced-brace scanner instead
|
|
725
|
+
// of regex `.indices`, same principle).
|
|
726
|
+
const ifMatch = s.match(/^if\s*\((.+?)\)\s*\{([\s\S]*)\}(?:\s*else\s*\{([\s\S]*)\})?\s*$/ds);
|
|
240
727
|
if (ifMatch) {
|
|
241
728
|
const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(ifMatch[1]), line });
|
|
242
729
|
_linkNodes(nodes, prev, ifNode);
|
|
243
730
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
244
|
-
const
|
|
731
|
+
const thenStartLine = line + _countNewlines(s, ifMatch.indices[2][0]);
|
|
732
|
+
const thenTail = _buildCfg(ifMatch[2], nodes, ifNode, thenStartLine, depth + 1);
|
|
245
733
|
_linkNodes(nodes, thenTail, join);
|
|
246
734
|
if (ifMatch[3]) {
|
|
247
|
-
const
|
|
735
|
+
const elseStartLine = line + _countNewlines(s, ifMatch.indices[3][0]);
|
|
736
|
+
const elseTail = _buildCfg(ifMatch[3], nodes, ifNode, elseStartLine, depth + 1);
|
|
248
737
|
_linkNodes(nodes, elseTail, join);
|
|
249
738
|
} else {
|
|
250
739
|
_linkNodes(nodes, ifNode, join);
|
|
251
740
|
}
|
|
252
741
|
prev = join;
|
|
253
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
254
742
|
continue;
|
|
255
743
|
}
|
|
256
744
|
|
|
257
|
-
const whileMatch = s.match(/^while\s*\((.+?)\)\s*\{([\s\S]*)\}\s*$/
|
|
745
|
+
const whileMatch = s.match(/^while\s*\((.+?)\)\s*\{([\s\S]*)\}\s*$/ds);
|
|
258
746
|
if (whileMatch) {
|
|
259
747
|
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
260
748
|
_linkNodes(nodes, prev, header);
|
|
261
|
-
const
|
|
749
|
+
const bodyStartLine = line + _countNewlines(s, whileMatch.indices[2][0]);
|
|
750
|
+
const bodyTail = _buildCfg(whileMatch[2], nodes, header, bodyStartLine, depth + 1);
|
|
262
751
|
_linkNodes(nodes, bodyTail, header);
|
|
263
752
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
264
753
|
_linkNodes(nodes, header, join);
|
|
265
754
|
prev = join;
|
|
266
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
267
755
|
continue;
|
|
268
756
|
}
|
|
269
757
|
|
|
270
|
-
const foreachMatch = s.match(/^foreach\s*\((.+?)\s+as\s+(?:\$\w+\s*=>\s*)?(\$\w+)\)\s*\{([\s\S]*)\}\s*$/
|
|
758
|
+
const foreachMatch = s.match(/^foreach\s*\((.+?)\s+as\s+(?:\$\w+\s*=>\s*)?(\$\w+)\)\s*\{([\s\S]*)\}\s*$/ds);
|
|
271
759
|
if (foreachMatch) {
|
|
272
760
|
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
273
761
|
_linkNodes(nodes, prev, header);
|
|
274
762
|
const assignId = _addNode(nodes, { kind: 'assign', target: foreachMatch[2], source: _lowerExpr(foreachMatch[1]), line });
|
|
275
763
|
_linkNodes(nodes, header, assignId);
|
|
276
|
-
const
|
|
764
|
+
const bodyStartLine = line + _countNewlines(s, foreachMatch.indices[3][0]);
|
|
765
|
+
const bodyTail = _buildCfg(foreachMatch[3], nodes, assignId, bodyStartLine, depth + 1);
|
|
277
766
|
_linkNodes(nodes, bodyTail, header);
|
|
278
767
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
279
768
|
_linkNodes(nodes, header, join);
|
|
280
769
|
prev = join;
|
|
281
|
-
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const tryScan = /^try\s*\{/.test(s) ? _scanTryCatchFinally(s) : null;
|
|
774
|
+
if (tryScan) {
|
|
775
|
+
const tryNode = _addNode(nodes, { kind: 'noop', line });
|
|
776
|
+
_linkNodes(nodes, prev, tryNode);
|
|
777
|
+
const join = _addNode(nodes, { kind: 'noop', line });
|
|
778
|
+
// Same exact-offset technique as the if/while/foreach/switch bodies
|
|
779
|
+
// above, just fed by `_scanTryCatchFinally`'s balanced-brace-scan
|
|
780
|
+
// offsets instead of a `d`-flagged regex's `.indices` (there's no
|
|
781
|
+
// single regex to attach `.indices` to here — that's the whole
|
|
782
|
+
// reason this recognizer scans by hand). catch/finally clauses
|
|
783
|
+
// especially do NOT start on `line` (the `try` keyword's own line)
|
|
784
|
+
// in general — they start wherever their own `catch (...) {` /
|
|
785
|
+
// `finally {` happens to land, often several lines later.
|
|
786
|
+
const tryStartLine = line + _countNewlines(s, tryScan.tryBodyOffset);
|
|
787
|
+
const tryTail = _buildCfg(tryScan.tryBody, nodes, tryNode, tryStartLine, depth + 1);
|
|
788
|
+
let tail = tryTail;
|
|
789
|
+
if (tryScan.catchBody !== null) {
|
|
790
|
+
const catchNode = _addNode(nodes, { kind: 'noop', line });
|
|
791
|
+
_linkNodes(nodes, tail, catchNode);
|
|
792
|
+
const catchStartLine = line + _countNewlines(s, tryScan.catchBodyOffset);
|
|
793
|
+
tail = _buildCfg(tryScan.catchBody, nodes, catchNode, catchStartLine, depth + 1);
|
|
794
|
+
}
|
|
795
|
+
if (tryScan.finallyBody !== null) {
|
|
796
|
+
const finallyNode = _addNode(nodes, { kind: 'noop', line });
|
|
797
|
+
_linkNodes(nodes, tail, finallyNode);
|
|
798
|
+
const finallyStartLine = line + _countNewlines(s, tryScan.finallyBodyOffset);
|
|
799
|
+
tail = _buildCfg(tryScan.finallyBody, nodes, finallyNode, finallyStartLine, depth + 1);
|
|
800
|
+
}
|
|
801
|
+
_linkNodes(nodes, tail, join);
|
|
802
|
+
prev = join;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const switchMatch = s.match(/^switch\s*\((.+?)\)\s*\{([\s\S]*)\}\s*$/ds);
|
|
807
|
+
if (switchMatch) {
|
|
808
|
+
const switchNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(switchMatch[1]), line });
|
|
809
|
+
_linkNodes(nodes, prev, switchNode);
|
|
810
|
+
const join = _addNode(nodes, { kind: 'noop', line });
|
|
811
|
+
// PHP switch/case bodies fall through by default (no per-case
|
|
812
|
+
// braces) — lower the whole switchBlock body as ONE linear sequence
|
|
813
|
+
// under the switch node, matching this plan's "linear-but-complete"
|
|
814
|
+
// target rather than modeling per-case branch/skip semantics.
|
|
815
|
+
const bodyStartLine = line + _countNewlines(s, switchMatch.indices[2][0]);
|
|
816
|
+
const bodyTail = _buildCfg(switchMatch[2], nodes, switchNode, bodyStartLine, depth + 1);
|
|
817
|
+
_linkNodes(nodes, bodyTail, join);
|
|
818
|
+
prev = join;
|
|
282
819
|
continue;
|
|
283
820
|
}
|
|
284
821
|
|
|
285
822
|
const node = _lowerStmt(s, line);
|
|
286
|
-
if (!node)
|
|
823
|
+
if (!node) continue;
|
|
287
824
|
const id = _addNode(nodes, node);
|
|
288
825
|
_linkNodes(nodes, prev, id);
|
|
289
826
|
prev = id;
|
|
290
|
-
line += (s.match(/\n/g) || []).length + 1;
|
|
291
827
|
}
|
|
292
828
|
return prev;
|
|
293
829
|
}
|
|
@@ -298,6 +834,7 @@ export function parsePhpFile(file, code) {
|
|
|
298
834
|
if (code.length > 1_000_000) return null;
|
|
299
835
|
|
|
300
836
|
const functions = [];
|
|
837
|
+
const spans = []; // {start, end}: source ranges fully consumed by a matched function (signature through closing brace)
|
|
301
838
|
FUNC_RE.lastIndex = 0;
|
|
302
839
|
_nid = 0;
|
|
303
840
|
let m;
|
|
@@ -318,7 +855,25 @@ export function parsePhpFile(file, code) {
|
|
|
318
855
|
const nodes = {};
|
|
319
856
|
const entry = _addNode(nodes, { kind: 'entry', line: startLine });
|
|
320
857
|
const exit = _addNode(nodes, { kind: 'exit', line: startLine });
|
|
321
|
-
|
|
858
|
+
// R8 fix round 3: the function body's `startLine` must be derived from
|
|
859
|
+
// `braceIdx` (the function's actual opening `{`), not from `startLine + 1`
|
|
860
|
+
// (a flat one-line offset from `m.index`, the FUNC_RE match start —
|
|
861
|
+
// typically a preceding boundary character/token, not the function
|
|
862
|
+
// itself). `startLine + 1` is only correct when `{` sits on the very
|
|
863
|
+
// next physical line after wherever `m.index` landed, which holds for
|
|
864
|
+
// ordinary same-line-brace, single-line-signature functions but breaks
|
|
865
|
+
// for: Allman brace style (`{` on its own line — off by 1), a
|
|
866
|
+
// multi-line function signature (off by 2-3, one per extra signature
|
|
867
|
+
// line), and a function preceded by blank lines (off by however many
|
|
868
|
+
// blank lines precede it — a nearly universal real-world style). This
|
|
869
|
+
// reproduces the exact same pragma-inert/wrong-line-suppresses symptom
|
|
870
|
+
// fix rounds 1-2 already fixed for control-flow bodies, just at the
|
|
871
|
+
// function-body base line instead of a nested recursion site — pre-
|
|
872
|
+
// existing to this task (confirmed byte-identical across the original
|
|
873
|
+
// commit and both prior fix rounds), not something this task's own
|
|
874
|
+
// changes introduced, but it defeats the same user-facing goal so it's
|
|
875
|
+
// fixed here rather than left for a separate task.
|
|
876
|
+
const tail = _buildCfg(extracted.body, nodes, entry, _lineAt(code, braceIdx + 1));
|
|
322
877
|
_linkNodes(nodes, tail, exit);
|
|
323
878
|
const cfg = { entry, exit, nodes };
|
|
324
879
|
functions.push({
|
|
@@ -327,11 +882,49 @@ export function parsePhpFile(file, code) {
|
|
|
327
882
|
cfg,
|
|
328
883
|
calls: callSitesFromCfg(cfg),
|
|
329
884
|
});
|
|
885
|
+
// span.end is exclusive (points after the closing brace), so interior and trailing
|
|
886
|
+
// gaps both work correctly without special casing. extracted.end points AT the brace,
|
|
887
|
+
// so we add 1 to make it exclusive. We keep FUNC_RE.lastIndex at extracted.end
|
|
888
|
+
// (the brace position) so subsequent function matches can use it as a boundary.
|
|
889
|
+
// span.start is m.index PLUS the boundary token FUNC_RE consumed ahead of the
|
|
890
|
+
// function itself (see _funcBoundaryLen) — the boundary char/tag belongs to
|
|
891
|
+
// whatever precedes the function, not to the function's own blanked span.
|
|
892
|
+
spans.push({ start: m.index + _funcBoundaryLen(code, m.index), end: extracted.end + 1 });
|
|
330
893
|
// Don't skip past the closing brace: for `<?php function h(){...} function m(){...}`
|
|
331
894
|
// that brace is the only boundary character available to anchor the next
|
|
332
895
|
// function's match (there's no newline/semicolon between them), and advancing
|
|
333
896
|
// past it here would make the following function declaration unmatchable.
|
|
334
897
|
FUNC_RE.lastIndex = extracted.end;
|
|
335
898
|
}
|
|
336
|
-
|
|
899
|
+
|
|
900
|
+
// R14(b): lower top-level (module-scope) statements into a synthetic
|
|
901
|
+
// <module> function, mirroring parser-js.js's Program-level lowering.
|
|
902
|
+
// Every real function's span is blanked (see _blankSpans) in a copy of
|
|
903
|
+
// the full source, and the WHOLE blanked text is lowered in a single
|
|
904
|
+
// _buildCfg call at startLine=1 — this keeps every remaining statement's
|
|
905
|
+
// reported line number exactly equal to its real source line, since no
|
|
906
|
+
// character is ever deleted, only blanked to a space (newlines always
|
|
907
|
+
// survive). No new statement-classification logic is needed.
|
|
908
|
+
spans.sort((a, b) => a.start - b.start);
|
|
909
|
+
const blanked = _blankLeadingOpenTag(_blankSpans(code, spans));
|
|
910
|
+
const modNodes = {};
|
|
911
|
+
const modEntry = _addNode(modNodes, { kind: 'entry', line: 1 });
|
|
912
|
+
const modExit = _addNode(modNodes, { kind: 'exit', line: 1 });
|
|
913
|
+
const modTail = _buildCfg(blanked, modNodes, modEntry, 1);
|
|
914
|
+
_linkNodes(modNodes, modTail, modExit);
|
|
915
|
+
const modHasContent = Object.values(modNodes).some(n => n.kind !== 'entry' && n.kind !== 'exit');
|
|
916
|
+
let topLevel = null;
|
|
917
|
+
if (modHasContent) {
|
|
918
|
+
const moduleCfg = { entry: modEntry, exit: modExit, nodes: modNodes };
|
|
919
|
+
const modQid = _qid(file, '<module>', 1, code);
|
|
920
|
+
functions.push({
|
|
921
|
+
qid: modQid,
|
|
922
|
+
name: '<module>', line: 1, params: [], file,
|
|
923
|
+
cfg: moduleCfg,
|
|
924
|
+
calls: callSitesFromCfg(moduleCfg),
|
|
925
|
+
});
|
|
926
|
+
topLevel = modQid;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return functions.length ? { file, functions, topLevel } : null;
|
|
337
930
|
}
|