feat(security): migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references
This commit is contained in:
@@ -84,7 +84,7 @@ jobs:
|
|||||||
mailer --version || echo "Note: Binary execution may fail in CI environment"
|
mailer --version || echo "Note: Binary execution may fail in CI environment"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking installed files:"
|
echo "Checking installed files:"
|
||||||
npm ls -g @serve.zone/mailer || true
|
npm ls -g @push.rocks/smartmta || true
|
||||||
|
|
||||||
- name: Publish to npm
|
- name: Publish to npm
|
||||||
env:
|
env:
|
||||||
@@ -93,10 +93,10 @@ jobs:
|
|||||||
echo "Publishing to npm registry..."
|
echo "Publishing to npm registry..."
|
||||||
npm publish --access public
|
npm publish --access public
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Successfully published @serve.zone/mailer to npm!"
|
echo "✅ Successfully published @push.rocks/smartmta to npm!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Package info:"
|
echo "Package info:"
|
||||||
npm view @serve.zone/mailer
|
npm view @push.rocks/smartmta
|
||||||
|
|
||||||
- name: Verify npm package
|
- name: Verify npm package
|
||||||
run: |
|
run: |
|
||||||
@@ -104,10 +104,10 @@ jobs:
|
|||||||
sleep 30
|
sleep 30
|
||||||
echo ""
|
echo ""
|
||||||
echo "Verifying published package..."
|
echo "Verifying published package..."
|
||||||
npm view @serve.zone/mailer
|
npm view @push.rocks/smartmta
|
||||||
echo ""
|
echo ""
|
||||||
echo "Testing installation from npm:"
|
echo "Testing installation from npm:"
|
||||||
npm install -g @serve.zone/mailer
|
npm install -g @push.rocks/smartmta
|
||||||
echo ""
|
echo ""
|
||||||
echo "Package installed successfully!"
|
echo "Package installed successfully!"
|
||||||
which mailer || echo "Binary location check skipped"
|
which mailer || echo "Binary location check skipped"
|
||||||
@@ -118,12 +118,12 @@ jobs:
|
|||||||
echo " npm Publish Complete!"
|
echo " npm Publish Complete!"
|
||||||
echo "================================================"
|
echo "================================================"
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Package: @serve.zone/mailer"
|
echo "✅ Package: @push.rocks/smartmta"
|
||||||
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
echo "✅ Version: ${{ steps.version.outputs.version }}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Installation:"
|
echo "Installation:"
|
||||||
echo " npm install -g @serve.zone/mailer"
|
echo " npm install -g @push.rocks/smartmta"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Registry:"
|
echo "Registry:"
|
||||||
echo " https://www.npmjs.com/package/@serve.zone/mailer"
|
echo " https://www.npmjs.com/package/@push.rocks/smartmta"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
10
changelog.md
10
changelog.md
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-10 - 2.1.0 - feat(security)
|
||||||
|
migrate content scanning and bounce detection to Rust security bridge; add scanContent IPC command and Rust content scanner with tests; update TS RustSecurityBridge and callers, and adjust CI package references
|
||||||
|
|
||||||
|
- Add Rust content scanner implementation (rust/crates/mailer-security/src/content_scanner.rs) with pattern-based detection and unit tests (~515 lines)
|
||||||
|
- Expose new IPC command 'scanContent' in mailer-bin and marshal results via JSON for the RustSecurityBridge
|
||||||
|
- Update TypeScript RustSecurityBridge with scanContent typing and method, and replace local JS detection logic (bounce/content) to call Rust bridge
|
||||||
|
- Update tests to start/stop the RustSecurityBridge and rely on Rust-based detection (test updates in test.bouncemanager.ts and test.contentscanner.ts)
|
||||||
|
- Update CI workflow messages and package references from @serve.zone/mailer to @push.rocks/smartmta
|
||||||
|
- Add regex dependency to rust mailer-security workspace (Cargo.toml / Cargo.lock updated)
|
||||||
|
|
||||||
## 2026-02-10 - 2.0.1 - fix(docs/readme)
|
## 2026-02-10 - 2.0.1 - fix(docs/readme)
|
||||||
update README: clarify APIs, document RustSecurityBridge, update examples and architecture diagram
|
update README: clarify APIs, document RustSecurityBridge, update examples and architecture diagram
|
||||||
|
|
||||||
|
|||||||
15
dist_ts/mail/core/classes.bouncemanager.d.ts
vendored
15
dist_ts/mail/core/classes.bouncemanager.d.ts
vendored
@@ -165,21 +165,6 @@ export declare class BounceManager {
|
|||||||
type: BounceType;
|
type: BounceType;
|
||||||
category: BounceCategory;
|
category: BounceCategory;
|
||||||
} | null;
|
} | null;
|
||||||
/**
|
|
||||||
* Analyze SMTP response and diagnostic codes to determine bounce type
|
|
||||||
* @param smtpResponse SMTP response string
|
|
||||||
* @param diagnosticCode Diagnostic code from bounce
|
|
||||||
* @param statusCode Status code from bounce
|
|
||||||
* @returns Detected bounce type and category
|
|
||||||
*/
|
|
||||||
private detectBounceType;
|
|
||||||
/**
|
|
||||||
* Check if text matches any pattern for a bounce type
|
|
||||||
* @param text Text to check against patterns
|
|
||||||
* @param bounceType Bounce type to get patterns for
|
|
||||||
* @returns Whether the text matches any pattern
|
|
||||||
*/
|
|
||||||
private matchesPattern;
|
|
||||||
/**
|
/**
|
||||||
* Get all known hard bounced addresses
|
* Get all known hard bounced addresses
|
||||||
* @returns Array of hard bounced email addresses
|
* @returns Array of hard bounced email addresses
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
52
dist_ts/security/classes.contentscanner.d.ts
vendored
52
dist_ts/security/classes.contentscanner.d.ts
vendored
@@ -54,9 +54,6 @@ export declare class ContentScanner {
|
|||||||
private static instance;
|
private static instance;
|
||||||
private scanCache;
|
private scanCache;
|
||||||
private options;
|
private options;
|
||||||
private static readonly MALICIOUS_PATTERNS;
|
|
||||||
private static readonly EXECUTABLE_EXTENSIONS;
|
|
||||||
private static readonly MACRO_DOCUMENT_EXTENSIONS;
|
|
||||||
/**
|
/**
|
||||||
* Default options for the content scanner
|
* Default options for the content scanner
|
||||||
*/
|
*/
|
||||||
@@ -73,7 +70,9 @@ export declare class ContentScanner {
|
|||||||
*/
|
*/
|
||||||
static getInstance(options?: IContentScannerOptions): ContentScanner;
|
static getInstance(options?: IContentScannerOptions): ContentScanner;
|
||||||
/**
|
/**
|
||||||
* Scan an email for malicious content
|
* 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.
|
||||||
* @param email The email to scan
|
* @param email The email to scan
|
||||||
* @returns Scan result
|
* @returns Scan result
|
||||||
*/
|
*/
|
||||||
@@ -85,41 +84,19 @@ export declare class ContentScanner {
|
|||||||
*/
|
*/
|
||||||
private generateCacheKey;
|
private generateCacheKey;
|
||||||
/**
|
/**
|
||||||
* Scan email subject for threats
|
* Scan attachment binary content for PE headers and VBA macros.
|
||||||
* @param subject The subject to scan
|
* This stays in TS because it accesses raw Buffer data (too large for IPC).
|
||||||
* @param result The scan result to update
|
|
||||||
*/
|
|
||||||
private scanSubject;
|
|
||||||
/**
|
|
||||||
* Scan plain text content for threats
|
|
||||||
* @param text The text content to scan
|
|
||||||
* @param result The scan result to update
|
|
||||||
*/
|
|
||||||
private scanTextContent;
|
|
||||||
/**
|
|
||||||
* Scan HTML content for threats
|
|
||||||
* @param html The HTML content to scan
|
|
||||||
* @param result The scan result to update
|
|
||||||
*/
|
|
||||||
private scanHtmlContent;
|
|
||||||
/**
|
|
||||||
* Scan an attachment for threats
|
|
||||||
* @param attachment The attachment to scan
|
* @param attachment The attachment to scan
|
||||||
* @param result The scan result to update
|
* @param result The scan result to update
|
||||||
*/
|
*/
|
||||||
private scanAttachment;
|
private scanAttachmentBinary;
|
||||||
/**
|
/**
|
||||||
* Extract links from HTML content
|
* Apply custom rules (runtime-configured patterns) to the email.
|
||||||
* @param html HTML content
|
* These stay in TS because they are configured at runtime.
|
||||||
* @returns Array of extracted links
|
* @param email The email to check
|
||||||
|
* @param result The scan result to update
|
||||||
*/
|
*/
|
||||||
private extractLinksFromHtml;
|
private applyCustomRules;
|
||||||
/**
|
|
||||||
* Extract plain text from HTML
|
|
||||||
* @param html HTML content
|
|
||||||
* @returns Extracted text
|
|
||||||
*/
|
|
||||||
private extractTextFromHtml;
|
|
||||||
/**
|
/**
|
||||||
* Extract text from a binary buffer for scanning
|
* Extract text from a binary buffer for scanning
|
||||||
* @param buffer Binary content
|
* @param buffer Binary content
|
||||||
@@ -128,17 +105,10 @@ export declare class ContentScanner {
|
|||||||
private extractTextFromBuffer;
|
private extractTextFromBuffer;
|
||||||
/**
|
/**
|
||||||
* Check if an Office document likely contains macros
|
* 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
|
* @param attachment The attachment to check
|
||||||
* @returns Whether the file likely contains macros
|
* @returns Whether the file likely contains macros
|
||||||
*/
|
*/
|
||||||
private likelyContainsMacros;
|
private likelyContainsMacros;
|
||||||
/**
|
|
||||||
* Map a pattern category to a threat type
|
|
||||||
* @param category The pattern category
|
|
||||||
* @returns The corresponding threat type
|
|
||||||
*/
|
|
||||||
private mapCategoryToThreatType;
|
|
||||||
/**
|
/**
|
||||||
* Log a high threat finding to the security logger
|
* Log a high threat finding to the security logger
|
||||||
* @param email The email containing the threat
|
* @param email The email containing the threat
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
15
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
15
dist_ts/security/classes.rustsecuritybridge.d.ts
vendored
@@ -48,6 +48,12 @@ interface IReputationResult {
|
|||||||
listed_count: number;
|
listed_count: number;
|
||||||
total_checked: number;
|
total_checked: number;
|
||||||
}
|
}
|
||||||
|
interface IContentScanResult {
|
||||||
|
threatScore: number;
|
||||||
|
threatType: string | null;
|
||||||
|
threatDetails: string | null;
|
||||||
|
scannedElements: string[];
|
||||||
|
}
|
||||||
interface IVersionInfo {
|
interface IVersionInfo {
|
||||||
bin: string;
|
bin: string;
|
||||||
core: string;
|
core: string;
|
||||||
@@ -88,6 +94,13 @@ export declare class RustSecurityBridge {
|
|||||||
diagnosticCode?: string;
|
diagnosticCode?: string;
|
||||||
statusCode?: string;
|
statusCode?: string;
|
||||||
}): Promise<IBounceDetection>;
|
}): Promise<IBounceDetection>;
|
||||||
|
/** Scan email content for threats (phishing, spam, malware, etc.). */
|
||||||
|
scanContent(opts: {
|
||||||
|
subject?: string;
|
||||||
|
textBody?: string;
|
||||||
|
htmlBody?: string;
|
||||||
|
attachmentNames?: string[];
|
||||||
|
}): Promise<IContentScanResult>;
|
||||||
/** Check IP reputation via DNSBL. */
|
/** Check IP reputation via DNSBL. */
|
||||||
checkIpReputation(ip: string): Promise<IReputationResult>;
|
checkIpReputation(ip: string): Promise<IReputationResult>;
|
||||||
/** Verify DKIM signatures on a raw email message. */
|
/** Verify DKIM signatures on a raw email message. */
|
||||||
@@ -123,4 +136,4 @@ export declare class RustSecurityBridge {
|
|||||||
mailFrom: string;
|
mailFrom: string;
|
||||||
}): Promise<IEmailSecurityResult>;
|
}): Promise<IEmailSecurityResult>;
|
||||||
}
|
}
|
||||||
export type { IDkimVerificationResult, ISpfResult, IDmarcResult, IEmailSecurityResult, IValidationResult, IBounceDetection, IReputationResult as IRustReputationResult, IVersionInfo, };
|
export type { IDkimVerificationResult, ISpfResult, IDmarcResult, IEmailSecurityResult, IValidationResult, IBounceDetection, IContentScanResult, IReputationResult as IRustReputationResult, IVersionInfo, };
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
212
readme.plan.md
212
readme.plan.md
@@ -1,198 +1,24 @@
|
|||||||
# Mailer Implementation Plan & Progress
|
# Rust Migration Plan
|
||||||
|
|
||||||
## Project Goals
|
## Completed Phases
|
||||||
|
|
||||||
Build a Deno-based mail server package (`@serve.zone/mailer`) with:
|
### Phase 3: Rust Primary Backend (DKIM/SPF/DMARC/IP Reputation)
|
||||||
1. CLI interface similar to nupst/spark
|
- Rust is the mandatory security backend — no TS fallbacks
|
||||||
2. SMTP server and client (ported from dcrouter)
|
- All DKIM signing/verification, SPF, DMARC, IP reputation through Rust bridge
|
||||||
3. HTTP REST API (Mailgun-compatible)
|
|
||||||
4. Automatic DNS management via Cloudflare
|
|
||||||
5. Systemd daemon service
|
|
||||||
6. Binary distribution via npm
|
|
||||||
|
|
||||||
## Completed Work
|
### Phase 5: BounceManager + ContentScanner
|
||||||
|
- BounceManager bounce detection delegated to Rust `detectBounce` IPC command
|
||||||
|
- ContentScanner pattern matching delegated to new Rust `scanContent` IPC command
|
||||||
|
- New module: `rust/crates/mailer-security/src/content_scanner.rs` (10 Rust tests)
|
||||||
|
- ~215 lines removed from BounceManager, ~350 lines removed from ContentScanner
|
||||||
|
- Binary attachment scanning (PE headers, VBA macros) stays in TS
|
||||||
|
- Custom rules (runtime-configured) stay in TS
|
||||||
|
- Net change: ~-560 TS lines, +265 Rust lines
|
||||||
|
|
||||||
### ✅ Phase 1: Project Structure
|
## Deferred
|
||||||
- [x] Created Deno-based project structure (deno.json, package.json)
|
|
||||||
- [x] Set up bin/ wrappers for npm binary distribution
|
|
||||||
- [x] Created compilation scripts (compile-all.sh)
|
|
||||||
- [x] Set up install scripts (install-binary.js)
|
|
||||||
- [x] Created TypeScript source directory structure
|
|
||||||
|
|
||||||
### ✅ Phase 2: Mail Implementation (Ported from dcrouter)
|
| Component | Rationale |
|
||||||
- [x] Copied and adapted mail/core/ (Email, EmailValidator, BounceManager, TemplateManager)
|
|-----------|-----------|
|
||||||
- [x] Copied and adapted mail/delivery/ (SMTP client, SMTP server, queues, rate limiting)
|
| EmailValidator | Already thin; uses smartmail; minimal gain |
|
||||||
- [x] Copied and adapted mail/routing/ (EmailRouter, DomainRegistry, DnsManager)
|
| DNS record generation | Pure string building; zero benefit from Rust |
|
||||||
- [x] Copied and adapted mail/security/ (DKIM, SPF, DMARC)
|
| MIME building (`toRFC822String`) | Sync in TS, async via IPC; too much blast radius |
|
||||||
- [x] Fixed all imports from .js to .ts extensions
|
|
||||||
- [x] Created stub modules for dcrouter dependencies (storage, security, deliverability, errors)
|
|
||||||
|
|
||||||
### ✅ Phase 3: Supporting Modules
|
|
||||||
- [x] Created logger module (simple console logging)
|
|
||||||
- [x] Created paths module (project paths)
|
|
||||||
- [x] Created plugins.ts (Deno dependencies + Node.js compatibility)
|
|
||||||
- [x] Added required npm dependencies (lru-cache, mailaddress-validator, cloudflare)
|
|
||||||
|
|
||||||
### ✅ Phase 4: DNS Management
|
|
||||||
- [x] Created DnsManager class with DNS record generation
|
|
||||||
- [x] Created CloudflareClient for automatic DNS setup
|
|
||||||
- [x] Added DNS validation functionality
|
|
||||||
|
|
||||||
### ✅ Phase 5: HTTP API
|
|
||||||
- [x] Created ApiServer class with basic routing
|
|
||||||
- [x] Implemented Mailgun-compatible endpoint structure
|
|
||||||
- [x] Added authentication and rate limiting stubs
|
|
||||||
|
|
||||||
### ✅ Phase 6: Configuration Management
|
|
||||||
- [x] Created ConfigManager for JSON-based config storage
|
|
||||||
- [x] Added domain configuration support
|
|
||||||
- [x] Implemented config load/save functionality
|
|
||||||
|
|
||||||
### ✅ Phase 7: Daemon Service
|
|
||||||
- [x] Created DaemonManager to coordinate SMTP server and API server
|
|
||||||
- [x] Added start/stop functionality
|
|
||||||
- [x] Integrated with ConfigManager
|
|
||||||
|
|
||||||
### ✅ Phase 8: CLI Interface
|
|
||||||
- [x] Created MailerCli class with command routing
|
|
||||||
- [x] Implemented service commands (start/stop/restart/status/enable/disable)
|
|
||||||
- [x] Implemented domain commands (add/remove/list)
|
|
||||||
- [x] Implemented DNS commands (setup/validate/show)
|
|
||||||
- [x] Implemented send command
|
|
||||||
- [x] Implemented config commands (show/set)
|
|
||||||
- [x] Added help and version commands
|
|
||||||
|
|
||||||
### ✅ Phase 9: Documentation
|
|
||||||
- [x] Created comprehensive README.md
|
|
||||||
- [x] Documented all CLI commands
|
|
||||||
- [x] Documented HTTP API endpoints
|
|
||||||
- [x] Provided configuration examples
|
|
||||||
- [x] Documented DNS requirements
|
|
||||||
- [x] Created changelog
|
|
||||||
|
|
||||||
## Next Steps (Remaining Work)
|
|
||||||
|
|
||||||
### Testing & Debugging
|
|
||||||
1. Fix remaining import/dependency issues
|
|
||||||
2. Test compilation with `deno compile`
|
|
||||||
3. Test CLI commands end-to-end
|
|
||||||
4. Test SMTP sending/receiving
|
|
||||||
5. Test HTTP API endpoints
|
|
||||||
6. Write unit tests
|
|
||||||
|
|
||||||
### Systemd Integration
|
|
||||||
1. Create systemd service file
|
|
||||||
2. Implement service enable/disable
|
|
||||||
3. Add service status checking
|
|
||||||
4. Test daemon auto-restart
|
|
||||||
|
|
||||||
### Cloudflare Integration
|
|
||||||
1. Test actual Cloudflare API calls
|
|
||||||
2. Handle Cloudflare errors gracefully
|
|
||||||
3. Add zone detection
|
|
||||||
4. Verify DNS record creation
|
|
||||||
|
|
||||||
### Production Readiness
|
|
||||||
1. Add proper error handling throughout
|
|
||||||
2. Implement logging to files
|
|
||||||
3. Add rate limiting implementation
|
|
||||||
4. Implement API key authentication
|
|
||||||
5. Add TLS certificate management
|
|
||||||
6. Implement email queue persistence
|
|
||||||
|
|
||||||
### Advanced Features
|
|
||||||
1. Webhook support for incoming emails
|
|
||||||
2. Email template system
|
|
||||||
3. Analytics and reporting
|
|
||||||
4. SMTP credential management
|
|
||||||
5. Email event tracking
|
|
||||||
6. Bounce handling
|
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
|
|
||||||
1. Some npm dependencies may need version adjustments
|
|
||||||
2. Deno crypto APIs may need adaptation for DKIM signing
|
|
||||||
3. Buffer vs Uint8Array conversions may be needed
|
|
||||||
4. Some dcrouter-specific code may need further adaptation
|
|
||||||
|
|
||||||
## File Structure Overview
|
|
||||||
|
|
||||||
```
|
|
||||||
mailer/
|
|
||||||
├── README.md ✅ Complete
|
|
||||||
├── license ✅ Complete
|
|
||||||
├── changelog.md ✅ Complete
|
|
||||||
├── deno.json ✅ Complete
|
|
||||||
├── package.json ✅ Complete
|
|
||||||
├── mod.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── bin/
|
|
||||||
│ └── mailer-wrapper.js ✅ Complete
|
|
||||||
│
|
|
||||||
├── scripts/
|
|
||||||
│ ├── compile-all.sh ✅ Complete
|
|
||||||
│ └── install-binary.js ✅ Complete
|
|
||||||
│
|
|
||||||
└── ts/
|
|
||||||
├── 00_commitinfo_data.ts ✅ Complete
|
|
||||||
├── index.ts ✅ Complete
|
|
||||||
├── cli.ts ✅ Complete
|
|
||||||
├── plugins.ts ✅ Complete
|
|
||||||
├── logger.ts ✅ Complete
|
|
||||||
├── paths.ts ✅ Complete
|
|
||||||
├── classes.mailer.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── cli/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ └── mailer-cli.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── api/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ ├── api-server.ts ✅ Complete
|
|
||||||
│ └── routes/ ✅ Structure ready
|
|
||||||
│
|
|
||||||
├── dns/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ ├── dns-manager.ts ✅ Complete
|
|
||||||
│ └── cloudflare-client.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── daemon/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ └── daemon-manager.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── config/
|
|
||||||
│ ├── index.ts ✅ Complete
|
|
||||||
│ └── config-manager.ts ✅ Complete
|
|
||||||
│
|
|
||||||
├── storage/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
├── security/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
├── deliverability/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
├── errors/
|
|
||||||
│ └── index.ts ✅ Stub complete
|
|
||||||
│
|
|
||||||
└── mail/ ✅ Ported from dcrouter
|
|
||||||
├── core/ ✅ Complete
|
|
||||||
├── delivery/ ✅ Complete
|
|
||||||
├── routing/ ✅ Complete
|
|
||||||
└── security/ ✅ Complete
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The mailer package structure is **95% complete**. All major components have been implemented:
|
|
||||||
- Project structure and build system ✅
|
|
||||||
- Mail implementation ported from dcrouter ✅
|
|
||||||
- CLI interface ✅
|
|
||||||
- DNS management ✅
|
|
||||||
- HTTP API ✅
|
|
||||||
- Configuration system ✅
|
|
||||||
- Daemon management ✅
|
|
||||||
- Documentation ✅
|
|
||||||
|
|
||||||
**Remaining work**: Testing, debugging dependency issues, systemd integration, and production hardening.
|
|
||||||
|
|||||||
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -1054,6 +1054,7 @@ dependencies = [
|
|||||||
"mail-auth",
|
"mail-auth",
|
||||||
"mailer-core",
|
"mailer-core",
|
||||||
"psl",
|
"psl",
|
||||||
|
"regex",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! mailer-bin: CLI and IPC binary for the @serve.zone/mailer Rust crates.
|
//! mailer-bin: CLI and IPC binary for the @push.rocks/smartmta Rust crates.
|
||||||
//!
|
//!
|
||||||
//! Supports two modes:
|
//! Supports two modes:
|
||||||
//! 1. **CLI mode** — traditional subcommands for testing and standalone use
|
//! 1. **CLI mode** — traditional subcommands for testing and standalone use
|
||||||
@@ -560,6 +560,25 @@ async fn handle_ipc_request(req: &IpcRequest) -> IpcResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"scanContent" => {
|
||||||
|
let subject = req.params.get("subject").and_then(|v| v.as_str());
|
||||||
|
let text_body = req.params.get("textBody").and_then(|v| v.as_str());
|
||||||
|
let html_body = req.params.get("htmlBody").and_then(|v| v.as_str());
|
||||||
|
let attachment_names: Vec<String> = req.params.get("attachmentNames")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let result = mailer_security::content_scanner::scan_content(
|
||||||
|
subject, text_body, html_body, &attachment_names
|
||||||
|
);
|
||||||
|
IpcResponse {
|
||||||
|
id: req.id.clone(),
|
||||||
|
success: true,
|
||||||
|
result: Some(serde_json::to_value(&result).unwrap()),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
"checkSpf" => {
|
"checkSpf" => {
|
||||||
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
let ip_str = req.params.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let helo = req
|
let helo = req
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ hickory-resolver.workspace = true
|
|||||||
ipnet.workspace = true
|
ipnet.workspace = true
|
||||||
rustls-pki-types.workspace = true
|
rustls-pki-types.workspace = true
|
||||||
psl.workspace = true
|
psl.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
|||||||
515
rust/crates/mailer-security/src/content_scanner.rs
Normal file
515
rust/crates/mailer-security/src/content_scanner.rs
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
//! Content scanning for email threat detection.
|
||||||
|
//!
|
||||||
|
//! Provides pattern-based scanning of email subjects, text bodies, HTML bodies,
|
||||||
|
//! and attachment filenames for phishing, spam, malware, suspicious links,
|
||||||
|
//! script injection, and sensitive data patterns.
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContentScanResult {
|
||||||
|
pub threat_score: u32,
|
||||||
|
pub threat_type: Option<String>,
|
||||||
|
pub threat_details: Option<String>,
|
||||||
|
pub scanned_elements: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pattern definitions (compiled once via LazyLock)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static PHISHING_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)(?:verify|confirm|update|login).*(?:account|password|details)").unwrap(),
|
||||||
|
Regex::new(r"(?i)urgent.*(?:action|attention|required)").unwrap(),
|
||||||
|
Regex::new(r"(?i)(?:paypal|apple|microsoft|amazon|google|bank).*(?:verify|confirm|suspend)").unwrap(),
|
||||||
|
Regex::new(r"(?i)your.*(?:account).*(?:suspended|compromised|locked)").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:password reset|security alert|security notice)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SPAM_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)\b(?:viagra|cialis|enlargement|diet pill|lose weight fast|cheap meds)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:million dollars|lottery winner|prize claim|inheritance|rich widow)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:earn from home|make money fast|earn \$\d{3,}/day)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:limited time offer|act now|exclusive deal|only \d+ left)\b").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:forex|stock tip|investment opportunity|cryptocurrency|bitcoin)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static MALWARE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)(?:attached file|see attachment).*(?:invoice|receipt|statement|document)").unwrap(),
|
||||||
|
Regex::new(r"(?i)open.*(?:the attached|this attachment)").unwrap(),
|
||||||
|
Regex::new(r"(?i)(?:enable|allow).*(?:macros|content|editing)").unwrap(),
|
||||||
|
Regex::new(r"(?i)download.*(?:attachment|file|document)").unwrap(),
|
||||||
|
Regex::new(r"(?i)\b(?:ransomware protection|virus alert|malware detected)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SUSPICIOUS_LINK_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?i)https?://bit\.ly/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://goo\.gl/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://t\.co/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://tinyurl\.com/").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://(?:\d{1,3}\.){3}\d{1,3}").unwrap(),
|
||||||
|
Regex::new(r"(?i)https?://.*\.(?:xyz|top|club|gq|cf)/").unwrap(),
|
||||||
|
Regex::new(r"(?i)(?:login|account|signin|auth).*\.(?:xyz|top|club|gq|cf|tk|ml|ga|pw|ws|buzz)\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SCRIPT_INJECTION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"(?is)<script.*>.*</script>").unwrap(),
|
||||||
|
Regex::new(r"(?i)javascript:").unwrap(),
|
||||||
|
Regex::new(r#"(?i)on(?:click|load|mouse|error|focus|blur)=".*""#).unwrap(),
|
||||||
|
Regex::new(r"(?i)document\.(?:cookie|write|location)").unwrap(),
|
||||||
|
Regex::new(r"(?i)eval\s*\(").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
static SENSITIVE_DATA_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
Regex::new(r"\b(?:\d{3}-\d{2}-\d{4}|\d{9})\b").unwrap(),
|
||||||
|
Regex::new(r"\b\d{13,16}\b").unwrap(),
|
||||||
|
Regex::new(r"\b(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})\b").unwrap(),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Link extraction from HTML href attributes.
|
||||||
|
static HREF_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r#"(?i)href=["'](https?://[^"']+)["']"#).unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Executable file extensions that are considered dangerous.
|
||||||
|
static EXECUTABLE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
".exe", ".dll", ".bat", ".cmd", ".msi", ".vbs", ".ps1",
|
||||||
|
".sh", ".jar", ".py", ".com", ".scr", ".pif", ".hta", ".cpl",
|
||||||
|
".reg", ".vba", ".lnk", ".wsf", ".msp", ".mst",
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Document extensions that may contain macros.
|
||||||
|
static MACRO_DOCUMENT_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
|
||||||
|
vec![
|
||||||
|
".doc", ".docm", ".xls", ".xlsm", ".ppt", ".pptm",
|
||||||
|
".dotm", ".xlsb", ".ppam", ".potm",
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Strip HTML tags and decode common entities to produce plain text.
|
||||||
|
fn extract_text_from_html(html: &str) -> String {
|
||||||
|
// Remove style and script blocks first
|
||||||
|
let no_style = Regex::new(r"(?is)<style[^>]*>.*?</style>").unwrap();
|
||||||
|
let no_script = Regex::new(r"(?is)<script[^>]*>.*?</script>").unwrap();
|
||||||
|
let no_tags = Regex::new(r"<[^>]+>").unwrap();
|
||||||
|
|
||||||
|
let text = no_style.replace_all(html, " ");
|
||||||
|
let text = no_script.replace_all(&text, " ");
|
||||||
|
let text = no_tags.replace_all(&text, " ");
|
||||||
|
|
||||||
|
text.replace(" ", " ")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract all href links from HTML.
|
||||||
|
fn extract_links_from_html(html: &str) -> Vec<String> {
|
||||||
|
HREF_PATTERN
|
||||||
|
.captures_iter(html)
|
||||||
|
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scoring helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn matches_any(text: &str, patterns: &[Regex]) -> bool {
|
||||||
|
patterns.iter().any(|p| p.is_match(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main scan entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Scan email content for threats.
|
||||||
|
///
|
||||||
|
/// This mirrors the TypeScript ContentScanner logic — scanning the subject,
|
||||||
|
/// text body, HTML body, and attachment filenames against predefined patterns.
|
||||||
|
/// Returns an aggregate threat score and the highest-severity threat type.
|
||||||
|
pub fn scan_content(
|
||||||
|
subject: Option<&str>,
|
||||||
|
text_body: Option<&str>,
|
||||||
|
html_body: Option<&str>,
|
||||||
|
attachment_names: &[String],
|
||||||
|
) -> ContentScanResult {
|
||||||
|
let mut score: u32 = 0;
|
||||||
|
let mut threat_type: Option<String> = None;
|
||||||
|
let mut threat_details: Option<String> = None;
|
||||||
|
let mut scanned: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
// Helper: upgrade threat info only if the new finding is more severe.
|
||||||
|
macro_rules! record {
|
||||||
|
($new_score:expr, $ttype:expr, $details:expr) => {
|
||||||
|
score += $new_score;
|
||||||
|
// Always adopt the threat type from the highest-scoring match.
|
||||||
|
threat_type = Some($ttype.to_string());
|
||||||
|
threat_details = Some($details.to_string());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subject scanning ──────────────────────────────────────────────
|
||||||
|
if let Some(subj) = subject {
|
||||||
|
scanned.push("subject".into());
|
||||||
|
|
||||||
|
if matches_any(subj, &PHISHING_PATTERNS) {
|
||||||
|
record!(25, "phishing", format!("Subject contains potential phishing indicators: {}", subj));
|
||||||
|
} else if matches_any(subj, &SPAM_PATTERNS) {
|
||||||
|
record!(15, "spam", format!("Subject contains potential spam indicators: {}", subj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text body scanning ────────────────────────────────────────────
|
||||||
|
if let Some(text) = text_body {
|
||||||
|
scanned.push("text".into());
|
||||||
|
|
||||||
|
// Check each category and accumulate score (same order as TS)
|
||||||
|
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 20;
|
||||||
|
if threat_type.as_deref() != Some("suspicious_link") {
|
||||||
|
threat_type = Some("suspicious_link".into());
|
||||||
|
threat_details = Some("Text contains suspicious links".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in PHISHING_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 25;
|
||||||
|
threat_type = Some("phishing".into());
|
||||||
|
threat_details = Some("Text contains potential phishing indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in SPAM_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 15;
|
||||||
|
if threat_type.is_none() {
|
||||||
|
threat_type = Some("spam".into());
|
||||||
|
threat_details = Some("Text contains potential spam indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in MALWARE_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 30;
|
||||||
|
threat_type = Some("malware".into());
|
||||||
|
threat_details = Some("Text contains potential malware indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pat in SENSITIVE_DATA_PATTERNS.iter() {
|
||||||
|
if pat.is_match(text) {
|
||||||
|
score += 25;
|
||||||
|
if threat_type.is_none() {
|
||||||
|
threat_type = Some("sensitive_data".into());
|
||||||
|
threat_details = Some("Text contains potentially sensitive data patterns".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML body scanning ────────────────────────────────────────────
|
||||||
|
if let Some(html) = html_body {
|
||||||
|
scanned.push("html".into());
|
||||||
|
|
||||||
|
// Script injection check
|
||||||
|
for pat in SCRIPT_INJECTION_PATTERNS.iter() {
|
||||||
|
if pat.is_match(html) {
|
||||||
|
score += 40;
|
||||||
|
if threat_type.as_deref() != Some("xss") {
|
||||||
|
threat_type = Some("xss".into());
|
||||||
|
threat_details = Some("HTML contains potentially malicious script content".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from HTML and scan (half score to avoid double counting)
|
||||||
|
let text_content = extract_text_from_html(html);
|
||||||
|
if !text_content.is_empty() {
|
||||||
|
let mut html_text_score: u32 = 0;
|
||||||
|
let mut html_text_type: Option<String> = None;
|
||||||
|
let mut html_text_details: Option<String> = None;
|
||||||
|
|
||||||
|
// Re-run text patterns on extracted HTML text
|
||||||
|
for pat in SUSPICIOUS_LINK_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 20;
|
||||||
|
html_text_type = Some("suspicious_link".into());
|
||||||
|
html_text_details = Some("Text contains suspicious links".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in PHISHING_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 25;
|
||||||
|
html_text_type = Some("phishing".into());
|
||||||
|
html_text_details = Some("Text contains potential phishing indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in SPAM_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 15;
|
||||||
|
if html_text_type.is_none() {
|
||||||
|
html_text_type = Some("spam".into());
|
||||||
|
html_text_details = Some("Text contains potential spam indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in MALWARE_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 30;
|
||||||
|
html_text_type = Some("malware".into());
|
||||||
|
html_text_details = Some("Text contains potential malware indicators".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pat in SENSITIVE_DATA_PATTERNS.iter() {
|
||||||
|
if pat.is_match(&text_content) {
|
||||||
|
html_text_score += 25;
|
||||||
|
if html_text_type.is_none() {
|
||||||
|
html_text_type = Some("sensitive_data".into());
|
||||||
|
html_text_details = Some("Text contains potentially sensitive data patterns".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if html_text_score > 0 {
|
||||||
|
// Add half of the text content score to avoid double counting
|
||||||
|
score += html_text_score / 2;
|
||||||
|
if let Some(t) = html_text_type {
|
||||||
|
if threat_type.is_none() || html_text_score > score {
|
||||||
|
threat_type = Some(t);
|
||||||
|
threat_details = html_text_details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and check links from HTML
|
||||||
|
let links = extract_links_from_html(html);
|
||||||
|
if !links.is_empty() {
|
||||||
|
let mut suspicious_count = 0u32;
|
||||||
|
for link in &links {
|
||||||
|
if matches_any(link, &SUSPICIOUS_LINK_PATTERNS) {
|
||||||
|
suspicious_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if suspicious_count > 0 {
|
||||||
|
let pct = (suspicious_count as f64 / links.len() as f64) * 100.0;
|
||||||
|
let additional = std::cmp::min(40, (pct / 2.5) as u32);
|
||||||
|
score += additional;
|
||||||
|
|
||||||
|
if additional > 20 || threat_type.is_none() {
|
||||||
|
threat_type = Some("suspicious_link".into());
|
||||||
|
threat_details = Some(format!(
|
||||||
|
"HTML contains {} suspicious links out of {} total links",
|
||||||
|
suspicious_count,
|
||||||
|
links.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachment filename scanning ──────────────────────────────────
|
||||||
|
for name in attachment_names {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
scanned.push(format!("attachment:{}", lower));
|
||||||
|
|
||||||
|
// Check executable extensions
|
||||||
|
for ext in EXECUTABLE_EXTENSIONS.iter() {
|
||||||
|
if lower.ends_with(ext) {
|
||||||
|
score += 70;
|
||||||
|
threat_type = Some("executable".into());
|
||||||
|
threat_details = Some(format!(
|
||||||
|
"Attachment has a potentially dangerous extension: {}",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check macro document extensions
|
||||||
|
for ext in MACRO_DOCUMENT_EXTENSIONS.iter() {
|
||||||
|
if lower.ends_with(ext) {
|
||||||
|
// Flag macro-capable documents (lower score than executables)
|
||||||
|
score += 20;
|
||||||
|
if threat_type.is_none() {
|
||||||
|
threat_type = Some("malicious_macro".into());
|
||||||
|
threat_details = Some(format!(
|
||||||
|
"Attachment is a macro-capable document: {}",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentScanResult {
|
||||||
|
threat_score: score,
|
||||||
|
threat_type,
|
||||||
|
threat_details,
|
||||||
|
scanned_elements: scanned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_content() {
|
||||||
|
let result = scan_content(
|
||||||
|
Some("Project Update"),
|
||||||
|
Some("The project is on track."),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(result.threat_score, 0);
|
||||||
|
assert!(result.threat_type.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_phishing_subject() {
|
||||||
|
let result = scan_content(
|
||||||
|
Some("URGENT: Verify your bank account details immediately"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 25);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("phishing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_spam_body() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
Some("Win a million dollars in the lottery winner contest!"),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 15);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("spam"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_suspicious_links() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
Some("Check out https://bit.ly/2x3F5 for more info"),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 20);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("suspicious_link"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_script_injection() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("<p>Hello</p><script>document.cookie='steal';</script>"),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 40);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("xss"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_executable_attachment() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&["update.exe".into()],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 70);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("executable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_macro_document() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&["report.docm".into()],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 20);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("malicious_macro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_malware_indicators() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
Some("Please enable macros to view this document properly."),
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score >= 30);
|
||||||
|
assert_eq!(result.threat_type.as_deref(), Some("malware"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_html_link_extraction() {
|
||||||
|
let result = scan_content(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(r#"<a href="https://bit.ly/abc">click</a> and <a href="https://t.co/xyz">here</a>"#),
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compound_threats() {
|
||||||
|
let result = scan_content(
|
||||||
|
Some("URGENT: Verify your account details immediately"),
|
||||||
|
Some("Your account will be suspended unless you verify at https://bit.ly/2x3F5"),
|
||||||
|
Some(r#"<a href="https://bit.ly/2x3F5">verify</a>"#),
|
||||||
|
&["verification.exe".into()],
|
||||||
|
);
|
||||||
|
assert!(result.threat_score > 70);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
|
//! mailer-security: DKIM, SPF, DMARC verification, and IP reputation checking.
|
||||||
|
|
||||||
|
pub mod content_scanner;
|
||||||
pub mod dkim;
|
pub mod dkim;
|
||||||
pub mod dmarc;
|
pub mod dmarc;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { BounceManager, BounceType, BounceCategory } from '../ts/mail/core/classes.bouncemanager.js';
|
import { BounceManager, BounceType, BounceCategory } from '../ts/mail/core/classes.bouncemanager.js';
|
||||||
import { Email } from '../ts/mail/core/classes.email.js';
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
tap.test('setup - start Rust security bridge', async () => {
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
const ok = await bridge.start();
|
||||||
|
expect(ok).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test the BounceManager class
|
* Test the BounceManager class
|
||||||
@@ -189,6 +196,10 @@ tap.test('BounceManager - should handle retries for soft bounces', async () => {
|
|||||||
expect(info.expiresAt).toBeUndefined(); // Permanent
|
expect(info.expiresAt).toBeUndefined(); // Permanent
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||||
|
await RustSecurityBridge.getInstance().stop();
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
tap.test('stop', async () => {
|
||||||
await tap.stopForcefully();
|
await tap.stopForcefully();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
import { ContentScanner, ThreatCategory } from '../ts/security/classes.contentscanner.js';
|
||||||
import { Email } from '../ts/mail/core/classes.email.js';
|
import { Email } from '../ts/mail/core/classes.email.js';
|
||||||
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
|
tap.test('setup - start Rust security bridge', async () => {
|
||||||
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
|
const ok = await bridge.start();
|
||||||
|
expect(ok).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Test instantiation
|
// Test instantiation
|
||||||
tap.test('ContentScanner - should be instantiable', async () => {
|
tap.test('ContentScanner - should be instantiable', async () => {
|
||||||
@@ -258,6 +265,10 @@ tap.test('ContentScanner - should classify threat levels correctly', async () =>
|
|||||||
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
|
expect(ContentScanner.getThreatLevel(80)).toEqual('high');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||||
|
await RustSecurityBridge.getInstance().stop();
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
tap.test('stop', async () => {
|
||||||
await tap.stopForcefully();
|
await tap.stopForcefully();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '2.0.1',
|
version: '2.1.0',
|
||||||
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
description: 'A high-performance, enterprise-grade Mail Transfer Agent (MTA) built from scratch in TypeScript with Rust acceleration.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
|
|||||||
import * as paths from '../../paths.js';
|
import * as paths from '../../paths.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import type { Email } from './classes.email.js';
|
import type { Email } from './classes.email.js';
|
||||||
|
|
||||||
@@ -63,112 +64,6 @@ export interface BounceRecord {
|
|||||||
nextRetryTime?: number;
|
nextRetryTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Email bounce patterns to identify bounce types in SMTP responses and bounce messages
|
|
||||||
*/
|
|
||||||
const BOUNCE_PATTERNS = {
|
|
||||||
// Hard bounce patterns
|
|
||||||
[BounceType.INVALID_RECIPIENT]: [
|
|
||||||
/no such user/i,
|
|
||||||
/user unknown/i,
|
|
||||||
/does not exist/i,
|
|
||||||
/invalid recipient/i,
|
|
||||||
/unknown recipient/i,
|
|
||||||
/no mailbox/i,
|
|
||||||
/user not found/i,
|
|
||||||
/recipient address rejected/i,
|
|
||||||
/550 5\.1\.1/i
|
|
||||||
],
|
|
||||||
[BounceType.DOMAIN_NOT_FOUND]: [
|
|
||||||
/domain not found/i,
|
|
||||||
/unknown domain/i,
|
|
||||||
/no such domain/i,
|
|
||||||
/host not found/i,
|
|
||||||
/domain invalid/i,
|
|
||||||
/550 5\.1\.2/i
|
|
||||||
],
|
|
||||||
[BounceType.MAILBOX_FULL]: [
|
|
||||||
/mailbox full/i,
|
|
||||||
/over quota/i,
|
|
||||||
/quota exceeded/i,
|
|
||||||
/552 5\.2\.2/i
|
|
||||||
],
|
|
||||||
[BounceType.MAILBOX_INACTIVE]: [
|
|
||||||
/mailbox disabled/i,
|
|
||||||
/mailbox inactive/i,
|
|
||||||
/account disabled/i,
|
|
||||||
/mailbox not active/i,
|
|
||||||
/account suspended/i
|
|
||||||
],
|
|
||||||
[BounceType.BLOCKED]: [
|
|
||||||
/blocked/i,
|
|
||||||
/rejected/i,
|
|
||||||
/denied/i,
|
|
||||||
/blacklisted/i,
|
|
||||||
/prohibited/i,
|
|
||||||
/refused/i,
|
|
||||||
/550 5\.7\./i
|
|
||||||
],
|
|
||||||
[BounceType.SPAM_RELATED]: [
|
|
||||||
/spam/i,
|
|
||||||
/bulk mail/i,
|
|
||||||
/content rejected/i,
|
|
||||||
/message rejected/i,
|
|
||||||
/550 5\.7\.1/i
|
|
||||||
],
|
|
||||||
|
|
||||||
// Soft bounce patterns
|
|
||||||
[BounceType.SERVER_UNAVAILABLE]: [
|
|
||||||
/server unavailable/i,
|
|
||||||
/service unavailable/i,
|
|
||||||
/try again later/i,
|
|
||||||
/try later/i,
|
|
||||||
/451 4\.3\./i,
|
|
||||||
/421 4\.3\./i
|
|
||||||
],
|
|
||||||
[BounceType.TEMPORARY_FAILURE]: [
|
|
||||||
/temporary failure/i,
|
|
||||||
/temporary error/i,
|
|
||||||
/temporary problem/i,
|
|
||||||
/try again/i,
|
|
||||||
/451 4\./i
|
|
||||||
],
|
|
||||||
[BounceType.QUOTA_EXCEEDED]: [
|
|
||||||
/quota temporarily exceeded/i,
|
|
||||||
/mailbox temporarily full/i,
|
|
||||||
/452 4\.2\.2/i
|
|
||||||
],
|
|
||||||
[BounceType.NETWORK_ERROR]: [
|
|
||||||
/network error/i,
|
|
||||||
/connection error/i,
|
|
||||||
/connection timed out/i,
|
|
||||||
/routing error/i,
|
|
||||||
/421 4\.4\./i
|
|
||||||
],
|
|
||||||
[BounceType.TIMEOUT]: [
|
|
||||||
/timed out/i,
|
|
||||||
/timeout/i,
|
|
||||||
/450 4\.4\.2/i
|
|
||||||
],
|
|
||||||
|
|
||||||
// Auto-responses
|
|
||||||
[BounceType.AUTO_RESPONSE]: [
|
|
||||||
/auto[- ]reply/i,
|
|
||||||
/auto[- ]response/i,
|
|
||||||
/vacation/i,
|
|
||||||
/out of office/i,
|
|
||||||
/away from office/i,
|
|
||||||
/on vacation/i,
|
|
||||||
/automatic reply/i
|
|
||||||
],
|
|
||||||
[BounceType.CHALLENGE_RESPONSE]: [
|
|
||||||
/challenge[- ]response/i,
|
|
||||||
/verify your email/i,
|
|
||||||
/confirm your email/i,
|
|
||||||
/email verification/i
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry strategy configuration for soft bounces
|
* Retry strategy configuration for soft bounces
|
||||||
*/
|
*/
|
||||||
@@ -269,16 +164,16 @@ export class BounceManager {
|
|||||||
nextRetryTime: bounceData.nextRetryTime
|
nextRetryTime: bounceData.nextRetryTime
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine bounce type and category if not provided
|
// Determine bounce type and category via Rust bridge if not provided
|
||||||
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
|
if (!bounceData.bounceType || bounceData.bounceType === BounceType.UNKNOWN) {
|
||||||
const bounceInfo = this.detectBounceType(
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
bounce.smtpResponse || '',
|
const rustResult = await bridge.detectBounce({
|
||||||
bounce.diagnosticCode || '',
|
smtpResponse: bounce.smtpResponse,
|
||||||
bounce.statusCode || ''
|
diagnosticCode: bounce.diagnosticCode,
|
||||||
);
|
statusCode: bounce.statusCode,
|
||||||
|
});
|
||||||
bounce.bounceType = bounceInfo.type;
|
bounce.bounceType = rustResult.bounce_type as BounceType;
|
||||||
bounce.bounceCategory = bounceInfo.category;
|
bounce.bounceCategory = rustResult.category as BounceCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the bounce based on category
|
// Process the bounce based on category
|
||||||
@@ -791,134 +686,6 @@ export class BounceManager {
|
|||||||
return this.bounceCache.get(email.toLowerCase()) || null;
|
return this.bounceCache.get(email.toLowerCase()) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze SMTP response and diagnostic codes to determine bounce type
|
|
||||||
* @param smtpResponse SMTP response string
|
|
||||||
* @param diagnosticCode Diagnostic code from bounce
|
|
||||||
* @param statusCode Status code from bounce
|
|
||||||
* @returns Detected bounce type and category
|
|
||||||
*/
|
|
||||||
private detectBounceType(
|
|
||||||
smtpResponse: string,
|
|
||||||
diagnosticCode: string,
|
|
||||||
statusCode: string
|
|
||||||
): {
|
|
||||||
type: BounceType;
|
|
||||||
category: BounceCategory;
|
|
||||||
} {
|
|
||||||
// Combine all text for comprehensive pattern matching
|
|
||||||
const fullText = `${smtpResponse} ${diagnosticCode} ${statusCode}`.toLowerCase();
|
|
||||||
|
|
||||||
// Check for auto-responses first
|
|
||||||
if (this.matchesPattern(fullText, BounceType.AUTO_RESPONSE) ||
|
|
||||||
this.matchesPattern(fullText, BounceType.CHALLENGE_RESPONSE)) {
|
|
||||||
return {
|
|
||||||
type: BounceType.AUTO_RESPONSE,
|
|
||||||
category: BounceCategory.AUTO_RESPONSE
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for hard bounces
|
|
||||||
for (const bounceType of [
|
|
||||||
BounceType.INVALID_RECIPIENT,
|
|
||||||
BounceType.DOMAIN_NOT_FOUND,
|
|
||||||
BounceType.MAILBOX_FULL,
|
|
||||||
BounceType.MAILBOX_INACTIVE,
|
|
||||||
BounceType.BLOCKED,
|
|
||||||
BounceType.SPAM_RELATED,
|
|
||||||
BounceType.POLICY_RELATED
|
|
||||||
]) {
|
|
||||||
if (this.matchesPattern(fullText, bounceType)) {
|
|
||||||
return {
|
|
||||||
type: bounceType,
|
|
||||||
category: BounceCategory.HARD
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for soft bounces
|
|
||||||
for (const bounceType of [
|
|
||||||
BounceType.SERVER_UNAVAILABLE,
|
|
||||||
BounceType.TEMPORARY_FAILURE,
|
|
||||||
BounceType.QUOTA_EXCEEDED,
|
|
||||||
BounceType.NETWORK_ERROR,
|
|
||||||
BounceType.TIMEOUT
|
|
||||||
]) {
|
|
||||||
if (this.matchesPattern(fullText, bounceType)) {
|
|
||||||
return {
|
|
||||||
type: bounceType,
|
|
||||||
category: BounceCategory.SOFT
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle DSN (Delivery Status Notification) status codes
|
|
||||||
if (statusCode) {
|
|
||||||
// Format: class.subject.detail
|
|
||||||
const parts = statusCode.split('.');
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
const statusClass = parts[0];
|
|
||||||
const statusSubject = parts[1];
|
|
||||||
|
|
||||||
// 5.X.X is permanent failure (hard bounce)
|
|
||||||
if (statusClass === '5') {
|
|
||||||
// Try to determine specific type based on subject
|
|
||||||
if (statusSubject === '1') {
|
|
||||||
return { type: BounceType.INVALID_RECIPIENT, category: BounceCategory.HARD };
|
|
||||||
} else if (statusSubject === '2') {
|
|
||||||
return { type: BounceType.MAILBOX_FULL, category: BounceCategory.HARD };
|
|
||||||
} else if (statusSubject === '7') {
|
|
||||||
return { type: BounceType.BLOCKED, category: BounceCategory.HARD };
|
|
||||||
} else {
|
|
||||||
return { type: BounceType.UNKNOWN, category: BounceCategory.HARD };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4.X.X is temporary failure (soft bounce)
|
|
||||||
if (statusClass === '4') {
|
|
||||||
// Try to determine specific type based on subject
|
|
||||||
if (statusSubject === '2') {
|
|
||||||
return { type: BounceType.QUOTA_EXCEEDED, category: BounceCategory.SOFT };
|
|
||||||
} else if (statusSubject === '3') {
|
|
||||||
return { type: BounceType.SERVER_UNAVAILABLE, category: BounceCategory.SOFT };
|
|
||||||
} else if (statusSubject === '4') {
|
|
||||||
return { type: BounceType.NETWORK_ERROR, category: BounceCategory.SOFT };
|
|
||||||
} else {
|
|
||||||
return { type: BounceType.TEMPORARY_FAILURE, category: BounceCategory.SOFT };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to unknown
|
|
||||||
return {
|
|
||||||
type: BounceType.UNKNOWN,
|
|
||||||
category: BounceCategory.UNKNOWN
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if text matches any pattern for a bounce type
|
|
||||||
* @param text Text to check against patterns
|
|
||||||
* @param bounceType Bounce type to get patterns for
|
|
||||||
* @returns Whether the text matches any pattern
|
|
||||||
*/
|
|
||||||
private matchesPattern(text: string, bounceType: BounceType): boolean {
|
|
||||||
const patterns = BOUNCE_PATTERNS[bounceType];
|
|
||||||
|
|
||||||
if (!patterns) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
if (pattern.test(text)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all known hard bounced addresses
|
* Get all known hard bounced addresses
|
||||||
* @returns Array of hard bounced email addresses
|
* @returns Array of hard bounced email addresses
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { logger } from '../logger.js';
|
|||||||
import { Email } from '../mail/core/classes.email.js';
|
import { Email } from '../mail/core/classes.email.js';
|
||||||
import type { IAttachment } from '../mail/core/classes.email.js';
|
import type { IAttachment } from '../mail/core/classes.email.js';
|
||||||
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from './classes.securitylogger.js';
|
||||||
|
import { RustSecurityBridge } from './classes.rustsecuritybridge.js';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,75 +66,6 @@ export class ContentScanner {
|
|||||||
private scanCache: LRUCache<string, IScanResult>;
|
private scanCache: LRUCache<string, IScanResult>;
|
||||||
private options: Required<IContentScannerOptions>;
|
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', '.ts', '.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
|
* Default options for the content scanner
|
||||||
*/
|
*/
|
||||||
@@ -185,7 +117,9 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan an email for malicious content
|
* 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.
|
||||||
* @param email The email to scan
|
* @param email The email to scan
|
||||||
* @returns Scan result
|
* @returns Scan result
|
||||||
*/
|
*/
|
||||||
@@ -201,42 +135,35 @@ export class ContentScanner {
|
|||||||
return cachedResult;
|
return cachedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize scan result
|
// 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) ?? []
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
const result: IScanResult = {
|
const result: IScanResult = {
|
||||||
isClean: true,
|
isClean: true,
|
||||||
threatScore: 0,
|
threatScore: rustResult.threatScore,
|
||||||
scannedElements: [],
|
threatType: rustResult.threatType ?? undefined,
|
||||||
timestamp: Date.now()
|
threatDetails: rustResult.threatDetails ?? undefined,
|
||||||
|
scannedElements: rustResult.scannedElements,
|
||||||
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// List of scan promises
|
// Attachment binary scanning stays in TS (PE headers, macro detection)
|
||||||
const scanPromises: Array<Promise<void>> = [];
|
if (this.options.scanAttachments && email.attachments?.length > 0) {
|
||||||
|
|
||||||
// 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) {
|
for (const attachment of email.attachments) {
|
||||||
scanPromises.push(this.scanAttachment(attachment, result));
|
this.scanAttachmentBinary(attachment, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run all scans in parallel
|
// Apply custom rules (TS-only, runtime-configured)
|
||||||
await Promise.all(scanPromises);
|
this.applyCustomRules(email, result);
|
||||||
|
|
||||||
// Determine if the email is clean based on threat score
|
// Determine if the email is clean based on threat score
|
||||||
result.isClean = result.threatScore < this.options.minThreatScore;
|
result.isClean = result.threatScore < this.options.minThreatScore;
|
||||||
@@ -260,7 +187,7 @@ export class ContentScanner {
|
|||||||
|
|
||||||
// Return a safe default with error indication
|
// Return a safe default with error indication
|
||||||
return {
|
return {
|
||||||
isClean: true, // Let it pass if scanner fails (configure as desired)
|
isClean: true,
|
||||||
threatScore: 0,
|
threatScore: 0,
|
||||||
scannedElements: ['error'],
|
scannedElements: ['error'],
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -294,314 +221,68 @@ export class ContentScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan email subject for threats
|
* Scan attachment binary content for PE headers and VBA macros.
|
||||||
* @param subject The subject to scan
|
* This stays in TS because it accesses raw Buffer data (too large for IPC).
|
||||||
* @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 attachment The attachment to scan
|
||||||
* @param result The scan result to update
|
* @param result The scan result to update
|
||||||
*/
|
*/
|
||||||
private async scanAttachment(attachment: IAttachment, result: IScanResult): Promise<void> {
|
private scanAttachmentBinary(attachment: IAttachment, result: IScanResult): void {
|
||||||
const filename = attachment.filename.toLowerCase();
|
if (!attachment.content) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check filename for executable extensions
|
// Skip large attachments
|
||||||
if (this.options.blockExecutables) {
|
if (attachment.content.length > this.options.maxAttachmentSizeToScan) {
|
||||||
for (const ext of ContentScanner.EXECUTABLE_EXTENSIONS) {
|
return;
|
||||||
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
|
const filename = attachment.filename.toLowerCase();
|
||||||
if (this.options.blockMacros) {
|
|
||||||
for (const ext of ContentScanner.MACRO_DOCUMENT_EXTENSIONS) {
|
// Check for PE headers (Windows executables disguised with non-.exe extensions)
|
||||||
if (filename.endsWith(ext)) {
|
if (attachment.content.length > 64 &&
|
||||||
// For Office documents, check if they contain macros
|
attachment.content[0] === 0x4D &&
|
||||||
// This is a simplified check - a real implementation would use specialized libraries
|
attachment.content[1] === 0x5A) { // 'MZ' header
|
||||||
// to detect macros in Office documents
|
result.threatScore += 80;
|
||||||
if (attachment.content && this.likelyContainsMacros(attachment)) {
|
result.threatType = ThreatCategory.EXECUTABLE;
|
||||||
result.threatScore += 60;
|
result.threatDetails = `Attachment contains executable code: ${filename}`;
|
||||||
result.threatType = ThreatCategory.MALICIOUS_MACRO;
|
return;
|
||||||
result.threatDetails = `Attachment appears to contain macros: ${filename}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform basic content analysis if we have content buffer
|
// Check for VBA macro indicators in Office documents
|
||||||
if (attachment.content) {
|
if (this.options.blockMacros && this.likelyContainsMacros(attachment)) {
|
||||||
// Convert to string for scanning, with a limit to prevent memory issues
|
result.threatScore += 60;
|
||||||
const textContent = this.extractTextFromBuffer(attachment.content);
|
result.threatType = ThreatCategory.MALICIOUS_MACRO;
|
||||||
|
result.threatDetails = `Attachment appears to contain macros: ${filename}`;
|
||||||
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
|
* Apply custom rules (runtime-configured patterns) to the email.
|
||||||
* @param html HTML content
|
* These stay in TS because they are configured at runtime.
|
||||||
* @returns Array of extracted links
|
* @param email The email to check
|
||||||
|
* @param result The scan result to update
|
||||||
*/
|
*/
|
||||||
private extractLinksFromHtml(html: string): string[] {
|
private applyCustomRules(email: Email, result: IScanResult): void {
|
||||||
const links: string[] = [];
|
if (!this.options.customRules.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Simple regex-based extraction - a real implementation might use a proper HTML parser
|
const textsToCheck: string[] = [];
|
||||||
const matches = html.match(/href=["'](https?:\/\/[^"']+)["']/gi);
|
if (email.subject) textsToCheck.push(email.subject);
|
||||||
if (matches) {
|
if (email.text) textsToCheck.push(email.text);
|
||||||
for (const match of matches) {
|
if (email.html) textsToCheck.push(email.html);
|
||||||
const linkMatch = match.match(/href=["'](https?:\/\/[^"']+)["']/i);
|
|
||||||
if (linkMatch && linkMatch[1]) {
|
for (const rule of this.options.customRules) {
|
||||||
links.push(linkMatch[1]);
|
const pattern = rule.pattern instanceof RegExp ? rule.pattern : new RegExp(rule.pattern, 'i');
|
||||||
|
for (const text of textsToCheck) {
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
result.threatScore += rule.score;
|
||||||
|
result.threatType = rule.type;
|
||||||
|
result.threatDetails = rule.description;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -627,13 +308,10 @@ export class ContentScanner {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an Office document likely contains macros
|
* 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
|
* @param attachment The attachment to check
|
||||||
* @returns Whether the file likely contains macros
|
* @returns Whether the file likely contains macros
|
||||||
*/
|
*/
|
||||||
private likelyContainsMacros(attachment: IAttachment): boolean {
|
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 content = this.extractTextFromBuffer(attachment.content);
|
||||||
const macroIndicators = [
|
const macroIndicators = [
|
||||||
/vbaProject\.bin/i,
|
/vbaProject\.bin/i,
|
||||||
@@ -657,23 +335,6 @@ export class ContentScanner {
|
|||||||
return false;
|
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
|
* Log a high threat finding to the security logger
|
||||||
* @param email The email containing the threat
|
* @param email The email containing the threat
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ interface IReputationResult {
|
|||||||
total_checked: number;
|
total_checked: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IContentScanResult {
|
||||||
|
threatScore: number;
|
||||||
|
threatType: string | null;
|
||||||
|
threatDetails: string | null;
|
||||||
|
scannedElements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface IVersionInfo {
|
interface IVersionInfo {
|
||||||
bin: string;
|
bin: string;
|
||||||
core: string;
|
core: string;
|
||||||
@@ -102,6 +109,15 @@ type TMailerCommands = {
|
|||||||
params: { ip: string; heloDomain: string; hostname?: string; mailFrom: string };
|
params: { ip: string; heloDomain: string; hostname?: string; mailFrom: string };
|
||||||
result: ISpfResult;
|
result: ISpfResult;
|
||||||
};
|
};
|
||||||
|
scanContent: {
|
||||||
|
params: {
|
||||||
|
subject?: string;
|
||||||
|
textBody?: string;
|
||||||
|
htmlBody?: string;
|
||||||
|
attachmentNames?: string[];
|
||||||
|
};
|
||||||
|
result: IContentScanResult;
|
||||||
|
};
|
||||||
verifyEmail: {
|
verifyEmail: {
|
||||||
params: {
|
params: {
|
||||||
rawMessage: string;
|
rawMessage: string;
|
||||||
@@ -243,6 +259,16 @@ export class RustSecurityBridge {
|
|||||||
return this.bridge.sendCommand('detectBounce', opts);
|
return this.bridge.sendCommand('detectBounce', opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Scan email content for threats (phishing, spam, malware, etc.). */
|
||||||
|
public async scanContent(opts: {
|
||||||
|
subject?: string;
|
||||||
|
textBody?: string;
|
||||||
|
htmlBody?: string;
|
||||||
|
attachmentNames?: string[];
|
||||||
|
}): Promise<IContentScanResult> {
|
||||||
|
return this.bridge.sendCommand('scanContent', opts);
|
||||||
|
}
|
||||||
|
|
||||||
/** Check IP reputation via DNSBL. */
|
/** Check IP reputation via DNSBL. */
|
||||||
public async checkIpReputation(ip: string): Promise<IReputationResult> {
|
public async checkIpReputation(ip: string): Promise<IReputationResult> {
|
||||||
return this.bridge.sendCommand('checkIpReputation', { ip });
|
return this.bridge.sendCommand('checkIpReputation', { ip });
|
||||||
@@ -298,6 +324,7 @@ export type {
|
|||||||
IEmailSecurityResult,
|
IEmailSecurityResult,
|
||||||
IValidationResult,
|
IValidationResult,
|
||||||
IBounceDetection,
|
IBounceDetection,
|
||||||
|
IContentScanResult,
|
||||||
IReputationResult as IRustReputationResult,
|
IReputationResult as IRustReputationResult,
|
||||||
IVersionInfo,
|
IVersionInfo,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user