@clear-capabilities/agentic-security-scanner 0.136.2 → 0.136.9
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 +236 -0
- package/bin/agentic-security.js +186 -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 +183 -14
- 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 +14 -14
- 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 +18 -10
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +2 -1
- 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 +202 -42
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +155 -14
- 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/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/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-go.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import * as crypto from 'node:crypto';
|
|
24
24
|
import { callSitesFromCfg } from './call-sites.js';
|
|
25
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
25
26
|
|
|
26
27
|
const FUNC_RE = new RegExp(
|
|
27
28
|
'(?:^|[\\n;{}])\\s*func\\s+' +
|
|
@@ -76,7 +77,11 @@ function _lowerExpr(text) {
|
|
|
76
77
|
const s = String(text || '').trim();
|
|
77
78
|
if (!s) return { kind: 'unknown' };
|
|
78
79
|
if (/^fmt\.Sprintf\s*\(/.test(s)) {
|
|
79
|
-
|
|
80
|
+
// Same balanced-paren concern as the call patterns below — a chained
|
|
81
|
+
// call after fmt.Sprintf(...) (rare but not impossible via a wrapper)
|
|
82
|
+
// would otherwise have its args text swallow the chain's own parens.
|
|
83
|
+
const sprintf = matchBalancedCall(s, /^fmt\.Sprintf/);
|
|
84
|
+
const inner = sprintf ? sprintf.argsText : s.slice(s.indexOf('(') + 1, s.lastIndexOf(')'));
|
|
80
85
|
const parts = _splitTopLevelCommas(inner).map(_lowerExpr);
|
|
81
86
|
return { kind: 'tpl', parts };
|
|
82
87
|
}
|
|
@@ -91,17 +96,30 @@ function _lowerExpr(text) {
|
|
|
91
96
|
if (/^"/.test(s) || /^`/.test(s)) return { kind: 'literal', value: s };
|
|
92
97
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
93
98
|
if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
|
|
94
|
-
// Call: foo.Bar(args) or Bar(args)
|
|
95
|
-
|
|
99
|
+
// Call: foo.Bar(args) or Bar(args). matchBalancedCall finds the paren
|
|
100
|
+
// that actually balances the FIRST '(' — not the greedy-to-end-of-string
|
|
101
|
+
// match the old `/\((.*)\)\s*$/` used, which corrupted the argument text
|
|
102
|
+
// for a chained call (`Sanitize(x).Trim()` produced args="x).Trim(",
|
|
103
|
+
// which then fell through to {kind:'unknown'} and silently dropped x).
|
|
104
|
+
const callMatch = matchBalancedCall(s, /^([\w.]+)/);
|
|
96
105
|
if (callMatch) {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
return { kind: 'call', callee, args };
|
|
106
|
+
const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
|
|
107
|
+
return { kind: 'call', callee: callMatch.callee, args };
|
|
100
108
|
}
|
|
101
|
-
// String concat with +
|
|
109
|
+
// String concat with + — a SECOND occurrence of the same check as above
|
|
110
|
+
// (line ~92), reached when the expression didn't match any branch in
|
|
111
|
+
// between (calls, literals, etc.). It's missing the `parts.length > 1`
|
|
112
|
+
// guard the first occurrence has: when the `+` sits inside an extra
|
|
113
|
+
// pair of parens (`("SELECT " + name)` — e.g. an over-parenthesized
|
|
114
|
+
// argument), `_splitTopLevelPlus` returns the input unchanged as ONE
|
|
115
|
+
// part (depth never returns to 0 at the `+`), and `.map(_lowerExpr)`
|
|
116
|
+
// then recurses on the IDENTICAL string forever — a stack overflow,
|
|
117
|
+
// uncaught here (only caught by buildProjectIR's per-file try/catch, so
|
|
118
|
+
// it surfaces only as "this file has no IR"). Same bug class, same fix,
|
|
119
|
+
// as parser-cs.js's `_splitTopLevelPlus` guard for `new Type(concat)`.
|
|
102
120
|
if (s.includes('+') && /["'`]/.test(s)) {
|
|
103
|
-
const parts = _splitTopLevelPlus(s)
|
|
104
|
-
return { kind: 'tpl', parts };
|
|
121
|
+
const parts = _splitTopLevelPlus(s);
|
|
122
|
+
if (parts.length > 1) return { kind: 'tpl', parts: parts.map(_lowerExpr) };
|
|
105
123
|
}
|
|
106
124
|
// Member: a.b.c
|
|
107
125
|
if (/^[A-Za-z_][\w.]*$/.test(s)) {
|
|
@@ -220,9 +238,9 @@ function _lowerStmt(stmt, line) {
|
|
|
220
238
|
return { kind: 'assign', line, target: varDecl[1], source: _lowerExpr(varDecl[2]) };
|
|
221
239
|
}
|
|
222
240
|
// Statement-form call: obj.Method(args) or Method(args)
|
|
223
|
-
const cm = s
|
|
241
|
+
const cm = matchBalancedCall(s, /^([\w.]+)/);
|
|
224
242
|
if (cm) {
|
|
225
|
-
return { kind: 'call', line, callee: cm
|
|
243
|
+
return { kind: 'call', line, callee: cm.callee, args: _splitTopLevelCommas(cm.argsText).map(_lowerExpr) };
|
|
226
244
|
}
|
|
227
245
|
return null;
|
|
228
246
|
}
|
package/src/ir/parser-java.js
CHANGED
|
@@ -58,6 +58,27 @@ function exprFromCst(node) {
|
|
|
58
58
|
// CST node with named children — recurse into the most informative one.
|
|
59
59
|
// Method invocation
|
|
60
60
|
if (node.children.methodInvocation) return _methodInvocation(node.children.methodInvocation[0]);
|
|
61
|
+
// `primary` is how java-parser actually models a call: the name lives in
|
|
62
|
+
// primaryPrefix (an fqnOrRefType) and the invocation itself in
|
|
63
|
+
// primarySuffix.methodInvocationSuffix. Without this branch the fall-through
|
|
64
|
+
// below recursed into the prefix and returned the NAME — so
|
|
65
|
+
// `req.getParameter("q")` lowered to a member access, never a call, and no
|
|
66
|
+
// source or sink in the Java catalog could ever match.
|
|
67
|
+
if (node.children.primaryPrefix) {
|
|
68
|
+
const prefix = node.children.primaryPrefix[0];
|
|
69
|
+
const suffixes = node.children.primarySuffix || [];
|
|
70
|
+
const invocation = suffixes
|
|
71
|
+
.map(s => s.children?.methodInvocationSuffix?.[0])
|
|
72
|
+
.find(Boolean);
|
|
73
|
+
if (invocation) {
|
|
74
|
+
const fqn = prefix?.children?.fqnOrRefType?.[0];
|
|
75
|
+
const callee = fqn ? _flattenFqnToString(fqn) : 'unknown';
|
|
76
|
+
const args = (invocation.children?.argumentList?.[0]?.children?.expression || [])
|
|
77
|
+
.map(exprFromCst);
|
|
78
|
+
return { kind: 'call', callee, args };
|
|
79
|
+
}
|
|
80
|
+
return exprFromCst(prefix);
|
|
81
|
+
}
|
|
61
82
|
// FQN ref
|
|
62
83
|
if (node.children.fqnOrRefType) return _fqnExpr(node.children.fqnOrRefType[0]);
|
|
63
84
|
if (node.children.unqualifiedClassInstanceCreationExpression) {
|
|
@@ -89,12 +110,37 @@ function exprFromCst(node) {
|
|
|
89
110
|
return { kind: 'unknown' };
|
|
90
111
|
}
|
|
91
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The identifier images of an `fqnOrRefType`, in order.
|
|
115
|
+
*
|
|
116
|
+
* java-parser does NOT put `Identifier` directly on `fqnOrRefType`. The real
|
|
117
|
+
* shape is fqnOrRefTypePartFirst / fqnOrRefTypePartRest → fqnOrRefTypePartCommon
|
|
118
|
+
* → Identifier, so reading `children.Identifier` returned nothing for every
|
|
119
|
+
* real-world expression and both callers below degraded to 'unknown'. The
|
|
120
|
+
* direct-Identifier form is still accepted as a fallback.
|
|
121
|
+
*/
|
|
122
|
+
function _fqnIdents(node) {
|
|
123
|
+
if (!node || !node.children) return [];
|
|
124
|
+
const direct = node.children.Identifier;
|
|
125
|
+
if (Array.isArray(direct) && direct.length) return direct.map(t => t.image);
|
|
126
|
+
const out = [];
|
|
127
|
+
const parts = [
|
|
128
|
+
...(node.children.fqnOrRefTypePartFirst || []),
|
|
129
|
+
...(node.children.fqnOrRefTypePartRest || []),
|
|
130
|
+
];
|
|
131
|
+
for (const p of parts) {
|
|
132
|
+
const common = p.children?.fqnOrRefTypePartCommon?.[0];
|
|
133
|
+
const id = common?.children?.Identifier?.[0]?.image;
|
|
134
|
+
if (id) out.push(id);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
92
139
|
function _fqnExpr(node) {
|
|
93
|
-
// java-parser fqnOrRefType has children { Identifier: [...] } sometimes
|
|
94
|
-
// and Dot tokens between them.
|
|
95
140
|
if (!node || !node.children) return { kind: 'unknown' };
|
|
96
|
-
const
|
|
97
|
-
if (!
|
|
141
|
+
const idImages = _fqnIdents(node);
|
|
142
|
+
if (!idImages.length) return { kind: 'unknown' };
|
|
143
|
+
const ids = idImages.map(image => ({ image }));
|
|
98
144
|
let cur = { kind: 'ident', name: ids[0].image };
|
|
99
145
|
for (let i = 1; i < ids.length; i++) {
|
|
100
146
|
cur = { kind: 'member', object: cur, prop: ids[i].image };
|
|
@@ -118,10 +164,28 @@ function _methodInvocation(node) {
|
|
|
118
164
|
}
|
|
119
165
|
|
|
120
166
|
function _flattenFqnToString(node) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
const ids = _fqnIdents(node);
|
|
168
|
+
return ids.length ? ids.join('.') : 'unknown';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Descend to the first leaf token in a CST subtree and return its
|
|
172
|
+
// startLine. java-parser (chevrotain) leaf tokens carry startLine directly;
|
|
173
|
+
// rule nodes only carry `children`. Returns 0 when nothing is found (e.g.
|
|
174
|
+
// an empty node), which callers already treat as "unknown".
|
|
175
|
+
function _lineOf(node) {
|
|
176
|
+
if (!node) return 0;
|
|
177
|
+
if (Array.isArray(node)) {
|
|
178
|
+
for (const n of node) { const l = _lineOf(n); if (l) return l; }
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
if (typeof node.startLine === 'number') return node.startLine;
|
|
182
|
+
if (node.children) {
|
|
183
|
+
for (const k of Object.keys(node.children)) {
|
|
184
|
+
const l = _lineOf(node.children[k]);
|
|
185
|
+
if (l) return l;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return 0;
|
|
125
189
|
}
|
|
126
190
|
|
|
127
191
|
/**
|
|
@@ -132,7 +196,7 @@ function _flattenFqnToString(node) {
|
|
|
132
196
|
* `if` / `loop-header` node and the body falls through linearly. This is
|
|
133
197
|
* coarser than the JS frontend; v2 will branch the succ array.
|
|
134
198
|
*/
|
|
135
|
-
function buildCfgFromBody(bodyNode
|
|
199
|
+
function buildCfgFromBody(bodyNode) {
|
|
136
200
|
const nodes = {};
|
|
137
201
|
const entry = nextNodeId();
|
|
138
202
|
const exit = nextNodeId();
|
|
@@ -157,6 +221,14 @@ function buildCfgFromBody(bodyNode, line) {
|
|
|
157
221
|
function walkStmts(stmtNode) {
|
|
158
222
|
if (!stmtNode || !stmtNode.children) return;
|
|
159
223
|
const kids = stmtNode.children;
|
|
224
|
+
// java-parser nests a `block` as block → blockStatements (PLURAL, an
|
|
225
|
+
// intermediate rule node) → blockStatement. Walking only `blockStatement`
|
|
226
|
+
// from the block node therefore descended into nothing and every method
|
|
227
|
+
// body lowered to an empty CFG — which is why Java had IR functions with
|
|
228
|
+
// entry/exit and no statements, and taint could never fire.
|
|
229
|
+
if (kids.blockStatements) {
|
|
230
|
+
for (const bss of kids.blockStatements) walkStmts(bss);
|
|
231
|
+
}
|
|
160
232
|
// Block statement children
|
|
161
233
|
if (kids.blockStatement) {
|
|
162
234
|
for (const bs of kids.blockStatement) walkStmts(bs);
|
|
@@ -170,7 +242,7 @@ function buildCfgFromBody(bodyNode, line) {
|
|
|
170
242
|
const target = d.children?.variableDeclaratorId?.[0]?.children?.Identifier?.[0]?.image;
|
|
171
243
|
const initExpr = d.children?.variableInitializer?.[0]?.children?.expression?.[0];
|
|
172
244
|
if (target) {
|
|
173
|
-
emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line:
|
|
245
|
+
emit({ kind: 'assign', target, source: initExpr ? exprFromCst(initExpr) : { kind: 'unknown' }, line: _lineOf(lv), succ: [] });
|
|
174
246
|
}
|
|
175
247
|
}
|
|
176
248
|
}
|
|
@@ -186,34 +258,34 @@ function buildCfgFromBody(bodyNode, line) {
|
|
|
186
258
|
const e = kids.expressionStatement[0]?.children?.statementExpression?.[0]?.children?.expression?.[0];
|
|
187
259
|
if (e) {
|
|
188
260
|
const expr = exprFromCst(e);
|
|
189
|
-
if (expr.kind === 'call') emit({ ...expr, line:
|
|
261
|
+
if (expr.kind === 'call') emit({ ...expr, line: _lineOf(kids.expressionStatement[0]), succ: [] });
|
|
190
262
|
else if (expr.kind === 'binary' && expr.op === '=') {
|
|
191
263
|
// assignment expr `x = y;`
|
|
192
|
-
emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line:
|
|
264
|
+
emit({ kind: 'assign', target: expr.left?.name || null, source: expr.right, line: _lineOf(kids.expressionStatement[0]), succ: [] });
|
|
193
265
|
}
|
|
194
266
|
}
|
|
195
267
|
}
|
|
196
268
|
if (kids.returnStatement) {
|
|
197
269
|
const r = kids.returnStatement[0];
|
|
198
270
|
const expr = r.children?.expression?.[0];
|
|
199
|
-
emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line:
|
|
271
|
+
emit({ kind: 'return', value: expr ? exprFromCst(expr) : null, line: _lineOf(r), succ: [] });
|
|
200
272
|
}
|
|
201
273
|
if (kids.throwStatement) {
|
|
202
274
|
const t = kids.throwStatement[0];
|
|
203
275
|
const expr = t.children?.expression?.[0];
|
|
204
|
-
emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line:
|
|
276
|
+
emit({ kind: 'throw', value: expr ? exprFromCst(expr) : null, line: _lineOf(t), succ: [] });
|
|
205
277
|
}
|
|
206
278
|
if (kids.ifStatement) {
|
|
207
279
|
const i = kids.ifStatement[0];
|
|
208
280
|
const cond = i.children?.expression?.[0];
|
|
209
|
-
emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line:
|
|
281
|
+
emit({ kind: 'if', cond: cond ? exprFromCst(cond) : null, line: _lineOf(i), succ: [] });
|
|
210
282
|
// Then branch body falls through linearly; v1 simplification.
|
|
211
283
|
for (const sub of (i.children?.statement || [])) walkStmts(sub);
|
|
212
284
|
}
|
|
213
285
|
if (kids.whileStatement) {
|
|
214
286
|
const w = kids.whileStatement[0];
|
|
215
287
|
const cond = w.children?.expression?.[0];
|
|
216
|
-
emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line:
|
|
288
|
+
emit({ kind: 'loop-header', cond: cond ? exprFromCst(cond) : null, line: _lineOf(w), succ: [] });
|
|
217
289
|
for (const sub of (w.children?.statement || [])) walkStmts(sub);
|
|
218
290
|
}
|
|
219
291
|
}
|
|
@@ -266,12 +338,17 @@ export async function parseJavaFile(file, raw) {
|
|
|
266
338
|
const params = []; // params extraction deferred
|
|
267
339
|
const body = md.children?.methodBody?.[0]?.children?.block?.[0];
|
|
268
340
|
if (body) {
|
|
341
|
+
const methodLine = _lineOf(md);
|
|
269
342
|
functions.push({
|
|
270
|
-
qid
|
|
343
|
+
// Sibling frontends (parser-js.js) suffix the qid with `@line`
|
|
344
|
+
// so overloaded/same-named methods don't collide — without it,
|
|
345
|
+
// callgraph.js's `functions.set(fn.qid, fn)` silently drops
|
|
346
|
+
// every overload but the last one declared.
|
|
347
|
+
qid: `${file}::${className || 'class'}::${name}@${methodLine}`,
|
|
271
348
|
name: className ? `${className}.${name}` : name,
|
|
272
|
-
line:
|
|
349
|
+
line: methodLine,
|
|
273
350
|
params,
|
|
274
|
-
cfg: buildCfgFromBody(body
|
|
351
|
+
cfg: buildCfgFromBody(body),
|
|
275
352
|
file,
|
|
276
353
|
});
|
|
277
354
|
}
|
package/src/ir/parser-js.js
CHANGED
|
@@ -98,7 +98,22 @@ function lhsPath(n) {
|
|
|
98
98
|
if (n.type === 'ThisExpression') return '_this_';
|
|
99
99
|
if (n.type === 'MemberExpression') {
|
|
100
100
|
const base = lhsPath(n.object);
|
|
101
|
-
|
|
101
|
+
// Stage 3 correctness audit (detection depth, path-feasibility): a
|
|
102
|
+
// computed WRITE with a literal key (`obj['secret'] = tainted`) used
|
|
103
|
+
// to collapse straight to the wildcard '*' — never trying to extract
|
|
104
|
+
// the literal, unlike exprOf's MemberExpression case just above,
|
|
105
|
+
// which DOES extract it for reads. That asymmetry meant a tainted
|
|
106
|
+
// write via bracket notation with a literal key produced access path
|
|
107
|
+
// "obj.*", while any later read of that same key (`obj.secret` or
|
|
108
|
+
// `obj['secret']`) resolves via exprOf to the specific path
|
|
109
|
+
// "obj.secret" — isCoveredBy has no wildcard semantics (`'*'` is a
|
|
110
|
+
// literal property name here, not a match-anything token), so the two
|
|
111
|
+
// paths never matched and the taint was silently unreachable from any
|
|
112
|
+
// correctly-computed read. Mirrors exprOf's extraction exactly so a
|
|
113
|
+
// write and a read of the same literal key always agree.
|
|
114
|
+
const prop = n.computed
|
|
115
|
+
? (n.property?.value != null ? String(n.property.value) : '*')
|
|
116
|
+
: (n.property?.name || '*');
|
|
102
117
|
if (!base) return null;
|
|
103
118
|
return base + '.' + prop;
|
|
104
119
|
}
|
|
@@ -138,16 +153,25 @@ export function parseJsFile(file, code) {
|
|
|
138
153
|
const exitId = nextNodeId();
|
|
139
154
|
const fn = {
|
|
140
155
|
qid, name: name || 'anon', line,
|
|
156
|
+
// Plain strings, per the IR shape contract (ir/CLAUDE.md: "params:
|
|
157
|
+
// ['arg1', 'arg2', ...]") — every other parser (py, cs, rb, cpp, ...)
|
|
158
|
+
// already emits this shape. This one used to emit {name,kind[,props]}
|
|
159
|
+
// objects instead, which every consumer (access-paths.js's
|
|
160
|
+
// isCoveredBy, summaries.js's paramNames.indexOf, entryStateFromCall,
|
|
161
|
+
// the k=2 pass's `new Set(fn.params)`) silently failed to match against
|
|
162
|
+
// — Set/string-equality checks against an object never succeed, so
|
|
163
|
+
// mutated-parameter taint and context-sensitive entry states were both
|
|
164
|
+
// unconditionally inert for every JS/TS function. Nothing in src/
|
|
165
|
+
// reads a param's .kind or .props, so the richer shape bought nothing.
|
|
141
166
|
params: (params || []).map(p => {
|
|
142
167
|
if (!p) return null;
|
|
143
|
-
if (p.type === 'Identifier') return
|
|
144
|
-
if (p.type === 'ObjectPattern') return
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (p.type === '
|
|
150
|
-
if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return { name: p.argument.name, kind: 'rest' };
|
|
168
|
+
if (p.type === 'Identifier') return p.name;
|
|
169
|
+
if (p.type === 'ObjectPattern') return '<obj>';
|
|
170
|
+
if (p.type === 'ArrayPattern') return '<arr>';
|
|
171
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'Identifier') return p.left.name;
|
|
172
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'ObjectPattern') return '<obj>';
|
|
173
|
+
if (p.type === 'AssignmentPattern' && p.left?.type === 'ArrayPattern') return '<arr>';
|
|
174
|
+
if (p.type === 'RestElement' && p.argument?.type === 'Identifier') return p.argument.name;
|
|
151
175
|
return null;
|
|
152
176
|
}).filter(Boolean),
|
|
153
177
|
cfg: { entry: entryId, exit: exitId, nodes: new Map() },
|
|
@@ -189,6 +213,38 @@ export function parseJsFile(file, code) {
|
|
|
189
213
|
return node.id;
|
|
190
214
|
};
|
|
191
215
|
|
|
216
|
+
// Babel visits an IfStatement's consequent then its alternate as ordinary
|
|
217
|
+
// children of the same enter/exit pair, with no boundary hook between
|
|
218
|
+
// them — so fn._cursor was never reset before the alternate was
|
|
219
|
+
// traversed: the alternate's first node was linked as a successor of the
|
|
220
|
+
// CONSEQUENT's tail (a false predecessor edge corrupting any taint-state
|
|
221
|
+
// reasoning across the join) rather than a true second branch off the
|
|
222
|
+
// condition (also silently dropping the condition's "false" outgoing
|
|
223
|
+
// edge, which made applyPathFeasibility's constant-condition pruning
|
|
224
|
+
// treat the `if` as unconditional and delete its only edge).
|
|
225
|
+
//
|
|
226
|
+
// Called from the generic `Statement` visitor below AND from the top of
|
|
227
|
+
// every handler whose node type can itself be the direct (unbraced) root
|
|
228
|
+
// of an if's alternate (ReturnStatement, ThrowStatement, IfStatement for
|
|
229
|
+
// else-if chains, the loop statements, TryStatement) — Babel merges
|
|
230
|
+
// alias-derived and type-specific visitors for the same node but runs the
|
|
231
|
+
// type-specific one FIRST, so relying on the `Statement` alias alone
|
|
232
|
+
// misses every alternate root that also has its own specific handler.
|
|
233
|
+
// The one-shot flag makes the (redundant, second) `Statement` firing a
|
|
234
|
+
// safe no-op once the type-specific handler already did the reset.
|
|
235
|
+
const maybeResetAtAlternateBoundary = (fn, path) => {
|
|
236
|
+
const parent = path.parentPath && path.parentPath.node;
|
|
237
|
+
if (!parent || parent.type !== 'IfStatement' || parent.alternate !== path.node) return;
|
|
238
|
+
if (path.node._alternateBoundaryHandled) return;
|
|
239
|
+
path.node._alternateBoundaryHandled = true;
|
|
240
|
+
// fn._cursor is still the consequent's tail (or the condition node
|
|
241
|
+
// itself, if the consequent added none) — converge it into the join
|
|
242
|
+
// before abandoning it, then start the alternate from the condition,
|
|
243
|
+
// exactly like the consequent did.
|
|
244
|
+
linkCfg(fn, fn._cursor, parent._asJoin);
|
|
245
|
+
fn._cursor = parent._asCond;
|
|
246
|
+
};
|
|
247
|
+
|
|
192
248
|
const recordWrite = (fn, target, source, nodeId) => {
|
|
193
249
|
if (!target || typeof target !== 'string') return;
|
|
194
250
|
if (!fn.writes.has(target)) fn.writes.set(target, []);
|
|
@@ -266,17 +322,45 @@ export function parseJsFile(file, code) {
|
|
|
266
322
|
const id = lhsPath(path.node.id);
|
|
267
323
|
if (!id) return;
|
|
268
324
|
const initExpr = exprOf(path.node.init);
|
|
269
|
-
const nodeId = nextNodeId();
|
|
270
325
|
const line = path.node.loc?.start?.line || 0;
|
|
271
|
-
|
|
272
|
-
|
|
326
|
+
if (typeof id === 'string') {
|
|
327
|
+
const nodeId = nextNodeId();
|
|
328
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: id, source: initExpr, line, succ: [], pred: [] });
|
|
329
|
+
recordWrite(fn, id, initExpr, nodeId);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Destructuring: `const {a, b: renamed} = obj;` / `const [a, b] = arr;`.
|
|
333
|
+
// Emit one REAL 'assign' CFG node per bound name — not just a
|
|
334
|
+
// recordWrite() bookkeeping entry — because the taint engine's
|
|
335
|
+
// step() 'assign' case reads `node.target` directly and requires
|
|
336
|
+
// a plain string (`typeof node.target === 'string'`); the single
|
|
337
|
+
// node this used to emit had target=the whole {kind:'object-
|
|
338
|
+
// pattern'|'array-pattern', ...} OBJECT, which step() always
|
|
339
|
+
// treated as `target=null` — so taint from `obj`/`arr` never
|
|
340
|
+
// reached ANY destructured binding, for every project that uses
|
|
341
|
+
// this extremely common pattern. fn.writes/recordWrite (used
|
|
342
|
+
// below too) is bookkeeping nothing in dataflow/ ever reads;
|
|
343
|
+
// only real CFG nodes matter to the walk.
|
|
273
344
|
if (id && typeof id === 'object' && id.kind === 'object-pattern') {
|
|
274
|
-
// x = { foo: a, bar: b } — emit one write per property.
|
|
275
345
|
for (const p of id.props) {
|
|
276
346
|
const alias = typeof p.alias === 'string' ? p.alias : null;
|
|
277
347
|
if (!alias) continue;
|
|
278
|
-
|
|
348
|
+
const memberSrc = { kind: 'member', object: initExpr, prop: p.key };
|
|
349
|
+
const nodeId = nextNodeId();
|
|
350
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
|
|
351
|
+
recordWrite(fn, alias, memberSrc, nodeId);
|
|
279
352
|
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (id && typeof id === 'object' && id.kind === 'array-pattern') {
|
|
356
|
+
id.elements.forEach((el, i) => {
|
|
357
|
+
const alias = typeof el === 'string' ? el : null;
|
|
358
|
+
if (!alias) return; // elision (`const [, b] = arr`) or nested pattern — not modeled
|
|
359
|
+
const memberSrc = { kind: 'member', object: initExpr, prop: String(i) };
|
|
360
|
+
const nodeId = nextNodeId();
|
|
361
|
+
addNode(fn, { id: nodeId, kind: 'assign', target: alias, source: memberSrc, line, succ: [], pred: [] });
|
|
362
|
+
recordWrite(fn, alias, memberSrc, nodeId);
|
|
363
|
+
});
|
|
280
364
|
}
|
|
281
365
|
},
|
|
282
366
|
|
|
@@ -298,7 +382,29 @@ export function parseJsFile(file, code) {
|
|
|
298
382
|
const parent = path.parent;
|
|
299
383
|
if (parent && (parent.type === 'VariableDeclarator' || parent.type === 'AssignmentExpression')) return;
|
|
300
384
|
const calleeExpr = exprOf(path.node.callee);
|
|
301
|
-
|
|
385
|
+
// An inline arrow/function-expression argument (`arr.map(x => ...)`)
|
|
386
|
+
// becomes {kind:'function-value', qid} instead of exprOf's generic
|
|
387
|
+
// {kind:'unknown'} fallback (exprOf has no case for either node
|
|
388
|
+
// type) — dataflow/higher-order.js's calleeIsResolvableCallback and
|
|
389
|
+
// engine.js's higher-order-invocation push site both special-case
|
|
390
|
+
// 'function-value', but nothing ever produced one for JS/TS, so
|
|
391
|
+
// higher-order taint flow only ever worked for a by-reference
|
|
392
|
+
// callback (`arr.map(processItem)`), never the far more common
|
|
393
|
+
// inline-callback shape. The qid is computed to match EXACTLY what
|
|
394
|
+
// this same node's own ArrowFunctionExpression/FunctionExpression
|
|
395
|
+
// visitor (below) will independently compute when Babel's
|
|
396
|
+
// traversal reaches it: for a function literal passed directly as
|
|
397
|
+
// a call argument, none of that visitor's naming heuristics
|
|
398
|
+
// (VariableDeclarator/AssignmentExpression/ObjectProperty parent)
|
|
399
|
+
// match, so it always resolves to name 'anon' scoped under the
|
|
400
|
+
// CURRENT function — exactly what's available here.
|
|
401
|
+
const args = (path.node.arguments || []).map(a => {
|
|
402
|
+
if (a && (a.type === 'ArrowFunctionExpression' || a.type === 'FunctionExpression')) {
|
|
403
|
+
const argLine = a.loc?.start?.line || 1; // matches enterFn's own fallback
|
|
404
|
+
return { kind: 'function-value', qid: fnQid(file, fn.name, 'anon', argLine) };
|
|
405
|
+
}
|
|
406
|
+
return exprOf(a);
|
|
407
|
+
});
|
|
302
408
|
const line = path.node.loc?.start?.line || 0;
|
|
303
409
|
const nodeId = nextNodeId();
|
|
304
410
|
addNode(fn, { id: nodeId, kind: 'call', callee: calleeExpr, args, line, succ: [], pred: [] });
|
|
@@ -313,6 +419,7 @@ export function parseJsFile(file, code) {
|
|
|
313
419
|
|
|
314
420
|
ReturnStatement(path) {
|
|
315
421
|
const fn = currentFn(); if (!fn) return;
|
|
422
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
316
423
|
const expr = path.node.argument ? exprOf(path.node.argument) : null;
|
|
317
424
|
const nodeId = nextNodeId();
|
|
318
425
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -322,12 +429,30 @@ export function parseJsFile(file, code) {
|
|
|
322
429
|
linkCfg(fn, nodeId, fn.cfg.exit);
|
|
323
430
|
},
|
|
324
431
|
|
|
432
|
+
// Fires for every statement node, via Babel's built-in "Statement"
|
|
433
|
+
// alias — the fallback path for whichever concrete type an if's
|
|
434
|
+
// `alternate` turns out to be when that type has no specific
|
|
435
|
+
// handler of its own (BlockStatement, ExpressionStatement,
|
|
436
|
+
// VariableDeclaration, ...). Types that DO have a specific handler
|
|
437
|
+
// below (ReturnStatement, IfStatement, the loop statements,
|
|
438
|
+
// TryStatement) call `maybeResetAtAlternateBoundary` themselves,
|
|
439
|
+
// since Babel runs a node's type-specific visitor BEFORE its
|
|
440
|
+
// alias-derived one for the same node — relying on this alone would
|
|
441
|
+
// miss those cases entirely.
|
|
442
|
+
Statement: {
|
|
443
|
+
enter(path) {
|
|
444
|
+
const fn = currentFn(); if (!fn) return;
|
|
445
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
|
|
325
449
|
IfStatement: {
|
|
326
450
|
enter(path) {
|
|
327
451
|
// We model branches by inserting a noop "join" after the if; both
|
|
328
452
|
// branches link to it. Without this, the linear cursor model would
|
|
329
453
|
// miss that statements after the if are reachable from either branch.
|
|
330
454
|
const fn = currentFn(); if (!fn) return;
|
|
455
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
331
456
|
const condNodeId = nextNodeId();
|
|
332
457
|
const joinId = nextNodeId();
|
|
333
458
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -335,7 +460,6 @@ export function parseJsFile(file, code) {
|
|
|
335
460
|
fn.cfg.nodes.set(joinId, { id: joinId, kind: 'noop', succ: [], pred: [], line });
|
|
336
461
|
path.node._asJoin = joinId;
|
|
337
462
|
path.node._asCond = condNodeId;
|
|
338
|
-
path.node._asBranchSavedCursor = fn._cursor; // == condNodeId
|
|
339
463
|
},
|
|
340
464
|
exit(path) {
|
|
341
465
|
const fn = currentFn(); if (!fn) return;
|
|
@@ -344,9 +468,10 @@ export function parseJsFile(file, code) {
|
|
|
344
468
|
if (!joinId || !condId) return;
|
|
345
469
|
// The visitor visited the body of the if — Babel's body visit ran
|
|
346
470
|
// *after* the enter(), so fn._cursor now points to the tail of the
|
|
347
|
-
// consequent
|
|
348
|
-
//
|
|
349
|
-
// the
|
|
471
|
+
// consequent (or, thanks to maybeResetAtAlternateBoundary, of the
|
|
472
|
+
// alternate when one exists). Connect it to the join, then if no
|
|
473
|
+
// else branch existed, connect the cond directly to the join
|
|
474
|
+
// (representing the "false" edge).
|
|
350
475
|
linkCfg(fn, fn._cursor, joinId);
|
|
351
476
|
if (!path.node.alternate) linkCfg(fn, condId, joinId);
|
|
352
477
|
fn._cursor = joinId;
|
|
@@ -359,6 +484,7 @@ export function parseJsFile(file, code) {
|
|
|
359
484
|
'WhileStatement|ForStatement|DoWhileStatement|ForInStatement|ForOfStatement': {
|
|
360
485
|
enter(path) {
|
|
361
486
|
const fn = currentFn(); if (!fn) return;
|
|
487
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
362
488
|
const headerId = nextNodeId();
|
|
363
489
|
const exitId = nextNodeId();
|
|
364
490
|
const line = path.node.loc?.start?.line || 0;
|
|
@@ -379,11 +505,16 @@ export function parseJsFile(file, code) {
|
|
|
379
505
|
},
|
|
380
506
|
|
|
381
507
|
TryStatement: {
|
|
382
|
-
enter() {
|
|
508
|
+
enter(path) {
|
|
509
|
+
const fn = currentFn(); if (!fn) return;
|
|
510
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
511
|
+
/* approximate try/catch as sequential — taint flows through both */
|
|
512
|
+
},
|
|
383
513
|
},
|
|
384
514
|
|
|
385
515
|
ThrowStatement(path) {
|
|
386
516
|
const fn = currentFn(); if (!fn) return;
|
|
517
|
+
maybeResetAtAlternateBoundary(fn, path);
|
|
387
518
|
const expr = exprOf(path.node.argument);
|
|
388
519
|
const nodeId = nextNodeId();
|
|
389
520
|
const line = path.node.loc?.start?.line || 0;
|
package/src/ir/parser-php.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import * as crypto from 'node:crypto';
|
|
21
21
|
import { callSitesFromCfg } from './call-sites.js';
|
|
22
|
+
import { matchBalancedCall } from './balanced-call.js';
|
|
22
23
|
|
|
23
24
|
const FUNC_RE = new RegExp(
|
|
24
25
|
'(?:^|[\\n;{}]|<\\?php|<\\?)\\s*' +
|
|
@@ -66,6 +67,35 @@ function _splitStatements(body) {
|
|
|
66
67
|
function _lowerExpr(text) {
|
|
67
68
|
const s = String(text || '').trim();
|
|
68
69
|
if (!s) return { kind: 'unknown' };
|
|
70
|
+
// Stage 3 correctness audit (detection depth, per-language-IR): PHP
|
|
71
|
+
// double-quoted strings interpolate variables directly
|
|
72
|
+
// ("SELECT ... WHERE id=$id", "hi {$user->name}") — single-quoted
|
|
73
|
+
// strings never do. This must run BEFORE the generic string-literal
|
|
74
|
+
// fallback just below, which otherwise treated ANY quoted string
|
|
75
|
+
// (including a double-quoted one containing a live variable) as an
|
|
76
|
+
// opaque clean literal — silently dropping the interpolated variable's
|
|
77
|
+
// taint. This is exactly `"SELECT ... WHERE id=$id"`, one of the most
|
|
78
|
+
// common real PHP SQL-injection shapes. Simple (`$var`, `$var->prop`,
|
|
79
|
+
// `$var[key]`) and complex (`{$expr}`) interpolation forms are both
|
|
80
|
+
// lowered into a template, same shape as the `.`-concat branch below.
|
|
81
|
+
if (/^"/.test(s) && s.includes('$')) {
|
|
82
|
+
const inner = s.slice(1, -1);
|
|
83
|
+
const re = /\{(\$[^}]+)\}|(\$[A-Za-z_]\w*(?:->[A-Za-z_]\w*|\[[^\]]+\])?)/g;
|
|
84
|
+
let lastIndex = 0;
|
|
85
|
+
const parts = [];
|
|
86
|
+
let matched = false;
|
|
87
|
+
let m;
|
|
88
|
+
while ((m = re.exec(inner)) !== null) {
|
|
89
|
+
matched = true;
|
|
90
|
+
if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
|
|
91
|
+
parts.push(_lowerExpr(m[1] !== undefined ? m[1] : m[2]));
|
|
92
|
+
lastIndex = re.lastIndex;
|
|
93
|
+
}
|
|
94
|
+
if (matched) {
|
|
95
|
+
if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
|
|
96
|
+
return { kind: 'tpl', parts };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
69
99
|
if (/^"/.test(s) || /^'/.test(s)) return { kind: 'literal', value: s };
|
|
70
100
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
71
101
|
if (/^(true|false|null|NULL)\b/.test(s)) return { kind: 'literal', value: s };
|
|
@@ -81,17 +111,22 @@ function _lowerExpr(text) {
|
|
|
81
111
|
}
|
|
82
112
|
// Variable
|
|
83
113
|
if (/^\$[A-Za-z_]\w*$/.test(s)) return { kind: 'ident', name: s };
|
|
84
|
-
// Method call: $obj->method(args) or ClassName::method(args)
|
|
85
|
-
|
|
114
|
+
// Method call: $obj->method(args) or ClassName::method(args).
|
|
115
|
+
// matchBalancedCall finds the paren that actually balances the FIRST
|
|
116
|
+
// '(' — not the greedy-to-end-of-string match the old `/\((.*)\)\s*$/`
|
|
117
|
+
// used, which corrupted the argument text for a chained call
|
|
118
|
+
// (`sanitize($x)->trim()` produced args="$x)->trim(", which then fell
|
|
119
|
+
// through to {kind:'unknown'} and silently dropped $x).
|
|
120
|
+
const methodCall = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
|
|
86
121
|
if (methodCall) {
|
|
87
|
-
const callee = methodCall
|
|
88
|
-
const args = _splitTopLevelCommas(methodCall
|
|
122
|
+
const callee = methodCall.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
123
|
+
const args = _splitTopLevelCommas(methodCall.argsText).map(_lowerExpr);
|
|
89
124
|
return { kind: 'call', callee, args };
|
|
90
125
|
}
|
|
91
126
|
// Function call: func(args)
|
|
92
|
-
const funcCall = s
|
|
127
|
+
const funcCall = matchBalancedCall(s, /^([A-Za-z_][\w]*)/);
|
|
93
128
|
if (funcCall) {
|
|
94
|
-
return { kind: 'call', callee: funcCall
|
|
129
|
+
return { kind: 'call', callee: funcCall.callee, args: _splitTopLevelCommas(funcCall.argsText).map(_lowerExpr) };
|
|
95
130
|
}
|
|
96
131
|
// Concat with .
|
|
97
132
|
if (s.includes('.') && /["'\$]/.test(s)) {
|
|
@@ -170,10 +205,10 @@ function _lowerStmt(stmt, line) {
|
|
|
170
205
|
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
171
206
|
}
|
|
172
207
|
// Statement-form call
|
|
173
|
-
const call = s
|
|
208
|
+
const call = matchBalancedCall(s, /^(\$[\w]+(?:->[\w]+)*|[A-Za-z_][\w]*(?:::[\w]+)*)/);
|
|
174
209
|
if (call) {
|
|
175
|
-
const callee = call
|
|
176
|
-
return { kind: 'call', line, callee, args: _splitTopLevelCommas(call
|
|
210
|
+
const callee = call.callee.replace(/->/g, '.').replace(/::/g, '.');
|
|
211
|
+
return { kind: 'call', line, callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
|
|
177
212
|
}
|
|
178
213
|
return null;
|
|
179
214
|
}
|