513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
|
import * as plugins from '../plugins.js';
|
||
|
import * as paths from '../paths.js';
|
||
|
import { logger } from '../logger.js';
|
||
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||
|
import { LRUCache } from 'lru-cache';
|
||
|
|
||
|
/**
|
||
|
* Reputation check result information
|
||
|
*/
|
||
|
export interface IReputationResult {
|
||
|
score: number; // 0 (worst) to 100 (best)
|
||
|
isSpam: boolean; // true if the IP is known for spam
|
||
|
isProxy: boolean; // true if the IP is a known proxy
|
||
|
isTor: boolean; // true if the IP is a known Tor exit node
|
||
|
isVPN: boolean; // true if the IP is a known VPN
|
||
|
country?: string; // Country code (if available)
|
||
|
asn?: string; // Autonomous System Number (if available)
|
||
|
org?: string; // Organization name (if available)
|
||
|
blacklists?: string[]; // Names of blacklists that include this IP
|
||
|
timestamp: number; // When this result was created/retrieved
|
||
|
error?: string; // Error message if check failed
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Reputation threshold scores
|
||
|
*/
|
||
|
export enum ReputationThreshold {
|
||
|
HIGH_RISK = 20, // Score below this is considered high risk
|
||
|
MEDIUM_RISK = 50, // Score below this is considered medium risk
|
||
|
LOW_RISK = 80 // Score below this is considered low risk (but not trusted)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* IP type classifications
|
||
|
*/
|
||
|
export enum IPType {
|
||
|
RESIDENTIAL = 'residential',
|
||
|
DATACENTER = 'datacenter',
|
||
|
PROXY = 'proxy',
|
||
|
TOR = 'tor',
|
||
|
VPN = 'vpn',
|
||
|
UNKNOWN = 'unknown'
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Options for the IP Reputation Checker
|
||
|
*/
|
||
|
export interface IIPReputationOptions {
|
||
|
maxCacheSize?: number; // Maximum number of IPs to cache (default: 10000)
|
||
|
cacheTTL?: number; // TTL for cache entries in ms (default: 24 hours)
|
||
|
dnsblServers?: string[]; // List of DNSBL servers to check
|
||
|
highRiskThreshold?: number; // Score below this is high risk
|
||
|
mediumRiskThreshold?: number; // Score below this is medium risk
|
||
|
lowRiskThreshold?: number; // Score below this is low risk
|
||
|
enableLocalCache?: boolean; // Whether to persist cache to disk (default: true)
|
||
|
enableDNSBL?: boolean; // Whether to use DNSBL checks (default: true)
|
||
|
enableIPInfo?: boolean; // Whether to use IP info service (default: true)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Class for checking IP reputation of inbound email senders
|
||
|
*/
|
||
|
export class IPReputationChecker {
|
||
|
private static instance: IPReputationChecker;
|
||
|
private reputationCache: LRUCache<string, IReputationResult>;
|
||
|
private options: Required<IIPReputationOptions>;
|
||
|
|
||
|
// Default DNSBL servers
|
||
|
private static readonly DEFAULT_DNSBL_SERVERS = [
|
||
|
'zen.spamhaus.org', // Spamhaus
|
||
|
'bl.spamcop.net', // SpamCop
|
||
|
'b.barracudacentral.org', // Barracuda
|
||
|
'spam.dnsbl.sorbs.net', // SORBS
|
||
|
'dnsbl.sorbs.net', // SORBS (expanded)
|
||
|
'cbl.abuseat.org', // Composite Blocking List
|
||
|
'xbl.spamhaus.org', // Spamhaus XBL
|
||
|
'pbl.spamhaus.org', // Spamhaus PBL
|
||
|
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
||
|
'psbl.surriel.com' // PSBL
|
||
|
];
|
||
|
|
||
|
// Default options
|
||
|
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||
|
maxCacheSize: 10000,
|
||
|
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||
|
dnsblServers: IPReputationChecker.DEFAULT_DNSBL_SERVERS,
|
||
|
highRiskThreshold: ReputationThreshold.HIGH_RISK,
|
||
|
mediumRiskThreshold: ReputationThreshold.MEDIUM_RISK,
|
||
|
lowRiskThreshold: ReputationThreshold.LOW_RISK,
|
||
|
enableLocalCache: true,
|
||
|
enableDNSBL: true,
|
||
|
enableIPInfo: true
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Constructor for IPReputationChecker
|
||
|
* @param options Configuration options
|
||
|
*/
|
||
|
constructor(options: IIPReputationOptions = {}) {
|
||
|
// Merge with default options
|
||
|
this.options = {
|
||
|
...IPReputationChecker.DEFAULT_OPTIONS,
|
||
|
...options
|
||
|
};
|
||
|
|
||
|
// Initialize reputation cache
|
||
|
this.reputationCache = new LRUCache<string, IReputationResult>({
|
||
|
max: this.options.maxCacheSize,
|
||
|
ttl: this.options.cacheTTL, // Cache TTL
|
||
|
});
|
||
|
|
||
|
// Load cache from disk if enabled
|
||
|
if (this.options.enableLocalCache) {
|
||
|
this.loadCache();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the singleton instance of the checker
|
||
|
* @param options Configuration options
|
||
|
* @returns Singleton instance
|
||
|
*/
|
||
|
public static getInstance(options: IIPReputationOptions = {}): IPReputationChecker {
|
||
|
if (!IPReputationChecker.instance) {
|
||
|
IPReputationChecker.instance = new IPReputationChecker(options);
|
||
|
}
|
||
|
return IPReputationChecker.instance;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check an IP address's reputation
|
||
|
* @param ip IP address to check
|
||
|
* @returns Reputation check result
|
||
|
*/
|
||
|
public async checkReputation(ip: string): Promise<IReputationResult> {
|
||
|
try {
|
||
|
// Validate IP address format
|
||
|
if (!this.isValidIPAddress(ip)) {
|
||
|
logger.log('warn', `Invalid IP address format: ${ip}`);
|
||
|
return this.createErrorResult(ip, 'Invalid IP address format');
|
||
|
}
|
||
|
|
||
|
// Check cache first
|
||
|
const cachedResult = this.reputationCache.get(ip);
|
||
|
if (cachedResult) {
|
||
|
logger.log('info', `Using cached reputation data for IP ${ip}`, {
|
||
|
score: cachedResult.score,
|
||
|
isSpam: cachedResult.isSpam
|
||
|
});
|
||
|
return cachedResult;
|
||
|
}
|
||
|
|
||
|
// Initialize empty result
|
||
|
const result: IReputationResult = {
|
||
|
score: 100, // Start with perfect score
|
||
|
isSpam: false,
|
||
|
isProxy: false,
|
||
|
isTor: false,
|
||
|
isVPN: false,
|
||
|
timestamp: Date.now()
|
||
|
};
|
||
|
|
||
|
// Check IP against DNS blacklists if enabled
|
||
|
if (this.options.enableDNSBL) {
|
||
|
const dnsblResult = await this.checkDNSBL(ip);
|
||
|
|
||
|
// Update result with DNSBL information
|
||
|
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
||
|
result.isSpam = dnsblResult.listCount > 0;
|
||
|
result.blacklists = dnsblResult.lists;
|
||
|
}
|
||
|
|
||
|
// Get additional IP information if enabled
|
||
|
if (this.options.enableIPInfo) {
|
||
|
const ipInfo = await this.getIPInfo(ip);
|
||
|
|
||
|
// Update result with IP info
|
||
|
result.country = ipInfo.country;
|
||
|
result.asn = ipInfo.asn;
|
||
|
result.org = ipInfo.org;
|
||
|
|
||
|
// Adjust score based on IP type
|
||
|
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
||
|
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
||
|
|
||
|
// Set proxy flags
|
||
|
result.isProxy = ipInfo.type === IPType.PROXY;
|
||
|
result.isTor = ipInfo.type === IPType.TOR;
|
||
|
result.isVPN = ipInfo.type === IPType.VPN;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Ensure score is between 0 and 100
|
||
|
result.score = Math.max(0, Math.min(100, result.score));
|
||
|
|
||
|
// Update cache with result
|
||
|
this.reputationCache.set(ip, result);
|
||
|
|
||
|
// Save cache if enabled
|
||
|
if (this.options.enableLocalCache) {
|
||
|
this.saveCache();
|
||
|
}
|
||
|
|
||
|
// Log the reputation check
|
||
|
this.logReputationCheck(ip, result);
|
||
|
|
||
|
return result;
|
||
|
} catch (error) {
|
||
|
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
||
|
ip,
|
||
|
stack: error.stack
|
||
|
});
|
||
|
|
||
|
return this.createErrorResult(ip, error.message);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Check an IP against DNS blacklists
|
||
|
* @param ip IP address to check
|
||
|
* @returns DNSBL check results
|
||
|
*/
|
||
|
private async checkDNSBL(ip: string): Promise<{
|
||
|
listCount: number;
|
||
|
lists: string[];
|
||
|
}> {
|
||
|
try {
|
||
|
// Reverse the IP for DNSBL queries
|
||
|
const reversedIP = this.reverseIP(ip);
|
||
|
|
||
|
const results = await Promise.allSettled(
|
||
|
this.options.dnsblServers.map(async (server) => {
|
||
|
try {
|
||
|
const lookupDomain = `${reversedIP}.${server}`;
|
||
|
await plugins.dns.promises.resolve(lookupDomain);
|
||
|
return server; // IP is listed in this DNSBL
|
||
|
} catch (error) {
|
||
|
if (error.code === 'ENOTFOUND') {
|
||
|
return null; // IP is not listed in this DNSBL
|
||
|
}
|
||
|
throw error; // Other error
|
||
|
}
|
||
|
})
|
||
|
);
|
||
|
|
||
|
// Extract successful lookups (listed in DNSBL)
|
||
|
const lists = results
|
||
|
.filter((result): result is PromiseFulfilledResult<string> =>
|
||
|
result.status === 'fulfilled' && result.value !== null
|
||
|
)
|
||
|
.map(result => result.value);
|
||
|
|
||
|
return {
|
||
|
listCount: lists.length,
|
||
|
lists
|
||
|
};
|
||
|
} catch (error) {
|
||
|
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
||
|
return {
|
||
|
listCount: 0,
|
||
|
lists: []
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get information about an IP address
|
||
|
* @param ip IP address to check
|
||
|
* @returns IP information
|
||
|
*/
|
||
|
private async getIPInfo(ip: string): Promise<{
|
||
|
country?: string;
|
||
|
asn?: string;
|
||
|
org?: string;
|
||
|
type: IPType;
|
||
|
}> {
|
||
|
try {
|
||
|
// In a real implementation, this would use an IP data service API
|
||
|
// For this implementation, we'll use a simplified approach
|
||
|
|
||
|
// Check if it's a known Tor exit node (simplified)
|
||
|
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
||
|
|
||
|
// Check if it's a known VPN (simplified)
|
||
|
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
||
|
|
||
|
// Check if it's a known proxy (simplified)
|
||
|
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
||
|
|
||
|
// Determine IP type
|
||
|
let type = IPType.UNKNOWN;
|
||
|
if (isTor) {
|
||
|
type = IPType.TOR;
|
||
|
} else if (isVPN) {
|
||
|
type = IPType.VPN;
|
||
|
} else if (isProxy) {
|
||
|
type = IPType.PROXY;
|
||
|
} else {
|
||
|
// Simple datacenters detection (major cloud providers)
|
||
|
if (
|
||
|
ip.startsWith('13.') || // AWS
|
||
|
ip.startsWith('35.') || // Google Cloud
|
||
|
ip.startsWith('52.') || // AWS
|
||
|
ip.startsWith('34.') || // Google Cloud
|
||
|
ip.startsWith('104.') // Various providers
|
||
|
) {
|
||
|
type = IPType.DATACENTER;
|
||
|
} else {
|
||
|
type = IPType.RESIDENTIAL;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Return the information
|
||
|
return {
|
||
|
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
||
|
asn: 'AS12345', // Simplified, would look up real ASN
|
||
|
org: this.determineOrg(ip), // Simplified, would use real org data
|
||
|
type
|
||
|
};
|
||
|
} catch (error) {
|
||
|
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
||
|
return {
|
||
|
type: IPType.UNKNOWN
|
||
|
};
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Simplified method to determine country from IP
|
||
|
* In a real implementation, this would use a geolocation database or service
|
||
|
* @param ip IP address
|
||
|
* @returns Country code
|
||
|
*/
|
||
|
private determineCountry(ip: string): string {
|
||
|
// Simplified mapping for demo purposes
|
||
|
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'US';
|
||
|
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'US';
|
||
|
if (ip.startsWith('185.')) return 'NL';
|
||
|
if (ip.startsWith('171.')) return 'DE';
|
||
|
return 'XX'; // Unknown
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Simplified method to determine organization from IP
|
||
|
* In a real implementation, this would use an IP-to-org database or service
|
||
|
* @param ip IP address
|
||
|
* @returns Organization name
|
||
|
*/
|
||
|
private determineOrg(ip: string): string {
|
||
|
// Simplified mapping for demo purposes
|
||
|
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'Amazon AWS';
|
||
|
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'Google Cloud';
|
||
|
if (ip.startsWith('185.156.')) return 'NordVPN';
|
||
|
if (ip.startsWith('37.120.')) return 'ExpressVPN';
|
||
|
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
||
|
return 'Unknown';
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
||
|
* @param ip IP address to reverse
|
||
|
* @returns Reversed IP for DNSBL queries
|
||
|
*/
|
||
|
private reverseIP(ip: string): string {
|
||
|
return ip.split('.').reverse().join('.');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create an error result for when reputation check fails
|
||
|
* @param ip IP address
|
||
|
* @param errorMessage Error message
|
||
|
* @returns Error result
|
||
|
*/
|
||
|
private createErrorResult(ip: string, errorMessage: string): IReputationResult {
|
||
|
return {
|
||
|
score: 50, // Neutral score for errors
|
||
|
isSpam: false,
|
||
|
isProxy: false,
|
||
|
isTor: false,
|
||
|
isVPN: false,
|
||
|
timestamp: Date.now(),
|
||
|
error: errorMessage
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Validate IP address format
|
||
|
* @param ip IP address to validate
|
||
|
* @returns Whether the IP is valid
|
||
|
*/
|
||
|
private isValidIPAddress(ip: string): boolean {
|
||
|
// IPv4 regex pattern
|
||
|
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||
|
return ipv4Pattern.test(ip);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Log reputation check to security logger
|
||
|
* @param ip IP address
|
||
|
* @param result Reputation result
|
||
|
*/
|
||
|
private logReputationCheck(ip: string, result: IReputationResult): void {
|
||
|
// Determine log level based on reputation score
|
||
|
let logLevel = SecurityLogLevel.INFO;
|
||
|
if (result.score < this.options.highRiskThreshold) {
|
||
|
logLevel = SecurityLogLevel.WARN;
|
||
|
} else if (result.score < this.options.mediumRiskThreshold) {
|
||
|
logLevel = SecurityLogLevel.INFO;
|
||
|
}
|
||
|
|
||
|
// Log the check
|
||
|
SecurityLogger.getInstance().logEvent({
|
||
|
level: logLevel,
|
||
|
type: SecurityEventType.IP_REPUTATION,
|
||
|
message: `IP reputation check ${result.isSpam ? 'flagged spam' : 'completed'} for ${ip}`,
|
||
|
ipAddress: ip,
|
||
|
details: {
|
||
|
score: result.score,
|
||
|
isSpam: result.isSpam,
|
||
|
isProxy: result.isProxy,
|
||
|
isTor: result.isTor,
|
||
|
isVPN: result.isVPN,
|
||
|
country: result.country,
|
||
|
blacklists: result.blacklists
|
||
|
},
|
||
|
success: !result.isSpam
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Save cache to disk
|
||
|
*/
|
||
|
private saveCache(): void {
|
||
|
try {
|
||
|
// Convert cache entries to serializable array
|
||
|
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
||
|
ip,
|
||
|
data
|
||
|
}));
|
||
|
|
||
|
// Only save if we have entries
|
||
|
if (entries.length === 0) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Ensure directory exists
|
||
|
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
||
|
plugins.smartfile.fs.ensureDirSync(cacheDir);
|
||
|
|
||
|
// Save to file
|
||
|
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
|
||
|
plugins.smartfile.memory.toFsSync(
|
||
|
JSON.stringify(entries),
|
||
|
cacheFile
|
||
|
);
|
||
|
|
||
|
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
||
|
} catch (error) {
|
||
|
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Load cache from disk
|
||
|
*/
|
||
|
private loadCache(): void {
|
||
|
try {
|
||
|
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
||
|
|
||
|
// Check if file exists
|
||
|
if (!plugins.fs.existsSync(cacheFile)) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Read and parse cache
|
||
|
const cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
||
|
const entries = JSON.parse(cacheData);
|
||
|
|
||
|
// Validate and filter entries
|
||
|
const now = Date.now();
|
||
|
const validEntries = entries.filter(entry => {
|
||
|
const age = now - entry.data.timestamp;
|
||
|
return age < this.options.cacheTTL; // Only load entries that haven't expired
|
||
|
});
|
||
|
|
||
|
// Restore cache
|
||
|
for (const entry of validEntries) {
|
||
|
this.reputationCache.set(entry.ip, entry.data);
|
||
|
}
|
||
|
|
||
|
logger.log('info', `Loaded ${validEntries.length} IP reputation cache entries from disk`);
|
||
|
} catch (error) {
|
||
|
logger.log('error', `Failed to load IP reputation cache: ${error.message}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the risk level for a reputation score
|
||
|
* @param score Reputation score (0-100)
|
||
|
* @returns Risk level description
|
||
|
*/
|
||
|
public static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted' {
|
||
|
if (score < ReputationThreshold.HIGH_RISK) {
|
||
|
return 'high';
|
||
|
} else if (score < ReputationThreshold.MEDIUM_RISK) {
|
||
|
return 'medium';
|
||
|
} else if (score < ReputationThreshold.LOW_RISK) {
|
||
|
return 'low';
|
||
|
} else {
|
||
|
return 'trusted';
|
||
|
}
|
||
|
}
|
||
|
}
|