@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
559 lines (464 loc) • 18.1 kB
JavaScript
/**
* S010 Analyzer using ts-morph for accurate AST-based analysis
* Detects insecure random usage in REAL security contexts only
*
* TRUE SECURITY CONTEXTS:
* - OTP generation
* - Token generation (session, reset, verify, magic link)
* - Password/API key generation
* - Security code generation
*
* NON-SECURITY (should NOT flag):
* - Filenames with timestamps
* - Log IDs
* - Request IDs (tracing)
* - Expiration time calculations
*/
const { Project, SyntaxKind } = require('ts-morph');
const fs = require('fs');
const path = require('path');
class S010Analyzer {
constructor() {
this.ruleId = 'S010';
this.MIN_ENTROPY_BITS = 128;
this.MIN_ENTROPY_BYTES = 16;
// TRUE security keywords - only these contexts should trigger
this.securityKeywords = [
// Authentication & Authorization
'otp', 'totp', 'hotp',
'token', 'accesstoken', 'refreshtoken', 'authtoken',
'session', 'sessionid', 'sessid',
// Password & Keys
'password', 'passwd', 'pwd',
'apikey', 'secretkey', 'privatekey',
'secret', // Generic secret
'salt', 'pepper',
// Verification & Reset
'verify', 'verification', 'verifycode',
'reset', 'resettoken', 'passwordreset',
'confirm', 'confirmation', 'confirmcode',
'magiclink',
// Security codes
'securitycode', 'authcode', 'verificationcode',
'pincode', 'pin',
// Encryption
'encrypt', 'cipher', 'iv', 'nonce',
'hmac', 'signature',
];
// EXCLUDE - these are NOT security contexts
this.nonSecurityKeywords = [
'filename', 'file', 'path', 'filepath',
'log', 'logging', 'trace', 'debug',
'request', 'requestid', 'traceid', 'correlationid',
'uuid', 'guid', // UUIDs are OK if properly generated
'timestamp', 'time', 'date',
'expire', 'expiration', 'ttl', 'timeout',
'zip', 'archive', 'backup',
'customer', 'user', 'temp', 'tmp',
];
// Security function names - if variable is inside these functions, it's security context
this.securityFunctionNames = [
'generateotp', 'createotp', 'sendotp',
'generatetoken', 'createtoken', 'issuetoken',
'generatesession', 'createsession',
'generateapikey', 'createapikey',
'generatepassword', 'resetpassword', 'changepassword',
'generateverificationcode', 'createverificationcode',
'generatemagiclink', 'createmagiclink',
'generateresettoken', 'createresettoken',
'generatesecret', 'createsecret',
'encrypt', 'hash', 'sign',
];
// Insecure patterns
this.insecurePatterns = {
mathRandom: ['Math.random'],
dateNow: ['Date.now', 'getTime'],
pythonRandom: ['random.random', 'random.randint', 'random.choice'],
javaRandom: ['new Random', 'Random.next'],
phpRandom: ['rand', 'mt_rand', 'uniqid'],
};
}
async analyze(files, language, options = {}) {
const violations = [];
for (const filePath of files) {
try {
const fileViolations = await this.analyzeFile(filePath);
violations.push(...fileViolations);
} catch (error) {
if (options.verbose) {
console.error(`Error analyzing ${filePath}:`, error.message);
}
}
}
return violations;
}
async analyzeFile(filePath) {
const violations = [];
const ext = path.extname(filePath);
// Only analyze JS/TS files with ts-morph
if (!['.js', '.jsx', '.ts', '.tsx'].includes(ext)) {
return violations;
}
const project = new Project();
const sourceFile = project.addSourceFileAtPath(filePath);
// Build function definition map for tracing
this.functionMap = this.buildFunctionMap(sourceFile);
// Find ALL variable declarations (including nested ones inside functions)
const allVarDecls = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration);
allVarDecls.forEach(varDecl => {
const violation = this.checkVariableDeclaration(varDecl, filePath, sourceFile);
if (violation) violations.push(violation);
});
// Find all call expressions (function calls)
sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).forEach(call => {
const violation = this.checkCallExpression(call, filePath);
if (violation) violations.push(violation);
});
return violations;
}
/**
* Build a map of function definitions to trace helper functions
* Includes imported functions from other files
*/
buildFunctionMap(sourceFile) {
const functionMap = new Map();
// Get all function declarations in current file
sourceFile.getFunctions().forEach(func => {
const name = func.getName();
if (name) {
functionMap.set(name.toLowerCase(), func);
}
});
// Get arrow functions assigned to variables
sourceFile.getVariableDeclarations().forEach(varDecl => {
const initializer = varDecl.getInitializer();
if (initializer &&
(initializer.getKind() === SyntaxKind.ArrowFunction ||
initializer.getKind() === SyntaxKind.FunctionExpression)) {
const name = varDecl.getName();
if (name) {
functionMap.set(name.toLowerCase(), initializer);
}
}
});
// Resolve imported functions from other files
this.resolveImportedFunctions(sourceFile, functionMap);
return functionMap;
}
/**
* Resolve imported functions from require() or import statements
*/
resolveImportedFunctions(sourceFile, functionMap) {
const filePath = sourceFile.getFilePath();
const fileDir = path.dirname(filePath);
// Find all require/import statements
sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).forEach(callExpr => {
const expr = callExpr.getExpression();
// Check if it's require('...')
if (expr.getText() === 'require') {
const args = callExpr.getArguments();
if (args.length > 0) {
const importPath = args[0].getText().replace(/['"]/g, '');
// Only resolve relative imports
if (importPath.startsWith('./') || importPath.startsWith('../')) {
try {
const resolvedPath = this.resolveImportPath(importPath, fileDir);
if (fs.existsSync(resolvedPath)) {
const importedFunctions = this.extractFunctionsFromFile(resolvedPath);
// Get imported names
const parent = callExpr.getParent();
if (parent.getKind() === SyntaxKind.VariableDeclaration) {
const varName = parent.getFirstChildByKind(SyntaxKind.Identifier)?.getText();
if (varName && importedFunctions.has(varName.toLowerCase())) {
functionMap.set(varName.toLowerCase(), importedFunctions.get(varName.toLowerCase()));
}
// Handle destructuring: const { randomString } = require(...)
const bindingPattern = parent.getFirstChildByKind(SyntaxKind.ObjectBindingPattern);
if (bindingPattern) {
bindingPattern.getElements().forEach(element => {
const name = element.getName();
if (name && importedFunctions.has(name.toLowerCase())) {
functionMap.set(name.toLowerCase(), importedFunctions.get(name.toLowerCase()));
}
});
}
}
}
} catch (error) {
// Silently skip import resolution errors
}
}
}
}
});
}
/**
* Resolve import path to absolute file path
*/
resolveImportPath(importPath, baseDir) {
let resolved = path.resolve(baseDir, importPath);
// Try with .js extension
if (!fs.existsSync(resolved)) {
resolved = resolved + '.js';
}
// Try with .ts extension
if (!fs.existsSync(resolved)) {
resolved = resolved.replace(/\.js$/, '.ts');
}
return resolved;
}
/**
* Extract function definitions from an external file
*/
extractFunctionsFromFile(filePath) {
const functionMap = new Map();
try {
const project = new Project();
const sourceFile = project.addSourceFileAtPath(filePath);
// Get exported functions
sourceFile.getFunctions().forEach(func => {
const name = func.getName();
if (name) {
functionMap.set(name.toLowerCase(), func);
}
});
// Get exported arrow functions
sourceFile.getVariableDeclarations().forEach(varDecl => {
const initializer = varDecl.getInitializer();
if (initializer &&
(initializer.getKind() === SyntaxKind.ArrowFunction ||
initializer.getKind() === SyntaxKind.FunctionExpression)) {
const name = varDecl.getName();
if (name) {
functionMap.set(name.toLowerCase(), initializer);
}
}
});
} catch (error) {
// Silently skip errors
}
return functionMap;
}
/**
* Check if a function contains insecure random usage
*/
functionContainsInsecureRandom(funcNode) {
if (!funcNode) return false;
const funcText = funcNode.getText();
return this.hasInsecureRandomUsage(funcText);
}
checkVariableDeclaration(varDecl, filePath, sourceFile) {
const varName = varDecl.getName().toLowerCase();
const initializer = varDecl.getInitializer();
if (!initializer) return null;
const lineNum = varDecl.getStartLineNumber();
// Check if variable name indicates security context
const isSecurityVar = this.isSecurityVariableName(varName);
const isNonSecurityVar = this.isNonSecurityVariableName(varName);
// If explicitly non-security, skip
if (isNonSecurityVar) {
return null;
}
// Check if initializer uses insecure random directly
let hasInsecureRandom = this.hasInsecureRandomUsage(initializer.getText());
let traceInfo = null;
// If no direct insecure random but security context, trace function calls
if (!hasInsecureRandom && isSecurityVar && initializer.getKind() === SyntaxKind.CallExpression) {
const callExpr = initializer;
const functionName = callExpr.getExpression().getText();
// Check if this function contains insecure random
const funcDef = this.functionMap?.get(functionName.toLowerCase());
if (funcDef && this.functionContainsInsecureRandom(funcDef)) {
hasInsecureRandom = true;
traceInfo = {
helperFunction: functionName,
message: `calls helper function "${functionName}()" which uses insecure random`
};
}
}
if (!hasInsecureRandom) return null;
// Check if inside security function context (even if variable name is generic)
const isInSecurityFunction = this.isInSecurityFunctionContext(varDecl);
if (hasInsecureRandom && (isSecurityVar || isInSecurityFunction)) {
let reason;
if (traceInfo) {
// Traced through helper function
reason = `Variable "${varDecl.getName()}" ${traceInfo.message}`;
} else if (isSecurityVar) {
reason = `Variable "${varDecl.getName()}" uses insecure random for security purpose.`;
} else {
reason = `Variable "${varDecl.getName()}" inside security function uses insecure random.`;
}
return {
ruleId: this.ruleId,
severity: 'error',
message: `${reason} Use crypto.randomBytes() or crypto.randomUUID().`,
line: varDecl.getStartLineNumber(),
column: varDecl.getStart(),
filePath: filePath,
context: varName,
...(traceInfo && { helperFunction: traceInfo.helperFunction })
};
}
return null;
}
checkCallExpression(call, filePath) {
const callText = call.getText();
// Check if it's Math.random() or Date.now()
if (!this.hasInsecureRandomUsage(callText)) {
return null;
}
// Get the context where this call is used
const parent = call.getParent();
const grandParent = parent?.getParent();
// Check property assignment: { token: Math.random() }
if (parent.getKind() === SyntaxKind.PropertyAssignment) {
const propName = parent.getChildAtIndex(0).getText().toLowerCase();
if (this.isSecurityVariableName(propName) && !this.isNonSecurityVariableName(propName)) {
return {
ruleId: this.ruleId,
severity: 'error',
message: `Property "${propName}" uses insecure random for security purpose.`,
line: call.getStartLineNumber(),
column: call.getStart(),
filePath: filePath,
context: propName,
};
}
}
// Check variable declaration
if (grandParent?.getKind() === SyntaxKind.VariableDeclaration) {
const varName = grandParent.getChildAtIndex(0).getText().toLowerCase();
if (this.isSecurityVariableName(varName) && !this.isNonSecurityVariableName(varName)) {
return {
ruleId: this.ruleId,
severity: 'error',
message: `Variable "${varName}" uses insecure random for security purpose.`,
line: call.getStartLineNumber(),
column: call.getStart(),
filePath: filePath,
context: varName,
};
}
}
return null;
}
isSecurityVariableName(name) {
const normalized = name.toLowerCase().replace(/[_\-]/g, '');
return this.securityKeywords.some(keyword =>
normalized.includes(keyword.toLowerCase())
);
}
isNonSecurityVariableName(name) {
const normalized = name.toLowerCase().replace(/[_\-]/g, '');
return this.nonSecurityKeywords.some(keyword =>
normalized.includes(keyword.toLowerCase())
);
}
isInSecurityFunctionContext(node) {
// Walk up the AST to find parent function
let current = node.getParent();
while (current) {
const kind = current.getKind();
// Check if it's a function declaration or arrow function
if (kind === SyntaxKind.FunctionDeclaration ||
kind === SyntaxKind.ArrowFunction ||
kind === SyntaxKind.FunctionExpression) {
// Get function name
let funcName = '';
if (kind === SyntaxKind.FunctionDeclaration) {
const nameNode = current.getNameNode();
funcName = nameNode ? nameNode.getText() : '';
} else {
// For arrow functions, check if assigned to a variable
const parent = current.getParent();
if (parent && parent.getKind() === SyntaxKind.VariableDeclaration) {
const nameNode = parent.getNameNode();
funcName = nameNode ? nameNode.getText() : '';
}
}
if (funcName) {
const normalizedFuncName = funcName.toLowerCase().replace(/[_\-]/g, '');
// Check if function name matches security patterns
const isSecurityFunc = this.securityFunctionNames.some(keyword =>
normalizedFuncName.includes(keyword)
);
if (isSecurityFunc) {
return true;
}
}
}
current = current.getParent();
}
return false;
}
hasInsecureRandomUsage(text) {
// Check for Math.random()
if (/Math\.random\s*\(/.test(text)) {
return true;
}
// Check for Date.now() or getTime() when used for randomness (not timestamp)
// Only flag if used with toString(36) or similar encoding
if (/Date\.now\s*\(\).*\.toString\s*\(/.test(text)) {
return true;
}
if (/getTime\s*\(\).*\.toString\s*\(/.test(text)) {
return true;
}
// Check for timestamp-based patterns with encoding
// Pattern: btoa(+new Date), btoa(Date.now()), etc.
if (/btoa\s*\(\s*\+\s*new\s+Date/.test(text)) {
return true;
}
if (/btoa\s*\(\s*Date\.now/.test(text)) {
return true;
}
// Check for +new Date (unary plus operator on Date)
// This converts Date to timestamp, similar to Date.now()
if (/\+\s*new\s+Date.*\.(?:toString|slice|substr|substring)/.test(text)) {
return true;
}
// Check for new Date().getTime() with encoding
if (/new\s+Date\s*\(\s*\)\.getTime\s*\(\).*\.toString/.test(text)) {
return true;
}
// Check for Buffer.from() with timestamp or Math.random
// Pattern: Buffer.from(String(Date.now())).toString('base64')
if (/Buffer\.from\s*\(.*(?:Date\.now|getTime|\+\s*new\s+Date|Math\.random).*\)\.toString\s*\(/.test(text)) {
return true;
}
// Check for btoa() with Math.random
if (/btoa\s*\(.*Math\.random/.test(text)) {
return true;
}
// Check for performance.now() - High-resolution timestamp (also predictable)
if (/performance\.now\s*\(\)/.test(text)) {
return true;
}
// Check for process.pid (low entropy - predictable process ID)
if (/process\.pid\b/.test(text)) {
return true;
}
// Check for process.hrtime() - High-resolution time (timestamp-based)
if (/process\.hrtime(?:\.bigint)?\s*\(/.test(text)) {
return true;
}
return false;
}
getSecureAlternatives(language = 'javascript') {
const alternatives = {
javascript: [
'crypto.randomBytes(16).toString("hex")',
'crypto.randomUUID()',
'crypto.randomInt(100000, 999999) // for OTP',
],
python: [
'secrets.token_hex(16)',
'secrets.token_urlsafe(16)',
'secrets.randbelow(900000) + 100000 # for OTP',
],
};
return alternatives[language] || alternatives.javascript;
}
}
module.exports = S010Analyzer;