circle-ir 4.7.2 → 4.9.7

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 (40) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +34 -0
  3. package/dist/analysis/config-loader.js.map +1 -1
  4. package/dist/analysis/passes/insecure-cookie-pass.d.ts +1 -0
  5. package/dist/analysis/passes/insecure-cookie-pass.d.ts.map +1 -1
  6. package/dist/analysis/passes/insecure-cookie-pass.js +37 -3
  7. package/dist/analysis/passes/insecure-cookie-pass.js.map +1 -1
  8. package/dist/analysis/passes/insecure-deserialization-config-pass.d.ts +2 -0
  9. package/dist/analysis/passes/insecure-deserialization-config-pass.d.ts.map +1 -1
  10. package/dist/analysis/passes/insecure-deserialization-config-pass.js +45 -2
  11. package/dist/analysis/passes/insecure-deserialization-config-pass.js.map +1 -1
  12. package/dist/analysis/passes/jwt-verify-disabled-pass.d.ts.map +1 -1
  13. package/dist/analysis/passes/jwt-verify-disabled-pass.js +48 -21
  14. package/dist/analysis/passes/jwt-verify-disabled-pass.js.map +1 -1
  15. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  16. package/dist/analysis/passes/language-sources-pass.js +131 -0
  17. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  18. package/dist/analysis/passes/tls-verify-disabled-pass.d.ts.map +1 -1
  19. package/dist/analysis/passes/tls-verify-disabled-pass.js +34 -0
  20. package/dist/analysis/passes/tls-verify-disabled-pass.js.map +1 -1
  21. package/dist/analysis/passes/weak-crypto-pass.d.ts.map +1 -1
  22. package/dist/analysis/passes/weak-crypto-pass.js +32 -19
  23. package/dist/analysis/passes/weak-crypto-pass.js.map +1 -1
  24. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  25. package/dist/analysis/taint-matcher.js +293 -25
  26. package/dist/analysis/taint-matcher.js.map +1 -1
  27. package/dist/analyzer.d.ts.map +1 -1
  28. package/dist/analyzer.js +14 -0
  29. package/dist/analyzer.js.map +1 -1
  30. package/dist/browser/circle-ir.js +660 -109
  31. package/dist/core/circle-ir-core.cjs +411 -68
  32. package/dist/core/circle-ir-core.js +411 -68
  33. package/dist/core/extractors/calls.js +17 -3
  34. package/dist/core/extractors/calls.js.map +1 -1
  35. package/dist/core/extractors/cfg.d.ts.map +1 -1
  36. package/dist/core/extractors/cfg.js +140 -40
  37. package/dist/core/extractors/cfg.js.map +1 -1
  38. package/dist/core/extractors/types.js +77 -2
  39. package/dist/core/extractors/types.js.map +1 -1
  40. package/package.json +1 -1
@@ -371,9 +371,20 @@ function findSources(calls, types, patterns, sourceLines, language) {
371
371
  // Check if parameter type could carry tainted data
372
372
  // For typed languages (Java), check the type
373
373
  // For untyped languages (JavaScript), treat all params as potentially tainted
374
- const isTaintable = param.type
375
- ? isInterproceduralTaintableType(param.type, language)
376
- : true; // JavaScript/Python - no type means any value
374
+ //
375
+ // C# ASP.NET model binding: a `[FromBody]`/`[FromQuery]`/`[FromRoute]`/
376
+ // `[FromForm]`/`[FromHeader]` parameter is attacker-controlled even when
377
+ // its type is a custom DTO (not a primitive) — the whole object and its
378
+ // properties come from the request. Seed those regardless of type; the
379
+ // tainted-var scan bridges `d` and its member accesses (`d.Name`) to
380
+ // sinks. (cognium-dev#273 DTO-property propagation.)
381
+ const hasCSharpBindingAttr = language === 'csharp' &&
382
+ param.annotations.some((a) => CSHARP_BINDING_ATTRS.has(a));
383
+ const isTaintable = hasCSharpBindingAttr
384
+ ? true
385
+ : param.type
386
+ ? isInterproceduralTaintableType(param.type, language)
387
+ : true; // JavaScript/Python - no type means any value
377
388
  if (isTaintable) {
378
389
  // Use parameter line if available, fallback to method start line
379
390
  const paramLine = param.line ?? method.start_line;
@@ -562,6 +573,10 @@ function findSources(calls, types, patterns, sourceLines, language) {
562
573
  }
563
574
  return result;
564
575
  }
576
+ /** ASP.NET model-binding attributes — a so-annotated parameter is request-controlled. */
577
+ const CSHARP_BINDING_ATTRS = new Set([
578
+ 'FromBody', 'FromQuery', 'FromRoute', 'FromForm', 'FromHeader',
579
+ ]);
565
580
  /**
566
581
  * Check if a parameter type could carry tainted data in inter-procedural analysis.
567
582
  * These are types commonly used to pass user-controlled data between methods.
@@ -908,36 +923,283 @@ function isSafeJSChildProcessCall(call, pattern, language) {
908
923
  * arg) and a shell executable (`Process.Start("/bin/sh", "-c " + x)` — kept
909
924
  * firing for #276) stay dangerous. A variable executable stays dangerous.
910
925
  */
911
- function isSafeCSharpProcessStartCall(call, pattern, language) {
912
- if (language !== 'csharp')
926
+ const CSHARP_SHELL_PROGRAMS = new Set([
927
+ 'sh', 'bash', 'zsh', 'dash', 'ash', 'ksh',
928
+ 'cmd', 'powershell', 'pwsh',
929
+ ]);
930
+ /** True when a `"prog"` / `@"C:\prog.exe"` literal names a non-shell program. */
931
+ function isConstNonShellExe(raw) {
932
+ if (!raw)
913
933
  return false;
914
- if (pattern.type !== 'command_injection')
934
+ const t = raw.trim();
935
+ // constant string literal only ("git", @"C:\git.exe"); a variable exe is dangerous.
936
+ if (!/^@?"[^"]*"$/.test(t))
915
937
  return false;
916
- if (call.method_name !== 'Start')
938
+ const program = (t.replace(/^@?"|"$/g, '').split(/[\\/]/).pop() ?? '')
939
+ .toLowerCase().replace(/\.exe$/, '');
940
+ return !CSHARP_SHELL_PROGRAMS.has(program);
941
+ }
942
+ // Extract the executable literal from a `new ProcessStartInfo("exe", …)`
943
+ // expression, or by resolving a bare `psi` variable back to its
944
+ // `psi = new ProcessStartInfo("exe", …)` construction in the file. Returns the
945
+ // raw literal (e.g. `"grep"`) or null when it cannot be determined statically.
946
+ const PSI_CTOR_EXE_RE = /\bnew\s+ProcessStartInfo\s*(?:<[^>]*>)?\s*\(\s*(@?"[^"]*")/;
947
+ function processStartInfoExe(expr, sourceLines) {
948
+ const inline = PSI_CTOR_EXE_RE.exec(expr);
949
+ if (inline)
950
+ return inline[1];
951
+ // Bare identifier — resolve its construction from the file text.
952
+ if (sourceLines && /^[A-Za-z_]\w*$/.test(expr.trim())) {
953
+ const varName = expr.trim();
954
+ const assignRe = new RegExp(`\\b${varName}\\s*=\\s*new\\s+ProcessStartInfo\\s*(?:<[^>]*>)?\\s*\\(\\s*(@?"[^"]*")`);
955
+ for (const line of sourceLines) {
956
+ const m = assignRe.exec(line);
957
+ if (m)
958
+ return m[1];
959
+ }
960
+ }
961
+ return null;
962
+ }
963
+ function isSafeCSharpProcessStartCall(call, pattern, language, sourceLines) {
964
+ if (language !== 'csharp')
917
965
  return false;
918
- // argv form only — needs a separate arguments arg beyond the filename.
919
- if (call.arguments.length < 2)
966
+ if (pattern.type !== 'command_injection')
920
967
  return false;
921
- const fileArg = call.arguments.find(a => a.position === 0);
922
- if (!fileArg)
968
+ const method = call.method_name;
969
+ if (method !== 'Start' && method !== 'ProcessStartInfo')
923
970
  return false;
924
- let raw;
925
- if (fileArg.literal !== null && fileArg.literal !== undefined) {
926
- raw = String(fileArg.literal).trim();
971
+ // argv forms: `Process.Start(exe, args)` and the inline
972
+ // `new ProcessStartInfo(exe, args)` constructor (cognium-ai#328 shape 2)
973
+ // executable at [0], argument string at [1]. A constant non-shell exe means
974
+ // the arguments are passed argv (no shell), so a tainted [1] is not injection.
975
+ if (call.arguments.length >= 2) {
976
+ const fileArg = call.arguments.find(a => a.position === 0);
977
+ const raw = fileArg?.literal != null ? String(fileArg.literal) : fileArg?.expression;
978
+ return isConstNonShellExe(raw);
979
+ }
980
+ // Single-arg `Process.Start(psi)` where `psi` is an inline or variable
981
+ // ProcessStartInfo built with a constant non-shell exe — argv/`ArgumentList`
982
+ // or a non-shell `Arguments` string, still no shell (cognium-ai#328 shape 2,
983
+ // object-carried variant).
984
+ if (method === 'Start' && call.arguments.length === 1) {
985
+ const arg0 = call.arguments.find(a => a.position === 0);
986
+ const expr = (arg0?.expression ?? '').trim();
987
+ return isConstNonShellExe(processStartInfoExe(expr, sourceLines));
988
+ }
989
+ return false;
990
+ }
991
+ // ---------------------------------------------------------------------------
992
+ // C# SSRF inline host-allowlist guard (cognium-ai#328 shape 1).
993
+ //
994
+ // An exact string-equality check against a constant is the strongest allowlist;
995
+ // when it *dominates* the sink the flowing URL/host is pinned to a constant and
996
+ // there is no SSRF. Correctness hinges on the operator AND the sink's position,
997
+ // not merely the guard's presence:
998
+ // if (host == "c") sink(host); // == , sink INSIDE then → SAFE
999
+ // if (host != "c") return; sink(host);// != , then is early-exit → SAFE
1000
+ // if (host == "c") return; sink(host);// == , early-exit (blocklist) → UNSAFE
1001
+ // if (host != "c") sink(host); // != , sink INSIDE then → UNSAFE
1002
+ // A naive "an == guard appears above the sink" heuristic would wrongly suppress
1003
+ // the two blocklist forms, so we resolve the then-block structurally.
1004
+ /** Strip C# string/char literals and `//` comments so brace/paren scans are clean. */
1005
+ function stripCsLiterals(line) {
1006
+ return line
1007
+ .replace(/\/\/.*$/, '')
1008
+ .replace(/@?"(?:[^"\\]|\\.)*"/g, '""')
1009
+ .replace(/'(?:[^'\\]|\\.)'/g, "''");
1010
+ }
1011
+ /** Cumulative `{` depth before line `idx` (string/comment-safe). */
1012
+ function csBraceDepthBefore(lines, idx) {
1013
+ let depth = 0;
1014
+ for (let i = 0; i < idx && i < lines.length; i++) {
1015
+ for (const ch of stripCsLiterals(lines[i])) {
1016
+ if (ch === '{')
1017
+ depth++;
1018
+ else if (ch === '}')
1019
+ depth--;
1020
+ }
1021
+ }
1022
+ return depth;
1023
+ }
1024
+ /** Net `{` minus `}` in a code fragment (caller strips literals first). */
1025
+ function netBraces(fragment) {
1026
+ let n = 0;
1027
+ for (const ch of fragment) {
1028
+ if (ch === '{')
1029
+ n++;
1030
+ else if (ch === '}')
1031
+ n--;
1032
+ }
1033
+ return n;
1034
+ }
1035
+ /** Split `if (cond) rest…` on one line; null if the condition spans lines. */
1036
+ function splitCsIf(line) {
1037
+ const m = /\bif\s*\(/.exec(line);
1038
+ if (!m)
1039
+ return null;
1040
+ const ifCol = m.index;
1041
+ let i = m.index + m[0].length;
1042
+ let depth = 1;
1043
+ let inStr = false;
1044
+ let strCh = '';
1045
+ for (; i < line.length; i++) {
1046
+ const c = line[i];
1047
+ if (inStr) {
1048
+ if (c === '\\') {
1049
+ i++;
1050
+ continue;
1051
+ }
1052
+ if (c === strCh)
1053
+ inStr = false;
1054
+ continue;
1055
+ }
1056
+ if (c === '"' || c === "'") {
1057
+ inStr = true;
1058
+ strCh = c;
1059
+ continue;
1060
+ }
1061
+ if (c === '(')
1062
+ depth++;
1063
+ else if (c === ')') {
1064
+ depth--;
1065
+ if (depth === 0)
1066
+ break;
1067
+ }
1068
+ }
1069
+ if (depth !== 0)
1070
+ return null;
1071
+ return { cond: line.slice(m.index + m[0].length, i), rest: line.slice(i + 1), ifCol };
1072
+ }
1073
+ /** Extract `{op, exprSide}` from a `EXPR == "lit"` / `"lit" != EXPR` condition. */
1074
+ function csEqualityGuard(cond) {
1075
+ const c = cond.trim();
1076
+ let m = /^(.*?)\s*(==|!=)\s*@?"[^"]*"\s*$/.exec(c);
1077
+ if (m)
1078
+ return { op: m[2], exprSide: m[1].trim() };
1079
+ m = /^@?"[^"]*"\s*(==|!=)\s*(.*)$/.exec(c);
1080
+ if (m)
1081
+ return { op: m[1], exprSide: m[2].trim() };
1082
+ return null;
1083
+ }
1084
+ const CS_IDENT_RE = /[A-Za-z_]\w*/g;
1085
+ function csIdentifiers(expr) {
1086
+ return new Set((expr.match(CS_IDENT_RE) ?? []));
1087
+ }
1088
+ /**
1089
+ * Resolve the then-block of an `if` at `ifIdx`, whose condition-line remainder
1090
+ * is `rest`. Returns the inclusive line range plus whether the block is an
1091
+ * early exit (only return/throw/continue/break).
1092
+ */
1093
+ function csThenBlock(lines, ifIdx, rest) {
1094
+ const isExit = (s) => /^\s*(?:return|throw|continue|break)\b/.test(s);
1095
+ // The then-target begins in the if-line remainder (`rest`), else on the next
1096
+ // non-empty line. When it starts on the if-line we must scan only `rest`, not
1097
+ // the whole line — earlier braces on that line (class/method `{`) are not ours.
1098
+ let firstIdx;
1099
+ let firstText;
1100
+ if (rest.trim()) {
1101
+ firstIdx = ifIdx;
1102
+ firstText = rest;
927
1103
  }
928
1104
  else {
929
- raw = (fileArg.expression ?? '').trim();
1105
+ let j = ifIdx + 1;
1106
+ while (j < lines.length && stripCsLiterals(lines[j]).trim() === '')
1107
+ j++;
1108
+ firstIdx = j;
1109
+ firstText = lines[j] ?? '';
1110
+ }
1111
+ if (firstText.trim().startsWith('{')) {
1112
+ // Braced block: brace-match from the block's own `{`, collecting the body so
1113
+ // early-exit is judged from the block's first statement, not the `if` line.
1114
+ let depth = 0;
1115
+ let started = false;
1116
+ let endLine = lines.length - 1;
1117
+ let body = '';
1118
+ for (let i = firstIdx; i < lines.length; i++) {
1119
+ const stripped = stripCsLiterals(i === firstIdx ? firstText : lines[i]);
1120
+ let broke = false;
1121
+ for (const ch of stripped) {
1122
+ if (ch === '{') {
1123
+ depth++;
1124
+ started = true;
1125
+ if (depth === 1)
1126
+ continue;
1127
+ }
1128
+ else if (ch === '}') {
1129
+ depth--;
1130
+ if (depth === 0) {
1131
+ endLine = i;
1132
+ broke = true;
1133
+ break;
1134
+ }
1135
+ }
1136
+ if (started && depth >= 1)
1137
+ body += ch;
1138
+ }
1139
+ if (broke)
1140
+ break;
1141
+ if (started)
1142
+ body += ' ';
1143
+ }
1144
+ return { start: ifIdx, end: endLine, earlyExit: isExit(body.trim()) };
930
1145
  }
931
- // constant string literal only ("git", @"C:\git.exe"); a variable exe is dangerous.
932
- if (!/^@?"[^"]*"$/.test(raw))
1146
+ // Single-statement then (same line as the `if`, or the next line).
1147
+ return { start: ifIdx, end: firstIdx, earlyExit: isExit(firstText.trim()) };
1148
+ }
1149
+ /**
1150
+ * True when a C# SSRF sink is dominated by an exact-equality host allowlist:
1151
+ * either `if (…var… == "const") <sink>` (sink inside the then-block) or
1152
+ * `if (…var… != "const") <early exit>` with the sink after the guard at the
1153
+ * same block depth. `var` is any identifier flowing into the sink argument.
1154
+ */
1155
+ function isCSharpSsrfHostAllowlistGuarded(call, pattern, language, sourceLines) {
1156
+ if (language !== 'csharp')
933
1157
  return false;
934
- const program = (raw.replace(/^@?"|"$/g, '').split(/[\\/]/).pop() ?? '')
935
- .toLowerCase().replace(/\.exe$/, '');
936
- const SHELL_PROGRAMS = new Set([
937
- 'sh', 'bash', 'zsh', 'dash', 'ash', 'ksh',
938
- 'cmd', 'powershell', 'pwsh',
939
- ]);
940
- return !SHELL_PROGRAMS.has(program);
1158
+ if (pattern.type !== 'ssrf')
1159
+ return false;
1160
+ if (!sourceLines || sourceLines.length === 0)
1161
+ return false;
1162
+ const candidates = new Set();
1163
+ for (const a of call.arguments) {
1164
+ if (a.variable)
1165
+ candidates.add(a.variable);
1166
+ for (const id of csIdentifiers(a.expression ?? ''))
1167
+ candidates.add(id);
1168
+ }
1169
+ if (candidates.size === 0)
1170
+ return false;
1171
+ const sinkIdx = call.location.line - 1;
1172
+ const sinkDepth = csBraceDepthBefore(sourceLines, sinkIdx);
1173
+ for (let i = 0; i < sinkIdx; i++) {
1174
+ const parts = splitCsIf(sourceLines[i]);
1175
+ if (!parts)
1176
+ continue;
1177
+ const guard = csEqualityGuard(parts.cond);
1178
+ if (!guard)
1179
+ continue;
1180
+ // The compared expression must reference a variable that flows to the sink.
1181
+ const guardIds = csIdentifiers(guard.exprSide);
1182
+ if (![...guardIds].some((id) => candidates.has(id)))
1183
+ continue;
1184
+ const block = csThenBlock(sourceLines, i, parts.rest);
1185
+ if (guard.op === '==') {
1186
+ // Positive allowlist: safe only when the sink sits inside the then-block.
1187
+ if (sinkIdx >= block.start && sinkIdx <= block.end)
1188
+ return true;
1189
+ }
1190
+ else {
1191
+ // Reject guard: safe only when the then-block is an early exit and the
1192
+ // sink follows it at the same enclosing depth (dominated by the guard).
1193
+ // The guard's depth includes any braces opened before `if` on its line
1194
+ // (compact `{ if (…)` layouts), so this is layout-independent.
1195
+ const guardDepth = csBraceDepthBefore(sourceLines, i) +
1196
+ netBraces(stripCsLiterals(sourceLines[i]).slice(0, parts.ifCol));
1197
+ if (block.earlyExit && sinkIdx > block.end && guardDepth === sinkDepth) {
1198
+ return true;
1199
+ }
1200
+ }
1201
+ }
1202
+ return false;
941
1203
  }
942
1204
  /**
943
1205
  * Check if a Rust `Command::new(...).arg(...).args(...).spawn().output()`
@@ -1559,7 +1821,13 @@ function findSinks(calls, patterns, typeHierarchy, language, sourceLines, types)
1559
1821
  // Skip C# `Process.Start(constNonShellExe, arguments)` argv calls —
1560
1822
  // a non-shell executable receives the arguments directly (no shell),
1561
1823
  // so tainted arguments cannot inject a command. cognium-ai#328.
1562
- if (isSafeCSharpProcessStartCall(call, pattern, language)) {
1824
+ if (isSafeCSharpProcessStartCall(call, pattern, language, sourceLines)) {
1825
+ continue;
1826
+ }
1827
+ // Skip C# SSRF sinks dominated by an inline exact-equality host
1828
+ // allowlist (`if (host == "c") sink` / `if (host != "c") return; sink`).
1829
+ // cognium-ai#328 shape 1.
1830
+ if (isCSharpSsrfHostAllowlistGuarded(call, pattern, language, sourceLines)) {
1563
1831
  continue;
1564
1832
  }
1565
1833
  // Skip typed deserialization overloads where the target type is a