@clear-capabilities/agentic-security-scanner 0.137.0 → 0.139.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 +219 -0
- package/dist/113.index.js +2 -2
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +29 -1
- package/dist/526.index.js +2 -2
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +10 -6
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +512 -14
- package/src/dataflow/engine.js +275 -27
- package/src/dataflow/summaries.js +30 -5
- package/src/engine.js +512 -120
- package/src/ir/CLAUDE.md +20 -5
- package/src/ir/balanced-call.js +11 -1
- package/src/ir/callgraph.js +34 -0
- package/src/ir/parser-cs.js +55 -6
- package/src/ir/parser-go.js +106 -2
- package/src/ir/parser-java.js +111 -10
- package/src/ir/parser-js.js +40 -0
- package/src/ir/parser-kt.js +194 -10
- package/src/ir/parser-php.js +108 -6
- package/src/ir/parser-py.helper.py +199 -10
- package/src/ir/parser-rb.js +405 -31
- package/src/mcp/tools.js +29 -1
- package/src/posture/accuracy-scorecard.js +103 -0
- package/src/runScan.js +5 -2
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/_auth-signals.js +141 -0
- package/src/sast/_comment-strip.js +80 -13
- package/src/sast/codegen-sink.js +110 -0
- package/src/sast/convention-deviation.js +235 -0
- package/src/sast/fastapi-hardening.js +45 -6
- package/src/sast/file-upload.js +29 -1
- package/src/sast/ownership-authz.js +245 -0
- package/src/sast/php.js +12 -2
- package/src/sast/rate-limit.js +2 -0
- package/src/sast/rbac-consistency.js +1 -1
- package/src/sast/redirect-toctou.js +167 -0
- package/src/sast/resource-exhaustion.js +217 -0
- package/src/sast/sibling-guard.js +176 -0
- package/src/sast/zip-slip.js +53 -2
package/src/ir/parser-java.js
CHANGED
|
@@ -68,24 +68,125 @@ function exprFromCst(node) {
|
|
|
68
68
|
if (node.children.primaryPrefix) {
|
|
69
69
|
const prefix = node.children.primaryPrefix[0];
|
|
70
70
|
const suffixes = node.children.primarySuffix || [];
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
// Taint-recall PRD (80%): java-parser models a CHAIN
|
|
72
|
+
// (`X().Y().Z(tainted)`) as ONE primary node with a FLAT array of
|
|
73
|
+
// primarySuffix entries — each is EITHER a methodInvocationSuffix
|
|
74
|
+
// (a `(...)` call) OR a {Dot, Identifier} member-access pair, in
|
|
75
|
+
// source order. The old code used `.find(Boolean)` to grab the
|
|
76
|
+
// FIRST methodInvocationSuffix in the whole array and stopped there
|
|
77
|
+
// — for `DocumentBuilderFactory.newInstance().newDocumentBuilder()
|
|
78
|
+
// .parse(s)` this returned just the `newInstance()` call with
|
|
79
|
+
// args: [], silently dropping `.newDocumentBuilder().parse(s)`
|
|
80
|
+
// entirely (confirmed via a real corpus fixture; the same shape
|
|
81
|
+
// also explains the earlier-documented, never-fixed
|
|
82
|
+
// `Runtime.getRuntime().exec(...)` gap). Now walks every suffix in
|
|
83
|
+
// order, building the callee name from member-access suffixes and,
|
|
84
|
+
// at each invocation suffix, folding that call's args into the
|
|
85
|
+
// running result — outermost-first (the LATEST call's own args are
|
|
86
|
+
// prepended), same convention as _followChain in the other 5
|
|
87
|
+
// hand-rolled parsers, so an existing `argIndex: 0` catalog entry
|
|
88
|
+
// keyed to the outermost call is unaffected while `argIndex: 'all'`
|
|
89
|
+
// can still find taint an inner call in the chain carried.
|
|
90
|
+
let chainCallee = '';
|
|
91
|
+
let chainArgs = null;
|
|
92
|
+
const fqn = prefix?.children?.fqnOrRefType?.[0];
|
|
93
|
+
// Taint-recall PRD (80%): a `new X(args)` CONSTRUCTOR starting the
|
|
94
|
+
// chain (`new URL(url).openStream()`) is a DIFFERENT CST shape than
|
|
95
|
+
// an fqnOrRefType prefix — `prefix.children.fqnOrRefType` is absent,
|
|
96
|
+
// so `fqn` above is undefined and, before this fix, BOTH the
|
|
97
|
+
// constructor's own class name AND its own arguments were silently
|
|
98
|
+
// dropped: `chainCallee` stayed empty and the suffix walk below only
|
|
99
|
+
// ever contributes the CHAINED method's name, producing a callee
|
|
100
|
+
// like bare "openStream" with the constructor's tainted argument
|
|
101
|
+
// nowhere in the IR at all — not misattributed, genuinely absent, a
|
|
102
|
+
// strictly worse failure than the terminal-segment-shift class this
|
|
103
|
+
// PRD fixes elsewhere with a receiver-scoped catalog entry (there is
|
|
104
|
+
// no catalog fix possible for taint the IR never represents).
|
|
105
|
+
// Confirmed via a real corpus fixture (CVE-2019-3799-spring-ssrf-
|
|
106
|
+
// shape). The actual CST path is one level deeper than the analogous
|
|
107
|
+
// fqnOrRefType check above: `primaryPrefix.children.newExpression[0]
|
|
108
|
+
// .children.unqualifiedClassInstanceCreationExpression[0]` — found by
|
|
109
|
+
// dumping `prefix.children` directly rather than guessing (the
|
|
110
|
+
// standalone, NON-chained `new X(args)` branch further below reaches
|
|
111
|
+
// `unqualifiedClassInstanceCreationExpression` straight off `node`,
|
|
112
|
+
// one level shallower, which is what made the wrapping `newExpression`
|
|
113
|
+
// layer easy to miss here). Seeds `chainCallee` with the class name
|
|
114
|
+
// and `chainArgs` with the constructor's OWN args (same
|
|
115
|
+
// argumentList.expression shape that standalone branch already
|
|
116
|
+
// extracts) — the suffix loop's existing `chainArgs === null ? args :
|
|
117
|
+
// args.concat(chainArgs)` then correctly prepends each chained call's
|
|
118
|
+
// own args ahead of the constructor's, preserving the outermost-first
|
|
119
|
+
// convention.
|
|
120
|
+
const ctorPrefix = prefix?.children?.newExpression?.[0]?.children?.unqualifiedClassInstanceCreationExpression?.[0];
|
|
121
|
+
if (fqn) {
|
|
122
|
+
chainCallee = _flattenFqnToString(fqn);
|
|
123
|
+
} else if (ctorPrefix) {
|
|
124
|
+
chainCallee = ctorPrefix.children?.classOrInterfaceTypeToInstantiate?.[0]?.children?.Identifier?.[0]?.image || '';
|
|
125
|
+
chainArgs = (ctorPrefix.children?.argumentList?.[0]?.children?.expression || []).map(exprFromCst);
|
|
126
|
+
}
|
|
127
|
+
// No FQN/constructor prefix — e.g. `this.foo(x)`, `super.foo(x)`
|
|
128
|
+
// (prefix is a keyword expression). chainCallee starts empty; the
|
|
129
|
+
// walk below supplies the real name from the first member-access
|
|
130
|
+
// suffix.
|
|
131
|
+
let pendingName = [];
|
|
132
|
+
const appendPending = () => {
|
|
133
|
+
if (!pendingName.length) return;
|
|
134
|
+
chainCallee = chainCallee ? `${chainCallee}.${pendingName.join('.')}` : pendingName.join('.');
|
|
135
|
+
pendingName = [];
|
|
136
|
+
};
|
|
137
|
+
for (const suf of suffixes) {
|
|
138
|
+
const inv = suf.children?.methodInvocationSuffix?.[0];
|
|
139
|
+
if (inv) {
|
|
140
|
+
appendPending();
|
|
141
|
+
const args = (inv.children?.argumentList?.[0]?.children?.expression || []).map(exprFromCst);
|
|
142
|
+
chainArgs = chainArgs === null ? args : args.concat(chainArgs);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const ident = suf.children?.Identifier?.[0]?.image;
|
|
146
|
+
if (ident) pendingName.push(ident);
|
|
147
|
+
}
|
|
148
|
+
if (chainArgs !== null) {
|
|
149
|
+
appendPending(); // a trailing member access after the last call (e.g. a field read)
|
|
150
|
+
return { kind: 'call', callee: chainCallee || 'unknown', args: chainArgs };
|
|
80
151
|
}
|
|
81
152
|
return exprFromCst(prefix);
|
|
82
153
|
}
|
|
154
|
+
// Taint-recall PRD (80%): a cast expression — `(String) xp.evaluate(...)`,
|
|
155
|
+
// `(int) computeVal(x)` — was falling through to the generic "recurse the
|
|
156
|
+
// first child" branch below, which for a castExpression's shape hits its
|
|
157
|
+
// own `primaryPrefix.children.castExpression` sub-node with no dedicated
|
|
158
|
+
// branch, and beneath THAT the raw `LBrace`/`LParen` token sorts first in
|
|
159
|
+
// key order — silently corrupting the parse into `{kind:'ident',
|
|
160
|
+
// name:'('}` and losing the entire operand (the actual call, and
|
|
161
|
+
// whatever tainted argument it carried). Confirmed via a real corpus
|
|
162
|
+
// fixture (CVE-2018-1320-xpath-injection). Casts are semantically
|
|
163
|
+
// transparent for taint purposes — unwrap to the operand, reference-type
|
|
164
|
+
// (`(String) x` → unaryExpressionNotPlusMinus) or primitive
|
|
165
|
+
// (`(int) x` → unaryExpression) shaped.
|
|
166
|
+
if (node.children.castExpression) {
|
|
167
|
+
const ce = node.children.castExpression[0];
|
|
168
|
+
const rtc = ce.children?.referenceTypeCastExpression?.[0];
|
|
169
|
+
const pc = ce.children?.primitiveCastExpression?.[0];
|
|
170
|
+
const operand = rtc?.children?.unaryExpressionNotPlusMinus?.[0]
|
|
171
|
+
|| pc?.children?.unaryExpression?.[0];
|
|
172
|
+
if (operand) return exprFromCst(operand);
|
|
173
|
+
}
|
|
83
174
|
// FQN ref
|
|
84
175
|
if (node.children.fqnOrRefType) return _fqnExpr(node.children.fqnOrRefType[0]);
|
|
85
176
|
if (node.children.unqualifiedClassInstanceCreationExpression) {
|
|
86
177
|
const ci = node.children.unqualifiedClassInstanceCreationExpression[0];
|
|
87
178
|
const callee = (ci.children?.classOrInterfaceTypeToInstantiate?.[0]?.children?.Identifier?.[0]?.image) || 'new';
|
|
88
|
-
|
|
179
|
+
// Taint-recall PRD (80%): args was hardcoded to [] — every
|
|
180
|
+
// `new X(arg1, arg2)` constructor call lowered with its arguments
|
|
181
|
+
// silently discarded, so a sink modeled as a constructor call
|
|
182
|
+
// (argIndex-based) could never see a tainted constructor argument
|
|
183
|
+
// regardless of catalog correctness. Same argumentList.expression
|
|
184
|
+
// shape the methodInvocationSuffix branch above already extracts
|
|
185
|
+
// from. Confirmed via a real corpus fixture
|
|
186
|
+
// (`new ByteArrayInputStream(xml)` feeding `b.parse(...)`).
|
|
187
|
+
const args = (ci.children?.argumentList?.[0]?.children?.expression || [])
|
|
188
|
+
.map(exprFromCst);
|
|
189
|
+
return { kind: 'call', callee, isNew: true, args };
|
|
89
190
|
}
|
|
90
191
|
if (node.children.literal) return exprFromCst(node.children.literal[0]);
|
|
91
192
|
if (node.children.Identifier) return { kind: 'ident', name: node.children.Identifier[0].image };
|
package/src/ir/parser-js.js
CHANGED
|
@@ -99,10 +99,50 @@ function exprOf(n) {
|
|
|
99
99
|
case 'ArrayExpression': return { kind: 'array', elements: (n.elements || []).map(exprOf) };
|
|
100
100
|
case 'SpreadElement': return exprOf(n.argument);
|
|
101
101
|
case 'ThisExpression': return { kind: 'ident', name: '_this_' };
|
|
102
|
+
// Taint-recall PRD (80%) Tier 3: JSX had ZERO IR modeling at all — every
|
|
103
|
+
// JSXElement fell through to {kind:'unknown'}, so `return <div
|
|
104
|
+
// dangerouslySetInnerHTML={{__html: html}} />` (React's canonical XSS
|
|
105
|
+
// sink, and the shape of a real corpus miss) silently dropped `html`'s
|
|
106
|
+
// taint entirely. Deliberately narrow: only the `dangerouslySetInnerHTML`
|
|
107
|
+
// attribute is extracted (found on this element or, recursively, any
|
|
108
|
+
// descendant — the attribute can sit on a nested element, not just the
|
|
109
|
+
// one directly returned) and lowered to a synthetic call
|
|
110
|
+
// (`__jsx_dangerously_set_inner_html__`) carrying the `__html` object
|
|
111
|
+
// property's value, so the existing `react-dangerouslySetInnerHTML`
|
|
112
|
+
// member-write sink's SIBLING call-shaped catalog entry can target it.
|
|
113
|
+
// Full JSX modeling (arbitrary attributes, children, expressions) is
|
|
114
|
+
// explicitly out of scope — children are React-auto-escaped by default,
|
|
115
|
+
// so `<div>{unsafeText}</div>` is not itself a vulnerability the way
|
|
116
|
+
// `dangerouslySetInnerHTML` is.
|
|
117
|
+
case 'JSXElement': {
|
|
118
|
+
const found = _findDangerouslySetInnerHTML(n);
|
|
119
|
+
if (found) return { kind: 'call', callee: '__jsx_dangerously_set_inner_html__', args: [exprOf(found)] };
|
|
120
|
+
return { kind: 'unknown' };
|
|
121
|
+
}
|
|
102
122
|
default: return { kind: 'unknown' };
|
|
103
123
|
}
|
|
104
124
|
}
|
|
105
125
|
|
|
126
|
+
function _findDangerouslySetInnerHTML(jsxElement) {
|
|
127
|
+
const attrs = jsxElement?.openingElement?.attributes || [];
|
|
128
|
+
for (const attr of attrs) {
|
|
129
|
+
if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'dangerouslySetInnerHTML') continue;
|
|
130
|
+
const val = attr.value;
|
|
131
|
+
if (val?.type !== 'JSXExpressionContainer') continue;
|
|
132
|
+
const obj = val.expression;
|
|
133
|
+
if (obj?.type !== 'ObjectExpression') continue;
|
|
134
|
+
const htmlProp = (obj.properties || []).find(p => p.type === 'ObjectProperty' && (p.key?.name === '__html' || p.key?.value === '__html'));
|
|
135
|
+
if (htmlProp) return htmlProp.value;
|
|
136
|
+
}
|
|
137
|
+
for (const child of jsxElement?.children || []) {
|
|
138
|
+
if (child.type === 'JSXElement') {
|
|
139
|
+
const nested = _findDangerouslySetInnerHTML(child);
|
|
140
|
+
if (nested) return nested;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
106
146
|
// Reduce a Babel LHS node to a string path used as a dataflow variable key.
|
|
107
147
|
function lhsPath(n) {
|
|
108
148
|
if (!n) return null;
|
package/src/ir/parser-kt.js
CHANGED
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
|
|
68
68
|
import * as crypto from 'node:crypto';
|
|
69
69
|
import { callSitesFromCfg } from './call-sites.js';
|
|
70
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
70
71
|
|
|
71
72
|
const FUN_RE = new RegExp(
|
|
72
73
|
'(?:^|[\\s;{}])(?:public|private|internal|protected|inline|suspend|tailrec|operator|infix|open|abstract|override|final|external)?' +
|
|
@@ -169,6 +170,25 @@ function _buildMemberChain(parts) {
|
|
|
169
170
|
return cur;
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
// Taint-recall PRD (80%): same architectural fix as parser-cs.js/
|
|
174
|
+
// parser-go.js/parser-php.js/parser-rb.js — a chained call
|
|
175
|
+
// (`ScriptEngineManager().getEngineByName("js").eval(userCode)`) previously
|
|
176
|
+
// stopped at (or, here, actively corrupted the parse at) the FIRST call,
|
|
177
|
+
// leaving `.method(args)` continuations unconsumed or garbled. Args from
|
|
178
|
+
// EVERY level are kept, outermost-first — a first version kept only the
|
|
179
|
+
// outermost, which broke `xp.compile(taintedExpr).evaluate(doc,
|
|
180
|
+
// XPathConstants.NODESET)`: the tainted value sits on the INNER call
|
|
181
|
+
// (.compile), not the final one, and keeping only the outer args (doc,
|
|
182
|
+
// NODESET) silently dropped it — see parser-cs.js's twin function for the
|
|
183
|
+
// full reasoning.
|
|
184
|
+
function _followChain(s, endIdx, calleeSoFar, argsSoFar) {
|
|
185
|
+
const rest = s.slice(endIdx);
|
|
186
|
+
const outer = matchBalancedCall(rest, /^\.(\w+)/);
|
|
187
|
+
if (!outer) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
|
|
188
|
+
const outerArgs = _splitTopLevelCommas(outer.argsText).map(_lowerExpr);
|
|
189
|
+
return _followChain(rest, outer.endIdx, `${calleeSoFar}.${outer.callee}`, outerArgs.concat(argsSoFar));
|
|
190
|
+
}
|
|
191
|
+
|
|
172
192
|
function _lowerExpr(text) {
|
|
173
193
|
const s = String(text || '').trim();
|
|
174
194
|
if (!s) return { kind: 'unknown' };
|
|
@@ -192,14 +212,42 @@ function _lowerExpr(text) {
|
|
|
192
212
|
if (parts.length === 1) return { kind: 'ident', name: parts[0] };
|
|
193
213
|
return _buildMemberChain(parts);
|
|
194
214
|
}
|
|
195
|
-
//
|
|
196
|
-
|
|
215
|
+
// Taint-recall PRD (80%): subscript/bracket access (`map[key]`,
|
|
216
|
+
// `call.parameters["q"]`) had NO recognizer at all — Kotlin's
|
|
217
|
+
// `operator fun get(key)` bracket syntax, used pervasively for Maps,
|
|
218
|
+
// arrays, and (critically) Ktor's `call.parameters[...]`, fell through
|
|
219
|
+
// every branch below to {kind:'unknown'}, silently dropping the value —
|
|
220
|
+
// and any taint on it — entirely. Lowered to the same synthetic `'[]'`
|
|
221
|
+
// prop convention parser-go.js's own Indexing branch uses: the base
|
|
222
|
+
// (everything before the first top-level `[`) becomes a real member
|
|
223
|
+
// chain, wrapped in one more member layer with `prop: '[]'`. This is
|
|
224
|
+
// what lets a cataloged MEMBER source on the base (e.g. `call.parameters`
|
|
225
|
+
// — `kt-ktor-parameters`) still taint the subscripted read: `exprIsSource`
|
|
226
|
+
// recurses into `expr.object` when the outer member itself doesn't match,
|
|
227
|
+
// landing on the base member it already knows.
|
|
228
|
+
const subscriptMatch = s.match(/^([A-Za-z_][\w.]*)\[(.+)\]$/s);
|
|
229
|
+
if (subscriptMatch) {
|
|
230
|
+
const parts = subscriptMatch[1].split('.');
|
|
231
|
+
const base = parts.length === 1 ? { kind: 'ident', name: parts[0] } : _buildMemberChain(parts);
|
|
232
|
+
return { kind: 'member', object: base, prop: '[]' };
|
|
233
|
+
}
|
|
234
|
+
// Call. Taint-recall PRD (80%): this used to be the naive
|
|
235
|
+
// `/^([\w.]+)\s*\((.*)\)\s*$/s` pattern every OTHER hand-rolled parser
|
|
236
|
+
// (cs/go/php/rb) also started with and has since moved off of —
|
|
237
|
+
// `(.*)` matches GREEDILY against the LAST `)` in the string, not the
|
|
238
|
+
// one balancing the FIRST `(`, so a chained call
|
|
239
|
+
// (`ScriptEngineManager().getEngineByName("js").eval(userCode)`)
|
|
240
|
+
// corrupted the args text into garbage rather than just dropping the
|
|
241
|
+
// chain. Kotlin never got migrated to matchBalancedCall when the other
|
|
242
|
+
// four were — confirmed via a real corpus fixture (this exact
|
|
243
|
+
// ScriptEngineManager shape). Now uses the same balanced-scan +
|
|
244
|
+
// chain-following approach: only the OUTERMOST call's own arguments are
|
|
245
|
+
// kept (see parser-cs.js's _followChain comment for why), each level's
|
|
246
|
+
// name dot-joined into one callee string.
|
|
247
|
+
const callMatch = matchBalancedCall(s, /^([\w.]+)/);
|
|
197
248
|
if (callMatch) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
callee: callMatch[1],
|
|
201
|
-
args: _splitTopLevelCommas(callMatch[2]).map(_lowerExpr),
|
|
202
|
-
};
|
|
249
|
+
const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
|
|
250
|
+
return _followChain(s, callMatch.endIdx, callMatch.callee, args);
|
|
203
251
|
}
|
|
204
252
|
// Concat
|
|
205
253
|
if (s.includes('+') && /["']/.test(s)) {
|
|
@@ -227,9 +275,13 @@ function _lowerStmt(stmt, line) {
|
|
|
227
275
|
if (assign && !/[=!<>]=/.test(s.slice(0, s.indexOf('=')+1).slice(0, -1))) {
|
|
228
276
|
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
229
277
|
}
|
|
230
|
-
// Statement-form call
|
|
231
|
-
|
|
232
|
-
|
|
278
|
+
// Statement-form call — same matchBalancedCall + chain-following as the
|
|
279
|
+
// expression-form case above (Taint-recall PRD 80%).
|
|
280
|
+
const cm = matchBalancedCall(s, /^([\w.]+)/);
|
|
281
|
+
if (cm) {
|
|
282
|
+
const chained = _followChain(s, cm.endIdx, cm.callee, _splitTopLevelCommas(cm.argsText).map(_lowerExpr));
|
|
283
|
+
return { kind: 'call', line, callee: chained.callee, args: chained.args };
|
|
284
|
+
}
|
|
233
285
|
// R8: trailing-lambda call — `recv.method(args)? { lambda }`, Kotlin's
|
|
234
286
|
// idiomatic collection-operator / scope-function syntax
|
|
235
287
|
// (`xs.forEach { x -> … }`, `xs.reduce(0) { acc, x -> … }`). Without this
|
|
@@ -399,6 +451,43 @@ function _linkNodes(nodes, src, dst) {
|
|
|
399
451
|
const HEADER_RE = /^(if|while|for|when|else\s+if|else|do|try|catch|finally)\b/;
|
|
400
452
|
const NEEDS_COND_RE = /^(?:if|while|when|else if|catch)$/;
|
|
401
453
|
|
|
454
|
+
// Taint-engine PRD P1: trailing-lambda body recursion. Detects the START of
|
|
455
|
+
// a `recv.method(args)? { … }` call — deliberately NOT the whole statement
|
|
456
|
+
// (the pre-P1 `_lowerStmt` trigger's `[\s\S]*\}\s*$` greedy tail is exactly
|
|
457
|
+
// what mis-captured a chained `xs.filter { … }.forEach { … }` as one big
|
|
458
|
+
// opaque lambda with the wrong callee). `_matchDelim` finds the REAL
|
|
459
|
+
// matching `}` from here, the same balanced-scan primitive every keyword
|
|
460
|
+
// branch below already uses.
|
|
461
|
+
//
|
|
462
|
+
// Split into two alternatives — no-args and with-args — rather than one
|
|
463
|
+
// optional group between two `\s*`s: `bench:self-scan:check` caught this
|
|
464
|
+
// exact shape as a genuine quadratic ReDoS (confirmed by direct timing:
|
|
465
|
+
// 40000 whitespace chars with no trailing `{` took ~1s), the identical
|
|
466
|
+
// defect class this file's own R8 task already fixed once (see the CLAUDE.md
|
|
467
|
+
// note on the sibling `decl` regex) and parser-cs.js's `attrRegex` fixed the
|
|
468
|
+
// same way. Each alternative has its own capture group for the callee name
|
|
469
|
+
// (`m[1]`/`m[2]`); only one is ever set.
|
|
470
|
+
const TRAILING_LAMBDA_TRIGGER_RE = /^([\w.]+)\s*\{|^([\w.]+)\s*(\([^()]*\))\s*\{/;
|
|
471
|
+
|
|
472
|
+
// Stdlib scope functions whose lambda receives the RECEIVER (or an element
|
|
473
|
+
// of it) as its own parameter — these get a synthesized taint-binding
|
|
474
|
+
// assign before the body is recursed into, mirroring the for-loop's
|
|
475
|
+
// loop-variable binding immediately below. `reduce`/`fold` pass BOTH an
|
|
476
|
+
// accumulator and an element; this codebase's recall-preserving doctrine
|
|
477
|
+
// (favor a false positive over a silent false negative — see catalog.js's
|
|
478
|
+
// sanitizer-recording comment for the same tradeoff) argues for binding
|
|
479
|
+
// every declared param to the receiver rather than trying to disambiguate
|
|
480
|
+
// which one is the actual element.
|
|
481
|
+
//
|
|
482
|
+
// Deliberately EXCLUDED: `apply`/`run` — these pass the receiver as an
|
|
483
|
+
// IMPLICIT `this`, not a named/`it` lambda parameter (`T.() -> R`, not
|
|
484
|
+
// `T.(T) -> R`), so there is nothing to bind here without modeling
|
|
485
|
+
// implicit-receiver member calls. Their bodies are still recursed into
|
|
486
|
+
// below (so an already-tainted OUTER variable referenced inside still
|
|
487
|
+
// works), just without receiver-as-param binding — a real, documented
|
|
488
|
+
// scope boundary, not a silent gap.
|
|
489
|
+
const LAMBDA_BINDABLE_METHODS = new Set(['forEach', 'map', 'filter', 'reduce', 'fold', 'use', 'let', 'also']);
|
|
490
|
+
|
|
402
491
|
// R8: recursive statement handler. `s` is ONE element returned by
|
|
403
492
|
// `_splitStatements` — which, because Kotlin's splitter (unlike C#'s) does
|
|
404
493
|
// not flush on a `}` reaching depth 0, may itself be a CHAIN of glued
|
|
@@ -422,6 +511,61 @@ function _consumeChunk(s, abs0, nodes, prevId, funcStartLine, lineStarts, depth)
|
|
|
422
511
|
const rest = s.slice(skipTo);
|
|
423
512
|
const hm = rest.match(HEADER_RE);
|
|
424
513
|
if (!hm) {
|
|
514
|
+
// Taint-engine PRD P1: trailing-lambda body recursion. Checked before
|
|
515
|
+
// falling through to a leaf statement — `HEADER_RE` never matches an
|
|
516
|
+
// identifier-starting trailing-lambda call, so there is no ambiguity
|
|
517
|
+
// between the two triggers.
|
|
518
|
+
const lambdaMatch = rest.match(TRAILING_LAMBDA_TRIGGER_RE);
|
|
519
|
+
if (lambdaMatch) {
|
|
520
|
+
const braceIdxInS = skipTo + lambdaMatch[0].length - 1; // index of '{' within s
|
|
521
|
+
const closeRel = _matchDelim(s, braceIdxInS, '{', '}');
|
|
522
|
+
if (closeRel !== -1) {
|
|
523
|
+
first = false;
|
|
524
|
+
const callee = lambdaMatch[1] || lambdaMatch[2];
|
|
525
|
+
const parenGroup = lambdaMatch[3] || null;
|
|
526
|
+
const argsText = parenGroup ? parenGroup.slice(1, -1) : '';
|
|
527
|
+
const callArgs = argsText ? _splitTopLevelCommas(argsText).map(_lowerExpr) : [];
|
|
528
|
+
const line = _lineForAbs(lineStarts, funcStartLine, abs0 + skipTo);
|
|
529
|
+
const callId = _addNode(nodes, { kind: 'call', line, callee, args: callArgs });
|
|
530
|
+
_linkNodes(nodes, prev, callId);
|
|
531
|
+
prev = callId;
|
|
532
|
+
|
|
533
|
+
const dot = callee.lastIndexOf('.');
|
|
534
|
+
// A leading-dot callee (`.forEach { … }`, the second link of a
|
|
535
|
+
// CHAINED trailing lambda like `xs.filter{}.forEach{}`) yields an
|
|
536
|
+
// empty receiver here — deliberately falls into the same
|
|
537
|
+
// no-binding path as .apply/.run below, since there is no real
|
|
538
|
+
// identifier to bind from (the true receiver is the previous
|
|
539
|
+
// lambda's return value, which this file does not model as a
|
|
540
|
+
// synthetic variable). The call site and body are still captured.
|
|
541
|
+
const receiver = dot > 0 ? callee.slice(0, dot) : null;
|
|
542
|
+
const method = dot >= 0 ? callee.slice(dot + 1) : callee;
|
|
543
|
+
|
|
544
|
+
const bodyInner = s.slice(braceIdxInS + 1, closeRel);
|
|
545
|
+
const bodyAbs0 = abs0 + braceIdxInS + 1;
|
|
546
|
+
|
|
547
|
+
if (receiver && LAMBDA_BINDABLE_METHODS.has(method)) {
|
|
548
|
+
const arrowIdx = _findTopLevelArrow(bodyInner);
|
|
549
|
+
const paramNames = arrowIdx >= 0
|
|
550
|
+
? bodyInner.slice(0, arrowIdx).split(',').map(p => p.trim()).filter(p => /^[A-Za-z_]\w*$/.test(p))
|
|
551
|
+
: ['it'];
|
|
552
|
+
for (const p of paramNames) {
|
|
553
|
+
const assignId = _addNode(nodes, { kind: 'assign', line, target: p, source: _lowerExpr(receiver) });
|
|
554
|
+
_linkNodes(nodes, prev, assignId);
|
|
555
|
+
prev = assignId;
|
|
556
|
+
}
|
|
557
|
+
const recurseFrom = arrowIdx >= 0 ? arrowIdx + 2 : 0;
|
|
558
|
+
prev = _buildCfg(bodyInner.slice(recurseFrom), nodes, prev, funcStartLine, lineStarts, bodyAbs0 + recurseFrom, depth + 1);
|
|
559
|
+
} else {
|
|
560
|
+
// .apply/.run, a chained continuation, or an unlisted scope
|
|
561
|
+
// function: body still reachable, no param binding.
|
|
562
|
+
prev = _buildCfg(bodyInner, nodes, prev, funcStartLine, lineStarts, bodyAbs0, depth + 1);
|
|
563
|
+
}
|
|
564
|
+
pos = closeRel + 1;
|
|
565
|
+
if (pos >= s.length || depth > 12) return prev;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
425
569
|
if (first) {
|
|
426
570
|
return _lowerLeafOrBlock(s, abs0, nodes, prev, funcStartLine, lineStarts, depth);
|
|
427
571
|
}
|
|
@@ -618,6 +762,45 @@ function _lineAt(src, idx) {
|
|
|
618
762
|
return line;
|
|
619
763
|
}
|
|
620
764
|
|
|
765
|
+
// Taint-recall PRD (80%): Kotlin's null-safety `?.` operator is invisible to
|
|
766
|
+
// every regex-based matcher in this file — the callee-matching regexes
|
|
767
|
+
// (`[\w.]+`-style), the plain-dotted-ident check, the trailing-lambda
|
|
768
|
+
// trigger, and `_followChain`'s continuation regex all key off a character
|
|
769
|
+
// class that excludes `?`. Unlike `::` (parser-rb.js) or a chain
|
|
770
|
+
// continuation, this is NOT limited to a later segment of a chain — the
|
|
771
|
+
// safe-call operator can appear on the very FIRST segment of an expression
|
|
772
|
+
// (`str?.trim()`, `xs?.forEach { … }`, even a bare property read `x?.y`),
|
|
773
|
+
// so patching individual regexes one at a time would miss call sites this
|
|
774
|
+
// file doesn't yet enumerate. Normalized once, globally, at the very top of
|
|
775
|
+
// the parse pipeline instead: string-literal-aware (so `"a?.b"` inside a
|
|
776
|
+
// literal string is left untouched) and strips only the `?` immediately
|
|
777
|
+
// before a `.` (so the elvis operator `?:` and a bare nullable-type marker
|
|
778
|
+
// `String?` are both unaffected — neither is followed by `.`). Dropping the
|
|
779
|
+
// `?` (not replacing with a same-length filler) is safe for this file's
|
|
780
|
+
// line-number bookkeeping specifically because `_lineStarts`/`_lineAt`/
|
|
781
|
+
// `_lineForOffset` are all purely newline-position-based — removing a
|
|
782
|
+
// non-newline character can shift a later character's COLUMN but never its
|
|
783
|
+
// LINE, and column offsets are never relied on anywhere in this file.
|
|
784
|
+
function _stripSafeCallOperator(code) {
|
|
785
|
+
let out = '';
|
|
786
|
+
let inStr = null;
|
|
787
|
+
let escape = false;
|
|
788
|
+
for (let i = 0; i < code.length; i++) {
|
|
789
|
+
const c = code[i];
|
|
790
|
+
if (escape) { out += c; escape = false; continue; }
|
|
791
|
+
if (inStr) {
|
|
792
|
+
out += c;
|
|
793
|
+
if (c === '\\') { escape = true; continue; }
|
|
794
|
+
if (c === inStr) inStr = null;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (c === '"' || c === '\'') { inStr = c; out += c; continue; }
|
|
798
|
+
if (c === '?' && code[i + 1] === '.') { out += '.'; i++; continue; }
|
|
799
|
+
out += c;
|
|
800
|
+
}
|
|
801
|
+
return out;
|
|
802
|
+
}
|
|
803
|
+
|
|
621
804
|
function _qid(file, name, line, body) {
|
|
622
805
|
const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
|
|
623
806
|
return `${file}::${name}@${line}#${sha}`;
|
|
@@ -625,6 +808,7 @@ function _qid(file, name, line, body) {
|
|
|
625
808
|
|
|
626
809
|
export function parseKotlinFile(file, code) {
|
|
627
810
|
if (!file || typeof code !== 'string') return null;
|
|
811
|
+
code = _stripSafeCallOperator(code);
|
|
628
812
|
const functions = [];
|
|
629
813
|
FUN_RE.lastIndex = 0;
|
|
630
814
|
let m;
|
package/src/ir/parser-php.js
CHANGED
|
@@ -167,6 +167,19 @@ function _splitStatements(body) {
|
|
|
167
167
|
if (i < body.length) { push('\n'); curLine++; }
|
|
168
168
|
continue;
|
|
169
169
|
}
|
|
170
|
+
// Taint-recall PRD (80%): PHP's THIRD line-comment form — `#`-comments
|
|
171
|
+
// were entirely invisible to this splitter (only `//`/`/* */` were
|
|
172
|
+
// handled), so a `#`-comment's text was treated as literal code:
|
|
173
|
+
// string-toggling apostrophes, brace/paren/bracket depth corruption,
|
|
174
|
+
// and — if it happened to contain a stray `;` — a bogus statement
|
|
175
|
+
// boundary. PHP 8 attributes (`#[Attribute]`) use the same leading `#`
|
|
176
|
+
// but are NOT a comment, so `#[` is excluded (checked via the next
|
|
177
|
+
// character) exactly like `_extractBody`'s twin fix does.
|
|
178
|
+
if (c === '#' && body[i + 1] !== '[') {
|
|
179
|
+
while (i < body.length && body[i] !== '\n') i++;
|
|
180
|
+
if (i < body.length) { push('\n'); curLine++; }
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
170
183
|
if (c === '/' && body[i + 1] === '*') {
|
|
171
184
|
// Block comment (incl. PHPDoc, e.g. `/** @param string $x */`).
|
|
172
185
|
// Skip to the matching `*/`, counting any newlines crossed so line
|
|
@@ -271,6 +284,26 @@ function _splitStatements(body) {
|
|
|
271
284
|
return out;
|
|
272
285
|
}
|
|
273
286
|
|
|
287
|
+
// Taint-recall PRD (80%): same architectural fix as parser-cs.js/
|
|
288
|
+
// parser-go.js — a chained call (`sanitize($x)->encode()`, or a real sink
|
|
289
|
+
// shape like `$xp->query(...)` reached through a chain) previously stopped
|
|
290
|
+
// at the FIRST balanced call, leaving `->method(args)` unconsumed. `callee`
|
|
291
|
+
// arrives already normalized to dots (->/:: both become "."), so the
|
|
292
|
+
// continuation check looks for a literal "->" or "::" in the SOURCE text
|
|
293
|
+
// (PHP's own syntax) even though the join uses ".". Args from EVERY level
|
|
294
|
+
// are kept, outermost-first — see parser-cs.js's twin function for why
|
|
295
|
+
// (a first version that kept only the outermost broke a real chain shape
|
|
296
|
+
// where the tainted value sits on an INNER call).
|
|
297
|
+
function _followChain(s, endIdx, calleeSoFar, argsSoFar) {
|
|
298
|
+
const rest = s.slice(endIdx);
|
|
299
|
+
const m = rest.match(/^(?:->|::)(\w+)/);
|
|
300
|
+
if (!m) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
|
|
301
|
+
const outer = matchBalancedCall(rest, /^(?:->|::)(\w+)/);
|
|
302
|
+
if (!outer) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
|
|
303
|
+
const outerArgs = _splitTopLevelCommas(outer.argsText).map(_lowerExpr);
|
|
304
|
+
return _followChain(rest, outer.endIdx, `${calleeSoFar}.${outer.callee}`, outerArgs.concat(argsSoFar));
|
|
305
|
+
}
|
|
306
|
+
|
|
274
307
|
function _lowerExpr(text) {
|
|
275
308
|
const s = String(text || '').trim();
|
|
276
309
|
if (!s) return { kind: 'unknown' };
|
|
@@ -285,8 +318,25 @@ function _lowerExpr(text) {
|
|
|
285
318
|
// common real PHP SQL-injection shapes. Simple (`$var`, `$var->prop`,
|
|
286
319
|
// `$var[key]`) and complex (`{$expr}`) interpolation forms are both
|
|
287
320
|
// lowered into a template, same shape as the `.`-concat branch below.
|
|
288
|
-
|
|
289
|
-
|
|
321
|
+
// Taint-recall PRD (80%): the old guard (`/^"/.test(s) && s.includes('$')`)
|
|
322
|
+
// treated ANY string simply STARTING with `"` and containing a `$`
|
|
323
|
+
// ANYWHERE as a self-contained double-quoted literal, then blindly sliced
|
|
324
|
+
// off the first and last characters as its quotes (`s.slice(1, -1)`).
|
|
325
|
+
// For a concat expression like `"SELECT ... id = " . $id` — arguably the
|
|
326
|
+
// single most common real-world PHP SQL-injection shape — `s` starts
|
|
327
|
+
// with `"` and contains a `$` (in `$id`, OUTSIDE the string, after the
|
|
328
|
+
// `.`), so this branch fired and treated the WHOLE concat expression's
|
|
329
|
+
// text as interpolation content: `inner` became `SELECT ... id = " . $i`
|
|
330
|
+
// (sliced off the true FIRST character and the true LAST character,
|
|
331
|
+
// neither of which bounds the actual string literal), corrupting both
|
|
332
|
+
// the literal text and truncating `$id` to `$i`. Now requires the ENTIRE
|
|
333
|
+
// trimmed expression to be exactly one closed double-quoted literal
|
|
334
|
+
// (anchored start AND end) before treating it as interpolation-only —
|
|
335
|
+
// `"literal" . $var` fails this (trailing ` . $var` breaks the end
|
|
336
|
+
// anchor) and correctly falls through to the concat branch below instead.
|
|
337
|
+
const dqLiteral = s.match(/^"((?:[^"\\]|\\.)*)"$/);
|
|
338
|
+
if (dqLiteral && s.includes('$')) {
|
|
339
|
+
const inner = dqLiteral[1];
|
|
290
340
|
const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
|
|
291
341
|
let lastIndex = 0;
|
|
292
342
|
const parts = [];
|
|
@@ -303,7 +353,16 @@ function _lowerExpr(text) {
|
|
|
303
353
|
return { kind: 'tpl', parts };
|
|
304
354
|
}
|
|
305
355
|
}
|
|
306
|
-
|
|
356
|
+
// Taint-recall PRD (80%): same unanchored-prefix defect as the
|
|
357
|
+
// interpolation branch above, one layer down — `/^"/.test(s)` only checks
|
|
358
|
+
// that `s` STARTS with a quote, not that it IS (only) a closed literal.
|
|
359
|
+
// For `"literal" . $id`, this fallback used to catch what the (now fixed)
|
|
360
|
+
// interpolation branch missed and swallow the ENTIRE concat expression —
|
|
361
|
+
// trailing ` . $id` included — into one opaque literal `value` string,
|
|
362
|
+
// silently dropping `$id`'s taint just as badly, only one level later.
|
|
363
|
+
// Anchored at both ends so a genuinely unclosed/concatenated string falls
|
|
364
|
+
// through to the concat branch below instead of being misread as whole.
|
|
365
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(s) || /^'(?:[^'\\]|\\.)*'$/.test(s)) return { kind: 'literal', value: s };
|
|
307
366
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
308
367
|
if (/^(true|false|null|NULL)\b/.test(s)) return { kind: 'literal', value: s };
|
|
309
368
|
// Superglobals
|
|
@@ -328,12 +387,13 @@ function _lowerExpr(text) {
|
|
|
328
387
|
if (methodCall) {
|
|
329
388
|
const callee = methodCall.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
330
389
|
const args = _splitTopLevelCommas(methodCall.argsText).map(_lowerExpr);
|
|
331
|
-
return
|
|
390
|
+
return _followChain(s, methodCall.endIdx, callee, args);
|
|
332
391
|
}
|
|
333
392
|
// Function call: func(args)
|
|
334
393
|
const funcCall = matchBalancedCall(s, /^([A-Za-z_][\w]*)/);
|
|
335
394
|
if (funcCall) {
|
|
336
|
-
|
|
395
|
+
const args = _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr);
|
|
396
|
+
return _followChain(s, funcCall.endIdx, funcCall.callee, args);
|
|
337
397
|
}
|
|
338
398
|
// Concat with .
|
|
339
399
|
//
|
|
@@ -418,6 +478,19 @@ function _lowerStmt(stmt, line) {
|
|
|
418
478
|
if (/^throw\b/.test(s)) {
|
|
419
479
|
return { kind: 'throw', line, value: _lowerExpr(s.replace(/^throw\s+/, '')) };
|
|
420
480
|
}
|
|
481
|
+
// Taint-recall PRD (80%): `echo`/`print` are PHP LANGUAGE CONSTRUCTS, not
|
|
482
|
+
// function calls — `echo "<div>" . $_GET['q'] . "</div>";` has no `(`
|
|
483
|
+
// immediately after the keyword, so the statement-form call regex below
|
|
484
|
+
// never matched it, and the entire echoed expression (including any
|
|
485
|
+
// reflected taint) was silently dropped. `echo` can take multiple
|
|
486
|
+
// comma-separated expressions; lowered to a synthetic call
|
|
487
|
+
// (`__php_echo__`) carrying each as an argument, so a normal callee-keyed
|
|
488
|
+
// catalog sink can target it exactly like any other call-shaped sink —
|
|
489
|
+
// same convention as parser-rb.js's `__ruby_backtick_exec__`.
|
|
490
|
+
if (/^(?:echo|print)\b/.test(s)) {
|
|
491
|
+
const rest = s.replace(/^(?:echo|print)\s*/, '');
|
|
492
|
+
return { kind: 'call', line, callee: '__php_echo__', args: _splitTopLevelCommas(rest).map(_lowerExpr) };
|
|
493
|
+
}
|
|
421
494
|
// Assignment: $var = expr
|
|
422
495
|
const assign = s.match(/^(\$[\w]+(?:->[\w]+)*)\s*=\s*(.+)$/s);
|
|
423
496
|
if (assign) {
|
|
@@ -427,11 +500,26 @@ function _lowerStmt(stmt, line) {
|
|
|
427
500
|
const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
|
|
428
501
|
if (call) {
|
|
429
502
|
const callee = call.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
430
|
-
|
|
503
|
+
const chained = _followChain(s, call.endIdx, callee, _splitTopLevelCommas(call.argsText).map(_lowerExpr));
|
|
504
|
+
return { kind: 'call', line, callee: chained.callee, args: chained.args };
|
|
431
505
|
}
|
|
432
506
|
return null;
|
|
433
507
|
}
|
|
434
508
|
|
|
509
|
+
// Taint-recall PRD (80%): `_extractBody` used to have ZERO comment
|
|
510
|
+
// awareness — worse than `_splitStatements` below, which at least skips
|
|
511
|
+
// `//`/`/* */` (though not `#`). An apostrophe inside ANY comment (`//`,
|
|
512
|
+
// `#`, or `/* */`) — "don't", "it's", "won't", all extremely common in real
|
|
513
|
+
// PHP — toggled `inStr` exactly as if a string literal had started. Every
|
|
514
|
+
// `{`/`}` from that point on was then invisible to depth-tracking until a
|
|
515
|
+
// SECOND apostrophe was found (typically much later, or never), so `depth`
|
|
516
|
+
// either never returns to 0 (the function's body extraction fails outright,
|
|
517
|
+
// returning `null` — silently dropping the ENTIRE FILE's IR, since a single
|
|
518
|
+
// failed top-level function match corrupts every span downstream of it) or
|
|
519
|
+
// returns to 0 at the wrong `}` (extracting a truncated or over-extended
|
|
520
|
+
// body). Now skips all three PHP comment forms exactly like
|
|
521
|
+
// `_splitStatements` already does for `//`/`/* */`, plus `#` (PHP 8
|
|
522
|
+
// attributes `#[...]` are NOT a comment — checked via the next character).
|
|
435
523
|
function _extractBody(src, openBrace) {
|
|
436
524
|
let depth = 1;
|
|
437
525
|
let i = openBrace + 1;
|
|
@@ -445,6 +533,20 @@ function _extractBody(src, openBrace) {
|
|
|
445
533
|
if (c === inStr) inStr = null;
|
|
446
534
|
i++; continue;
|
|
447
535
|
}
|
|
536
|
+
if (c === '/' && src[i + 1] === '/') {
|
|
537
|
+
while (i < src.length && src[i] !== '\n') i++;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (c === '#' && src[i + 1] !== '[') {
|
|
541
|
+
while (i < src.length && src[i] !== '\n') i++;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (c === '/' && src[i + 1] === '*') {
|
|
545
|
+
i += 2;
|
|
546
|
+
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
|
|
547
|
+
if (i < src.length) i += 2;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
448
550
|
if (c === '"' || c === '\'') { inStr = c; i++; continue; }
|
|
449
551
|
if (c === '{') depth++;
|
|
450
552
|
else if (c === '}') depth--;
|