@sun-asterisk/sunlint
Version:
☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards
367 lines (297 loc) • 13.1 kB
JavaScript
/**
* Heuristic analyzer for S048 - No Current Password in Reset Process
* Purpose: Detect requiring current password during password reset process
* Based on OWASP A04:2021 - Insecure Design
*/
class S048Analyzer {
constructor() {
this.ruleId = 'S048';
this.ruleName = 'No Current Password in Reset Process';
this.description = 'Do not require current password during password reset process';
// Keywords that indicate password reset functionality
this.resetKeywords = [
'reset', 'forgot', 'recover', 'change', 'update', 'modify',
'resetpassword', 'forgotpassword', 'changepassword', 'updatepassword'
];
// Keywords that indicate current password requirement
this.currentPasswordKeywords = [
'currentpassword', 'current_password', 'oldpassword', 'old_password',
'existingpassword', 'existing_password', 'presentpassword', 'present_password',
'previouspassword', 'previous_password', 'originalpassword', 'original_password'
];
// API endpoint patterns for password reset
this.resetEndpointPatterns = [
/\/reset[-_]?password/i,
/\/forgot[-_]?password/i,
/\/change[-_]?password/i,
/\/update[-_]?password/i,
/\/password[-_]?reset/i,
/\/password[-_]?change/i,
/\/password[-_]?update/i,
/\/user\/password/i,
/\/auth\/reset/i,
/\/auth\/forgot/i
];
// Function/method patterns related to password reset
this.resetFunctionPatterns = [
/resetpassword/i,
/forgotpassword/i,
/changepassword/i,
/updatepassword/i,
/passwordreset/i,
/passwordchange/i,
/passwordupdate/i,
/handlepasswordreset/i,
/handleforgotpassword/i,
/processpasswordreset/i
];
// Patterns for requiring current password in reset context
this.violationPatterns = [
// Validation/requirement patterns
/(?:required?|validate|check|verify).*(?:current|old|existing|present|previous|original).*password/i,
/(?:current|old|existing|present|previous|original).*password.*(?:required?|validate|check|verify)/i,
// Form field patterns
/(?:input|field|param|body|request).*(?:current|old|existing|present|previous|original).*password/i,
/(?:current|old|existing|present|previous|original).*password.*(?:input|field|param|body|request)/i,
// Comparison patterns
/(?:compare|match|equal|verify).*(?:current|old|existing|present|previous|original).*password/i,
/(?:current|old|existing|present|previous|original).*password.*(?:compare|match|equal|verify)/i,
// Database lookup patterns
/(?:select|find|get|fetch|query).*(?:current|old|existing|present|previous|original).*password/i,
/(?:current|old|existing|present|previous|original).*password.*(?:select|find|get|fetch|query)/i,
// Error message patterns
/(?:current|old|existing|present|previous|original).*password.*(?:incorrect|wrong|invalid|mismatch)/i,
/(?:incorrect|wrong|invalid|mismatch).*(?:current|old|existing|present|previous|original).*password/i,
// Schema/model field patterns
/currentPassword|current_password|oldPassword|old_password|existingPassword|existing_password/,
// Template/HTML patterns
/"[^"]*(?:current|old|existing|present|previous|original)[^"]*password[^"]*"/i,
/'[^']*(?:current|old|existing|present|previous|original)[^']*password[^']*'/i,
/`[^`]*(?:current|old|existing|present|previous|original)[^`]*password[^`]*`/i
];
// Safe patterns that should be excluded
this.safePatterns = [
// Comments and documentation
/\/\/|\/\*|\*\/|@param|@return|@example|@deprecated/,
// Import/export statements
/import|export|require|module\.exports/i,
// Type definitions
/interface|type|enum|class.*\{/i,
// Configuration files
/config|setting|option|constant|env/i,
// Test files patterns
/test|spec|mock|fixture|stub/i,
// Logging patterns (acceptable for debugging)
/log|debug|trace|console|logger/i,
// Historical/audit patterns (not current validation)
/history|audit|backup|archive|previous.*login/i,
// Password change (not reset) - legitimate to require current password
/changepassword.*current/i,
/updatepassword.*current/i,
// Safe messages about security
/for security|security purposes|secure|protection|best practice/i,
// Documentation patterns
/should not|avoid|don't|never|security risk|vulnerability/i
];
// Context keywords that indicate password reset (not change)
this.resetContextKeywords = [
'reset', 'forgot', 'forgotten', 'recover', 'recovery', 'token', 'link', 'email',
'verification', 'verify', 'code', 'otp', 'temporary'
];
// Keywords that indicate password change (legitimate to require current password)
this.changeContextKeywords = [
'profile', 'settings', 'account', 'preferences', 'dashboard', 'authenticated',
'logged', 'session'
];
}
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.'
];
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 password reset context
if (this.isPasswordResetContext(content, line, lineNumber)) {
// Check for current password requirement violation
const violation = this.checkForCurrentPasswordRequirement(line, lineNumber, filePath, content);
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')
);
}
isPasswordResetContext(content, line, lineNumber) {
const lowerContent = content.toLowerCase();
const lowerLine = line.toLowerCase();
// Check if this is in a password reset context
const hasResetContext = (
// Check current line for reset keywords
this.resetKeywords.some(keyword => lowerLine.includes(keyword)) ||
// Check for reset endpoint patterns
this.resetEndpointPatterns.some(pattern => pattern.test(line)) ||
// Check for reset function patterns
this.resetFunctionPatterns.some(pattern => pattern.test(line)) ||
// Check surrounding context (within 10 lines)
this.hasResetContextNearby(content, lineNumber)
);
// Exclude if it's clearly a password change context (not reset)
const hasChangeContext = this.changeContextKeywords.some(keyword =>
lowerContent.includes(keyword) || lowerLine.includes(keyword)
);
return hasResetContext && !hasChangeContext;
}
hasResetContextNearby(content, lineNumber) {
const lines = content.split('\n');
const start = Math.max(0, lineNumber - 10);
const end = Math.min(lines.length, lineNumber + 10);
for (let i = start; i < end; i++) {
const nearbyLine = lines[i].toLowerCase();
// Check for reset context keywords
if (this.resetContextKeywords.some(keyword => nearbyLine.includes(keyword))) {
return true;
}
// Check for reset endpoints
if (this.resetEndpointPatterns.some(pattern => pattern.test(lines[i]))) {
return true;
}
// Check for reset function names
if (this.resetFunctionPatterns.some(pattern => pattern.test(lines[i]))) {
return true;
}
}
return false;
}
checkForCurrentPasswordRequirement(line, lineNumber, filePath, content) {
// First check if line contains safe patterns (early exit)
if (this.containsSafePattern(line)) {
return null;
}
// Check for direct violation patterns
for (const pattern of this.violationPatterns) {
if (pattern.test(line)) {
// Additional context validation to reduce false positives
if (this.isValidViolationContext(line, content, lineNumber)) {
return {
ruleId: this.ruleId,
severity: 'error',
message: 'Password reset process should not require current password. Use secure token-based reset instead.',
line: lineNumber,
column: this.findPatternColumn(line, pattern),
filePath: filePath,
type: 'current_password_in_reset',
details: 'Requiring current password during reset defeats the purpose of password reset and creates security issues. Use email/SMS verification with secure tokens instead.'
};
}
}
}
// Check for variable/field names that suggest current password requirement
const currentPasswordField = this.checkCurrentPasswordField(line, lineNumber, filePath);
if (currentPasswordField) {
return currentPasswordField;
}
return null;
}
containsSafePattern(line) {
return this.safePatterns.some(pattern => pattern.test(line));
}
isValidViolationContext(line, content, lineNumber) {
const lowerLine = line.toLowerCase();
// Check if this is actually about password reset (not change)
const hasResetIndicators = this.resetContextKeywords.some(keyword =>
content.toLowerCase().includes(keyword)
);
// Check if it's in a validation/requirement context
const hasRequirementContext = [
'required', 'validate', 'check', 'verify', 'input', 'field', 'param',
'body', 'request', 'schema', 'model', 'form'
].some(keyword => lowerLine.includes(keyword));
// Check if it's actually requiring/validating current password
const hasCurrentPasswordRequirement = this.currentPasswordKeywords.some(keyword =>
lowerLine.includes(keyword)
);
return hasResetIndicators && hasRequirementContext && hasCurrentPasswordRequirement;
}
checkCurrentPasswordField(line, lineNumber, filePath) {
// Look for variable declarations, object properties, or field definitions
// that suggest current password fields in reset context
const fieldPatterns = [
// Variable declarations
/(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=.*(?:current|old|existing).*password/i,
// Object properties
/['"']?(currentPassword|current_password|oldPassword|old_password|existingPassword|existing_password)['"']?\s*:/,
// Form field names
/name\s*=\s*['"](current|old|existing)[-_]?password['"]/i,
// Schema/model fields
/(?:currentPassword|current_password|oldPassword|old_password|existingPassword|existing_password)\s*:\s*(?:String|type|required)/i,
// Validation rules
/(?:currentPassword|current_password|oldPassword|old_password|existingPassword|existing_password).*(?:required|validate)/i
];
for (const pattern of fieldPatterns) {
const match = line.match(pattern);
if (match) {
return {
ruleId: this.ruleId,
severity: 'warning',
message: `Field '${match[1] || match[0]}' suggests requiring current password in reset process. This should be avoided.`,
line: lineNumber,
column: line.indexOf(match[0]) + 1,
filePath: filePath,
type: 'current_password_field',
fieldName: match[1] || match[0],
details: 'Password reset should use token-based verification, not current password validation.'
};
}
}
return null;
}
findPatternColumn(line, pattern) {
const match = pattern.exec(line);
return match ? match.index + 1 : 1;
}
}
module.exports = S048Analyzer;