@oddessentials/odd-ai-reviewers 1.7.4 → 1.9.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 (75) hide show
  1. package/dist/agents/ai_semantic_review.d.ts.map +1 -1
  2. package/dist/agents/ai_semantic_review.js +7 -1
  3. package/dist/agents/ai_semantic_review.js.map +1 -1
  4. package/dist/agents/control_flow/safe-source-detector.d.ts +48 -0
  5. package/dist/agents/control_flow/safe-source-detector.d.ts.map +1 -0
  6. package/dist/agents/control_flow/safe-source-detector.js +434 -0
  7. package/dist/agents/control_flow/safe-source-detector.js.map +1 -0
  8. package/dist/agents/control_flow/safe-source-patterns.d.ts +61 -0
  9. package/dist/agents/control_flow/safe-source-patterns.d.ts.map +1 -0
  10. package/dist/agents/control_flow/safe-source-patterns.js +137 -0
  11. package/dist/agents/control_flow/safe-source-patterns.js.map +1 -0
  12. package/dist/agents/control_flow/scope-stack.d.ts +167 -0
  13. package/dist/agents/control_flow/scope-stack.d.ts.map +1 -0
  14. package/dist/agents/control_flow/scope-stack.js +448 -0
  15. package/dist/agents/control_flow/scope-stack.js.map +1 -0
  16. package/dist/agents/control_flow/vulnerability-detector.d.ts +13 -0
  17. package/dist/agents/control_flow/vulnerability-detector.d.ts.map +1 -1
  18. package/dist/agents/control_flow/vulnerability-detector.js +630 -35
  19. package/dist/agents/control_flow/vulnerability-detector.js.map +1 -1
  20. package/dist/agents/opencode.d.ts.map +1 -1
  21. package/dist/agents/opencode.js +7 -1
  22. package/dist/agents/opencode.js.map +1 -1
  23. package/dist/agents/pr_agent.d.ts.map +1 -1
  24. package/dist/agents/pr_agent.js +8 -2
  25. package/dist/agents/pr_agent.js.map +1 -1
  26. package/dist/agents/security.d.ts.map +1 -1
  27. package/dist/agents/security.js +1 -0
  28. package/dist/agents/security.js.map +1 -1
  29. package/dist/agents/types.d.ts +6 -0
  30. package/dist/agents/types.d.ts.map +1 -1
  31. package/dist/benchmark/adapter.d.ts +87 -0
  32. package/dist/benchmark/adapter.d.ts.map +1 -0
  33. package/dist/benchmark/adapter.js +298 -0
  34. package/dist/benchmark/adapter.js.map +1 -0
  35. package/dist/benchmark/scoring.d.ts +100 -0
  36. package/dist/benchmark/scoring.d.ts.map +1 -0
  37. package/dist/benchmark/scoring.js +195 -0
  38. package/dist/benchmark/scoring.js.map +1 -0
  39. package/dist/cli/dependencies/schemas.d.ts +3 -3
  40. package/dist/context-loader.d.ts +80 -0
  41. package/dist/context-loader.d.ts.map +1 -0
  42. package/dist/context-loader.js +202 -0
  43. package/dist/context-loader.js.map +1 -0
  44. package/dist/main.d.ts.map +1 -1
  45. package/dist/main.js +131 -4
  46. package/dist/main.js.map +1 -1
  47. package/dist/phases/index.d.ts +1 -1
  48. package/dist/phases/index.d.ts.map +1 -1
  49. package/dist/phases/index.js +1 -1
  50. package/dist/phases/index.js.map +1 -1
  51. package/dist/phases/report.d.ts +8 -1
  52. package/dist/phases/report.d.ts.map +1 -1
  53. package/dist/phases/report.js +52 -5
  54. package/dist/phases/report.js.map +1 -1
  55. package/dist/report/ado.d.ts +2 -0
  56. package/dist/report/ado.d.ts.map +1 -1
  57. package/dist/report/ado.js +9 -23
  58. package/dist/report/ado.js.map +1 -1
  59. package/dist/report/finding-validator.d.ts +130 -0
  60. package/dist/report/finding-validator.d.ts.map +1 -0
  61. package/dist/report/finding-validator.js +347 -0
  62. package/dist/report/finding-validator.js.map +1 -0
  63. package/dist/report/framework-pattern-filter.d.ts +53 -0
  64. package/dist/report/framework-pattern-filter.d.ts.map +1 -0
  65. package/dist/report/framework-pattern-filter.js +189 -0
  66. package/dist/report/framework-pattern-filter.js.map +1 -0
  67. package/dist/report/github.d.ts +2 -0
  68. package/dist/report/github.d.ts.map +1 -1
  69. package/dist/report/github.js +9 -23
  70. package/dist/report/github.js.map +1 -1
  71. package/dist/trust.d.ts +6 -0
  72. package/dist/trust.d.ts.map +1 -1
  73. package/dist/trust.js +2 -0
  74. package/dist/trust.js.map +1 -1
  75. package/package.json +5 -5
@@ -8,6 +8,88 @@
8
8
  */
9
9
  import ts from 'typescript';
10
10
  import { getLogger } from './logger.js';
11
+ import { detectSafeSources, filterSafeSources } from './safe-source-detector.js';
12
+ import { ScopeStack, isScopeNode, nodeIdentityKey, registerNodeDeclarations, extractBindingsFromAssignmentTarget, extractBindingNames, } from './scope-stack.js';
13
+ // =============================================================================
14
+ // Assignment operator detection — includes compound assignments (+=, ??=, etc.)
15
+ // that can propagate taint from the RHS to the LHS variable.
16
+ // =============================================================================
17
+ const ASSIGNMENT_OPERATORS = new Set([
18
+ ts.SyntaxKind.EqualsToken, // =
19
+ ts.SyntaxKind.PlusEqualsToken, // +=
20
+ ts.SyntaxKind.BarBarEqualsToken, // ||=
21
+ ts.SyntaxKind.QuestionQuestionEqualsToken, // ??=
22
+ ts.SyntaxKind.AmpersandAmpersandEqualsToken, // &&=
23
+ ]);
24
+ function isAssignmentOperator(kind) {
25
+ return ASSIGNMENT_OPERATORS.has(kind);
26
+ }
27
+ function getObjectLiteralPropertyKey(prop) {
28
+ if (ts.isPropertyAssignment(prop)) {
29
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
30
+ return prop.name.text;
31
+ }
32
+ }
33
+ if (ts.isShorthandPropertyAssignment(prop)) {
34
+ return prop.name.text;
35
+ }
36
+ return undefined;
37
+ }
38
+ function getObjectLiteralPropertyValue(prop) {
39
+ if (ts.isPropertyAssignment(prop)) {
40
+ return prop.initializer;
41
+ }
42
+ if (ts.isShorthandPropertyAssignment(prop)) {
43
+ return prop.name;
44
+ }
45
+ return undefined;
46
+ }
47
+ function getObjectBindingElementLookupKey(element) {
48
+ if (element.propertyName) {
49
+ if (ts.isIdentifier(element.propertyName) || ts.isStringLiteral(element.propertyName)) {
50
+ return element.propertyName.text;
51
+ }
52
+ return undefined;
53
+ }
54
+ if (ts.isIdentifier(element.name)) {
55
+ return element.name.text;
56
+ }
57
+ // Defensive fallback: in valid TS, nested binding patterns always have propertyName
58
+ // (handled above), so this branch is unreachable. Kept for robustness.
59
+ const nestedBindings = extractBindingNames(element.name);
60
+ return nestedBindings[0]?.name;
61
+ }
62
+ function getExcludedObjectRestKeys(elements, restIndex) {
63
+ const excludedKeys = new Set();
64
+ for (let i = 0; i < restIndex; i++) {
65
+ const el = elements[i];
66
+ if (!el)
67
+ continue;
68
+ const key = getObjectBindingElementLookupKey(el);
69
+ if (key)
70
+ excludedKeys.add(key);
71
+ }
72
+ return excludedKeys;
73
+ }
74
+ /**
75
+ * Collect property keys that are explicitly destructured before the rest element
76
+ * in a destructuring assignment target (Expression-based LHS).
77
+ *
78
+ * This is the assignment-path counterpart to `getExcludedObjectRestKeys` which
79
+ * operates on `BindingElement[]` from declaration patterns.
80
+ */
81
+ function getExcludedAssignmentRestKeys(bindings, restIndex) {
82
+ const excludedKeys = new Set();
83
+ for (let i = 0; i < restIndex; i++) {
84
+ const binding = bindings[i];
85
+ if (!binding || binding.isRest)
86
+ continue;
87
+ const key = binding.propertyKey ?? binding.name;
88
+ if (key)
89
+ excludedKeys.add(key);
90
+ }
91
+ return excludedKeys;
92
+ }
11
93
  /**
12
94
  * Built-in sink patterns for common vulnerability types.
13
95
  */
@@ -136,9 +218,19 @@ export class VulnerabilityDetector {
136
218
  this.findSinks(sourceFile, filePath, detectedSinks);
137
219
  // Step 2: Find all sources (user input)
138
220
  this.findSources(sourceFile, filePath, detectedSources);
139
- // Step 3: Track taint propagation
140
- this.trackTaint(sourceFile, filePath, detectedSources, taintedVariables);
141
- // Step 4: Match sinks to tainted data
221
+ // Step 3: Detect safe sources and filter them out (prevents false positives)
222
+ const safeSources = detectSafeSources(sourceFile, filePath);
223
+ const filteredSources = filterSafeSources(detectedSources, safeSources);
224
+ for (const ss of safeSources) {
225
+ this.logger.log('debug', 'vulnerability', 'Safe source detected', {
226
+ patternId: ss.patternId,
227
+ variableName: ss.variableName,
228
+ file: filePath,
229
+ });
230
+ }
231
+ // Step 4: Track taint propagation
232
+ this.trackTaint(sourceFile, filePath, filteredSources, taintedVariables);
233
+ // Step 5: Match sinks to tainted data
142
234
  for (const sink of detectedSinks) {
143
235
  const affectedVariable = this.findAffectedVariable(sink, taintedVariables, sourceFile);
144
236
  if (affectedVariable) {
@@ -150,6 +242,7 @@ export class VulnerabilityDetector {
150
242
  file: filePath,
151
243
  sinksFound: detectedSinks.length,
152
244
  sourcesFound: detectedSources.length,
245
+ safeSourcesDetected: safeSources.length,
153
246
  taintedVars: taintedVariables.length,
154
247
  vulnerabilities: vulnerabilities.length,
155
248
  });
@@ -268,23 +361,30 @@ export class VulnerabilityDetector {
268
361
  * Find user input sources in the source file.
269
362
  */
270
363
  findSources(sourceFile, filePath, sources) {
364
+ const scope = new ScopeStack();
271
365
  const visit = (node) => {
366
+ const isScope = isScopeNode(node);
367
+ if (isScope)
368
+ scope.enterScope(node);
369
+ registerNodeDeclarations(node, scope);
272
370
  // Check property access for source patterns
273
371
  if (ts.isPropertyAccessExpression(node)) {
274
- this.checkPropertyAccessForSource(node, sourceFile, filePath, sources);
372
+ this.checkPropertyAccessForSource(node, sourceFile, filePath, sources, scope);
275
373
  }
276
374
  // Check element access (e.g., req['body'])
277
375
  if (ts.isElementAccessExpression(node)) {
278
- this.checkElementAccessForSource(node, sourceFile, filePath, sources);
376
+ this.checkElementAccessForSource(node, sourceFile, filePath, sources, scope);
279
377
  }
280
378
  ts.forEachChild(node, visit);
379
+ if (isScope)
380
+ scope.leaveScope();
281
381
  };
282
382
  visit(sourceFile);
283
383
  }
284
384
  /**
285
385
  * Check if property access is a taint source.
286
386
  */
287
- checkPropertyAccessForSource(node, sourceFile, filePath, sources) {
387
+ checkPropertyAccessForSource(node, sourceFile, filePath, sources, scope) {
288
388
  const propName = node.name.text;
289
389
  let objectName = '';
290
390
  if (ts.isIdentifier(node.expression)) {
@@ -295,15 +395,12 @@ export class VulnerabilityDetector {
295
395
  const objectMatches = !pattern.objects ||
296
396
  pattern.objects.some((obj) => objectName.toLowerCase() === obj.toLowerCase());
297
397
  if (nameMatches && objectMatches) {
298
- const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
398
+ const binding = this.findAssignedBinding(node, sourceFile, filePath, scope);
299
399
  sources.push({
300
- location: {
301
- file: filePath,
302
- line: line + 1,
303
- column: character,
304
- },
400
+ location: binding?.location ?? this.nodeLocation(node, sourceFile, filePath),
305
401
  expression: node.getText(sourceFile),
306
- variableName: this.findAssignedVariable(node, sourceFile) || propName,
402
+ variableName: binding?.variableName ?? (this.findAssignedVariable(node, sourceFile) || propName),
403
+ declarationKey: binding?.declarationKey ?? null,
307
404
  });
308
405
  break;
309
406
  }
@@ -312,7 +409,7 @@ export class VulnerabilityDetector {
312
409
  /**
313
410
  * Check element access for source patterns (e.g., req['body']).
314
411
  */
315
- checkElementAccessForSource(node, sourceFile, filePath, sources) {
412
+ checkElementAccessForSource(node, sourceFile, filePath, sources, scope) {
316
413
  if (!ts.isStringLiteral(node.argumentExpression))
317
414
  return;
318
415
  const propName = node.argumentExpression.text;
@@ -325,15 +422,12 @@ export class VulnerabilityDetector {
325
422
  const objectMatches = !pattern.objects ||
326
423
  pattern.objects.some((obj) => objectName.toLowerCase() === obj.toLowerCase());
327
424
  if (nameMatches && objectMatches) {
328
- const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
425
+ const binding = this.findAssignedBinding(node, sourceFile, filePath, scope);
329
426
  sources.push({
330
- location: {
331
- file: filePath,
332
- line: line + 1,
333
- column: character,
334
- },
427
+ location: binding?.location ?? this.nodeLocation(node, sourceFile, filePath),
335
428
  expression: node.getText(sourceFile),
336
- variableName: this.findAssignedVariable(node, sourceFile) || propName,
429
+ variableName: binding?.variableName ?? (this.findAssignedVariable(node, sourceFile) || propName),
430
+ declarationKey: binding?.declarationKey ?? null,
337
431
  });
338
432
  break;
339
433
  }
@@ -357,84 +451,585 @@ export class VulnerabilityDetector {
357
451
  }
358
452
  return null;
359
453
  }
454
+ findAssignedBinding(node, sourceFile, filePath, scope) {
455
+ let current = node.parent;
456
+ while (current) {
457
+ if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) {
458
+ return {
459
+ variableName: current.name.text,
460
+ declarationKey: nodeIdentityKey(current, sourceFile),
461
+ location: this.nodeLocation(current, sourceFile, filePath),
462
+ };
463
+ }
464
+ if (ts.isBinaryExpression(current) &&
465
+ isAssignmentOperator(current.operatorToken.kind) &&
466
+ ts.isIdentifier(current.left)) {
467
+ const decl = scope.resolveDeclaration(current.left.text);
468
+ return {
469
+ variableName: current.left.text,
470
+ declarationKey: decl ? nodeIdentityKey(decl, sourceFile) : null,
471
+ location: decl
472
+ ? this.nodeLocation(decl, sourceFile, filePath)
473
+ : this.nodeLocation(current.left, sourceFile, filePath),
474
+ };
475
+ }
476
+ current = current.parent;
477
+ }
478
+ return null;
479
+ }
480
+ nodeLocation(node, sourceFile, filePath) {
481
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
482
+ return {
483
+ file: filePath,
484
+ line: line + 1,
485
+ column: character,
486
+ };
487
+ }
360
488
  /**
361
489
  * Track taint propagation through variable assignments.
490
+ * Uses a scope stack to resolve identifiers to their declaring nodes,
491
+ * keying taint entries by declaration identity instead of raw name.
362
492
  */
363
493
  trackTaint(sourceFile, filePath, sources, tainted) {
364
494
  // Initialize tainted variables from sources
495
+ // Sources don't have declaration keys yet — they'll be resolved during the walk
365
496
  for (const source of sources) {
366
497
  tainted.push({
367
498
  name: source.variableName,
499
+ declarationKey: source.declarationKey,
368
500
  sourceLocation: source.location,
369
501
  taintedAt: source.location,
370
502
  });
371
503
  }
504
+ const scope = new ScopeStack();
372
505
  // Track taint through assignments
373
506
  const visit = (node) => {
507
+ const isScope = isScopeNode(node);
508
+ if (isScope)
509
+ scope.enterScope(node);
510
+ // Register all declaration types (var/let/const, destructuring, params, catch)
511
+ registerNodeDeclarations(node, scope);
512
+ // Resolve declaration keys for initial source entries when we encounter
513
+ // their declaring variable. This ensures taint entries for `const userInput = req.body.data`
514
+ // get a proper declarationKey so scope-based matching works in findTaintInExpression.
515
+ // Match by BOTH name AND source location to avoid assigning the wrong key when
516
+ // the same variable name is declared in multiple scopes (e.g., two handlers each
517
+ // doing `const data = req.body.data`).
518
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
519
+ const varName = node.name.text;
520
+ const declKey = nodeIdentityKey(node, sourceFile);
521
+ const declStart = node.getStart();
522
+ const declEnd = node.getEnd();
523
+ const { line: declStartLine } = sourceFile.getLineAndCharacterOfPosition(declStart);
524
+ const { line: declEndLine } = sourceFile.getLineAndCharacterOfPosition(declEnd);
525
+ for (const entry of tainted) {
526
+ if (entry.name === varName &&
527
+ entry.declarationKey === null &&
528
+ entry.taintedAt.file === filePath) {
529
+ // Check the source falls within the declaration's line range
530
+ // (handles multi-line initializers like `const x =\n req.body.y`)
531
+ const srcLine = entry.taintedAt.line - 1; // zero-based
532
+ if (srcLine < declStartLine || srcLine > declEndLine) {
533
+ continue;
534
+ }
535
+ // When column data is available, verify via absolute position
536
+ // to prevent mismatches when two same-named variables share a line
537
+ if (entry.taintedAt.column !== undefined) {
538
+ const sourceAbsPos = ts.getPositionOfLineAndCharacter(sourceFile, srcLine, entry.taintedAt.column);
539
+ if (sourceAbsPos < declStart || sourceAbsPos >= declEnd) {
540
+ continue;
541
+ }
542
+ }
543
+ entry.declarationKey = declKey;
544
+ }
545
+ }
546
+ }
547
+ // Resolve declaration keys for taint entries created by assignment expressions
548
+ // (e.g., `let q; q = req.body.query;` or `q += req.body.query;`).
549
+ // The taint source line falls within the BinaryExpression span, not within
550
+ // a VariableDeclaration, so the block above won't match.
551
+ // We resolve the LHS identifier via the scope stack.
552
+ if (ts.isBinaryExpression(node) &&
553
+ isAssignmentOperator(node.operatorToken.kind) &&
554
+ ts.isIdentifier(node.left)) {
555
+ const varName = node.left.text;
556
+ const assignStart = node.getStart();
557
+ const assignEnd = node.getEnd();
558
+ const { line: assignStartLine } = sourceFile.getLineAndCharacterOfPosition(assignStart);
559
+ const { line: assignEndLine } = sourceFile.getLineAndCharacterOfPosition(assignEnd);
560
+ for (const entry of tainted) {
561
+ if (entry.name === varName &&
562
+ entry.declarationKey === null &&
563
+ entry.taintedAt.file === filePath) {
564
+ const srcLine = entry.taintedAt.line - 1; // zero-based
565
+ if (srcLine < assignStartLine || srcLine > assignEndLine)
566
+ continue;
567
+ const decl = scope.resolveDeclaration(varName);
568
+ if (decl) {
569
+ entry.declarationKey = nodeIdentityKey(decl, sourceFile);
570
+ }
571
+ }
572
+ }
573
+ }
374
574
  if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
375
575
  const varName = node.name.text;
376
- const taintSource = this.findTaintInExpression(node.initializer, tainted, sourceFile);
576
+ const taintSource = this.findTaintInExpression(node.initializer, tainted, sourceFile, scope);
377
577
  if (taintSource) {
378
578
  const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
579
+ const declKey = nodeIdentityKey(node, sourceFile);
379
580
  tainted.push({
380
581
  name: varName,
582
+ declarationKey: declKey,
381
583
  sourceLocation: taintSource.sourceLocation,
382
584
  taintedAt: { file: filePath, line: line + 1, column: character },
383
585
  });
384
586
  }
385
587
  }
588
+ // Destructuring variable declarations: const { a, b } = expr or const [a, b] = expr
589
+ if (ts.isVariableDeclaration(node) &&
590
+ !ts.isIdentifier(node.name) &&
591
+ node.initializer &&
592
+ (ts.isObjectBindingPattern(node.name) || ts.isArrayBindingPattern(node.name))) {
593
+ const bindings = extractBindingNames(node.name);
594
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
595
+ const taintedAt = { file: filePath, line: line + 1, column: character };
596
+ const declKey = nodeIdentityKey(node, sourceFile);
597
+ if (ts.isArrayLiteralExpression(node.initializer) && ts.isArrayBindingPattern(node.name)) {
598
+ // Per-element taint for array patterns with array literal initializers
599
+ let idx = 0;
600
+ for (const element of node.name.elements) {
601
+ if (ts.isOmittedExpression(element)) {
602
+ idx++;
603
+ continue;
604
+ }
605
+ const isRest = element.dotDotDotToken !== undefined;
606
+ const elementBindings = extractBindingNames(element.name);
607
+ if (isRest) {
608
+ // Rest captures remaining elements — taint if any remaining is tainted
609
+ let restTainted = null;
610
+ for (let ri = idx; ri < node.initializer.elements.length; ri++) {
611
+ const rhsElem = node.initializer.elements[ri];
612
+ if (rhsElem) {
613
+ restTainted = this.findTaintInExpression(rhsElem, tainted, sourceFile, scope);
614
+ if (restTainted)
615
+ break;
616
+ }
617
+ }
618
+ if (restTainted) {
619
+ for (const b of elementBindings) {
620
+ tainted.push({
621
+ name: b.name,
622
+ declarationKey: declKey,
623
+ sourceLocation: restTainted.sourceLocation,
624
+ taintedAt,
625
+ });
626
+ }
627
+ }
628
+ }
629
+ else {
630
+ const rhsElem = node.initializer.elements[idx];
631
+ if (rhsElem) {
632
+ const elemTaint = this.findTaintInExpression(rhsElem, tainted, sourceFile, scope);
633
+ if (elemTaint) {
634
+ for (const b of elementBindings) {
635
+ tainted.push({
636
+ name: b.name,
637
+ declarationKey: declKey,
638
+ sourceLocation: elemTaint.sourceLocation,
639
+ taintedAt,
640
+ });
641
+ }
642
+ }
643
+ }
644
+ }
645
+ idx++;
646
+ }
647
+ }
648
+ else if (ts.isObjectLiteralExpression(node.initializer) &&
649
+ ts.isObjectBindingPattern(node.name)) {
650
+ // Per-element taint for object patterns with object literal initializers
651
+ // Match each binding element to its corresponding RHS property by key
652
+ for (const [elementIndex, element] of node.name.elements.entries()) {
653
+ const localBindings = extractBindingNames(element.name);
654
+ // Rest elements (...rest) capture all remaining properties.
655
+ // Taint rest bindings only from properties that remain after earlier
656
+ // named bindings are removed from the rest object.
657
+ if (element.dotDotDotToken) {
658
+ const excludedKeys = getExcludedObjectRestKeys(node.name.elements, elementIndex);
659
+ for (const prop of node.initializer.properties) {
660
+ if (ts.isSpreadAssignment(prop)) {
661
+ const spreadTaint = this.findTaintInExpression(prop.expression, tainted, sourceFile, scope);
662
+ if (spreadTaint) {
663
+ for (const b of localBindings) {
664
+ tainted.push({
665
+ name: b.name,
666
+ declarationKey: declKey,
667
+ sourceLocation: spreadTaint.sourceLocation,
668
+ taintedAt,
669
+ });
670
+ }
671
+ break;
672
+ }
673
+ continue;
674
+ }
675
+ const propKey = getObjectLiteralPropertyKey(prop);
676
+ if (propKey && excludedKeys.has(propKey))
677
+ continue;
678
+ const propValue = getObjectLiteralPropertyValue(prop);
679
+ if (propValue) {
680
+ const elemTaint = this.findTaintInExpression(propValue, tainted, sourceFile, scope);
681
+ if (elemTaint) {
682
+ for (const b of localBindings) {
683
+ tainted.push({
684
+ name: b.name,
685
+ declarationKey: declKey,
686
+ sourceLocation: elemTaint.sourceLocation,
687
+ taintedAt,
688
+ });
689
+ }
690
+ break; // One tainted property is enough to taint rest
691
+ }
692
+ }
693
+ }
694
+ continue;
695
+ }
696
+ // Determine the property key: explicit propertyName or implicit (same as local name)
697
+ const lookupKey = getObjectBindingElementLookupKey(element);
698
+ if (!lookupKey)
699
+ continue;
700
+ let rhsValue;
701
+ for (const prop of node.initializer.properties) {
702
+ if (getObjectLiteralPropertyKey(prop) === lookupKey) {
703
+ rhsValue = getObjectLiteralPropertyValue(prop);
704
+ break;
705
+ }
706
+ }
707
+ if (rhsValue) {
708
+ const elemTaint = this.findTaintInExpression(rhsValue, tainted, sourceFile, scope);
709
+ if (elemTaint) {
710
+ for (const b of localBindings) {
711
+ tainted.push({
712
+ name: b.name,
713
+ declarationKey: declKey,
714
+ sourceLocation: elemTaint.sourceLocation,
715
+ taintedAt,
716
+ });
717
+ }
718
+ }
719
+ }
720
+ }
721
+ }
722
+ else {
723
+ // Conservative: check entire initializer for taint, apply to all bindings
724
+ const taintSource = this.findTaintInExpression(node.initializer, tainted, sourceFile, scope);
725
+ if (taintSource) {
726
+ for (const b of bindings) {
727
+ tainted.push({
728
+ name: b.name,
729
+ declarationKey: declKey,
730
+ sourceLocation: taintSource.sourceLocation,
731
+ taintedAt,
732
+ });
733
+ }
734
+ }
735
+ }
736
+ }
386
737
  if (ts.isBinaryExpression(node) &&
387
- node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
738
+ isAssignmentOperator(node.operatorToken.kind) &&
388
739
  ts.isIdentifier(node.left)) {
389
740
  const varName = node.left.text;
390
- const taintSource = this.findTaintInExpression(node.right, tainted, sourceFile);
741
+ const taintSource = this.findTaintInExpression(node.right, tainted, sourceFile, scope);
391
742
  if (taintSource) {
392
743
  const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
744
+ // Resolve the LHS to its declaration
745
+ const decl = scope.resolveDeclaration(varName);
746
+ const declKey = decl ? nodeIdentityKey(decl, sourceFile) : null;
393
747
  tainted.push({
394
748
  name: varName,
749
+ declarationKey: declKey,
395
750
  sourceLocation: taintSource.sourceLocation,
396
751
  taintedAt: { file: filePath, line: line + 1, column: character },
397
752
  });
398
753
  }
399
754
  }
755
+ // Destructuring assignment taint propagation:
756
+ // [a, b] = expr or ({a, b} = expr)
757
+ if (ts.isBinaryExpression(node) &&
758
+ isAssignmentOperator(node.operatorToken.kind) &&
759
+ (ts.isArrayLiteralExpression(node.left) || ts.isObjectLiteralExpression(node.left))) {
760
+ const bindings = extractBindingsFromAssignmentTarget(node.left);
761
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
762
+ const taintedAt = { file: filePath, line: line + 1, column: character };
763
+ if (ts.isArrayLiteralExpression(node.right) || ts.isObjectLiteralExpression(node.right)) {
764
+ // Tier 1 — Per-element taint: evaluate each RHS element individually
765
+ if (ts.isArrayLiteralExpression(node.right)) {
766
+ for (const binding of bindings) {
767
+ if (binding.isRest) {
768
+ // Rest element captures remaining RHS elements — conservatively taint
769
+ // if ANY remaining element is tainted
770
+ const restStart = binding.index ?? 0;
771
+ let restTainted = null;
772
+ for (let ri = restStart; ri < node.right.elements.length; ri++) {
773
+ const rhsElem = node.right.elements[ri];
774
+ if (rhsElem) {
775
+ restTainted = this.findTaintInExpression(rhsElem, tainted, sourceFile, scope);
776
+ if (restTainted)
777
+ break;
778
+ }
779
+ }
780
+ if (restTainted) {
781
+ const decl = scope.resolveDeclaration(binding.name);
782
+ tainted.push({
783
+ name: binding.name,
784
+ declarationKey: decl ? nodeIdentityKey(decl, sourceFile) : null,
785
+ sourceLocation: restTainted.sourceLocation,
786
+ taintedAt,
787
+ });
788
+ }
789
+ }
790
+ else if (binding.index !== undefined || binding.parentIndex !== undefined) {
791
+ const lookupIndex = binding.index ?? binding.parentIndex;
792
+ const rhsElem = lookupIndex !== undefined ? node.right.elements[lookupIndex] : undefined;
793
+ if (rhsElem) {
794
+ const elemTaint = this.findTaintInExpression(rhsElem, tainted, sourceFile, scope);
795
+ if (elemTaint) {
796
+ const decl = scope.resolveDeclaration(binding.name);
797
+ tainted.push({
798
+ name: binding.name,
799
+ declarationKey: decl ? nodeIdentityKey(decl, sourceFile) : null,
800
+ sourceLocation: elemTaint.sourceLocation,
801
+ taintedAt,
802
+ });
803
+ }
804
+ }
805
+ }
806
+ }
807
+ }
808
+ else {
809
+ // Object literal RHS: match by property key
810
+ const rhsObj = node.right;
811
+ for (const [bindingIndex, binding] of bindings.entries()) {
812
+ const lookupKey = binding.propertyKey ?? binding.name;
813
+ // For nested bindings with outerKeys (e.g. { a: { b } } = { a: { b: ... } }),
814
+ // traverse into the RHS object following the outer key path first
815
+ let targetObj = rhsObj;
816
+ let traversalFailed = false;
817
+ if (binding.outerKeys) {
818
+ for (const outerKey of binding.outerKeys) {
819
+ let nestedValue;
820
+ for (const prop of targetObj.properties) {
821
+ if (getObjectLiteralPropertyKey(prop) === outerKey) {
822
+ nestedValue = getObjectLiteralPropertyValue(prop);
823
+ break;
824
+ }
825
+ }
826
+ if (nestedValue && ts.isObjectLiteralExpression(nestedValue)) {
827
+ targetObj = nestedValue;
828
+ }
829
+ else {
830
+ traversalFailed = true;
831
+ break;
832
+ }
833
+ }
834
+ }
835
+ if (traversalFailed)
836
+ continue;
837
+ // Find matching RHS property in the (possibly nested) target object
838
+ let rhsValue;
839
+ for (const prop of targetObj.properties) {
840
+ if (getObjectLiteralPropertyKey(prop) === lookupKey) {
841
+ rhsValue = getObjectLiteralPropertyValue(prop);
842
+ break;
843
+ }
844
+ }
845
+ if (rhsValue) {
846
+ const elemTaint = this.findTaintInExpression(rhsValue, tainted, sourceFile, scope);
847
+ if (elemTaint) {
848
+ const decl = scope.resolveDeclaration(binding.name);
849
+ tainted.push({
850
+ name: binding.name,
851
+ declarationKey: decl ? nodeIdentityKey(decl, sourceFile) : null,
852
+ sourceLocation: elemTaint.sourceLocation,
853
+ taintedAt,
854
+ });
855
+ }
856
+ }
857
+ else if (binding.isRest) {
858
+ const excludedKeys = getExcludedAssignmentRestKeys(bindings, bindingIndex);
859
+ let restTaint = null;
860
+ for (const prop of targetObj.properties) {
861
+ if (ts.isSpreadAssignment(prop)) {
862
+ restTaint = this.findTaintInExpression(prop.expression, tainted, sourceFile, scope);
863
+ if (restTaint)
864
+ break;
865
+ continue;
866
+ }
867
+ const propKey = getObjectLiteralPropertyKey(prop);
868
+ if (propKey && excludedKeys.has(propKey))
869
+ continue;
870
+ const propValue = getObjectLiteralPropertyValue(prop);
871
+ if (!propValue)
872
+ continue;
873
+ restTaint = this.findTaintInExpression(propValue, tainted, sourceFile, scope);
874
+ if (restTaint)
875
+ break;
876
+ }
877
+ if (restTaint) {
878
+ const decl = scope.resolveDeclaration(binding.name);
879
+ tainted.push({
880
+ name: binding.name,
881
+ declarationKey: decl ? nodeIdentityKey(decl, sourceFile) : null,
882
+ sourceLocation: restTaint.sourceLocation,
883
+ taintedAt,
884
+ });
885
+ }
886
+ }
887
+ }
888
+ }
889
+ }
890
+ else {
891
+ // Tier 2 — Conservative-all: RHS is a non-literal expression,
892
+ // mark ALL extracted bindings as tainted
893
+ const taintSource = this.findTaintInExpression(node.right, tainted, sourceFile, scope);
894
+ if (taintSource) {
895
+ for (const binding of bindings) {
896
+ const decl = scope.resolveDeclaration(binding.name);
897
+ tainted.push({
898
+ name: binding.name,
899
+ declarationKey: decl ? nodeIdentityKey(decl, sourceFile) : null,
900
+ sourceLocation: taintSource.sourceLocation,
901
+ taintedAt,
902
+ });
903
+ }
904
+ }
905
+ }
906
+ }
400
907
  ts.forEachChild(node, visit);
908
+ if (isScope)
909
+ scope.leaveScope();
401
910
  };
402
911
  visit(sourceFile);
403
912
  }
404
913
  /**
405
914
  * Find if an expression uses a tainted variable.
915
+ * Resolves identifiers to their declaring nodes via the scope stack,
916
+ * then matches by declaration identity key rather than raw name.
917
+ * Falls back to name-based matching for taint entries without a declaration key
918
+ * (e.g., initial source entries that weren't resolved during the walk).
406
919
  */
407
- findTaintInExpression(node, tainted, _sourceFile) {
920
+ findTaintInExpression(node, tainted, sourceFile, scope) {
408
921
  if (ts.isIdentifier(node)) {
409
- return tainted.find((t) => t.name === node.text) || null;
922
+ if (scope) {
923
+ const decl = scope.resolveDeclaration(node.text);
924
+ if (decl) {
925
+ // Scope resolved the identifier to a specific declaration.
926
+ // Only check taint entries that match this exact declaration.
927
+ // Do NOT fall back to name-based matching — scope resolution is authoritative.
928
+ const declKey = nodeIdentityKey(decl, sourceFile);
929
+ return (tainted.find((t) => t.declarationKey === declKey && (!t.name || t.name === node.text)) || null);
930
+ }
931
+ }
932
+ // Fallback: name-based match ONLY when scope could not resolve the identifier
933
+ // (undeclared global, no scope stack available, or identifier not found in any scope)
934
+ return tainted.find((t) => t.declarationKey === null && t.name === node.text) || null;
935
+ }
936
+ // FR-016: Explicit template literal taint detection.
937
+ // If any interpolated expression in a template literal is tainted,
938
+ // the entire template expression is tainted.
939
+ if (ts.isTemplateExpression(node)) {
940
+ for (const span of node.templateSpans) {
941
+ const taintSource = this.findTaintInExpression(span.expression, tainted, sourceFile, scope);
942
+ if (taintSource)
943
+ return taintSource;
944
+ }
945
+ return null;
410
946
  }
411
947
  let found = null;
412
948
  node.forEachChild((child) => {
413
949
  if (!found) {
414
- found = this.findTaintInExpression(child, tainted, _sourceFile);
950
+ found = this.findTaintInExpression(child, tainted, sourceFile, scope);
415
951
  }
416
952
  });
417
953
  return found;
418
954
  }
419
955
  /**
420
956
  * Find the affected variable for a sink.
957
+ *
958
+ * Parses the sink's AST sub-tree to find identifiers, resolves each to its
959
+ * declaration via the scope stack, and matches against tainted entries by
960
+ * declaration identity key. Falls back to name-based matching for taint
961
+ * entries without a declaration key.
421
962
  */
422
963
  findAffectedVariable(sink, tainted, sourceFile) {
423
- // Parse the sink expression to find referenced variables
964
+ // Build a scope stack to resolve identifiers in the sink
965
+ const scope = new ScopeStack();
966
+ const sinkLine = sink.location.line; // 1-based
967
+ // Collect all identifier names referenced in the sink expression text.
968
+ // We need to find the AST node for the sink and walk its identifiers.
969
+ let sinkNode = null;
970
+ const findSinkNode = (node) => {
971
+ const isScope = isScopeNode(node);
972
+ if (isScope)
973
+ scope.enterScope(node);
974
+ // Register all declaration types
975
+ registerNodeDeclarations(node, scope);
976
+ // Check if this node matches the sink location
977
+ const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
978
+ if (line + 1 === sinkLine && node.getText(sourceFile) === sink.expression) {
979
+ sinkNode = node;
980
+ }
981
+ ts.forEachChild(node, findSinkNode);
982
+ if (isScope)
983
+ scope.leaveScope();
984
+ };
985
+ findSinkNode(sourceFile);
986
+ // Walk identifiers in the sink node and match against tainted declarations
987
+ if (sinkNode) {
988
+ // Re-walk up to and including the sink node to have scope in correct state
989
+ const scope2 = new ScopeStack();
990
+ let result = null;
991
+ const walkForMatch = (node) => {
992
+ const isScope = isScopeNode(node);
993
+ if (isScope)
994
+ scope2.enterScope(node);
995
+ registerNodeDeclarations(node, scope2);
996
+ // If we found the sink node, search its identifiers
997
+ if (node === sinkNode) {
998
+ const match = this.findTaintInExpression(node, tainted, sourceFile, scope2);
999
+ if (match) {
1000
+ result = match;
1001
+ if (isScope)
1002
+ scope2.leaveScope();
1003
+ return true;
1004
+ }
1005
+ }
1006
+ let found = false;
1007
+ node.forEachChild((child) => {
1008
+ if (!found) {
1009
+ found = walkForMatch(child);
1010
+ }
1011
+ });
1012
+ if (isScope)
1013
+ scope2.leaveScope();
1014
+ return found;
1015
+ };
1016
+ walkForMatch(sourceFile);
1017
+ // Scope-based resolution is authoritative when the sink node was found.
1018
+ // Return the result (which may be null) without falling through to text-based matching.
1019
+ return result;
1020
+ }
1021
+ // Fallback: text-based matching ONLY for cases where AST node wasn't found.
1022
+ // Uses escaped name to prevent regex metacharacter injection.
424
1023
  const sinkText = sink.expression;
425
1024
  for (const taintedVar of tainted) {
426
- // Check if the tainted variable is used in the sink
427
- // Trust: ANALYZED_CODE - Variable name extracted from analyzed source, not user input
428
- // Control: Word boundaries (\b) prevent pattern injection from identifier names
429
- // See docs/security/regex-threat-model.md
430
- // eslint-disable-next-line security/detect-non-literal-regexp -- Analyzed code identifier
431
- const varPattern = new RegExp(`\\b${taintedVar.name}\\b`);
1025
+ const escaped = taintedVar.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1026
+ // eslint-disable-next-line security/detect-non-literal-regexp -- Escaped analyzed code identifier
1027
+ const varPattern = new RegExp(`\\b${escaped}\\b`);
432
1028
  if (varPattern.test(sinkText)) {
433
1029
  return taintedVar;
434
1030
  }
435
1031
  }
436
1032
  // Also check for common patterns where taint flows through function params
437
- // This is a simplified check - full taint tracking would be more complex
438
1033
  const functionParams = this.extractFunctionParams(sourceFile, sink.location.line);
439
1034
  for (const param of functionParams) {
440
1035
  const taintedParam = tainted.find((t) => t.name === param);