circle-ir 4.14.2 → 4.15.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 (78) hide show
  1. package/dist/analysis/const-table-lookup.d.ts +29 -0
  2. package/dist/analysis/const-table-lookup.d.ts.map +1 -0
  3. package/dist/analysis/const-table-lookup.js +145 -0
  4. package/dist/analysis/const-table-lookup.js.map +1 -0
  5. package/dist/analysis/findings.d.ts.map +1 -1
  6. package/dist/analysis/findings.js +15 -4
  7. package/dist/analysis/findings.js.map +1 -1
  8. package/dist/analysis/go-strings-replacer.d.ts +21 -0
  9. package/dist/analysis/go-strings-replacer.d.ts.map +1 -0
  10. package/dist/analysis/go-strings-replacer.js +145 -0
  11. package/dist/analysis/go-strings-replacer.js.map +1 -0
  12. package/dist/analysis/interprocedural.d.ts.map +1 -1
  13. package/dist/analysis/interprocedural.js +53 -0
  14. package/dist/analysis/interprocedural.js.map +1 -1
  15. package/dist/analysis/java-canonical-prefix.d.ts +42 -0
  16. package/dist/analysis/java-canonical-prefix.d.ts.map +1 -0
  17. package/dist/analysis/java-canonical-prefix.js +145 -0
  18. package/dist/analysis/java-canonical-prefix.js.map +1 -0
  19. package/dist/analysis/java-crlf-strip.d.ts +30 -0
  20. package/dist/analysis/java-crlf-strip.d.ts.map +1 -0
  21. package/dist/analysis/java-crlf-strip.js +195 -0
  22. package/dist/analysis/java-crlf-strip.js.map +1 -0
  23. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts +2 -0
  24. package/dist/analysis/passes/cleartext-credential-transport-pass.d.ts.map +1 -1
  25. package/dist/analysis/passes/cleartext-credential-transport-pass.js +64 -12
  26. package/dist/analysis/passes/cleartext-credential-transport-pass.js.map +1 -1
  27. package/dist/analysis/passes/cross-file-pass.d.ts.map +1 -1
  28. package/dist/analysis/passes/cross-file-pass.js +43 -0
  29. package/dist/analysis/passes/cross-file-pass.js.map +1 -1
  30. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -1
  31. package/dist/analysis/passes/csharp-sanitizer-credit.js +18 -0
  32. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -1
  33. package/dist/analysis/passes/interprocedural-pass.d.ts +28 -0
  34. package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
  35. package/dist/analysis/passes/interprocedural-pass.js +20 -1
  36. package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
  37. package/dist/analysis/passes/language-sources-pass.d.ts +41 -2
  38. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  39. package/dist/analysis/passes/language-sources-pass.js +842 -10
  40. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  41. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts +8 -0
  42. package/dist/analysis/passes/param-source-flow-gate-pass.d.ts.map +1 -1
  43. package/dist/analysis/passes/param-source-flow-gate-pass.js +162 -6
  44. package/dist/analysis/passes/param-source-flow-gate-pass.js.map +1 -1
  45. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  46. package/dist/analysis/passes/sink-filter-pass.js +207 -1
  47. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  48. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  49. package/dist/analysis/passes/taint-propagation-pass.js +257 -2
  50. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  51. package/dist/analysis/scalar-derivation.d.ts +25 -0
  52. package/dist/analysis/scalar-derivation.d.ts.map +1 -0
  53. package/dist/analysis/scalar-derivation.js +196 -0
  54. package/dist/analysis/scalar-derivation.js.map +1 -0
  55. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  56. package/dist/analysis/taint-matcher.js +167 -6
  57. package/dist/analysis/taint-matcher.js.map +1 -1
  58. package/dist/analysis/taint-propagation.d.ts.map +1 -1
  59. package/dist/analysis/taint-propagation.js +149 -19
  60. package/dist/analysis/taint-propagation.js.map +1 -1
  61. package/dist/analyzer.d.ts.map +1 -1
  62. package/dist/analyzer.js +7 -0
  63. package/dist/analyzer.js.map +1 -1
  64. package/dist/browser/circle-ir.js +3140 -1186
  65. package/dist/core/circle-ir-core.cjs +485 -27
  66. package/dist/core/circle-ir-core.js +485 -27
  67. package/dist/core/extractors/dfg.js +240 -6
  68. package/dist/core/extractors/dfg.js.map +1 -1
  69. package/dist/graph/code-graph.d.ts +9 -0
  70. package/dist/graph/code-graph.d.ts.map +1 -1
  71. package/dist/graph/code-graph.js +11 -0
  72. package/dist/graph/code-graph.js.map +1 -1
  73. package/dist/resolution/cross-file.d.ts +7 -0
  74. package/dist/resolution/cross-file.d.ts.map +1 -1
  75. package/dist/resolution/cross-file.js +10 -1
  76. package/dist/resolution/cross-file.js.map +1 -1
  77. package/dist/wasm/web-tree-sitter.wasm +0 -0
  78. package/package.json +3 -3
@@ -10,12 +10,14 @@
10
10
  *
11
11
  * Depends on: sink-filter, constant-propagation
12
12
  */
13
+ import { findJavaSpringReturnXSSSinks } from './language-sources-pass.js';
13
14
  import { identifierOutsideSanitizerCalls, propagateTaint } from '../taint-propagation.js';
14
15
  import { isFalsePositive, isCorrelatedPredicateFP } from '../constant-propagation.js';
15
16
  import { buildJavaTaintedVars, buildPythonTaintedVars, buildRustTaintedVars, findPythonTrustBoundaryViolations, findPythonReturnXSSSinks } from './language-sources-pass.js';
16
17
  import { canSourceReachSink, sourceSemanticsAllowed } from '../findings.js';
17
18
  import { walkBackwardDefs } from '../dfg-walk.js';
18
19
  import { sanitizerCoversSink } from '../sanitizer-index.js';
20
+ import { javaCanonicalStartsWithAllBare } from '../java-canonical-prefix.js';
19
21
  /**
20
22
  * cognium-dev#328 — the const-prop per-step veto, with two scoped exemptions.
21
23
  *
@@ -40,6 +42,177 @@ import { sanitizerCoversSink } from '../sanitizer-index.js';
40
42
  * code, resolved constant) are unaffected.
41
43
  */
42
44
  const CONTAINER_READ_SOURCES = new Set(['plugin_param', 'config_param']);
45
+ /**
46
+ * #515 — find the real source a Python derived variable actually references.
47
+ * Reads the derivation's right-hand side at its origin line (f-string braces
48
+ * kept, plain quotes and trailing `#…` comments dropped), picks the real
49
+ * source variables named there (earliest line wins when several), and follows
50
+ * names that are themselves derived — including a later assignment of a name
51
+ * that was also a real source. Returns undefined when nothing can be
52
+ * resolved, so the caller keeps its earliest-source fallback.
53
+ */
54
+ /** Drop a trailing `#…` comment, leaving `#` inside string literals alone. */
55
+ function stripPythonTrailingComment(text) {
56
+ let i = 0;
57
+ while (i < text.length) {
58
+ const ch = text[i];
59
+ if (ch === '"' || ch === "'") {
60
+ const triple = text.startsWith(ch.repeat(3), i);
61
+ const delim = triple ? ch.repeat(3) : ch;
62
+ i += delim.length;
63
+ while (i < text.length && !text.startsWith(delim, i)) {
64
+ if (text[i] === '\\') {
65
+ i += 2;
66
+ continue;
67
+ }
68
+ i++;
69
+ }
70
+ i += delim.length;
71
+ continue;
72
+ }
73
+ if (ch === '#')
74
+ return text.slice(0, i);
75
+ i++;
76
+ }
77
+ return text;
78
+ }
79
+ /**
80
+ * Keep only the f-string interpolation expression: cut before a top-level
81
+ * `!conversion` or `:format_spec` (Python replacement_field grammar). A `!`
82
+ * followed by `=` is the `!=` operator and is not a cut point. Depth
83
+ * tracks `()[]{}`; string literals are skipped so a `:` inside a subscript
84
+ * key does not truncate the expression.
85
+ */
86
+ function pythonFStringNestedFields(body) {
87
+ // Format-spec / conversion tails only: a `{` here always opens a nested
88
+ // replacement field (Python 3.12+). Do NOT treat `{{` / `}}` as literal-brace
89
+ // escapes — `f"{label:{{fmt}}}"` parses as a nested FormattedValue whose
90
+ // expression is the set `{fmt}`. The top-level f-string skip stays in
91
+ // braceExprs so a literal `f"{{fmt}}"` still does not credit `fmt`. Each
92
+ // nested `{…}` is itself an expression (and may carry its own conversion /
93
+ // format-spec), so recurse through pythonFStringExprOnly.
94
+ let acc = '';
95
+ for (let j = 0; j < body.length;) {
96
+ if (body[j] === '{') {
97
+ j++;
98
+ let depth = 1;
99
+ let inner = '';
100
+ while (j < body.length && depth > 0) {
101
+ if (body[j] === '{')
102
+ depth++;
103
+ else if (body[j] === '}') {
104
+ depth--;
105
+ if (depth === 0)
106
+ break;
107
+ }
108
+ inner += body[j];
109
+ j++;
110
+ }
111
+ j++;
112
+ acc += ' ' + pythonFStringExprOnly(inner) + ' ';
113
+ continue;
114
+ }
115
+ j++;
116
+ }
117
+ return acc;
118
+ }
119
+ /**
120
+ * Keep the expression part of an f-string replacement field; drop literal
121
+ * conversion / format-spec text, but retain nested `{…}` fields from the
122
+ * discarded tail (format specs may contain nested replacement fields).
123
+ */
124
+ function pythonFStringExprOnly(inner) {
125
+ let depth = 0;
126
+ let i = 0;
127
+ while (i < inner.length) {
128
+ const ch = inner[i];
129
+ if (ch === '"' || ch === "'") {
130
+ const triple = inner.startsWith(ch.repeat(3), i);
131
+ const delim = triple ? ch.repeat(3) : ch;
132
+ i += delim.length;
133
+ while (i < inner.length && !inner.startsWith(delim, i)) {
134
+ if (inner[i] === '\\') {
135
+ i += 2;
136
+ continue;
137
+ }
138
+ i++;
139
+ }
140
+ i += delim.length;
141
+ continue;
142
+ }
143
+ if (ch === '(' || ch === '[' || ch === '{') {
144
+ depth++;
145
+ i++;
146
+ continue;
147
+ }
148
+ if (ch === ')' || ch === ']' || ch === '}') {
149
+ depth--;
150
+ i++;
151
+ continue;
152
+ }
153
+ if (depth === 0 && ch === '!') {
154
+ // `!=` is the comparison operator, not a `!conversion`; keep scanning.
155
+ let j = i + 1;
156
+ while (j < inner.length && (inner[j] === ' ' || inner[j] === '\t'))
157
+ j++;
158
+ if (inner[j] === '=') {
159
+ i = j + 1;
160
+ continue;
161
+ }
162
+ const expr = inner.slice(0, i);
163
+ const nested = pythonFStringNestedFields(inner.slice(i + 1));
164
+ return nested ? expr + nested : expr;
165
+ }
166
+ if (depth === 0 && ch === ':') {
167
+ const expr = inner.slice(0, i);
168
+ const nested = pythonFStringNestedFields(inner.slice(i + 1));
169
+ return nested ? expr + nested : expr;
170
+ }
171
+ i++;
172
+ }
173
+ return inner;
174
+ }
175
+ function resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines, visiting) {
176
+ if (visiting.has(varName))
177
+ return undefined;
178
+ visiting.add(varName);
179
+ const originLine = derived.get(varName);
180
+ if (originLine === undefined)
181
+ return undefined;
182
+ const text = codeLines[originLine - 1];
183
+ if (!text)
184
+ return undefined;
185
+ const eq = text.search(/(?<![=!<>+\-*/%&|^:])=(?!=)/);
186
+ if (eq < 0)
187
+ return undefined;
188
+ // Drop a real trailing `#…` comment first (outside strings only), then lift
189
+ // f-string `{…}` names. Format specs / conversion flags are cut inside
190
+ // braceExprs so a `#` in `{width:#04x}` cannot eat later names, and `!s`
191
+ // cannot become a scanned identifier; nested `{…}` fields in the format
192
+ // spec (e.g. `{label:{fmt}}`) are kept.
193
+ const rhs = maskStringLiteralsForTokenScan(stripPythonTrailingComment(text.slice(eq + 1)));
194
+ let best;
195
+ for (const m of rhs.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
196
+ const id = m[0];
197
+ if (id === varName)
198
+ continue;
199
+ const real = realSourceByVar.get(id);
200
+ const derivedLine = derived.get(id);
201
+ // A later assignment of the same name (recorded in `derived` after the
202
+ // real source line) is the value that flows — resolve it first. Fall
203
+ // back to the real source when that derivation does not resolve (e.g.
204
+ // `fmt = request.form.get("fmt")`, whose RHS names no source variable).
205
+ let cand;
206
+ if (derivedLine !== undefined && (!real || derivedLine > real.line)) {
207
+ cand = resolvePythonDerivedAnchor(id, derived, realSourceByVar, codeLines, visiting);
208
+ }
209
+ if (!cand)
210
+ cand = real;
211
+ if (cand && (!best || cand.line < best.line))
212
+ best = cand;
213
+ }
214
+ return best;
215
+ }
43
216
  function makeConstPropVeto(constProp, graph, sources, code, language) {
44
217
  const lines = typeof code === 'string' ? code.split('\n') : [];
45
218
  const selfReassign = new Map();
@@ -186,6 +359,38 @@ export class TaintPropagationPass {
186
359
  flows.push(f);
187
360
  return true;
188
361
  };
362
+ // Supplement: Spring MVC return-as-body XSS flows — cognium-dev#566. The
363
+ // sink `LanguageSourcesPass` synthesises at the `return` has no method or
364
+ // argument, so no argument matcher reaches it; the detector already knows
365
+ // both ends, so the flow is emitted here (as for Python #368 below).
366
+ if (ctx.language === 'java' && typeof ctx.code === 'string') {
367
+ const xssSanitizerLines = sanitizers
368
+ .filter(sa => sa.sanitizes.includes('xss'))
369
+ .map(sa => sa.line);
370
+ for (const rx of findJavaSpringReturnXSSSinks(ctx.code, types, sources, xssSanitizerLines)) {
371
+ if (constProp.unreachableLines.has(rx.sinkLine))
372
+ continue;
373
+ const sink = sinks.find(sk => sk.line === rx.sinkLine && sk.type === 'xss');
374
+ if (!sink)
375
+ continue;
376
+ const atLine = sources.filter(sc => sc.line === rx.sourceLine);
377
+ const src = atLine.find(sc => sc.type !== 'interprocedural_param') ?? atLine[0];
378
+ if (!src)
379
+ continue;
380
+ pushIfNew({
381
+ source_line: src.line,
382
+ sink_line: rx.sinkLine,
383
+ source_type: src.type,
384
+ sink_type: 'xss',
385
+ path: [
386
+ { variable: src.variable ?? 'response', line: src.line, type: 'source' },
387
+ { variable: src.variable ?? 'response', line: rx.sinkLine, type: 'sink' },
388
+ ],
389
+ confidence: (src.confidence ?? 1) * (sink.confidence ?? 1),
390
+ sanitized: false,
391
+ });
392
+ }
393
+ }
189
394
  // Supplement: Python trust-boundary (CWE-501) flows — cognium-dev#363.
190
395
  //
191
396
  // `LanguageSourcesPass` detects `session[<tainted>] = …` and synthesises a
@@ -421,6 +626,24 @@ export class TaintPropagationPass {
421
626
  arr.push(san);
422
627
  sanitizersByLine.set(san.line, arr);
423
628
  }
629
+ // True unless every def at `line` among `visited` is provably off this
630
+ // flow: not a variable on its path, and a complete backward walk from it
631
+ // never reaches `sourceLine`.
632
+ const sanitizedDefOnFlow = (visited, line, f) => {
633
+ let sawDef = false;
634
+ for (const id of visited) {
635
+ const def = graph.defById.get(id);
636
+ if (!def || def.line !== line)
637
+ continue;
638
+ sawDef = true;
639
+ if (f.path.some(step => step.variable === def.variable))
640
+ return true;
641
+ const back = walkBackwardDefs(id, graph.chainsByToDef, graph.defById, { maxHops: 32 });
642
+ if (back.hopCapReached || back.lines.has(f.source_line))
643
+ return true;
644
+ }
645
+ return !sawDef;
646
+ };
424
647
  finalFlows = finalFlows.filter(f => {
425
648
  // Two-tier filter:
426
649
  // - `external_taint_escape` (synthetic CWE-668 fallback): no
@@ -507,6 +730,12 @@ export class TaintPropagationPass {
507
730
  const sansAtLine = sanitizersByLine.get(line);
508
731
  if (!sansAtLine || sansAtLine.length === 0)
509
732
  continue;
733
+ // cognium-dev#348: the walk visits every contributor of the sink
734
+ // value. In `clean = a.replaceAll(..); msg = clean + b; log(msg)`
735
+ // the strip on `a` must not credit the flow from `b`. Skip a
736
+ // sanitized def that does not derive from this flow's source.
737
+ if (line > f.source_line && !sanitizedDefOnFlow(walk.visited, line, f))
738
+ continue;
510
739
  for (const san of sansAtLine) {
511
740
  if (sanitizerCoversSink(san, f.sink_type)) {
512
741
  return false;
@@ -829,6 +1058,10 @@ function isInJavaSanitizedMethod(code, types, sinkLine, sinkType) {
829
1058
  return false;
830
1059
  if (!/\.startsWith\s*\([^)]*getCanonicalPath/.test(body))
831
1060
  return false;
1061
+ // cognium-dev#618: `startsWith(parent.getCanonicalPath())` with no
1062
+ // trailing separator is the prefix-confusion bypass, not a guard.
1063
+ if (javaCanonicalStartsWithAllBare(body))
1064
+ return false;
832
1065
  if (!/\bthrow\s+new\b/.test(body))
833
1066
  return false;
834
1067
  return true;
@@ -1599,9 +1832,18 @@ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachabl
1599
1832
  }
1600
1833
  const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
1601
1834
  const literalOnly = new RegExp(`^${strLit}$`);
1835
+ // For an if/else chain the literal's own arm is skipped: an earlier
1836
+ // assignment in that same arm (`if (a) { if (b) x = input; x = "safe"; }`) is
1837
+ // overwritten by the literal, it is not a sibling branch. A switch body shares
1838
+ // one brace, so every line of it stays a sibling.
1839
+ const isSwitch = /\bswitch\b/.test(block.header);
1840
+ const ownStart = block.line;
1841
+ const ownEnd = end.line;
1602
1842
  for (let i = first; i <= last; i++) {
1603
1843
  if (i === idx || unreachableLines?.has(i + 1))
1604
1844
  continue;
1845
+ if (!isSwitch && i > ownStart && i < ownEnd)
1846
+ continue;
1605
1847
  const m = assign.exec(lines[i]);
1606
1848
  if (m && m[1] && !literalOnly.test(m[1]))
1607
1849
  return true;
@@ -1692,7 +1934,7 @@ export function maskStringLiteralsForTokenScan(expr) {
1692
1934
  j++;
1693
1935
  }
1694
1936
  j++;
1695
- acc += ' ' + maskStringLiteralsForTokenScan(inner) + ' ';
1937
+ acc += ' ' + maskStringLiteralsForTokenScan(pythonFStringExprOnly(inner)) + ' ';
1696
1938
  continue;
1697
1939
  }
1698
1940
  j++;
@@ -1885,6 +2127,15 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1885
2127
  anchor = s;
1886
2128
  }
1887
2129
  }
2130
+ // #515: real (non-derived) sources by variable name, earliest first, and
2131
+ // the file's lines for reading each derivation's right-hand side.
2132
+ const realSourceByVar = new Map();
2133
+ for (const s of sourcesWithVar) {
2134
+ const prev = realSourceByVar.get(s.variable);
2135
+ if (!prev || s.line < prev.line)
2136
+ realSourceByVar.set(s.variable, s);
2137
+ }
2138
+ const codeLines = code.split('\n');
1888
2139
  for (const [varName, originLine] of derived) {
1889
2140
  if (!varName || existingVars.has(varName))
1890
2141
  continue;
@@ -1894,8 +2145,12 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1894
2145
  // `http_param` type — the for-loop / bare-inline patterns we want
1895
2146
  // to recover here are all web-request-derived in practice.
1896
2147
  if (hasRealSource && anchor) {
2148
+ // #515: anchor to the real source this derivation actually
2149
+ // references (through earlier derived vars); the earliest-source
2150
+ // anchor stays the fallback when none can be found.
2151
+ const referenced = resolvePythonDerivedAnchor(varName, derived, realSourceByVar, codeLines, new Set());
1897
2152
  sourcesWithVar.push({
1898
- ...anchor,
2153
+ ...(referenced ?? anchor),
1899
2154
  variable: varName,
1900
2155
  });
1901
2156
  }