BREAKING CHANGE(rust-bridge): make Rust the primary security backend, remove all TS fallbacks
Phase 3 of the Rust migration: the Rust security bridge is now mandatory and all TypeScript security fallback implementations have been removed. - UnifiedEmailServer.start() throws if Rust bridge fails to start - SpfVerifier gutted to thin wrapper (parseSpfRecord stays in TS) - DKIMVerifier gutted to thin wrapper delegating to bridge.verifyDkim() - IPReputationChecker delegates to bridge.checkIpReputation(), keeps LRU cache - DmarcVerifier keeps alignment logic (works with pre-computed results) - DKIM signing via bridge.signDkim() in all 4 locations - Removed mailauth and ip packages from plugins.ts (~1,200 lines deleted)
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartmta',
|
name: '@push.rocks/smartmta',
|
||||||
version: '2.0.0',
|
version: '2.0.1',
|
||||||
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.'
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiMDBfY29tbWl0aW5mb19kYXRhLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vdHMvMDBfY29tbWl0aW5mb19kYXRhLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOztHQUVHO0FBQ0gsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUFHO0lBQ3hCLElBQUksRUFBRSxzQkFBc0I7SUFDNUIsT0FBTyxFQUFFLE9BQU87SUFDaEIsV0FBVyxFQUFFLHlIQUF5SDtDQUN2SSxDQUFBIn0=
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
export class EmailSignJob {
|
export class EmailSignJob {
|
||||||
emailServerRef;
|
emailServerRef;
|
||||||
jobOptions;
|
jobOptions;
|
||||||
@@ -12,25 +13,14 @@ export class EmailSignJob {
|
|||||||
}
|
}
|
||||||
async getSignatureHeader(emailMessage) {
|
async getSignatureHeader(emailMessage) {
|
||||||
const privateKey = await this.loadPrivateKey();
|
const privateKey = await this.loadPrivateKey();
|
||||||
const signResult = await plugins.dkimSign(emailMessage, {
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
signingDomain: this.jobOptions.domain,
|
const signResult = await bridge.signDkim({
|
||||||
|
rawMessage: emailMessage,
|
||||||
|
domain: this.jobOptions.domain,
|
||||||
selector: this.jobOptions.selector,
|
selector: this.jobOptions.selector,
|
||||||
privateKey,
|
privateKey,
|
||||||
canonicalization: 'relaxed/relaxed',
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
signTime: new Date(),
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: this.jobOptions.domain,
|
|
||||||
selector: this.jobOptions.selector,
|
|
||||||
privateKey,
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
canonicalization: 'relaxed/relaxed',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const signature = signResult.signatures;
|
return signResult.header;
|
||||||
return signature;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFjNUMsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLFVBQVUsR0FBRyxNQUFNLE9BQU8sQ0FBQyxRQUFRLENBQUMsWUFBWSxFQUFFO1lBQ3RELGFBQWEsRUFBRSxJQUFJLENBQUMsVUFBVSxDQUFDLE1BQU07WUFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtZQUNsQyxVQUFVO1lBQ1YsZ0JBQWdCLEVBQUUsaUJBQWlCO1lBQ25DLFNBQVMsRUFBRSxZQUFZO1lBQ3ZCLFFBQVEsRUFBRSxJQUFJLElBQUksRUFBRTtZQUNwQixhQUFhLEVBQUU7Z0JBQ2I7b0JBQ0UsYUFBYSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtvQkFDckMsUUFBUSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsUUFBUTtvQkFDbEMsVUFBVTtvQkFDVixTQUFTLEVBQUUsWUFBWTtvQkFDdkIsZ0JBQWdCLEVBQUUsaUJBQWlCO2lCQUNwQzthQUNGO1NBQ0YsQ0FBQyxDQUFDO1FBQ0gsTUFBTSxTQUFTLEdBQUcsVUFBVSxDQUFDLFVBQVUsQ0FBQztRQUN4QyxPQUFPLFNBQVMsQ0FBQztJQUNuQixDQUFDO0NBQ0YifQ==
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5lbWFpbHNpZ25qb2IuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi90cy9tYWlsL2RlbGl2ZXJ5L2NsYXNzZXMuZW1haWxzaWduam9iLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sOENBQThDLENBQUM7QUFhbEYsTUFBTSxPQUFPLFlBQVk7SUFDdkIsY0FBYyxDQUFxQjtJQUNuQyxVQUFVLENBQXVCO0lBRWpDLFlBQVksY0FBa0MsRUFBRSxPQUE2QjtRQUMzRSxJQUFJLENBQUMsY0FBYyxHQUFHLGNBQWMsQ0FBQztRQUNyQyxJQUFJLENBQUMsVUFBVSxHQUFHLE9BQU8sQ0FBQztJQUM1QixDQUFDO0lBRUQsS0FBSyxDQUFDLGNBQWM7UUFDbEIsTUFBTSxPQUFPLEdBQUcsTUFBTSxJQUFJLENBQUMsY0FBYyxDQUFDLFdBQVcsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLFVBQVUsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMzRixPQUFPLE9BQU8sQ0FBQyxVQUFVLENBQUM7SUFDNUIsQ0FBQztJQUVNLEtBQUssQ0FBQyxrQkFBa0IsQ0FBQyxZQUFvQjtRQUNsRCxNQUFNLFVBQVUsR0FBRyxNQUFNLElBQUksQ0FBQyxjQUFjLEVBQUUsQ0FBQztRQUMvQyxNQUFNLE1BQU0sR0FBRyxrQkFBa0IsQ0FBQyxXQUFXLEVBQUUsQ0FBQztRQUNoRCxNQUFNLFVBQVUsR0FBRyxNQUFNLE1BQU0sQ0FBQyxRQUFRLENBQUM7WUFDdkMsVUFBVSxFQUFFLFlBQVk7WUFDeEIsTUFBTSxFQUFFLElBQUksQ0FBQyxVQUFVLENBQUMsTUFBTTtZQUM5QixRQUFRLEVBQUUsSUFBSSxDQUFDLFVBQVUsQ0FBQyxRQUFRO1lBQ2xDLFVBQVU7U0FDWCxDQUFDLENBQUM7UUFDSCxPQUFPLFVBQVUsQ0FBQyxNQUFNLENBQUM7SUFDM0IsQ0FBQztDQUNGIn0=
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
25
dist_ts/mail/security/classes.dkimverifier.d.ts
vendored
25
dist_ts/mail/security/classes.dkimverifier.d.ts
vendored
@@ -11,36 +11,19 @@ export interface IDkimVerificationResult {
|
|||||||
signatureFields?: Record<string, string>;
|
signatureFields?: Record<string, string>;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Enhanced DKIM verifier using smartmail capabilities
|
* DKIM verifier — delegates to the Rust security bridge.
|
||||||
*/
|
*/
|
||||||
export declare class DKIMVerifier {
|
export declare class DKIMVerifier {
|
||||||
private verificationCache;
|
|
||||||
private cacheTtl;
|
|
||||||
constructor();
|
constructor();
|
||||||
/**
|
/**
|
||||||
* Verify DKIM signature for an email
|
* Verify DKIM signature for an email via Rust bridge
|
||||||
* @param emailData The raw email data
|
|
||||||
* @param options Verification options
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
*/
|
||||||
verify(emailData: string, options?: {
|
verify(emailData: string, options?: {
|
||||||
useCache?: boolean;
|
useCache?: boolean;
|
||||||
returnDetails?: boolean;
|
returnDetails?: boolean;
|
||||||
}): Promise<IDkimVerificationResult>;
|
}): Promise<IDkimVerificationResult>;
|
||||||
/**
|
/** No-op — Rust bridge handles its own caching */
|
||||||
* Fetch DKIM public key from DNS
|
|
||||||
* @param domain The domain
|
|
||||||
* @param selector The DKIM selector
|
|
||||||
* @returns The DKIM public key or null if not found
|
|
||||||
*/
|
|
||||||
private fetchDkimKey;
|
|
||||||
/**
|
|
||||||
* Clear the verification cache
|
|
||||||
*/
|
|
||||||
clearCache(): void;
|
clearCache(): void;
|
||||||
/**
|
/** Always 0 — cache is managed by the Rust side */
|
||||||
* Get the size of the verification cache
|
|
||||||
* @returns Number of cached items
|
|
||||||
*/
|
|
||||||
getCacheSize(): number;
|
getCacheSize(): number;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
46
dist_ts/mail/security/classes.spfverifier.d.ts
vendored
46
dist_ts/mail/security/classes.spfverifier.d.ts
vendored
@@ -50,54 +50,22 @@ export interface SpfResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Class for verifying SPF records
|
* Class for verifying SPF records.
|
||||||
|
* Delegates actual SPF evaluation to the Rust security bridge.
|
||||||
|
* Retains parseSpfRecord() for lightweight local parsing.
|
||||||
*/
|
*/
|
||||||
export declare class SpfVerifier {
|
export declare class SpfVerifier {
|
||||||
private dnsManager?;
|
constructor(_dnsManager?: any);
|
||||||
private lookupCount;
|
|
||||||
constructor(dnsManager?: any);
|
|
||||||
/**
|
/**
|
||||||
* Parse SPF record from TXT record
|
* Parse SPF record from TXT record (pure string parsing, no DNS)
|
||||||
* @param record SPF TXT record
|
|
||||||
* @returns Parsed SPF record or null if invalid
|
|
||||||
*/
|
*/
|
||||||
parseSpfRecord(record: string): SpfRecord | null;
|
parseSpfRecord(record: string): SpfRecord | null;
|
||||||
/**
|
/**
|
||||||
* Check if IP is in CIDR range
|
* Verify SPF for a given email — delegates to Rust bridge
|
||||||
* @param ip IP address to check
|
|
||||||
* @param cidr CIDR range
|
|
||||||
* @returns Whether the IP is in the CIDR range
|
|
||||||
*/
|
|
||||||
private isIpInCidr;
|
|
||||||
/**
|
|
||||||
* Check if a domain has the specified IP in its A or AAAA records
|
|
||||||
* @param domain Domain to check
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns Whether the domain resolves to the IP
|
|
||||||
*/
|
|
||||||
private isDomainResolvingToIp;
|
|
||||||
/**
|
|
||||||
* Verify SPF for a given email with IP and helo domain
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param ip Sender IP address
|
|
||||||
* @param heloDomain HELO/EHLO domain used by sender
|
|
||||||
* @returns SPF verification result
|
|
||||||
*/
|
*/
|
||||||
verify(email: Email, ip: string, heloDomain: string): Promise<SpfResult>;
|
verify(email: Email, ip: string, heloDomain: string): Promise<SpfResult>;
|
||||||
/**
|
/**
|
||||||
* Check SPF record against IP address
|
* Check if email passes SPF verification and apply headers
|
||||||
* @param spfRecord Parsed SPF record
|
|
||||||
* @param domain Domain being checked
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns SPF result
|
|
||||||
*/
|
|
||||||
private checkSpfRecord;
|
|
||||||
/**
|
|
||||||
* Check if email passes SPF verification
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param ip Sender IP address
|
|
||||||
* @param heloDomain HELO/EHLO domain used by sender
|
|
||||||
* @returns Whether email passes SPF
|
|
||||||
*/
|
*/
|
||||||
verifyAndApply(email: Email, ip: string, heloDomain: string): Promise<boolean>;
|
verifyAndApply(email: Email, ip: string, heloDomain: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
5
dist_ts/plugins.d.ts
vendored
5
dist_ts/plugins.d.ts
vendored
@@ -42,9 +42,6 @@ import * as cloudflare from '@apiclient.xyz/cloudflare';
|
|||||||
export { cloudflare, };
|
export { cloudflare, };
|
||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
export { tsclass, };
|
export { tsclass, };
|
||||||
import * as mailauth from 'mailauth';
|
|
||||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
|
||||||
import mailparser from 'mailparser';
|
import mailparser from 'mailparser';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
import * as ip from 'ip';
|
export { mailparser, uuid, };
|
||||||
export { mailauth, dkimSign, mailparser, uuid, ip, };
|
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ export { cloudflare, };
|
|||||||
import * as tsclass from '@tsclass/tsclass';
|
import * as tsclass from '@tsclass/tsclass';
|
||||||
export { tsclass, };
|
export { tsclass, };
|
||||||
// third party
|
// third party
|
||||||
import * as mailauth from 'mailauth';
|
|
||||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
|
||||||
import mailparser from 'mailparser';
|
import mailparser from 'mailparser';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
import * as ip from 'ip';
|
export { mailparser, uuid, };
|
||||||
export { mailauth, dkimSign, mailparser, uuid, ip, };
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUV2RCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUFFLFdBQVcsRUFBRSxJQUFJLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxVQUFVLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLFlBQVksRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLdlAsc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sVUFBVSxNQUFNLFlBQVksQ0FBQztBQUNwQyxPQUFPLEtBQUssSUFBSSxNQUFNLE1BQU0sQ0FBQztBQUU3QixPQUFPLEVBQ0wsVUFBVSxFQUNWLElBQUksR0FDTCxDQUFBIn0=
|
||||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGx1Z2lucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3RzL3BsdWdpbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsY0FBYztBQUNkLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxNQUFNLE1BQU0sUUFBUSxDQUFDO0FBQ2pDLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxFQUFFLE1BQU0sSUFBSSxDQUFDO0FBQ3pCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBQzdCLE9BQU8sS0FBSyxHQUFHLE1BQU0sS0FBSyxDQUFDO0FBQzNCLE9BQU8sS0FBSyxJQUFJLE1BQU0sTUFBTSxDQUFDO0FBRTdCLE9BQU8sRUFDTCxHQUFHLEVBQ0gsRUFBRSxFQUNGLE1BQU0sRUFDTixJQUFJLEVBQ0osR0FBRyxFQUNILEVBQUUsRUFDRixJQUFJLEVBQ0osR0FBRyxFQUNILElBQUksR0FDTCxDQUFBO0FBRUQsb0JBQW9CO0FBQ3BCLE9BQU8sS0FBSyxtQkFBbUIsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQ0wsbUJBQW1CLEVBQ3BCLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssV0FBVyxNQUFNLHlCQUF5QixDQUFDO0FBQ3ZELE9BQU8sS0FBSyxXQUFXLE1BQU0seUJBQXlCLENBQUM7QUFFdkQsT0FBTyxFQUNMLFlBQVksRUFDWixXQUFXLEVBQ1gsV0FBVyxHQUNaLENBQUE7QUFFRCxvQkFBb0I7QUFDcEIsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUN2RCxPQUFPLEtBQUssSUFBSSxNQUFNLGtCQUFrQixDQUFDO0FBQ3pDLE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxFQUFFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ25FLE9BQU8sS0FBSyxVQUFVLE1BQU0sd0JBQXdCLENBQUM7QUFDckQsT0FBTyxLQUFLLFFBQVEsTUFBTSxzQkFBc0IsQ0FBQztBQUNqRCxPQUFPLEtBQUssUUFBUSxNQUFNLHNCQUFzQixDQUFDO0FBQ2pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFlBQVksTUFBTSwwQkFBMEIsQ0FBQztBQUN6RCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxTQUFTLE1BQU0sdUJBQXVCLENBQUM7QUFDbkQsT0FBTyxLQUFLLFVBQVUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRCxPQUFPLEtBQUssWUFBWSxNQUFNLDBCQUEwQixDQUFDO0FBQ3pELE9BQU8sS0FBSyxZQUFZLE1BQU0sMEJBQTBCLENBQUM7QUFDekQsT0FBTyxLQUFLLFNBQVMsTUFBTSx1QkFBdUIsQ0FBQztBQUNuRCxPQUFPLEtBQUssU0FBUyxNQUFNLHVCQUF1QixDQUFDO0FBQ25ELE9BQU8sS0FBSyxPQUFPLE1BQU0scUJBQXFCLENBQUM7QUFDL0MsT0FBTyxLQUFLLFdBQVcsTUFBTSx5QkFBeUIsQ0FBQztBQUV2RCxNQUFNLENBQUMsTUFBTSxPQUFPLEdBQUcsSUFBSSxPQUFPLENBQUMsSUFBSSxtQkFBbUIsRUFBRSxDQUFDLENBQUM7QUFFOUQsT0FBTyxFQUFFLFdBQVcsRUFBRSxJQUFJLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxVQUFVLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLFlBQVksRUFBRSxTQUFTLEVBQUUsVUFBVSxFQUFFLFlBQVksRUFBRSxZQUFZLEVBQUUsU0FBUyxFQUFFLFNBQVMsRUFBRSxPQUFPLEVBQUUsV0FBVyxFQUFFLENBQUM7QUFLdlAsc0JBQXNCO0FBQ3RCLE9BQU8sS0FBSyxVQUFVLE1BQU0sMkJBQTJCLENBQUM7QUFFeEQsT0FBTyxFQUNMLFVBQVUsR0FDWCxDQUFBO0FBRUQsZ0JBQWdCO0FBQ2hCLE9BQU8sS0FBSyxPQUFPLE1BQU0sa0JBQWtCLENBQUM7QUFFNUMsT0FBTyxFQUNMLE9BQU8sR0FDUixDQUFBO0FBRUQsY0FBYztBQUNkLE9BQU8sS0FBSyxRQUFRLE1BQU0sVUFBVSxDQUFDO0FBQ3JDLE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSwyQkFBMkIsQ0FBQztBQUNyRCxPQUFPLFVBQVUsTUFBTSxZQUFZLENBQUM7QUFDcEMsT0FBTyxLQUFLLElBQUksTUFBTSxNQUFNLENBQUM7QUFDN0IsT0FBTyxLQUFLLEVBQUUsTUFBTSxJQUFJLENBQUM7QUFFekIsT0FBTyxFQUNMLFFBQVEsRUFDUixRQUFRLEVBQ1IsVUFBVSxFQUNWLElBQUksRUFDSixFQUFFLEdBQ0gsQ0FBQSJ9
|
|
||||||
@@ -48,103 +48,26 @@ export interface IIPReputationOptions {
|
|||||||
enableIPInfo?: boolean;
|
enableIPInfo?: boolean;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Class for checking IP reputation of inbound email senders
|
* IP reputation checker — delegates DNSBL lookups to the Rust security bridge.
|
||||||
|
* Retains LRU caching and disk persistence in TypeScript.
|
||||||
*/
|
*/
|
||||||
export declare class IPReputationChecker {
|
export declare class IPReputationChecker {
|
||||||
private static instance;
|
private static instance;
|
||||||
private reputationCache;
|
private reputationCache;
|
||||||
private options;
|
private options;
|
||||||
private storageManager?;
|
private storageManager?;
|
||||||
private static readonly DEFAULT_DNSBL_SERVERS;
|
|
||||||
private static readonly DEFAULT_OPTIONS;
|
private static readonly DEFAULT_OPTIONS;
|
||||||
/**
|
|
||||||
* Constructor for IPReputationChecker
|
|
||||||
* @param options Configuration options
|
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
*/
|
|
||||||
constructor(options?: IIPReputationOptions, storageManager?: any);
|
constructor(options?: IIPReputationOptions, storageManager?: any);
|
||||||
/**
|
|
||||||
* Get the singleton instance of the checker
|
|
||||||
* @param options Configuration options
|
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
* @returns Singleton instance
|
|
||||||
*/
|
|
||||||
static getInstance(options?: IIPReputationOptions, storageManager?: any): IPReputationChecker;
|
static getInstance(options?: IIPReputationOptions, storageManager?: any): IPReputationChecker;
|
||||||
/**
|
/**
|
||||||
* Check an IP address's reputation
|
* Check an IP address's reputation via the Rust bridge
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns Reputation check result
|
|
||||||
*/
|
*/
|
||||||
checkReputation(ip: string): Promise<IReputationResult>;
|
checkReputation(ip: string): Promise<IReputationResult>;
|
||||||
/**
|
|
||||||
* Check an IP against DNS blacklists
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns DNSBL check results
|
|
||||||
*/
|
|
||||||
private checkDNSBL;
|
|
||||||
/**
|
|
||||||
* Get information about an IP address
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns IP information
|
|
||||||
*/
|
|
||||||
private getIPInfo;
|
|
||||||
/**
|
|
||||||
* Simplified method to determine country from IP
|
|
||||||
* In a real implementation, this would use a geolocation database or service
|
|
||||||
* @param ip IP address
|
|
||||||
* @returns Country code
|
|
||||||
*/
|
|
||||||
private determineCountry;
|
|
||||||
/**
|
|
||||||
* Simplified method to determine organization from IP
|
|
||||||
* In a real implementation, this would use an IP-to-org database or service
|
|
||||||
* @param ip IP address
|
|
||||||
* @returns Organization name
|
|
||||||
*/
|
|
||||||
private determineOrg;
|
|
||||||
/**
|
|
||||||
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
|
||||||
* @param ip IP address to reverse
|
|
||||||
* @returns Reversed IP for DNSBL queries
|
|
||||||
*/
|
|
||||||
private reverseIP;
|
|
||||||
/**
|
|
||||||
* Create an error result for when reputation check fails
|
|
||||||
* @param ip IP address
|
|
||||||
* @param errorMessage Error message
|
|
||||||
* @returns Error result
|
|
||||||
*/
|
|
||||||
private createErrorResult;
|
private createErrorResult;
|
||||||
/**
|
|
||||||
* Validate IP address format
|
|
||||||
* @param ip IP address to validate
|
|
||||||
* @returns Whether the IP is valid
|
|
||||||
*/
|
|
||||||
private isValidIPAddress;
|
private isValidIPAddress;
|
||||||
/**
|
|
||||||
* Log reputation check to security logger
|
|
||||||
* @param ip IP address
|
|
||||||
* @param result Reputation result
|
|
||||||
*/
|
|
||||||
private logReputationCheck;
|
private logReputationCheck;
|
||||||
/**
|
|
||||||
* Save cache to disk or storage manager
|
|
||||||
*/
|
|
||||||
private saveCache;
|
private saveCache;
|
||||||
/**
|
|
||||||
* Load cache from disk or storage manager
|
|
||||||
*/
|
|
||||||
private loadCache;
|
private loadCache;
|
||||||
/**
|
|
||||||
* Get the risk level for a reputation score
|
|
||||||
* @param score Reputation score (0-100)
|
|
||||||
* @returns Risk level description
|
|
||||||
*/
|
|
||||||
static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted';
|
static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted';
|
||||||
/**
|
|
||||||
* Update the storage manager after instantiation
|
|
||||||
* This is useful when the storage manager is not available at construction time
|
|
||||||
* @param storageManager The StorageManager instance to use
|
|
||||||
*/
|
|
||||||
updateStorageManager(storageManager: any): void;
|
updateStorageManager(storageManager: any): void;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,21 +1,19 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import { IPReputationChecker, ReputationThreshold, IPType } from '../ts/security/classes.ipreputationchecker.js';
|
import { IPReputationChecker, ReputationThreshold } from '../ts/security/classes.ipreputationchecker.js';
|
||||||
import * as plugins from '../ts/plugins.js';
|
import { RustSecurityBridge } from '../ts/security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
// Mock for dns lookup
|
let bridge: RustSecurityBridge;
|
||||||
const originalDnsResolve = plugins.dns.promises.resolve;
|
|
||||||
let mockDnsResolveImpl: (hostname: string) => Promise<string[]> = async () => ['127.0.0.1'];
|
|
||||||
|
|
||||||
// Setup mock DNS resolver with proper typing
|
// Start the Rust bridge before tests
|
||||||
(plugins.dns.promises as any).resolve = async (hostname: string) => {
|
tap.test('setup - start Rust security bridge', async () => {
|
||||||
return mockDnsResolveImpl(hostname);
|
bridge = RustSecurityBridge.getInstance();
|
||||||
};
|
const ok = await bridge.start();
|
||||||
|
expect(ok).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Test instantiation
|
// Test instantiation
|
||||||
tap.test('IPReputationChecker - should be instantiable', async () => {
|
tap.test('IPReputationChecker - should be instantiable', async () => {
|
||||||
const checker = IPReputationChecker.getInstance({
|
const checker = IPReputationChecker.getInstance({
|
||||||
enableDNSBL: false,
|
|
||||||
enableIPInfo: false,
|
|
||||||
enableLocalCache: false
|
enableLocalCache: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,82 +25,51 @@ tap.test('IPReputationChecker - should use singleton pattern', async () => {
|
|||||||
const checker1 = IPReputationChecker.getInstance();
|
const checker1 = IPReputationChecker.getInstance();
|
||||||
const checker2 = IPReputationChecker.getInstance();
|
const checker2 = IPReputationChecker.getInstance();
|
||||||
|
|
||||||
// Both instances should be the same object
|
|
||||||
expect(checker1 === checker2).toEqual(true);
|
expect(checker1 === checker2).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test IP validation
|
// Test IP validation
|
||||||
tap.test('IPReputationChecker - should validate IP address format', async () => {
|
tap.test('IPReputationChecker - should validate IP address format', async () => {
|
||||||
const checker = IPReputationChecker.getInstance({
|
const checker = IPReputationChecker.getInstance();
|
||||||
enableDNSBL: false,
|
|
||||||
enableIPInfo: false,
|
|
||||||
enableLocalCache: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Valid IP should work
|
|
||||||
const result = await checker.checkReputation('192.168.1.1');
|
|
||||||
expect(result.score).toBeGreaterThan(0);
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
|
|
||||||
// Invalid IP should fail with error
|
// Invalid IP should fail with error
|
||||||
const invalidResult = await checker.checkReputation('invalid.ip');
|
const invalidResult = await checker.checkReputation('invalid.ip');
|
||||||
expect(invalidResult.error).toBeTruthy();
|
expect(invalidResult.error).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test DNSBL lookups
|
// Test reputation check via Rust bridge
|
||||||
tap.test('IPReputationChecker - should check IP against DNSBL', async () => {
|
tap.test('IPReputationChecker - should check IP reputation via Rust', async () => {
|
||||||
try {
|
|
||||||
// Setup mock implementation for DNSBL
|
|
||||||
mockDnsResolveImpl = async (hostname: string) => {
|
|
||||||
// Listed in DNSBL if IP contains 2
|
|
||||||
if (hostname.includes('2.1.168.192') && hostname.includes('zen.spamhaus.org')) {
|
|
||||||
return ['127.0.0.2'];
|
|
||||||
}
|
|
||||||
throw { code: 'ENOTFOUND' };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create a new instance with specific settings for this test
|
|
||||||
const testInstance = new IPReputationChecker({
|
const testInstance = new IPReputationChecker({
|
||||||
dnsblServers: ['zen.spamhaus.org'],
|
|
||||||
enableIPInfo: false,
|
|
||||||
enableLocalCache: false,
|
enableLocalCache: false,
|
||||||
maxCacheSize: 1 // Small cache for testing
|
maxCacheSize: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean IP should have good score
|
// Check a public IP (Google DNS) — should get a result with a score
|
||||||
const cleanResult = await testInstance.checkReputation('192.168.1.1');
|
const result = await testInstance.checkReputation('8.8.8.8');
|
||||||
expect(cleanResult.isSpam).toEqual(false);
|
expect(result).toBeTruthy();
|
||||||
expect(cleanResult.score).toEqual(100);
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
expect(result.score).toBeLessThanOrEqual(100);
|
||||||
// Blacklisted IP should have reduced score
|
expect(typeof result.isSpam).toEqual('boolean');
|
||||||
const blacklistedResult = await testInstance.checkReputation('192.168.1.2');
|
expect(typeof result.isProxy).toEqual('boolean');
|
||||||
expect(blacklistedResult.isSpam).toEqual(true);
|
expect(typeof result.isTor).toEqual('boolean');
|
||||||
expect(blacklistedResult.score < 100).toEqual(true); // Less than 100
|
expect(typeof result.isVPN).toEqual('boolean');
|
||||||
expect(blacklistedResult.blacklists).toBeTruthy();
|
expect(result.timestamp).toBeGreaterThan(0);
|
||||||
expect((blacklistedResult.blacklists || []).length > 0).toEqual(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Test error:', err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test caching behavior
|
// Test caching behavior
|
||||||
tap.test('IPReputationChecker - should cache reputation results', async () => {
|
tap.test('IPReputationChecker - should cache reputation results', async () => {
|
||||||
// Create a fresh instance for this test
|
|
||||||
const testInstance = new IPReputationChecker({
|
const testInstance = new IPReputationChecker({
|
||||||
enableIPInfo: false,
|
|
||||||
enableLocalCache: false,
|
enableLocalCache: false,
|
||||||
maxCacheSize: 10 // Small cache for testing
|
maxCacheSize: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check that first look performs a lookup and second uses cache
|
const ip = '1.1.1.1';
|
||||||
const ip = '192.168.1.10';
|
|
||||||
|
|
||||||
// First check should add to cache
|
// First check should add to cache
|
||||||
const result1 = await testInstance.checkReputation(ip);
|
const result1 = await testInstance.checkReputation(ip);
|
||||||
expect(result1).toBeTruthy();
|
expect(result1).toBeTruthy();
|
||||||
|
|
||||||
// Manually verify it's in cache - access private member for testing
|
// Verify it's in cache
|
||||||
const hasInCache = (testInstance as any).reputationCache.has(ip);
|
const hasInCache = (testInstance as any).reputationCache.has(ip);
|
||||||
expect(hasInCache).toEqual(true);
|
expect(hasInCache).toEqual(true);
|
||||||
|
|
||||||
@@ -110,8 +77,9 @@ tap.test('IPReputationChecker - should cache reputation results', async () => {
|
|||||||
const result2 = await testInstance.checkReputation(ip);
|
const result2 = await testInstance.checkReputation(ip);
|
||||||
expect(result2).toBeTruthy();
|
expect(result2).toBeTruthy();
|
||||||
|
|
||||||
// Results should be identical
|
// Results should be identical (from cache)
|
||||||
expect(result1.score).toEqual(result2.score);
|
expect(result1.score).toEqual(result2.score);
|
||||||
|
expect(result1.isSpam).toEqual(result2.isSpam);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test risk level classification
|
// Test risk level classification
|
||||||
@@ -122,54 +90,23 @@ tap.test('IPReputationChecker - should classify risk levels correctly', async ()
|
|||||||
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
|
expect(IPReputationChecker.getRiskLevel(90)).toEqual('trusted');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test IP type detection
|
// Test error handling for error result
|
||||||
tap.test('IPReputationChecker - should detect special IP types', async () => {
|
tap.test('IPReputationChecker - should handle errors gracefully', async () => {
|
||||||
const testInstance = new IPReputationChecker({
|
const testInstance = new IPReputationChecker({
|
||||||
enableDNSBL: false,
|
|
||||||
enableIPInfo: true,
|
|
||||||
enableLocalCache: false,
|
enableLocalCache: false,
|
||||||
maxCacheSize: 5 // Small cache for testing
|
maxCacheSize: 5
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test Tor exit node detection
|
// Invalid format should return error result with neutral score
|
||||||
const torResult = await testInstance.checkReputation('171.25.1.1');
|
const result = await testInstance.checkReputation('not-an-ip');
|
||||||
expect(torResult.isTor).toEqual(true);
|
expect(result.score).toEqual(50);
|
||||||
expect(torResult.score < 90).toEqual(true);
|
expect(result.error).toBeTruthy();
|
||||||
|
|
||||||
// Test VPN detection
|
|
||||||
const vpnResult = await testInstance.checkReputation('185.156.1.1');
|
|
||||||
expect(vpnResult.isVPN).toEqual(true);
|
|
||||||
expect(vpnResult.score < 90).toEqual(true);
|
|
||||||
|
|
||||||
// Test proxy detection
|
|
||||||
const proxyResult = await testInstance.checkReputation('34.92.1.1');
|
|
||||||
expect(proxyResult.isProxy).toEqual(true);
|
|
||||||
expect(proxyResult.score < 90).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test error handling
|
|
||||||
tap.test('IPReputationChecker - should handle DNS lookup errors gracefully', async () => {
|
|
||||||
// Setup mock implementation to simulate error
|
|
||||||
mockDnsResolveImpl = async () => {
|
|
||||||
throw new Error('DNS server error');
|
|
||||||
};
|
|
||||||
|
|
||||||
const checker = IPReputationChecker.getInstance({
|
|
||||||
dnsblServers: ['zen.spamhaus.org'],
|
|
||||||
enableIPInfo: false,
|
|
||||||
enableLocalCache: false,
|
|
||||||
maxCacheSize: 300 // Force new instance
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should return a result despite errors
|
|
||||||
const result = await checker.checkReputation('192.168.1.1');
|
|
||||||
expect(result.score).toEqual(100); // No blacklist hits found due to error
|
|
||||||
expect(result.isSpam).toEqual(false);
|
expect(result.isSpam).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore original implementation at the end
|
// Stop bridge
|
||||||
tap.test('Cleanup - restore mocks', async () => {
|
tap.test('cleanup - stop Rust security bridge', async () => {
|
||||||
plugins.dns.promises.resolve = originalDnsResolve;
|
await bridge.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('stop', async () => {
|
tap.test('stop', async () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { UnifiedDeliveryQueue, type IQueueItem } from './classes.delivery.queue.
|
|||||||
import type { Email } from '../core/classes.email.js';
|
import type { Email } from '../core/classes.email.js';
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
import type { SmtpClient } from './smtpclient/smtp-client.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivery status enumeration
|
* Delivery status enumeration
|
||||||
@@ -764,32 +765,23 @@ export class MultiModeDeliverySystem extends EventEmitter {
|
|||||||
// Ensure DKIM keys exist for the domain
|
// Ensure DKIM keys exist for the domain
|
||||||
await this.emailServer.dkimCreator.handleDKIMKeysForDomain(domainName);
|
await this.emailServer.dkimCreator.handleDKIMKeysForDomain(domainName);
|
||||||
|
|
||||||
|
// Get the private key
|
||||||
|
const dkimPrivateKey = (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey;
|
||||||
|
|
||||||
// Convert Email to raw format for signing
|
// Convert Email to raw format for signing
|
||||||
const rawEmail = email.toRFC822String();
|
const rawEmail = email.toRFC822String();
|
||||||
|
|
||||||
// Sign the email
|
// Sign via Rust bridge
|
||||||
const dkimPrivateKey = (await this.emailServer.dkimCreator.readDKIMKeys(domainName)).privateKey;
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
const signResult = await plugins.dkimSign(rawEmail, {
|
const signResult = await bridge.signDkim({
|
||||||
signingDomain: domainName,
|
rawMessage: rawEmail,
|
||||||
|
domain: domainName,
|
||||||
selector: keySelector,
|
selector: keySelector,
|
||||||
privateKey: dkimPrivateKey,
|
privateKey: dkimPrivateKey,
|
||||||
canonicalization: 'relaxed/relaxed',
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
signTime: new Date(),
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: domainName,
|
|
||||||
selector: keySelector,
|
|
||||||
privateKey: dkimPrivateKey,
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
canonicalization: 'relaxed/relaxed'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add the DKIM-Signature header to the email
|
if (signResult.header) {
|
||||||
if (signResult.signatures) {
|
email.addHeader('DKIM-Signature', signResult.header);
|
||||||
email.addHeader('DKIM-Signature', signResult.signatures);
|
|
||||||
logger.log('info', `Successfully added DKIM signature for ${domainName}`);
|
logger.log('info', `Successfully added DKIM signature for ${domainName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
import type { UnifiedEmailServer } from '../routing/classes.unified.email.server.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
interface Headers {
|
interface Headers {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
@@ -28,24 +29,13 @@ export class EmailSignJob {
|
|||||||
|
|
||||||
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
public async getSignatureHeader(emailMessage: string): Promise<string> {
|
||||||
const privateKey = await this.loadPrivateKey();
|
const privateKey = await this.loadPrivateKey();
|
||||||
const signResult = await plugins.dkimSign(emailMessage, {
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
signingDomain: this.jobOptions.domain,
|
const signResult = await bridge.signDkim({
|
||||||
|
rawMessage: emailMessage,
|
||||||
|
domain: this.jobOptions.domain,
|
||||||
selector: this.jobOptions.selector,
|
selector: this.jobOptions.selector,
|
||||||
privateKey,
|
privateKey,
|
||||||
canonicalization: 'relaxed/relaxed',
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
signTime: new Date(),
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: this.jobOptions.domain,
|
|
||||||
selector: this.jobOptions.selector,
|
|
||||||
privateKey,
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
canonicalization: 'relaxed/relaxed',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const signature = signResult.signatures;
|
return signResult.header;
|
||||||
return signature;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
SecurityLogLevel,
|
SecurityLogLevel,
|
||||||
SecurityEventType
|
SecurityEventType
|
||||||
} from '../../security/index.js';
|
} from '../../security/index.js';
|
||||||
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MtaConnectionError,
|
MtaConnectionError,
|
||||||
@@ -845,39 +846,19 @@ export class SmtpClient {
|
|||||||
try {
|
try {
|
||||||
logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`);
|
logger.log('debug', `Signing email with DKIM for domain ${this.options.dkim.domain}`);
|
||||||
|
|
||||||
// Format email for DKIM signing
|
|
||||||
const { dkimSign } = plugins;
|
|
||||||
const emailContent = await this.getFormattedEmail(email);
|
const emailContent = await this.getFormattedEmail(email);
|
||||||
|
|
||||||
// Sign email
|
// Sign via Rust bridge
|
||||||
const signOptions = {
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
signingDomain: this.options.dkim.domain,
|
const signResult = await bridge.signDkim({
|
||||||
|
rawMessage: emailContent,
|
||||||
|
domain: this.options.dkim.domain,
|
||||||
selector: this.options.dkim.selector,
|
selector: this.options.dkim.selector,
|
||||||
privateKey: this.options.dkim.privateKey,
|
privateKey: this.options.dkim.privateKey,
|
||||||
canonicalization: 'relaxed/relaxed' as const,
|
});
|
||||||
algorithm: 'rsa-sha256' as const,
|
|
||||||
signTime: new Date(),
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: this.options.dkim.domain,
|
|
||||||
selector: this.options.dkim.selector,
|
|
||||||
privateKey: this.options.dkim.privateKey,
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
canonicalization: 'relaxed/relaxed',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const signResult = await dkimSign(emailContent, signOptions);
|
if (signResult.header) {
|
||||||
|
email.addHeader('DKIM-Signature', signResult.header);
|
||||||
// Add DKIM-Signature header from the signing result
|
|
||||||
if (signResult.signatures) {
|
|
||||||
const dkimHeader = signResult.signatures.split('\r\n')
|
|
||||||
.find(line => line.startsWith('DKIM-Signature: '));
|
|
||||||
|
|
||||||
if (dkimHeader) {
|
|
||||||
email.addHeader('DKIM-Signature', dkimHeader.substring('DKIM-Signature: '.length));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('debug', 'DKIM signature applied successfully');
|
logger.log('debug', 'DKIM signature applied successfully');
|
||||||
|
|||||||
@@ -366,13 +366,12 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
await this.deliverySystem.start();
|
await this.deliverySystem.start();
|
||||||
logger.log('info', 'Email delivery system started');
|
logger.log('info', 'Email delivery system started');
|
||||||
|
|
||||||
// Start Rust security bridge (non-blocking — server works without it)
|
// Start Rust security bridge — required for all security operations
|
||||||
const bridgeOk = await this.rustBridge.start();
|
const bridgeOk = await this.rustBridge.start();
|
||||||
if (bridgeOk) {
|
if (!bridgeOk) {
|
||||||
logger.log('info', 'Rust security bridge started — using Rust for DKIM/SPF/DMARC verification');
|
throw new Error('Rust security bridge failed to start. The mailer-bin binary is required. Run "pnpm build" to compile it.');
|
||||||
} else {
|
|
||||||
logger.log('warn', 'Rust security bridge unavailable — falling back to TypeScript security verification');
|
|
||||||
}
|
}
|
||||||
|
logger.log('info', 'Rust security bridge started — Rust is the primary security backend');
|
||||||
|
|
||||||
// Set up DKIM for all domains
|
// Set up DKIM for all domains
|
||||||
await this.setupDkimForDomains();
|
await this.setupDkimForDomains();
|
||||||
@@ -430,25 +429,24 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
verifyDmarc: true
|
verifyDmarc: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Security verification delegated to the Rust bridge when available
|
// Security verification delegated to the Rust bridge
|
||||||
dkimVerifier: {
|
dkimVerifier: {
|
||||||
verify: async (rawMessage: string) => {
|
verify: async (rawMessage: string) => {
|
||||||
if (this.rustBridge.running) {
|
|
||||||
try {
|
try {
|
||||||
const results = await this.rustBridge.verifyDkim(rawMessage);
|
const results = await this.rustBridge.verifyDkim(rawMessage);
|
||||||
const first = results[0];
|
const first = results[0];
|
||||||
return { isValid: first?.is_valid ?? false, domain: first?.domain ?? '' };
|
return { isValid: first?.is_valid ?? false, domain: first?.domain ?? '' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('warn', `Rust DKIM verification failed, accepting: ${(err as Error).message}`);
|
logger.log('warn', `Rust DKIM verification failed: ${(err as Error).message}`);
|
||||||
return { isValid: true, domain: '' };
|
return { isValid: false, domain: '' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { isValid: true, domain: '' }; // No bridge — accept
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
spfVerifier: {
|
spfVerifier: {
|
||||||
verifyAndApply: async (session: any) => {
|
verifyAndApply: async (session: any) => {
|
||||||
if (this.rustBridge.running && session?.remoteAddress && session.remoteAddress !== '127.0.0.1') {
|
if (!session?.remoteAddress || session.remoteAddress === '127.0.0.1') {
|
||||||
|
return true; // localhost — skip SPF
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.rustBridge.checkSpf({
|
const result = await this.rustBridge.checkSpf({
|
||||||
ip: session.remoteAddress,
|
ip: session.remoteAddress,
|
||||||
@@ -458,12 +456,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
return result.result === 'pass' || result.result === 'none' || result.result === 'neutral';
|
return result.result === 'pass' || result.result === 'none' || result.result === 'neutral';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.log('warn', `Rust SPF check failed, accepting: ${(err as Error).message}`);
|
logger.log('warn', `Rust SPF check failed: ${(err as Error).message}`);
|
||||||
return true;
|
return true; // Accept on error to avoid blocking mail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true; // No bridge or localhost — accept
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
dmarcVerifier: {
|
dmarcVerifier: {
|
||||||
verify: async () => ({}),
|
verify: async () => ({}),
|
||||||
@@ -637,10 +633,6 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
* Falls back gracefully if the bridge is not running.
|
* Falls back gracefully if the bridge is not running.
|
||||||
*/
|
*/
|
||||||
private async verifyInboundSecurity(email: Email, session: IExtendedSmtpSession): Promise<void> {
|
private async verifyInboundSecurity(email: Email, session: IExtendedSmtpSession): Promise<void> {
|
||||||
if (!this.rustBridge.running) {
|
|
||||||
return; // Bridge not available — skip verification
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rawMessage = session.emailData || email.toRFC822String();
|
const rawMessage = session.emailData || email.toRFC822String();
|
||||||
const result = await this.rustBridge.verifyEmail({
|
const result = await this.rustBridge.verifyEmail({
|
||||||
@@ -942,52 +934,10 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
|
|
||||||
// Apply DKIM signing if enabled
|
// Apply DKIM signing if enabled
|
||||||
if (options.dkimSign && options.dkimOptions) {
|
if (options.dkimSign && options.dkimOptions) {
|
||||||
// Sign the email with DKIM
|
|
||||||
logger.log('info', `Signing email with DKIM for domain ${options.dkimOptions.domainName}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Ensure DKIM keys exist for the domain
|
|
||||||
await this.dkimCreator.handleDKIMKeysForDomain(options.dkimOptions.domainName);
|
|
||||||
|
|
||||||
// Convert Email to raw format for signing
|
|
||||||
const rawEmail = email.toRFC822String();
|
|
||||||
|
|
||||||
// Create headers object
|
|
||||||
const headers = {};
|
|
||||||
for (const [key, value] of Object.entries(email.headers)) {
|
|
||||||
headers[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the email
|
|
||||||
const dkimDomain = options.dkimOptions.domainName;
|
const dkimDomain = options.dkimOptions.domainName;
|
||||||
const dkimSelector = options.dkimOptions.keySelector || 'mta';
|
const dkimSelector = options.dkimOptions.keySelector || 'mta';
|
||||||
const dkimPrivateKey = (await this.dkimCreator.readDKIMKeys(dkimDomain)).privateKey;
|
logger.log('info', `Signing email with DKIM for domain ${dkimDomain}`);
|
||||||
const signResult = await plugins.dkimSign(rawEmail, {
|
await this.handleDkimSigning(email, dkimDomain, dkimSelector);
|
||||||
signingDomain: dkimDomain,
|
|
||||||
selector: dkimSelector,
|
|
||||||
privateKey: dkimPrivateKey,
|
|
||||||
canonicalization: 'relaxed/relaxed',
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
signTime: new Date(),
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: dkimDomain,
|
|
||||||
selector: dkimSelector,
|
|
||||||
privateKey: dkimPrivateKey,
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
canonicalization: 'relaxed/relaxed'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the DKIM-Signature header to the email
|
|
||||||
if (signResult.signatures) {
|
|
||||||
email.addHeader('DKIM-Signature', signResult.signatures);
|
|
||||||
logger.log('info', `Successfully added DKIM signature for ${options.dkimOptions.domainName}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Failed to sign email with DKIM: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1562,28 +1512,16 @@ export class UnifiedEmailServer extends EventEmitter {
|
|||||||
// Convert Email to raw format for signing
|
// Convert Email to raw format for signing
|
||||||
const rawEmail = email.toRFC822String();
|
const rawEmail = email.toRFC822String();
|
||||||
|
|
||||||
// Sign the email
|
// Sign the email via Rust bridge
|
||||||
const signResult = await plugins.dkimSign(rawEmail, {
|
const signResult = await this.rustBridge.signDkim({
|
||||||
signingDomain: domain,
|
rawMessage: rawEmail,
|
||||||
selector: selector,
|
domain,
|
||||||
privateKey: privateKey,
|
selector,
|
||||||
canonicalization: 'relaxed/relaxed',
|
privateKey,
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
signTime: new Date(),
|
|
||||||
signatureData: [
|
|
||||||
{
|
|
||||||
signingDomain: domain,
|
|
||||||
selector: selector,
|
|
||||||
privateKey: privateKey,
|
|
||||||
algorithm: 'rsa-sha256',
|
|
||||||
canonicalization: 'relaxed/relaxed'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add the DKIM-Signature header to the email
|
if (signResult.header) {
|
||||||
if (signResult.signatures) {
|
email.addHeader('DKIM-Signature', signResult.header);
|
||||||
email.addHeader('DKIM-Signature', signResult.signatures);
|
|
||||||
logger.log('info', `Successfully added DKIM signature for ${domain}`);
|
logger.log('info', `Successfully added DKIM signature for ${domain}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
|
||||||
// MtaService reference removed
|
|
||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a DKIM verification
|
* Result of a DKIM verification
|
||||||
@@ -17,23 +16,13 @@ export interface IDkimVerificationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced DKIM verifier using smartmail capabilities
|
* DKIM verifier — delegates to the Rust security bridge.
|
||||||
*/
|
*/
|
||||||
export class DKIMVerifier {
|
export class DKIMVerifier {
|
||||||
// MtaRef reference removed
|
constructor() {}
|
||||||
|
|
||||||
// Cache verified results to avoid repeated verification
|
|
||||||
private verificationCache: Map<string, { result: IDkimVerificationResult, timestamp: number }> = new Map();
|
|
||||||
private cacheTtl = 30 * 60 * 1000; // 30 minutes cache
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify DKIM signature for an email
|
* Verify DKIM signature for an email via Rust bridge
|
||||||
* @param emailData The raw email data
|
|
||||||
* @param options Verification options
|
|
||||||
* @returns Verification result
|
|
||||||
*/
|
*/
|
||||||
public async verify(
|
public async verify(
|
||||||
emailData: string,
|
emailData: string,
|
||||||
@@ -43,232 +32,38 @@ export class DKIMVerifier {
|
|||||||
} = {}
|
} = {}
|
||||||
): Promise<IDkimVerificationResult> {
|
): Promise<IDkimVerificationResult> {
|
||||||
try {
|
try {
|
||||||
// Generate a cache key from the first 128 bytes of the email data
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
const cacheKey = emailData.slice(0, 128);
|
const results = await bridge.verifyDkim(emailData);
|
||||||
|
const first = results[0];
|
||||||
// Check cache if enabled
|
|
||||||
if (options.useCache !== false) {
|
|
||||||
const cached = this.verificationCache.get(cacheKey);
|
|
||||||
|
|
||||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTtl) {
|
|
||||||
logger.log('info', 'DKIM verification result from cache');
|
|
||||||
return cached.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to verify using mailauth first
|
|
||||||
try {
|
|
||||||
const verificationMailauth = await plugins.mailauth.authenticate(emailData, {});
|
|
||||||
|
|
||||||
if (verificationMailauth && verificationMailauth.dkim && verificationMailauth.dkim.results.length > 0) {
|
|
||||||
const dkimResult = verificationMailauth.dkim.results[0];
|
|
||||||
const isValid = dkimResult.status.result === 'pass';
|
|
||||||
|
|
||||||
const result: IDkimVerificationResult = {
|
const result: IDkimVerificationResult = {
|
||||||
isValid,
|
isValid: first?.is_valid ?? false,
|
||||||
domain: dkimResult.signingDomain,
|
domain: first?.domain ?? undefined,
|
||||||
selector: dkimResult.selector,
|
selector: first?.selector ?? undefined,
|
||||||
status: dkimResult.status.result,
|
status: first?.status ?? 'none',
|
||||||
signatureFields: (dkimResult as any).signature,
|
details: options.returnDetails ? results : undefined,
|
||||||
details: options.returnDetails ? verificationMailauth : undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
this.verificationCache.set(cacheKey, {
|
|
||||||
result,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log(isValid ? 'info' : 'warn', `DKIM Verification using mailauth: ${isValid ? 'pass' : 'fail'} for domain ${dkimResult.signingDomain}`);
|
|
||||||
|
|
||||||
// Enhanced security logging
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
level: result.isValid ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||||||
type: SecurityEventType.DKIM,
|
type: SecurityEventType.DKIM,
|
||||||
message: `DKIM verification ${isValid ? 'passed' : 'failed'} for domain ${dkimResult.signingDomain}`,
|
message: `DKIM verification ${result.isValid ? 'passed' : 'failed'} for domain ${result.domain || 'unknown'}`,
|
||||||
details: {
|
details: { selector: result.selector, status: result.status },
|
||||||
selector: dkimResult.selector,
|
domain: result.domain || 'unknown',
|
||||||
signatureFields: (dkimResult as any).signature,
|
success: result.isValid
|
||||||
result: dkimResult.status.result
|
|
||||||
},
|
|
||||||
domain: dkimResult.signingDomain,
|
|
||||||
success: isValid
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.log(result.isValid ? 'info' : 'warn',
|
||||||
|
`DKIM verification: ${result.status} for domain ${result.domain || 'unknown'}`);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
|
||||||
} catch (mailauthError) {
|
|
||||||
logger.log('warn', `DKIM verification with mailauth failed, trying smartmail: ${mailauthError.message}`);
|
|
||||||
|
|
||||||
// Enhanced security logging
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `DKIM verification with mailauth failed, trying smartmail fallback`,
|
|
||||||
details: { error: mailauthError.message },
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to smartmail for verification
|
|
||||||
try {
|
|
||||||
// Parse and extract DKIM signature
|
|
||||||
const parsedEmail = await plugins.mailparser.simpleParser(emailData);
|
|
||||||
|
|
||||||
// Find DKIM signature header
|
|
||||||
let dkimSignature = '';
|
|
||||||
if (parsedEmail.headers.has('dkim-signature')) {
|
|
||||||
dkimSignature = parsedEmail.headers.get('dkim-signature') as string;
|
|
||||||
} else {
|
|
||||||
// No DKIM signature found
|
|
||||||
const result: IDkimVerificationResult = {
|
|
||||||
isValid: false,
|
|
||||||
errorMessage: 'No DKIM signature found'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.verificationCache.set(cacheKey, {
|
|
||||||
result,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract domain from DKIM signature
|
|
||||||
const domainMatch = dkimSignature.match(/d=([^;]+)/i);
|
|
||||||
const domain = domainMatch ? domainMatch[1].trim() : undefined;
|
|
||||||
|
|
||||||
// Extract selector from DKIM signature
|
|
||||||
const selectorMatch = dkimSignature.match(/s=([^;]+)/i);
|
|
||||||
const selector = selectorMatch ? selectorMatch[1].trim() : undefined;
|
|
||||||
|
|
||||||
// Parse DKIM fields
|
|
||||||
const signatureFields: Record<string, string> = {};
|
|
||||||
const fieldMatches = dkimSignature.matchAll(/([a-z]+)=([^;]+)/gi);
|
|
||||||
for (const match of fieldMatches) {
|
|
||||||
if (match[1] && match[2]) {
|
|
||||||
signatureFields[match[1].toLowerCase()] = match[2].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use smartmail's verification if we have domain and selector
|
|
||||||
if (domain && selector) {
|
|
||||||
const dkimKey = await this.fetchDkimKey(domain, selector);
|
|
||||||
|
|
||||||
if (!dkimKey) {
|
|
||||||
const result: IDkimVerificationResult = {
|
|
||||||
isValid: false,
|
|
||||||
domain,
|
|
||||||
selector,
|
|
||||||
status: 'permerror',
|
|
||||||
errorMessage: 'DKIM public key not found',
|
|
||||||
signatureFields
|
|
||||||
};
|
|
||||||
|
|
||||||
this.verificationCache.set(cacheKey, {
|
|
||||||
result,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a real implementation, we would validate the signature here
|
|
||||||
// For now, if we found a key, we'll consider it valid
|
|
||||||
// In a future update, add actual crypto verification
|
|
||||||
|
|
||||||
const result: IDkimVerificationResult = {
|
|
||||||
isValid: true,
|
|
||||||
domain,
|
|
||||||
selector,
|
|
||||||
status: 'pass',
|
|
||||||
signatureFields
|
|
||||||
};
|
|
||||||
|
|
||||||
this.verificationCache.set(cacheKey, {
|
|
||||||
result,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log('info', `DKIM verification using smartmail: pass for domain ${domain}`);
|
|
||||||
|
|
||||||
// Enhanced security logging
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.INFO,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `DKIM verification passed for domain ${domain} using fallback verification`,
|
|
||||||
details: {
|
|
||||||
selector,
|
|
||||||
signatureFields
|
|
||||||
},
|
|
||||||
domain,
|
|
||||||
success: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
// Missing domain or selector
|
|
||||||
const result: IDkimVerificationResult = {
|
|
||||||
isValid: false,
|
|
||||||
domain,
|
|
||||||
selector,
|
|
||||||
status: 'permerror',
|
|
||||||
errorMessage: 'Missing domain or selector in DKIM signature',
|
|
||||||
signatureFields
|
|
||||||
};
|
|
||||||
|
|
||||||
this.verificationCache.set(cacheKey, {
|
|
||||||
result,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log('warn', `DKIM verification failed: Missing domain or selector in DKIM signature`);
|
|
||||||
|
|
||||||
// Enhanced security logging
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `DKIM verification failed: Missing domain or selector in signature`,
|
|
||||||
details: { domain, selector, signatureFields },
|
|
||||||
domain: domain || 'unknown',
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const result: IDkimVerificationResult = {
|
logger.log('error', `DKIM verification failed: ${error.message}`);
|
||||||
isValid: false,
|
|
||||||
status: 'temperror',
|
|
||||||
errorMessage: `Verification error: ${error.message}`
|
|
||||||
};
|
|
||||||
|
|
||||||
this.verificationCache.set(cacheKey, {
|
|
||||||
result,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.log('error', `DKIM verification error: ${error.message}`);
|
|
||||||
|
|
||||||
// Enhanced security logging
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: SecurityLogLevel.ERROR,
|
level: SecurityLogLevel.ERROR,
|
||||||
type: SecurityEventType.DKIM,
|
type: SecurityEventType.DKIM,
|
||||||
message: `DKIM verification error during processing`,
|
message: `DKIM verification error`,
|
||||||
details: { error: error.message },
|
|
||||||
success: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `DKIM verification failed with unexpected error: ${error.message}`);
|
|
||||||
|
|
||||||
// Enhanced security logging for unexpected errors
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `DKIM verification failed with unexpected error`,
|
|
||||||
details: { error: error.message },
|
details: { error: error.message },
|
||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
@@ -276,107 +71,16 @@ export class DKIMVerifier {
|
|||||||
return {
|
return {
|
||||||
isValid: false,
|
isValid: false,
|
||||||
status: 'temperror',
|
status: 'temperror',
|
||||||
errorMessage: `Unexpected verification error: ${error.message}`
|
errorMessage: `Verification error: ${error.message}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** No-op — Rust bridge handles its own caching */
|
||||||
* Fetch DKIM public key from DNS
|
public clearCache(): void {}
|
||||||
* @param domain The domain
|
|
||||||
* @param selector The DKIM selector
|
|
||||||
* @returns The DKIM public key or null if not found
|
|
||||||
*/
|
|
||||||
private async fetchDkimKey(domain: string, selector: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const dkimRecord = `${selector}._domainkey.${domain}`;
|
|
||||||
|
|
||||||
// Use DNS lookup from plugins
|
/** Always 0 — cache is managed by the Rust side */
|
||||||
const txtRecords = await new Promise<string[]>((resolve, reject) => {
|
|
||||||
plugins.dns.resolveTxt(dkimRecord, (err, records) => {
|
|
||||||
if (err) {
|
|
||||||
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
|
|
||||||
resolve([]);
|
|
||||||
} else {
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Flatten the arrays that resolveTxt returns
|
|
||||||
resolve(records.map(record => record.join('')));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!txtRecords || txtRecords.length === 0) {
|
|
||||||
logger.log('warn', `No DKIM TXT record found for ${dkimRecord}`);
|
|
||||||
|
|
||||||
// Security logging for missing DKIM record
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `No DKIM TXT record found for ${dkimRecord}`,
|
|
||||||
domain,
|
|
||||||
success: false,
|
|
||||||
details: { selector }
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find record matching DKIM format
|
|
||||||
for (const record of txtRecords) {
|
|
||||||
if (record.includes('p=')) {
|
|
||||||
// Extract public key
|
|
||||||
const publicKeyMatch = record.match(/p=([^;]+)/i);
|
|
||||||
if (publicKeyMatch && publicKeyMatch[1]) {
|
|
||||||
return publicKeyMatch[1].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('warn', `No valid DKIM public key found in TXT records for ${dkimRecord}`);
|
|
||||||
|
|
||||||
// Security logging for invalid DKIM key
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.WARN,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `No valid DKIM public key found in TXT records`,
|
|
||||||
domain,
|
|
||||||
success: false,
|
|
||||||
details: { dkimRecord, selector }
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error fetching DKIM key: ${error.message}`);
|
|
||||||
|
|
||||||
// Security logging for DKIM key fetch error
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
|
||||||
level: SecurityLogLevel.ERROR,
|
|
||||||
type: SecurityEventType.DKIM,
|
|
||||||
message: `Error fetching DKIM key for domain`,
|
|
||||||
domain,
|
|
||||||
success: false,
|
|
||||||
details: { error: error.message, selector, dkimRecord: `${selector}._domainkey.${domain}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the verification cache
|
|
||||||
*/
|
|
||||||
public clearCache(): void {
|
|
||||||
this.verificationCache.clear();
|
|
||||||
logger.log('info', 'DKIM verification cache cleared');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the size of the verification cache
|
|
||||||
* @returns Number of cached items
|
|
||||||
*/
|
|
||||||
public getCacheSize(): number {
|
public getCacheSize(): number {
|
||||||
return this.verificationCache.size;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.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';
|
||||||
// MtaService reference removed
|
|
||||||
import type { Email } from '../core/classes.email.js';
|
import type { Email } from '../core/classes.email.js';
|
||||||
import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DMARC policy types
|
* DMARC policy types
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.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';
|
||||||
// MtaService reference removed
|
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
|
||||||
import type { Email } from '../core/classes.email.js';
|
import type { Email } from '../core/classes.email.js';
|
||||||
import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SPF result qualifiers
|
* SPF result qualifiers
|
||||||
@@ -61,26 +60,17 @@ export interface SpfResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum lookup limit for SPF records (prevent infinite loops)
|
* Class for verifying SPF records.
|
||||||
*/
|
* Delegates actual SPF evaluation to the Rust security bridge.
|
||||||
const MAX_SPF_LOOKUPS = 10;
|
* Retains parseSpfRecord() for lightweight local parsing.
|
||||||
|
|
||||||
/**
|
|
||||||
* Class for verifying SPF records
|
|
||||||
*/
|
*/
|
||||||
export class SpfVerifier {
|
export class SpfVerifier {
|
||||||
// DNS Manager reference for verifying records
|
constructor(_dnsManager?: any) {
|
||||||
private dnsManager?: any;
|
// dnsManager is no longer needed — Rust handles DNS lookups
|
||||||
private lookupCount: number = 0;
|
|
||||||
|
|
||||||
constructor(dnsManager?: any) {
|
|
||||||
this.dnsManager = dnsManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse SPF record from TXT record
|
* Parse SPF record from TXT record (pure string parsing, no DNS)
|
||||||
* @param record SPF TXT record
|
|
||||||
* @returns Parsed SPF record or null if invalid
|
|
||||||
*/
|
*/
|
||||||
public parseSpfRecord(record: string): SpfRecord | null {
|
public parseSpfRecord(record: string): SpfRecord | null {
|
||||||
if (!record.startsWith('v=spf1')) {
|
if (!record.startsWith('v=spf1')) {
|
||||||
@@ -94,32 +84,26 @@ export class SpfVerifier {
|
|||||||
modifiers: {}
|
modifiers: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Split into terms
|
|
||||||
const terms = record.split(' ').filter(term => term.length > 0);
|
const terms = record.split(' ').filter(term => term.length > 0);
|
||||||
|
|
||||||
// Skip version term
|
|
||||||
for (let i = 1; i < terms.length; i++) {
|
for (let i = 1; i < terms.length; i++) {
|
||||||
const term = terms[i];
|
const term = terms[i];
|
||||||
|
|
||||||
// Check if it's a modifier (name=value)
|
|
||||||
if (term.includes('=')) {
|
if (term.includes('=')) {
|
||||||
const [name, value] = term.split('=');
|
const [name, value] = term.split('=');
|
||||||
spfRecord.modifiers[name] = value;
|
spfRecord.modifiers[name] = value;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse as mechanism
|
let qualifier = SpfQualifier.PASS;
|
||||||
let qualifier = SpfQualifier.PASS; // Default is +
|
|
||||||
let mechanismText = term;
|
let mechanismText = term;
|
||||||
|
|
||||||
// Check for qualifier
|
|
||||||
if (term.startsWith('+') || term.startsWith('-') ||
|
if (term.startsWith('+') || term.startsWith('-') ||
|
||||||
term.startsWith('~') || term.startsWith('?')) {
|
term.startsWith('~') || term.startsWith('?')) {
|
||||||
qualifier = term[0] as SpfQualifier;
|
qualifier = term[0] as SpfQualifier;
|
||||||
mechanismText = term.substring(1);
|
mechanismText = term.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse mechanism type and value
|
|
||||||
const colonIndex = mechanismText.indexOf(':');
|
const colonIndex = mechanismText.indexOf(':');
|
||||||
let type: SpfMechanismType;
|
let type: SpfMechanismType;
|
||||||
let value: string | undefined;
|
let value: string | undefined;
|
||||||
@@ -145,58 +129,7 @@ export class SpfVerifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if IP is in CIDR range
|
* Verify SPF for a given email — delegates to Rust bridge
|
||||||
* @param ip IP address to check
|
|
||||||
* @param cidr CIDR range
|
|
||||||
* @returns Whether the IP is in the CIDR range
|
|
||||||
*/
|
|
||||||
private isIpInCidr(ip: string, cidr: string): boolean {
|
|
||||||
try {
|
|
||||||
const ipAddress = plugins.ip.Address4.parse(ip);
|
|
||||||
return ipAddress.isInSubnet(new plugins.ip.Address4(cidr));
|
|
||||||
} catch (error) {
|
|
||||||
// Try IPv6
|
|
||||||
try {
|
|
||||||
const ipAddress = plugins.ip.Address6.parse(ip);
|
|
||||||
return ipAddress.isInSubnet(new plugins.ip.Address6(cidr));
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a domain has the specified IP in its A or AAAA records
|
|
||||||
* @param domain Domain to check
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns Whether the domain resolves to the IP
|
|
||||||
*/
|
|
||||||
private async isDomainResolvingToIp(domain: string, ip: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// First try IPv4
|
|
||||||
const ipv4Addresses = await plugins.dns.promises.resolve4(domain);
|
|
||||||
if (ipv4Addresses.includes(ip)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then try IPv6
|
|
||||||
const ipv6Addresses = await plugins.dns.promises.resolve6(domain);
|
|
||||||
if (ipv6Addresses.includes(ip)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify SPF for a given email with IP and helo domain
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param ip Sender IP address
|
|
||||||
* @param heloDomain HELO/EHLO domain used by sender
|
|
||||||
* @returns SPF verification result
|
|
||||||
*/
|
*/
|
||||||
public async verify(
|
public async verify(
|
||||||
email: Email,
|
email: Email,
|
||||||
@@ -204,106 +137,45 @@ export class SpfVerifier {
|
|||||||
heloDomain: string
|
heloDomain: string
|
||||||
): Promise<SpfResult> {
|
): Promise<SpfResult> {
|
||||||
const securityLogger = SecurityLogger.getInstance();
|
const securityLogger = SecurityLogger.getInstance();
|
||||||
|
const mailFrom = email.from || '';
|
||||||
// Reset lookup count
|
const domain = mailFrom.split('@')[1] || '';
|
||||||
this.lookupCount = 0;
|
|
||||||
|
|
||||||
// Get domain from envelope from (return-path)
|
|
||||||
const domain = email.getEnvelopeFrom().split('@')[1] || '';
|
|
||||||
|
|
||||||
if (!domain) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'No envelope from domain',
|
|
||||||
domain: '',
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Look up SPF record
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
const spfVerificationResult = this.dnsManager ?
|
const result = await bridge.checkSpf({
|
||||||
await this.dnsManager.verifySpfRecord(domain) :
|
|
||||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
|
||||||
|
|
||||||
if (!spfVerificationResult.found) {
|
|
||||||
return {
|
|
||||||
result: 'none',
|
|
||||||
explanation: 'No SPF record found',
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!spfVerificationResult.valid) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'Invalid SPF record',
|
|
||||||
domain,
|
|
||||||
ip,
|
|
||||||
record: spfVerificationResult.value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse SPF record
|
|
||||||
const spfRecord = this.parseSpfRecord(spfVerificationResult.value);
|
|
||||||
|
|
||||||
if (!spfRecord) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'Failed to parse SPF record',
|
|
||||||
domain,
|
|
||||||
ip,
|
|
||||||
record: spfVerificationResult.value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check SPF record
|
|
||||||
const result = await this.checkSpfRecord(spfRecord, domain, ip);
|
|
||||||
|
|
||||||
// Log the result
|
|
||||||
const spfLogLevel = result.result === 'pass' ?
|
|
||||||
SecurityLogLevel.INFO :
|
|
||||||
(result.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO);
|
|
||||||
|
|
||||||
securityLogger.logEvent({
|
|
||||||
level: spfLogLevel,
|
|
||||||
type: SecurityEventType.SPF,
|
|
||||||
message: `SPF ${result.result} for ${domain} from IP ${ip}`,
|
|
||||||
domain,
|
|
||||||
details: {
|
|
||||||
ip,
|
ip,
|
||||||
heloDomain,
|
heloDomain,
|
||||||
result: result.result,
|
hostname: plugins.os.hostname(),
|
||||||
explanation: result.explanation,
|
mailFrom,
|
||||||
record: spfVerificationResult.value
|
|
||||||
},
|
|
||||||
success: result.result === 'pass'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const spfResult: SpfResult = {
|
||||||
...result,
|
result: result.result as SpfResult['result'],
|
||||||
domain,
|
domain: result.domain,
|
||||||
ip,
|
ip: result.ip,
|
||||||
record: spfVerificationResult.value
|
explanation: result.explanation ?? undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
// Log error
|
securityLogger.logEvent({
|
||||||
logger.log('error', `SPF verification error: ${error.message}`, {
|
level: spfResult.result === 'pass' ? SecurityLogLevel.INFO :
|
||||||
domain,
|
(spfResult.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO),
|
||||||
ip,
|
type: SecurityEventType.SPF,
|
||||||
error: error.message
|
message: `SPF ${spfResult.result} for ${spfResult.domain} from IP ${ip}`,
|
||||||
|
domain: spfResult.domain,
|
||||||
|
details: { ip, heloDomain, result: spfResult.result, explanation: spfResult.explanation },
|
||||||
|
success: spfResult.result === 'pass'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return spfResult;
|
||||||
|
} catch (error) {
|
||||||
|
logger.log('error', `SPF verification error: ${error.message}`, { domain, ip, error: error.message });
|
||||||
|
|
||||||
securityLogger.logEvent({
|
securityLogger.logEvent({
|
||||||
level: SecurityLogLevel.ERROR,
|
level: SecurityLogLevel.ERROR,
|
||||||
type: SecurityEventType.SPF,
|
type: SecurityEventType.SPF,
|
||||||
message: `SPF verification error for ${domain}`,
|
message: `SPF verification error for ${domain}`,
|
||||||
domain,
|
domain,
|
||||||
details: {
|
details: { ip, error: error.message },
|
||||||
ip,
|
|
||||||
error: error.message
|
|
||||||
},
|
|
||||||
success: false
|
success: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,245 +190,7 @@ export class SpfVerifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check SPF record against IP address
|
* Check if email passes SPF verification and apply headers
|
||||||
* @param spfRecord Parsed SPF record
|
|
||||||
* @param domain Domain being checked
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns SPF result
|
|
||||||
*/
|
|
||||||
private async checkSpfRecord(
|
|
||||||
spfRecord: SpfRecord,
|
|
||||||
domain: string,
|
|
||||||
ip: string
|
|
||||||
): Promise<SpfResult> {
|
|
||||||
// Check for 'redirect' modifier
|
|
||||||
if (spfRecord.modifiers.redirect) {
|
|
||||||
this.lookupCount++;
|
|
||||||
|
|
||||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'Too many DNS lookups',
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle redirect
|
|
||||||
const redirectDomain = spfRecord.modifiers.redirect;
|
|
||||||
const redirectResult = this.dnsManager ?
|
|
||||||
await this.dnsManager.verifySpfRecord(redirectDomain) :
|
|
||||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
|
||||||
|
|
||||||
if (!redirectResult.found || !redirectResult.valid) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: `Invalid redirect to ${redirectDomain}`,
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectRecord = this.parseSpfRecord(redirectResult.value);
|
|
||||||
|
|
||||||
if (!redirectRecord) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: `Failed to parse redirect record from ${redirectDomain}`,
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.checkSpfRecord(redirectRecord, redirectDomain, ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check each mechanism in order
|
|
||||||
for (const mechanism of spfRecord.mechanisms) {
|
|
||||||
let matched = false;
|
|
||||||
|
|
||||||
switch (mechanism.type) {
|
|
||||||
case SpfMechanismType.ALL:
|
|
||||||
matched = true;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SpfMechanismType.IP4:
|
|
||||||
if (mechanism.value) {
|
|
||||||
matched = this.isIpInCidr(ip, mechanism.value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SpfMechanismType.IP6:
|
|
||||||
if (mechanism.value) {
|
|
||||||
matched = this.isIpInCidr(ip, mechanism.value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SpfMechanismType.A:
|
|
||||||
this.lookupCount++;
|
|
||||||
|
|
||||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'Too many DNS lookups',
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if domain has A/AAAA record matching IP
|
|
||||||
const checkDomain = mechanism.value || domain;
|
|
||||||
matched = await this.isDomainResolvingToIp(checkDomain, ip);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SpfMechanismType.MX:
|
|
||||||
this.lookupCount++;
|
|
||||||
|
|
||||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'Too many DNS lookups',
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check MX records
|
|
||||||
const mxDomain = mechanism.value || domain;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mxRecords = await plugins.dns.promises.resolveMx(mxDomain);
|
|
||||||
|
|
||||||
for (const mx of mxRecords) {
|
|
||||||
// Check if this MX record's IP matches
|
|
||||||
const mxMatches = await this.isDomainResolvingToIp(mx.exchange, ip);
|
|
||||||
|
|
||||||
if (mxMatches) {
|
|
||||||
matched = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// No MX records or error
|
|
||||||
matched = false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SpfMechanismType.INCLUDE:
|
|
||||||
if (!mechanism.value) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lookupCount++;
|
|
||||||
|
|
||||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'Too many DNS lookups',
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check included domain's SPF record
|
|
||||||
const includeDomain = mechanism.value;
|
|
||||||
const includeResult = this.dnsManager ?
|
|
||||||
await this.dnsManager.verifySpfRecord(includeDomain) :
|
|
||||||
{ found: false, valid: false, error: 'DNS Manager not available' };
|
|
||||||
|
|
||||||
if (!includeResult.found || !includeResult.valid) {
|
|
||||||
continue; // Skip this mechanism
|
|
||||||
}
|
|
||||||
|
|
||||||
const includeRecord = this.parseSpfRecord(includeResult.value);
|
|
||||||
|
|
||||||
if (!includeRecord) {
|
|
||||||
continue; // Skip this mechanism
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively check the included SPF record
|
|
||||||
const includeCheck = await this.checkSpfRecord(includeRecord, includeDomain, ip);
|
|
||||||
|
|
||||||
// Include mechanism matches if the result is "pass"
|
|
||||||
matched = includeCheck.result === 'pass';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SpfMechanismType.EXISTS:
|
|
||||||
if (!mechanism.value) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lookupCount++;
|
|
||||||
|
|
||||||
if (this.lookupCount > MAX_SPF_LOOKUPS) {
|
|
||||||
return {
|
|
||||||
result: 'permerror',
|
|
||||||
explanation: 'Too many DNS lookups',
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if domain exists (has any A record)
|
|
||||||
try {
|
|
||||||
await plugins.dns.promises.resolve(mechanism.value, 'A');
|
|
||||||
matched = true;
|
|
||||||
} catch (error) {
|
|
||||||
matched = false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this mechanism matched, return its result
|
|
||||||
if (matched) {
|
|
||||||
switch (mechanism.qualifier) {
|
|
||||||
case SpfQualifier.PASS:
|
|
||||||
return {
|
|
||||||
result: 'pass',
|
|
||||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
case SpfQualifier.FAIL:
|
|
||||||
return {
|
|
||||||
result: 'fail',
|
|
||||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
case SpfQualifier.SOFTFAIL:
|
|
||||||
return {
|
|
||||||
result: 'softfail',
|
|
||||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
case SpfQualifier.NEUTRAL:
|
|
||||||
return {
|
|
||||||
result: 'neutral',
|
|
||||||
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no mechanism matched, default to neutral
|
|
||||||
return {
|
|
||||||
result: 'neutral',
|
|
||||||
explanation: 'No matching mechanism found',
|
|
||||||
domain,
|
|
||||||
ip
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if email passes SPF verification
|
|
||||||
* @param email Email to verify
|
|
||||||
* @param ip Sender IP address
|
|
||||||
* @param heloDomain HELO/EHLO domain used by sender
|
|
||||||
* @returns Whether email passes SPF
|
|
||||||
*/
|
*/
|
||||||
public async verifyAndApply(
|
public async verifyAndApply(
|
||||||
email: Email,
|
email: Email,
|
||||||
@@ -565,37 +199,30 @@ export class SpfVerifier {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await this.verify(email, ip, heloDomain);
|
const result = await this.verify(email, ip, heloDomain);
|
||||||
|
|
||||||
// Add headers
|
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
||||||
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
|
|
||||||
|
|
||||||
// Apply policy based on result
|
|
||||||
switch (result.result) {
|
switch (result.result) {
|
||||||
case 'fail':
|
case 'fail':
|
||||||
// Fail - mark as spam
|
|
||||||
email.mightBeSpam = true;
|
email.mightBeSpam = true;
|
||||||
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
|
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
case 'softfail':
|
case 'softfail':
|
||||||
// Soft fail - accept but mark as suspicious
|
|
||||||
email.mightBeSpam = true;
|
email.mightBeSpam = true;
|
||||||
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
|
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'neutral':
|
case 'neutral':
|
||||||
case 'none':
|
case 'none':
|
||||||
// Neutral or none - accept but note in headers
|
|
||||||
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
|
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'pass':
|
case 'pass':
|
||||||
// Pass - accept
|
|
||||||
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
|
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case 'temperror':
|
case 'temperror':
|
||||||
case 'permerror':
|
case 'permerror':
|
||||||
// Temporary or permanent error - log but accept
|
|
||||||
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
|
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
|||||||
@@ -84,16 +84,10 @@ export {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// third party
|
// third party
|
||||||
import * as mailauth from 'mailauth';
|
|
||||||
import { dkimSign } from 'mailauth/lib/dkim/sign.js';
|
|
||||||
import mailparser from 'mailparser';
|
import mailparser from 'mailparser';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
import * as ip from 'ip';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
mailauth,
|
|
||||||
dkimSign,
|
|
||||||
mailparser,
|
mailparser,
|
||||||
uuid,
|
uuid,
|
||||||
ip,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,33 +59,19 @@ export interface IIPReputationOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class for checking IP reputation of inbound email senders
|
* IP reputation checker — delegates DNSBL lookups to the Rust security bridge.
|
||||||
|
* Retains LRU caching and disk persistence in TypeScript.
|
||||||
*/
|
*/
|
||||||
export class IPReputationChecker {
|
export class IPReputationChecker {
|
||||||
private static instance: IPReputationChecker;
|
private static instance: IPReputationChecker;
|
||||||
private reputationCache: LRUCache<string, IReputationResult>;
|
private reputationCache: LRUCache<string, IReputationResult>;
|
||||||
private options: Required<IIPReputationOptions>;
|
private options: Required<IIPReputationOptions>;
|
||||||
private storageManager?: any; // StorageManager instance
|
private storageManager?: any;
|
||||||
|
|
||||||
// Default DNSBL servers
|
|
||||||
private static readonly DEFAULT_DNSBL_SERVERS = [
|
|
||||||
'zen.spamhaus.org', // Spamhaus
|
|
||||||
'bl.spamcop.net', // SpamCop
|
|
||||||
'b.barracudacentral.org', // Barracuda
|
|
||||||
'spam.dnsbl.sorbs.net', // SORBS
|
|
||||||
'dnsbl.sorbs.net', // SORBS (expanded)
|
|
||||||
'cbl.abuseat.org', // Composite Blocking List
|
|
||||||
'xbl.spamhaus.org', // Spamhaus XBL
|
|
||||||
'pbl.spamhaus.org', // Spamhaus PBL
|
|
||||||
'dnsbl-1.uceprotect.net', // UCEPROTECT
|
|
||||||
'psbl.surriel.com' // PSBL
|
|
||||||
];
|
|
||||||
|
|
||||||
// Default options
|
|
||||||
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
private static readonly DEFAULT_OPTIONS: Required<IIPReputationOptions> = {
|
||||||
maxCacheSize: 10000,
|
maxCacheSize: 10000,
|
||||||
cacheTTL: 24 * 60 * 60 * 1000, // 24 hours
|
cacheTTL: 24 * 60 * 60 * 1000,
|
||||||
dnsblServers: IPReputationChecker.DEFAULT_DNSBL_SERVERS,
|
dnsblServers: [],
|
||||||
highRiskThreshold: ReputationThreshold.HIGH_RISK,
|
highRiskThreshold: ReputationThreshold.HIGH_RISK,
|
||||||
mediumRiskThreshold: ReputationThreshold.MEDIUM_RISK,
|
mediumRiskThreshold: ReputationThreshold.MEDIUM_RISK,
|
||||||
lowRiskThreshold: ReputationThreshold.LOW_RISK,
|
lowRiskThreshold: ReputationThreshold.LOW_RISK,
|
||||||
@@ -94,13 +80,7 @@ export class IPReputationChecker {
|
|||||||
enableIPInfo: true
|
enableIPInfo: true
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor for IPReputationChecker
|
|
||||||
* @param options Configuration options
|
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
*/
|
|
||||||
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
constructor(options: IIPReputationOptions = {}, storageManager?: any) {
|
||||||
// Merge with default options
|
|
||||||
this.options = {
|
this.options = {
|
||||||
...IPReputationChecker.DEFAULT_OPTIONS,
|
...IPReputationChecker.DEFAULT_OPTIONS,
|
||||||
...options
|
...options
|
||||||
@@ -108,36 +88,18 @@ export class IPReputationChecker {
|
|||||||
|
|
||||||
this.storageManager = storageManager;
|
this.storageManager = storageManager;
|
||||||
|
|
||||||
// If no storage manager provided, log warning
|
|
||||||
if (!storageManager && this.options.enableLocalCache) {
|
|
||||||
logger.log('warn',
|
|
||||||
'⚠️ WARNING: IPReputationChecker initialized without StorageManager.\n' +
|
|
||||||
' IP reputation cache will only be stored to filesystem.\n' +
|
|
||||||
' Consider passing a StorageManager instance for better storage flexibility.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize reputation cache
|
|
||||||
this.reputationCache = new LRUCache<string, IReputationResult>({
|
this.reputationCache = new LRUCache<string, IReputationResult>({
|
||||||
max: this.options.maxCacheSize,
|
max: this.options.maxCacheSize,
|
||||||
ttl: this.options.cacheTTL, // Cache TTL
|
ttl: this.options.cacheTTL,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load cache from disk if enabled
|
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
// Fire and forget the load operation
|
|
||||||
this.loadCache().catch(error => {
|
this.loadCache().catch(error => {
|
||||||
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
|
logger.log('error', `Failed to load IP reputation cache during initialization: ${error.message}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the singleton instance of the checker
|
|
||||||
* @param options Configuration options
|
|
||||||
* @param storageManager Optional StorageManager instance for persistence
|
|
||||||
* @returns Singleton instance
|
|
||||||
*/
|
|
||||||
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
public static getInstance(options: IIPReputationOptions = {}, storageManager?: any): IPReputationChecker {
|
||||||
if (!IPReputationChecker.instance) {
|
if (!IPReputationChecker.instance) {
|
||||||
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
IPReputationChecker.instance = new IPReputationChecker(options, storageManager);
|
||||||
@@ -146,13 +108,10 @@ export class IPReputationChecker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check an IP address's reputation
|
* Check an IP address's reputation via the Rust bridge
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns Reputation check result
|
|
||||||
*/
|
*/
|
||||||
public async checkReputation(ip: string): Promise<IReputationResult> {
|
public async checkReputation(ip: string): Promise<IReputationResult> {
|
||||||
try {
|
try {
|
||||||
// Validate IP address format
|
|
||||||
if (!this.isValidIPAddress(ip)) {
|
if (!this.isValidIPAddress(ip)) {
|
||||||
logger.log('warn', `Invalid IP address format: ${ip}`);
|
logger.log('warn', `Invalid IP address format: ${ip}`);
|
||||||
return this.createErrorResult(ip, 'Invalid IP address format');
|
return this.createErrorResult(ip, 'Invalid IP address format');
|
||||||
@@ -168,11 +127,10 @@ export class IPReputationChecker {
|
|||||||
return cachedResult;
|
return cachedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try Rust bridge first (parallel DNSBL via tokio — faster than Node sequential DNS)
|
// Delegate to Rust bridge
|
||||||
const bridge = RustSecurityBridge.getInstance();
|
const bridge = RustSecurityBridge.getInstance();
|
||||||
if (bridge.running) {
|
|
||||||
try {
|
|
||||||
const rustResult = await bridge.checkIpReputation(ip);
|
const rustResult = await bridge.checkIpReputation(ip);
|
||||||
|
|
||||||
const result: IReputationResult = {
|
const result: IReputationResult = {
|
||||||
score: rustResult.score,
|
score: rustResult.score,
|
||||||
isSpam: rustResult.listed_count > 0,
|
isSpam: rustResult.listed_count > 0,
|
||||||
@@ -184,246 +142,32 @@ export class IPReputationChecker {
|
|||||||
.map(d => d.server),
|
.map(d => d.server),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.reputationCache.set(ip, result);
|
this.reputationCache.set(ip, result);
|
||||||
|
|
||||||
if (this.options.enableLocalCache) {
|
if (this.options.enableLocalCache) {
|
||||||
this.saveCache().catch(error => {
|
this.saveCache().catch(error => {
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logReputationCheck(ip, result);
|
this.logReputationCheck(ip, result);
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
logger.log('warn', `Rust IP reputation check failed, falling back to TS: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: TypeScript DNSBL implementation
|
|
||||||
const result: IReputationResult = {
|
|
||||||
score: 100, // Start with perfect score
|
|
||||||
isSpam: false,
|
|
||||||
isProxy: false,
|
|
||||||
isTor: false,
|
|
||||||
isVPN: false,
|
|
||||||
timestamp: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check IP against DNS blacklists if enabled
|
|
||||||
if (this.options.enableDNSBL) {
|
|
||||||
const dnsblResult = await this.checkDNSBL(ip);
|
|
||||||
|
|
||||||
// Update result with DNSBL information
|
|
||||||
result.score -= dnsblResult.listCount * 10; // Subtract 10 points per blacklist
|
|
||||||
result.isSpam = dnsblResult.listCount > 0;
|
|
||||||
result.blacklists = dnsblResult.lists;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get additional IP information if enabled
|
|
||||||
if (this.options.enableIPInfo) {
|
|
||||||
const ipInfo = await this.getIPInfo(ip);
|
|
||||||
|
|
||||||
// Update result with IP info
|
|
||||||
result.country = ipInfo.country;
|
|
||||||
result.asn = ipInfo.asn;
|
|
||||||
result.org = ipInfo.org;
|
|
||||||
|
|
||||||
// Adjust score based on IP type
|
|
||||||
if (ipInfo.type === IPType.PROXY || ipInfo.type === IPType.TOR || ipInfo.type === IPType.VPN) {
|
|
||||||
result.score -= 30; // Subtract 30 points for proxies, Tor, VPNs
|
|
||||||
|
|
||||||
// Set proxy flags
|
|
||||||
result.isProxy = ipInfo.type === IPType.PROXY;
|
|
||||||
result.isTor = ipInfo.type === IPType.TOR;
|
|
||||||
result.isVPN = ipInfo.type === IPType.VPN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure score is between 0 and 100
|
|
||||||
result.score = Math.max(0, Math.min(100, result.score));
|
|
||||||
|
|
||||||
// Update cache with result
|
|
||||||
this.reputationCache.set(ip, result);
|
|
||||||
|
|
||||||
// Save cache if enabled
|
|
||||||
if (this.options.enableLocalCache) {
|
|
||||||
// Fire and forget the save operation
|
|
||||||
this.saveCache().catch(error => {
|
|
||||||
logger.log('error', `Failed to save IP reputation cache: ${error.message}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the reputation check
|
|
||||||
this.logReputationCheck(ip, result);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
logger.log('error', `Error checking IP reputation for ${ip}: ${error.message}`, {
|
||||||
ip,
|
ip,
|
||||||
stack: error.stack
|
stack: error.stack
|
||||||
});
|
});
|
||||||
|
const errorResult = this.createErrorResult(ip, error.message);
|
||||||
return this.createErrorResult(ip, error.message);
|
// Cache error results to avoid repeated failing lookups
|
||||||
|
this.reputationCache.set(ip, errorResult);
|
||||||
|
return errorResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check an IP against DNS blacklists
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns DNSBL check results
|
|
||||||
*/
|
|
||||||
private async checkDNSBL(ip: string): Promise<{
|
|
||||||
listCount: number;
|
|
||||||
lists: string[];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// Reverse the IP for DNSBL queries
|
|
||||||
const reversedIP = this.reverseIP(ip);
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
this.options.dnsblServers.map(async (server) => {
|
|
||||||
try {
|
|
||||||
const lookupDomain = `${reversedIP}.${server}`;
|
|
||||||
await plugins.dns.promises.resolve(lookupDomain);
|
|
||||||
return server; // IP is listed in this DNSBL
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOTFOUND') {
|
|
||||||
return null; // IP is not listed in this DNSBL
|
|
||||||
}
|
|
||||||
throw error; // Other error
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract successful lookups (listed in DNSBL)
|
|
||||||
const lists = results
|
|
||||||
.filter((result): result is PromiseFulfilledResult<string> =>
|
|
||||||
result.status === 'fulfilled' && result.value !== null
|
|
||||||
)
|
|
||||||
.map(result => result.value);
|
|
||||||
|
|
||||||
return {
|
|
||||||
listCount: lists.length,
|
|
||||||
lists
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error checking DNSBL for ${ip}: ${error.message}`);
|
|
||||||
return {
|
|
||||||
listCount: 0,
|
|
||||||
lists: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get information about an IP address
|
|
||||||
* @param ip IP address to check
|
|
||||||
* @returns IP information
|
|
||||||
*/
|
|
||||||
private async getIPInfo(ip: string): Promise<{
|
|
||||||
country?: string;
|
|
||||||
asn?: string;
|
|
||||||
org?: string;
|
|
||||||
type: IPType;
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
// In a real implementation, this would use an IP data service API
|
|
||||||
// For this implementation, we'll use a simplified approach
|
|
||||||
|
|
||||||
// Check if it's a known Tor exit node (simplified)
|
|
||||||
const isTor = ip.startsWith('171.25.') || ip.startsWith('185.220.') || ip.startsWith('95.216.');
|
|
||||||
|
|
||||||
// Check if it's a known VPN (simplified)
|
|
||||||
const isVPN = ip.startsWith('185.156.') || ip.startsWith('37.120.');
|
|
||||||
|
|
||||||
// Check if it's a known proxy (simplified)
|
|
||||||
const isProxy = ip.startsWith('34.92.') || ip.startsWith('34.206.');
|
|
||||||
|
|
||||||
// Determine IP type
|
|
||||||
let type = IPType.UNKNOWN;
|
|
||||||
if (isTor) {
|
|
||||||
type = IPType.TOR;
|
|
||||||
} else if (isVPN) {
|
|
||||||
type = IPType.VPN;
|
|
||||||
} else if (isProxy) {
|
|
||||||
type = IPType.PROXY;
|
|
||||||
} else {
|
|
||||||
// Simple datacenters detection (major cloud providers)
|
|
||||||
if (
|
|
||||||
ip.startsWith('13.') || // AWS
|
|
||||||
ip.startsWith('35.') || // Google Cloud
|
|
||||||
ip.startsWith('52.') || // AWS
|
|
||||||
ip.startsWith('34.') || // Google Cloud
|
|
||||||
ip.startsWith('104.') // Various providers
|
|
||||||
) {
|
|
||||||
type = IPType.DATACENTER;
|
|
||||||
} else {
|
|
||||||
type = IPType.RESIDENTIAL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the information
|
|
||||||
return {
|
|
||||||
country: this.determineCountry(ip), // Simplified, would use geolocation service
|
|
||||||
asn: 'AS12345', // Simplified, would look up real ASN
|
|
||||||
org: this.determineOrg(ip), // Simplified, would use real org data
|
|
||||||
type
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.log('error', `Error getting IP info for ${ip}: ${error.message}`);
|
|
||||||
return {
|
|
||||||
type: IPType.UNKNOWN
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified method to determine country from IP
|
|
||||||
* In a real implementation, this would use a geolocation database or service
|
|
||||||
* @param ip IP address
|
|
||||||
* @returns Country code
|
|
||||||
*/
|
|
||||||
private determineCountry(ip: string): string {
|
|
||||||
// Simplified mapping for demo purposes
|
|
||||||
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'US';
|
|
||||||
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'US';
|
|
||||||
if (ip.startsWith('185.')) return 'NL';
|
|
||||||
if (ip.startsWith('171.')) return 'DE';
|
|
||||||
return 'XX'; // Unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified method to determine organization from IP
|
|
||||||
* In a real implementation, this would use an IP-to-org database or service
|
|
||||||
* @param ip IP address
|
|
||||||
* @returns Organization name
|
|
||||||
*/
|
|
||||||
private determineOrg(ip: string): string {
|
|
||||||
// Simplified mapping for demo purposes
|
|
||||||
if (ip.startsWith('13.') || ip.startsWith('52.')) return 'Amazon AWS';
|
|
||||||
if (ip.startsWith('35.') || ip.startsWith('34.')) return 'Google Cloud';
|
|
||||||
if (ip.startsWith('185.156.')) return 'NordVPN';
|
|
||||||
if (ip.startsWith('37.120.')) return 'ExpressVPN';
|
|
||||||
if (ip.startsWith('185.220.')) return 'Tor Exit Node';
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse an IP address for DNSBL lookups (e.g., 1.2.3.4 -> 4.3.2.1)
|
|
||||||
* @param ip IP address to reverse
|
|
||||||
* @returns Reversed IP for DNSBL queries
|
|
||||||
*/
|
|
||||||
private reverseIP(ip: string): string {
|
|
||||||
return ip.split('.').reverse().join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error result for when reputation check fails
|
|
||||||
* @param ip IP address
|
|
||||||
* @param errorMessage Error message
|
|
||||||
* @returns Error result
|
|
||||||
*/
|
|
||||||
private createErrorResult(ip: string, errorMessage: string): IReputationResult {
|
private createErrorResult(ip: string, errorMessage: string): IReputationResult {
|
||||||
return {
|
return {
|
||||||
score: 50, // Neutral score for errors
|
score: 50,
|
||||||
isSpam: false,
|
isSpam: false,
|
||||||
isProxy: false,
|
isProxy: false,
|
||||||
isTor: false,
|
isTor: false,
|
||||||
@@ -433,32 +177,17 @@ export class IPReputationChecker {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate IP address format
|
|
||||||
* @param ip IP address to validate
|
|
||||||
* @returns Whether the IP is valid
|
|
||||||
*/
|
|
||||||
private isValidIPAddress(ip: string): boolean {
|
private isValidIPAddress(ip: string): boolean {
|
||||||
// IPv4 regex pattern
|
|
||||||
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
const ipv4Pattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
return ipv4Pattern.test(ip);
|
return ipv4Pattern.test(ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Log reputation check to security logger
|
|
||||||
* @param ip IP address
|
|
||||||
* @param result Reputation result
|
|
||||||
*/
|
|
||||||
private logReputationCheck(ip: string, result: IReputationResult): void {
|
private logReputationCheck(ip: string, result: IReputationResult): void {
|
||||||
// Determine log level based on reputation score
|
|
||||||
let logLevel = SecurityLogLevel.INFO;
|
let logLevel = SecurityLogLevel.INFO;
|
||||||
if (result.score < this.options.highRiskThreshold) {
|
if (result.score < this.options.highRiskThreshold) {
|
||||||
logLevel = SecurityLogLevel.WARN;
|
logLevel = SecurityLogLevel.WARN;
|
||||||
} else if (result.score < this.options.mediumRiskThreshold) {
|
|
||||||
logLevel = SecurityLogLevel.INFO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the check
|
|
||||||
SecurityLogger.getInstance().logEvent({
|
SecurityLogger.getInstance().logEvent({
|
||||||
level: logLevel,
|
level: logLevel,
|
||||||
type: SecurityEventType.IP_REPUTATION,
|
type: SecurityEventType.IP_REPUTATION,
|
||||||
@@ -477,36 +206,27 @@ export class IPReputationChecker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save cache to disk or storage manager
|
|
||||||
*/
|
|
||||||
private async saveCache(): Promise<void> {
|
private async saveCache(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Convert cache entries to serializable array
|
|
||||||
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
const entries = Array.from(this.reputationCache.entries()).map(([ip, data]) => ({
|
||||||
ip,
|
ip,
|
||||||
data
|
data
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Only save if we have entries
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheData = JSON.stringify(entries);
|
const cacheData = JSON.stringify(entries);
|
||||||
|
|
||||||
// Save to storage manager if available
|
|
||||||
if (this.storageManager) {
|
if (this.storageManager) {
|
||||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
logger.log('info', `Saved ${entries.length} IP reputation cache entries to StorageManager`);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to filesystem
|
|
||||||
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
const cacheDir = plugins.path.join(paths.dataDir, 'security');
|
||||||
await plugins.smartfs.directory(cacheDir).recursive().create();
|
await plugins.smartfs.directory(cacheDir).recursive().create();
|
||||||
|
|
||||||
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
|
const cacheFile = plugins.path.join(cacheDir, 'ip_reputation_cache.json');
|
||||||
await plugins.smartfs.file(cacheFile).write(cacheData);
|
await plugins.smartfs.file(cacheFile).write(cacheData);
|
||||||
|
|
||||||
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
logger.log('info', `Saved ${entries.length} IP reputation cache entries to disk`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -514,33 +234,23 @@ export class IPReputationChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load cache from disk or storage manager
|
|
||||||
*/
|
|
||||||
private async loadCache(): Promise<void> {
|
private async loadCache(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
let cacheData: string | null = null;
|
let cacheData: string | null = null;
|
||||||
let fromFilesystem = false;
|
let fromFilesystem = false;
|
||||||
|
|
||||||
// Try to load from storage manager first
|
|
||||||
if (this.storageManager) {
|
if (this.storageManager) {
|
||||||
try {
|
try {
|
||||||
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
cacheData = await this.storageManager.get('/security/ip-reputation-cache.json');
|
||||||
|
|
||||||
if (!cacheData) {
|
if (!cacheData) {
|
||||||
// Check if data exists in filesystem and migrate it
|
|
||||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
||||||
|
|
||||||
if (plugins.fs.existsSync(cacheFile)) {
|
if (plugins.fs.existsSync(cacheFile)) {
|
||||||
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
|
logger.log('info', 'Migrating IP reputation cache from filesystem to StorageManager');
|
||||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
||||||
fromFilesystem = true;
|
fromFilesystem = true;
|
||||||
|
|
||||||
// Migrate to storage manager
|
|
||||||
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
await this.storageManager.set('/security/ip-reputation-cache.json', cacheData);
|
||||||
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
|
logger.log('info', 'IP reputation cache migrated to StorageManager successfully');
|
||||||
|
|
||||||
// Optionally delete the old file after successful migration
|
|
||||||
try {
|
try {
|
||||||
plugins.fs.unlinkSync(cacheFile);
|
plugins.fs.unlinkSync(cacheFile);
|
||||||
logger.log('info', 'Old cache file removed after migration');
|
logger.log('info', 'Old cache file removed after migration');
|
||||||
@@ -553,27 +263,21 @@ export class IPReputationChecker {
|
|||||||
logger.log('error', `Error loading from StorageManager: ${error.message}`);
|
logger.log('error', `Error loading from StorageManager: ${error.message}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No storage manager, load from filesystem
|
|
||||||
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
const cacheFile = plugins.path.join(paths.dataDir, 'security', 'ip_reputation_cache.json');
|
||||||
|
|
||||||
if (plugins.fs.existsSync(cacheFile)) {
|
if (plugins.fs.existsSync(cacheFile)) {
|
||||||
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
cacheData = plugins.fs.readFileSync(cacheFile, 'utf8');
|
||||||
fromFilesystem = true;
|
fromFilesystem = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and restore cache if data was found
|
|
||||||
if (cacheData) {
|
if (cacheData) {
|
||||||
const entries = JSON.parse(cacheData);
|
const entries = JSON.parse(cacheData);
|
||||||
|
|
||||||
// Validate and filter entries
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const validEntries = entries.filter(entry => {
|
const validEntries = entries.filter(entry => {
|
||||||
const age = now - entry.data.timestamp;
|
const age = now - entry.data.timestamp;
|
||||||
return age < this.options.cacheTTL; // Only load entries that haven't expired
|
return age < this.options.cacheTTL;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore cache
|
|
||||||
for (const entry of validEntries) {
|
for (const entry of validEntries) {
|
||||||
this.reputationCache.set(entry.ip, entry.data);
|
this.reputationCache.set(entry.ip, entry.data);
|
||||||
}
|
}
|
||||||
@@ -586,11 +290,6 @@ export class IPReputationChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the risk level for a reputation score
|
|
||||||
* @param score Reputation score (0-100)
|
|
||||||
* @returns Risk level description
|
|
||||||
*/
|
|
||||||
public static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted' {
|
public static getRiskLevel(score: number): 'high' | 'medium' | 'low' | 'trusted' {
|
||||||
if (score < ReputationThreshold.HIGH_RISK) {
|
if (score < ReputationThreshold.HIGH_RISK) {
|
||||||
return 'high';
|
return 'high';
|
||||||
@@ -603,16 +302,10 @@ export class IPReputationChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the storage manager after instantiation
|
|
||||||
* This is useful when the storage manager is not available at construction time
|
|
||||||
* @param storageManager The StorageManager instance to use
|
|
||||||
*/
|
|
||||||
public updateStorageManager(storageManager: any): void {
|
public updateStorageManager(storageManager: any): void {
|
||||||
this.storageManager = storageManager;
|
this.storageManager = storageManager;
|
||||||
logger.log('info', 'IPReputationChecker storage manager updated');
|
logger.log('info', 'IPReputationChecker storage manager updated');
|
||||||
|
|
||||||
// If cache is enabled and we have entries, save them to the new storage manager
|
|
||||||
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
|
if (this.options.enableLocalCache && this.reputationCache.size > 0) {
|
||||||
this.saveCache().catch(error => {
|
this.saveCache().catch(error => {
|
||||||
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
|
logger.log('error', `Failed to save cache to new storage manager: ${error.message}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user