@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-js.js
CHANGED
|
@@ -63,11 +63,23 @@ function exprOf(n) {
|
|
|
63
63
|
prop: n.computed ? (n.property?.value != null ? String(n.property.value) : '*') : (n.property?.name || '*'),
|
|
64
64
|
};
|
|
65
65
|
case 'CallExpression':
|
|
66
|
-
case 'OptionalCallExpression':
|
|
66
|
+
case 'OptionalCallExpression': return {
|
|
67
|
+
kind: 'call',
|
|
68
|
+
callee: exprOf(n.callee),
|
|
69
|
+
args: (n.arguments || []).map(exprOf),
|
|
70
|
+
};
|
|
71
|
+
// `new Foo()` is emitted as a call PLUS an `isNew: true` marker, matching
|
|
72
|
+
// what parser-java.js and parser-cs.js already emit. Without the marker a
|
|
73
|
+
// `new Foo()` is byte-identical in the IR to a plain `Foo()` call, and
|
|
74
|
+
// class-hierarchy.js's typeOfVar walker therefore typed `const x =
|
|
75
|
+
// SomeFactoryFn()` as class `SomeFactoryFn` — a fabricated type that then
|
|
76
|
+
// reaches the dataflow receiver-type gate and can suppress a real finding.
|
|
77
|
+
// Nothing else in the engine reads `isNew`, so this is purely additive.
|
|
67
78
|
case 'NewExpression': return {
|
|
68
79
|
kind: 'call',
|
|
69
80
|
callee: exprOf(n.callee),
|
|
70
81
|
args: (n.arguments || []).map(exprOf),
|
|
82
|
+
isNew: true,
|
|
71
83
|
};
|
|
72
84
|
case 'BinaryExpression': return { kind: 'binary', op: n.operator, left: exprOf(n.left), right: exprOf(n.right) };
|
|
73
85
|
case 'LogicalExpression': return { kind: 'logical', op: n.operator, left: exprOf(n.left), right: exprOf(n.right) };
|
|
@@ -98,7 +110,22 @@ function lhsPath(n) {
|
|
|
98
110
|
if (n.type === 'ThisExpression') return '_this_';
|
|
99
111
|
if (n.type === 'MemberExpression') {
|
|
100
112
|
const base = lhsPath(n.object);
|
|
101
|
-
|
|
113
|
+
// Stage 3 correctness audit (detection depth, path-feasibility): a
|
|
114
|
+
// computed WRITE with a literal key (`obj['secret'] = tainted`) used
|
|
115
|
+
// to collapse straight to the wildcard '*' — never trying to extract
|
|
116
|
+
// the literal, unlike exprOf's MemberExpression case just above,
|
|
117
|
+
// which DOES extract it for reads. That asymmetry meant a tainted
|
|
118
|
+
// write via bracket notation with a literal key produced access path
|
|
119
|
+
// "obj.*", while any later read of that same key (`obj.secret` or
|
|
120
|
+
// `obj['secret']`) resolves via exprOf to the specific path
|
|
121
|
+
// "obj.secret" — isCoveredBy has no wildcard semantics (`'*'` is a
|
|
122
|
+
// literal property name here, not a match-anything token), so the two
|
|
123
|
+
// paths never matched and the taint was silently unreachable from any
|
|
124
|
+
// correctly-computed read. Mirrors exprOf's extraction exactly so a
|
|
125
|
+
// write and a read of the same literal key always agree.
|
|
126
|
+
const prop = n.computed
|
|
127
|
+
? (n.property?.value != null ? String(n.property.value) : '*')
|
|
128
|
+
: (n.property?.name || '*');
|
|
102
129
|
if (!base) return null;
|
|
103
130
|
return base + '.' + prop;
|
|
104
131
|
}
|
|
@@ -136,18 +163,54 @@ export function parseJsFile(file, code) {
|
|
|
136
163
|
const qid = fnQid(file, scopeName, name, line);
|
|
137
164
|
const entryId = nextNodeId();
|
|
138
165
|
const exitId = nextNodeId();
|
|
166
|
+
const paramAnnotations = [];
|
|
139
167
|
const fn = {
|
|
140
168
|
qid, name: name || 'anon', line,
|
|
141
|
-
|
|
169
|
+
// Plain strings, per the IR shape contract (ir/CLAUDE.md: "params:
|
|
170
|
+
// ['arg1', 'arg2', ...]") — every other parser (py, cs, rb, cpp, ...)
|
|
171
|
+
// already emits this shape. This one used to emit {name,kind[,props]}
|
|
172
|
+
// objects instead, which every consumer (access-paths.js's
|
|
173
|
+
// isCoveredBy, summaries.js's paramNames.indexOf, entryStateFromCall,
|
|
174
|
+
// the k=2 pass's `new Set(fn.params)`) silently failed to match against
|
|
175
|
+
// — Set/string-equality checks against an object never succeed, so
|
|
176
|
+
// mutated-parameter taint and context-sensitive entry states were both
|
|
177
|
+
// unconditionally inert for every JS/TS function. Nothing in src/
|
|
178
|
+
// reads a param's .kind or .props, so the richer shape bought nothing.
|
|
179
|
+
params: (params || []).map((p, idx) => {
|
|
142
180
|
if (!p) return null;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
181
|
+
// NestJS/Angular-style parameter decorators (@Query(), @Body(), etc.)
|
|
182
|
+
// — Babel attaches these to the raw param node as `p.decorators`
|
|
183
|
+
// (or `p.left.decorators` for a defaulted param, e.g. `@Query() page
|
|
184
|
+
// = 1` — a very common NestJS idiom). This is a real array; every
|
|
185
|
+
// entry must be captured, not just the first, since stacked
|
|
186
|
+
// decorators (@Query() @SomeOtherDecorator() x) are legal and
|
|
187
|
+
// dropping later ones silently loses the source-relevant one.
|
|
188
|
+
//
|
|
189
|
+
// For a defaulted param, `p.type` is 'AssignmentPattern', never
|
|
190
|
+
// 'Identifier' — the identifier that actually carries the decorator
|
|
191
|
+
// and the name lives at `p.left`. The guard below must check THAT
|
|
192
|
+
// resolved node's type, not `p.type` unconditionally, or every
|
|
193
|
+
// defaulted-identifier decorated parameter silently loses its
|
|
194
|
+
// decorator (fix round 1, R14(a) Task 4).
|
|
195
|
+
const resolvedIdent = p.type === 'AssignmentPattern' ? p.left : p;
|
|
196
|
+
const decoratorNodes = p.decorators || (p.left && p.left.decorators) || [];
|
|
197
|
+
for (const d of decoratorNodes) {
|
|
198
|
+
const expr = d.expression;
|
|
199
|
+
const decoratorName = expr?.type === 'CallExpression' ? expr.callee?.name : expr?.name;
|
|
200
|
+
// Only record when the parameter itself resolves to a plain
|
|
201
|
+
// identifier — decorators on destructured params are rare and
|
|
202
|
+
// out of scope for this plan.
|
|
203
|
+
if (decoratorName && resolvedIdent?.type === 'Identifier') {
|
|
204
|
+
paramAnnotations.push({ index: idx, name: resolvedIdent.name, decorator: decoratorName });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (p.type === 'Identifier') return p.name;
|
|
208
|
+
if (p.type === 'ObjectPattern') return '<obj>';
|
|
209
|
+
if (p.type === 'ArrayPattern') return '<arr>';
|
|
210
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return p.left.name;
|
|
211
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'ObjectPattern') return '<obj>';
|
|
212
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'ArrayPattern') return '<arr>';
|
|
213
|
+
if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return p.argument.name;
|
|
151
214
|
return null;
|
|
152
215
|
}).filter(Boolean),
|
|
153
216
|
cfg: { entry: entryId, exit: exitId, nodes: new Map() },
|
|
@@ -157,6 +220,7 @@ export function parseJsFile(file, code) {
|
|
|
157
220
|
writes: new Map(),
|
|
158
221
|
file,
|
|
159
222
|
_cursor: entryId, // current node ID — next addNode() links from here
|
|
223
|
+
...(paramAnnotations.length ? { paramAnnotations } : {}),
|
|
160
224
|
};
|
|
161
225
|
fn.cfg.nodes.set(entryId, { id: entryId, kind: 'entry', succ: [], pred: [], line });
|
|
162
226
|
fn.cfg.nodes.set(exitId, { id: exitId, kind: 'exit', succ: [], pred: [], line });
|
|
@@ -189,6 +253,38 @@ export function parseJsFile(file, code) {
|
|
|
189
253
|
return node.id;
|
|
190
254
|
};
|
|
191
255
|
|
|
256
|
+
// Babel visits an IfStatement's consequent then its alternate as ordinary
|
|
257
|
+
// children of the same enter/exit pair, with no boundary hook between
|
|
258
|
+
// them — so fn._cursor was never reset before the alternate was
|
|
259
|
+
// traversed: the alternate's first node was linked as a successor of the
|
|
260
|
+
// CONSEQUENT's tail (a false predecessor edge corrupting any taint-state
|
|
261
|
+
// reasoning across the join) rather than a true second branch off the
|
|
262
|
+
// condition (also silently dropping the condition's "false" outgoing
|
|
263
|
+
// edge, which made applyPathFeasibility's constant-condition pruning
|
|
264
|
+
// treat the `if` as unconditional and delete its only edge).
|
|
265
|
+
//
|
|
266
|
+
// Called from the generic `Statement` visitor below AND from the top of
|
|
267
|
+
// every handler whose node type can itself be the direct (unbraced) root
|
|
268
|
+
// of an if's alternate (ReturnStatement, ThrowStatement, IfStatement for
|
|
269
|
+
// else-if chains, the loop statements, TryStatement) — Babel merges
|
|
270
|
+
// alias-derived and type-specific visitors for the same node but runs the
|
|
271
|
+
// type-specific one FIRST, so relying on the `Statement` alias alone
|
|
272
|
+
// misses every alternate root that also has its own specific handler.
|
|
273
|
+
// The one-shot flag makes the (redundant, second) `Statement` firing a
|
|
274
|
+
// safe no-op once the type-specific handler already did the reset.
|
|
275
|
+
const maybeResetAtAlternateBoundary = (fn, path) => {
|
|
276
|
+
const parent = path.parentPath && path.parentPath.node;
|
|
277
|
+
if (!parent || parent.type !== 'IfStatement' || parent.alternate !== path.node) return;
|
|
278
|
+
if (path.node._alternateBoundaryHandled) return;
|
|
279
|
+
path.node._alternateBoundaryHandled = true;
|
|
280
|
+
// fn._cursor is still the consequent's tail (or the condition node
|
|
281
|
+
// itself, if the consequent added none) — converge it into the join
|
|
282
|
+
// before abandoning it, then start the alternate from the condition,
|
|
283
|
+
// exactly like the consequent did.
|
|
284
|
+
linkCfg(fn, fn._cursor, parent._asJoin);
|
|
285
|
+
fn._cursor = parent._asCond;
|
|
286
|
+
};
|
|
287
|
+
|
|
192
288
|
const recordWrite = (fn, target, source, nodeId) => {
|
|
193
289
|
if (!target || typeof target !== 'string') return;
|
|
194
290
|
if (!fn.writes.has(target)) fn.writes.set(target, []);
|
|
@@ -263,20 +359,77 @@ export function parseJsFile(file, code) {
|
|
|
263
359
|
|
|
264
360
|
VariableDeclarator(path) {
|
|
265
361
|
const fn = currentFn(); if (!fn) return;
|
|
362
|
+
// PRD R13(b): `for (const item of tainted)` is, structurally, also
|
|
363
|
+
// an ordinary VariableDeclarator (`item`, no `init`) — Babel visits
|
|
364
|
+
// it as a normal child of the ForOfStatement's `left` on the way
|
|
365
|
+
// into the loop body. Left to the general case below, this generic
|
|
366
|
+
// visit fires AFTER the loop visitor's enter() hook synthesizes
|
|
367
|
+
// `item = <iterated expr>`, re-assigning `item` from an absent
|
|
368
|
+
// `init` (source: unknown) and silently erasing the taint just
|
|
369
|
+
// synthesized. Skip it here ONLY for the simple-identifier shape
|
|
370
|
+
// the loop visitor actually owns and synthesizes for — a bare
|
|
371
|
+
// `for (const item of ...)` binding.
|
|
372
|
+
//
|
|
373
|
+
// A destructuring for-of binding (`for (const {a,b} of ...)` /
|
|
374
|
+
// `for (const [a,b] of ...)`) is explicitly NOT covered by the
|
|
375
|
+
// loop visitor's synthesis (see the ForOfStatement branch below —
|
|
376
|
+
// `loopVar` stays null and nothing is emitted for it), so it MUST
|
|
377
|
+
// fall through to the general destructuring handling further down
|
|
378
|
+
// in this same visitor. That handling was already here before this
|
|
379
|
+
// task and does something this guard must not break: it emits real
|
|
380
|
+
// taint-KILL assign nodes for each destructured name, which is what
|
|
381
|
+
// makes `let cmd = tainted; for (const {cmd} of SAFE) sink(cmd)`
|
|
382
|
+
// correctly clear cmd's stale outer taint. An earlier version of
|
|
383
|
+
// this guard matched on the ForOfStatement `left` alone (no
|
|
384
|
+
// Identifier check) and silently deleted those taint-kill nodes,
|
|
385
|
+
// producing a false positive on exactly that shadowing shape — see
|
|
386
|
+
// the regression test below.
|
|
387
|
+
const gpDecl = path.parentPath && path.parentPath.node;
|
|
388
|
+
const gpLoop = path.parentPath && path.parentPath.parentPath && path.parentPath.parentPath.node;
|
|
389
|
+
if (gpLoop && gpLoop.type === 'ForOfStatement' && gpLoop.left === gpDecl
|
|
390
|
+
&& path.node.id?.type === 'Identifier') return;
|
|
266
391
|
const id = lhsPath(path.node.id);
|
|
267
392
|
if (!id) return;
|
|
268
393
|
const initExpr = exprOf(path.node.init);
|
|
269
|
-
const nodeId = nextNodeId();
|
|
270
394
|
const line = path.node.loc?.start?.line || 0;
|
|
271
|
-
|
|
272
|
-
|
|
395
|
+
if (typeof id === 'string') {
|
|
396
|
+
const nodeId = nextNodeId();
|
|
397
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
|
|
398
|
+
recordWrite(fn, id, initExpr, nodeId);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Destructuring: `const {a, b: renamed} = obj;` / `const [a, b] = arr;`.
|
|
402
|
+
// Emit one REAL 'assign' CFG node per bound name — not just a
|
|
403
|
+
// recordWrite() bookkeeping entry — because the taint engine's
|
|
404
|
+
// step() 'assign' case reads `node.target` directly and requires
|
|
405
|
+
// a plain string (`typeof node.target === 'string'`); the single
|
|
406
|
+
// node this used to emit had target=the whole {kind:'object-
|
|
407
|
+
// pattern'|'array-pattern', ...} OBJECT, which step() always
|
|
408
|
+
// treated as `target=null` — so taint from `obj`/`arr` never
|
|
409
|
+
// reached ANY destructured binding, for every project that uses
|
|
410
|
+
// this extremely common pattern. fn.writes/recordWrite (used
|
|
411
|
+
// below too) is bookkeeping nothing in dataflow/ ever reads;
|
|
412
|
+
// only real CFG nodes matter to the walk.
|
|
273
413
|
if (id && typeof id === 'object' && id.kind === 'object-pattern') {
|
|
274
|
-
// x = { foo: a, bar: b } — emit one write per property.
|
|
275
414
|
for (const p of id.props) {
|
|
276
415
|
const alias = typeof p.alias === 'string' ? p.alias : null;
|
|
277
416
|
if (!alias) continue;
|
|
278
|
-
|
|
417
|
+
const memberSrc = { kind: 'member', object: initExpr, prop: p.key };
|
|
418
|
+
const nodeId = nextNodeId();
|
|
419
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
|
|
420
|
+
recordWrite(fn, alias, memberSrc, nodeId);
|
|
279
421
|
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (id && typeof id === 'object' && id.kind === 'array-pattern') {
|
|
425
|
+
id.elements.forEach((el, i) => {
|
|
426
|
+
const alias = typeof el === 'string' ? el : null;
|
|
427
|
+
if (!alias) return; // elision (`const [, b] = arr`) or nested pattern — not modeled
|
|
428
|
+
const memberSrc = { kind: 'member', object: initExpr, prop: String(i) };
|
|
429
|
+
const nodeId = nextNodeId();
|
|
430
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
|
|
431
|
+
recordWrite(fn, alias, memberSrc, nodeId);
|
|
432
|
+
});
|
|
280
433
|
}
|
|
281
434
|
},
|
|
282
435
|
|
|
@@ -298,7 +451,29 @@ export function parseJsFile(file, code) {
|
|
|
298
451
|
const parent = path.parent;
|
|
299
452
|
if (parent && (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression')) return;
|
|
300
453
|
const calleeExpr = exprOf(path.node.callee);
|
|
301
|
-
|
|
454
|
+
// An inline arrow/function-expression argument (`arr.map(x => ...)`)
|
|
455
|
+
// becomes {kind:'function-value', qid} instead of exprOf's generic
|
|
456
|
+
// {kind:'unknown'} fallback (exprOf has no case for either node
|
|
457
|
+
// type) — dataflow/higher-order.js's calleeIsResolvableCallback and
|
|
458
|
+
// engine.js's higher-order-invocation push site both special-case
|
|
459
|
+
// 'function-value', but nothing ever produced one for JS/TS, so
|
|
460
|
+
// higher-order taint flow only ever worked for a by-reference
|
|
461
|
+
// callback (`arr.map(processItem)`), never the far more common
|
|
462
|
+
// inline-callback shape. The qid is computed to match EXACTLY what
|
|
463
|
+
// this same node's own ArrowFunctionExpression/FunctionExpression
|
|
464
|
+
// visitor (below) will independently compute when Babel's
|
|
465
|
+
// traversal reaches it: for a function literal passed directly as
|
|
466
|
+
// a call argument, none of that visitor's naming heuristics
|
|
467
|
+
// (VariableDeclarator/AssignmentExpression/ObjectProperty parent)
|
|
468
|
+
// match, so it always resolves to name 'anon' scoped under the
|
|
469
|
+
// CURRENT function — exactly what's available here.
|
|
470
|
+
const args = (path.node.arguments || []).map(a => {
|
|
471
|
+
if (a && (a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression')) {
|
|
472
|
+
const argLine = a.loc?.start?.line || 1; // matches enterFn's own fallback
|
|
473
|
+
return { kind: 'function-value', qid: fnQid(file, fn.name, 'anon', argLine) };
|
|
474
|
+
}
|
|
475
|
+
return exprOf(a);
|
|
476
|
+
});
|
|
302
477
|
const line = path.node.loc?.start?.line || 0;
|
|
303
478
|
const nodeId = nextNodeId();
|
|
304
479
|
addNode(fn, { id: nodeId, kind: 'call', callee: calleeExpr, args, line, succ: [], pred: [] });
|
|
@@ -313,6 +488,7 @@ export function parseJsFile(file, code) {
|
|
|
313
488
|
|
|
314
489
|
ReturnStatement(path) {
|
|
315
490
|
const fn = currentFn(); if (!fn) return;
|
|
491
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
316
492
|
const expr = path.node.argument ? exprOf(path.node.argument) : null;
|
|
317
493
|
const nodeId = nextNodeId();
|
|
318
494
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -322,12 +498,30 @@ export function parseJsFile(file, code) {
|
|
|
322
498
|
linkCfg(fn, nodeId, fn.cfg.exit);
|
|
323
499
|
},
|
|
324
500
|
|
|
501
|
+
// Fires for every statement node, via Babel's built-in "Statement"
|
|
502
|
+
// alias — the fallback path for whichever concrete type an if's
|
|
503
|
+
// `alternate` turns out to be when that type has no specific
|
|
504
|
+
// handler of its own (BlockStatement, ExpressionStatement,
|
|
505
|
+
// VariableDeclaration, ...). Types that DO have a specific handler
|
|
506
|
+
// below (ReturnStatement, IfStatement, the loop statements,
|
|
507
|
+
// TryStatement) call `maybeResetAtAlternateBoundary` themselves,
|
|
508
|
+
// since Babel runs a node's type-specific visitor BEFORE its
|
|
509
|
+
// alias-derived one for the same node — relying on this alone would
|
|
510
|
+
// miss those cases entirely.
|
|
511
|
+
Statement: {
|
|
512
|
+
enter(path) {
|
|
513
|
+
const fn = currentFn(); if (!fn) return;
|
|
514
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
|
|
325
518
|
IfStatement: {
|
|
326
519
|
enter(path) {
|
|
327
520
|
// We model branches by inserting a noop "join" after the if; both
|
|
328
521
|
// branches link to it. Without this, the linear cursor model would
|
|
329
522
|
// miss that statements after the if are reachable from either branch.
|
|
330
523
|
const fn = currentFn(); if (!fn) return;
|
|
524
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
331
525
|
const condNodeId = nextNodeId();
|
|
332
526
|
const joinId = nextNodeId();
|
|
333
527
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -335,7 +529,6 @@ export function parseJsFile(file, code) {
|
|
|
335
529
|
fn.cfg.nodes.set(joinId, { id: joinId, kind: 'noop', succ: [], pred: [], line });
|
|
336
530
|
path.node._asJoin = joinId;
|
|
337
531
|
path.node._asCond = condNodeId;
|
|
338
|
-
path.node._asBranchSavedCursor = fn._cursor; // == condNodeId
|
|
339
532
|
},
|
|
340
533
|
exit(path) {
|
|
341
534
|
const fn = currentFn(); if (!fn) return;
|
|
@@ -344,9 +537,10 @@ export function parseJsFile(file, code) {
|
|
|
344
537
|
if (!joinId || !condId) return;
|
|
345
538
|
// The visitor visited the body of the if — Babel's body visit ran
|
|
346
539
|
// *after* the enter(), so fn._cursor now points to the tail of the
|
|
347
|
-
// consequent
|
|
348
|
-
//
|
|
349
|
-
// the
|
|
540
|
+
// consequent (or, thanks to maybeResetAtAlternateBoundary, of the
|
|
541
|
+
// alternate when one exists). Connect it to the join, then if no
|
|
542
|
+
// else branch existed, connect the cond directly to the join
|
|
543
|
+
// (representing the "false" edge).
|
|
350
544
|
linkCfg(fn, fn._cursor, joinId);
|
|
351
545
|
if (!path.node.alternate) linkCfg(fn, condId, joinId);
|
|
352
546
|
fn._cursor = joinId;
|
|
@@ -359,6 +553,7 @@ export function parseJsFile(file, code) {
|
|
|
359
553
|
'WhileStatement|ForStatement|DoWhileStatement|ForInStatement|ForOfStatement': {
|
|
360
554
|
enter(path) {
|
|
361
555
|
const fn = currentFn(); if (!fn) return;
|
|
556
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
362
557
|
const headerId = nextNodeId();
|
|
363
558
|
const exitId = nextNodeId();
|
|
364
559
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -366,6 +561,64 @@ export function parseJsFile(file, code) {
|
|
|
366
561
|
fn.cfg.nodes.set(exitId, { id: exitId, kind: 'noop', succ: [], pred: [], line });
|
|
367
562
|
path.node._loopHeader = headerId;
|
|
368
563
|
path.node._loopExit = exitId;
|
|
564
|
+
// PRD R13(b): for-of's binding variable is never connected to the
|
|
565
|
+
// iterated expression, so `for (const x of tainted) sink(x)` reads
|
|
566
|
+
// x as {kind:'unknown'} — clean. Synthesize an assign binding the
|
|
567
|
+
// loop variable to the iterated expression, exactly as the
|
|
568
|
+
// Python-CST/Go/PHP parsers already do for their own for-each
|
|
569
|
+
// constructs (parser-py.helper.py, parser-go.js, parser-php.js).
|
|
570
|
+
// Conservative, matching this file's own stated doctrine for
|
|
571
|
+
// loops in general ("any iteration could taint X"): the WHOLE
|
|
572
|
+
// loop variable is tainted if the iterable is tainted, not a
|
|
573
|
+
// specific element — element-level precision isn't modeled here
|
|
574
|
+
// any more than it is for the rest of this file's loop handling.
|
|
575
|
+
// Scoped to ForOfStatement only — While/For/DoWhile/ForIn have no
|
|
576
|
+
// "loop variable bound to an iterated collection" shape and must
|
|
577
|
+
// see zero behavior change from this addition.
|
|
578
|
+
if (path.node.type === 'ForOfStatement') {
|
|
579
|
+
const leftNode = path.node.left;
|
|
580
|
+
const declId = leftNode && leftNode.type === 'VariableDeclaration'
|
|
581
|
+
? leftNode.declarations[0]?.id
|
|
582
|
+
: leftNode;
|
|
583
|
+
// Only the simple `for (const x of ...)` / `for (x of ...)`
|
|
584
|
+
// shape is synthesized. A destructuring binding
|
|
585
|
+
// (`for (const {a,b} of ...)`) has no single flat target name
|
|
586
|
+
// lhsPath-style logic could bind to — left unsynthesized rather
|
|
587
|
+
// than guessed at, matching this codebase's "refuse rather than
|
|
588
|
+
// invent an edge" doctrine elsewhere (see _resolvableCalleeName
|
|
589
|
+
// in dataflow/engine.js for the same principle applied to call
|
|
590
|
+
// resolution).
|
|
591
|
+
const loopVar = declId && declId.type === 'Identifier' ? declId.name : null;
|
|
592
|
+
if (loopVar) {
|
|
593
|
+
const iterExpr = exprOf(path.node.right);
|
|
594
|
+
const bindId = nextNodeId();
|
|
595
|
+
addNode(fn, { id: bindId, kind: 'assign', target: loopVar, source: iterExpr, line, succ: [], pred: [] });
|
|
596
|
+
// `for (const x of ...)` / `for (let x of ...)` declares a
|
|
597
|
+
// BLOCK-SCOPED binding: a same-named `x` outside the loop is a
|
|
598
|
+
// different variable and must be completely unaffected by
|
|
599
|
+
// whatever the loop's `x` held. This engine's taint model has
|
|
600
|
+
// no block scoping, so the synthesized binding above would
|
|
601
|
+
// otherwise flow past the loop's exit and over-taint an outer
|
|
602
|
+
// `x` (proven: `let item='safe'; for (const item of req.body
|
|
603
|
+
// .items){} eval(item)` reported a Code Injection finding that
|
|
604
|
+
// pre-R13(b) code correctly reported as clean — the generic
|
|
605
|
+
// VariableDeclarator visitor used to emit `assign x <-
|
|
606
|
+
// {kind:'unknown'}` here, a taint KILL, and the guard above now
|
|
607
|
+
// suppresses it for exactly this shape). Record the name so
|
|
608
|
+
// exit() can re-emit that kill AFTER the loop, restoring the
|
|
609
|
+
// pre-existing behavior for post-loop reads while keeping the
|
|
610
|
+
// new in-loop taint flow.
|
|
611
|
+
//
|
|
612
|
+
// Deliberately NOT done for the bare-assignment form
|
|
613
|
+
// (`for (x of ...)`, leftNode is not a VariableDeclaration):
|
|
614
|
+
// that binding is not block-scoped, it reuses an existing
|
|
615
|
+
// outer `x`, and its value legitimately survives the loop —
|
|
616
|
+
// killing it there would itself be a regression.
|
|
617
|
+
if (leftNode && leftNode.type === 'VariableDeclaration') {
|
|
618
|
+
path.node._loopBindVar = loopVar;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
369
622
|
},
|
|
370
623
|
exit(path) {
|
|
371
624
|
const fn = currentFn(); if (!fn) return;
|
|
@@ -375,15 +628,40 @@ export function parseJsFile(file, code) {
|
|
|
375
628
|
linkCfg(fn, fn._cursor, headerId); // back-edge
|
|
376
629
|
linkCfg(fn, headerId, exitId); // exit edge
|
|
377
630
|
fn._cursor = exitId;
|
|
631
|
+
// See the _loopBindVar comment in enter(): restore the taint KILL
|
|
632
|
+
// that the ForOfStatement VariableDeclarator guard suppresses, so
|
|
633
|
+
// a block-scoped for-of binding's synthesized taint cannot leak
|
|
634
|
+
// past the loop onto a same-named outer variable. Emitted AFTER
|
|
635
|
+
// `fn._cursor = exitId` so it sits on the loop's normal exit path
|
|
636
|
+
// (header -> exit-noop -> kill -> whatever follows the loop) and
|
|
637
|
+
// therefore runs once on the way out — never inside the body, so
|
|
638
|
+
// in-loop taint reachability is unchanged.
|
|
639
|
+
if (path.node._loopBindVar) {
|
|
640
|
+
const killId = nextNodeId();
|
|
641
|
+
addNode(fn, {
|
|
642
|
+
id: killId,
|
|
643
|
+
kind: 'assign',
|
|
644
|
+
target: path.node._loopBindVar,
|
|
645
|
+
source: { kind: 'unknown' },
|
|
646
|
+
line: path.node.loc?.start?.line || 0,
|
|
647
|
+
succ: [],
|
|
648
|
+
pred: [],
|
|
649
|
+
});
|
|
650
|
+
}
|
|
378
651
|
},
|
|
379
652
|
},
|
|
380
653
|
|
|
381
654
|
TryStatement: {
|
|
382
|
-
enter() {
|
|
655
|
+
enter(path) {
|
|
656
|
+
const fn = currentFn(); if (!fn) return;
|
|
657
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
658
|
+
/* approximate try/catch as sequential — taint flows through both */
|
|
659
|
+
},
|
|
383
660
|
},
|
|
384
661
|
|
|
385
662
|
ThrowStatement(path) {
|
|
386
663
|
const fn = currentFn(); if (!fn) return;
|
|
664
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
387
665
|
const expr = exprOf(path.node.argument);
|
|
388
666
|
const nodeId = nextNodeId();
|
|
389
667
|
const line = path.node.loc?.start?.line || 0;
|