@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
542 lines (471 loc) • 14.2 kB
JavaScript
const { Project, SyntaxKind } = require("ts-morph");
/**
* S017 Regex-Based Analyzer - Always use parameterized queries
* Uses regex patterns and TypeScript AST to detect SQL injection vulnerabilities
*/
class S017RegexBasedAnalyzer {
constructor(semanticEngine = null) {
this.ruleId = "S017";
this.ruleName = "Always use parameterized queries";
this.semanticEngine = semanticEngine;
this.verbose = false;
this.debug = process.env.SUNLINT_DEBUG === "1";
// SQL execution methods
this.sqlMethods = [
"query",
"execute",
"exec",
"run",
"all",
"get",
"prepare",
"createQuery",
"executeQuery",
"executeSql",
"rawQuery",
];
// SQL keywords that indicate SQL operations
this.sqlKeywords = [
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"CREATE",
"ALTER",
"UNION",
"WHERE",
"ORDER BY",
"GROUP BY",
"HAVING",
"FROM",
"JOIN",
"INNER JOIN",
"LEFT JOIN",
"RIGHT JOIN",
"FULL JOIN",
];
// Database libraries to look for
this.databaseLibraries = [
"mysql",
"mysql2",
"pg",
"postgres",
"sqlite3",
"sqlite",
"mssql",
"tedious",
"oracle",
"mongodb",
"mongoose",
"sequelize",
"typeorm",
"prisma",
"knex",
"objection",
];
// Safe patterns that indicate parameterized queries
this.safePatterns = [
"\\?",
"\\$1",
"\\$2",
"\\$3",
"\\$4",
"\\$5",
"prepare",
"bind",
"params",
"parameters",
"values",
];
if (this.debug) {
console.log(
`🔧 [S017-Regex] Constructor - databaseLibraries:`,
this.databaseLibraries.length
);
console.log(
`🔧 [S017-Regex] Constructor - sqlMethods:`,
this.sqlMethods.length
);
}
}
/**
* Initialize with semantic engine
*/
async initialize(semanticEngine = null) {
if (semanticEngine) {
this.semanticEngine = semanticEngine;
this.verbose = semanticEngine.verbose || false;
}
if (this.verbose) {
console.log(
`🔧 [S017 Regex-Based] Analyzer initialized, verbose: ${this.verbose}`
);
}
}
/**
* Analyze file using AST
*/
async analyzeFile(filePath, fileContent) {
if (this.debug) {
console.log(`🔍 [S017-AST] Analyzing: ${filePath}`);
}
const violations = [];
try {
const project = new Project({
useInMemoryFileSystem: true,
compilerOptions: {
allowJs: true,
target: "ES2020",
},
});
const sourceFile = project.createSourceFile(filePath, fileContent);
// Find SQL-related method calls
const sqlMethodCalls = this.findSqlMethodCalls(sourceFile);
for (const methodCall of sqlMethodCalls) {
const sqlViolations = this.analyzeSqlMethodCall(methodCall, filePath);
violations.push(...sqlViolations);
}
// Find template literals with SQL content
const templateLiterals = this.findSqlTemplateLiterals(sourceFile);
for (const template of templateLiterals) {
const templateViolations = this.analyzeTemplateLiteral(
template,
filePath
);
violations.push(...templateViolations);
}
if (this.debug) {
console.log(
`🔍 [S017-AST] Found ${violations.length} violations in ${filePath}`
);
}
} catch (error) {
if (this.debug) {
console.error(`❌ [S017-AST] Error analyzing ${filePath}:`, error);
}
}
return violations;
}
/**
* Find method calls that might execute SQL
*/
findSqlMethodCalls(sourceFile) {
const methodCalls = [];
sourceFile.forEachDescendant((node) => {
if (node.getKind() === SyntaxKind.CallExpression) {
const callExpr = node;
const expression = callExpr.getExpression();
let methodName = "";
if (expression.getKind() === SyntaxKind.PropertyAccessExpression) {
const propAccess = expression;
methodName = propAccess.getName();
} else if (expression.getKind() === SyntaxKind.Identifier) {
methodName = expression.getText();
}
// Check if method name matches SQL execution methods
if (this.sqlMethods.includes(methodName)) {
methodCalls.push({
node: callExpr,
methodName,
line: callExpr.getStartLineNumber(),
column: callExpr.getStart(),
});
}
}
});
return methodCalls;
}
/**
* Check if text contains SQL keywords in proper SQL context
*/
containsSqlKeywords(text) {
// Convert to uppercase for case-insensitive matching
const upperText = text.toUpperCase();
// Check for SQL keywords that should be word-bounded
return this.sqlKeywords.some((keyword) => {
const upperKeyword = keyword.toUpperCase();
// For multi-word keywords like "ORDER BY", check exact match
if (upperKeyword.includes(" ")) {
return upperText.includes(upperKeyword);
}
// For single-word keywords, ensure word boundaries
// This prevents "FROM" matching "documents from logs" (casual English)
// but allows "SELECT * FROM users" (SQL context)
const wordBoundaryRegex = new RegExp(`\\b${upperKeyword}\\b`, "g");
const matches = upperText.match(wordBoundaryRegex);
if (!matches) return false;
// Additional context check: if it's a common English word in non-SQL context, be more strict
if (["FROM", "WHERE", "ORDER", "GROUP", "JOIN"].includes(upperKeyword)) {
// Check if it's likely SQL context by looking for other SQL indicators
const sqlIndicators = [
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"TABLE",
"DATABASE",
"\\*",
"SET ",
"VALUES",
];
const hasSqlContext = sqlIndicators.some((indicator) =>
upperText.includes(indicator.toUpperCase())
);
// For logging statements, require stronger SQL context
if (this.isLikelyLoggingStatement(text)) {
return hasSqlContext && matches.length > 0;
}
return hasSqlContext || matches.length > 1; // Multiple SQL keywords suggest SQL context
}
return matches.length > 0;
});
}
/**
* Check if text looks like a logging statement
*/
isLikelyLoggingStatement(text) {
const loggingIndicators = [
"✅",
"❌",
"🐝",
"⚠️",
"🔧",
"📊",
"🔍", // Emoji indicators
"log:",
"info:",
"debug:",
"warn:",
"error:", // Log level indicators
"Step",
"Start",
"End",
"Complete",
"Success",
"Failed", // Process indicators
"We got",
"We have",
"Found",
"Processed",
"Recovered", // Reporting language
"[LINE]",
"[DB]",
"[Service]",
"[API]", // System component indicators
"Delete rich-menu",
"Create rich-menu",
"Update rich-menu", // Specific app operations
"successfully",
"failed",
"done",
"error", // Result indicators
"Rollback",
"Upload",
"Download", // Action verbs in app context
".log(",
".error(",
".warn(",
".info(",
".debug(", // Method calls
];
return loggingIndicators.some((indicator) => text.includes(indicator));
}
/**
* Check if text contains SQL keywords in proper SQL context
*/
containsSqlKeywords(text) {
// Convert to uppercase for case-insensitive matching
const upperText = text.toUpperCase();
// Early return if this looks like logging - be more permissive
if (this.isLikelyLoggingStatement(text)) {
// For logging statements, require very strong SQL context
const strongSqlIndicators = [
"SELECT *",
"INSERT INTO",
"UPDATE SET",
"DELETE FROM",
"CREATE TABLE",
"DROP TABLE",
"ALTER TABLE",
"WHERE ",
"JOIN ",
"UNION ",
"GROUP BY",
"ORDER BY",
];
const hasStrongSqlContext = strongSqlIndicators.some((indicator) =>
upperText.includes(indicator.toUpperCase())
);
// Only flag logging statements if they contain strong SQL patterns
return hasStrongSqlContext;
}
// Check for SQL keywords that should be word-bounded
return this.sqlKeywords.some((keyword) => {
const upperKeyword = keyword.toUpperCase();
// For multi-word keywords like "ORDER BY", check exact match
if (upperKeyword.includes(" ")) {
return upperText.includes(upperKeyword);
}
// For single-word keywords, ensure word boundaries
const wordBoundaryRegex = new RegExp(`\\b${upperKeyword}\\b`, "g");
const matches = upperText.match(wordBoundaryRegex);
if (!matches) return false;
// Additional context check: if it's a common English word in non-SQL context, be more strict
if (
[
"FROM",
"WHERE",
"ORDER",
"GROUP",
"JOIN",
"CREATE",
"DELETE",
"UPDATE",
].includes(upperKeyword)
) {
// Check if it's likely SQL context by looking for other SQL indicators
const sqlIndicators = [
"TABLE",
"DATABASE",
"COLUMN",
"\\*",
"SET ",
"VALUES",
"INTO ",
];
const hasSqlContext = sqlIndicators.some((indicator) =>
upperText.includes(indicator.toUpperCase())
);
return hasSqlContext || matches.length > 1; // Multiple SQL keywords suggest SQL context
}
return matches.length > 0;
});
}
/**
* Find template literals that might contain SQL
*/
findSqlTemplateLiterals(sourceFile) {
const templateLiterals = [];
sourceFile.forEachDescendant((node) => {
if (node.getKind() === SyntaxKind.TemplateExpression) {
const template = node;
const text = template.getText();
// Check if template contains SQL keywords using improved logic
const containsSql = this.containsSqlKeywords(text);
if (containsSql) {
templateLiterals.push({
node: template,
text,
line: template.getStartLineNumber(),
column: template.getStart(),
});
}
}
});
return templateLiterals;
}
/**
* Analyze SQL method call for vulnerabilities
*/
analyzeSqlMethodCall(methodCall, filePath) {
const violations = [];
const { node, methodName, line } = methodCall;
const args = node.getArguments();
if (args.length === 0) return violations;
const firstArg = args[0];
// Check if first argument is a string concatenation or template literal
if (this.isSuspiciousSqlArgument(firstArg)) {
const argText = firstArg.getText();
const evidence =
node.getText().length > 100
? node.getText().substring(0, 100) + "..."
: node.getText();
violations.push({
ruleId: this.ruleId,
severity: "error",
message: `SQL injection risk in ${methodName}(): avoid string concatenation or template literals in SQL queries`,
source: this.ruleId,
file: filePath,
line: line,
column: firstArg.getStart(),
evidence: evidence,
suggestion: `Use parameterized queries with ${methodName}() method instead of string concatenation`,
category: "security",
});
if (this.debug) {
console.log(
`🚨 [S017-AST] Unsafe SQL method call at line ${line}: ${methodName}`
);
}
}
return violations;
}
/**
* Analyze template literal for SQL injection risks
*/
analyzeTemplateLiteral(template, filePath) {
const violations = [];
const { node, text, line } = template;
// Check if template has variable interpolation
if (node.getTemplateSpans().length > 0) {
const evidence =
text.length > 100 ? text.substring(0, 100) + "..." : text;
violations.push({
ruleId: this.ruleId,
severity: "error",
message:
"SQL injection risk: template literal with variable interpolation in SQL query",
source: this.ruleId,
file: filePath,
line: line,
column: node.getStart(),
evidence: evidence,
suggestion:
"Use parameterized queries instead of template literals for SQL statements",
category: "security",
});
if (this.debug) {
console.log(`🚨 [S017-AST] Unsafe SQL template at line ${line}`);
}
}
return violations;
}
/**
* Check if argument is suspicious for SQL injection
*/
isSuspiciousSqlArgument(argNode) {
const kind = argNode.getKind();
// Template expressions with interpolation
if (kind === SyntaxKind.TemplateExpression) {
return argNode.getTemplateSpans().length > 0;
}
// Binary expressions (string concatenation)
if (kind === SyntaxKind.BinaryExpression) {
const binExpr = argNode;
return binExpr.getOperatorToken().getKind() === SyntaxKind.PlusToken;
}
// Check if it's a template literal with SQL keywords
if (kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
const text = argNode.getText();
return this.sqlKeywords.some((keyword) =>
text.toUpperCase().includes(keyword.toUpperCase())
);
}
return false;
}
/**
* Get analyzer metadata
*/
getMetadata() {
return {
rule: "S017",
name: "Always use parameterized queries",
category: "security",
type: "regex-based",
description:
"Uses regex patterns and AST analysis to detect SQL injection vulnerabilities",
};
}
}
module.exports = S017RegexBasedAnalyzer;