circle-ir 4.9.11 → 4.9.13

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.
@@ -9363,6 +9363,7 @@ function buildCSharpDFG(tree, cache) {
9363
9363
  ];
9364
9364
  for (const method of methods) {
9365
9365
  scopeStack.push(/* @__PURE__ */ new Map());
9366
+ const methodDefStart = defs.length;
9366
9367
  const params = method.childForFieldName("parameters");
9367
9368
  if (params) {
9368
9369
  for (const def of extractParameterDefs(params, defIdCounter)) {
@@ -9392,6 +9393,7 @@ function buildCSharpDFG(tree, cache) {
9392
9393
  }
9393
9394
  }
9394
9395
  const bodyUses = extractUses(body2, useIdCounter, scopeStack, false);
9396
+ resolveReassignedUses(defs.slice(methodDefStart), bodyUses.uses);
9395
9397
  uses.push(...bodyUses.uses);
9396
9398
  useIdCounter = bodyUses.nextId;
9397
9399
  }
@@ -10101,6 +10103,30 @@ function findReachingDef(name2, scopeStack) {
10101
10103
  }
10102
10104
  return null;
10103
10105
  }
10106
+ function resolveReassignedUses(methodDefs, methodUses) {
10107
+ const defsByVar = /* @__PURE__ */ new Map();
10108
+ for (const def of methodDefs) {
10109
+ if (def.kind !== "local" && def.kind !== "param") continue;
10110
+ const existing = defsByVar.get(def.variable);
10111
+ if (existing) existing.push(def);
10112
+ else defsByVar.set(def.variable, [def]);
10113
+ }
10114
+ for (const list of defsByVar.values()) {
10115
+ if (list.length > 1) list.sort((a, b) => a.line - b.line || a.id - b.id);
10116
+ }
10117
+ for (const use of methodUses) {
10118
+ const list = defsByVar.get(use.variable);
10119
+ if (!list || list.length < 2) continue;
10120
+ let strictlyBefore;
10121
+ let sameLine;
10122
+ for (const def of list) {
10123
+ if (def.line < use.line) strictlyBefore = def;
10124
+ else if (def.line === use.line) sameLine = def;
10125
+ }
10126
+ const reaching = strictlyBefore ?? sameLine;
10127
+ if (reaching) use.def_id = reaching.id;
10128
+ }
10129
+ }
10104
10130
  function computeChains(defs, uses) {
10105
10131
  const chains = [];
10106
10132
  const defById = /* @__PURE__ */ new Map();
@@ -15850,7 +15876,7 @@ function isCSharpSsrfHostAllowlistGuarded(call, pattern, language, sourceLines)
15850
15876
  const guards = guardMap.get(cand);
15851
15877
  if (!guards) continue;
15852
15878
  for (const g of guards) {
15853
- if (g.i >= sinkIdx || checked.has(g)) continue;
15879
+ if (g.i > sinkIdx || checked.has(g)) continue;
15854
15880
  checked.add(g);
15855
15881
  const block = csThenBlock(sourceLines, g.i, g.rest);
15856
15882
  if (g.op === "==") {
@@ -18965,7 +18991,7 @@ function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
18965
18991
  function buildTaintFlow(source, sink, taintInfo) {
18966
18992
  const path = [];
18967
18993
  path.push({
18968
- variable: taintInfo.variable,
18994
+ variable: source.variable ?? taintInfo.variable,
18969
18995
  line: source.line,
18970
18996
  type: "source",
18971
18997
  description: `Tainted data enters via ${source.type}`
@@ -27163,6 +27189,7 @@ var LanguageSourcesPass = class {
27163
27189
  additionalSanitizers.push(...findPythonUrlAllowlistRedirectSanitizers(code));
27164
27190
  additionalSanitizers.push(...findPythonDefusedXmlSanitizers(code));
27165
27191
  additionalSanitizers.push(...findPythonJinjaAutoescapeSanitizers(code));
27192
+ additionalSanitizers.push(...findCrlfWrapperFunctionSanitizers(code, "python"));
27166
27193
  const pyMisconfigFindings = findPythonPatternFindings(code, graph.ir.meta.file);
27167
27194
  for (const finding of pyMisconfigFindings) {
27168
27195
  ctx.addFinding(finding);
@@ -27321,6 +27348,11 @@ var LanguageSourcesPass = class {
27321
27348
  ctx.addFinding(finding);
27322
27349
  }
27323
27350
  }
27351
+ if (language === "csharp") {
27352
+ additionalSanitizers.push(
27353
+ ...findCSharpFullPathStartsWithGuardSanitizers(code)
27354
+ );
27355
+ }
27324
27356
  if (language === "java") {
27325
27357
  additionalSanitizers.push(...findJavaSafeJsonParseSanitizers(code));
27326
27358
  additionalSanitizers.push(
@@ -27329,6 +27361,7 @@ var LanguageSourcesPass = class {
27329
27361
  additionalSanitizers.push(...findJavaPathGetFileNameSanitizers(code));
27330
27362
  additionalSanitizers.push(...findJavaCanonicalPathStartsWithGuardSanitizers(code));
27331
27363
  additionalSanitizers.push(...findJavaInlineCrlfStripLogSanitizers(code));
27364
+ additionalSanitizers.push(...findCrlfWrapperFunctionSanitizers(code, "java"));
27332
27365
  additionalSanitizers.push(...findJavaArgvFormExecSanitizers(code));
27333
27366
  for (const finding of findJavaPatternFindings(code, graph.ir.meta.file)) {
27334
27367
  ctx.addFinding(finding);
@@ -29896,7 +29929,7 @@ function findJsPathResolveStartsWithGuardSanitizers(code) {
29896
29929
  let guardLine = -1;
29897
29930
  for (let l = c.line; l < Math.min(lines.length, c.line + 6); l++) {
29898
29931
  if (!guardRe.test(lines[l])) continue;
29899
- if (terminatorRe.test(lines[l]) || l + 1 < lines.length && terminatorRe.test(lines[l + 1])) {
29932
+ if (guardRejects(lines, l, terminatorRe, 2)) {
29900
29933
  guardLine = l + 1;
29901
29934
  break;
29902
29935
  }
@@ -30005,7 +30038,7 @@ function findJavaPathNormalizeStartsWithGuardSanitizers(code) {
30005
30038
  let guardLine = -1;
30006
30039
  for (let l = c.line; l < Math.min(lines.length, c.line + 6); l++) {
30007
30040
  if (!guardRe.test(lines[l])) continue;
30008
- if (terminatorRe.test(lines[l]) || l + 1 < lines.length && terminatorRe.test(lines[l + 1])) {
30041
+ if (guardRejects(lines, l, terminatorRe, 2)) {
30009
30042
  guardLine = l + 1;
30010
30043
  break;
30011
30044
  }
@@ -30039,17 +30072,7 @@ function findJavaCanonicalPathStartsWithGuardSanitizers(code) {
30039
30072
  for (let i2 = 0; i2 < lines.length; i2++) {
30040
30073
  const m = guardRe.exec(lines[i2]);
30041
30074
  if (!m) continue;
30042
- let hasTerminator = terminatorRe.test(lines[i2]);
30043
- if (!hasTerminator) {
30044
- for (let j = i2 + 1; j < Math.min(lines.length, i2 + 6); j++) {
30045
- if (terminatorRe.test(lines[j])) {
30046
- hasTerminator = true;
30047
- break;
30048
- }
30049
- if (lines[j].includes("}")) break;
30050
- }
30051
- }
30052
- if (!hasTerminator) continue;
30075
+ if (!guardRejects(lines, i2, terminatorRe, 6)) continue;
30053
30076
  const guarded = /* @__PURE__ */ new Set([m[1]]);
30054
30077
  const mentions = (t) => {
30055
30078
  for (const n of guarded) if (new RegExp(`\\b${n}\\b`).test(t)) return true;
@@ -30074,6 +30097,58 @@ function findJavaCanonicalPathStartsWithGuardSanitizers(code) {
30074
30097
  }
30075
30098
  return sanitizers;
30076
30099
  }
30100
+ function guardRejects(lines, i2, terminatorRe, window2) {
30101
+ if (terminatorRe.test(lines[i2])) return true;
30102
+ const opens = (lines[i2].match(/\{/g) ?? []).length;
30103
+ const closes = (lines[i2].match(/\}/g) ?? []).length;
30104
+ if (opens > 0 && closes >= opens) return false;
30105
+ for (let j = i2 + 1; j < Math.min(lines.length, i2 + window2); j++) {
30106
+ if (terminatorRe.test(lines[j])) return true;
30107
+ if (lines[j].includes("}")) return false;
30108
+ }
30109
+ return false;
30110
+ }
30111
+ function findCSharpFullPathStartsWithGuardSanitizers(code) {
30112
+ const sanitizers = [];
30113
+ const lines = code.split("\n");
30114
+ const assignRe = /^\s*(?:(?:var|string)\s+)?([A-Za-z_]\w*)\s*=(?!=)\s*(.*)$/;
30115
+ const fullPathRe = /\bPath\s*\.\s*GetFullPath\s*\(/;
30116
+ const canonicalVars = /* @__PURE__ */ new Set();
30117
+ for (const line of lines) {
30118
+ const a = assignRe.exec(line);
30119
+ if (a && fullPathRe.test(a[2])) canonicalVars.add(a[1]);
30120
+ }
30121
+ if (canonicalVars.size === 0) return sanitizers;
30122
+ const guardRe = /\bif\s*\(\s*!\s*([A-Za-z_]\w*)\s*\.\s*StartsWith\s*\(/;
30123
+ const terminatorRe = /\b(throw|return)\b/;
30124
+ for (let i2 = 0; i2 < lines.length; i2++) {
30125
+ const m = guardRe.exec(lines[i2]);
30126
+ if (!m || !canonicalVars.has(m[1])) continue;
30127
+ if (!guardRejects(lines, i2, terminatorRe, 6)) continue;
30128
+ const guarded = /* @__PURE__ */ new Set([m[1]]);
30129
+ const mentions = (t) => {
30130
+ for (const n of guarded) if (new RegExp(`\\b${n}\\b`).test(t)) return true;
30131
+ return false;
30132
+ };
30133
+ for (let l = 0; l < lines.length; l++) {
30134
+ const lt = lines[l];
30135
+ const a = assignRe.exec(lt);
30136
+ if (a) {
30137
+ if (mentions(a[2])) guarded.add(a[1]);
30138
+ else if (a[1] !== m[1]) guarded.delete(a[1]);
30139
+ }
30140
+ if (mentions(lt)) {
30141
+ sanitizers.push({
30142
+ type: "csharp_fullpath_startswith_guard",
30143
+ method: "GetFullPath",
30144
+ line: l + 1,
30145
+ sanitizes: ["path_traversal", "external_taint_escape"]
30146
+ });
30147
+ }
30148
+ }
30149
+ }
30150
+ return sanitizers;
30151
+ }
30077
30152
  function findJavaPathGetFileNameSanitizers(code) {
30078
30153
  const sanitizers = [];
30079
30154
  const lines = code.split("\n");
@@ -30112,6 +30187,34 @@ function findJavaPathGetFileNameSanitizers(code) {
30112
30187
  }
30113
30188
  return sanitizers;
30114
30189
  }
30190
+ function findCrlfWrapperFunctionSanitizers(code, language) {
30191
+ const sanitizers = [];
30192
+ const lines = code.split("\n");
30193
+ const declRe = language === "java" ? /^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+)?(?:String|CharSequence)\s+([A-Za-z_]\w*)\s*\(/ : /^\s*def\s+([A-Za-z_]\w*)\s*\(/;
30194
+ const stripRe = language === "java" ? /\.\s*replace(?:All)?\s*\(\s*(?:"[^"]*\\[rnt]|'\\[rnt]')/ : /(?:re\s*\.\s*sub\s*\(\s*r?["'][^"']*\\[rnt]|\.\s*replace\s*\(\s*["']\\[rnt])/;
30195
+ const bodyWindow = 10;
30196
+ const wrappers = /* @__PURE__ */ new Set();
30197
+ for (let i2 = 0; i2 < lines.length; i2++) {
30198
+ const m = declRe.exec(lines[i2]);
30199
+ if (!m) continue;
30200
+ const body2 = lines.slice(i2, Math.min(lines.length, i2 + bodyWindow)).join("\n");
30201
+ if (stripRe.test(body2)) wrappers.add(m[1]);
30202
+ }
30203
+ if (wrappers.size === 0) return sanitizers;
30204
+ for (let i2 = 0; i2 < lines.length; i2++) {
30205
+ for (const name2 of wrappers) {
30206
+ if (declRe.test(lines[i2]) && new RegExp(`\\b${name2}\\s*\\(`).test(lines[i2])) continue;
30207
+ if (!new RegExp(`\\b${name2}\\s*\\(`).test(lines[i2])) continue;
30208
+ sanitizers.push({
30209
+ type: language === "java" ? "java_wrapper_crlf_strip" : "python_wrapper_crlf_strip",
30210
+ method: name2,
30211
+ line: i2 + 1,
30212
+ sanitizes: ["log_injection", "external_taint_escape"]
30213
+ });
30214
+ }
30215
+ }
30216
+ return sanitizers;
30217
+ }
30115
30218
  function findJavaInlineCrlfStripLogSanitizers(code) {
30116
30219
  const sanitizers = [];
30117
30220
  const lines = code.split("\n");
@@ -33924,10 +34027,18 @@ function jsSsrfHostGuardedLines(code) {
33924
34027
  }
33925
34028
  return covered;
33926
34029
  }
34030
+ var CSHARP_ALLOWLIST_STRIP_RE = /\bRegex\s*\.\s*Replace\s*\([^,]*,\s*@?"\[\^(?:[A-Za-z0-9_\- ]|\\w|\\d|\\s)+\]"\s*,\s*@?"[A-Za-z0-9_]?"\s*\)/;
33927
34031
  var CSHARP_SANITIZER_RES = [
33928
34032
  { re: /\b(?:HtmlEncode|JavaScriptStringEncode)\s*\(/, type: "xss" },
33929
34033
  { re: /\bHtmlEncoder\s*\.\s*Encode\s*\(/, type: "xss" },
33930
- { re: /\bPath\s*\.\s*GetFileName\s*\(/, type: "path_traversal" }
34034
+ { re: /\bPath\s*\.\s*GetFileName\s*\(/, type: "path_traversal" },
34035
+ // An allowlist strip leaves only inert characters, so it covers every family
34036
+ // whose exploitation needs a metacharacter. Each has a fixture; see
34037
+ // `issue-272-csharp-allowlist-strip.test.ts`.
34038
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: "ldap_injection" },
34039
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: "xpath_injection" },
34040
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: "command_injection" },
34041
+ { re: CSHARP_ALLOWLIST_STRIP_RE, type: "sql_injection" }
33931
34042
  ];
33932
34043
  function csharpSanitizedVarsByType(code) {
33933
34044
  const lines = code.split("\n");
@@ -34144,6 +34255,21 @@ function hasJavaImportFromPackage(packagePrefix, sourceLines) {
34144
34255
  }
34145
34256
  return false;
34146
34257
  }
34258
+ function hasSameOriginLiteralPrefix(lineText) {
34259
+ const m = lineText.match(/\(\s*(['"`])([^'"`]*)\1\s*(?:\+|,|\))/);
34260
+ const prefix = m?.[2];
34261
+ if (prefix === void 0) {
34262
+ const t = lineText.match(/\(\s*`([^`$]*)\$\{/);
34263
+ if (!t) return false;
34264
+ return isSameOriginQueryPrefix(t[1]);
34265
+ }
34266
+ return isSameOriginQueryPrefix(prefix);
34267
+ }
34268
+ function isSameOriginQueryPrefix(prefix) {
34269
+ if (!prefix.startsWith("/")) return false;
34270
+ if (prefix.startsWith("//")) return false;
34271
+ return prefix.includes("?");
34272
+ }
34147
34273
  var SinkFilterPass = class {
34148
34274
  name = "sink-filter";
34149
34275
  category = "security";
@@ -34299,6 +34425,9 @@ var SinkFilterPass = class {
34299
34425
  if (setHeaderMatch) {
34300
34426
  return false;
34301
34427
  }
34428
+ if (sink.type === "open_redirect" && hasSameOriginLiteralPrefix(sinkLineText)) {
34429
+ return false;
34430
+ }
34302
34431
  if (sink.method === "cookie" && sink.type === "crlf") {
34303
34432
  return false;
34304
34433
  }
@@ -34728,6 +34857,36 @@ var SinkFilterPass = class {
34728
34857
  return true;
34729
34858
  });
34730
34859
  }
34860
+ if (language === "csharp") {
34861
+ const sourceLines = ctx.code.split("\n");
34862
+ const constBaseClients = /* @__PURE__ */ new Set();
34863
+ const declRe = /\b(?:var|HttpClient)\s+([A-Za-z_]\w*)\s*=\s*new\s+HttpClient\s*\{[^}]*\bBaseAddress\s*=\s*new\s+Uri\s*\(\s*(?:@?"[^"]*"|\$?"[^"{}]*")\s*\)/;
34864
+ for (const line of sourceLines) {
34865
+ const m = declRe.exec(line);
34866
+ if (m) constBaseClients.add(m[1]);
34867
+ }
34868
+ if (constBaseClients.size > 0) {
34869
+ const relativeLiteralArgRe = /\.\s*(?:Get|Post|Put|Patch|Delete|Send|GetString|GetByteArray|GetStream)\w*Async\s*\(\s*(?:@?\$?)"([^"]*)"/;
34870
+ filtered = filtered.filter((sink) => {
34871
+ if (sink.type !== "ssrf") return true;
34872
+ const text = sourceLines[sink.line - 1] ?? "";
34873
+ let onConstClient = false;
34874
+ for (const v of constBaseClients) {
34875
+ if (new RegExp(`(?<![\\w])${escapeRegex(v)}\\s*\\.`).test(text)) {
34876
+ onConstClient = true;
34877
+ break;
34878
+ }
34879
+ }
34880
+ if (!onConstClient) return true;
34881
+ const m = relativeLiteralArgRe.exec(text);
34882
+ if (!m) return true;
34883
+ const lit = m[1];
34884
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(lit)) return true;
34885
+ if (lit.startsWith("//")) return true;
34886
+ return false;
34887
+ });
34888
+ }
34889
+ }
34731
34890
  if (["javascript", "typescript"].includes(language)) {
34732
34891
  const sourceLines = ctx.code.split("\n");
34733
34892
  filtered = filtered.filter((sink) => {
@@ -40557,6 +40716,18 @@ var CRYPTO_BLOCKING_METHODS = /* @__PURE__ */ new Set([
40557
40716
  "deriveKeySync"
40558
40717
  ]);
40559
40718
  var SYNC_SUFFIX_RE2 = /Sync$/;
40719
+ var ROUTER_METHODS = /* @__PURE__ */ new Set([
40720
+ "get",
40721
+ "post",
40722
+ "put",
40723
+ "patch",
40724
+ "delete",
40725
+ "all",
40726
+ "options",
40727
+ "head",
40728
+ "use"
40729
+ ]);
40730
+ var FN_ARG_PARAMS_RE = /^(?:async\s+)?(?:function\s*[\w$]*\s*)?\(([^)]*)\)\s*(?:=>|\{)|^(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/;
40560
40731
  var BlockingMainThreadPass = class {
40561
40732
  name = "blocking-main-thread";
40562
40733
  category = "performance";
@@ -40578,7 +40749,10 @@ var BlockingMainThreadPass = class {
40578
40749
  }
40579
40750
  }
40580
40751
  }
40581
- if (handlerRanges.length === 0) return { blockingInHandlers: [] };
40752
+ const inlineHandlers = this.collectInlineHandlerNames(graph.ir.calls);
40753
+ if (handlerRanges.length === 0 && inlineHandlers.size === 0) {
40754
+ return { blockingInHandlers: [] };
40755
+ }
40582
40756
  const blockingInHandlers = [];
40583
40757
  for (const call of graph.ir.calls) {
40584
40758
  const name2 = call.method_name;
@@ -40586,7 +40760,8 @@ var BlockingMainThreadPass = class {
40586
40760
  const isSyncSuffix = SYNC_SUFFIX_RE2.test(name2);
40587
40761
  if (!isCrypto && !isSyncSuffix) continue;
40588
40762
  const line = call.location.line;
40589
- const range = handlerRanges.find((r) => line >= r.start && line <= r.end);
40763
+ const enclosing = call.in_method ?? null;
40764
+ const range = handlerRanges.find((r) => line >= r.start && line <= r.end) ?? (enclosing && inlineHandlers.has(enclosing) ? { start: line, end: line, name: enclosing } : void 0);
40590
40765
  if (!range) continue;
40591
40766
  const reason = isCrypto ? "crypto" : "sync-suffix";
40592
40767
  blockingInHandlers.push({ line, method: name2, handler: range.name, reason });
@@ -40607,6 +40782,40 @@ var BlockingMainThreadPass = class {
40607
40782
  }
40608
40783
  return { blockingInHandlers };
40609
40784
  }
40785
+ /**
40786
+ * Synthetic `<verb>_handler` names that really do belong to an HTTP route.
40787
+ *
40788
+ * The extractor tags *any* function argument of *any* member-expression call
40789
+ * this way, so `items.map(x => …)` becomes `map_handler` and
40790
+ * `cache.get(k, () => …)` becomes `get_handler`. Accepting every
40791
+ * `*_handler` would pull ordinary callbacks into a pass that is explicitly
40792
+ * about the HTTP request path, so two conditions are required: the method is
40793
+ * a router verb, and the callback's own parameters look like a request
40794
+ * handler's — the same `HANDLER_PARAM_NAMES` test used for methods.
40795
+ *
40796
+ * Known limitation: `in_method` carries no receiver, so two callbacks that
40797
+ * share a verb in one file are indistinguishable. A file containing both
40798
+ * `app.get('/x', (req, res) => …)` and `cache.get(k, () => …)` will treat a
40799
+ * blocking call in the second as in-handler. Both conditions still have to
40800
+ * hold for the verb to register at all, which keeps that to files already
40801
+ * mounting a real route of the same verb.
40802
+ */
40803
+ collectInlineHandlerNames(calls) {
40804
+ const names = /* @__PURE__ */ new Set();
40805
+ for (const call of calls) {
40806
+ if (!ROUTER_METHODS.has(call.method_name)) continue;
40807
+ for (const arg of call.arguments) {
40808
+ const match = FN_ARG_PARAMS_RE.exec((arg.expression ?? "").trim());
40809
+ if (!match) continue;
40810
+ const params = (match[1] ?? match[2] ?? "").split(",").map((p) => p.trim().toLowerCase()).filter((p) => p.length > 0);
40811
+ if (params.some((p) => HANDLER_PARAM_NAMES.has(p))) {
40812
+ names.add(`${call.method_name}_handler`);
40813
+ break;
40814
+ }
40815
+ }
40816
+ }
40817
+ return names;
40818
+ }
40610
40819
  isRequestHandler(method) {
40611
40820
  if (method.annotations.some((a) => HTTP_DECORATORS.has(a))) return true;
40612
40821
  if (HANDLER_METHOD_NAMES.has(method.name)) return true;
@@ -9409,6 +9409,7 @@ function buildCSharpDFG(tree, cache) {
9409
9409
  ];
9410
9410
  for (const method of methods) {
9411
9411
  scopeStack.push(/* @__PURE__ */ new Map());
9412
+ const methodDefStart = defs.length;
9412
9413
  const params = method.childForFieldName("parameters");
9413
9414
  if (params) {
9414
9415
  for (const def of extractParameterDefs(params, defIdCounter)) {
@@ -9438,6 +9439,7 @@ function buildCSharpDFG(tree, cache) {
9438
9439
  }
9439
9440
  }
9440
9441
  const bodyUses = extractUses(body2, useIdCounter, scopeStack, false);
9442
+ resolveReassignedUses(defs.slice(methodDefStart), bodyUses.uses);
9441
9443
  uses.push(...bodyUses.uses);
9442
9444
  useIdCounter = bodyUses.nextId;
9443
9445
  }
@@ -10147,6 +10149,30 @@ function findReachingDef(name2, scopeStack) {
10147
10149
  }
10148
10150
  return null;
10149
10151
  }
10152
+ function resolveReassignedUses(methodDefs, methodUses) {
10153
+ const defsByVar = /* @__PURE__ */ new Map();
10154
+ for (const def of methodDefs) {
10155
+ if (def.kind !== "local" && def.kind !== "param") continue;
10156
+ const existing = defsByVar.get(def.variable);
10157
+ if (existing) existing.push(def);
10158
+ else defsByVar.set(def.variable, [def]);
10159
+ }
10160
+ for (const list of defsByVar.values()) {
10161
+ if (list.length > 1) list.sort((a, b) => a.line - b.line || a.id - b.id);
10162
+ }
10163
+ for (const use of methodUses) {
10164
+ const list = defsByVar.get(use.variable);
10165
+ if (!list || list.length < 2) continue;
10166
+ let strictlyBefore;
10167
+ let sameLine;
10168
+ for (const def of list) {
10169
+ if (def.line < use.line) strictlyBefore = def;
10170
+ else if (def.line === use.line) sameLine = def;
10171
+ }
10172
+ const reaching = strictlyBefore ?? sameLine;
10173
+ if (reaching) use.def_id = reaching.id;
10174
+ }
10175
+ }
10150
10176
  function computeChains(defs, uses) {
10151
10177
  const chains = [];
10152
10178
  const defById = /* @__PURE__ */ new Map();
@@ -15150,7 +15176,7 @@ function isCSharpSsrfHostAllowlistGuarded(call, pattern, language, sourceLines)
15150
15176
  const guards = guardMap.get(cand);
15151
15177
  if (!guards) continue;
15152
15178
  for (const g of guards) {
15153
- if (g.i >= sinkIdx || checked.has(g)) continue;
15179
+ if (g.i > sinkIdx || checked.has(g)) continue;
15154
15180
  checked.add(g);
15155
15181
  const block = csThenBlock(sourceLines, g.i, g.rest);
15156
15182
  if (g.op === "==") {
@@ -16930,7 +16956,7 @@ function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
16930
16956
  function buildTaintFlow(source, sink, taintInfo) {
16931
16957
  const path = [];
16932
16958
  path.push({
16933
- variable: taintInfo.variable,
16959
+ variable: source.variable ?? taintInfo.variable,
16934
16960
  line: source.line,
16935
16961
  type: "source",
16936
16962
  description: `Tainted data enters via ${source.type}`
@@ -9343,6 +9343,7 @@ function buildCSharpDFG(tree, cache) {
9343
9343
  ];
9344
9344
  for (const method of methods) {
9345
9345
  scopeStack.push(/* @__PURE__ */ new Map());
9346
+ const methodDefStart = defs.length;
9346
9347
  const params = method.childForFieldName("parameters");
9347
9348
  if (params) {
9348
9349
  for (const def of extractParameterDefs(params, defIdCounter)) {
@@ -9372,6 +9373,7 @@ function buildCSharpDFG(tree, cache) {
9372
9373
  }
9373
9374
  }
9374
9375
  const bodyUses = extractUses(body2, useIdCounter, scopeStack, false);
9376
+ resolveReassignedUses(defs.slice(methodDefStart), bodyUses.uses);
9375
9377
  uses.push(...bodyUses.uses);
9376
9378
  useIdCounter = bodyUses.nextId;
9377
9379
  }
@@ -10081,6 +10083,30 @@ function findReachingDef(name2, scopeStack) {
10081
10083
  }
10082
10084
  return null;
10083
10085
  }
10086
+ function resolveReassignedUses(methodDefs, methodUses) {
10087
+ const defsByVar = /* @__PURE__ */ new Map();
10088
+ for (const def of methodDefs) {
10089
+ if (def.kind !== "local" && def.kind !== "param") continue;
10090
+ const existing = defsByVar.get(def.variable);
10091
+ if (existing) existing.push(def);
10092
+ else defsByVar.set(def.variable, [def]);
10093
+ }
10094
+ for (const list of defsByVar.values()) {
10095
+ if (list.length > 1) list.sort((a, b) => a.line - b.line || a.id - b.id);
10096
+ }
10097
+ for (const use of methodUses) {
10098
+ const list = defsByVar.get(use.variable);
10099
+ if (!list || list.length < 2) continue;
10100
+ let strictlyBefore;
10101
+ let sameLine;
10102
+ for (const def of list) {
10103
+ if (def.line < use.line) strictlyBefore = def;
10104
+ else if (def.line === use.line) sameLine = def;
10105
+ }
10106
+ const reaching = strictlyBefore ?? sameLine;
10107
+ if (reaching) use.def_id = reaching.id;
10108
+ }
10109
+ }
10084
10110
  function computeChains(defs, uses) {
10085
10111
  const chains = [];
10086
10112
  const defById = /* @__PURE__ */ new Map();
@@ -15084,7 +15110,7 @@ function isCSharpSsrfHostAllowlistGuarded(call, pattern, language, sourceLines)
15084
15110
  const guards = guardMap.get(cand);
15085
15111
  if (!guards) continue;
15086
15112
  for (const g of guards) {
15087
- if (g.i >= sinkIdx || checked.has(g)) continue;
15113
+ if (g.i > sinkIdx || checked.has(g)) continue;
15088
15114
  checked.add(g);
15089
15115
  const block = csThenBlock(sourceLines, g.i, g.rest);
15090
15116
  if (g.op === "==") {
@@ -16864,7 +16890,7 @@ function checkSanitized(fromLine, toLine, sinkType, sanitizersByLine, ctx) {
16864
16890
  function buildTaintFlow(source, sink, taintInfo) {
16865
16891
  const path = [];
16866
16892
  path.push({
16867
- variable: taintInfo.variable,
16893
+ variable: source.variable ?? taintInfo.variable,
16868
16894
  line: source.line,
16869
16895
  type: "source",
16870
16896
  description: `Tainted data enters via ${source.type}`
@@ -90,6 +90,9 @@ function buildCSharpDFG(tree, cache) {
90
90
  ];
91
91
  for (const method of methods) {
92
92
  scopeStack.push(new Map());
93
+ // Where this method's defs begin, so the reaching-def correction below stays
94
+ // inside the method — `defs` accumulates across every method in the file.
95
+ const methodDefStart = defs.length;
93
96
  // Parameters (kind: param).
94
97
  const params = method.childForFieldName('parameters');
95
98
  if (params) {
@@ -123,6 +126,7 @@ function buildCSharpDFG(tree, cache) {
123
126
  }
124
127
  // Uses: all identifier references, reaching defs resolved from scope.
125
128
  const bodyUses = extractUses(body, useIdCounter, scopeStack, false);
129
+ resolveReassignedUses(defs.slice(methodDefStart), bodyUses.uses);
126
130
  uses.push(...bodyUses.uses);
127
131
  useIdCounter = bodyUses.nextId;
128
132
  }
@@ -986,6 +990,79 @@ function findReachingDef(name, scopeStack) {
986
990
  }
987
991
  return null;
988
992
  }
993
+ /**
994
+ * Point uses of a *reassigned* variable at the nearest preceding definition.
995
+ *
996
+ * `DFGUse.def_id` is specified as the reaching definition, but the C# builder
997
+ * collects every def in a method body before it resolves any use, so the scope
998
+ * map holds only the LAST def of each variable by the time uses are resolved.
999
+ * With one def per variable last == reaching and nothing is wrong; a reassigned
1000
+ * variable binds every use to its final def, which breaks taint two ways
1001
+ * (cognium-dev#287):
1002
+ *
1003
+ * var v = input; // def A
1004
+ * v = v + ""; // def B — the right-hand `v` resolved to B, i.e. itself
1005
+ * sink(v); // resolved to B
1006
+ *
1007
+ * `computeChains` skips a use whose def_id is the def it is building, so the
1008
+ * self-reference produced no A -> B chain and taint stopped at A, while the
1009
+ * sink read B. It also emitted a *backwards* B -> A chain, because the use of
1010
+ * `v` on def A's own line resolved to B. Net effect: `v = v.Trim()`,
1011
+ * `v = v.Replace(...)`, `v = v + x` silently cleared taint — a false negative,
1012
+ * which leaves no trace to notice.
1013
+ *
1014
+ * Deliberately narrow. A use is repointed only when its variable has more than
1015
+ * one definition AND a strictly-preceding one exists, so single-def variables
1016
+ * and one-line `var sb = new StringBuilder(); sb.Append(x)` shapes keep exactly
1017
+ * the binding they have today. Same-line ties resolve to the earlier def
1018
+ * because in `v = f(v)` the right-hand side is evaluated before the assignment.
1019
+ *
1020
+ * Scoped to `local` and `param` defs. Fields are excluded on purpose: they are
1021
+ * registered after the method loop and resolving them here would newly enable
1022
+ * cross-method field-carried taint, which is a separate feature (the
1023
+ * `San08FieldStore` half of #287), not this correction.
1024
+ *
1025
+ * Call this per method, with only that method's defs, or a local in one method
1026
+ * will bind to a same-named local in another.
1027
+ */
1028
+ function resolveReassignedUses(methodDefs, methodUses) {
1029
+ const defsByVar = new Map();
1030
+ for (const def of methodDefs) {
1031
+ if (def.kind !== 'local' && def.kind !== 'param')
1032
+ continue;
1033
+ const existing = defsByVar.get(def.variable);
1034
+ if (existing)
1035
+ existing.push(def);
1036
+ else
1037
+ defsByVar.set(def.variable, [def]);
1038
+ }
1039
+ for (const list of defsByVar.values()) {
1040
+ if (list.length > 1)
1041
+ list.sort((a, b) => a.line - b.line || a.id - b.id);
1042
+ }
1043
+ for (const use of methodUses) {
1044
+ const list = defsByVar.get(use.variable);
1045
+ if (!list || list.length < 2)
1046
+ continue; // not reassigned — leave untouched
1047
+ // Prefer the last def strictly before this use: in `v = f(v)` the
1048
+ // right-hand side is evaluated before the assignment, so the read sees the
1049
+ // previous value. Falling back to a def on the use's own line keeps the
1050
+ // declaration site (`var v = input`) bound to itself instead of to a later
1051
+ // reassignment — that mis-binding is what emitted a *backwards* chain from
1052
+ // the second def to the first.
1053
+ let strictlyBefore;
1054
+ let sameLine;
1055
+ for (const def of list) {
1056
+ if (def.line < use.line)
1057
+ strictlyBefore = def;
1058
+ else if (def.line === use.line)
1059
+ sameLine = def;
1060
+ }
1061
+ const reaching = strictlyBefore ?? sameLine;
1062
+ if (reaching)
1063
+ use.def_id = reaching.id;
1064
+ }
1065
+ }
989
1066
  /**
990
1067
  * Compute def-use chains.
991
1068
  *
@@ -1023,7 +1100,7 @@ function computeChains(defs, uses) {
1023
1100
  for (const use of usesOnLine) {
1024
1101
  // If this use has a reaching def, create a chain
1025
1102
  if (use.def_id !== null && use.def_id !== def.id) {
1026
- const key = `${use.def_id}${def.id}${use.variable}`;
1103
+ const key = `${use.def_id}\0${def.id}\0${use.variable}`;
1027
1104
  if (!seenChains.has(key)) {
1028
1105
  seenChains.add(key);
1029
1106
  chains.push({