@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.
- package/dist/agents/ai_semantic_review.d.ts.map +1 -1
- package/dist/agents/ai_semantic_review.js +7 -1
- package/dist/agents/ai_semantic_review.js.map +1 -1
- package/dist/agents/control_flow/safe-source-detector.d.ts +48 -0
- package/dist/agents/control_flow/safe-source-detector.d.ts.map +1 -0
- package/dist/agents/control_flow/safe-source-detector.js +434 -0
- package/dist/agents/control_flow/safe-source-detector.js.map +1 -0
- package/dist/agents/control_flow/safe-source-patterns.d.ts +61 -0
- package/dist/agents/control_flow/safe-source-patterns.d.ts.map +1 -0
- package/dist/agents/control_flow/safe-source-patterns.js +137 -0
- package/dist/agents/control_flow/safe-source-patterns.js.map +1 -0
- package/dist/agents/control_flow/scope-stack.d.ts +167 -0
- package/dist/agents/control_flow/scope-stack.d.ts.map +1 -0
- package/dist/agents/control_flow/scope-stack.js +448 -0
- package/dist/agents/control_flow/scope-stack.js.map +1 -0
- package/dist/agents/control_flow/vulnerability-detector.d.ts +13 -0
- package/dist/agents/control_flow/vulnerability-detector.d.ts.map +1 -1
- package/dist/agents/control_flow/vulnerability-detector.js +630 -35
- package/dist/agents/control_flow/vulnerability-detector.js.map +1 -1
- package/dist/agents/opencode.d.ts.map +1 -1
- package/dist/agents/opencode.js +7 -1
- package/dist/agents/opencode.js.map +1 -1
- package/dist/agents/pr_agent.d.ts.map +1 -1
- package/dist/agents/pr_agent.js +8 -2
- package/dist/agents/pr_agent.js.map +1 -1
- package/dist/agents/security.d.ts.map +1 -1
- package/dist/agents/security.js +1 -0
- package/dist/agents/security.js.map +1 -1
- package/dist/agents/types.d.ts +6 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/benchmark/adapter.d.ts +87 -0
- package/dist/benchmark/adapter.d.ts.map +1 -0
- package/dist/benchmark/adapter.js +298 -0
- package/dist/benchmark/adapter.js.map +1 -0
- package/dist/benchmark/scoring.d.ts +100 -0
- package/dist/benchmark/scoring.d.ts.map +1 -0
- package/dist/benchmark/scoring.js +195 -0
- package/dist/benchmark/scoring.js.map +1 -0
- package/dist/cli/dependencies/schemas.d.ts +3 -3
- package/dist/context-loader.d.ts +80 -0
- package/dist/context-loader.d.ts.map +1 -0
- package/dist/context-loader.js +202 -0
- package/dist/context-loader.js.map +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +131 -4
- package/dist/main.js.map +1 -1
- package/dist/phases/index.d.ts +1 -1
- package/dist/phases/index.d.ts.map +1 -1
- package/dist/phases/index.js +1 -1
- package/dist/phases/index.js.map +1 -1
- package/dist/phases/report.d.ts +8 -1
- package/dist/phases/report.d.ts.map +1 -1
- package/dist/phases/report.js +52 -5
- package/dist/phases/report.js.map +1 -1
- package/dist/report/ado.d.ts +2 -0
- package/dist/report/ado.d.ts.map +1 -1
- package/dist/report/ado.js +9 -23
- package/dist/report/ado.js.map +1 -1
- package/dist/report/finding-validator.d.ts +130 -0
- package/dist/report/finding-validator.d.ts.map +1 -0
- package/dist/report/finding-validator.js +347 -0
- package/dist/report/finding-validator.js.map +1 -0
- package/dist/report/framework-pattern-filter.d.ts +53 -0
- package/dist/report/framework-pattern-filter.d.ts.map +1 -0
- package/dist/report/framework-pattern-filter.js +189 -0
- package/dist/report/framework-pattern-filter.js.map +1 -0
- package/dist/report/github.d.ts +2 -0
- package/dist/report/github.d.ts.map +1 -1
- package/dist/report/github.js +9 -23
- package/dist/report/github.js.map +1 -1
- package/dist/trust.d.ts +6 -0
- package/dist/trust.d.ts.map +1 -1
- package/dist/trust.js +2 -0
- package/dist/trust.js.map +1 -1
- 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:
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
920
|
+
findTaintInExpression(node, tainted, sourceFile, scope) {
|
|
408
921
|
if (ts.isIdentifier(node)) {
|
|
409
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
427
|
-
//
|
|
428
|
-
|
|
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);
|