circle-ir 4.9.27 → 4.9.29

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 (31) hide show
  1. package/dist/analysis/config-loader.d.ts.map +1 -1
  2. package/dist/analysis/config-loader.js +58 -5
  3. package/dist/analysis/config-loader.js.map +1 -1
  4. package/dist/analysis/findings.d.ts.map +1 -1
  5. package/dist/analysis/findings.js +130 -0
  6. package/dist/analysis/findings.js.map +1 -1
  7. package/dist/analysis/interprocedural.d.ts.map +1 -1
  8. package/dist/analysis/interprocedural.js +63 -0
  9. package/dist/analysis/interprocedural.js.map +1 -1
  10. package/dist/analysis/passes/language-sources-pass.d.ts.map +1 -1
  11. package/dist/analysis/passes/language-sources-pass.js +162 -8
  12. package/dist/analysis/passes/language-sources-pass.js.map +1 -1
  13. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  14. package/dist/analysis/passes/taint-propagation-pass.js +214 -16
  15. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  16. package/dist/analysis/passes/variable-shadowing-pass.d.ts.map +1 -1
  17. package/dist/analysis/passes/variable-shadowing-pass.js +15 -0
  18. package/dist/analysis/passes/variable-shadowing-pass.js.map +1 -1
  19. package/dist/analysis/taint-matcher.d.ts.map +1 -1
  20. package/dist/analysis/taint-matcher.js +62 -1
  21. package/dist/analysis/taint-matcher.js.map +1 -1
  22. package/dist/browser/circle-ir.js +486 -33
  23. package/dist/core/circle-ir-core.cjs +173 -13
  24. package/dist/core/circle-ir-core.js +173 -13
  25. package/dist/core/extractors/calls.js +155 -1
  26. package/dist/core/extractors/calls.js.map +1 -1
  27. package/dist/core/extractors/types.js +50 -7
  28. package/dist/core/extractors/types.js.map +1 -1
  29. package/dist/types/config.d.ts +14 -0
  30. package/dist/types/config.d.ts.map +1 -1
  31. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"taint-propagation-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/taint-propagation-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAU9E,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,aAAa,EAAE,CAAC;CACxB;AAED,qBAAa,oBAAqB,YAAW,YAAY,CAAC,0BAA0B,CAAC;IACnF,QAAQ,CAAC,IAAI,uBAAuB;IACpC,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,0BAA0B,CA2hBhD;CACF;AA6sBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,wBAAgB,8BAA8B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA6FnE"}
1
+ {"version":3,"file":"taint-propagation-pass.d.ts","sourceRoot":"","sources":["../../../src/analysis/passes/taint-propagation-pass.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAE1D,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAU9E,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,aAAa,EAAE,CAAC;CACxB;AA0FD,qBAAa,oBAAqB,YAAW,YAAY,CAAC,0BAA0B,CAAC;IACnF,QAAQ,CAAC,IAAI,uBAAuB;IACpC,QAAQ,CAAC,QAAQ,EAAG,UAAU,CAAU;IAExC,GAAG,CAAC,GAAG,EAAE,WAAW,GAAG,0BAA0B,CA8hBhD;CACF;AA6sBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AACH,wBAAgB,8BAA8B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA6FnE"}
@@ -16,6 +16,97 @@ import { buildJavaTaintedVars, buildPythonTaintedVars, buildRustTaintedVars, fin
16
16
  import { canSourceReachSink, sourceSemanticsAllowed } from '../findings.js';
17
17
  import { walkBackwardDefs } from '../dfg-walk.js';
18
18
  import { sanitizerCoversSink } from '../sanitizer-index.js';
19
+ /**
20
+ * cognium-dev#328 — the const-prop per-step veto, with two scoped exemptions.
21
+ *
22
+ * `isFalsePositive`'s `variable_not_tainted` reason discards a flow when
23
+ * const-prop tracked the step variable (value `unknown`) but never marked it
24
+ * tainted. On Java that verdict is usually informed — const-prop does its own
25
+ * key-aware collection / safe-overwrite reasoning, and lifting the veto
26
+ * globally measured +1 on-category TP vs +311 FP files on OWASP. But const-prop
27
+ * only seeds taint from its own narrow pattern list, so a variable fed by a
28
+ * parameter or by a registered source it has no pattern for is "untainted"
29
+ * purely for lack of information.
30
+ *
31
+ * Walking the step variable's reaching-def chain back (through self-derived
32
+ * reassignments only), the veto is skipped when:
33
+ * (A) the chain crosses at least one `x = f(x)` / `x op= y` and ends inside
34
+ * the source..sink span — the #287 shape (`v = v.Trim()`); or
35
+ * (E) the chain ends at the source's own assignment line, and that source is
36
+ * not a container read (`plugin_param` / `config_param`), where const-prop
37
+ * is key-aware and its verdict is informed.
38
+ * Reaching defs are per method, so a same-named variable in a sibling method
39
+ * (Juliet `*_41` / `*_45` G2B sinks) never qualifies. Reasons 1 and 2 (dead
40
+ * code, resolved constant) are unaffected.
41
+ */
42
+ const CONTAINER_READ_SOURCES = new Set(['plugin_param', 'config_param']);
43
+ function makeConstPropVeto(constProp, graph, sources, code) {
44
+ const lines = typeof code === 'string' ? code.split('\n') : [];
45
+ const selfReassign = new Map();
46
+ const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
+ for (const [line, defs] of graph.defsByLine) {
48
+ const text = lines[line - 1];
49
+ if (!text)
50
+ continue;
51
+ for (const d of defs) {
52
+ if (d.kind !== 'local' && d.kind !== 'param')
53
+ continue;
54
+ const v = escape(d.variable);
55
+ // `v op= …` or `v = …v…` (RHS reads the variable being assigned).
56
+ const m = new RegExp(`(?:^|[^\\w.$])${v}\\s*(?:([-+*/%&|^]|<<|>>)=|=(?!=))(.*)$`).exec(text);
57
+ if (!m)
58
+ continue;
59
+ if (m[1] || new RegExp(`(?:^|[^\\w.$])${v}\\b`).test(m[2])) {
60
+ const arr = selfReassign.get(d.variable) ?? [];
61
+ arr.push(line);
62
+ selfReassign.set(d.variable, arr);
63
+ }
64
+ }
65
+ }
66
+ const reassignedAt = (v, line) => (selfReassign.get(v) ?? []).includes(line);
67
+ /** Reaching def of `v` as read on `line` (a def on that line wins: it is the value after the line). */
68
+ const defAt = (v, line) => {
69
+ const own = (graph.defsByLine.get(line) ?? []).find(d => d.variable === v);
70
+ if (own)
71
+ return own;
72
+ const use = (graph.usesByLine.get(line) ?? []).find(u => u.variable === v && u.def_id != null);
73
+ return use ? graph.defById.get(use.def_id) : undefined;
74
+ };
75
+ return (step, sourceLine, sinkLine) => {
76
+ const r = isFalsePositive(constProp, step.line, step.variable);
77
+ if (!r.isFalsePositive)
78
+ return false;
79
+ if (r.reason !== 'variable_not_tainted')
80
+ return true;
81
+ const lo = Math.min(sourceLine, sinkLine);
82
+ // Walk the reaching-def chain back from this step, stepping through
83
+ // self-derived reassignments only. Reaching defs are per method, so a
84
+ // same-named variable in a sibling method can never be picked up.
85
+ let d = defAt(step.variable, step.line);
86
+ let selfHops = 0;
87
+ for (let guard = 0; d && d.line > lo && reassignedAt(d.variable, d.line) && guard < 32; guard++) {
88
+ const here = d.id;
89
+ const prior = (graph.usesByLine.get(d.line) ?? [])
90
+ .find(u => u.variable === d.variable && u.def_id != null && u.def_id !== here);
91
+ if (!prior)
92
+ break;
93
+ selfHops++;
94
+ d = graph.defById.get(prior.def_id);
95
+ }
96
+ if (!d)
97
+ return true;
98
+ // (E) the chain ends at the source's own assignment, so const-prop's
99
+ // "untainted" can only mean it has no pattern for this source. Container
100
+ // reads are excluded: const-prop tracks collection keys, and there its
101
+ // verdict is informed (`bar = map.get("safeKey")` — 10 OWASP FP files).
102
+ if (d.line === sourceLine && sources.some(s => s.line === sourceLine && !CONTAINER_READ_SOURCES.has(s.type)))
103
+ return false;
104
+ // (A) the chain crossed at least one `x = f(x)` and ends inside the span.
105
+ if (selfHops > 0 && d.line >= lo)
106
+ return false;
107
+ return true;
108
+ };
109
+ }
19
110
  export class TaintPropagationPass {
20
111
  name = 'taint-propagation';
21
112
  category = 'security';
@@ -37,13 +128,15 @@ export class TaintPropagationPass {
37
128
  }
38
129
  // DFG-based taint propagation
39
130
  const propagationResult = propagateTaint(graph, sources, sinks, sanitizers);
131
+ // cognium-dev#328 — scoped exemption from const-prop's `variable_not_tainted`
132
+ // veto for self-derived reassignment (`x = f(x)`, `x += y`), the #287 shape.
133
+ const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code);
40
134
  // Filter flows: eliminate dead-code paths and constant-propagation FPs
41
135
  const verifiedFlows = propagationResult.flows.filter(flow => {
42
136
  if (constProp.unreachableLines.has(flow.sink.line))
43
137
  return false;
44
138
  for (const step of flow.path) {
45
- const fpCheck = isFalsePositive(constProp, step.line, step.variable);
46
- if (fpCheck.isFalsePositive)
139
+ if (isVetoed(step, flow.source.line, flow.sink.line))
47
140
  return false;
48
141
  }
49
142
  if (isCorrelatedPredicateFP(constProp, flow))
@@ -216,7 +309,7 @@ export class TaintPropagationPass {
216
309
  continue;
217
310
  let isFP = false;
218
311
  for (const step of f.path) {
219
- if (isFalsePositive(constProp, step.line, step.variable).isFalsePositive) {
312
+ if (isVetoed(step, f.source_line, f.sink_line)) {
220
313
  isFP = true;
221
314
  break;
222
315
  }
@@ -261,7 +354,7 @@ export class TaintPropagationPass {
261
354
  continue;
262
355
  let isFP = false;
263
356
  for (const step of f.path) {
264
- if (isFalsePositive(constProp, step.line, step.variable).isFalsePositive) {
357
+ if (isVetoed(step, f.source_line, f.sink_line)) {
265
358
  isFP = true;
266
359
  break;
267
360
  }
@@ -1403,19 +1496,40 @@ export function maskStringLiteralsForTokenScan(expr) {
1403
1496
  }
1404
1497
  return out;
1405
1498
  }
1499
+ /**
1500
+ * Does `expr` reference one of `sources`' variables in a CODE position?
1501
+ *
1502
+ * String-literal bodies are stripped first, so a tainted variable NAME that
1503
+ * merely appears as text inside the SQL or markup — `id` in
1504
+ * `"… WHERE id=@id"`, which is the parameterized, safe form — does not count.
1505
+ * Interpolation holes are kept, because `$"… {id}"` really does splice the
1506
+ * value in. Shared by the #271 CommandText seeding and the #530 builder
1507
+ * seeding, which need exactly the same judgement.
1508
+ */
1509
+ function argsReferenceTaintedVar(expr, sources) {
1510
+ const codePart = expr
1511
+ .replace(/\$@?"(?:[^"\\]|\\.)*"/g, (lit) => ' ' + [...lit.matchAll(/\{([^}]*)\}/g)].map(x => x[1]).join(' ') + ' ')
1512
+ .replace(/@?"(?:[^"\\]|\\.)*"/g, ' ')
1513
+ // Comments are not code. A trailing `// pass `id` through` would otherwise
1514
+ // seed taint on the strength of prose. Literals are stripped first, so a
1515
+ // `//` inside a string (a URL, say) is already gone by this point.
1516
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
1517
+ .replace(/\/\/[^\n]*/g, ' ');
1518
+ return sources.some(s => new RegExp(`(?<![\\p{L}\\p{N}_])${s.variable.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}_])`, 'u').test(codePart));
1519
+ }
1406
1520
  function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachableLines, tainted, code, language, types) {
1407
1521
  const flows = [];
1408
1522
  // #472 — Go function ranges for the same-function gate below. Returns the
1409
1523
  // start line of the outermost function containing `line`, or -1 when the
1410
1524
  // line is at package level (no scoping then, so package-level sources still
1411
1525
  // reach every function).
1412
- const goFnRanges = language === 'go' && types
1526
+ const methodRanges = (langs) => language && langs.includes(language) && types
1413
1527
  ? types.flatMap((t) => t.methods.map((m) => [m.start_line, m.end_line]))
1414
1528
  : [];
1415
- const goFnStart = (line) => {
1529
+ const enclosingStart = (ranges, line) => {
1416
1530
  let best = -1;
1417
1531
  let bestSpan = -1;
1418
- for (const [start, end] of goFnRanges) {
1532
+ for (const [start, end] of ranges) {
1419
1533
  if (line >= start && line <= end && end - start > bestSpan) {
1420
1534
  best = start;
1421
1535
  bestSpan = end - start;
@@ -1423,6 +1537,14 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1423
1537
  }
1424
1538
  return best;
1425
1539
  };
1540
+ const goFnRanges = methodRanges(['go']);
1541
+ const goFnStart = (line) => enclosingStart(goFnRanges, line);
1542
+ // cognium-dev#530 — the same gate for C#, used by the builder seeding below.
1543
+ // Available only since #539 made C# method ranges actually populate; before
1544
+ // that `types[].methods` was empty for any file with a `#if` directive, which
1545
+ // is every Juliet C# file.
1546
+ const csFnRanges = methodRanges(['csharp']);
1547
+ const csFnStart = (line) => enclosingStart(csFnRanges, line);
1426
1548
  // Variable-name scan path: only consider sources that carry an explicit
1427
1549
  // variable name. The colocation path below (cognium-dev #83) runs even
1428
1550
  // when this set is empty, so we no longer early-return.
@@ -1902,21 +2024,79 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1902
2024
  const [, obj, rhs] = m;
1903
2025
  if (known.has(obj))
1904
2026
  continue;
1905
- // Reduce the RHS to code positions: keep interpolation `{…}` contents,
1906
- // drop plain string-literal bodies — so a tainted var NAME appearing as
1907
- // a substring inside the SQL text (e.g. `id` in `"… WHERE id=@id"`, a
1908
- // parameterized/safe query) does NOT spuriously taint the command.
1909
- const codePart = rhs
1910
- .replace(/\$@?"(?:[^"\\]|\\.)*"/g, (lit) => ' ' + [...lit.matchAll(/\{([^}]*)\}/g)].map(x => x[1]).join(' ') + ' ')
1911
- .replace(/@?"(?:[^"\\]|\\.)*"/g, ' ');
1912
- const rhsTainted = sourcesWithVar.some(s => new RegExp(`(?<![\\p{L}\\p{N}_])${s.variable.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}(?![\\p{L}\\p{N}_])`, 'u').test(codePart));
1913
- if (!rhsTainted)
2027
+ if (!argsReferenceTaintedVar(rhs, sourcesWithVar))
1914
2028
  continue;
1915
2029
  sourcesWithVar.push({ ...anchor, variable: obj, line: i + 1 });
1916
2030
  added = true;
1917
2031
  }
1918
2032
  }
1919
2033
  }
2034
+ // Keys (`variable@line`) of sources synthesised by the #530 builder seeding
2035
+ // below. These need a tighter match rule than a real source: see the gate in
2036
+ // the candidate loop.
2037
+ const builderSeeds = new Set();
2038
+ // cognium-dev#530: builder-carried taint. `sb.Append(<tainted>)` leaves the
2039
+ // taint inside the StringBuilder, and `sb.ToString()` reads it back out — so
2040
+ // without this, a statement accumulated across several calls is invisible
2041
+ // while the same statement concatenated in one expression is caught. That is
2042
+ // not a corner case: a StringBuilder is the idiomatic way to build anything
2043
+ // multi-line in C#, and NIST Juliet's entire CWE-94 family uses it, which is
2044
+ // why that family scored 0.0% for want of propagation rather than a sink.
2045
+ //
2046
+ // Same shape as #271 above: taint enters an object, is read back later, so it
2047
+ // has to ride the object. Seeding the receiver as a tainted var at the append
2048
+ // line lets the existing argument scan link `sb.ToString()` to its sink.
2049
+ //
2050
+ // Receiver-gated to variables declared as a StringBuilder. `Insert` and
2051
+ // `Append` are far too common as names to match on a bare receiver — gating
2052
+ // on the declared type is what keeps this from tainting arbitrary objects.
2053
+ if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0) {
2054
+ const lines = code.split('\n');
2055
+ // `StringBuilder sb` covers locals, fields and parameters; the second form
2056
+ // covers `var sb = new StringBuilder(...)`, where the type is on the right.
2057
+ const builders = new Set();
2058
+ for (const line of lines) {
2059
+ for (const m of line.matchAll(/\bStringBuilder\s+([\p{L}_][\p{L}\p{N}_]*)/gu))
2060
+ builders.add(m[1]);
2061
+ for (const m of line.matchAll(/\b([\p{L}_][\p{L}\p{N}_]*)\s*=\s*new\s+StringBuilder\b/gu))
2062
+ builders.add(m[1]);
2063
+ }
2064
+ if (builders.size > 0) {
2065
+ // `Append`, `AppendLine`, `AppendFormat` and `Insert` all write through to
2066
+ // the builder's contents. `Replace` is deliberately absent: it can just as
2067
+ // easily be removing the tainted text as adding it, so crediting it either
2068
+ // way would be a guess.
2069
+ const appendRe = /([\p{L}_][\p{L}\p{N}_]*)\s*\.\s*(?:Append|AppendLine|AppendFormat|Insert)\s*\(([\s\S]*)/u;
2070
+ const anchor = sourcesWithVar.reduce((a, s) => (s.line < a.line ? s : a), sourcesWithVar[0]);
2071
+ let added = true;
2072
+ let guard = 0;
2073
+ while (added && guard < lines.length + 2) {
2074
+ added = false;
2075
+ guard++;
2076
+ const known = new Set(sourcesWithVar.map(s => s.variable));
2077
+ for (let i = 0; i < lines.length; i++) {
2078
+ const m = appendRe.exec(lines[i]);
2079
+ if (!m)
2080
+ continue;
2081
+ const [, obj, args] = m;
2082
+ if (!builders.has(obj) || known.has(obj))
2083
+ continue;
2084
+ // Same-method gate. `sourcesWithVar` is file-wide, so without this a
2085
+ // source named `data` in one method taints a builder appended to in
2086
+ // another method that happens to use the same local name — which is
2087
+ // precisely the Juliet `Bad` / `GoodG2B` pair, and measured as 186
2088
+ // false positives against 82 true ones before the gate.
2089
+ const appendFn = csFnStart(i + 1);
2090
+ const reachable = sourcesWithVar.filter((sv) => appendFn === -1 || csFnStart(sv.line) === -1 || csFnStart(sv.line) === appendFn);
2091
+ if (reachable.length > 0 && argsReferenceTaintedVar(args, reachable)) {
2092
+ sourcesWithVar.push({ ...anchor, variable: obj, line: i + 1 });
2093
+ builderSeeds.add(`${obj}@${i + 1}`);
2094
+ added = true;
2095
+ }
2096
+ }
2097
+ }
2098
+ }
2099
+ }
1920
2100
  // A single identifier token; such variables are matched via the fast token
1921
2101
  // index below, where membership is exactly equivalent to a word-boundary
1922
2102
  // regex — so they need no compiled regex at all.
@@ -2038,6 +2218,24 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2038
2218
  if (srcFn !== -1 && sinkFn !== -1 && srcFn !== sinkFn)
2039
2219
  continue;
2040
2220
  }
2221
+ // cognium-dev#530: a builder-seeded source must not escape its method.
2222
+ // The seed stands for "this StringBuilder now holds tainted text",
2223
+ // which is true only of the one object in the one method where the
2224
+ // append happened. The scan above matches by NAME across the whole
2225
+ // file, and Juliet reuses `sourceCode` in `Bad` and in every `Good*`
2226
+ // variant — so without this, a seed created correctly inside `Bad`
2227
+ // linked to the sink inside `GoodG2B1`, whose own `data` is a
2228
+ // hardcoded constant. Measured as 160 false positives.
2229
+ //
2230
+ // Scoped to seeded sources deliberately. Applying a line-range gate
2231
+ // to every C# source would very likely help too, but that changes
2232
+ // matching for all C# consumers and deserves its own measurement.
2233
+ if (csFnRanges.length > 0 && builderSeeds.has(`${source.variable}@${source.line}`)) {
2234
+ const srcFn = csFnStart(source.line);
2235
+ const sinkFn = csFnStart(sink.line);
2236
+ if (srcFn !== sinkFn)
2237
+ continue;
2238
+ }
2041
2239
  // Simple-identifier sources are already confirmed present by the
2042
2240
  // token index; only complex (attribute-path) sources need the
2043
2241
  // word-boundary regex to confirm they actually occur in the arg.