@clear-capabilities/agentic-security-scanner 0.137.1 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/dist/113.index.js +2 -2
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/435.index.js +29 -1
  6. package/dist/526.index.js +2 -2
  7. package/dist/637.index.js +1 -1
  8. package/dist/agentic-security.mjs +14 -14
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +10 -6
  11. package/src/dataflow/CLAUDE.md +30 -0
  12. package/src/dataflow/catalog.js +512 -14
  13. package/src/dataflow/engine.js +275 -27
  14. package/src/dataflow/summaries.js +30 -5
  15. package/src/engine.js +512 -120
  16. package/src/ir/CLAUDE.md +20 -5
  17. package/src/ir/balanced-call.js +11 -1
  18. package/src/ir/callgraph.js +34 -0
  19. package/src/ir/parser-cs.js +55 -6
  20. package/src/ir/parser-go.js +106 -2
  21. package/src/ir/parser-java.js +111 -10
  22. package/src/ir/parser-js.js +40 -0
  23. package/src/ir/parser-kt.js +194 -10
  24. package/src/ir/parser-php.js +108 -6
  25. package/src/ir/parser-py.helper.py +199 -10
  26. package/src/ir/parser-rb.js +405 -31
  27. package/src/mcp/tools.js +29 -1
  28. package/src/posture/accuracy-scorecard.js +103 -0
  29. package/src/runScan.js +5 -2
  30. package/src/sast/CLAUDE.md +1 -1
  31. package/src/sast/_auth-signals.js +141 -0
  32. package/src/sast/_comment-strip.js +80 -13
  33. package/src/sast/codegen-sink.js +110 -0
  34. package/src/sast/convention-deviation.js +235 -0
  35. package/src/sast/fastapi-hardening.js +45 -6
  36. package/src/sast/file-upload.js +29 -1
  37. package/src/sast/ownership-authz.js +245 -0
  38. package/src/sast/php.js +12 -2
  39. package/src/sast/rate-limit.js +2 -0
  40. package/src/sast/rbac-consistency.js +1 -1
  41. package/src/sast/redirect-toctou.js +167 -0
  42. package/src/sast/resource-exhaustion.js +217 -0
  43. package/src/sast/sibling-guard.js +176 -0
  44. package/src/sast/zip-slip.js +53 -2
@@ -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 _RB_OPENERS = /^(?:if|unless|while|until|for|case|begin|do)\b/;
80
- const _RB_BLOCK_KW = /\b(?:def|class|module|if|unless|while|until|for|case|begin|do)\b/;
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 && _RB_OPENERS.test(line)) {
105
- if (buf.trim()) out.push({ text: buf.trim(), line: bufLine });
106
- buf = line + '\n';
107
- bufLine = lineNo;
108
- for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
109
- if (/\bend\b/.test(line)) depth--;
110
- if (depth <= 0) { depth = 0; out.push({ text: buf.trim(), line: bufLine }); buf = ''; }
111
- return;
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
- out.push({ text: line, line: lineNo });
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
- const callMatch = matchBalancedCall(s, /^([\w.]+)/);
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
- return { kind: 'call', callee: callMatch.callee, args: _splitTopLevelCommas(callMatch.argsText).map(_lowerExpr) };
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
- const call = matchBalancedCall(s, /^([\w.]+)/);
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
- return { kind: 'call', line, callee: call.callee, args: _splitTopLevelCommas(call.argsText).map(_lowerExpr) };
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
- function _buildCfg(bodyText, nodes, prevId, startLine) {
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 base = rel.split('/').pop() || '';
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