2026-02-10 15:31:31 +00:00
|
|
|
import * as plugins from '../plugins.js';
|
|
|
|
|
import * as paths from '../paths.js';
|
|
|
|
|
import { logger } from '../logger.js';
|
|
|
|
|
import { Email } from '../mail/core/classes.email.js';
|
|
|
|
|
import type { IAttachment } from '../mail/core/classes.email.js';
|
|
|
|
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
2026-02-10 21:19:13 +00:00
|
|
|
import { RustSecurityBridge } from './classes.rustsecuritybridge.js';
|
2025-10-28 20:27:00 +00:00
|
|
|
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>;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-10 21:19:13 +00:00
|
|
|
* Scan an email for malicious content.
|
|
|
|
|
* Delegates text/subject/html/filename pattern scanning to Rust.
|
|
|
|
|
* Binary attachment scanning (PE headers, VBA macros) stays in TS.
|
2025-10-28 20:27:00 +00:00
|
|
|
* @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);
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
// Check cache first
|
|
|
|
|
const cachedResult = this.scanCache.get(cacheKey);
|
|
|
|
|
if (cachedResult) {
|
|
|
|
|
logger.log('info', `Using cached scan result for email ${email.getMessageId()}`);
|
|
|
|
|
return cachedResult;
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
|
|
|
|
// Delegate text/subject/html/filename scanning to Rust
|
|
|
|
|
const bridge = RustSecurityBridge.getInstance();
|
|
|
|
|
const rustResult = await bridge.scanContent({
|
|
|
|
|
subject: this.options.scanSubject ? email.subject : undefined,
|
|
|
|
|
textBody: this.options.scanBody ? email.text : undefined,
|
|
|
|
|
htmlBody: this.options.scanBody ? email.html : undefined,
|
|
|
|
|
attachmentNames: this.options.scanAttachmentNames
|
|
|
|
|
? email.attachments?.map(a => a.filename) ?? []
|
|
|
|
|
: [],
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
const result: IScanResult = {
|
|
|
|
|
isClean: true,
|
2026-02-10 21:19:13 +00:00
|
|
|
threatScore: rustResult.threatScore,
|
|
|
|
|
threatType: rustResult.threatType ?? undefined,
|
|
|
|
|
threatDetails: rustResult.threatDetails ?? undefined,
|
|
|
|
|
scannedElements: rustResult.scannedElements,
|
|
|
|
|
timestamp: Date.now(),
|
2025-10-28 20:27:00 +00:00
|
|
|
};
|
2026-02-10 21:19:13 +00:00
|
|
|
|
|
|
|
|
// Attachment binary scanning stays in TS (PE headers, macro detection)
|
|
|
|
|
if (this.options.scanAttachments && email.attachments?.length > 0) {
|
2025-10-28 20:27:00 +00:00
|
|
|
for (const attachment of email.attachments) {
|
2026-02-10 21:19:13 +00:00
|
|
|
this.scanAttachmentBinary(attachment, result);
|
2025-10-28 20:27:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
|
|
|
|
// Apply custom rules (TS-only, runtime-configured)
|
|
|
|
|
this.applyCustomRules(email, result);
|
|
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
// Determine if the email is clean based on threat score
|
|
|
|
|
result.isClean = result.threatScore < this.options.minThreatScore;
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
// Save to cache
|
|
|
|
|
this.scanCache.set(cacheKey, result);
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
// Log high threat findings
|
|
|
|
|
if (result.threatScore >= this.options.highThreatScore) {
|
|
|
|
|
this.logHighThreatFound(email, result);
|
|
|
|
|
} else if (!result.isClean) {
|
|
|
|
|
this.logThreatFound(email, result);
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.log('error', `Error scanning email: ${error.message}`, {
|
|
|
|
|
messageId: email.getMessageId(),
|
|
|
|
|
error: error.stack
|
|
|
|
|
});
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
// Return a safe default with error indication
|
|
|
|
|
return {
|
2026-02-10 21:19:13 +00:00
|
|
|
isClean: true,
|
2025-10-28 20:27:00 +00:00
|
|
|
threatScore: 0,
|
|
|
|
|
scannedElements: ['error'],
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
threatType: 'scan_error',
|
|
|
|
|
threatDetails: `Scan error: ${error.message}`
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
/**
|
|
|
|
|
* 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()}`;
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
// 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(':');
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
return `email:${plugins.crypto.createHash('sha256').update(contentToHash).digest('hex')}`;
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
/**
|
2026-02-10 21:19:13 +00:00
|
|
|
* Scan attachment binary content for PE headers and VBA macros.
|
|
|
|
|
* This stays in TS because it accesses raw Buffer data (too large for IPC).
|
|
|
|
|
* @param attachment The attachment to scan
|
2025-10-28 20:27:00 +00:00
|
|
|
* @param result The scan result to update
|
|
|
|
|
*/
|
2026-02-10 21:19:13 +00:00
|
|
|
private scanAttachmentBinary(attachment: IAttachment, result: IScanResult): void {
|
|
|
|
|
if (!attachment.content) {
|
|
|
|
|
return;
|
2025-10-28 20:27:00 +00:00
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
|
|
|
|
// Skip large attachments
|
|
|
|
|
if (attachment.content.length > this.options.maxAttachmentSizeToScan) {
|
|
|
|
|
return;
|
2025-10-28 20:27:00 +00:00
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
|
|
|
|
const filename = attachment.filename.toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Check for PE headers (Windows executables disguised with non-.exe extensions)
|
|
|
|
|
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}`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for VBA macro indicators in Office documents
|
|
|
|
|
if (this.options.blockMacros && this.likelyContainsMacros(attachment)) {
|
|
|
|
|
result.threatScore += 60;
|
|
|
|
|
result.threatType = ThreatCategory.MALICIOUS_MACRO;
|
|
|
|
|
result.threatDetails = `Attachment appears to contain macros: ${filename}`;
|
2025-10-28 20:27:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
/**
|
2026-02-10 21:19:13 +00:00
|
|
|
* Apply custom rules (runtime-configured patterns) to the email.
|
|
|
|
|
* These stay in TS because they are configured at runtime.
|
|
|
|
|
* @param email The email to check
|
2025-10-28 20:27:00 +00:00
|
|
|
* @param result The scan result to update
|
|
|
|
|
*/
|
2026-02-10 21:19:13 +00:00
|
|
|
private applyCustomRules(email: Email, result: IScanResult): void {
|
|
|
|
|
if (!this.options.customRules.length) {
|
|
|
|
|
return;
|
2025-10-28 20:27:00 +00:00
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
|
|
|
|
const textsToCheck: string[] = [];
|
|
|
|
|
if (email.subject) textsToCheck.push(email.subject);
|
|
|
|
|
if (email.text) textsToCheck.push(email.text);
|
|
|
|
|
if (email.html) textsToCheck.push(email.html);
|
|
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
for (const rule of this.options.customRules) {
|
|
|
|
|
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
2026-02-10 21:19:13 +00:00
|
|
|
for (const text of textsToCheck) {
|
|
|
|
|
if (pattern.test(text)) {
|
|
|
|
|
result.threatScore += rule.score;
|
2025-10-28 20:27:00 +00:00
|
|
|
result.threatType = rule.type;
|
|
|
|
|
result.threatDetails = rule.description;
|
2026-02-10 21:19:13 +00:00
|
|
|
return;
|
2025-10-28 20:27:00 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
/**
|
|
|
|
|
* 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);
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
// 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 '';
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
/**
|
|
|
|
|
* Check if an Office document likely contains macros
|
|
|
|
|
* @param attachment The attachment to check
|
|
|
|
|
* @returns Whether the file likely contains macros
|
|
|
|
|
*/
|
|
|
|
|
private likelyContainsMacros(attachment: IAttachment): boolean {
|
|
|
|
|
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
|
|
|
|
|
];
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
for (const indicator of macroIndicators) {
|
|
|
|
|
if (indicator.test(content)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-10 21:19:13 +00:00
|
|
|
|
2025-10-28 20:27:00 +00:00
|
|
|
/**
|
|
|
|
|
* 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';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|