@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.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 (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/1336.index.js +16010 -0
  3. package/dist/agentic-security.mjs +80 -80
  4. package/dist/agentic-security.mjs.sha256 +1 -1
  5. package/package.json +7 -6
  6. package/src/dataflow/CLAUDE.md +1 -1
  7. package/src/dataflow/backward.js +104 -7
  8. package/src/dataflow/catalog.js +802 -13
  9. package/src/dataflow/cross-service-taint.js +154 -11
  10. package/src/dataflow/engine.js +875 -90
  11. package/src/dataflow/exploit-prover.js +111 -23
  12. package/src/dataflow/incremental.js +13 -0
  13. package/src/dataflow/index.js +29 -6
  14. package/src/dataflow/path-feasibility.js +86 -9
  15. package/src/dataflow/string-domain.js +168 -0
  16. package/src/dataflow/summaries.js +69 -8
  17. package/src/engine.js +118 -9
  18. package/src/ir/CLAUDE.md +2 -1
  19. package/src/ir/balanced-call.js +33 -1
  20. package/src/ir/callgraph.js +26 -2
  21. package/src/ir/class-hierarchy.js +261 -7
  22. package/src/ir/csharp-ir.js +97 -8
  23. package/src/ir/index.js +21 -1
  24. package/src/ir/parser-cs.js +706 -24
  25. package/src/ir/parser-java.js +498 -17
  26. package/src/ir/parser-js.js +22 -0
  27. package/src/ir/parser-php.js +446 -43
  28. package/src/ir/parser-rust.js +1338 -0
  29. package/src/ir/php-include-merge.js +190 -0
  30. package/src/lineage/sink-registry.js +18 -0
  31. package/src/lineage/source-registry.js +23 -0
  32. package/src/posture/csharp-analysis.js +341 -43
  33. package/src/posture/finding-defaults.js +5 -1
  34. package/src/posture/fix-coverage.js +1 -0
  35. package/src/posture/proof-coverage.js +1 -0
  36. package/src/report/index.js +12 -0
  37. package/src/sast/CLAUDE.md +1 -1
  38. package/src/sast/authz.js +144 -1
  39. package/src/sast/crypto-protocol.js +65 -7
  40. package/src/sast/csharp-cross-file.js +125 -0
  41. package/src/sast/csharp-structural.js +78 -2
  42. package/src/sast/csharp.js +574 -42
  43. package/src/sast/java-ast-folding.js +268 -11
  44. package/src/sast/java-bench-extras.js +530 -2
  45. package/src/sast/java-deserialization.js +31 -2
  46. package/src/sast/java-structural-cross-file.js +146 -0
  47. package/src/sast/java-structural.js +499 -6
  48. package/src/sast/ldap-injection.js +133 -3
  49. package/src/sast/rust.js +57 -0
  50. package/src/sast/xss-reflected-multilang.js +252 -3
  51. package/src/dataflow/smt-feasibility.js +0 -189
@@ -10,7 +10,16 @@
10
10
  // are stable enough (Request.Query / Request.Form / Request.Headers /
11
11
  // HttpContext.Request.* / IFormCollection / BinaryReader / etc.) that a
12
12
  // simple forward-pass catches the vast majority. Misses on:
13
- // - Aliased sources via method indirection (caller-supplied taint)
13
+ // - Aliased sources via method indirection (caller-supplied taint) —
14
+ // SARD_80_F1 W4.C41 closed the SAME-FILE, SINGLE-HOP case (a value
15
+ // tainted in a caller passed as an argument to a private/same-class
16
+ // helper — Juliet's own "data passed as an argument from one method
17
+ // to another in the same class" flow-variant idiom): see
18
+ // `analyzeCSharpIR`'s post-pass below. Still deliberately NOT
19
+ // modeled: a second hop (the helper passes it on to a THIRD method)
20
+ // and any cross-FILE call, both out of scope for this lightweight
21
+ // analyzer (the real, general interprocedural case is what the deep
22
+ // taint engine in `dataflow/` exists for).
14
23
  // - Inheritance-resolved property reads
15
24
  // - Generic constraints
16
25
  // The Layer 4 LLM validator stage covers the residue when enabled.
@@ -101,6 +110,53 @@ function isSanitizedExpr(text) {
101
110
  return false;
102
111
  }
103
112
 
113
+ // SARD_80_F1 W3.x — `TAINT_SOURCE_PATTERNS`'s `StreamReader.ReadLine` /
114
+ // `BinaryReader.ReadString` entries require the LITERAL type name as the
115
+ // receiver, which real code never writes (`StreamReader.ReadLine()` isn't
116
+ // even a static method) — idiomatic C# always calls through an INSTANCE
117
+ // variable (`using (StreamReader sr = …) { sr.ReadLine() }`), so those
118
+ // patterns were effectively dead against this dominant shape. Exposed (not
119
+ // created) by the `resolveCalleeReturnTaint` precision fix above: the old
120
+ // identifier-name fallback used to accidentally re-taint the result via an
121
+ // unrelated already-tainted variable merely appearing in the call text
122
+ // (the exact over-tainting bug this fix removes), which happened to produce
123
+ // the right verdict here for the wrong reason. Mirrors
124
+ // `HTTP_TAINTED_PARAM_TYPES`'s type-based approach: a call through a
125
+ // variable whose DECLARED type is a reader class is a source regardless of
126
+ // the variable's name.
127
+ const READER_SOURCE_TYPES = /^(?:StreamReader|BinaryReader|TextReader)$/;
128
+ const READER_SOURCE_CALL_RE = /\b([A-Za-z_]\w*)\s*\.\s*(?:ReadLine|ReadToEnd|ReadString|ReadAllText|ReadAllLines)\s*\(/g;
129
+ // SARD_80_F1 W4.C34 — a second reader-shaped type/method pair, same class of
130
+ // gap as the StreamReader one above: `SqlDataReader dr = command.ExecuteReader();
131
+ // data = dr.GetString(1);` (Juliet's own idiomatic ADO.NET shape, confirmed
132
+ // via the public C# mirror, `CWE23_Relative_Path_Traversal__Database_01.cs`)
133
+ // is a real database-row read every bit as untrusted as a file/stream read,
134
+ // but `dataflow/catalog.js`'s OWN separate `cs-datareader-*` source entries
135
+ // (a completely different detector, the deep taint engine, not this file's
136
+ // lexical type-flow) require the LITERAL variable-name substring "reader" —
137
+ // investigated and found genuinely inert there too (W4.C32/W4.C33: giving
138
+ // THAT engine receiver-type confirmation is real but doesn't help here,
139
+ // since `classOfVar`'s CHA only resolves `new Foo()` allocations, never a
140
+ // factory-method return like `ExecuteReader()` — a separate, deeper
141
+ // limitation, not this file's concern). THIS detector already has exactly
142
+ // the right mechanism (declared-type lookup via `typeMap`, independent of
143
+ // variable name) — it just never had a DataReader entry.
144
+ const DATAREADER_SOURCE_TYPES = /^(?:Sql|OleDb|Odbc|MySql|Npgsql|Sqlite)DataReader$/;
145
+ const DATAREADER_SOURCE_CALL_RE = /\b([A-Za-z_]\w*)\s*\.\s*(?:GetString|GetValue|GetInt16|GetInt32|GetInt64|GetDateTime|GetDecimal|GetDouble|GetFloat|GetBoolean|GetGuid|GetChar|GetByte)\s*\(/g;
146
+ function _typedReaderSourceCall(text, typeMap) {
147
+ if (!text) return false;
148
+ READER_SOURCE_CALL_RE.lastIndex = 0;
149
+ let m;
150
+ while ((m = READER_SOURCE_CALL_RE.exec(text))) {
151
+ if (READER_SOURCE_TYPES.test(typeMap.get(m[1]) || '')) return true;
152
+ }
153
+ DATAREADER_SOURCE_CALL_RE.lastIndex = 0;
154
+ while ((m = DATAREADER_SOURCE_CALL_RE.exec(text))) {
155
+ if (DATAREADER_SOURCE_TYPES.test(typeMap.get(m[1]) || '')) return true;
156
+ }
157
+ return false;
158
+ }
159
+
104
160
  // For each SANITIZER_PATTERNS match in `text`, find the nearest `(...)` call
105
161
  // span after the match and return its [start, end) bounds. Used to tell
106
162
  // "the sanitizer call actually wraps the tainted value" (HtmlEncode(x)) apart
@@ -154,6 +210,43 @@ function _refEscapesSanitizers(text, ref, spans) {
154
210
  // HttpRequest, the data inside it is by definition user-controlled.
155
211
  const HTTP_TAINTED_PARAM_TYPES = /^(?:HttpRequest(?:Base|Message)?|HttpListenerRequest|HttpResponseBase|HttpResponse|HttpResponseMessage|HttpContext(?:Base)?|IPrincipal|HttpListenerContext|HttpServletRequest|HttpServletResponse|IFormCollection|IFormFile|IFormFileCollection|Stream|StreamReader|BinaryReader|TextReader|HttpListener)$/;
156
212
 
213
+ // SARD_80_F1 W3.x — is `text` (the ENTIRE trimmed expression, not merely a
214
+ // substring) exactly one call `Name(args…)`, with nothing before the name
215
+ // (besides an optional `this.`) and nothing after the matching close-paren?
216
+ // Used to decide when a call-shaped RHS/return can be resolved against its
217
+ // OWN callee's return-taint verdict instead of the identifier-name fallback
218
+ // below (which cannot distinguish "the callee's return derives from this
219
+ // argument" from "this argument's tainted NAME merely appears in the call
220
+ // text", the latter being exactly W4.J17's documented `argIsTainted`
221
+ // over-tainting bug: `data = GoodG2BSource(req, resp);` taints `data`
222
+ // purely because `req`/`resp` are unconditionally-tainted HTTP-typed
223
+ // params, even though `GoodG2BSource` never reads them).
224
+ function _asBareCall(text) {
225
+ if (!text) return null;
226
+ const t = text.trim();
227
+ const m = t.match(/^(?:this\s*\.\s*)?([A-Za-z_]\w*)\s*\(/);
228
+ if (!m) return null;
229
+ const openIdx = t.indexOf('(', m[0].length - 1);
230
+ let depth = 0;
231
+ for (let i = openIdx; i < t.length; i++) {
232
+ if (t[i] === '(') depth++;
233
+ else if (t[i] === ')') {
234
+ depth--;
235
+ if (depth === 0) return i === t.length - 1 ? m[1] : null;
236
+ }
237
+ }
238
+ return null;
239
+ }
240
+
241
+ // The pre-existing "any tainted identifier's name appears in the text"
242
+ // fallback, factored out so both the propagation loop and the return-taint
243
+ // computation below share one definition.
244
+ function _identRefTainted(text, taintMap) {
245
+ const refs = (text.match(/\b[A-Za-z_]\w*\b/g) || []);
246
+ for (const ref of refs) if (taintMap.get(ref)) return true;
247
+ return false;
248
+ }
249
+
157
250
  function analyzeMethodFlow(method, opts = {}) {
158
251
  const typeMap = new Map();
159
252
  const taintMap = new Map();
@@ -167,62 +260,109 @@ function analyzeMethodFlow(method, opts = {}) {
167
260
  // ADDITIONALLY: any parameter whose TYPE is an HTTP context type
168
261
  // (HttpRequest, HttpResponse, IFormCollection, …) is tainted regardless
169
262
  // of opts — the data IN those types is by definition user-controlled.
263
+ // `opts.taintedParamNames` (SARD_80_F1 W4.C41, optional Set<string>) is
264
+ // the SAME-FILE, single-hop caller-argument-taint case referenced above:
265
+ // `analyzeCSharpIR`'s post-pass computes, from an ALREADY-computed
266
+ // caller flow, which of THIS method's own parameter names were passed a
267
+ // tainted argument at some real call site, and re-invokes this function
268
+ // with that set so the normal forward pass below propagates it through
269
+ // this method's body exactly as it would a route-handler-tainted param.
170
270
  const paramsTainted = !!opts.treatParamsAsTainted;
271
+ const taintedParamNames = opts.taintedParamNames || null;
171
272
  for (const p of method.params || []) {
172
273
  typeMap.set(p.name, p.type);
173
274
  const typeBase = String(p.type || '').replace(/\?$/, '').replace(/<.*$/, '');
174
275
  const isHttpTaintedType = HTTP_TAINTED_PARAM_TYPES.test(typeBase);
175
- if (paramsTainted || isHttpTaintedType) {
276
+ const isCallerArgTainted = !!(taintedParamNames && taintedParamNames.has(p.name));
277
+ if (paramsTainted || isHttpTaintedType || isCallerArgTainted) {
176
278
  taintMap.set(p.name, true);
177
279
  sourceLines.set(p.name, method.line);
178
280
  }
179
281
  }
180
282
 
181
- // Forward pass through decls. Method.decls is already in source order.
182
- for (const d of method.decls || []) {
183
- if (d.type && d.type !== 'var') typeMap.set(d.name, d.type);
184
- else if (d.isVar && d.rhsText) {
185
- // Best-effort type inference for `var x = new T(...)`.
186
- const m = d.rhsText.match(/^\s*new\s+([\w.<>?\[\],\s]+?)\s*\(/);
187
- if (m) typeMap.set(d.name, m[1].trim());
188
- }
189
- if (d.rhsText) {
190
- if (isSourceExpr(d.rhsText) && !isSanitizedExpr(d.rhsText)) {
191
- taintMap.set(d.name, true);
192
- sourceLines.set(d.name, d.line);
193
- continue;
194
- }
195
- // Propagation: rhs references a tainted var → lhs becomes tainted.
196
- const refs = (d.rhsText.match(/\b[A-Za-z_]\w*\b/g) || []);
197
- for (const ref of refs) {
198
- if (taintMap.get(ref)) {
199
- taintMap.set(d.name, true);
200
- sourceLines.set(d.name, d.line);
201
- break;
202
- }
283
+ // Single forward pass, in TRUE source-line order, over decls AND
284
+ // assignments merged together.
285
+ //
286
+ // Previously this ran as two SEPARATE forward passes — all of
287
+ // method.decls, then all of method.assignments — each individually in
288
+ // source order, but with no ordering between the two lists. Juliet's own
289
+ // universal C# convention (confirmed via the public Juliet C# mirror this
290
+ // project's SARD manifest pins) declares a source variable bare, then
291
+ // assigns it on the next line: `string data; data = Environment.
292
+ // GetEnvironmentVariable("ADD");` — a plain decl (no rhsText, doesn't
293
+ // taint) followed by a SEPARATE ASSIGNMENT (which does). Any LATER
294
+ // declare-with-initializer that reads that variable (`string[] tokens =
295
+ // data.Split(...);`) is a decl, processed in the FIRST loop — before the
296
+ // assignment that actually taints `data` had run in the SECOND loop — so
297
+ // the propagation check saw `data` as not-yet-tainted and the dependent
298
+ // variable silently stayed clean, regardless of true source order.
299
+ // Reproduced down to a minimal 100%-failing case (SARD_80_F1 W4.C8) and
300
+ // confirmed real (not Juliet-specific): any C# code that separately
301
+ // assigns a source, then derives a value from it in a later declared
302
+ // variable, hits this. Fixing it can only ADD correctly-propagated taint
303
+ // (this model has no un-taint step — a value that starts tainted stays
304
+ // tainted — so re-ordering never removes a propagation that used to fire).
305
+ const decls = (method.decls || []).map(d => ({ kind: 'decl', line: d.line, item: d }));
306
+ const assigns = (method.assignments || []).map(a => ({ kind: 'assign', line: a.line, item: a }));
307
+ const merged = decls.concat(assigns).sort((x, y) => x.line - y.line);
308
+ for (const { kind, item } of merged) {
309
+ const targetKey = kind === 'decl' ? item.name : item.fullTarget;
310
+ const rhsText = item.rhsText;
311
+ if (kind === 'decl') {
312
+ if (item.type && item.type !== 'var') typeMap.set(item.name, item.type);
313
+ else if (item.isVar && rhsText) {
314
+ // Best-effort type inference for `var x = new T(...)`.
315
+ const m = rhsText.match(/^\s*new\s+([\w.<>?\[\],\s]+?)\s*\(/);
316
+ if (m) typeMap.set(item.name, m[1].trim());
203
317
  }
204
318
  }
205
- }
206
-
207
- // Then assignments — same forward propagation rules.
208
- for (const a of method.assignments || []) {
209
- if (!a.rhsText) continue;
210
- const targetKey = a.fullTarget;
211
- if (isSourceExpr(a.rhsText) && !isSanitizedExpr(a.rhsText)) {
319
+ if (!rhsText) continue;
320
+ if ((isSourceExpr(rhsText) || _typedReaderSourceCall(rhsText, typeMap)) && !isSanitizedExpr(rhsText)) {
212
321
  taintMap.set(targetKey, true);
213
- sourceLines.set(targetKey, a.line);
322
+ sourceLines.set(targetKey, item.line);
214
323
  continue;
215
324
  }
216
- const refs = (a.rhsText.match(/\b[A-Za-z_]\w*\b/g) || []);
217
- for (const ref of refs) {
218
- if (taintMap.get(ref)) {
219
- taintMap.set(targetKey, true);
220
- sourceLines.set(targetKey, a.line);
221
- break;
222
- }
325
+ // Propagation via a resolvable same-file call: consult the callee's OWN
326
+ // return-taint verdict (SARD_80_F1 W3.x) instead of the identifier-name
327
+ // fallback, which cannot tell "the callee's return actually derives
328
+ // from this argument" apart from "this argument's tainted NAME merely
329
+ // appears in the call text" — see `_asBareCall`'s header comment.
330
+ const bareCallName = _asBareCall(rhsText);
331
+ let resolved = null;
332
+ if (bareCallName && opts.resolveCalleeReturnTaint) {
333
+ resolved = opts.resolveCalleeReturnTaint(bareCallName);
334
+ }
335
+ if (resolved !== null) {
336
+ if (resolved) { taintMap.set(targetKey, true); sourceLines.set(targetKey, item.line); }
337
+ continue;
338
+ }
339
+ // Fallback: rhs references a tainted var → lhs becomes tainted. Reached
340
+ // for non-call RHS shapes, and for calls the resolver couldn't resolve
341
+ // (external/library callee, overload ambiguity, or a dependency cycle)
342
+ // — recall-preserving: an unresolved call keeps the old, more liberal
343
+ // behavior rather than silently going quiet.
344
+ if (_identRefTainted(rhsText, taintMap)) {
345
+ taintMap.set(targetKey, true);
346
+ sourceLines.set(targetKey, item.line);
223
347
  }
224
348
  }
225
- return { typeMap, taintMap, sourceLines };
349
+ // Per-return taint verdict, so a CALLER can resolve a call to THIS method
350
+ // via the same mechanism (see `resolveCalleeReturnTaint` in
351
+ // `analyzeCSharpIR`). Same three-way resolution as the propagation loop
352
+ // above: an explicit source wins, then a resolvable nested call, then the
353
+ // identifier-name fallback.
354
+ const returns = (method.returns || []).map((r) => {
355
+ let tainted;
356
+ if ((isSourceExpr(r.exprText) || _typedReaderSourceCall(r.exprText, typeMap)) && !isSanitizedExpr(r.exprText)) {
357
+ tainted = true;
358
+ } else {
359
+ const bareCallName = _asBareCall(r.exprText);
360
+ const resolved = bareCallName && opts.resolveCalleeReturnTaint ? opts.resolveCalleeReturnTaint(bareCallName) : null;
361
+ tainted = resolved !== null ? resolved : _identRefTainted(r.exprText, taintMap);
362
+ }
363
+ return { line: r.line, tainted };
364
+ });
365
+ return { typeMap, taintMap, sourceLines, returns };
226
366
  }
227
367
 
228
368
  // Attribute → route classifier. Each entry maps an attribute name to
@@ -250,7 +390,7 @@ function extractPath(argsRaw, argIdx) {
250
390
  return null;
251
391
  }
252
392
 
253
- export function analyzeCSharpIR(ir) {
393
+ export function analyzeCSharpIR(ir, opts = {}) {
254
394
  // Class-level attribute roll-up.
255
395
  const classAuth = new Map(); // class-ref → { authedAtClass, anonymousAtClass, isController }
256
396
  for (const c of ir.classes) {
@@ -278,6 +418,21 @@ export function analyzeCSharpIR(ir) {
278
418
  const methodFlow = new Map();
279
419
  const methodToClass = new Map();
280
420
  for (const c of ir.classes) for (const m of c.methods) methodToClass.set(m, c);
421
+ const classesByName = new Map();
422
+ for (const c of ir.classes) classesByName.set(c.name, c);
423
+ // SARD_80_F1 W3.x — resolve a same-file call's return-taint verdict
424
+ // instead of letting `analyzeMethodFlow`'s identifier-name fallback treat
425
+ // ANY tainted identifier appearing in the call text (including an
426
+ // argument the callee never reads) as tainting the result. Bare-name
427
+ // keyed (this codebase's call sites don't carry receiver/overload info to
428
+ // disambiguate further); a name with 0 or 2+ candidates is unresolvable
429
+ // (`null`, meaning "fall back to the old heuristic") rather than guessed.
430
+ const methodsByName = new Map();
431
+ for (const m of ir.methods) {
432
+ if (!methodsByName.has(m.name)) methodsByName.set(m.name, []);
433
+ methodsByName.get(m.name).push(m);
434
+ }
435
+ const treatParamsAsTaintedByMethod = new Map();
281
436
  for (const m of ir.methods) {
282
437
  const attrNames = (m.attrs || []).map(x => x.name);
283
438
  const isRouteAttr = attrNames.some(n => ROUTE_ATTRS[n]);
@@ -285,8 +440,151 @@ export function analyzeCSharpIR(ir) {
285
440
  const classIsController = cls ? !!classAuth.get(cls)?.isController : false;
286
441
  const classHasApiAttr = cls && (cls.attrs || []).some(a => a.name === 'ApiController' || a.name === 'Route');
287
442
  const isPublic = !m.modifiers || m.modifiers.includes('public') || (!m.modifiers.includes('private') && !m.modifiers.includes('protected') && !m.modifiers.includes('internal'));
288
- const treatParamsAsTainted = (isRouteAttr || classHasApiAttr || classIsController) && isPublic;
289
- methodFlow.set(m, analyzeMethodFlow(m, { treatParamsAsTainted }));
443
+ treatParamsAsTaintedByMethod.set(m, (isRouteAttr || classHasApiAttr || classIsController) && isPublic);
444
+ }
445
+ const calleeFlowCache = new Map();
446
+ const calleeReturnTaintMemo = new Map();
447
+ const resolvingStack = new Set();
448
+ function computeFlow(m) {
449
+ let flow = calleeFlowCache.get(m);
450
+ if (!flow) {
451
+ flow = analyzeMethodFlow(m, { treatParamsAsTainted: treatParamsAsTaintedByMethod.get(m), resolveCalleeReturnTaint });
452
+ calleeFlowCache.set(m, flow);
453
+ }
454
+ return flow;
455
+ }
456
+ function resolveCalleeReturnTaint(name) {
457
+ if (calleeReturnTaintMemo.has(name)) return calleeReturnTaintMemo.get(name);
458
+ const candidates = methodsByName.get(name);
459
+ if (!candidates || candidates.length !== 1) { calleeReturnTaintMemo.set(name, null); return null; }
460
+ const callee = candidates[0];
461
+ // Cycle guard (self- or mutually-recursive calls): unresolvable for
462
+ // THIS resolution, deliberately not memoized so a later, non-cyclic
463
+ // call to the same name can still resolve normally.
464
+ if (resolvingStack.has(callee)) return null;
465
+ resolvingStack.add(callee);
466
+ const flow = computeFlow(callee);
467
+ resolvingStack.delete(callee);
468
+ const tainted = flow.returns && flow.returns.length ? flow.returns.some(r => r.tainted) : null;
469
+ calleeReturnTaintMemo.set(name, tainted);
470
+ return tainted;
471
+ }
472
+ for (const m of ir.methods) methodFlow.set(m, computeFlow(m));
473
+
474
+ // SARD_80_F1 W4.C41 — same-file, single-hop caller-argument taint.
475
+ // Every detector in csharp.js reads `analysis.methodFlow.get(m)` and
476
+ // that flow's own `taintMap` only ever seeds from THIS method's own
477
+ // params (route-handler convention / HTTP-typed param) — never from
478
+ // what a CALLER actually passed it. Juliet's own "Flow Variant NN: data
479
+ // passed as an argument from one method to another in the same class"
480
+ // idiom (a route handler reads a source, then calls a private static
481
+ // sink-wrapper helper with it) was therefore invisible to every
482
+ // per-method detector here, confirmed via a standalone `runScan`
483
+ // reproduction of the exact real-corpus shape (CWE36's own
484
+ // `Params_Get_Web_41.cs`): the DIRECT single-method form of the same
485
+ // sink already fired correctly; the moment the tainted value crossed a
486
+ // same-file method-call boundary, it vanished with zero findings from
487
+ // any detector, structural or deep-engine.
488
+ //
489
+ // Deliberately conservative, matching this analyzer's own "single
490
+ // forward pass" simplicity rather than a full fixed point: (1) SINGLE
491
+ // HOP only — a callee that itself forwards the value to a THIRD method
492
+ // is not covered (mirrors `resolveCalleeReturnTaint`'s own scope, which
493
+ // is the established precedent for interprocedural resolution in this
494
+ // file); (2) SAME-FILE, unambiguous-name resolution only, via the same
495
+ // `methodsByName` index `resolveCalleeReturnTaint` already uses — a
496
+ // callee name with zero or 2+ same-named candidates project-wide is
497
+ // left unresolved (permissive: the finding is simply not gained, never
498
+ // a false suppression); (3) self-recursive calls are skipped (a
499
+ // parameter's own taint state within its own body is already handled
500
+ // by the normal forward pass, and re-seeding it from itself adds
501
+ // nothing); (4) this recomputes the DIRECT callee's flow only — it does
502
+ // NOT retroactively fix up `resolveCalleeReturnTaint`'s memo for any
503
+ // caller further up a chain that already resolved this callee's
504
+ // pre-this-pass return-taint verdict, an accepted, narrow gap given the
505
+ // single-hop scope above.
506
+ // SARD_80_F1 W5.31 — genuine cross-FILE extension of W5.30's same-file
507
+ // cross-class fix. This function has no visibility beyond its own `ir`
508
+ // (one file), so it cannot itself discover that `Sink.BadSink(data, …)`
509
+ // resolves to a static method declared in a DIFFERENT file — that
510
+ // project-wide resolution now happens one layer up, in
511
+ // `sast/csharp-cross-file.js`, which parses every `.cs` file in the
512
+ // project, finds a caller's tainted argument passed to a receiver-
513
+ // qualified call whose class lives in ANOTHER file, and re-invokes this
514
+ // function on the CALLEE's own file with the result seeded here via
515
+ // `opts.externalTaintedParams` (a `Map<methodObject, Set<paramName>>`,
516
+ // keyed by an object reference into THIS SAME `ir.methods` array — the
517
+ // caller resolves the callee from its own freshly-parsed copy of this
518
+ // exact `ir`, so the reference always matches). Seeding happens BEFORE
519
+ // the same-file loop below runs, and the two never conflict: the
520
+ // same-file loop only ever ADDS to whatever Set is already present for a
521
+ // callee (`if (!taintedParamNamesByMethod.has(callee)) …set(callee, new
522
+ // Set())`), so a param already seeded from across files is simply
523
+ // preserved, and a param seeded here that the same-file loop separately
524
+ // also proves tainted collapses harmlessly into the same Set entry.
525
+ const taintedParamNamesByMethod = new Map();
526
+ if (opts.externalTaintedParams) {
527
+ for (const [m, names] of opts.externalTaintedParams) {
528
+ taintedParamNamesByMethod.set(m, new Set(names));
529
+ }
530
+ }
531
+ for (const caller of ir.methods) {
532
+ const callerFlow = methodFlow.get(caller);
533
+ if (!callerFlow) continue;
534
+ for (const call of caller.calls || []) {
535
+ let candidates;
536
+ if (call.receiver) {
537
+ // SARD_80_F1 W5.30 — a receiver-qualified call (`Sink.BadSink(data,
538
+ // …)`) was unconditionally skipped here, same as any OTHER
539
+ // receiver-bearing call — Juliet's own cross-class "Flow Variant 51:
540
+ // data passed as an argument from one method to another in
541
+ // different classes in the same package" idiom (confirmed via the
542
+ // public C# mirror's own
543
+ // CWE36_..._Params_Get_Web_51a.cs/_51b.cs pair) was therefore
544
+ // invisible here even in the SAME FILE, let alone across files —
545
+ // confirmed by direct `runScan` reproduction showing ZERO findings
546
+ // from any detector for this shape, structural or deep-engine,
547
+ // before this fix. Resolvable ONLY when `call.receiver` is a REAL
548
+ // class declared in THIS file (`classesByName`, never an instance
549
+ // variable this analyzer has no type-tracking for) AND the matched
550
+ // candidate is `static` — the second check is load-bearing, not
551
+ // redundant: C# cannot compile `ClassName.InstanceMethod()` without
552
+ // an object reference, so if the resolved candidate is NOT static,
553
+ // `call.receiver`'s text is far more likely a coincidentally
554
+ // same-named INSTANCE VARIABLE than a genuine static-class
555
+ // qualifier, and treating it as one would silently invent a data-flow
556
+ // edge that doesn't exist — exactly the risk `byQname`'s own header
557
+ // comment in `callgraph.js` warns against for a structurally
558
+ // analogous ambiguity. Deliberately single-hop and same-file only,
559
+ // matching every other interprocedural mechanism in this file —
560
+ // cross-FILE resolution is a separate, larger undertaking (the deep
561
+ // taint engine's own `callgraph.js` already resolves cross-file
562
+ // static calls project-wide; this structural analyzer does not).
563
+ const cls = classesByName.get(call.receiver);
564
+ candidates = cls
565
+ ? (methodsByName.get(call.method) || []).filter(m => methodToClass.get(m) === cls && (m.modifiers || []).includes('static'))
566
+ : null;
567
+ } else {
568
+ candidates = methodsByName.get(call.method);
569
+ }
570
+ if (!candidates || candidates.length !== 1) continue;
571
+ const callee = candidates[0];
572
+ if (callee === caller) continue;
573
+ for (let i = 0; i < (call.args || []).length; i++) {
574
+ const param = (callee.params || [])[i];
575
+ if (!param) continue;
576
+ if (!argIsTainted(callerFlow, call.args[i])) continue;
577
+ if (!taintedParamNamesByMethod.has(callee)) taintedParamNamesByMethod.set(callee, new Set());
578
+ taintedParamNamesByMethod.get(callee).add(param.name);
579
+ }
580
+ }
581
+ }
582
+ for (const [callee, taintedParamNames] of taintedParamNamesByMethod) {
583
+ methodFlow.set(callee, analyzeMethodFlow(callee, {
584
+ treatParamsAsTainted: treatParamsAsTaintedByMethod.get(callee),
585
+ resolveCalleeReturnTaint,
586
+ taintedParamNames,
587
+ }));
290
588
  }
291
589
  // Route detection.
292
590
  const routes = [];
@@ -19,11 +19,14 @@ const _CWE_FAMILY = {
19
19
  'CWE-90': 'ldap-injection',
20
20
  'CWE-91': 'xpath-injection',
21
21
  'CWE-94': 'code-injection',
22
+ 'CWE-95': 'code-injection',
23
+ 'CWE-98': 'code-injection',
22
24
  'CWE-22': 'path-traversal',
23
25
  'CWE-23': 'path-traversal',
24
26
  'CWE-36': 'path-traversal',
25
27
  'CWE-200': 'info-disclosure',
26
28
  'CWE-209': 'info-disclosure',
29
+ 'CWE-134': 'format-string',
27
30
  'CWE-256': 'hardcoded-secret',
28
31
  'CWE-259': 'hardcoded-secret',
29
32
  'CWE-287': 'broken-auth',
@@ -40,6 +43,7 @@ const _CWE_FAMILY = {
40
43
  'CWE-352': 'csrf',
41
44
  'CWE-384': 'session-fixation',
42
45
  'CWE-434': 'unrestricted-upload',
46
+ 'CWE-470': 'code-injection',
43
47
  'CWE-502': 'insecure-deserialization',
44
48
  'CWE-601': 'open-redirect',
45
49
  'CWE-611': 'xxe',
@@ -50,7 +54,7 @@ const _CWE_FAMILY = {
50
54
  'CWE-776': 'xxe',
51
55
  'CWE-798': 'hardcoded-secret',
52
56
  'CWE-829': 'supply-chain',
53
- 'CWE-862': 'broken-authz',
57
+ 'CWE-862': 'missing-authz',
54
58
  'CWE-863': 'broken-authz',
55
59
  'CWE-915': 'mass-assignment',
56
60
  'CWE-918': 'ssrf',
@@ -30,6 +30,7 @@ export const DECLINED_TO_FIX = Object.freeze({
30
30
  'broken-access-control': 'the correct authorisation rule is a product decision — a scanner that invents one is guessing at intent, and a wrong authz patch fails open.',
31
31
  'idor': 'same as broken-access-control: which identity may read which record is not recoverable from the code.',
32
32
  'broken-authz': 'the rule that was checked wrongly is a product decision; patching it from the code alone guesses at which roles may do what, and guessing fails open.',
33
+ 'missing-authz': 'the rule that was checked wrongly is a product decision; patching it from the code alone guesses at which roles may do what, and guessing fails open.',
33
34
  'business-logic': 'by definition the defect is a mismatch with intent, and intent is not in the file.',
34
35
  'concurrency-bug': 'the correct lock discipline depends on the whole call graph; a local patch can deadlock rather than fix.',
35
36
  'license-graph': 'a licence conflict is resolved by a policy or a dependency decision, not by editing code.',
@@ -41,6 +41,7 @@ export const INDETERMINATE_BY_CLASS = Object.freeze({
41
41
  'idor': 'proving it means showing user A read user B\'s record, which needs two authenticated identities and a populated data store. A single-shot harness would invent both, and a PoC built on invented state proves something about the invention.',
42
42
  'broken-access-control': 'same as idor — requires two identities and real state.',
43
43
  'broken-authz': 'same as idor — requires two identities and real state.',
44
+ 'missing-authz': 'same as idor — requires two identities and real state.',
44
45
  'ssrf': 'the proof is that the server fetched an attacker-named host. The sandbox denies egress by design, so a failed fetch is confinement talking, not the finding.',
45
46
  'xss': 'needs a browser to say whether the payload executed; a marker file cannot observe a DOM.',
46
47
  'mutation-xss': 'needs a DOM and a parser round-trip — same limit as xss.',
@@ -228,6 +228,18 @@ export function normalizeFindings(scan){
228
228
  dataClasses: f.dataClasses || [],
229
229
  chain: Array.isArray(f.chain) ? f.chain : null,
230
230
  sourceProvenance: f.sourceProvenance || null,
231
+ // Next-gen taint capability #7 (cross-service schema-driven taint):
232
+ // dataflow/cross-service-taint.js SETS crossService/_severityBumpReason
233
+ // on findings whose source crosses a declared services.yml trust
234
+ // boundary, but this allowlist never named either field — the same
235
+ // "silently dropped field" bug class engine.js's own _funcQid/callee/
236
+ // argIndex comments already document twice for the raw finding
237
+ // object, this time at the report layer. Without this, an operator
238
+ // reading a report saw a bumped severity with no visible reason at
239
+ // all: `severity` itself IS in this allowlist (so the bump survived),
240
+ // but its only explanation was silently discarded.
241
+ crossService: f.crossService || null,
242
+ _severityBumpReason: f._severityBumpReason || null,
231
243
  confidence: typeof f.confidence === 'number' ? f.confidence : null,
232
244
  // R17: corroboration ("one issue, many signals") — count of independent
233
245
  // analyses that agreed, and which ones.
@@ -13,7 +13,7 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
13
13
 
14
14
  ## What lives here, by category
15
15
 
16
- **Language-specific** — `cpp.js`, `csharp.js`, `csharp-structural.js` (regex structural: hardcoded-secret incl. split-concat + guarded SSRF — complements the flow-based `csharp.js`), `dart-flutter.js`, `go-extended.js`, `java-deserialization.js`, `java-structural.js` (regex structural SQLi/cmdi/path/SSRF via concat, with path/SSRF guards — complements the AST/flow Java modules), `kotlin.js` (Kotlin idioms + **taint-independent structural injection** detectors: SQLi/cmdi/path via string template/concat, SSRF (guarded), XXE (insecure XML config), ObjectInputStream deser — closes the corpus Kotlin FNs where a tainted-by-convention param has no in-file source), `php.js` (+ structural SQLi/cmdi via concat/`$`-interp: DB::raw/whereRaw, shell_exec/exec; structural path traversal: readfile/file_get_contents/fopen with concat), `python-sinks.js`, `ruby.js` (+ structural ActiveRecord SQLi via `#{}`/concat, backtick/system cmdi, and File/IO path traversal via interpolation/concat; **plus `scanRubyPathJoin` (PRD F1.3)** — `File.join(<root>, …, <variable>)` reaching a filesystem operation with no traversal guard, which the interpolation rule cannot reach because it requires a string LITERAL as the first component while the real advisories join variables. Refuses a constant root (`__dir__`/`Rails.root`/`Dir.pwd`), a literal last component, and a join that never reaches the filesystem; any containment guard in the window silences it, since that guard is the fix. FP budget measured at 41 findings across 3,782 real `.rb` files in 128 packages), `rust.js`, `solidity.js`, `swift.js`, `xxe.js` (CWE-611 for Java/Python **and** PHP/Go/Ruby — each non-JVM stack is XXE-safe by default, so it flags the explicit external-entity opt-in: PHP `LIBXML_NOENT`/`LIBXML_DTDLOAD`, Go `xml.Decoder` `Strict=false`/custom `Entity`, Ruby Nokogiri `noent`/`dtdload`/`replace_entities`; default-safe parses don't match).
16
+ **Language-specific** — `cpp.js`, `csharp.js`, `csharp-cross-file.js` (SARD_80_F1 W5.31 — project-wide pass, run once after the main per-file loop like `db-taint.js`'s own `scanDbTaintCrossFile`: parses every `.cs` file, builds a project-wide class registry, and resolves a receiver-qualified call whose class lives in a DIFFERENT file by re-running `csharp.js`'s detector suite — via the newly-exported `runCSharpDetectors` — against that file with the parameter seeded as externally tainted via `csharp-analysis.js`'s `analyzeCSharpIR(ir, {externalTaintedParams})` option; only genuinely NEW findings (absent from that file's own ordinary, un-seeded scan) are reported, so nothing is ever double-counted. Single-hop, unambiguous-class/unambiguous-static-method only — the cross-file counterpart of `csharp-analysis.js`'s own same-file cross-class fix), `csharp-structural.js` (regex structural: hardcoded-secret incl. split-concat + guarded SSRF — complements the flow-based `csharp.js`), `dart-flutter.js`, `go-extended.js`, `java-deserialization.js`, `java-structural.js` (regex structural SQLi/cmdi/path/SSRF via concat, with path/SSRF guards — complements the AST/flow Java modules), `kotlin.js` (Kotlin idioms + **taint-independent structural injection** detectors: SQLi/cmdi/path via string template/concat, SSRF (guarded), XXE (insecure XML config), ObjectInputStream deser — closes the corpus Kotlin FNs where a tainted-by-convention param has no in-file source), `php.js` (+ structural SQLi/cmdi via concat/`$`-interp: DB::raw/whereRaw, shell_exec/exec; structural path traversal: readfile/file_get_contents/fopen with concat), `python-sinks.js`, `ruby.js` (+ structural ActiveRecord SQLi via `#{}`/concat, backtick/system cmdi, and File/IO path traversal via interpolation/concat; **plus `scanRubyPathJoin` (PRD F1.3)** — `File.join(<root>, …, <variable>)` reaching a filesystem operation with no traversal guard, which the interpolation rule cannot reach because it requires a string LITERAL as the first component while the real advisories join variables. Refuses a constant root (`__dir__`/`Rails.root`/`Dir.pwd`), a literal last component, and a join that never reaches the filesystem; any containment guard in the window silences it, since that guard is the fix. FP budget measured at 41 findings across 3,782 real `.rb` files in 128 packages), `rust.js` (TLS verification disabled, weak hash/cipher, weak RNG feeding a token/secret identifier, unsafe raw-memory ops (`transmute`/`from_raw_parts`/`get_unchecked`) — complements the flow-based Rust taint coverage in `dataflow/catalog.js`/`ir/parser-rust.js` for SQLi/cmdi/path/SSRF/XSS/log-injection/deserialization, which Rust gets in full since its SARD 80% F1 push addition as a first-class language), `solidity.js`, `swift.js`, `xxe.js` (CWE-611 for Java/Python **and** PHP/Go/Ruby — each non-JVM stack is XXE-safe by default, so it flags the explicit external-entity opt-in: PHP `LIBXML_NOENT`/`LIBXML_DTDLOAD`, Go `xml.Decoder` `Strict=false`/custom `Entity`, Ruby Nokogiri `noent`/`dtdload`/`replace_entities`; default-safe parses don't match).
17
17
 
18
18
  > **Structural-detector pattern (Tier 1 recall).** `kotlin.js`/`ruby.js`/`php.js` carry taint-independent rules: a dangerous sink built with string interpolation/concat is the injection shape regardless of variable names. This closes corpus FNs where a value is routed through a local var (`params[:x]` → `where("…#{x}")`) so the taint engine sees no source. Keep them high-precision: parameterized / array-form / literal variants must NOT match. Verify on the cve-replay `pre/`+`post/` pairs.
19
19