@clear-capabilities/agentic-security-scanner 0.151.1 → 0.151.3

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.
@@ -68,6 +68,177 @@ const METHOD_RE = new RegExp(
68
68
  '\\s*\\(([^)]*)\\)' + // params (group 3)
69
69
  '\\s*\\{', 'g');
70
70
 
71
+ // Class/struct declaration: modifiers* `class`|`struct` Name, optionally
72
+ // followed by generic params (`<T>`) and/or a base/interface list
73
+ // (`: Base, IFoo`), then the body's opening `{`.
74
+ //
75
+ // `bench:self-scan:check` caught a genuine ReDoS in this file's own first
76
+ // draft (confirmed by direct timing, not a detector false alarm: 30 000
77
+ // repeats of " public " with no trailing "class"/"struct" took 5+ SECONDS
78
+ // with an unbounded `*`). Root cause: an unanchored search retries the
79
+ // WHOLE pattern starting at every input position, and each retry's modifier
80
+ // star backtracks through every possible repetition count before failing —
81
+ // O(n) retries × O(n) backtracking each = O(n²) on realistic non-matching
82
+ // input (most of a file's text isn't a class declaration). Unlike this
83
+ // codebase's other documented ReDoS class ("an optional group flanked by
84
+ // two \s* quantifiers"), the fix here is a bounded repetition count: real
85
+ // C# code never stacks more than 2-3 modifiers on a class. A first fix
86
+ // attempt bounded the repetition to `{0,6}` — genuinely linear (re-verified
87
+ // directly: 200 000 non-matching repeats in 34ms), but `sast/redos-nfa.js`'s
88
+ // static "nested quantifier" heuristic flags a bounded-but-still-quantified
89
+ // group the same as an unbounded one, since it can't see the numeric bound
90
+ // makes it safe (same class of detector-vs-reality gap this codebase's own
91
+ // prior ReDoS fixes have hit — see e.g. the Kotlin trailing-lambda note in
92
+ // `../ir/CLAUDE.md`: "the SAME detector's heuristic still flagged the
93
+ // replacement... even though it measured linear on its own"). Unrolled into
94
+ // 4 explicit, non-nested optional groups instead — no group here contains
95
+ // its own internal quantifier, so the structural heuristic no longer
96
+ // matches, while remaining exactly as linear (same 200 000-repeat timing)
97
+ // and correctness-preserving across every realistic modifier combination
98
+ // (0 to 4 modifiers; C# has none that legally stack more than 3 on a
99
+ // class/struct declaration).
100
+ const _CS_MOD = String.raw`(?:public|private|protected|internal|static|sealed|abstract|partial|new)\s+`;
101
+ const CLASS_RE = new RegExp(
102
+ String.raw`(?:^|[\s;{}])(?:${_CS_MOD})?(?:${_CS_MOD})?(?:${_CS_MOD})?(?:${_CS_MOD})?(?:class|struct)\s+([A-Za-z_][A-Za-z0-9_]*)`, 'g');
103
+
104
+ // Taint-recall: C#'s `_qid()`/`fn.name` never recorded which CLASS a method
105
+ // belongs to at all (no middle `::ClassName::` segment, unlike Java's/JS's
106
+ // convention — see `../dataflow/CLAUDE.md`'s C# row and
107
+ // `bench/sard/IMPLEMENTATION_STATUS.md`'s "real accuracy improvement pass"
108
+ // section for the full account). That makes `callgraph.js`'s `classMethods`
109
+ // index — which resolves `new Helper().BadSink(x)` / `Helper.BadSink(x)` —
110
+ // PERMANENTLY EMPTY for C#, so no cross-class call (same file or not) could
111
+ // ever resolve, blocking interprocedural taint for exactly the shape
112
+ // Juliet's own interface-based helper-class test structure (CWE-90/643/79-
113
+ // 83/113/78/etc.'s dominant remaining flow-variant pattern) uses.
114
+ //
115
+ // Finds every class/struct declaration's body range so a method's enclosing
116
+ // class can be looked up by character offset. A method not inside any class
117
+ // (a rare but legal C# top-level-statements shape) gets `className: null`.
118
+ // Nested classes are handled by picking the SMALLEST (innermost) range that
119
+ // contains the method — ranges are not required to be non-overlapping.
120
+ function _findClassRanges(code) {
121
+ const ranges = [];
122
+ CLASS_RE.lastIndex = 0;
123
+ let m;
124
+ while ((m = CLASS_RE.exec(code)) !== null) {
125
+ const name = m[1];
126
+ // Skip past an optional generic parameter list and/or base/interface
127
+ // list to find the REAL opening brace — `class Foo<T> : Base<T>, IBar`
128
+ // has two `<...>` regions before the body even starts. Track angle-
129
+ // bracket depth; balanced-`<>` is a reasonable approximation here (C#
130
+ // generics don't nest with unbalanced `<`/`>` in valid code), and a
131
+ // `{` encountered while depth > 0 (an unlikely default-value-in-
132
+ // generic-constraint edge case) is simply skipped rather than
133
+ // mis-treated as the class body.
134
+ let i = m.index + m[0].length;
135
+ let angleDepth = 0;
136
+ while (i < code.length) {
137
+ const c = code[i];
138
+ if (c === '<') angleDepth++;
139
+ else if (c === '>') { if (angleDepth > 0) angleDepth--; }
140
+ else if (c === '{' && angleDepth === 0) break;
141
+ else if ((c === ';') && angleDepth === 0) { i = -1; break; } // forward-declaration-shaped or malformed; bail
142
+ i++;
143
+ }
144
+ if (i < 0 || i >= code.length || code[i] !== '{') continue;
145
+ const extracted = _extractBody(code, i);
146
+ if (!extracted) continue;
147
+ ranges.push({ name, start: i, end: extracted.end });
148
+ CLASS_RE.lastIndex = i + 1; // resume scanning INSIDE the class body too, for nested classes
149
+ }
150
+ return ranges;
151
+ }
152
+
153
+ // Innermost class whose range contains `pos` (a method declaration's match
154
+ // offset), or null when the method sits outside any class (top-level
155
+ // statements — legal but rare).
156
+ function _enclosingClassName(ranges, pos) {
157
+ let best = null;
158
+ for (const r of ranges) {
159
+ if (pos >= r.start && pos < r.end) {
160
+ if (!best || (r.end - r.start) < (best.end - best.start)) best = r;
161
+ }
162
+ }
163
+ return best ? best.name : null;
164
+ }
165
+
166
+ // Companion to the class-tracking fix above, closing the OTHER half of the
167
+ // same gap: `HelperB h = new HelperB(); h.BadSink(data);` — Juliet's other
168
+ // dominant helper-class idiom, alongside the inline `new Helper().Sink(x)`
169
+ // chain `classMethods` now resolves directly. `h.BadSink` is a callee
170
+ // string built from the LOCAL VARIABLE name, which `classMethods` (keyed
171
+ // by real class name) can never match. This is exact same-function local
172
+ // type inference, not a guess: `h`'s constructed type is unambiguous
173
+ // wherever `h = new HelperB()` appears, so a call written as `h.BadSink(x)`
174
+ // is rewritten in place to `HelperB.BadSink` — the callee string
175
+ // `classMethods` already knows how to resolve (and, since `classMethods`
176
+ // is a PROJECT-WIDE index built across every file, this also transparently
177
+ // covers Juliet's documented cross-FILE `_NNa`/`_NNb` paired-variant
178
+ // convention when the helper class lives in a sibling file, with no
179
+ // separate cross-file mechanism needed). Refuses to guess when a variable
180
+ // is (re)assigned to more than one distinct constructed type in the same
181
+ // function — ambiguous, same "refuse rather than fabricate an edge"
182
+ // convention `bareTailInFile` already uses.
183
+ function _localVarConstructedTypes(nodes) {
184
+ const varTypes = new Map(); // varName -> className | null (ambiguous)
185
+ for (const node of Object.values(nodes)) {
186
+ if (node.kind !== 'assign') continue;
187
+ const src = node.source;
188
+ if (!src || src.kind !== 'call' || !src.isNew || typeof src.callee !== 'string') continue;
189
+ const varName = node.target;
190
+ if (!varName || varName.includes('.')) continue; // only a bare local, never a member-write target
191
+ if (varTypes.has(varName)) {
192
+ if (varTypes.get(varName) !== src.callee) varTypes.set(varName, null);
193
+ } else {
194
+ varTypes.set(varName, src.callee);
195
+ }
196
+ }
197
+ return varTypes;
198
+ }
199
+
200
+ // Recursively rewrites `varName.method`-shaped call callees to
201
+ // `ClassName.method` wherever `varName` has an unambiguous constructed type
202
+ // in `varTypes`, walking into every expression-tree shape `_lowerExpr` can
203
+ // produce (a tainted argument to a rewritten call is just as real a finding
204
+ // as the call itself, so args/branches must be walked too, not just the
205
+ // node's own top-level callee).
206
+ function _rewriteVarTypeCallees(expr, varTypes) {
207
+ if (!expr || typeof expr !== 'object') return;
208
+ if (expr.kind === 'call') {
209
+ if (typeof expr.callee === 'string') {
210
+ const dot = expr.callee.indexOf('.');
211
+ if (dot > 0) {
212
+ const varName = expr.callee.slice(0, dot);
213
+ const cls = varTypes.get(varName);
214
+ if (cls) expr.callee = `${cls}${expr.callee.slice(dot)}`;
215
+ }
216
+ }
217
+ if (Array.isArray(expr.args)) for (const a of expr.args) _rewriteVarTypeCallees(a, varTypes);
218
+ return;
219
+ }
220
+ if (expr.kind === 'member') { _rewriteVarTypeCallees(expr.object, varTypes); return; }
221
+ if (expr.kind === 'binary' || expr.kind === 'logical') {
222
+ _rewriteVarTypeCallees(expr.left, varTypes); _rewriteVarTypeCallees(expr.right, varTypes); return;
223
+ }
224
+ if (expr.kind === 'tpl' && Array.isArray(expr.parts)) { for (const p of expr.parts) _rewriteVarTypeCallees(p, varTypes); return; }
225
+ if (expr.kind === 'union' && Array.isArray(expr.branches)) { for (const b of expr.branches) _rewriteVarTypeCallees(b, varTypes); return; }
226
+ if (expr.kind === 'array' && Array.isArray(expr.elements)) { for (const e of expr.elements) _rewriteVarTypeCallees(e, varTypes); return; }
227
+ }
228
+
229
+ function _applyVarTypeRewrite(nodes) {
230
+ const varTypes = _localVarConstructedTypes(nodes);
231
+ let any = false;
232
+ for (const t of varTypes.values()) if (t) { any = true; break; }
233
+ if (!any) return;
234
+ for (const node of Object.values(nodes)) {
235
+ if (node.kind === 'call') _rewriteVarTypeCallees(node, varTypes);
236
+ else if (node.kind === 'assign') _rewriteVarTypeCallees(node.source, varTypes);
237
+ else if (node.kind === 'return' || node.kind === 'throw') _rewriteVarTypeCallees(node.value, varTypes);
238
+ else if (node.kind === 'if') _rewriteVarTypeCallees(node.cond, varTypes);
239
+ }
240
+ }
241
+
71
242
  // Matches a top-level statement inside a method body. Splits on `;` at
72
243
  // brace-depth 0 (keeping simple lambdas inside calls intact), AND — R8 —
73
244
  // also flushes on a `}` that returns the SAME shared depth counter to 0.
@@ -460,6 +631,28 @@ function _lowerStmt(stmt, line) {
460
631
  const chained = _followChain(s, cm.endIdx, cm.callee, _splitTopLevelCommas(cm.argsText).map(_lowerExpr), false);
461
632
  return { kind: 'call', line, callee: chained.callee, args: chained.args };
462
633
  }
634
+ // Statement-form constructor-chained call: `new Helper().BadSink(data);`
635
+ // — the inline-instantiate-and-call idiom, with NO assignment at all.
636
+ // Previously fell through to `unknown` entirely (the statement-form
637
+ // branch above requires the FIRST token to be a plain identifier, never
638
+ // `new`) — silently dropping the call, its arguments, and any taint
639
+ // reaching a sink inside the constructed object's method. Mirrors the
640
+ // identical `new`-chain handling `_lowerExpr`'s expression path already
641
+ // has (this statement-form path is otherwise a strict subset of that
642
+ // one), and inherits `classMethods`'s cross-class resolution for free
643
+ // once the chain's callee dot-joins to `ClassName.method`.
644
+ const nm = matchBalancedCall(s, /^new\s+([\w.]+)/);
645
+ if (nm) {
646
+ const ctorArgs = _splitTopLevelCommas(nm.argsText).map(_lowerExpr);
647
+ const chained = _followChain(s, nm.endIdx, nm.callee.split('.').pop(), ctorArgs, true);
648
+ // A trailing `.Method(...)` grows `chained.callee` past the bare
649
+ // constructor name (into `ClassName.Method`) — only THAT shape is a
650
+ // call-kind statement worth reporting. A bare `new Helper();` with no
651
+ // chained call has no observable side effect from this statement's own
652
+ // perspective; fall through to `unknown`, matching this file's
653
+ // existing "nothing to lower" convention for a no-op statement.
654
+ if (chained.callee.includes('.')) return { kind: 'call', line, callee: chained.callee, args: chained.args };
655
+ }
463
656
  return { kind: 'unknown', line, text: s };
464
657
  }
465
658
 
@@ -492,9 +685,15 @@ function _lineAt(src, idx) {
492
685
  return line;
493
686
  }
494
687
 
495
- function _qid(file, name, line, body) {
688
+ function _qid(file, name, line, body, className) {
496
689
  const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
497
- return `${file}::${name}@${line}#${sha}`;
690
+ // `::ClassName::` middle segment — the SAME convention parser-java.js and
691
+ // parser-js.js already use (see class-hierarchy.js's "Shape 2" and
692
+ // callgraph.js's `classMethods` index, both of which parse exactly this
693
+ // shape back out). Omitted (falls back to the pre-existing two-segment
694
+ // form) when the method sits outside any class — legal C# top-level
695
+ // statements — so that shape is unaffected.
696
+ return className ? `${file}::${className}::${name}@${line}#${sha}` : `${file}::${name}@${line}#${sha}`;
498
697
  }
499
698
 
500
699
  // Node-id counter for `_buildCfg`. Reset to 0 per function (see
@@ -679,10 +878,12 @@ function _buildCfg(bodyText, nodes, prevId, funcStartLine, lineStarts, baseAbs,
679
878
  export function parseCSharpFile(file, code) {
680
879
  if (!file || typeof code !== 'string') return null;
681
880
  const functions = [];
881
+ const classRanges = _findClassRanges(code);
682
882
  METHOD_RE.lastIndex = 0;
683
883
  let m;
684
884
  while ((m = METHOD_RE.exec(code)) !== null) {
685
885
  const name = m[2];
886
+ const className = _enclosingClassName(classRanges, m.index);
686
887
  const paramsText = m[3] || '';
687
888
  const paramAnnotations = [];
688
889
  // `keptIdx` tracks the parameter's position in the FILTERED array — the
@@ -771,10 +972,11 @@ export function parseCSharpFile(file, code) {
771
972
  const tail = _buildCfg(extracted.body, nodes, 'entry', bodyStartLine, lineStarts, 0, 0);
772
973
  nodes[tail].succ.push('exit');
773
974
  nodes.exit.pred.push(tail);
975
+ _applyVarTypeRewrite(nodes);
774
976
  const cfg = { entry: 'entry', exit: 'exit', nodes };
775
977
  functions.push({
776
- qid: _qid(file, name, startLine, extracted.body),
777
- name, line: startLine, params, file,
978
+ qid: _qid(file, name, startLine, extracted.body, className),
979
+ name: className ? `${className}.${name}` : name, line: startLine, params, file,
778
980
  cfg,
779
981
  calls: callSitesFromCfg(cfg),
780
982
  ...(paramAnnotations.length ? { paramAnnotations } : {}),
@@ -192,15 +192,37 @@ function exprFromCst(node) {
192
192
  if (node.children.Identifier) return { kind: 'ident', name: node.children.Identifier[0].image };
193
193
  // Binary expression
194
194
  if (node.children.BinaryOperator || node.children.binaryExpression) {
195
- // Best-effort: take the two operands.
195
+ // Found via the independent-holdout fixture microledger (SARD_80_F1_
196
+ // SCANNER_PRD.md P1 item 6): this branch hardcoded `op: '?'` for EVERY
197
+ // binary expression, regardless of the real operator, and only ever
198
+ // took the first two operands — chevrotain's `binaryExpression`
199
+ // production for a chain like `"a" + query + "b"` is FLAT (one node
200
+ // listing all 3 operands and both `+` tokens), not a nested binary
201
+ // tree, so a 3+-operand chain silently dropped every operand past the
202
+ // second AND mislabeled the operator on top of that.
203
+ //
204
+ // Neither defect was independently harmless: a taint value sitting in
205
+ // the THIRD (or later) operand of a concatenation was invisible to the
206
+ // taint walker regardless of the operator bug, and a real corpus
207
+ // fixture with exactly this shape (`"<div>..." + query + "</div>"`,
208
+ // one `sink(a + tainted + b)` call) lost its finding entirely — not
209
+ // merely mislabeled. `node.children.BinaryOperator` is chevrotain's own
210
+ // token array for this production; `.image` is the literal operator
211
+ // text (confirmed via direct CST inspection this session — `+`, not a
212
+ // synthesized value). Left-folds all operands with their real
213
+ // operators, matching left-to-right evaluation order; the `|| '+'`
214
+ // fallback only matters if a future java-parser version ever changes
215
+ // this shape so operator/operand counts disagree, and `+` is the
216
+ // overwhelmingly dominant real-world case for this production
217
+ // (string concatenation), so it is the safer of two bad guesses.
196
218
  const kids = node.children.unaryExpression || node.children.expression || [];
219
+ const ops = (node.children.BinaryOperator || []).map(t => t.image);
197
220
  if (kids.length >= 2) {
198
- return {
199
- kind: 'binary',
200
- op: '?',
201
- left: exprFromCst(kids[0]),
202
- right: exprFromCst(kids[1]),
203
- };
221
+ let result = exprFromCst(kids[0]);
222
+ for (let i = 1; i < kids.length; i++) {
223
+ result = { kind: 'binary', op: ops[i - 1] || '+', left: result, right: exprFromCst(kids[i]) };
224
+ }
225
+ return result;
204
226
  }
205
227
  }
206
228
  // Fall through: recurse the first child
@@ -62,6 +62,15 @@ function patternsFor(lang) {
62
62
  { key: 'roslyn', re: new RegExp(String.raw`\bCSharpScript\s*\.\s*(?:EvaluateAsync|RunAsync|Create)\s*\(\s*${notLiteral}`, 'g') },
63
63
  { key: 'datatable-compute', re: new RegExp(String.raw`\.\s*Compute\s*\(\s*${notLiteral}`, 'g'),
64
64
  gate: /\b(?:DataTable|DataColumn|DataView)\b/ },
65
+ // System.CodeDom.Compiler's CSharpCodeProvider/VBCodeProvider — the
66
+ // historically standard .NET dynamic-compile-and-run API, predating
67
+ // Roslyn's CSharpScript by over a decade and still the one most
68
+ // widely documented and used. Canonical shape is
69
+ // `provider.CompileAssemblyFromSource(parameters, source)` — the
70
+ // tainted source is the SECOND argument, not the first, unlike every
71
+ // other sink in this file. A literal source string doesn't match.
72
+ { key: 'codedom-compile', re: new RegExp(String.raw`\.\s*CompileAssemblyFromSource\s*\(\s*[^,()]+\s*,\s*${notLiteral}`, 'g'),
73
+ gate: /\b(?:CSharpCodeProvider|VBCodeProvider|CodeDomProvider)\b/ },
65
74
  ];
66
75
  }
67
76
  if (lang === 'js') {
@@ -23,9 +23,18 @@ import { blankComments } from './_comment-strip.js';
23
23
  // etc.) so we don't fire on every `"foo=" + bar` concatenation in unrelated
24
24
  // code.
25
25
 
26
- // LDAP filter attributes that strongly imply an LDAP filter (not a generic
27
- // key=value string). Shared across all language patterns.
28
- const ATTR = '(?:uid|cn|mail|sAMAccountName|givenName|sn|memberOf|userPrincipalName|distinguishedName|ou)';
26
+ // LDAP filter attribute name. Real LDAP/AD schemas define far more
27
+ // attributes than any fixed enum can enumerate (custom schema extensions,
28
+ // and standard-but-uncommon ones like `department`/`title`/`employeeID`/
29
+ // `homeDirectory`/`description`) — a hardcoded whitelist here is a real,
30
+ // general precision-vs-recall bug, not just narrow for one benchmark.
31
+ // Matches conventional LDAP attribute-name shape (alnum + hyphen, the RFC
32
+ // 4512 `descr` production, simplified). Path A (inline concat, ungated) and
33
+ // Path B (variable-form) both already anchor on a `(<attr>=` filter-syntax
34
+ // shape plus, for Path B, a file-level LDAP-API hint (`LDAP_HINT_RE`) — the
35
+ // widened ATTR only loosens which attribute NAME is accepted, not whether
36
+ // the surrounding shape looks like an LDAP filter at all.
37
+ const ATTR = '[A-Za-z][\\w-]{0,40}';
29
38
 
30
39
  // Path A — concatenation/interpolation INSIDE (or adjacent to) the sink call.
31
40
  // High-confidence; does not need the file-level context hint.