UNPKG

@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
/** * 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;