@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
407 lines (321 loc) • 17 kB
JavaScript
/**
* Heuristic analyzer for S007 - No Plaintext OTP
* Purpose: Detect storing or transmitting OTP codes in plaintext
* Based on OWASP A02:2021 - Cryptographic Failures
*/
class S007Analyzer {
constructor() {
this.ruleId = 'S007';
this.ruleName = 'No Plaintext OTP';
this.description = 'One-Time Passwords must not be stored in plaintext';
// Keywords that indicate OTP/one-time codes
this.otpKeywords = [
'otp', 'totp', 'hotp', 'one.?time', 'onetime', '2fa', 'mfa',
'authenticator', 'verification.?code', 'auth.?code', 'sms.?code',
'temp.?code', 'temp.?password', 'pin.?code', 'security.?code',
'access.?code', 'login.?code', 'token'
];
// Keywords that indicate storage operations
this.storageKeywords = [
'save', 'store', 'insert', 'update', 'create', 'persist', 'write',
'set', 'put', 'add', 'database', 'db', 'collection', 'table',
'cache', 'session', 'localStorage', 'sessionStorage', 'cookie',
'redis', 'mongo', 'sql', 'query', 'orm'
];
// Keywords that indicate transmission operations
this.transmissionKeywords = [
'send', 'email', 'sms', 'text', 'message', 'mail', 'push', 'notify',
'transmit', 'deliver', 'dispatch', 'forward', 'post', 'response',
'json', 'body', 'payload', 'data', 'return', 'res\.', 'api'
];
// Patterns that indicate plaintext OTP usage
this.plaintextOtpPatterns = [
// Storage patterns - storing OTP in plaintext
/(?:save|store|insert|update|create|persist|write|set|put|add).*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code)/i,
/(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code).*(?:save|store|insert|update|create|persist|write|set|put|add)/i,
// Database storage patterns
/(?:database|db|collection|table|redis|mongo|sql|query|orm).*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code)/i,
/(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code).*(?:database|db|collection|table|redis|mongo|sql|query|orm)/i,
// Transmission patterns - sending OTP in plaintext
/(?:send|email|sms|text|message|mail|push|notify|transmit|deliver|dispatch|forward|post).*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code)/i,
/(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code).*(?:send|email|sms|text|message|mail|push|notify|transmit|deliver|dispatch|forward|post)/i,
// Response patterns - returning OTP in API responses
/(?:response|json|body|payload|data|return|res\.).*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code)/i,
/(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code).*(?:response|json|body|payload|data|return|res\.)/i,
// Variable assignments with OTP that are stored/transmitted (not just declared)
/(?:const|let|var)\s+\w*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code)\w*\s*=\s*['"`][^'"`]+['"`].*(?:save|store|send|transmit|cache|redis|db|database)/i,
// String literals containing OTP
/['"`].*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code).*['"`]/i,
// Template strings with OTP
/\$\{.*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code).*\}/i,
// Console/logging with OTP
/(?:console|log|debug|trace|print).*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code)/i,
// Session/localStorage with OTP
/(?:session|localStorage|sessionStorage|cookie).*(?:otp|totp|hotp|one.?time|onetime|2fa|mfa|auth.?code|verification.?code|sms.?code|temp.?code|security.?code|access.?code|login.?code)/i,
];
// Patterns that should be excluded (safe practices)
this.safePatterns = [
// Hashed or encrypted OTP
/hash|encrypt|cipher|bcrypt|crypto|secure|salt|hmac|sha|md5|pbkdf2/i,
// Environment variables or config (configuration, not storage of actual values)
/process\.env|config\.|getenv|\.env/i,
// Comments and documentation
/\/\/|\/\*|\*\/|@param|@return|@example|@description|TODO|FIXME/,
// Type definitions and interfaces
/interface|type|enum|class\s+\w+\s*\{|abstract\s+class/i,
// Import/export statements
/import|export|require|module\.exports/i,
// Safe message patterns (no actual OTP values exposed)
/instructions sent|sent to|check your|please enter|has been sent|successfully sent|we've sent|click the link|enter the code|will expire|verify|authenticate/i,
// Function/method definitions
/function\s+\w+|async\s+function|\w+\s*\([^)]*\)\s*\{|=>\s*\{/i,
// Safe return statements (status messages, not actual codes)
/return\s*\{[^}]*(?:success|message|status|sent|verified)[^}]*\}/i,
// Time-based checks or validation (not storage)
/(?:validate|verify|check|compare|match|expired|timeout|ttl|duration)/i,
// Method names that don't store plaintext
/(?:generateOtp|createOtp|validateOtp|verifyOtp|checkOtp|expireOtp|deleteOtp|removeOtp)/i,
// Safe operations on OTP (hashing, encrypting, etc.)
/(?:hash|encrypt|decrypt|cipher|secure|bcrypt|createHash|createHmac).*(?:otp|code|auth)/i,
/(?:otp|code|auth).*(?:hash|encrypt|decrypt|cipher|secure|bcrypt|Hash|Hmac)/i,
// Secure service calls
/(?:secure|encrypted|safe).*(?:send|email|sms|service)/i,
/(?:send|email|sms).*(?:secure|encrypted|safe)/i,
// Token/session creation from OTP (not storing OTP itself)
/(?:create|generate).*(?:token|session).*(?:from|with)/i,
/(?:token|session).*(?:create|generate)/i,
// Safe logging patterns
/log.*(?:success|sent|generated|timestamp|result)/i,
/console\.(?:log|error|warn|debug).*(?:user|failed|success|validation|error)(?!.*otp.*:)/i,
/log.*(?:failed|error|validation).*user/i,
// Return only metadata, not OTP
/return.*(?:timestamp|Date|success|message|sent)/i,
// Safe crypto operations on OTP (hashing, not exposing)
/\.update\(.*otp.*\+.*\)|\.update\(otp.*\)|createHmac.*\.update/i,
/crypto\.create(?:Hash|Hmac).*\.update\(/i,
];
// Common safe variable names that might contain OTP keywords
this.safeVariableNames = [
/^(is|has|can|should|will|enable|disable|show|hide|display|need|require).*(?:otp|code|auth)/i,
/^.*(?:type|config|setting|option|flag|enabled|disabled|required|valid|invalid|expired)$/i,
/^(?:otp|code|auth).*(?:type|config|setting|option|flag|enabled|disabled|required|valid|invalid|expired)$/i,
/^(?:validate|verify|check|generate|create|expire|delete|remove).*(?:otp|code|auth)/i,
];
}
async analyze(files, language, options = {}) {
const violations = [];
for (const filePath of files) {
// Skip test files, build directories, and node_modules
if (this.shouldSkipFile(filePath)) {
continue;
}
try {
const content = require('fs').readFileSync(filePath, 'utf8');
const fileViolations = this.analyzeFile(content, filePath, options);
violations.push(...fileViolations);
} catch (error) {
if (options.verbose) {
console.warn(`⚠️ Failed to analyze ${filePath}: ${error.message}`);
}
}
}
return violations;
}
shouldSkipFile(filePath) {
const skipPatterns = [
'test/', 'tests/', '__tests__/', '.test.', '.spec.',
'node_modules/', 'build/', 'dist/', '.next/', 'coverage/',
'vendor/', 'mocks/', '.mock.'
// Removed 'fixtures/' to allow testing
];
return skipPatterns.some(pattern => filePath.includes(pattern));
}
analyzeFile(content, filePath, options = {}) {
const violations = [];
const lines = content.split('\n');
lines.forEach((line, index) => {
const lineNumber = index + 1;
const trimmedLine = line.trim();
// Skip comments, imports, and empty lines
if (this.shouldSkipLine(trimmedLine)) {
return;
}
// Check for potential plaintext OTP usage
const violation = this.checkForPlaintextOtpUsage(line, lineNumber, filePath);
if (violation) {
violations.push(violation);
}
});
return violations;
}
shouldSkipLine(line) {
// Skip comments, imports, and other non-code lines
return (
line.length === 0 ||
line.startsWith('//') ||
line.startsWith('/*') ||
line.startsWith('*') ||
line.startsWith('import ') ||
line.startsWith('export ') ||
line.startsWith('require(') ||
line.includes('module.exports')
);
}
checkForPlaintextOtpUsage(line, lineNumber, filePath) {
const lowerLine = line.toLowerCase();
// First check if line contains safe patterns (early exit)
if (this.containsSafePattern(line)) {
return null;
}
// Skip lines that are clearly secure operations
if (this.isSecureOperation(line)) {
return null;
}
// Check for variable assignments with sensitive OTP names
const sensitiveAssignment = this.checkSensitiveOtpAssignment(line, lineNumber, filePath);
if (sensitiveAssignment) {
return sensitiveAssignment;
}
// Check for direct plaintext OTP patterns
for (const pattern of this.plaintextOtpPatterns) {
if (pattern.test(line)) {
// Additional context check to reduce false positives
if (this.hasOtpUsageContext(line) && !this.isSecureOperation(line)) {
return {
ruleId: this.ruleId,
severity: 'error',
message: 'OTP codes should not be stored or transmitted in plaintext. Use encrypted storage or hashed values.',
line: lineNumber,
column: this.findPatternColumn(line, pattern),
filePath: filePath,
type: 'plaintext_otp_usage',
details: 'Consider encrypting OTP codes before storage, using secure transmission channels, or storing only hashed/encrypted versions.'
};
}
}
}
return null;
}
containsSafePattern(line) {
return this.safePatterns.some(pattern => pattern.test(line));
}
isSecureOperation(line) {
// Check if the line is performing secure operations on OTP
const secureOperationPatterns = [
// Hashing operations
/(?:bcrypt\.hash|crypto\.createHash|createHmac|hash|encrypt).*(?:otp|code|auth)/i,
/(?:otp|code|auth).*(?:bcrypt\.hash|crypto\.createHash|createHmac|hash|encrypt)/i,
// Secure service calls
/(?:secure|encrypted|safe).*(?:send|email|sms|service|Email|SMS|Service)/i,
// Variable containing 'hashed', 'encrypted', 'secure', 'token'
/(?:hashed|encrypted|secure|token).*(?:otp|code|auth)/i,
/(?:otp|code|auth).*(?:hash|encrypted|secure|token)/i,
// Method calls that return tokens/hashes
/(?:create|generate).*(?:token|hash|secure)/i,
// Comparison operations (validation, not storage)
/(?:compare|verify|validate|check).*(?:otp|code|auth)/i,
/(?:otp|code|auth).*(?:compare|verify|validate|check)/i,
// Safe logging (no actual OTP values) - improved patterns
/console\.(?:log|debug|error|warn).*(?:generated|sent|success|timestamp|user|result|validation|failed)(?!.*otp.*:)/i,
/console\.error.*validation.*failed.*user/i,
/console\.error.*failed.*user.*:/i,
// Return statements with safe content
/return.*(?:timestamp|Date\.now|success|message|sent)/i,
// Simple variable declarations without immediate storage/transmission
/(?:const|let|var)\s+\w*(?:otp|code|auth)\w*\s*=\s*(?:this\.generate|generate|create|[\w.]+\()/i,
// Method calls to secure functions
/await\s+this\.(?:send|secure|encrypted|safe)/i,
// Variable assignments that are passed to secure functions
/(?:const|let|var)\s+\w+\s*=.*;\s*$|(?:const|let|var)\s+\w+\s*=.*(?:generate|create)/i,
// Crypto operations like .update() on hash/hmac objects
/\.update\(.*(?:otp|code|auth).*\+.*\)|\.update\((?:otp|code|auth).*\)/i,
/createHmac.*\.update|createHash.*\.update/i,
];
return secureOperationPatterns.some(pattern => pattern.test(line));
}
checkSensitiveOtpAssignment(line, lineNumber, filePath) {
// Look for variable assignments that combine OTP with storage/transmission
const assignmentMatch = line.match(/(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(.+)/);
if (!assignmentMatch) {
return null;
}
const [, variableName, valueExpr] = assignmentMatch;
const lowerVarName = variableName.toLowerCase();
const lowerValueExpr = valueExpr.toLowerCase();
// Skip safe variable names
if (this.safeVariableNames.some(pattern => pattern.test(lowerVarName))) {
return null;
}
// Check if variable name suggests OTP usage
const hasOtpKeyword = this.otpKeywords.some(keyword => {
const keywordRegex = new RegExp(keyword, 'i');
return keywordRegex.test(lowerVarName);
});
const hasStorageOrTransmissionKeyword = [
...this.storageKeywords,
...this.transmissionKeywords
].some(keyword => {
const keywordRegex = new RegExp(keyword, 'i');
return keywordRegex.test(lowerVarName) || keywordRegex.test(lowerValueExpr);
});
if (hasOtpKeyword && hasStorageOrTransmissionKeyword) {
// Check if the value looks like it contains actual OTP or sensitive data
if (this.valueContainsOtp(valueExpr)) {
return {
ruleId: this.ruleId,
severity: 'warning',
message: `Variable '${variableName}' appears to handle OTP codes for storage/transmission. Ensure OTP codes are encrypted or hashed.`,
line: lineNumber,
column: line.indexOf(variableName) + 1,
filePath: filePath,
type: 'sensitive_otp_variable',
variableName: variableName,
details: 'Consider encrypting OTP codes before storage/transmission or use secure channels.'
};
}
}
return null;
}
hasOtpUsageContext(line) {
const otpUsageIndicators = [
// Storage operations
/(?:save|store|insert|update|create|persist|write|set|put|add|database|db|collection|table|redis|mongo|sql|query|orm)/i,
// Transmission operations
/(?:send|email|sms|text|message|mail|push|notify|transmit|deliver|dispatch|forward|post)/i,
// Response operations
/res\.(?:send|json|status|end)|response\.(?:send|json|status|end)|return.*(?:json|response|status)/i,
// Session/localStorage
/(?:session|localStorage|sessionStorage|cookie|cache)/i,
// Logging (often a violation when OTP is logged)
/(?:console|log|debug|trace|print)/i,
// String operations that might expose OTP
/\+.*['"`]|['"`].*\+|\$\{.*\}/,
];
return otpUsageIndicators.some(indicator => indicator.test(line));
}
valueContainsOtp(valueExpr) {
// Check if the value expression contains actual OTP patterns or user data
const otpValuePatterns = [
// Template strings with variables
/\$\{[^}]+\}/,
// String concatenation
/\+\s*[a-zA-Z_$]/,
// Function calls that might return OTP
/\w+\([^)]*\)/,
// Property access that might be OTP
/\w+\.\w+/,
// Array/object access
/\[.*\]/,
// Direct string literals that look like OTP codes (4-8 digits/chars)
/['"`][a-zA-Z0-9]{4,8}['"`]/,
// Patterns that suggest OTP generation or user input
/(?:generate|random|user|input|request|body|params)/i,
];
return otpValuePatterns.some(pattern => pattern.test(valueExpr));
}
findPatternColumn(line, pattern) {
const match = pattern.exec(line);
return match ? match.index + 1 : 1;
}
}
module.exports = S007Analyzer;