circle-ir 4.9.13 → 4.9.15

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 (39) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +25 -5
  3. package/dist/analysis/config-loader.js.map +1 -1
  4. package/dist/analysis/literal-expression.d.ts +33 -0
  5. package/dist/analysis/literal-expression.d.ts.map +1 -0
  6. package/dist/analysis/literal-expression.js +61 -0
  7. package/dist/analysis/literal-expression.js.map +1 -0
  8. package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
  9. package/dist/analysis/passes/cross-file-pass.js +7 -4
  10. package/dist/analysis/passes/cross-file-pass.js.map +1 -1
  11. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  12. package/dist/analysis/passes/language-sources-pass.js +35 -1
  13. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  14. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  15. package/dist/analysis/passes/sink-filter-pass.js +131 -4
  16. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  17. package/dist/analysis/passes/taint-propagation-pass.d.ts +48 -0
  18. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  19. package/dist/analysis/passes/taint-propagation-pass.js +166 -1
  20. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  21. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  22. package/dist/analysis/taint-matcher.js +79 -2
  23. package/dist/analysis/taint-matcher.js.map +1 -1
  24. package/dist/analyzer.d.ts +22 -0
  25. package/dist/analyzer.d.ts.map +1 -1
  26. package/dist/analyzer.js +20 -0
  27. package/dist/analyzer.js.map +1 -1
  28. package/dist/browser/circle-ir.js +254 -20
  29. package/dist/core/circle-ir-core.cjs +81 -7
  30. package/dist/core/circle-ir-core.js +81 -7
  31. package/dist/core/extractors/calls.js +19 -0
  32. package/dist/core/extractors/calls.js.map +1 -1
  33. package/dist/resolution/cross-file.d.ts +19 -3
  34. package/dist/resolution/cross-file.d.ts.map +1 -1
  35. package/dist/resolution/cross-file.js +25 -3
  36. package/dist/resolution/cross-file.js.map +1 -1
  37. package/dist/types/index.d.ts +13 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/package.json +1 -1
@@ -6746,6 +6746,14 @@ function buildResolutionContext(tree, cache) {
6746
6746
  }
6747
6747
  }
6748
6748
  }
6749
+ const resourceDecls = getNodesFromCache(tree.rootNode, "resource", cache);
6750
+ for (const res of resourceDecls) {
6751
+ const typeNode = res.childForFieldName("type");
6752
+ const nameNode = res.childForFieldName("name");
6753
+ if (typeNode && nameNode) {
6754
+ context.localVarTypes.set(getNodeText(nameNode), getNodeText(typeNode));
6755
+ }
6756
+ }
6749
6757
  const imports = getNodesFromCache(tree.rootNode, "import_declaration", cache);
6750
6758
  for (const imp of imports) {
6751
6759
  const text = getNodeText(imp);
@@ -10858,6 +10866,30 @@ function extractGoAddressableVarName(node) {
10858
10866
  return null;
10859
10867
  }
10860
10868
 
10869
+ // src/analysis/literal-expression.ts
10870
+ function isProvablyLiteralExpression(expr) {
10871
+ let e = (expr ?? "").trim();
10872
+ if (e.length === 0) return false;
10873
+ e = e.replace(/\/\/.*$/, "").replace(/;\s*$/, "").trim();
10874
+ if (e.length === 0) return false;
10875
+ if (e.includes("${")) return false;
10876
+ if (/[+]\s*$/.test(e)) return false;
10877
+ const stripped = e.replace(
10878
+ /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.)*`/g,
10879
+ ""
10880
+ );
10881
+ if (/["'`]/.test(stripped)) return false;
10882
+ return /^[\s+()]*$/.test(stripped);
10883
+ }
10884
+ var DOM_ASSIGNMENT_SINK_METHODS = /* @__PURE__ */ new Set([
10885
+ "innerHTML",
10886
+ "outerHTML",
10887
+ "cssText",
10888
+ "src",
10889
+ "href",
10890
+ "textContent"
10891
+ ]);
10892
+
10861
10893
  // src/analysis/config-loader.ts
10862
10894
  function parseConfig(content) {
10863
10895
  return JSON.parse(content);
@@ -11767,9 +11799,27 @@ var DEFAULT_SINKS = [
11767
11799
  // Command Injection (CWE-78)
11768
11800
  { method: "exec", class: "Runtime", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0, 1] },
11769
11801
  { method: "start", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [] },
11770
- // ProcessBuilder constructor
11771
- { method: "ProcessBuilder", class: "constructor", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0] },
11772
- { method: "command", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0] },
11802
+ // ProcessBuilder constructor + command(): cognium-dev#351 — BOTH take
11803
+ // `String...` varargs (or a single List<String>), and EVERY element is a
11804
+ // command token, so no position is safe. Pinning these to `[0]` missed the
11805
+ // canonical shell-wrapper shape, where the payload is the last argument:
11806
+ //
11807
+ // new ProcessBuilder("bash", "-c", tainted) // payload at position 2
11808
+ // pb.command("bash", "-c", tainted)
11809
+ //
11810
+ // `Runtime.exec(String[])` with the identical array already fired, so the
11811
+ // taint was available and only the sink model was wrong.
11812
+ //
11813
+ // The positions are enumerated rather than left `[]` because the two layers
11814
+ // that read this field disagree about what an empty array means:
11815
+ // `isInDangerousPosition` (taint-matcher) does `arg_positions.includes(pos)`,
11816
+ // so `[]` matches NOTHING, while the sink-reachability loop in
11817
+ // taint-propagation treats `length === 0` as "any argument".
11818
+ // `ProcessBuilder.start` gets away with `[]` only because it takes no
11819
+ // arguments at all. Enumerating is unambiguous in both, and matches how the
11820
+ // logging sinks in this file already spell it.
11821
+ { method: "ProcessBuilder", class: "constructor", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0, 1, 2, 3, 4, 5, 6, 7] },
11822
+ { method: "command", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0, 1, 2, 3, 4, 5, 6, 7] },
11773
11823
  // Commons Exec
11774
11824
  // Note: bare class 'Executor' removed — it collided with java.util.concurrent.Executor
11775
11825
  // (Executor.execute(Runnable) is not command injection). Apache Commons Exec users
@@ -13047,8 +13097,10 @@ var DEFAULT_SINKS = [
13047
13097
  // =========================================================================
13048
13098
  // Java CWE-Bench Enhancement Patterns (Collection/Builder)
13049
13099
  // =========================================================================
13050
- // Collection-based command injection (ProcessBuilder with List)
13051
- { method: "command", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0] },
13100
+ // Collection-based command injection (ProcessBuilder with List) — the
13101
+ // `ProcessBuilder.command` entry lives with the other ProcessBuilder sinks
13102
+ // above; a second copy here only risked the two drifting apart, which is
13103
+ // what happened (cognium-dev#351).
13052
13104
  // ProcessBuilder.inheritIO removed in 3.83.0 (#124): no args, no command
13053
13105
  // string flows into it. See note above next to the Process-related cluster.
13054
13106
  // Jenkins DSL patterns
@@ -15303,6 +15355,22 @@ function isRegexReceiver(receiver, sourceLines) {
15303
15355
  const decl = new RegExp(`(?:const|let|var)\\s+${escapeRe(r)}\\s*=\\s*(?:/|(?:new\\s+)?RegExp\\s*\\()`);
15304
15356
  return sourceLines.some((l) => decl.test(l));
15305
15357
  }
15358
+ function isDeclaredFunctionReference(arg, sourceLines) {
15359
+ if (arg.literal !== null && arg.literal !== void 0) return false;
15360
+ if (!sourceLines) return false;
15361
+ const name2 = (arg.expression ?? "").trim();
15362
+ if (!/^[A-Za-z_$][\w$]*$/.test(name2)) return false;
15363
+ const n = escapeRe(name2);
15364
+ const decls = [
15365
+ // function connect() {} / async function connect() {}
15366
+ new RegExp(`\\bfunction\\s+${n}\\s*\\(`),
15367
+ // const connect = () => / function / async () =>
15368
+ new RegExp(
15369
+ `\\b(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\s+)?(?:function\\b|\\(|[A-Za-z_$][\\w$]*\\s*=>)`
15370
+ )
15371
+ ];
15372
+ return sourceLines.some((l) => decls.some((re) => re.test(l)));
15373
+ }
15306
15374
  function isFunctionCallbackArgument(arg) {
15307
15375
  if (arg.literal !== null && arg.literal !== void 0) return false;
15308
15376
  const expr = (arg.expression ?? "").trim();
@@ -15481,11 +15549,17 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
15481
15549
  }
15482
15550
  if (pattern.type === "code_injection" && (call.method_name === "setInterval" || call.method_name === "setTimeout" || call.method_name === "setImmediate")) {
15483
15551
  const firstArg = call.arguments.find((a) => a.position === 0);
15484
- if (firstArg && isFunctionCallbackArgument(firstArg)) {
15552
+ if (firstArg && (isFunctionCallbackArgument(firstArg) || isDeclaredFunctionReference(firstArg, sourceLines))) {
15553
+ continue;
15554
+ }
15555
+ }
15556
+ if (pattern.type === "xss" && (language === "javascript" || language === "typescript") && DOM_ASSIGNMENT_SINK_METHODS.has(call.method_name) && call.arguments.length > 0) {
15557
+ const rhs = call.arguments.find((a) => a.position === 0);
15558
+ if (rhs && isProvablyLiteralExpression(rhs.expression ?? "")) {
15485
15559
  continue;
15486
15560
  }
15487
15561
  }
15488
- if (pattern.type === "command_injection" && call.method_name === "exec" && (language === "javascript" || language === "typescript") && isRegexReceiver(call.receiver, sourceLines)) {
15562
+ if ((pattern.type === "command_injection" || pattern.type === "sql_injection") && call.method_name === "exec" && (language === "javascript" || language === "typescript") && isRegexReceiver(call.receiver, sourceLines)) {
15489
15563
  continue;
15490
15564
  }
15491
15565
  if (pattern.type === "code_injection" && language === "python" && call.method_name === "compile" && call.receiver && call.receiver !== "builtins") {
@@ -6680,6 +6680,14 @@ function buildResolutionContext(tree, cache) {
6680
6680
  }
6681
6681
  }
6682
6682
  }
6683
+ const resourceDecls = getNodesFromCache(tree.rootNode, "resource", cache);
6684
+ for (const res of resourceDecls) {
6685
+ const typeNode = res.childForFieldName("type");
6686
+ const nameNode = res.childForFieldName("name");
6687
+ if (typeNode && nameNode) {
6688
+ context.localVarTypes.set(getNodeText(nameNode), getNodeText(typeNode));
6689
+ }
6690
+ }
6683
6691
  const imports = getNodesFromCache(tree.rootNode, "import_declaration", cache);
6684
6692
  for (const imp of imports) {
6685
6693
  const text = getNodeText(imp);
@@ -10792,6 +10800,30 @@ function extractGoAddressableVarName(node) {
10792
10800
  return null;
10793
10801
  }
10794
10802
 
10803
+ // src/analysis/literal-expression.ts
10804
+ function isProvablyLiteralExpression(expr) {
10805
+ let e = (expr ?? "").trim();
10806
+ if (e.length === 0) return false;
10807
+ e = e.replace(/\/\/.*$/, "").replace(/;\s*$/, "").trim();
10808
+ if (e.length === 0) return false;
10809
+ if (e.includes("${")) return false;
10810
+ if (/[+]\s*$/.test(e)) return false;
10811
+ const stripped = e.replace(
10812
+ /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.)*`/g,
10813
+ ""
10814
+ );
10815
+ if (/["'`]/.test(stripped)) return false;
10816
+ return /^[\s+()]*$/.test(stripped);
10817
+ }
10818
+ var DOM_ASSIGNMENT_SINK_METHODS = /* @__PURE__ */ new Set([
10819
+ "innerHTML",
10820
+ "outerHTML",
10821
+ "cssText",
10822
+ "src",
10823
+ "href",
10824
+ "textContent"
10825
+ ]);
10826
+
10795
10827
  // src/analysis/config-loader.ts
10796
10828
  function parseConfig(content) {
10797
10829
  return JSON.parse(content);
@@ -11701,9 +11733,27 @@ var DEFAULT_SINKS = [
11701
11733
  // Command Injection (CWE-78)
11702
11734
  { method: "exec", class: "Runtime", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0, 1] },
11703
11735
  { method: "start", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [] },
11704
- // ProcessBuilder constructor
11705
- { method: "ProcessBuilder", class: "constructor", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0] },
11706
- { method: "command", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0] },
11736
+ // ProcessBuilder constructor + command(): cognium-dev#351 — BOTH take
11737
+ // `String...` varargs (or a single List<String>), and EVERY element is a
11738
+ // command token, so no position is safe. Pinning these to `[0]` missed the
11739
+ // canonical shell-wrapper shape, where the payload is the last argument:
11740
+ //
11741
+ // new ProcessBuilder("bash", "-c", tainted) // payload at position 2
11742
+ // pb.command("bash", "-c", tainted)
11743
+ //
11744
+ // `Runtime.exec(String[])` with the identical array already fired, so the
11745
+ // taint was available and only the sink model was wrong.
11746
+ //
11747
+ // The positions are enumerated rather than left `[]` because the two layers
11748
+ // that read this field disagree about what an empty array means:
11749
+ // `isInDangerousPosition` (taint-matcher) does `arg_positions.includes(pos)`,
11750
+ // so `[]` matches NOTHING, while the sink-reachability loop in
11751
+ // taint-propagation treats `length === 0` as "any argument".
11752
+ // `ProcessBuilder.start` gets away with `[]` only because it takes no
11753
+ // arguments at all. Enumerating is unambiguous in both, and matches how the
11754
+ // logging sinks in this file already spell it.
11755
+ { method: "ProcessBuilder", class: "constructor", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0, 1, 2, 3, 4, 5, 6, 7] },
11756
+ { method: "command", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0, 1, 2, 3, 4, 5, 6, 7] },
11707
11757
  // Commons Exec
11708
11758
  // Note: bare class 'Executor' removed — it collided with java.util.concurrent.Executor
11709
11759
  // (Executor.execute(Runnable) is not command injection). Apache Commons Exec users
@@ -12981,8 +13031,10 @@ var DEFAULT_SINKS = [
12981
13031
  // =========================================================================
12982
13032
  // Java CWE-Bench Enhancement Patterns (Collection/Builder)
12983
13033
  // =========================================================================
12984
- // Collection-based command injection (ProcessBuilder with List)
12985
- { method: "command", class: "ProcessBuilder", type: "command_injection", cwe: "CWE-78", severity: "critical", arg_positions: [0] },
13034
+ // Collection-based command injection (ProcessBuilder with List) — the
13035
+ // `ProcessBuilder.command` entry lives with the other ProcessBuilder sinks
13036
+ // above; a second copy here only risked the two drifting apart, which is
13037
+ // what happened (cognium-dev#351).
12986
13038
  // ProcessBuilder.inheritIO removed in 3.83.0 (#124): no args, no command
12987
13039
  // string flows into it. See note above next to the Process-related cluster.
12988
13040
  // Jenkins DSL patterns
@@ -15237,6 +15289,22 @@ function isRegexReceiver(receiver, sourceLines) {
15237
15289
  const decl = new RegExp(`(?:const|let|var)\\s+${escapeRe(r)}\\s*=\\s*(?:/|(?:new\\s+)?RegExp\\s*\\()`);
15238
15290
  return sourceLines.some((l) => decl.test(l));
15239
15291
  }
15292
+ function isDeclaredFunctionReference(arg, sourceLines) {
15293
+ if (arg.literal !== null && arg.literal !== void 0) return false;
15294
+ if (!sourceLines) return false;
15295
+ const name2 = (arg.expression ?? "").trim();
15296
+ if (!/^[A-Za-z_$][\w$]*$/.test(name2)) return false;
15297
+ const n = escapeRe(name2);
15298
+ const decls = [
15299
+ // function connect() {} / async function connect() {}
15300
+ new RegExp(`\\bfunction\\s+${n}\\s*\\(`),
15301
+ // const connect = () => / function / async () =>
15302
+ new RegExp(
15303
+ `\\b(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\s+)?(?:function\\b|\\(|[A-Za-z_$][\\w$]*\\s*=>)`
15304
+ )
15305
+ ];
15306
+ return sourceLines.some((l) => decls.some((re) => re.test(l)));
15307
+ }
15240
15308
  function isFunctionCallbackArgument(arg) {
15241
15309
  if (arg.literal !== null && arg.literal !== void 0) return false;
15242
15310
  const expr = (arg.expression ?? "").trim();
@@ -15415,11 +15483,17 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
15415
15483
  }
15416
15484
  if (pattern.type === "code_injection" && (call.method_name === "setInterval" || call.method_name === "setTimeout" || call.method_name === "setImmediate")) {
15417
15485
  const firstArg = call.arguments.find((a) => a.position === 0);
15418
- if (firstArg && isFunctionCallbackArgument(firstArg)) {
15486
+ if (firstArg && (isFunctionCallbackArgument(firstArg) || isDeclaredFunctionReference(firstArg, sourceLines))) {
15487
+ continue;
15488
+ }
15489
+ }
15490
+ if (pattern.type === "xss" && (language === "javascript" || language === "typescript") && DOM_ASSIGNMENT_SINK_METHODS.has(call.method_name) && call.arguments.length > 0) {
15491
+ const rhs = call.arguments.find((a) => a.position === 0);
15492
+ if (rhs && isProvablyLiteralExpression(rhs.expression ?? "")) {
15419
15493
  continue;
15420
15494
  }
15421
15495
  }
15422
- if (pattern.type === "command_injection" && call.method_name === "exec" && (language === "javascript" || language === "typescript") && isRegexReceiver(call.receiver, sourceLines)) {
15496
+ if ((pattern.type === "command_injection" || pattern.type === "sql_injection") && call.method_name === "exec" && (language === "javascript" || language === "typescript") && isRegexReceiver(call.receiver, sourceLines)) {
15423
15497
  continue;
15424
15498
  }
15425
15499
  if (pattern.type === "code_injection" && language === "python" && call.method_name === "compile" && call.receiver && call.receiver !== "builtins") {
@@ -1049,6 +1049,25 @@ function buildResolutionContext(tree, cache) {
1049
1049
  }
1050
1050
  }
1051
1051
  }
1052
+ // cognium-dev#350 — try-with-resources declarations are ALSO local variables.
1053
+ //
1054
+ // try (Connection c = …; Statement s = c.createStatement()) { s.executeQuery(q); }
1055
+ //
1056
+ // A resource is a `resource` node under `resource_specification`, and it holds
1057
+ // its type and name directly — there is no `variable_declarator` child — so
1058
+ // the loop above never sees it. Without an entry here the receiver type stays
1059
+ // `null`, the class-scoped `Statement.executeQuery` sink cannot match, and the
1060
+ // call degrades to the generic `external_taint_escape` fallback: the sink is
1061
+ // still reported, but as CWE-668 instead of CWE-89. That is the idiomatic JDBC
1062
+ // form and the shape most real Spring code uses.
1063
+ const resourceDecls = getNodesFromCache(tree.rootNode, 'resource', cache);
1064
+ for (const res of resourceDecls) {
1065
+ const typeNode = res.childForFieldName('type');
1066
+ const nameNode = res.childForFieldName('name');
1067
+ if (typeNode && nameNode) {
1068
+ context.localVarTypes.set(getNodeText(nameNode), getNodeText(typeNode));
1069
+ }
1070
+ }
1052
1071
  // Collect imports — map simple class name to FQN
1053
1072
  const imports = getNodesFromCache(tree.rootNode, 'import_declaration', cache);
1054
1073
  for (const imp of imports) {