sicua
Version:
A tool for analyzing project structure and dependencies
488 lines (487 loc) • 21.1 kB
JavaScript
"use strict";
/**
* Detector for unvalidated redirects using router.push() and similar methods
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UnvalidatedRedirectDetector = void 0;
const typescript_1 = __importDefault(require("typescript"));
const BaseDetector_1 = require("./BaseDetector");
const ASTTraverser_1 = require("../utils/ASTTraverser");
const network_constants_1 = require("../constants/network.constants");
const security_constants_1 = require("../constants/security.constants");
class UnvalidatedRedirectDetector extends BaseDetector_1.BaseDetector {
constructor() {
super("UnvalidatedRedirectDetector", "unvalidated-redirect", "medium", UnvalidatedRedirectDetector.REDIRECT_PATTERNS);
}
async detect(scanResult) {
const vulnerabilities = [];
// Filter relevant files
// TODO: MOVE TO CONSTANTS
const relevantFiles = this.filterRelevantFiles(scanResult, [".ts", ".tsx", ".js", ".jsx"], [
"node_modules",
"dist",
"build",
".git",
"coverage",
"__tests__",
".test.",
".spec.",
]);
for (const filePath of relevantFiles) {
const content = scanResult.fileContents.get(filePath);
if (!content)
continue;
// Apply pattern matching
const patternResults = this.applyPatternMatching(content, filePath);
const patternVulnerabilities = this.convertPatternMatchesToVulnerabilities(patternResults, (match) => this.validateRedirectMatch(match));
// Apply AST-based analysis
const sourceFile = scanResult.sourceFiles.get(filePath);
if (sourceFile) {
const astVulnerabilities = this.applyASTAnalysis(sourceFile, filePath, (sf, fp) => this.analyzeASTForUnvalidatedRedirects(sf, fp));
vulnerabilities.push(...astVulnerabilities);
}
// Adjust confidence based on file context
const fileContext = this.getFileContext(filePath, content);
for (const vuln of patternVulnerabilities) {
vuln.confidence = this.adjustConfidenceBasedOnContext(vuln, fileContext);
if (this.validateVulnerability(vuln)) {
vulnerabilities.push(vuln);
}
}
}
return vulnerabilities;
}
/**
* Validate if a redirect pattern match is problematic
*/
validateRedirectMatch(matchResult) {
const match = matchResult.matches[0];
if (!match)
return false;
// Check if it's in a comment
if (this.isInComment(match.context || "", match.match)) {
return false;
}
// Check if it's in test code
if (this.isInTestContext(match.context || "")) {
return false;
}
// Check if there's validation in the context
if (this.hasValidationInContext(match.context || "")) {
return false;
}
return true;
}
/**
* AST-based analysis for unvalidated redirects
*/
analyzeASTForUnvalidatedRedirects(sourceFile, filePath) {
const vulnerabilities = [];
// Find redirect-related call expressions
const redirectCalls = this.findRedirectCalls(sourceFile);
for (const redirectCall of redirectCalls) {
const redirectVuln = this.analyzeRedirectCall(redirectCall, sourceFile, filePath);
if (redirectVuln) {
vulnerabilities.push(redirectVuln);
}
}
// Find window.location assignments
const locationAssignments = this.findLocationAssignments(sourceFile);
for (const locationAssign of locationAssignments) {
const locationVuln = this.analyzeLocationAssignment(locationAssign, sourceFile, filePath);
if (locationVuln) {
vulnerabilities.push(locationVuln);
}
}
return vulnerabilities;
}
/**
* Find redirect-related function calls
*/
findRedirectCalls(sourceFile) {
return ASTTraverser_1.ASTTraverser.findNodesByKind(sourceFile, typescript_1.default.SyntaxKind.CallExpression, (node) => {
// Skip array methods first
if (this.isArrayMethod(node)) {
return false;
}
if (typescript_1.default.isPropertyAccessExpression(node.expression)) {
const method = node.expression.name;
if (typescript_1.default.isIdentifier(method)) {
return network_constants_1.REDIRECT_METHODS.includes(method.text);
}
}
else if (typescript_1.default.isIdentifier(node.expression)) {
return network_constants_1.REDIRECT_METHODS.includes(node.expression.text);
}
return false;
});
}
/**
* Find window.location assignments
*/
findLocationAssignments(sourceFile) {
return ASTTraverser_1.ASTTraverser.findNodesByKind(sourceFile, typescript_1.default.SyntaxKind.BinaryExpression, (node) => {
return (node.operatorToken.kind === typescript_1.default.SyntaxKind.EqualsToken &&
this.isLocationProperty(node.left));
});
}
/**
* Check if expression is a location property (window.location, location.href, etc.)
*/
isLocationProperty(expr) {
if (typescript_1.default.isPropertyAccessExpression(expr)) {
const obj = expr.expression;
const prop = expr.name;
if (typescript_1.default.isIdentifier(obj) && typescript_1.default.isIdentifier(prop)) {
return ((obj.text === "window" && prop.text === "location") ||
(obj.text === "location" &&
(prop.text === "href" || prop.text === "pathname")));
}
// Check for window.location.href
if (typescript_1.default.isPropertyAccessExpression(obj) &&
typescript_1.default.isIdentifier(obj.expression) &&
typescript_1.default.isIdentifier(obj.name) &&
obj.expression.text === "window" &&
obj.name.text === "location" &&
typescript_1.default.isIdentifier(prop) &&
(prop.text === "href" || prop.text === "pathname")) {
return true;
}
}
return false;
}
/**
* Analyze redirect function call
*/
analyzeRedirectCall(callExpr, sourceFile, filePath) {
const location = ASTTraverser_1.ASTTraverser.getNodeLocation(callExpr, sourceFile);
const context = ASTTraverser_1.ASTTraverser.getNodeContext(callExpr, sourceFile);
const code = ASTTraverser_1.ASTTraverser.getNodeText(callExpr, sourceFile);
// Get method name
const methodName = this.getRedirectMethodName(callExpr);
if (!methodName)
return null;
// Analyze arguments for user input
const userInputAnalysis = this.analyzeRedirectArguments(callExpr, sourceFile);
if (!userInputAnalysis || userInputAnalysis.userInputSources.length === 0) {
return null;
}
// Check if there's validation
const hasValidation = this.hasValidationInContext(context) ||
this.hasValidationInFunction(callExpr, sourceFile);
const confidence = hasValidation
? "low"
: userInputAnalysis.confidence === "high"
? "medium"
: "low";
return this.createVulnerability(filePath, {
line: location.line,
column: location.column,
endLine: location.line,
endColumn: location.column + code.length,
}, {
code,
surroundingContext: context,
functionName: this.extractFunctionFromAST(callExpr),
}, `${methodName}() called with potentially unvalidated user input: ${userInputAnalysis.userInputSources.join(", ")}`, "medium", confidence, {
method: methodName,
userInputSources: userInputAnalysis.userInputSources,
hasValidation,
riskLevel: userInputAnalysis.riskLevel,
recommendations: this.generateRedirectRecommendations(userInputAnalysis.userInputSources, hasValidation),
detectionMethod: "redirect-call-analysis",
});
}
/**
* Analyze location assignment
*/
analyzeLocationAssignment(assignment, sourceFile, filePath) {
const location = ASTTraverser_1.ASTTraverser.getNodeLocation(assignment, sourceFile);
const context = ASTTraverser_1.ASTTraverser.getNodeContext(assignment, sourceFile);
const code = ASTTraverser_1.ASTTraverser.getNodeText(assignment, sourceFile);
// Analyze right side for user input
const userInputAnalysis = this.analyzeExpressionForUserInput(assignment.right, sourceFile);
if (!userInputAnalysis || userInputAnalysis.userInputSources.length === 0) {
return null;
}
// Check if there's validation
const hasValidation = this.hasValidationInContext(context) ||
this.hasValidationInFunction(assignment, sourceFile);
const confidence = hasValidation
? "low"
: userInputAnalysis.confidence === "high"
? "medium"
: "low";
return this.createVulnerability(filePath, {
line: location.line,
column: location.column,
endLine: location.line,
endColumn: location.column + code.length,
}, {
code,
surroundingContext: context,
functionName: this.extractFunctionFromAST(assignment),
}, `window.location assignment with potentially unvalidated user input: ${userInputAnalysis.userInputSources.join(", ")}`, "medium", confidence, {
locationType: "location-assignment",
userInputSources: userInputAnalysis.userInputSources,
hasValidation,
riskLevel: userInputAnalysis.riskLevel,
recommendations: this.generateRedirectRecommendations(userInputAnalysis.userInputSources, hasValidation),
detectionMethod: "location-assignment-analysis",
});
}
/**
* Get redirect method name
*/
getRedirectMethodName(callExpr) {
if (typescript_1.default.isPropertyAccessExpression(callExpr.expression) &&
typescript_1.default.isIdentifier(callExpr.expression.name)) {
return callExpr.expression.name.text;
}
else if (typescript_1.default.isIdentifier(callExpr.expression)) {
return callExpr.expression.text;
}
return null;
}
/**
* Analyze redirect function arguments for user input
*/
analyzeRedirectArguments(callExpr, sourceFile) {
const analysis = {
userInputSources: [],
confidence: "low",
riskLevel: "low",
};
if (callExpr.arguments.length > 0) {
const urlArg = callExpr.arguments[0];
const urlAnalysis = this.analyzeExpressionForUserInput(urlArg, sourceFile);
if (urlAnalysis) {
// Filter out server data sources and data manipulation
const filteredSources = urlAnalysis.userInputSources.filter((source) => !this.isServerData(source) && !this.isDataManipulation(source));
if (filteredSources.length > 0) {
analysis.userInputSources.push(...filteredSources);
analysis.confidence = urlAnalysis.confidence;
analysis.riskLevel = urlAnalysis.riskLevel;
}
}
}
return analysis.userInputSources.length > 0 ? analysis : null;
}
/**
* Analyze expression for user input sources
*/
analyzeExpressionForUserInput(expr, sourceFile) {
const analysis = {
userInputSources: [],
confidence: "low",
riskLevel: "low",
};
// Check property access expressions (e.g., req.query.redirect)
if (typescript_1.default.isPropertyAccessExpression(expr)) {
const userInputSource = this.extractUserInputFromPropertyAccess(expr);
if (userInputSource && !this.isDataManipulation(userInputSource)) {
analysis.userInputSources.push(userInputSource);
analysis.confidence = "high";
analysis.riskLevel = this.assessRiskLevel(userInputSource);
}
}
// Check identifiers that might be user input
else if (typescript_1.default.isIdentifier(expr)) {
const name = expr.text.toLowerCase();
// Skip data manipulation variables
if (this.isDataManipulation(name)) {
return null;
}
const matchingSource = network_constants_1.USER_INPUT_SOURCES.find((source) => name.includes(source.toLowerCase()));
if (matchingSource) {
analysis.userInputSources.push(name);
analysis.confidence = "medium";
analysis.riskLevel = this.assessRiskLevel(name);
}
}
// Check template expressions
else if (typescript_1.default.isTemplateExpression(expr)) {
for (const span of expr.templateSpans) {
const spanAnalysis = this.analyzeExpressionForUserInput(span.expression, sourceFile);
if (spanAnalysis) {
analysis.userInputSources.push(...spanAnalysis.userInputSources);
analysis.confidence = "medium";
analysis.riskLevel = "medium";
}
}
}
return analysis.userInputSources.length > 0 ? analysis : null;
}
/**
* Extract user input source from property access
*/
extractUserInputFromPropertyAccess(expr) {
const path = [];
let current = expr;
// Build the property access path
while (typescript_1.default.isPropertyAccessExpression(current)) {
if (typescript_1.default.isIdentifier(current.name)) {
path.unshift(current.name.text);
}
current = current.expression;
}
if (typescript_1.default.isIdentifier(current)) {
path.unshift(current.text);
}
const fullPath = path.join(".");
// Check if any part of the path indicates user input
const hasUserInput = network_constants_1.USER_INPUT_SOURCES.some((source) => fullPath.toLowerCase().includes(source.toLowerCase()));
return hasUserInput ? fullPath : null;
}
/**
* Check if the input source is actually data manipulation rather than user input
*/
isDataManipulation(inputSource) {
const lowerSource = inputSource.toLowerCase();
return security_constants_1.DATA_MANIPULATION_PATTERNS.some((pattern) => lowerSource.includes(pattern) ||
lowerSource.replace(/[_-]/g, "").includes(pattern));
}
/**
* Assess risk level based on input source
*/
assessRiskLevel(inputSource) {
const lowerSource = inputSource.toLowerCase();
if (network_constants_1.HIGH_RISK_INPUT_SOURCES.some((risk) => lowerSource.includes(risk))) {
return "high";
}
else if (network_constants_1.MEDIUM_RISK_INPUT_SOURCES.some((risk) => lowerSource.includes(risk))) {
return "medium";
}
return "low";
}
/**
* Check if validation exists in context
*/
hasValidationInContext(context) {
return network_constants_1.REDIRECT_GATING_PATTERNS.some((pattern) => pattern.test(context));
}
isServerData(inputSource) {
return security_constants_1.SERVER_DATA_INDICATORS.some((indicator) => inputSource.toLowerCase().includes(indicator.toLowerCase()));
}
/**
* Check if the method call is actually for array/collection operations, not navigation
*/
isArrayMethod(callExpr) {
if (!typescript_1.default.isPropertyAccessExpression(callExpr.expression)) {
return false;
}
const obj = callExpr.expression.expression;
const method = callExpr.expression.name;
if (!typescript_1.default.isIdentifier(method)) {
return false;
}
// Check if it's an array method
if (!security_constants_1.ARRAY_METHODS.includes(method.text)) {
return false;
}
// Check if the object appears to be an array/collection
if (typescript_1.default.isIdentifier(obj)) {
const objName = obj.text.toLowerCase();
return security_constants_1.ARRAY_INDICATORS.some((indicator) => objName.includes(indicator));
}
return false;
}
/**
* Check if validation exists in the containing function
*/
hasValidationInFunction(node, sourceFile) {
const containingFunction = ASTTraverser_1.ASTTraverser.findNearestParent(node, typescript_1.default.isFunctionLike);
if (!containingFunction)
return false;
const functionText = ASTTraverser_1.ASTTraverser.getNodeText(containingFunction, sourceFile);
return this.hasValidationInContext(functionText);
}
/**
* Generate security recommendations
*/
generateRedirectRecommendations(userInputSources, hasValidation) {
const recommendations = [];
if (!hasValidation) {
recommendations.push("Implement URL validation before redirecting");
recommendations.push("Use allowlist of permitted redirect URLs");
}
if (userInputSources.some((source) => source.includes("query") || source.includes("params"))) {
recommendations.push("Sanitize URL parameters before using in redirects");
recommendations.push("Validate that redirect URLs are internal or trusted domains");
}
recommendations.push("Consider using relative URLs instead of absolute URLs");
recommendations.push("Log redirect attempts for security monitoring");
return recommendations;
}
/**
* Extract function name from AST node context
*/
extractFunctionFromAST(node) {
let current = node.parent;
while (current) {
if (typescript_1.default.isFunctionDeclaration(current) && current.name) {
return current.name.text;
}
if (typescript_1.default.isMethodDeclaration(current) && typescript_1.default.isIdentifier(current.name)) {
return current.name.text;
}
if (typescript_1.default.isVariableDeclaration(current) &&
typescript_1.default.isIdentifier(current.name) &&
current.initializer &&
(typescript_1.default.isFunctionExpression(current.initializer) ||
typescript_1.default.isArrowFunction(current.initializer))) {
return current.name.text;
}
current = current.parent;
}
return undefined;
}
}
exports.UnvalidatedRedirectDetector = UnvalidatedRedirectDetector;
UnvalidatedRedirectDetector.REDIRECT_PATTERNS = [
{
id: "router-push-user-input",
name: "router.push with user input",
description: "router.push() with potentially unvalidated user input detected",
pattern: {
type: "regex",
expression: /router\.push\s*\(\s*[^)]*(?:query|params|searchParams|req\.|request\.)/gi,
},
vulnerabilityType: "unvalidated-redirect",
severity: "medium",
confidence: "medium",
fileTypes: [".ts", ".tsx", ".js", ".jsx"],
enabled: true,
},
{
id: "window-location-user-input",
name: "window.location with user input",
description: "window.location assignment with potentially unvalidated user input detected",
pattern: {
type: "regex",
expression: /window\.location\s*=\s*[^;]*(?:query|params|searchParams|req\.|request\.)/gi,
},
vulnerabilityType: "unvalidated-redirect",
severity: "medium",
confidence: "medium",
fileTypes: [".ts", ".tsx", ".js", ".jsx"],
enabled: true,
},
{
id: "redirect-function-user-input",
name: "redirect() with user input",
description: "redirect() function with potentially unvalidated user input detected",
pattern: {
type: "regex",
expression: /redirect\s*\(\s*[^)]*(?:query|params|searchParams|req\.|request\.)/gi,
},
vulnerabilityType: "unvalidated-redirect",
severity: "medium",
confidence: "medium",
fileTypes: [".ts", ".tsx", ".js", ".jsx"],
enabled: true,
},
];