circle-ir 4.12.0 → 4.13.1

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 (27) hide show
  1. package/README.md +9 -9
  2. package/dist/analysis/constant-propagation/propagator.d.ts +8 -0
  3. package/dist/analysis/constant-propagation/propagator.d.ts.map +1 -1
  4. package/dist/analysis/constant-propagation/propagator.js +96 -3
  5. package/dist/analysis/constant-propagation/propagator.js.map +1 -1
  6. package/dist/analysis/interprocedural.d.ts.map +1 -1
  7. package/dist/analysis/interprocedural.js +4 -0
  8. package/dist/analysis/interprocedural.js.map +1 -1
  9. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts +168 -0
  10. package/dist/analysis/passes/csharp-sanitizer-credit.d.ts.map +1 -0
  11. package/dist/analysis/passes/csharp-sanitizer-credit.js +657 -0
  12. package/dist/analysis/passes/csharp-sanitizer-credit.js.map +1 -0
  13. package/dist/analysis/passes/interprocedural-pass.d.ts.map +1 -1
  14. package/dist/analysis/passes/interprocedural-pass.js +90 -0
  15. package/dist/analysis/passes/interprocedural-pass.js.map +1 -1
  16. package/dist/analysis/passes/sink-filter-pass.d.ts.map +1 -1
  17. package/dist/analysis/passes/sink-filter-pass.js +51 -54
  18. package/dist/analysis/passes/sink-filter-pass.js.map +1 -1
  19. package/dist/analysis/passes/taint-propagation-pass.d.ts.map +1 -1
  20. package/dist/analysis/passes/taint-propagation-pass.js +328 -51
  21. package/dist/analysis/passes/taint-propagation-pass.js.map +1 -1
  22. package/dist/browser/circle-ir.js +922 -86
  23. package/dist/core/circle-ir-core.cjs +89 -3
  24. package/dist/core/circle-ir-core.js +89 -3
  25. package/dist/core/extractors/dfg.js +17 -0
  26. package/dist/core/extractors/dfg.js.map +1 -1
  27. package/package.json +1 -1
@@ -40,7 +40,7 @@ import { sanitizerCoversSink } from '../sanitizer-index.js';
40
40
  * code, resolved constant) are unaffected.
41
41
  */
42
42
  const CONTAINER_READ_SOURCES = new Set(['plugin_param', 'config_param']);
43
- function makeConstPropVeto(constProp, graph, sources, code) {
43
+ function makeConstPropVeto(constProp, graph, sources, code, language) {
44
44
  const lines = typeof code === 'string' ? code.split('\n') : [];
45
45
  const selfReassign = new Map();
46
46
  const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -78,6 +78,13 @@ function makeConstPropVeto(constProp, graph, sources, code) {
78
78
  return false;
79
79
  if (r.reason !== 'variable_not_tainted')
80
80
  return true;
81
+ // cognium-dev#579: const-prop has no C# source model, so its tainted set is
82
+ // empty for C# and "not tainted" there means "never looked", not "clean".
83
+ // It still records a plain reassignment (`s = q;`) as a tracked symbol,
84
+ // which vetoed every C# flow through one while `var s = q;` passed. Dead
85
+ // code and resolved constants (the reasons above) still apply.
86
+ if (language === 'csharp')
87
+ return false;
81
88
  const lo = Math.min(sourceLine, sinkLine);
82
89
  // Walk the reaching-def chain back from this step, stepping through
83
90
  // self-derived reassignments only. Reaching defs are per method, so a
@@ -130,7 +137,7 @@ export class TaintPropagationPass {
130
137
  const propagationResult = propagateTaint(graph, sources, sinks, sanitizers);
131
138
  // cognium-dev#328 — scoped exemption from const-prop's `variable_not_tainted`
132
139
  // veto for self-derived reassignment (`x = f(x)`, `x += y`), the #287 shape.
133
- const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code);
140
+ const isVetoed = makeConstPropVeto(constProp, graph, sources, ctx.code, ctx.language);
134
141
  // Filter flows: eliminate dead-code paths and constant-propagation FPs
135
142
  const verifiedFlows = propagationResult.flows.filter(flow => {
136
143
  if (constProp.unreachableLines.has(flow.sink.line))
@@ -1027,7 +1034,7 @@ function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLi
1027
1034
  // detectExpressionScanFlows. Suppress when the variable is
1028
1035
  // demonstrably rewritten to a literal between source and sink.
1029
1036
  if (typeof code === 'string' &&
1030
- isReassignedToLiteralBetween(code, varName, source.line, sink.line)) {
1037
+ isReassignedToLiteralBetween(code, varName, source.line, sink.line, unreachableLines)) {
1031
1038
  continue;
1032
1039
  }
1033
1040
  flows.push({
@@ -1076,7 +1083,7 @@ function detectCollectionFlows(calls, sources, sinks, taintedVars, unreachableLi
1076
1083
  continue;
1077
1084
  }
1078
1085
  if (typeof code === 'string' &&
1079
- isReassignedToLiteralBetween(code, collectionVar, source.line, sink.line)) {
1086
+ isReassignedToLiteralBetween(code, collectionVar, source.line, sink.line, unreachableLines)) {
1080
1087
  continue;
1081
1088
  }
1082
1089
  flows.push({
@@ -1199,7 +1206,7 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1199
1206
  const exists = flows.some(f => f.source_line === paramSource.line && f.sink_line === sink.line && f.sink_type === sink.type);
1200
1207
  if (!exists) {
1201
1208
  if (typeof code === 'string' &&
1202
- isReassignedToLiteralBetween(code, arg.variable, paramSource.line, sink.line)) {
1209
+ isReassignedToLiteralBetween(code, arg.variable, paramSource.line, sink.line, unreachableLines)) {
1203
1210
  continue;
1204
1211
  }
1205
1212
  // Note: DFG-flow filter handles sanitizer-guard suppression
@@ -1290,6 +1297,117 @@ function detectParameterSinkFlows(types, calls, sources, sinks, unreachableLines
1290
1297
  // whole file on every call — O(lines) per call, O(n²) across a large file
1291
1298
  // (cognium-ai#305). Within a single analysis all calls pass the same `code`
1292
1299
  // reference, so a 1-slot cache collapses the repeated splits to one.
1300
+ /**
1301
+ * C# alias expansion that respects method boundaries (cognium-dev#548).
1302
+ *
1303
+ * `buildJavaTaintedVars` is file-wide and keyed by name, so a local `data`
1304
+ * tainted in one method tainted every `data` in the file. Here a derivation
1305
+ * `lhs = …tainted…` only counts inside the method where the tainted name lives.
1306
+ * Taint crosses methods in exactly two ways, both real data flow:
1307
+ * - a class field: `dataBad = data;` in one method, `x = dataBad;` in another;
1308
+ * - a return value: `return data;` in `Source()`, `x = Source();` in a caller.
1309
+ *
1310
+ * Returns one entry per derived name. `carried` entries entered their method
1311
+ * through a field read or a call, so their `line` (the derivation) is where
1312
+ * the taint starts in that method; the others derive from a source in the
1313
+ * same method.
1314
+ */
1315
+ function csharpMethodScopedAliases(code, seeds, methods, fieldNames) {
1316
+ const lines = code.split('\n');
1317
+ const methodOf = (line) => {
1318
+ let best = -1;
1319
+ let span = Infinity;
1320
+ methods.forEach((m, i) => {
1321
+ if (line >= m.start_line && line <= m.end_line && m.end_line - m.start_line < span) {
1322
+ best = i;
1323
+ span = m.end_line - m.start_line;
1324
+ }
1325
+ });
1326
+ return best;
1327
+ };
1328
+ const declRe = /^\s*(?:public|private|protected|internal|static|readonly|const|\s)*\s*(?:[A-Za-z_][\w.]*(?:\s*<[^>]*>)?(?:\s*\[\s*\])*\??)\s+([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
1329
+ const assignRe = /^\s*(?:this\s*\.\s*)?([A-Za-z_]\w*)\s*=\s*(.+?);\s*$/;
1330
+ const returnRe = /^\s*return\s+(.+?);\s*$/;
1331
+ const KEYWORDS = new Set(['return', 'throw', 'new', 'await', 'yield', 'else', 'case', 'using', 'var']);
1332
+ const codeOf = (rhs) => rhs
1333
+ .replace(/\$@?"(?:[^"\\]|\\.)*"/g, lit => ' ' + [...lit.matchAll(/\{([^}]*)\}/g)].map(x => x[1]).join(' ') + ' ')
1334
+ .replace(/@?"(?:[^"\\]|\\.)*"/g, ' ')
1335
+ .replace(/'(?:[^'\\]|\\.)'/g, ' ');
1336
+ const local = new Map(); // method index -> tainted locals
1337
+ const taintedFields = new Set();
1338
+ const returnsTainted = new Set(); // method names
1339
+ const localsOf = (m) => {
1340
+ let set = local.get(m);
1341
+ if (!set) {
1342
+ set = new Set();
1343
+ local.set(m, set);
1344
+ }
1345
+ return set;
1346
+ };
1347
+ for (const seed of seeds) {
1348
+ const base = seed.variable.replace(/^this\./, '');
1349
+ if (fieldNames.has(base))
1350
+ taintedFields.add(base);
1351
+ else
1352
+ localsOf(methodOf(seed.line)).add(seed.variable);
1353
+ }
1354
+ const out = [];
1355
+ let changed = true;
1356
+ for (let guard = 0; changed && guard < lines.length + 2; guard++) {
1357
+ changed = false;
1358
+ for (let i = 0; i < lines.length; i++) {
1359
+ let text = lines[i];
1360
+ const trimmed = text.trimStart();
1361
+ if (trimmed.startsWith('//') || trimmed.startsWith('*'))
1362
+ continue;
1363
+ // A statement wrapped over several lines (`string q = "…" + user +` /
1364
+ // `"…" + pass;`) is one assignment: join it up to its `;` so the names
1365
+ // on the continuation lines count (cognium-dev#513).
1366
+ if (/^\s*(?:[\w.<>\[\]?]+\s+)?[A-Za-z_]\w*\s*=(?!=)/.test(text) && !/;\s*(?:\/\/.*)?$/.test(text)) {
1367
+ for (let j = i + 1; j < lines.length && j <= i + 12; j++) {
1368
+ // trimEnd: a CRLF file leaves `\r` on each line, which `.` will not cross.
1369
+ text = text.trimEnd() + ' ' + lines[j].trim();
1370
+ if (/;\s*(?:\/\/.*)?$/.test(lines[j]))
1371
+ break;
1372
+ }
1373
+ }
1374
+ const m = methodOf(i + 1);
1375
+ const here = localsOf(m);
1376
+ const ret = returnRe.exec(text);
1377
+ if (ret && m !== -1 && !returnsTainted.has(methods[m].name)) {
1378
+ const idents = codeOf(ret[1]).match(/[\p{L}\p{N}_]+/gu) ?? [];
1379
+ if (idents.some(t => here.has(t) || taintedFields.has(t))) {
1380
+ returnsTainted.add(methods[m].name);
1381
+ changed = true;
1382
+ }
1383
+ continue;
1384
+ }
1385
+ const d = declRe.exec(text) ?? assignRe.exec(text);
1386
+ if (!d)
1387
+ continue;
1388
+ const [, lhs, rhs] = d;
1389
+ if (KEYWORDS.has(lhs))
1390
+ continue;
1391
+ const isField = fieldNames.has(lhs) && !declRe.test(text);
1392
+ if (isField ? taintedFields.has(lhs) : here.has(lhs))
1393
+ continue;
1394
+ const rhsCode = codeOf(rhs);
1395
+ const idents = rhsCode.match(/[\p{L}\p{N}_]+/gu) ?? [];
1396
+ const viaLocal = idents.some(t => here.has(t));
1397
+ const viaField = idents.some(t => taintedFields.has(t) && !here.has(t));
1398
+ const viaCall = [...rhsCode.matchAll(/([\p{L}_][\p{L}\p{N}_]*)\s*\(/gu)].some(c => returnsTainted.has(c[1]));
1399
+ if (!viaLocal && !viaField && !viaCall)
1400
+ continue;
1401
+ if (isField)
1402
+ taintedFields.add(lhs);
1403
+ else
1404
+ here.add(lhs);
1405
+ out.push({ variable: lhs, line: i + 1, carried: !viaLocal });
1406
+ changed = true;
1407
+ }
1408
+ }
1409
+ return out;
1410
+ }
1293
1411
  let _reassignSplitCode;
1294
1412
  let _reassignSplitLines;
1295
1413
  function splitLinesCached(code) {
@@ -1299,7 +1417,7 @@ function splitLinesCached(code) {
1299
1417
  _reassignSplitLines = code.split('\n');
1300
1418
  return _reassignSplitLines;
1301
1419
  }
1302
- function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine) {
1420
+ function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine, unreachableLines) {
1303
1421
  if (!variable || sinkLine - srcLine < 2)
1304
1422
  return false;
1305
1423
  // Bare identifiers only — attribute paths like `obj.attr` are not
@@ -1340,56 +1458,145 @@ function isReassignedToLiteralBetween(code, variable, srcLine, sinkLine) {
1340
1458
  // A literal assigned in one case of a switch only overwrites the value
1341
1459
  // when no case gives the variable anything else. `case 'A': bar = param;`
1342
1460
  // next to `default: bar = "safe";` leaves `bar` tainted on the 'A' path.
1343
- if (switchAlsoAssignsNonLiteral(lines, i, variable, strLit))
1461
+ if (siblingBranchAssignsNonLiteral(lines, i, variable, strLit, unreachableLines))
1344
1462
  continue;
1345
1463
  return true;
1346
1464
  }
1347
1465
  return false;
1348
1466
  }
1349
- /**
1350
- * True when line `idx` sits inside a `switch` block in which some line
1351
- * assigns `variable` a value that is not a string literal. Brace counting on
1352
- * raw text: good enough for the brace-delimited switch bodies of Java, JS/TS
1353
- * and Go, and it answers false (keeping the literal as an overwrite) whenever
1354
- * no enclosing switch is found.
1355
- */
1356
- function switchAlsoAssignsNonLiteral(lines, idx, variable, strLit) {
1467
+ /** The `{` that encloses position `(line, col)`, with the statement that opens it. */
1468
+ function enclosingBrace(lines, line, col) {
1357
1469
  let depth = 0;
1358
- let start = -1;
1359
- for (let i = idx; i >= 0; i--) {
1360
- const line = lines[i];
1361
- for (let c = line.length - 1; c >= 0; c--) {
1362
- if (line[c] === '}')
1470
+ for (let i = line; i >= 0; i--) {
1471
+ const text = lines[i];
1472
+ for (let c = i === line ? Math.min(col, text.length) - 1 : text.length - 1; c >= 0; c--) {
1473
+ if (text[c] === '}')
1363
1474
  depth++;
1364
- else if (line[c] === '{')
1475
+ else if (text[c] === '{') {
1476
+ if (depth === 0) {
1477
+ // K&R: the statement precedes the brace on this line. Allman: the
1478
+ // brace stands alone and the statement is the line above it.
1479
+ let header = text.slice(0, c).trim();
1480
+ let headerLine = i;
1481
+ for (let h = i - 1; header === '' && h >= 0; h--) {
1482
+ header = lines[h].trim();
1483
+ headerLine = h;
1484
+ }
1485
+ return { line: i, col: c, header, headerLine };
1486
+ }
1365
1487
  depth--;
1488
+ }
1366
1489
  }
1367
- if (depth < 0) {
1368
- if (/\bswitch\b/.test(line))
1369
- start = i;
1370
- break;
1490
+ }
1491
+ return null;
1492
+ }
1493
+ /** The `}` matching the `{` at `(line, col)`. */
1494
+ function matchingClose(lines, line, col) {
1495
+ let depth = 0;
1496
+ for (let i = line; i < lines.length; i++) {
1497
+ const text = lines[i];
1498
+ for (let c = i === line ? col : 0; c < text.length; c++) {
1499
+ if (text[c] === '{')
1500
+ depth++;
1501
+ else if (text[c] === '}' && --depth === 0)
1502
+ return { line: i, col: c };
1371
1503
  }
1372
1504
  }
1373
- if (start < 0)
1505
+ return null;
1506
+ }
1507
+ /**
1508
+ * True when line `idx` assigns `variable` in one arm of a `switch` or one
1509
+ * branch of an `if`/`else` chain, and a sibling arm or branch assigns it a
1510
+ * value that is not a string literal.
1511
+ *
1512
+ * A literal in one branch does not overwrite what a sibling branch assigned:
1513
+ * `if (c) { data = input; } else { data = "foo"; }` leaves `data` tainted on
1514
+ * one path, so the literal must not suppress the flow (cognium-dev#513, #582).
1515
+ * A sibling assignment on a line constant propagation proved unreachable does
1516
+ * not count, which keeps `if (false) { x = input; } else { x = "safe"; }` clean.
1517
+ *
1518
+ * Works on raw text by brace matching, for brace-delimited bodies in either
1519
+ * brace style. It answers false, keeping the literal as an overwrite, when
1520
+ * the line is not inside such a construct or a branch has no braces.
1521
+ */
1522
+ function siblingBranchAssignsNonLiteral(lines, idx, variable, strLit, unreachableLines) {
1523
+ let block = enclosingBrace(lines, idx, 0);
1524
+ // A braced `case 6: { … }` arm: step out to the switch body.
1525
+ if (block && /^(?:case\b.*|default\s*):$/.test(block.header)) {
1526
+ block = enclosingBrace(lines, block.line, block.col);
1527
+ }
1528
+ if (!block)
1374
1529
  return false;
1375
- const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
1376
- const literalOnly = new RegExp(`^${strLit}$`);
1377
- depth = 0;
1378
- for (let i = start; i < lines.length; i++) {
1379
- const line = lines[i];
1380
- if (i > start || line.includes('{')) {
1381
- const m = assign.exec(line);
1382
- if (m && m[1] && !literalOnly.test(m[1]))
1383
- return true;
1530
+ let first = block.headerLine;
1531
+ let last;
1532
+ const end = matchingClose(lines, block.line, block.col);
1533
+ if (!end)
1534
+ return false;
1535
+ last = end.line;
1536
+ if (/\bswitch\b/.test(block.header)) {
1537
+ // the whole switch body
1538
+ }
1539
+ else if (/^(?:\}\s*)?else\b/.test(block.header) || /^(?:\}\s*)?(?:else\s+)?if\s*\(/.test(block.header)) {
1540
+ // Walk up through `else` / `else if` to the opening `if`.
1541
+ let cur = block;
1542
+ for (let guard = 0; guard < 64 && /^(?:\}\s*)?else\b/.test(cur.header); guard++) {
1543
+ // The previous branch closes on the header line (`} else {`) or on the
1544
+ // last non-blank line above it.
1545
+ let closeLine = cur.headerLine;
1546
+ let closeCol = lines[closeLine].indexOf('}');
1547
+ if (closeCol === -1 || closeCol > lines[closeLine].search(/\belse\b/)) {
1548
+ closeLine--;
1549
+ while (closeLine >= 0 && lines[closeLine].trim() === '')
1550
+ closeLine--;
1551
+ if (closeLine < 0 || !lines[closeLine].trimEnd().endsWith('}'))
1552
+ return false;
1553
+ closeCol = lines[closeLine].lastIndexOf('}');
1554
+ }
1555
+ const prev = enclosingBrace(lines, closeLine, closeCol);
1556
+ if (!prev)
1557
+ return false;
1558
+ cur = prev;
1559
+ first = prev.headerLine;
1384
1560
  }
1385
- for (const ch of line) {
1386
- if (ch === '{')
1387
- depth++;
1388
- else if (ch === '}')
1389
- depth--;
1561
+ // Walk down through following `else` branches.
1562
+ let close = end;
1563
+ for (let guard = 0; guard < 64; guard++) {
1564
+ let l = close.line;
1565
+ let rest = lines[l].slice(close.col + 1).trim();
1566
+ while (rest === '' && l + 1 < lines.length) {
1567
+ l++;
1568
+ rest = lines[l].trim();
1569
+ }
1570
+ if (!/^else\b/.test(rest))
1571
+ break;
1572
+ let openLine = l;
1573
+ let openCol = lines[l].indexOf('{', l === close.line ? close.col + 1 : 0);
1574
+ if (openCol === -1) {
1575
+ openLine = l + 1;
1576
+ while (openLine < lines.length && lines[openLine].trim() === '')
1577
+ openLine++;
1578
+ if (openLine >= lines.length || !lines[openLine].trimStart().startsWith('{'))
1579
+ break;
1580
+ openCol = lines[openLine].indexOf('{');
1581
+ }
1582
+ const next = matchingClose(lines, openLine, openCol);
1583
+ if (!next)
1584
+ break;
1585
+ close = next;
1586
+ last = next.line;
1390
1587
  }
1391
- if (i > start && depth <= 0)
1392
- break;
1588
+ }
1589
+ else {
1590
+ return false;
1591
+ }
1592
+ const assign = new RegExp(`(?:^|[^\\w.$])${variable}\\s*(?::?=)(?!=)\\s*(.*?)\\s*;?\\s*(?:break\\s*;?)?\\s*$`);
1593
+ const literalOnly = new RegExp(`^${strLit}$`);
1594
+ for (let i = first; i <= last; i++) {
1595
+ if (i === idx || unreachableLines?.has(i + 1))
1596
+ continue;
1597
+ const m = assign.exec(lines[i]);
1598
+ if (m && m[1] && !literalOnly.test(m[1]))
1599
+ return true;
1393
1600
  }
1394
1601
  return false;
1395
1602
  }
@@ -1600,6 +1807,26 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
1600
1807
  // is every Juliet C# file.
1601
1808
  const csFnRanges = methodRanges(['csharp']);
1602
1809
  const csFnStart = (line) => enclosingStart(csFnRanges, line);
1810
+ // cognium-dev#548 — names declared as class fields. A field is the one kind
1811
+ // of variable whose value legitimately crosses from one method to another, so
1812
+ // the per-method gate below must not apply to it.
1813
+ const csFieldNames = new Set();
1814
+ if (language === 'csharp') {
1815
+ for (const t of types ?? [])
1816
+ for (const f of t.fields ?? [])
1817
+ csFieldNames.add(f.name);
1818
+ }
1819
+ /** True when a C# source and a later line can only be linked by a reused local name. */
1820
+ const csDifferentMethod = (variable, sourceLine, otherLine) => {
1821
+ if (csFnRanges.length === 0)
1822
+ return false;
1823
+ const a = csFnStart(sourceLine);
1824
+ const b = csFnStart(otherLine);
1825
+ if (a === -1 || b === -1 || a === b)
1826
+ return false;
1827
+ const base = variable.replace(/^this\./, '').split('.')[0];
1828
+ return !csFieldNames.has(base);
1829
+ };
1603
1830
  // Variable-name scan path: only consider sources that carry an explicit
1604
1831
  // variable name. The colocation path below (cognium-dev #83) runs even
1605
1832
  // when this set is empty, so we no longer early-return.
@@ -2000,7 +2227,39 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2000
2227
  // the array literal defeats the sink-arg colocation heuristic. Seeds
2001
2228
  // with real source variables (HTTP source `arg` or the now-populated
2002
2229
  // interprocedural_param parameter name) and iterates to a fixpoint.
2003
- if ((language === 'java' || language === 'csharp') && typeof code === 'string' && sourcesWithVar.length > 0) {
2230
+ if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0 && csFnRanges.length > 0) {
2231
+ // cognium-dev#548: derive aliases per method. A name tainted in one method
2232
+ // says nothing about the same name in another; taint reaches another
2233
+ // method only through a field or a return value.
2234
+ const csMethods = (types ?? []).flatMap(t => t.methods);
2235
+ const seeds = sourcesWithVar.map(sv => ({ variable: sv.variable, line: sv.line }));
2236
+ const methodStartOf = (line) => csFnStart(line);
2237
+ const aliases = csharpMethodScopedAliases(code, seeds, csMethods, csFieldNames);
2238
+ const fileAnchor = sourcesWithVar.reduce((a, sv) => (sv.line < a.line ? sv : a), sourcesWithVar[0]);
2239
+ const have = new Set(sourcesWithVar.map(sv => `${sv.variable}@${methodStartOf(sv.line)}`));
2240
+ for (const alias of aliases) {
2241
+ const fn = methodStartOf(alias.line);
2242
+ const isField = csFieldNames.has(alias.variable);
2243
+ const key = `${alias.variable}@${isField ? 'field' : fn}`;
2244
+ if (have.has(key))
2245
+ continue;
2246
+ have.add(key);
2247
+ // A same-method derivation reports the source it derives from. A carried
2248
+ // one starts where the value enters this method (the field read or the
2249
+ // call), which also keeps it ahead of a sink in a method declared
2250
+ // earlier in the file than the method holding the source.
2251
+ const inMethod = sourcesWithVar.filter(sv => methodStartOf(sv.line) === fn && sv.line <= alias.line);
2252
+ const from = !alias.carried && inMethod.length > 0
2253
+ ? inMethod.reduce((a, sv) => (sv.line < a.line ? sv : a), inMethod[0])
2254
+ : fileAnchor;
2255
+ sourcesWithVar.push({
2256
+ ...from,
2257
+ variable: alias.variable,
2258
+ ...(alias.carried || inMethod.length === 0 ? { line: alias.line } : {}),
2259
+ });
2260
+ }
2261
+ }
2262
+ else if ((language === 'java' || language === 'csharp') && typeof code === 'string' && sourcesWithVar.length > 0) {
2004
2263
  const seedVars = new Set(sourcesWithVar.map(s => s.variable));
2005
2264
  const derived = buildJavaTaintedVars(code, seedVars);
2006
2265
  if (derived.size > 0) {
@@ -2065,23 +2324,31 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2065
2324
  if (language === 'csharp' && typeof code === 'string' && sourcesWithVar.length > 0) {
2066
2325
  const lines = code.split('\n');
2067
2326
  const ctRe = /(\w+)\s*\.\s*CommandText\s*\+?=\s*(.+)/;
2068
- const anchor = sourcesWithVar.reduce((a, s) => (s.line < a.line ? s : a), sourcesWithVar[0]);
2327
+ // cognium-dev#548: seed per method, from sources in that method. The seed
2328
+ // used to be made once per file and matched by name, so `cmd.CommandText =
2329
+ // … + data` in `Bad()` tainted `cmd.ExecuteScalar()` in `GoodG2B()`, whose
2330
+ // own `data` is a constant: all 27 Juliet CWE-89 false positives. Once per
2331
+ // file also meant a second vulnerable method reusing the name got no seed.
2332
+ const seeded = new Set();
2069
2333
  let added = true;
2070
2334
  let guard = 0;
2071
2335
  while (added && guard < lines.length + 2) {
2072
2336
  added = false;
2073
2337
  guard++;
2074
- const known = new Set(sourcesWithVar.map(s => s.variable));
2075
2338
  for (let i = 0; i < lines.length; i++) {
2076
2339
  const m = ctRe.exec(lines[i]);
2077
2340
  if (!m)
2078
2341
  continue;
2079
2342
  const [, obj, rhs] = m;
2080
- if (known.has(obj))
2343
+ const key = `${obj}@${csFnStart(i + 1)}`;
2344
+ if (seeded.has(key))
2081
2345
  continue;
2082
- if (!argsReferenceTaintedVar(rhs, sourcesWithVar))
2346
+ const reachable = sourcesWithVar.filter(sv => sv.line <= i + 1 && !csDifferentMethod(sv.variable, sv.line, i + 1));
2347
+ if (reachable.length === 0 || !argsReferenceTaintedVar(rhs, reachable))
2083
2348
  continue;
2084
- sourcesWithVar.push({ ...anchor, variable: obj, line: i + 1 });
2349
+ const from = reachable.reduce((a, sv) => (sv.line < a.line ? sv : a), reachable[0]);
2350
+ sourcesWithVar.push({ ...from, variable: obj, line: i + 1 });
2351
+ seeded.add(key);
2085
2352
  added = true;
2086
2353
  }
2087
2354
  }
@@ -2123,18 +2390,21 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2123
2390
  // way would be a guess.
2124
2391
  const appendRe = /([\p{L}_][\p{L}\p{N}_]*)\s*\.\s*(?:Append|AppendLine|AppendFormat|Insert)\s*\(([\s\S]*)/u;
2125
2392
  const anchor = sourcesWithVar.reduce((a, s) => (s.line < a.line ? s : a), sourcesWithVar[0]);
2393
+ const builderSeeded = new Set();
2126
2394
  let added = true;
2127
2395
  let guard = 0;
2128
2396
  while (added && guard < lines.length + 2) {
2129
2397
  added = false;
2130
2398
  guard++;
2131
- const known = new Set(sourcesWithVar.map(s => s.variable));
2132
2399
  for (let i = 0; i < lines.length; i++) {
2133
2400
  const m = appendRe.exec(lines[i]);
2134
2401
  if (!m)
2135
2402
  continue;
2136
2403
  const [, obj, args] = m;
2137
- if (!builders.has(obj) || known.has(obj))
2404
+ // Keyed per method (#548): one seed per file skipped a second
2405
+ // method that appends tainted text to a builder of the same name.
2406
+ const seedKey = `${obj}@${csFnStart(i + 1)}`;
2407
+ if (!builders.has(obj) || builderSeeded.has(seedKey))
2138
2408
  continue;
2139
2409
  // Same-method gate. `sourcesWithVar` is file-wide, so without this a
2140
2410
  // source named `data` in one method taints a builder appended to in
@@ -2146,6 +2416,7 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2146
2416
  if (reachable.length > 0 && argsReferenceTaintedVar(args, reachable)) {
2147
2417
  sourcesWithVar.push({ ...anchor, variable: obj, line: i + 1 });
2148
2418
  builderSeeds.add(`${obj}@${i + 1}`);
2419
+ builderSeeded.add(seedKey);
2149
2420
  added = true;
2150
2421
  }
2151
2422
  }
@@ -2291,6 +2562,12 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2291
2562
  if (srcFn !== sinkFn)
2292
2563
  continue;
2293
2564
  }
2565
+ // cognium-dev#548: the same gate for every C# source. Two locals of
2566
+ // the same name in different methods are different variables; a
2567
+ // value that really crosses methods does so through a field
2568
+ // (exempt), a parameter or a return, never through a name match.
2569
+ if (csDifferentMethod(source.variable, source.line, sink.line))
2570
+ continue;
2294
2571
  // Simple-identifier sources are already confirmed present by the
2295
2572
  // token index; only complex (attribute-path) sources need the
2296
2573
  // word-boundary regex to confirm they actually occur in the arg.
@@ -2327,7 +2604,7 @@ function detectExpressionScanFlows(calls, sources, sinks, sanitizers, unreachabl
2327
2604
  // `if var not in ALLOWLIST: var = "lit"`), the original taint
2328
2605
  // no longer reaches the sink — suppress the flow.
2329
2606
  if (typeof code === 'string' &&
2330
- isReassignedToLiteralBetween(code, source.variable, source.line, sink.line)) {
2607
+ isReassignedToLiteralBetween(code, source.variable, source.line, sink.line, unreachableLines)) {
2331
2608
  break;
2332
2609
  }
2333
2610
  // Note: DFG-flow filter handles sanitizer-guard suppression