420 lines
13 KiB
JavaScript
420 lines
13 KiB
JavaScript
const dns = require('dns');
|
|
const { promisify } = require('util');
|
|
const reverseLookup = promisify(dns.reverse);
|
|
|
|
class SecurityLogService {
|
|
constructor() {
|
|
// High-risk countries (ISO 2-letter codes)
|
|
this.HIGH_RISK_COUNTRIES = new Set([
|
|
'RU', // Russia
|
|
'CN', // China
|
|
'KP', // North Korea
|
|
'IR', // Iran
|
|
'BY', // Belarus
|
|
// Add more as needed
|
|
]);
|
|
|
|
// Cache for IP lookups to avoid repeated API calls
|
|
this.ipCache = new Map();
|
|
this.rdnsCache = new Map();
|
|
|
|
// Suspicious activity tracking
|
|
this.recentFailedLogins = new Map(); // IP -> array of timestamps
|
|
this.recentSuccessfulLogins = new Map(); // IP -> array of timestamps
|
|
|
|
// Clean up old tracking data every hour
|
|
setInterval(() => this.cleanupTrackingData(), 60 * 60 * 1000);
|
|
}
|
|
|
|
/**
|
|
* Log a security event
|
|
*/
|
|
async logSecurityEvent(eventData) {
|
|
try {
|
|
const SecurityLog = require('../models/SecurityLog');
|
|
|
|
// Enhance event data with IP information
|
|
const enhancedData = await this.enhanceEventData(eventData);
|
|
|
|
// Create the security log entry
|
|
const securityLog = await SecurityLog.create(enhancedData);
|
|
|
|
// Check for suspicious patterns if this is a login event
|
|
if (eventData.event_type.includes('login')) {
|
|
await this.checkSuspiciousPatterns(enhancedData);
|
|
}
|
|
|
|
console.log(`🔐 Security event logged: ${eventData.event_type} from ${eventData.ip_address}`);
|
|
|
|
return securityLog;
|
|
} catch (error) {
|
|
console.error('❌ Failed to log security event:', error.message);
|
|
// Don't throw - security logging failures shouldn't break the main application
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log failed login attempt
|
|
*/
|
|
async logFailedLogin(req, username, reason = 'Invalid credentials') {
|
|
const eventData = {
|
|
event_type: 'login_failed',
|
|
severity: 'medium',
|
|
username: username,
|
|
ip_address: this.getClientIP(req),
|
|
client_ip: this.getRealClientIP(req),
|
|
user_agent: req.get('User-Agent'),
|
|
endpoint: req.originalUrl || req.url,
|
|
method: req.method,
|
|
status_code: 401,
|
|
message: `Failed login attempt for user '${username}': ${reason}`,
|
|
metadata: {
|
|
reason: reason,
|
|
referer: req.get('Referer'),
|
|
origin: req.get('Origin'),
|
|
timestamp: new Date().toISOString()
|
|
},
|
|
session_id: req.sessionID,
|
|
request_id: req.id || req.get('X-Request-ID')
|
|
};
|
|
|
|
// Track failed login for pattern detection
|
|
const clientIP = eventData.client_ip || eventData.ip_address;
|
|
this.trackFailedLogin(clientIP, username);
|
|
|
|
return await this.logSecurityEvent(eventData);
|
|
}
|
|
|
|
/**
|
|
* Log successful login
|
|
*/
|
|
async logSuccessfulLogin(req, user, wasAfterFailedAttempt = false) {
|
|
const severity = wasAfterFailedAttempt ? 'high' : 'low';
|
|
|
|
const eventData = {
|
|
event_type: 'login_success',
|
|
severity: severity,
|
|
user_id: user.id,
|
|
username: user.username || user.email,
|
|
ip_address: this.getClientIP(req),
|
|
client_ip: this.getRealClientIP(req),
|
|
user_agent: req.get('User-Agent'),
|
|
endpoint: req.originalUrl || req.url,
|
|
method: req.method,
|
|
status_code: 200,
|
|
message: `Successful login for user '${user.username || user.email}'${wasAfterFailedAttempt ? ' (after failed attempts)' : ''}`,
|
|
metadata: {
|
|
user_id: user.id,
|
|
after_failed_attempt: wasAfterFailedAttempt,
|
|
referer: req.get('Referer'),
|
|
origin: req.get('Origin'),
|
|
timestamp: new Date().toISOString()
|
|
},
|
|
session_id: req.sessionID,
|
|
request_id: req.id || req.get('X-Request-ID')
|
|
};
|
|
|
|
// Track successful login for pattern detection
|
|
const clientIP = eventData.client_ip || eventData.ip_address;
|
|
this.trackSuccessfulLogin(clientIP);
|
|
|
|
return await this.logSecurityEvent(eventData);
|
|
}
|
|
|
|
/**
|
|
* Enhance event data with IP geolocation and reverse DNS
|
|
*/
|
|
async enhanceEventData(eventData) {
|
|
const clientIP = eventData.client_ip || eventData.ip_address;
|
|
|
|
if (clientIP) {
|
|
// Get geolocation data
|
|
const geoData = await this.getIPGeolocation(clientIP);
|
|
if (geoData) {
|
|
eventData.country_code = geoData.country_code;
|
|
eventData.country_name = geoData.country_name;
|
|
eventData.city = geoData.city;
|
|
eventData.is_high_risk_country = this.HIGH_RISK_COUNTRIES.has(geoData.country_code);
|
|
}
|
|
|
|
// Get reverse DNS
|
|
eventData.rdns = await this.getRDNS(clientIP);
|
|
|
|
// Enhance logging for high-risk countries
|
|
if (eventData.is_high_risk_country) {
|
|
eventData.severity = this.escalateSeverity(eventData.severity);
|
|
eventData.message += ` [HIGH-RISK COUNTRY: ${eventData.country_name}]`;
|
|
}
|
|
}
|
|
|
|
return eventData;
|
|
}
|
|
|
|
/**
|
|
* Get client IP address, handling proxies
|
|
*/
|
|
getClientIP(req) {
|
|
return req.ip ||
|
|
req.connection?.remoteAddress ||
|
|
req.socket?.remoteAddress ||
|
|
req.connection?.socket?.remoteAddress ||
|
|
'127.0.0.1';
|
|
}
|
|
|
|
/**
|
|
* Get real client IP from headers (X-Forwarded-For, X-Real-IP, etc.)
|
|
*/
|
|
getRealClientIP(req) {
|
|
const xForwardedFor = req.get('X-Forwarded-For');
|
|
if (xForwardedFor) {
|
|
return xForwardedFor.split(',')[0].trim();
|
|
}
|
|
|
|
return req.get('X-Real-IP') ||
|
|
req.get('X-Client-IP') ||
|
|
req.get('CF-Connecting-IP') || // Cloudflare
|
|
this.getClientIP(req);
|
|
}
|
|
|
|
/**
|
|
* Get IP geolocation (stub - implement with actual service)
|
|
*/
|
|
async getIPGeolocation(ip) {
|
|
// Skip localhost and private IPs
|
|
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
|
|
return {
|
|
country_code: 'XX',
|
|
country_name: 'Local/Private',
|
|
city: 'Local'
|
|
};
|
|
}
|
|
|
|
// Check cache first
|
|
if (this.ipCache.has(ip)) {
|
|
return this.ipCache.get(ip);
|
|
}
|
|
|
|
try {
|
|
// TODO: Integrate with actual IP geolocation service (MaxMind, ipapi, etc.)
|
|
// For now, return a placeholder
|
|
const geoData = {
|
|
country_code: 'US',
|
|
country_name: 'United States',
|
|
city: 'Unknown'
|
|
};
|
|
|
|
// Cache for 24 hours
|
|
this.ipCache.set(ip, geoData);
|
|
setTimeout(() => this.ipCache.delete(ip), 24 * 60 * 60 * 1000);
|
|
|
|
return geoData;
|
|
} catch (error) {
|
|
console.error(`Failed to get geolocation for IP ${ip}:`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get reverse DNS for IP
|
|
*/
|
|
async getRDNS(ip) {
|
|
// Skip localhost and private IPs
|
|
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.')) {
|
|
return 'localhost';
|
|
}
|
|
|
|
// Check cache first
|
|
if (this.rdnsCache.has(ip)) {
|
|
return this.rdnsCache.get(ip);
|
|
}
|
|
|
|
try {
|
|
const hostnames = await reverseLookup(ip);
|
|
const rdns = hostnames[0] || null;
|
|
|
|
// Cache for 1 hour
|
|
this.rdnsCache.set(ip, rdns);
|
|
setTimeout(() => this.rdnsCache.delete(ip), 60 * 60 * 1000);
|
|
|
|
return rdns;
|
|
} catch (error) {
|
|
// RDNS lookup failed - not uncommon
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track failed login for pattern detection
|
|
*/
|
|
trackFailedLogin(ip, username) {
|
|
if (!this.recentFailedLogins.has(ip)) {
|
|
this.recentFailedLogins.set(ip, []);
|
|
}
|
|
|
|
this.recentFailedLogins.get(ip).push({
|
|
timestamp: Date.now(),
|
|
username: username
|
|
});
|
|
|
|
// Keep only last 24 hours
|
|
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
|
this.recentFailedLogins.set(ip,
|
|
this.recentFailedLogins.get(ip).filter(attempt => attempt.timestamp > oneDayAgo)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Track successful login for pattern detection
|
|
*/
|
|
trackSuccessfulLogin(ip) {
|
|
if (!this.recentSuccessfulLogins.has(ip)) {
|
|
this.recentSuccessfulLogins.set(ip, []);
|
|
}
|
|
|
|
this.recentSuccessfulLogins.get(ip).push(Date.now());
|
|
|
|
// Keep only last 24 hours
|
|
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
|
this.recentSuccessfulLogins.set(ip,
|
|
this.recentSuccessfulLogins.get(ip).filter(timestamp => timestamp > oneDayAgo)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check for suspicious login patterns
|
|
*/
|
|
async checkSuspiciousPatterns(eventData) {
|
|
const clientIP = eventData.client_ip || eventData.ip_address;
|
|
|
|
// Pattern 1: Successful login after multiple failed attempts
|
|
if (eventData.event_type === 'login_success') {
|
|
const failedAttempts = this.recentFailedLogins.get(clientIP) || [];
|
|
const recentFailures = failedAttempts.filter(attempt =>
|
|
attempt.timestamp > (Date.now() - 60 * 60 * 1000) // Last hour
|
|
);
|
|
|
|
if (recentFailures.length >= 3) {
|
|
await this.alertSuperAdmins('suspicious_login_pattern', {
|
|
pattern: 'successful_after_failures',
|
|
ip: clientIP,
|
|
username: eventData.username,
|
|
failed_attempts: recentFailures.length,
|
|
country: eventData.country_name,
|
|
message: `Successful login for ${eventData.username} from ${clientIP} after ${recentFailures.length} failed attempts`
|
|
});
|
|
}
|
|
}
|
|
|
|
// Pattern 2: Multiple failed logins from different IPs to different usernames (distributed attack)
|
|
await this.checkDistributedAttack();
|
|
}
|
|
|
|
/**
|
|
* Check for distributed brute force attacks
|
|
*/
|
|
async checkDistributedAttack() {
|
|
const now = Date.now();
|
|
const fiveMinutesAgo = now - (5 * 60 * 1000);
|
|
|
|
let totalFailedLogins = 0;
|
|
let uniqueIPs = new Set();
|
|
let uniqueUsernames = new Set();
|
|
|
|
for (const [ip, attempts] of this.recentFailedLogins.entries()) {
|
|
const recentAttempts = attempts.filter(attempt => attempt.timestamp > fiveMinutesAgo);
|
|
if (recentAttempts.length > 0) {
|
|
totalFailedLogins += recentAttempts.length;
|
|
uniqueIPs.add(ip);
|
|
recentAttempts.forEach(attempt => uniqueUsernames.add(attempt.username));
|
|
}
|
|
}
|
|
|
|
// Alert if we see many failed logins from many IPs to many usernames
|
|
if (totalFailedLogins >= 20 && uniqueIPs.size >= 5 && uniqueUsernames.size >= 5) {
|
|
await this.alertSuperAdmins('distributed_attack', {
|
|
pattern: 'distributed_brute_force',
|
|
total_attempts: totalFailedLogins,
|
|
unique_ips: uniqueIPs.size,
|
|
unique_usernames: uniqueUsernames.size,
|
|
message: `Distributed attack detected: ${totalFailedLogins} failed logins from ${uniqueIPs.size} IPs targeting ${uniqueUsernames.size} usernames in the last 5 minutes`
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Alert super admins about suspicious activity
|
|
*/
|
|
async alertSuperAdmins(alertType, details) {
|
|
try {
|
|
// Log the alert as a critical security event
|
|
await this.logSecurityEvent({
|
|
event_type: 'security_alert',
|
|
severity: 'critical',
|
|
message: `SECURITY ALERT: ${details.message}`,
|
|
metadata: {
|
|
alert_type: alertType,
|
|
...details
|
|
},
|
|
alerted: true
|
|
});
|
|
|
|
// TODO: Send actual notifications (email, SMS, Slack, etc.)
|
|
console.log(`🚨 SECURITY ALERT - ${alertType.toUpperCase()}:`, details.message);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to alert super admins:', error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escalate severity level for high-risk events
|
|
*/
|
|
escalateSeverity(currentSeverity) {
|
|
const severityLevels = { low: 1, medium: 2, high: 3, critical: 4 };
|
|
const reverseLevels = { 1: 'low', 2: 'medium', 3: 'high', 4: 'critical' };
|
|
|
|
const currentLevel = severityLevels[currentSeverity] || 1;
|
|
const newLevel = Math.min(currentLevel + 1, 4);
|
|
|
|
return reverseLevels[newLevel];
|
|
}
|
|
|
|
/**
|
|
* Clean up old tracking data
|
|
*/
|
|
cleanupTrackingData() {
|
|
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
|
|
|
for (const [ip, attempts] of this.recentFailedLogins.entries()) {
|
|
const filtered = attempts.filter(attempt => attempt.timestamp > oneDayAgo);
|
|
if (filtered.length === 0) {
|
|
this.recentFailedLogins.delete(ip);
|
|
} else {
|
|
this.recentFailedLogins.set(ip, filtered);
|
|
}
|
|
}
|
|
|
|
for (const [ip, timestamps] of this.recentSuccessfulLogins.entries()) {
|
|
const filtered = timestamps.filter(timestamp => timestamp > oneDayAgo);
|
|
if (filtered.length === 0) {
|
|
this.recentSuccessfulLogins.delete(ip);
|
|
} else {
|
|
this.recentSuccessfulLogins.set(ip, filtered);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if IP recently had failed login followed by successful login
|
|
*/
|
|
hadRecentFailedAttempts(ip) {
|
|
const failedAttempts = this.recentFailedLogins.get(ip) || [];
|
|
const recentFailures = failedAttempts.filter(attempt =>
|
|
attempt.timestamp > (Date.now() - 60 * 60 * 1000) // Last hour
|
|
);
|
|
|
|
return recentFailures.length > 0;
|
|
}
|
|
}
|
|
|
|
module.exports = new SecurityLogService(); |