update
This commit is contained in:
739
ts/security/classes.contentscanner.ts
Normal file
739
ts/security/classes.contentscanner.ts
Normal file
@ -0,0 +1,739 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as paths from '../paths.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { Email } from '../mta/classes.email.js';
|
||||
import type { IAttachment } from '../mta/classes.email.js';
|
||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
/**
|
||||
* Scan result information
|
||||
*/
|
||||
export interface IScanResult {
|
||||
isClean: boolean; // Whether the content is clean (no threats detected)
|
||||
threatType?: string; // Type of threat if detected
|
||||
threatDetails?: string; // Details about the detected threat
|
||||
threatScore: number; // 0 (clean) to 100 (definitely malicious)
|
||||
scannedElements: string[]; // What was scanned (subject, body, attachments, etc.)
|
||||
timestamp: number; // When this scan was performed
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for content scanner configuration
|
||||
*/
|
||||
export interface IContentScannerOptions {
|
||||
maxCacheSize?: number; // Maximum number of entries to cache
|
||||
cacheTTL?: number; // TTL for cache entries in ms
|
||||
scanSubject?: boolean; // Whether to scan email subjects
|
||||
scanBody?: boolean; // Whether to scan email bodies
|
||||
scanAttachments?: boolean; // Whether to scan attachments
|
||||
maxAttachmentSizeToScan?: number; // Max size of attachments to scan in bytes
|
||||
scanAttachmentNames?: boolean; // Whether to scan attachment filenames
|
||||
blockExecutables?: boolean; // Whether to block executable attachments
|
||||
blockMacros?: boolean; // Whether to block documents with macros
|
||||
customRules?: Array<{ // Custom scanning rules
|
||||
pattern: string | RegExp; // Pattern to match
|
||||
type: string; // Type of threat
|
||||
score: number; // Threat score
|
||||
description: string; // Description of the threat
|
||||
}>;
|
||||
minThreatScore?: number; // Minimum score to consider content as a threat
|
||||
highThreatScore?: number; // Score above which content is considered high threat
|
||||
}
|
||||
|
||||
/**
|
||||
* Threat categories
|
||||
*/
|
||||
export enum ThreatCategory {
|
||||
SPAM = 'spam',
|
||||
PHISHING = 'phishing',
|
||||
MALWARE = 'malware',
|
||||
EXECUTABLE = 'executable',
|
||||
SUSPICIOUS_LINK = 'suspicious_link',
|
||||
MALICIOUS_MACRO = 'malicious_macro',
|
||||
XSS = 'xss',
|
||||
SENSITIVE_DATA = 'sensitive_data',
|
||||
BLACKLISTED_CONTENT = 'blacklisted_content',
|
||||
CUSTOM_RULE = 'custom_rule'
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Scanner for detecting malicious email content
|
||||
*/
|
||||
export class ContentScanner {
|
||||
private static instance: ContentScanner;
|
||||
private scanCache: LRUCache<string, IScanResult>;
|
||||
private options: Required<IContentScannerOptions>;
|
||||
|
||||
// Predefined patterns for common threats
|
||||
private static readonly MALICIOUS_PATTERNS = {
|
||||
// Phishing patterns
|
||||
phishing: [
|
||||
/(?:verify|confirm|update|login).*(?:account|password|details)/i,
|
||||
/urgent.*(?:action|attention|required)/i,
|
||||
/(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)/i,
|
||||
/your.*(?:account).*(?:suspended|compromised|locked)/i,
|
||||
/\b(?:password reset|security alert|security notice)\b/i
|
||||
],
|
||||
|
||||
// Spam indicators
|
||||
spam: [
|
||||
/\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b/i,
|
||||
/\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b/i,
|
||||
/\b(?:earn from home|make money fast|earn \$\d{3,}\/day)\b/i,
|
||||
/\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b/i,
|
||||
/\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b/i
|
||||
],
|
||||
|
||||
// Malware indicators in text
|
||||
malware: [
|
||||
/(?:attached file|see attachment).*(?:invoice|receipt|statement|document)/i,
|
||||
/open.*(?:the attached|this attachment)/i,
|
||||
/(?:enable|allow).*(?:macros|content|editing)/i,
|
||||
/download.*(?:attachment|file|document)/i,
|
||||
/\b(?:ransomware protection|virus alert|malware detected)\b/i
|
||||
],
|
||||
|
||||
// Suspicious links
|
||||
suspiciousLinks: [
|
||||
/https?:\/\/bit\.ly\//i,
|
||||
/https?:\/\/goo\.gl\//i,
|
||||
/https?:\/\/t\.co\//i,
|
||||
/https?:\/\/tinyurl\.com\//i,
|
||||
/https?:\/\/(?:\d{1,3}\.){3}\d{1,3}/i, // IP address URLs
|
||||
/https?:\/\/.*\.(?:xyz|top|club|gq|cf)\//i, // Suspicious TLDs
|
||||
/(?:login|account|signin|auth).*\.(?!gov|edu|com|org|net)\w+\.\w+/i, // Login pages on unusual domains
|
||||
],
|
||||
|
||||
// XSS and script injection
|
||||
scriptInjection: [
|
||||
/<script.*>.*<\/script>/is,
|
||||
/javascript:/i,
|
||||
/on(?:click|load|mouse|error|focus|blur)=".*"/i,
|
||||
/document\.(?:cookie|write|location)/i,
|
||||
/eval\s*\(/i
|
||||
],
|
||||
|
||||
// Sensitive data patterns
|
||||
sensitiveData: [
|
||||
/\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b/, // SSN
|
||||
/\b\d{13,16}\b/, // Credit card numbers
|
||||
/\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b/ // Possible Base64
|
||||
]
|
||||
};
|
||||
|
||||
// Common executable extensions
|
||||
private static readonly EXECUTABLE_EXTENSIONS = [
|
||||
'.exe', '.dll', '.bat', '.cmd', '.msi', '.js', '.vbs', '.ps1',
|
||||
'.sh', '.jar', '.py', '.com', '.scr', '.pif', '.hta', '.cpl',
|
||||
'.reg', '.vba', '.lnk', '.wsf', '.msi', '.msp', '.mst'
|
||||
];
|
||||
|
||||
// Document formats that may contain macros
|
||||
private static readonly MACRO_DOCUMENT_EXTENSIONS = [
|
||||
'.doc', '.docm', '.xls', '.xlsm', '.ppt', '.pptm', '.dotm', '.xlsb', '.ppam', '.potm'
|
||||
];
|
||||
|
||||
/**
|
||||
* Default options for the content scanner
|
||||
*/
|
||||
private static readonly DEFAULT_OPTIONS: Required<IContentScannerOptions> = {
|
||||
maxCacheSize: 10000,
|
||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
scanSubject: true,
|
||||
scanBody: true,
|
||||
scanAttachments: true,
|
||||
maxAttachmentSizeToScan: 10 * 1024 * 1024, // 10MB
|
||||
scanAttachmentNames: true,
|
||||
blockExecutables: true,
|
||||
blockMacros: true,
|
||||
customRules: [],
|
||||
minThreatScore: 30, // Minimum score to consider content as a threat
|
||||
highThreatScore: 70 // Score above which content is considered high threat
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor for the ContentScanner
|
||||
* @param options Configuration options
|
||||
*/
|
||||
constructor(options: IContentScannerOptions = {}) {
|
||||
// Merge with default options
|
||||
this.options = {
|
||||
...ContentScanner.DEFAULT_OPTIONS,
|
||||
...options
|
||||
};
|
||||
|
||||
// Initialize cache
|
||||
this.scanCache = new LRUCache<string, IScanResult>({
|
||||
max: this.options.maxCacheSize,
|
||||
ttl: this.options.cacheTTL,
|
||||
});
|
||||
|
||||
logger.log('info', 'ContentScanner initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the scanner
|
||||
* @param options Configuration options
|
||||
* @returns Singleton scanner instance
|
||||
*/
|
||||
public static getInstance(options: IContentScannerOptions = {}): ContentScanner {
|
||||
if (!ContentScanner.instance) {
|
||||
ContentScanner.instance = new ContentScanner(options);
|
||||
}
|
||||
return ContentScanner.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an email for malicious content
|
||||
* @param email The email to scan
|
||||
* @returns Scan result
|
||||
*/
|
||||
public async scanEmail(email: Email): Promise<IScanResult> {
|
||||
try {
|
||||
// Generate a cache key from the email
|
||||
const cacheKey = this.generateCacheKey(email);
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = this.scanCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
logger.log('info', `Using cached scan result for email ${email.getMessageId()}`);
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
// Initialize scan result
|
||||
const result: IScanResult = {
|
||||
isClean: true,
|
||||
threatScore: 0,
|
||||
scannedElements: [],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// List of scan promises
|
||||
const scanPromises: Array<Promise<void>> = [];
|
||||
|
||||
// Scan subject
|
||||
if (this.options.scanSubject && email.subject) {
|
||||
scanPromises.push(this.scanSubject(email.subject, result));
|
||||
}
|
||||
|
||||
// Scan body content
|
||||
if (this.options.scanBody) {
|
||||
if (email.text) {
|
||||
scanPromises.push(this.scanTextContent(email.text, result));
|
||||
}
|
||||
|
||||
if (email.html) {
|
||||
scanPromises.push(this.scanHtmlContent(email.html, result));
|
||||
}
|
||||
}
|
||||
|
||||
// Scan attachments
|
||||
if (this.options.scanAttachments && email.attachments && email.attachments.length > 0) {
|
||||
for (const attachment of email.attachments) {
|
||||
scanPromises.push(this.scanAttachment(attachment, result));
|
||||
}
|
||||
}
|
||||
|
||||
// Run all scans in parallel
|
||||
await Promise.all(scanPromises);
|
||||
|
||||
// Determine if the email is clean based on threat score
|
||||
result.isClean = result.threatScore < this.options.minThreatScore;
|
||||
|
||||
// Save to cache
|
||||
this.scanCache.set(cacheKey, result);
|
||||
|
||||
// Log high threat findings
|
||||
if (result.threatScore >= this.options.highThreatScore) {
|
||||
this.logHighThreatFound(email, result);
|
||||
} else if (!result.isClean) {
|
||||
this.logThreatFound(email, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log('error', `Error scanning email: ${error.message}`, {
|
||||
messageId: email.getMessageId(),
|
||||
error: error.stack
|
||||
});
|
||||
|
||||
// Return a safe default with error indication
|
||||
return {
|
||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
||||
threatScore: 0,
|
||||
scannedElements: ['error'],
|
||||
timestamp: Date.now(),
|
||||
threatType: 'scan_error',
|
||||
threatDetails: `Scan error: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from an email
|
||||
* @param email The email to generate a key for
|
||||
* @returns Cache key
|
||||
*/
|
||||
private generateCacheKey(email: Email): string {
|
||||
// Use message ID if available
|
||||
if (email.getMessageId()) {
|
||||
return `email:${email.getMessageId()}`;
|
||||
}
|
||||
|
||||
// Fallback to a hash of key content
|
||||
const contentToHash = [
|
||||
email.from,
|
||||
email.subject || '',
|
||||
email.text?.substring(0, 1000) || '',
|
||||
email.html?.substring(0, 1000) || '',
|
||||
email.attachments?.length || 0
|
||||
].join(':');
|
||||
|
||||
return `email:${plugins.crypto.createHash('sha256').update(contentToHash).digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan email subject for threats
|
||||
* @param subject The subject to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanSubject(subject: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('subject');
|
||||
|
||||
// Check against phishing patterns
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.phishing) {
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += 25;
|
||||
result.threatType = ThreatCategory.PHISHING;
|
||||
result.threatDetails = `Subject contains potential phishing indicators: ${subject}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check against spam patterns
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.spam) {
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += 15;
|
||||
result.threatType = ThreatCategory.SPAM;
|
||||
result.threatDetails = `Subject contains potential spam indicators: ${subject}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom rules
|
||||
for (const rule of this.options.customRules) {
|
||||
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(subject)) {
|
||||
result.threatScore += rule.score;
|
||||
result.threatType = rule.type;
|
||||
result.threatDetails = rule.description;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan plain text content for threats
|
||||
* @param text The text content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanTextContent(text: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('text');
|
||||
|
||||
// Check suspicious links
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.suspiciousLinks) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 20;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SUSPICIOUS_LINK ? 0 : 20)) {
|
||||
result.threatType = ThreatCategory.SUSPICIOUS_LINK;
|
||||
result.threatDetails = `Text contains suspicious links`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check phishing
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.phishing) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 25;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.PHISHING ? 0 : 25)) {
|
||||
result.threatType = ThreatCategory.PHISHING;
|
||||
result.threatDetails = `Text contains potential phishing indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check spam
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.spam) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 15;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SPAM ? 0 : 15)) {
|
||||
result.threatType = ThreatCategory.SPAM;
|
||||
result.threatDetails = `Text contains potential spam indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check malware indicators
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.malware) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 30;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.MALWARE ? 0 : 30)) {
|
||||
result.threatType = ThreatCategory.MALWARE;
|
||||
result.threatDetails = `Text contains potential malware indicators`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check sensitive data
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.sensitiveData) {
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += 25;
|
||||
if (!result.threatType || result.threatScore > (result.threatType === ThreatCategory.SENSITIVE_DATA ? 0 : 25)) {
|
||||
result.threatType = ThreatCategory.SENSITIVE_DATA;
|
||||
result.threatDetails = `Text contains potentially sensitive data patterns`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom rules
|
||||
for (const rule of this.options.customRules) {
|
||||
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||
if (pattern.test(text)) {
|
||||
result.threatScore += rule.score;
|
||||
if (!result.threatType || result.threatScore > 20) {
|
||||
result.threatType = rule.type;
|
||||
result.threatDetails = rule.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan HTML content for threats
|
||||
* @param html The HTML content to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanHtmlContent(html: string, result: IScanResult): Promise<void> {
|
||||
result.scannedElements.push('html');
|
||||
|
||||
// Check for script injection
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.scriptInjection) {
|
||||
if (pattern.test(html)) {
|
||||
result.threatScore += 40;
|
||||
if (!result.threatType || result.threatType !== ThreatCategory.XSS) {
|
||||
result.threatType = ThreatCategory.XSS;
|
||||
result.threatDetails = `HTML contains potentially malicious script content`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text content from HTML for further scanning
|
||||
const textContent = this.extractTextFromHtml(html);
|
||||
if (textContent) {
|
||||
// We'll leverage the text scanning but not double-count threat score
|
||||
const tempResult: IScanResult = {
|
||||
isClean: true,
|
||||
threatScore: 0,
|
||||
scannedElements: [],
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await this.scanTextContent(textContent, tempResult);
|
||||
|
||||
// Only add additional threat types if they're more severe
|
||||
if (tempResult.threatType && tempResult.threatScore > 0) {
|
||||
// Add half of the text content score to avoid double counting
|
||||
result.threatScore += Math.floor(tempResult.threatScore / 2);
|
||||
|
||||
// Adopt the threat type if more severe or no existing type
|
||||
if (!result.threatType || tempResult.threatScore > result.threatScore) {
|
||||
result.threatType = tempResult.threatType;
|
||||
result.threatDetails = tempResult.threatDetails;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and check links from HTML
|
||||
const links = this.extractLinksFromHtml(html);
|
||||
if (links.length > 0) {
|
||||
// Check for suspicious links
|
||||
let suspiciousLinks = 0;
|
||||
for (const link of links) {
|
||||
for (const pattern of ContentScanner.MALICIOUS_PATTERNS.suspiciousLinks) {
|
||||
if (pattern.test(link)) {
|
||||
suspiciousLinks++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suspiciousLinks > 0) {
|
||||
// Add score based on percentage of suspicious links
|
||||
const suspiciousPercentage = (suspiciousLinks / links.length) * 100;
|
||||
const additionalScore = Math.min(40, Math.floor(suspiciousPercentage / 2.5));
|
||||
result.threatScore += additionalScore;
|
||||
|
||||
if (!result.threatType || additionalScore > 20) {
|
||||
result.threatType = ThreatCategory.SUSPICIOUS_LINK;
|
||||
result.threatDetails = `HTML contains ${suspiciousLinks} suspicious links out of ${links.length} total links`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan an attachment for threats
|
||||
* @param attachment The attachment to scan
|
||||
* @param result The scan result to update
|
||||
*/
|
||||
private async scanAttachment(attachment: IAttachment, result: IScanResult): Promise<void> {
|
||||
const filename = attachment.filename.toLowerCase();
|
||||
result.scannedElements.push(`attachment:${filename}`);
|
||||
|
||||
// Skip large attachments if configured
|
||||
if (attachment.content && attachment.content.length > this.options.maxAttachmentSizeToScan) {
|
||||
logger.log('info', `Skipping scan of large attachment: ${filename} (${attachment.content.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check filename for executable extensions
|
||||
if (this.options.blockExecutables) {
|
||||
for (const ext of ContentScanner.EXECUTABLE_EXTENSIONS) {
|
||||
if (filename.endsWith(ext)) {
|
||||
result.threatScore += 70; // High score for executable attachments
|
||||
result.threatType = ThreatCategory.EXECUTABLE;
|
||||
result.threatDetails = `Attachment has a potentially dangerous extension: ${filename}`;
|
||||
return; // No need to scan contents if filename already flagged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Office documents with macros
|
||||
if (this.options.blockMacros) {
|
||||
for (const ext of ContentScanner.MACRO_DOCUMENT_EXTENSIONS) {
|
||||
if (filename.endsWith(ext)) {
|
||||
// For Office documents, check if they contain macros
|
||||
// This is a simplified check - a real implementation would use specialized libraries
|
||||
// to detect macros in Office documents
|
||||
if (attachment.content && this.likelyContainsMacros(attachment)) {
|
||||
result.threatScore += 60;
|
||||
result.threatType = ThreatCategory.MALICIOUS_MACRO;
|
||||
result.threatDetails = `Attachment appears to contain macros: ${filename}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform basic content analysis if we have content buffer
|
||||
if (attachment.content) {
|
||||
// Convert to string for scanning, with a limit to prevent memory issues
|
||||
const textContent = this.extractTextFromBuffer(attachment.content);
|
||||
|
||||
if (textContent) {
|
||||
// Scan for malicious patterns in attachment content
|
||||
for (const category in ContentScanner.MALICIOUS_PATTERNS) {
|
||||
const patterns = ContentScanner.MALICIOUS_PATTERNS[category];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(textContent)) {
|
||||
result.threatScore += 30;
|
||||
|
||||
if (!result.threatType) {
|
||||
result.threatType = this.mapCategoryToThreatType(category);
|
||||
result.threatDetails = `Attachment content contains suspicious patterns: ${filename}`;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for PE headers (Windows executables)
|
||||
if (attachment.content.length > 64 &&
|
||||
attachment.content[0] === 0x4D &&
|
||||
attachment.content[1] === 0x5A) { // 'MZ' header
|
||||
result.threatScore += 80;
|
||||
result.threatType = ThreatCategory.EXECUTABLE;
|
||||
result.threatDetails = `Attachment contains executable code: ${filename}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract links from HTML content
|
||||
* @param html HTML content
|
||||
* @returns Array of extracted links
|
||||
*/
|
||||
private extractLinksFromHtml(html: string): string[] {
|
||||
const links: string[] = [];
|
||||
|
||||
// Simple regex-based extraction - a real implementation might use a proper HTML parser
|
||||
const matches = html.match(/href=["'](https?:\/\/[^"']+)["']/gi);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
const linkMatch = match.match(/href=["'](https?:\/\/[^"']+)["']/i);
|
||||
if (linkMatch && linkMatch[1]) {
|
||||
links.push(linkMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from HTML
|
||||
* @param html HTML content
|
||||
* @returns Extracted text
|
||||
*/
|
||||
private extractTextFromHtml(html: string): string {
|
||||
// Remove HTML tags and decode entities - simplified version
|
||||
return html
|
||||
.replace(/<style[^>]*>.*?<\/style>/gs, '')
|
||||
.replace(/<script[^>]*>.*?<\/script>/gs, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a binary buffer for scanning
|
||||
* @param buffer Binary content
|
||||
* @returns Extracted text (may be partial)
|
||||
*/
|
||||
private extractTextFromBuffer(buffer: Buffer): string {
|
||||
try {
|
||||
// Limit the amount we convert to avoid memory issues
|
||||
const sampleSize = Math.min(buffer.length, 100 * 1024); // 100KB max sample
|
||||
const sample = buffer.slice(0, sampleSize);
|
||||
|
||||
// Try to convert to string, filtering out non-printable chars
|
||||
return sample.toString('utf8')
|
||||
.replace(/[\x00-\x09\x0B-\x1F\x7F-\x9F]/g, '') // Remove control chars
|
||||
.replace(/\uFFFD/g, ''); // Remove replacement char
|
||||
} catch (error) {
|
||||
logger.log('warn', `Error extracting text from buffer: ${error.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an Office document likely contains macros
|
||||
* This is a simplified check - real implementation would use specialized libraries
|
||||
* @param attachment The attachment to check
|
||||
* @returns Whether the file likely contains macros
|
||||
*/
|
||||
private likelyContainsMacros(attachment: IAttachment): boolean {
|
||||
// Simple heuristic: look for VBA/macro related strings
|
||||
// This is a simplified approach and not comprehensive
|
||||
const content = this.extractTextFromBuffer(attachment.content);
|
||||
const macroIndicators = [
|
||||
/vbaProject\.bin/i,
|
||||
/Microsoft VBA/i,
|
||||
/\bVBA\b/,
|
||||
/Auto_Open/i,
|
||||
/AutoExec/i,
|
||||
/DocumentOpen/i,
|
||||
/AutoOpen/i,
|
||||
/\bExecute\(/i,
|
||||
/\bShell\(/i,
|
||||
/\bCreateObject\(/i
|
||||
];
|
||||
|
||||
for (const indicator of macroIndicators) {
|
||||
if (indicator.test(content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a pattern category to a threat type
|
||||
* @param category The pattern category
|
||||
* @returns The corresponding threat type
|
||||
*/
|
||||
private mapCategoryToThreatType(category: string): string {
|
||||
switch (category) {
|
||||
case 'phishing': return ThreatCategory.PHISHING;
|
||||
case 'spam': return ThreatCategory.SPAM;
|
||||
case 'malware': return ThreatCategory.MALWARE;
|
||||
case 'suspiciousLinks': return ThreatCategory.SUSPICIOUS_LINK;
|
||||
case 'scriptInjection': return ThreatCategory.XSS;
|
||||
case 'sensitiveData': return ThreatCategory.SENSITIVE_DATA;
|
||||
default: return ThreatCategory.BLACKLISTED_CONTENT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a high threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
* @param result The scan result
|
||||
*/
|
||||
private logHighThreatFound(email: Email, result: IScanResult): void {
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.ERROR,
|
||||
type: SecurityEventType.MALWARE,
|
||||
message: `High threat content detected in email from ${email.from} to ${email.to.join(', ')}`,
|
||||
details: {
|
||||
messageId: email.getMessageId(),
|
||||
threatType: result.threatType,
|
||||
threatDetails: result.threatDetails,
|
||||
threatScore: result.threatScore,
|
||||
scannedElements: result.scannedElements,
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a threat finding to the security logger
|
||||
* @param email The email containing the threat
|
||||
* @param result The scan result
|
||||
*/
|
||||
private logThreatFound(email: Email, result: IScanResult): void {
|
||||
SecurityLogger.getInstance().logEvent({
|
||||
level: SecurityLogLevel.WARN,
|
||||
type: SecurityEventType.SPAM,
|
||||
message: `Suspicious content detected in email from ${email.from} to ${email.to.join(', ')}`,
|
||||
details: {
|
||||
messageId: email.getMessageId(),
|
||||
threatType: result.threatType,
|
||||
threatDetails: result.threatDetails,
|
||||
threatScore: result.threatScore,
|
||||
scannedElements: result.scannedElements,
|
||||
subject: email.subject
|
||||
},
|
||||
success: false,
|
||||
domain: email.getFromDomain()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat level description based on score
|
||||
* @param score Threat score
|
||||
* @returns Threat level description
|
||||
*/
|
||||
public static getThreatLevel(score: number): 'none' | 'low' | 'medium' | 'high' {
|
||||
if (score < 20) {
|
||||
return 'none';
|
||||
} else if (score < 40) {
|
||||
return 'low';
|
||||
} else if (score < 70) {
|
||||
return 'medium';
|
||||
} else {
|
||||
return 'high';
|
||||
}
|
||||
}
|
||||
}
|
513
ts/security/classes.ipreputationchecker.ts
Normal file
513
ts/security/classes.ipreputationchecker.ts
Normal file
@ -0,0 +1,513 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
294
ts/security/classes.securitylogger.ts
Normal file
294
ts/security/classes.securitylogger.ts
Normal file
@ -0,0 +1,294 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Log level for security events
|
||||
*/
|
||||
export enum SecurityLogLevel {
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
CRITICAL = 'critical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Security event types for categorization
|
||||
*/
|
||||
export enum SecurityEventType {
|
||||
AUTHENTICATION = 'authentication',
|
||||
ACCESS_CONTROL = 'access_control',
|
||||
EMAIL_VALIDATION = 'email_validation',
|
||||
DKIM = 'dkim',
|
||||
SPF = 'spf',
|
||||
DMARC = 'dmarc',
|
||||
RATE_LIMIT = 'rate_limit',
|
||||
SPAM = 'spam',
|
||||
MALWARE = 'malware',
|
||||
CONNECTION = 'connection',
|
||||
DATA_EXPOSURE = 'data_exposure',
|
||||
CONFIGURATION = 'configuration',
|
||||
IP_REPUTATION = 'ip_reputation'
|
||||
}
|
||||
|
||||
/**
|
||||
* Security event interface
|
||||
*/
|
||||
export interface ISecurityEvent {
|
||||
timestamp: number;
|
||||
level: SecurityLogLevel;
|
||||
type: SecurityEventType;
|
||||
message: string;
|
||||
details?: any;
|
||||
ipAddress?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
emailId?: string;
|
||||
domain?: string;
|
||||
action?: string;
|
||||
result?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Security logger for enhanced security monitoring
|
||||
*/
|
||||
export class SecurityLogger {
|
||||
private static instance: SecurityLogger;
|
||||
private securityEvents: ISecurityEvent[] = [];
|
||||
private maxEventHistory: number;
|
||||
private enableNotifications: boolean;
|
||||
|
||||
private constructor(options?: {
|
||||
maxEventHistory?: number;
|
||||
enableNotifications?: boolean;
|
||||
}) {
|
||||
this.maxEventHistory = options?.maxEventHistory || 1000;
|
||||
this.enableNotifications = options?.enableNotifications || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(options?: {
|
||||
maxEventHistory?: number;
|
||||
enableNotifications?: boolean;
|
||||
}): SecurityLogger {
|
||||
if (!SecurityLogger.instance) {
|
||||
SecurityLogger.instance = new SecurityLogger(options);
|
||||
}
|
||||
return SecurityLogger.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a security event
|
||||
* @param event The security event to log
|
||||
*/
|
||||
public logEvent(event: Omit<ISecurityEvent, 'timestamp'>): void {
|
||||
const fullEvent: ISecurityEvent = {
|
||||
...event,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Store in memory buffer
|
||||
this.securityEvents.push(fullEvent);
|
||||
|
||||
// Trim history if needed
|
||||
if (this.securityEvents.length > this.maxEventHistory) {
|
||||
this.securityEvents.shift();
|
||||
}
|
||||
|
||||
// Log to regular logger with appropriate level
|
||||
switch (event.level) {
|
||||
case SecurityLogLevel.INFO:
|
||||
logger.log('info', `[SECURITY:${event.type}] ${event.message}`, event.details);
|
||||
break;
|
||||
case SecurityLogLevel.WARN:
|
||||
logger.log('warn', `[SECURITY:${event.type}] ${event.message}`, event.details);
|
||||
break;
|
||||
case SecurityLogLevel.ERROR:
|
||||
case SecurityLogLevel.CRITICAL:
|
||||
logger.log('error', `[SECURITY:${event.type}] ${event.message}`, event.details);
|
||||
|
||||
// Send notification for critical events if enabled
|
||||
if (event.level === SecurityLogLevel.CRITICAL && this.enableNotifications) {
|
||||
this.sendNotification(fullEvent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent security events
|
||||
* @param limit Maximum number of events to return
|
||||
* @param filter Filter for specific event types
|
||||
* @returns Recent security events
|
||||
*/
|
||||
public getRecentEvents(limit: number = 100, filter?: {
|
||||
level?: SecurityLogLevel;
|
||||
type?: SecurityEventType;
|
||||
fromTimestamp?: number;
|
||||
toTimestamp?: number;
|
||||
}): ISecurityEvent[] {
|
||||
let filteredEvents = this.securityEvents;
|
||||
|
||||
// Apply filters
|
||||
if (filter) {
|
||||
if (filter.level) {
|
||||
filteredEvents = filteredEvents.filter(event => event.level === filter.level);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
filteredEvents = filteredEvents.filter(event => event.type === filter.type);
|
||||
}
|
||||
|
||||
if (filter.fromTimestamp) {
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp >= filter.fromTimestamp);
|
||||
}
|
||||
|
||||
if (filter.toTimestamp) {
|
||||
filteredEvents = filteredEvents.filter(event => event.timestamp <= filter.toTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// Return most recent events up to limit
|
||||
return filteredEvents
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by security level
|
||||
* @param level The security level to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events matching the level
|
||||
*/
|
||||
public getEventsByLevel(level: SecurityLogLevel, limit: number = 100): ISecurityEvent[] {
|
||||
return this.getRecentEvents(limit, { level });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by security type
|
||||
* @param type The event type to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events matching the type
|
||||
*/
|
||||
public getEventsByType(type: SecurityEventType, limit: number = 100): ISecurityEvent[] {
|
||||
return this.getRecentEvents(limit, { type });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events for a specific IP address
|
||||
* @param ipAddress The IP address to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events for the IP address
|
||||
*/
|
||||
public getEventsByIP(ipAddress: string, limit: number = 100): ISecurityEvent[] {
|
||||
return this.securityEvents
|
||||
.filter(event => event.ipAddress === ipAddress)
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security events for a specific domain
|
||||
* @param domain The domain to filter by
|
||||
* @param limit Maximum number of events to return
|
||||
* @returns Security events for the domain
|
||||
*/
|
||||
public getEventsByDomain(domain: string, limit: number = 100): ISecurityEvent[] {
|
||||
return this.securityEvents
|
||||
.filter(event => event.domain === domain)
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification for critical security events
|
||||
* @param event The security event to notify about
|
||||
* @private
|
||||
*/
|
||||
private sendNotification(event: ISecurityEvent): void {
|
||||
// In a production environment, this would integrate with a notification service
|
||||
// For now, we'll just log that we would send a notification
|
||||
logger.log('error', `[SECURITY NOTIFICATION] ${event.message}`, {
|
||||
...event,
|
||||
notificationSent: true
|
||||
});
|
||||
|
||||
// Future integration with alerting systems would go here
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear event history
|
||||
*/
|
||||
public clearEvents(): void {
|
||||
this.securityEvents = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistical summary of security events
|
||||
* @param timeWindow Optional time window in milliseconds
|
||||
* @returns Summary of security events
|
||||
*/
|
||||
public getEventsSummary(timeWindow?: number): {
|
||||
total: number;
|
||||
byLevel: Record<SecurityLogLevel, number>;
|
||||
byType: Record<SecurityEventType, number>;
|
||||
topIPs: Array<{ ip: string; count: number }>;
|
||||
topDomains: Array<{ domain: string; count: number }>;
|
||||
} {
|
||||
// Filter by time window if provided
|
||||
let events = this.securityEvents;
|
||||
if (timeWindow) {
|
||||
const cutoff = Date.now() - timeWindow;
|
||||
events = events.filter(e => e.timestamp >= cutoff);
|
||||
}
|
||||
|
||||
// Count by level
|
||||
const byLevel = Object.values(SecurityLogLevel).reduce((acc, level) => {
|
||||
acc[level] = events.filter(e => e.level === level).length;
|
||||
return acc;
|
||||
}, {} as Record<SecurityLogLevel, number>);
|
||||
|
||||
// Count by type
|
||||
const byType = Object.values(SecurityEventType).reduce((acc, type) => {
|
||||
acc[type] = events.filter(e => e.type === type).length;
|
||||
return acc;
|
||||
}, {} as Record<SecurityEventType, number>);
|
||||
|
||||
// Count by IP
|
||||
const ipCounts = new Map<string, number>();
|
||||
events.forEach(e => {
|
||||
if (e.ipAddress) {
|
||||
ipCounts.set(e.ipAddress, (ipCounts.get(e.ipAddress) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Count by domain
|
||||
const domainCounts = new Map<string, number>();
|
||||
events.forEach(e => {
|
||||
if (e.domain) {
|
||||
domainCounts.set(e.domain, (domainCounts.get(e.domain) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort and limit top entries
|
||||
const topIPs = Array.from(ipCounts.entries())
|
||||
.map(([ip, count]) => ({ ip, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
const topDomains = Array.from(domainCounts.entries())
|
||||
.map(([domain, count]) => ({ domain, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
return {
|
||||
total: events.length,
|
||||
byLevel,
|
||||
byType,
|
||||
topIPs,
|
||||
topDomains
|
||||
};
|
||||
}
|
||||
}
|
21
ts/security/index.ts
Normal file
21
ts/security/index.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export {
|
||||
SecurityLogger,
|
||||
SecurityLogLevel,
|
||||
SecurityEventType,
|
||||
type ISecurityEvent
|
||||
} from './classes.securitylogger.js';
|
||||
|
||||
export {
|
||||
IPReputationChecker,
|
||||
ReputationThreshold,
|
||||
IPType,
|
||||
type IReputationResult,
|
||||
type IIPReputationOptions
|
||||
} from './classes.ipreputationchecker.js';
|
||||
|
||||
export {
|
||||
ContentScanner,
|
||||
ThreatCategory,
|
||||
type IScanResult,
|
||||
type IContentScannerOptions
|
||||
} from './classes.contentscanner.js';
|
Reference in New Issue
Block a user