@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-rb.js
CHANGED
|
@@ -76,8 +76,28 @@ function _extractRubyBody(src, defEnd) {
|
|
|
76
76
|
return { body: src.slice(defEnd, i - 3).trimEnd(), end: i };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
const
|
|
80
|
-
|
|
79
|
+
const _RB_BLOCK_KW = /\b(?:def|class|module|if|unless|while|until|for|case|begin|do)\b/g;
|
|
80
|
+
|
|
81
|
+
// Taint-recall PRD (80%): the count of these keywords in `line`, minus the
|
|
82
|
+
// count of `end` — used to detect whether a line OPENS (or continues) a
|
|
83
|
+
// depth-tracked block, not just when the line's FIRST word is a keyword.
|
|
84
|
+
// The old `_RB_OPENERS.test(line)` gate required the opener at the very
|
|
85
|
+
// START of the line, so a trailing block attached to a call — the
|
|
86
|
+
// idiomatic Ruby shape (`xs.each do |x|`, `Nokogiri::XML(xml) do |c|`) —
|
|
87
|
+
// was never recognized as starting a chunk at all: each line of the block
|
|
88
|
+
// body, and the `end` that should have closed it, were instead emitted as
|
|
89
|
+
// independent, nonsensical top-level statements. `#`-comments are stripped
|
|
90
|
+
// first so a keyword appearing only in a trailing comment doesn't
|
|
91
|
+
// false-positive (string-literal occurrences are a known, accepted
|
|
92
|
+
// imprecision shared with every other hand-rolled parser in this codebase).
|
|
93
|
+
function _rbLineDepthDelta(line) {
|
|
94
|
+
const noComment = line.replace(/#.*$/, '');
|
|
95
|
+
let delta = 0;
|
|
96
|
+
for (const _ of noComment.matchAll(_RB_BLOCK_KW)) delta++;
|
|
97
|
+
const endMatches = noComment.match(/\bend\b/g);
|
|
98
|
+
if (endMatches) delta -= endMatches.length;
|
|
99
|
+
return delta;
|
|
100
|
+
}
|
|
81
101
|
|
|
82
102
|
// Returns `{ text, line }[]` — `line` is the 1-indexed line, relative to the
|
|
83
103
|
// START of `body`, where that statement's text begins (the array index of
|
|
@@ -91,6 +111,18 @@ const _RB_BLOCK_KW = /\b(?:def|class|module|if|unless|while|until|for|case|begin
|
|
|
91
111
|
// parser-php.js's twin fix and comment for the full rationale (Finding 2 of
|
|
92
112
|
// the R14(b) final whole-branch review) — this is the same root bug in a
|
|
93
113
|
// per-line splitter instead of a per-semicolon one.
|
|
114
|
+
// Taint-recall PRD (80%): the depth check now uses _rbLineDepthDelta (which
|
|
115
|
+
// scans the WHOLE line, not just its first word) at BOTH decision points —
|
|
116
|
+
// not just when already inside a chunk (the old `depth > 0` branch). A
|
|
117
|
+
// trailing block attached to a call (`xs.each do |x|`, `Nokogiri::XML(xml)
|
|
118
|
+
// do |c|`) has its opener keyword mid-line, so the old `depth === 0 &&
|
|
119
|
+
// _RB_OPENERS.test(line)` gate (line-START only) never even recognized
|
|
120
|
+
// such a line as starting a chunk — each line of the block body, and the
|
|
121
|
+
// `end` meant to close it, were emitted as independent, nonsensical
|
|
122
|
+
// top-level statements instead. Confirmed via a real corpus fixture
|
|
123
|
+
// (Nokogiri::XML's brace form was already broken the same way; do...end is
|
|
124
|
+
// the more common Rails idiom and was worse — not just dropped, actively
|
|
125
|
+
// mis-split line by line).
|
|
94
126
|
function _splitStatements(body) {
|
|
95
127
|
const lines = body.split('\n');
|
|
96
128
|
const out = [];
|
|
@@ -101,52 +133,124 @@ function _splitStatements(body) {
|
|
|
101
133
|
const lineNo = idx + 1;
|
|
102
134
|
const line = rawLine.trim();
|
|
103
135
|
if (!line || line.startsWith('#')) return;
|
|
104
|
-
if (depth === 0
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (depth > 0) {
|
|
114
|
-
buf += line + '\n';
|
|
115
|
-
for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
|
|
116
|
-
const endMatches = line.match(/\bend\b/g);
|
|
117
|
-
if (endMatches) depth -= endMatches.length;
|
|
118
|
-
if (depth <= 0) { depth = 0; out.push({ text: buf.trim(), line: bufLine }); buf = ''; }
|
|
136
|
+
if (depth === 0) {
|
|
137
|
+
const delta = _rbLineDepthDelta(line);
|
|
138
|
+
if (delta > 0) {
|
|
139
|
+
buf = line + '\n';
|
|
140
|
+
bufLine = lineNo;
|
|
141
|
+
depth = delta;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
out.push({ text: line, line: lineNo });
|
|
119
145
|
return;
|
|
120
146
|
}
|
|
121
|
-
|
|
147
|
+
buf += line + '\n';
|
|
148
|
+
depth += _rbLineDepthDelta(line);
|
|
149
|
+
if (depth <= 0) { depth = 0; out.push({ text: buf.trim(), line: bufLine }); buf = ''; }
|
|
122
150
|
});
|
|
123
151
|
if (buf.trim()) out.push({ text: buf.trim(), line: bufLine });
|
|
124
152
|
return out;
|
|
125
153
|
}
|
|
126
154
|
|
|
155
|
+
// Taint-recall PRD (80%): same architectural fix as parser-cs.js/
|
|
156
|
+
// parser-go.js/parser-php.js — a chained call (`sanitize(x).strip`, or a
|
|
157
|
+
// real sink shape like `Nokogiri::XML(x).at_xpath(...)`) previously stopped
|
|
158
|
+
// at the FIRST balanced call, leaving `.method(args)` unconsumed. Args from
|
|
159
|
+
// EVERY level are kept, outermost-first — see parser-cs.js's twin function
|
|
160
|
+
// for why (a first version that kept only the outermost broke a real chain
|
|
161
|
+
// shape where the tainted value sits on an INNER call).
|
|
162
|
+
function _followChain(s, endIdx, calleeSoFar, argsSoFar) {
|
|
163
|
+
const rest = s.slice(endIdx);
|
|
164
|
+
const outer = matchBalancedCall(rest, /^\.(\w+)/);
|
|
165
|
+
if (!outer) return { kind: 'call', callee: calleeSoFar, args: argsSoFar };
|
|
166
|
+
const outerArgs = _splitTopLevelCommas(outer.argsText).map(_lowerExpr);
|
|
167
|
+
return _followChain(rest, outer.endIdx, `${calleeSoFar}.${outer.callee}`, outerArgs.concat(argsSoFar));
|
|
168
|
+
}
|
|
169
|
+
|
|
127
170
|
function _lowerExpr(text) {
|
|
128
171
|
const s = String(text || '').trim();
|
|
129
172
|
if (!s) return { kind: 'unknown' };
|
|
173
|
+
// Taint-recall PRD (80%): Ruby's BACKTICK shell-execution operator
|
|
174
|
+
// (`` `finger #{user}` `` — Kernel#`, equivalent to %x{...}) is a
|
|
175
|
+
// completely distinct syntax from a double-quoted string, but this file
|
|
176
|
+
// had no recognizer for it at all — it fell through every branch below to
|
|
177
|
+
// {kind:'unknown'}, silently dropping the shell command (and any
|
|
178
|
+
// interpolated taint inside it) entirely. Confirmed via
|
|
179
|
+
// CVE-2022-25927-rails-cmdi-shape's exact real shape. Lowered to a
|
|
180
|
+
// synthetic call (`__ruby_backtick_exec__`, an identifier Ruby code can
|
|
181
|
+
// never actually name a method — backtick itself IS a valid Ruby method
|
|
182
|
+
// name, but never dotted as `__ruby_backtick_exec__`) carrying the
|
|
183
|
+
// interpolated command as its sole argument, so a normal `callee`-keyed
|
|
184
|
+
// catalog sink entry can target it exactly like any other call-shaped
|
|
185
|
+
// sink — no new match.type needed.
|
|
186
|
+
if (/^`[\s\S]*`$/.test(s)) {
|
|
187
|
+
const inner = s.slice(1, -1);
|
|
188
|
+
const parts = [];
|
|
189
|
+
let lastIndex = 0;
|
|
190
|
+
for (const m of inner.matchAll(/#\{([^}]+)\}/g)) {
|
|
191
|
+
if (m.index > lastIndex) parts.push({ kind: 'literal', value: inner.slice(lastIndex, m.index) });
|
|
192
|
+
parts.push(_lowerExpr(m[1]));
|
|
193
|
+
lastIndex = m.index + m[0].length;
|
|
194
|
+
}
|
|
195
|
+
if (lastIndex < inner.length) parts.push({ kind: 'literal', value: inner.slice(lastIndex) });
|
|
196
|
+
const cmdExpr = parts.length ? { kind: 'tpl', parts } : { kind: 'literal', value: inner };
|
|
197
|
+
return { kind: 'call', callee: '__ruby_backtick_exec__', args: [cmdExpr] };
|
|
198
|
+
}
|
|
130
199
|
// String interpolation before plain literal check
|
|
131
200
|
if (/^".*#\{/.test(s)) {
|
|
132
201
|
const parts = [];
|
|
133
202
|
for (const m of s.matchAll(/#\{([^}]+)\}/g)) parts.push(_lowerExpr(m[1]));
|
|
134
203
|
if (parts.length) return { kind: 'tpl', parts };
|
|
135
204
|
}
|
|
205
|
+
// Taint-recall PRD (80%): string concat with `+` MUST be checked before
|
|
206
|
+
// the plain-literal rule below — `"/var/data/" + name` starts with a
|
|
207
|
+
// quote, so the old ordering swallowed the WHOLE expression (including
|
|
208
|
+
// `+ name`) as one opaque literal, silently dropping the concatenated
|
|
209
|
+
// variable (confirmed via a real corpus fixture). Same lesson
|
|
210
|
+
// parser-go.js's R3 fix already learned for the identical reason. Only
|
|
211
|
+
// fires on a TOP-LEVEL `+` (outside string/paren/bracket nesting, via
|
|
212
|
+
// _splitTopLevelPlus) so a literal that merely CONTAINS a "+" character
|
|
213
|
+
// (`"a+b"`) is not incorrectly split into broken fragments.
|
|
214
|
+
if (s.includes('+')) {
|
|
215
|
+
const plusParts = _splitTopLevelPlus(s);
|
|
216
|
+
if (plusParts.length > 1) return { kind: 'tpl', parts: plusParts.map(_lowerExpr) };
|
|
217
|
+
}
|
|
136
218
|
if (/^['"]/.test(s)) return { kind: 'literal', value: s };
|
|
137
219
|
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
138
220
|
if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
|
|
139
221
|
// Symbol
|
|
140
222
|
if (/^:\w+/.test(s)) return { kind: 'literal', value: s };
|
|
223
|
+
// Taint-recall PRD (80%): keyword-argument / hash-shorthand syntax
|
|
224
|
+
// (`method(base: "...", filter: tainted)`) had no branch at all — every
|
|
225
|
+
// such arg text fell through to `{kind:'unknown'}`, silently dropping
|
|
226
|
+
// whatever value (including a tainted one) it carried. This is Ruby's
|
|
227
|
+
// dominant kwargs idiom (net/ldap, most Rails-adjacent APIs), so any call
|
|
228
|
+
// using it lost taint through EVERY keyword arg, not just one. Lower to
|
|
229
|
+
// just the value expression — the key name itself carries no taint
|
|
230
|
+
// relevance, same treatment JS/Python object-literal properties already
|
|
231
|
+
// get via exprTaint's 'object' case. The colon must immediately follow
|
|
232
|
+
// the identifier (no space) so this can't mis-fire on a ternary's
|
|
233
|
+
// `cond ? a : b` (space before its colon).
|
|
234
|
+
const kwarg = s.match(/^([A-Za-z_]\w*):\s*(.+)$/);
|
|
235
|
+
if (kwarg) return _lowerExpr(kwarg[2]);
|
|
141
236
|
// Call: obj.method(args) or method(args). matchBalancedCall finds the
|
|
142
237
|
// paren that actually balances the FIRST '(' — not the greedy-to-end-of-
|
|
143
238
|
// string match the old `/\((.*)\)\s*$/` used, which corrupted the
|
|
144
239
|
// argument text for a chained call (`sanitize(x).strip` produced
|
|
145
240
|
// args="x).strip", which then fell through to {kind:'unknown'} and
|
|
146
241
|
// silently dropped x).
|
|
147
|
-
|
|
242
|
+
// Taint-recall PRD (80%): `[\w.]+` excludes `:`, so `Nokogiri::XML(xml)`
|
|
243
|
+
// — Ruby's `::` module-scope call operator, a common idiom for
|
|
244
|
+
// class/module-level factory methods — never matched here at all (the
|
|
245
|
+
// regex only ever captured "Nokogiri", then failed to find `(`
|
|
246
|
+
// immediately after it, since a `:` sat in between). Callee normalizes
|
|
247
|
+
// `::` to `.` for consistency with _followChain's dot-joining and every
|
|
248
|
+
// catalog entry's dotted-segment matching.
|
|
249
|
+
const callMatch = matchBalancedCall(s, /^([\w.:]+)/);
|
|
148
250
|
if (callMatch) {
|
|
149
|
-
|
|
251
|
+
const callee = callMatch.callee.replace(/::/g, '.');
|
|
252
|
+
const args = _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr);
|
|
253
|
+
return _followChain(s, callMatch.endIdx, callee, args);
|
|
150
254
|
}
|
|
151
255
|
// Method call without parens is very common in Ruby but hard to detect
|
|
152
256
|
// reliably with regex. We handle the explicit-paren form above.
|
|
@@ -165,14 +269,36 @@ function _lowerExpr(text) {
|
|
|
165
269
|
}
|
|
166
270
|
// Simple ident
|
|
167
271
|
if (/^[A-Za-z_@]\w*$/.test(s)) return { kind: 'ident', name: s };
|
|
168
|
-
// Concat with +
|
|
169
|
-
if (s.includes('+')) {
|
|
170
|
-
const parts = s.split('+').map(p => _lowerExpr(p.trim()));
|
|
171
|
-
return { kind: 'tpl', parts };
|
|
172
|
-
}
|
|
173
272
|
return { kind: 'unknown' };
|
|
174
273
|
}
|
|
175
274
|
|
|
275
|
+
// Taint-recall PRD (80%): top-level-aware `+` splitter, same purpose as
|
|
276
|
+
// _splitTopLevelCommas below — tracks string/paren/bracket nesting so a
|
|
277
|
+
// `+` inside a string literal or a nested call's argument list doesn't get
|
|
278
|
+
// mistaken for a concatenation operator.
|
|
279
|
+
function _splitTopLevelPlus(s) {
|
|
280
|
+
const out = [];
|
|
281
|
+
let buf = '';
|
|
282
|
+
let depth = 0;
|
|
283
|
+
let inStr = null;
|
|
284
|
+
for (let i = 0; i < s.length; i++) {
|
|
285
|
+
const c = s[i];
|
|
286
|
+
if (inStr) {
|
|
287
|
+
buf += c;
|
|
288
|
+
if (c === '\\') { i++; buf += s[i] || ''; continue; }
|
|
289
|
+
if (c === inStr) inStr = null;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (c === '"' || c === '\'') { inStr = c; buf += c; continue; }
|
|
293
|
+
if (c === '(' || c === '{' || c === '[') depth++;
|
|
294
|
+
if (c === ')' || c === '}' || c === ']') depth--;
|
|
295
|
+
if (c === '+' && depth === 0) { out.push(buf.trim()); buf = ''; continue; }
|
|
296
|
+
buf += c;
|
|
297
|
+
}
|
|
298
|
+
if (buf.trim()) out.push(buf.trim());
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
176
302
|
function _splitTopLevelCommas(s) {
|
|
177
303
|
const out = [];
|
|
178
304
|
let buf = '';
|
|
@@ -206,15 +332,47 @@ function _lowerStmt(stmt, line) {
|
|
|
206
332
|
if (/^raise\b/.test(s)) {
|
|
207
333
|
return { kind: 'throw', line, value: _lowerExpr(s.replace(/^raise\s*/, '')) };
|
|
208
334
|
}
|
|
335
|
+
// Taint-recall PRD (80%): a bare backtick shell-execution statement
|
|
336
|
+
// (`` `finger #{user}` `` as its own statement — the LAST expression of a
|
|
337
|
+
// do/end block, which Ruby implicitly returns, is exactly this shape)
|
|
338
|
+
// matched none of the branches below (assignment needs `=`, the call
|
|
339
|
+
// regexes below all expect an identifier before any parens/args) and was
|
|
340
|
+
// silently dropped as a statement, even though `_lowerExpr` already knows
|
|
341
|
+
// how to lower the expression itself (used when it's an assignment RHS).
|
|
342
|
+
// Delegate directly so the statement-form case gets the same synthetic
|
|
343
|
+
// `__ruby_backtick_exec__` call node.
|
|
344
|
+
if (/^`[\s\S]*`$/.test(s)) {
|
|
345
|
+
const expr = _lowerExpr(s);
|
|
346
|
+
return { kind: 'call', line, callee: expr.callee, args: expr.args };
|
|
347
|
+
}
|
|
348
|
+
// Taint-recall PRD (80%): subscript-assignment on a member chain
|
|
349
|
+
// (`response.headers["X-Trace"] = params[:trace]`) had no branch at
|
|
350
|
+
// all — the plain assign regex below requires a bare `@?\w+` target, so
|
|
351
|
+
// this fell through to the bareCall heuristic (further down), which also
|
|
352
|
+
// doesn't match, and the WHOLE statement was silently dropped. Same
|
|
353
|
+
// shape/fix as parser-py.helper.py's `__setitem__` synthesis this PRD
|
|
354
|
+
// added earlier: lowered as a synthetic `<receiver>.[]=(key, value)`
|
|
355
|
+
// call so it flows through the existing argument-based sink-matching
|
|
356
|
+
// machinery — argIndex 1 is the assigned value.
|
|
357
|
+
const subAssign = s.match(/^([A-Za-z_][\w.]*)\[(.+?)\]\s*=\s*(.+)$/s);
|
|
358
|
+
if (subAssign) {
|
|
359
|
+
const receiver = subAssign[1].replace(/::/g, '.');
|
|
360
|
+
const key = _lowerExpr(subAssign[2]);
|
|
361
|
+
const value = _lowerExpr(subAssign[3]);
|
|
362
|
+
return { kind: 'call', line, callee: `${receiver}.[]=`, args: [key, value] };
|
|
363
|
+
}
|
|
209
364
|
// Assignment: var = expr
|
|
210
365
|
const assign = s.match(/^(@?\w+)\s*=\s*(.+)$/s);
|
|
211
366
|
if (assign && !/^={2}/.test(assign[2])) {
|
|
212
367
|
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
213
368
|
}
|
|
214
|
-
// Statement-form call with parens
|
|
215
|
-
|
|
369
|
+
// Statement-form call with parens. Same `::`-inclusion fix as _lowerExpr's
|
|
370
|
+
// twin (Taint-recall PRD 80%).
|
|
371
|
+
const call = matchBalancedCall(s, /^([\w.:]+)/);
|
|
216
372
|
if (call) {
|
|
217
|
-
|
|
373
|
+
const callee = call.callee.replace(/::/g, '.');
|
|
374
|
+
const chained = _followChain(s, call.endIdx, callee, _splitTopLevelCommas(call.argsText).map(_lowerExpr));
|
|
375
|
+
return { kind: 'call', line, callee: chained.callee, args: chained.args };
|
|
218
376
|
}
|
|
219
377
|
// Statement-form call without parens (common Ruby idiom): redirect_to expr
|
|
220
378
|
//
|
|
@@ -310,6 +468,130 @@ function _extractRubyBlockBody(compound) {
|
|
|
310
468
|
return lines.slice(1, -1).join('\n');
|
|
311
469
|
}
|
|
312
470
|
|
|
471
|
+
// Taint-recall PRD (80%): full Ruby CFG rebuild. `case/when/else` was
|
|
472
|
+
// previously not recursed into at all (the whole block silently dropped —
|
|
473
|
+
// `_lowerStmt` has no branch for it and "case" is excluded from the
|
|
474
|
+
// bare-call fallback, so it returns null and the entire chunk vanishes).
|
|
475
|
+
// Splits the case body into `when`/`else` arms by scanning for those
|
|
476
|
+
// keywords at NESTED depth 0 (mirroring parser-kt.js's _buildWhenArms,
|
|
477
|
+
// which needed the identical depth guard to avoid colliding with a nested
|
|
478
|
+
// if/else's own `else`). Each arm is linked directly from the case's own
|
|
479
|
+
// entry point (not chained if-else-style) and its body-tail joins a common
|
|
480
|
+
// exit node — this doesn't model mutual exclusivity between arms, which is
|
|
481
|
+
// fine for taint purposes: every arm's sink is reachable, which is what
|
|
482
|
+
// recall-preserving analysis needs (a false "this arm is also reachable"
|
|
483
|
+
// is far cheaper than silently dropping the arm that actually executes).
|
|
484
|
+
function _buildCaseArms(innerBody, nodes, entryId, startLine, cfgDepth) {
|
|
485
|
+
const lines = innerBody.split('\n');
|
|
486
|
+
const arms = [];
|
|
487
|
+
let depth = 0;
|
|
488
|
+
let cur = null;
|
|
489
|
+
lines.forEach((rawLine, idx) => {
|
|
490
|
+
const line = rawLine.trim();
|
|
491
|
+
if (depth === 0 && /^when\s+/.test(line)) {
|
|
492
|
+
if (cur) arms.push(cur);
|
|
493
|
+
cur = { condText: line.replace(/^when\s+/, '').replace(/\s+then\s*$/, '').trim(), bodyLines: [], startIdx: idx };
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (depth === 0 && /^else\b/.test(line)) {
|
|
497
|
+
if (cur) arms.push(cur);
|
|
498
|
+
cur = { condText: null, bodyLines: [], startIdx: idx };
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (cur) cur.bodyLines.push(rawLine);
|
|
502
|
+
depth += _rbLineDepthDelta(line);
|
|
503
|
+
if (depth < 0) depth = 0;
|
|
504
|
+
});
|
|
505
|
+
if (cur) arms.push(cur);
|
|
506
|
+
|
|
507
|
+
const join = _addNode(nodes, { kind: 'noop', line: startLine });
|
|
508
|
+
for (const arm of arms) {
|
|
509
|
+
const armLine = startLine + arm.startIdx;
|
|
510
|
+
const bodyText = arm.bodyLines.join('\n');
|
|
511
|
+
let branchStart = entryId;
|
|
512
|
+
if (arm.condText !== null) {
|
|
513
|
+
const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(arm.condText), line: armLine });
|
|
514
|
+
_linkNodes(nodes, entryId, ifNode);
|
|
515
|
+
branchStart = ifNode;
|
|
516
|
+
}
|
|
517
|
+
const tail = _buildCfg(bodyText, nodes, branchStart, armLine + 1, cfgDepth + 1);
|
|
518
|
+
_linkNodes(nodes, tail, join);
|
|
519
|
+
}
|
|
520
|
+
if (!arms.length) _linkNodes(nodes, entryId, join);
|
|
521
|
+
return join;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Taint-recall PRD (80%): `begin/rescue/ensure` was also previously
|
|
525
|
+
// dropped entirely (same root cause as case/when — no _lowerStmt branch).
|
|
526
|
+
// `rescue` clauses are linked from the SAME entry point as the begin body
|
|
527
|
+
// (an exception can occur at any point inside it, so precise "which
|
|
528
|
+
// statement raised" ordering isn't modeled — same recall-preserving
|
|
529
|
+
// tradeoff as case/when). `ensure`, when present, always runs after every
|
|
530
|
+
// other path converges, mirroring real semantics for reachability purposes
|
|
531
|
+
// even though this doesn't model early-return-through-ensure precisely.
|
|
532
|
+
function _buildBeginRescueEnsure(innerBody, nodes, entryId, startLine, cfgDepth) {
|
|
533
|
+
const lines = innerBody.split('\n');
|
|
534
|
+
const clauses = [{ kind: 'begin', bodyLines: [], startIdx: 0 }];
|
|
535
|
+
let depth = 0;
|
|
536
|
+
lines.forEach((rawLine, idx) => {
|
|
537
|
+
const line = rawLine.trim();
|
|
538
|
+
if (depth === 0 && /^rescue\b/.test(line)) {
|
|
539
|
+
clauses.push({ kind: 'rescue', bodyLines: [], startIdx: idx });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (depth === 0 && /^ensure\b/.test(line)) {
|
|
543
|
+
clauses.push({ kind: 'ensure', bodyLines: [], startIdx: idx });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
clauses[clauses.length - 1].bodyLines.push(rawLine);
|
|
547
|
+
depth += _rbLineDepthDelta(line);
|
|
548
|
+
if (depth < 0) depth = 0;
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
let beginTail = entryId;
|
|
552
|
+
const rescueTails = [];
|
|
553
|
+
let ensureBody = null, ensureLine = startLine;
|
|
554
|
+
for (const clause of clauses) {
|
|
555
|
+
const clauseLine = startLine + clause.startIdx;
|
|
556
|
+
const bodyText = clause.bodyLines.join('\n');
|
|
557
|
+
if (clause.kind === 'begin') {
|
|
558
|
+
beginTail = _buildCfg(bodyText, nodes, entryId, clauseLine + 1, cfgDepth + 1);
|
|
559
|
+
} else if (clause.kind === 'rescue') {
|
|
560
|
+
rescueTails.push(_buildCfg(bodyText, nodes, entryId, clauseLine + 1, cfgDepth + 1));
|
|
561
|
+
} else {
|
|
562
|
+
ensureBody = bodyText;
|
|
563
|
+
ensureLine = clauseLine;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const converge = _addNode(nodes, { kind: 'noop', line: startLine });
|
|
567
|
+
_linkNodes(nodes, beginTail, converge);
|
|
568
|
+
for (const t of rescueTails) _linkNodes(nodes, t, converge);
|
|
569
|
+
const join = _addNode(nodes, { kind: 'noop', line: startLine });
|
|
570
|
+
const finalTail = ensureBody !== null ? _buildCfg(ensureBody, nodes, converge, ensureLine + 1, cfgDepth + 1) : converge;
|
|
571
|
+
_linkNodes(nodes, finalTail, join);
|
|
572
|
+
return join;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Taint-recall PRD (80%): a trailing block attached to a call
|
|
576
|
+
// (`xs.each do |x| ... end`, `Nokogiri::XML(xml) { |c| ... }`) — Ruby's
|
|
577
|
+
// dominant Rails/ActiveRecord idiom (`.each`/`.map`/scope chains, resource
|
|
578
|
+
// blocks) — was previously silently dropped (do...end) or corrupted
|
|
579
|
+
// (brace form, via the pre-fix matchBalancedCall gap). Recurses into the
|
|
580
|
+
// body unconditionally (the PRD's own investigation recommended this,
|
|
581
|
+
// given how dominant the idiom is in real Rails code) and binds every
|
|
582
|
+
// named block parameter to the call's receiver — permissive by design
|
|
583
|
+
// (Ruby has no equivalent of Kotlin's implicit-this apply/run that would
|
|
584
|
+
// need to NOT bind; over-binding here is the safe direction for a
|
|
585
|
+
// recall-preserving engine).
|
|
586
|
+
function _lowerBlockTrigger(callText) {
|
|
587
|
+
const paren = matchBalancedCall(callText, /^([\w.:]+)/);
|
|
588
|
+
if (paren) {
|
|
589
|
+
const callee = paren.callee.replace(/::/g, '.');
|
|
590
|
+
return { kind: 'call', callee, args: _splitTopLevelCommas(paren.argsText).map(_lowerExpr) };
|
|
591
|
+
}
|
|
592
|
+
return { kind: 'call', callee: callText.replace(/::/g, '.'), args: [] };
|
|
593
|
+
}
|
|
594
|
+
|
|
313
595
|
// `startLine` is the absolute source line of the FIRST raw line of
|
|
314
596
|
// `bodyText`. Each statement's absolute line is `startLine + stmt.line - 1`
|
|
315
597
|
// (`stmt.line` from _splitStatements is already 1-indexed and relative to
|
|
@@ -319,7 +601,13 @@ function _extractRubyBlockBody(compound) {
|
|
|
319
601
|
// silently dropped any blank line (or, at module level, any blanked-out def
|
|
320
602
|
// span — see `_blankSpans`) that preceded a statement, which is exactly
|
|
321
603
|
// what made module-level Ruby findings report the wrong source line.
|
|
322
|
-
|
|
604
|
+
//
|
|
605
|
+
// `depth` guards against unbounded recursion on deeply/adversarially
|
|
606
|
+
// nested input (confirmed real risk — every OTHER R8-style rebuild in this
|
|
607
|
+
// codebase, Kotlin's trailing-lambda work most recently, needed the same
|
|
608
|
+
// guard after hitting a real stack overflow during its own testing).
|
|
609
|
+
function _buildCfg(bodyText, nodes, prevId, startLine, depth = 0) {
|
|
610
|
+
if (depth > 60) return prevId;
|
|
323
611
|
const stmts = _splitStatements(bodyText);
|
|
324
612
|
let prev = prevId;
|
|
325
613
|
for (const stmt of stmts) {
|
|
@@ -334,7 +622,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
334
622
|
const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(condText), line });
|
|
335
623
|
_linkNodes(nodes, prev, ifNode);
|
|
336
624
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
337
|
-
const thenTail = _buildCfg(innerBody, nodes, ifNode, line + 1);
|
|
625
|
+
const thenTail = _buildCfg(innerBody, nodes, ifNode, line + 1, depth + 1);
|
|
338
626
|
_linkNodes(nodes, thenTail, join);
|
|
339
627
|
_linkNodes(nodes, ifNode, join);
|
|
340
628
|
prev = join;
|
|
@@ -346,7 +634,7 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
346
634
|
const innerBody = _extractRubyBlockBody(s);
|
|
347
635
|
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
348
636
|
_linkNodes(nodes, prev, header);
|
|
349
|
-
const bodyTail = _buildCfg(innerBody, nodes, header, line + 1);
|
|
637
|
+
const bodyTail = _buildCfg(innerBody, nodes, header, line + 1, depth + 1);
|
|
350
638
|
_linkNodes(nodes, bodyTail, header);
|
|
351
639
|
const join = _addNode(nodes, { kind: 'noop', line });
|
|
352
640
|
_linkNodes(nodes, header, join);
|
|
@@ -354,6 +642,92 @@ function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
|
354
642
|
continue;
|
|
355
643
|
}
|
|
356
644
|
|
|
645
|
+
// Taint-recall PRD (80%): `for x in xs ... end` (also accepts the
|
|
646
|
+
// optional trailing `do` — `for x in xs do`) — previously not
|
|
647
|
+
// recognized by _buildCfg at all, silently dropped. Synthesizes a
|
|
648
|
+
// loop-variable binding assign, mirroring every OTHER R8-style
|
|
649
|
+
// for-each fix in this codebase (Java's enhanced-for, C#'s foreach,
|
|
650
|
+
// Kotlin's `for (x in xs)`) so the variable itself carries taint
|
|
651
|
+
// provenance from the iterated expression.
|
|
652
|
+
const forMatch = s.match(/^for\s+(\w+)\s+in\s+(.+?)\s*$/m);
|
|
653
|
+
if (forMatch && /\bend\b\s*$/.test(s)) {
|
|
654
|
+
const loopVar = forMatch[1];
|
|
655
|
+
const iterText = forMatch[2].replace(/\bdo\s*$/, '').trim();
|
|
656
|
+
const innerBody = _extractRubyBlockBody(s);
|
|
657
|
+
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
658
|
+
_linkNodes(nodes, prev, header);
|
|
659
|
+
const bindId = _addNode(nodes, { kind: 'assign', target: loopVar, source: _lowerExpr(iterText), line });
|
|
660
|
+
_linkNodes(nodes, header, bindId);
|
|
661
|
+
const bodyTail = _buildCfg(innerBody, nodes, bindId, line + 1, depth + 1);
|
|
662
|
+
_linkNodes(nodes, bodyTail, header);
|
|
663
|
+
const join = _addNode(nodes, { kind: 'noop', line });
|
|
664
|
+
_linkNodes(nodes, header, join);
|
|
665
|
+
prev = join;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (/^case\b/.test(s) && /\bend\b\s*$/.test(s)) {
|
|
670
|
+
const innerBody = _extractRubyBlockBody(s);
|
|
671
|
+
prev = _buildCaseArms(innerBody, nodes, prev, line + 1, depth + 1);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (/^begin\b/.test(s) && /\bend\b\s*$/.test(s)) {
|
|
676
|
+
const innerBody = _extractRubyBlockBody(s);
|
|
677
|
+
prev = _buildBeginRescueEnsure(innerBody, nodes, prev, line + 1, depth + 1);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// `bench:self-scan:check` caught a genuine ReDoS here — but NOT the
|
|
682
|
+
// "optional group between two \s*" shape this session's other fixes
|
|
683
|
+
// (parser-kt.js's trailing-lambda regex, parser-cs.js's attrRegex) were.
|
|
684
|
+
// A first version split into two mutually exclusive alternatives
|
|
685
|
+
// (no-params / with-params), the fix that worked for those — and it was
|
|
686
|
+
// STILL quadratic (confirmed by direct timing: 200000 chars of
|
|
687
|
+
// unmatched trailing whitespace took ~48s), because the real culprit is
|
|
688
|
+
// the LEADING `(.+?)\s+do` shared by both alternatives: an unbounded
|
|
689
|
+
// lazy group followed by a whitespace quantifier, which backtracks
|
|
690
|
+
// catastrophically against a long homogeneous run (e.g. all spaces)
|
|
691
|
+
// when the overall match fails — a different ReDoS class entirely, not
|
|
692
|
+
// fixed by re-partitioning what comes AFTER "do". Fixed by dropping the
|
|
693
|
+
// leading capturing group altogether: search directly for the
|
|
694
|
+
// trailing `\bdo\b` (a plain forward scan, nothing to backtrack) and
|
|
695
|
+
// derive the callee text from the substring before it — confirmed
|
|
696
|
+
// linear (1,000,000 chars: 2ms) and correctness-preserving (word
|
|
697
|
+
// boundaries correctly skip a method literally named `do_something`).
|
|
698
|
+
// Split into two mutually exclusive alternatives (with-params /
|
|
699
|
+
// no-params) rather than one optional group even though — unlike the
|
|
700
|
+
// `(.+?)\s+do` version above — this specific shape already measured
|
|
701
|
+
// linear on its own: bench:self-scan:check's detector flags the SHAPE
|
|
702
|
+
// (an optional group between two `\s*`) independent of whether a
|
|
703
|
+
// compounding leading group is present, so satisfying it here too
|
|
704
|
+
// keeps this file's own precision baseline honest without another
|
|
705
|
+
// detector-vs-reality debate.
|
|
706
|
+
const firstLine = s.split('\n')[0].trim();
|
|
707
|
+
const doMatch = firstLine.match(/\bdo\b\s*\|([^|]*)\|\s*$/) || firstLine.match(/\bdo\b\s*$/);
|
|
708
|
+
const blockMatch = doMatch ? [doMatch[0], firstLine.slice(0, doMatch.index), doMatch[1]] : null;
|
|
709
|
+
if (blockMatch && /\bend\s*$/.test(s)) {
|
|
710
|
+
const callText = blockMatch[1].trim();
|
|
711
|
+
const paramsText = blockMatch[2] || '';
|
|
712
|
+
const innerBody = _extractRubyBlockBody(s);
|
|
713
|
+
const triggerExpr = _lowerBlockTrigger(callText);
|
|
714
|
+
const callId = _addNode(nodes, { kind: 'call', line, callee: triggerExpr.callee, args: triggerExpr.args });
|
|
715
|
+
_linkNodes(nodes, prev, callId);
|
|
716
|
+
let bodyStart = callId;
|
|
717
|
+
const dot = triggerExpr.callee.lastIndexOf('.');
|
|
718
|
+
const receiver = dot > 0 ? triggerExpr.callee.slice(0, dot) : null;
|
|
719
|
+
if (receiver) {
|
|
720
|
+
const params = paramsText.split(',').map(p => p.trim().replace(/^\*+/, '')).filter(p => /^[A-Za-z_]\w*$/.test(p));
|
|
721
|
+
for (const p of params) {
|
|
722
|
+
const bindId = _addNode(nodes, { kind: 'assign', target: p, source: { kind: 'ident', name: receiver }, line });
|
|
723
|
+
_linkNodes(nodes, bodyStart, bindId);
|
|
724
|
+
bodyStart = bindId;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
prev = _buildCfg(innerBody, nodes, bodyStart, line + 1, depth + 1);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
357
731
|
const node = _lowerStmt(s, line);
|
|
358
732
|
if (!node) continue;
|
|
359
733
|
const id = _addNode(nodes, node);
|
package/src/mcp/tools.js
CHANGED
|
@@ -96,7 +96,29 @@ const RESERVED_WRITE_SUFFIXES = [
|
|
|
96
96
|
'.tfvars',
|
|
97
97
|
'docker-compose.yml',
|
|
98
98
|
'docker-compose.yaml',
|
|
99
|
+
// _CONFINEMENT rule 3 — backup and lock files. The specific lock BASENAMES
|
|
100
|
+
// above cover the ecosystems we know; this catches the rest (`deps.lock`,
|
|
101
|
+
// `foo.bak`) without needing to enumerate them. Nothing an autofix should
|
|
102
|
+
// ever be rewriting: a `.bak` is someone's safety copy and a `.lock` is
|
|
103
|
+
// generated state.
|
|
104
|
+
'.bak',
|
|
105
|
+
'.lock',
|
|
99
106
|
];
|
|
107
|
+
// _CONFINEMENT rule 3 — build output. Matched as a PATH SEGMENT at any depth,
|
|
108
|
+
// not as a top-level prefix, because build output is routinely nested
|
|
109
|
+
// (`packages/web/dist/`, `services/api/target/`) and a top-level-only check
|
|
110
|
+
// would refuse the monorepo root and allow every package inside it.
|
|
111
|
+
//
|
|
112
|
+
// This matters most in THIS repository: `scanner/dist/` holds the shipped
|
|
113
|
+
// bundle, which carries its own SHA-256 integrity sidecar precisely because
|
|
114
|
+
// what it contains matters. Before this, `apply_fix` would rewrite it and
|
|
115
|
+
// report success.
|
|
116
|
+
//
|
|
117
|
+
// NOTE for a future change, deliberately not made here: the PREFIX list above
|
|
118
|
+
// (`node_modules/`, `.git/`, …) is still top-level-only, so a nested
|
|
119
|
+
// `packages/a/node_modules/` is not covered by it. That is a separate widening
|
|
120
|
+
// with its own blast radius and belongs in its own change with its own tests.
|
|
121
|
+
const RESERVED_WRITE_DIR_SEGMENTS = new Set(['dist', 'build', 'target']);
|
|
100
122
|
function _isReservedWritePath(sessionRoot, absFile) {
|
|
101
123
|
// Resolve sessionRoot symlinks so the relative path is computed against
|
|
102
124
|
// the same canonical root as `absFile` (which _confine already realpath'd).
|
|
@@ -105,9 +127,15 @@ function _isReservedWritePath(sessionRoot, absFile) {
|
|
|
105
127
|
const rootReal = fs.realpathSync(path.resolve(sessionRoot));
|
|
106
128
|
const rel = path.relative(rootReal, absFile).replace(/\\/g, '/');
|
|
107
129
|
if (RESERVED_WRITE_PREFIXES.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p))) return true;
|
|
108
|
-
const
|
|
130
|
+
const segments = rel.split('/');
|
|
131
|
+
const base = segments[segments.length - 1] || '';
|
|
109
132
|
if (RESERVED_WRITE_BASENAMES.has(base)) return true;
|
|
110
133
|
if (RESERVED_WRITE_SUFFIXES.some(s => base === s || base.endsWith(s))) return true;
|
|
134
|
+
// Any DIRECTORY segment that names build output — checked over
|
|
135
|
+
// `segments.length - 1` so a source file legitimately called `build` or
|
|
136
|
+
// `dist` is not refused for its own name; only living inside such a
|
|
137
|
+
// directory counts.
|
|
138
|
+
if (segments.slice(0, -1).some(seg => RESERVED_WRITE_DIR_SEGMENTS.has(seg))) return true;
|
|
111
139
|
return false;
|
|
112
140
|
}
|
|
113
141
|
|