spamscanner 6.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +515 -0
- package/dist/cjs/arf.cjs +539 -0
- package/dist/cjs/arf.cjs.map +7 -0
- package/dist/cjs/cli.cjs +4729 -0
- package/dist/cjs/cli.cjs.map +7 -0
- package/dist/cjs/{index.js → index.cjs} +1663 -49
- package/dist/cjs/index.cjs.map +7 -0
- package/dist/esm/arf.js +514 -0
- package/dist/esm/arf.js.map +7 -0
- package/dist/esm/cli.js +4713 -0
- package/dist/esm/cli.js.map +7 -0
- package/dist/esm/index.js +1662 -48
- package/dist/esm/index.js.map +4 -4
- package/dist/types/arf.d.ts +129 -0
- package/dist/types/auth.d.ts +157 -0
- package/dist/types/get-attributes.d.ts +148 -0
- package/dist/types/index.d.ts +105 -2
- package/dist/types/is-arbitrary.d.ts +231 -0
- package/dist/types/reputation.d.ts +60 -0
- package/package.json +11 -2
- package/dist/cjs/index.js.map +0 -7
package/dist/esm/cli.js
ADDED
|
@@ -0,0 +1,4713 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// replacement-words.json
|
|
13
|
+
var replacement_words_default;
|
|
14
|
+
var init_replacement_words = __esm({
|
|
15
|
+
"replacement-words.json"() {
|
|
16
|
+
replacement_words_default = [
|
|
17
|
+
"url",
|
|
18
|
+
"email",
|
|
19
|
+
"number",
|
|
20
|
+
"currency",
|
|
21
|
+
"initialism",
|
|
22
|
+
"abbreviation",
|
|
23
|
+
"emoji",
|
|
24
|
+
"hexa",
|
|
25
|
+
"mac",
|
|
26
|
+
"phone",
|
|
27
|
+
"bitcoin",
|
|
28
|
+
"cc"
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// replacements.js
|
|
34
|
+
var replacements_exports = {};
|
|
35
|
+
__export(replacements_exports, {
|
|
36
|
+
default: () => replacements_default
|
|
37
|
+
});
|
|
38
|
+
import { debuglog as debuglog5 } from "node:util";
|
|
39
|
+
import { readFileSync } from "node:fs";
|
|
40
|
+
import cryptoRandomString from "crypto-random-string";
|
|
41
|
+
var debug5, randomOptions, replacements, replacements_default;
|
|
42
|
+
var init_replacements = __esm({
|
|
43
|
+
"replacements.js"() {
|
|
44
|
+
init_replacement_words();
|
|
45
|
+
debug5 = debuglog5("spamscanner");
|
|
46
|
+
randomOptions = {
|
|
47
|
+
length: 10,
|
|
48
|
+
characters: "abcdefghijklmnopqrstuvwxyz"
|
|
49
|
+
};
|
|
50
|
+
replacements = {};
|
|
51
|
+
try {
|
|
52
|
+
replacements = JSON.parse(readFileSync("./replacements.json", "utf8"));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
debug5(error);
|
|
55
|
+
for (const replacement of replacement_words_default) {
|
|
56
|
+
replacements[replacement] = `${replacement}${cryptoRandomString(randomOptions)}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
replacements_default = replacements;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// get-classifier.js
|
|
64
|
+
var get_classifier_exports = {};
|
|
65
|
+
__export(get_classifier_exports, {
|
|
66
|
+
default: () => get_classifier_default
|
|
67
|
+
});
|
|
68
|
+
import { debuglog as debuglog6 } from "node:util";
|
|
69
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
70
|
+
import NaiveBayes from "@ladjs/naivebayes";
|
|
71
|
+
var debug6, classifier, get_classifier_default;
|
|
72
|
+
var init_get_classifier = __esm({
|
|
73
|
+
"get-classifier.js"() {
|
|
74
|
+
debug6 = debuglog6("spamscanner");
|
|
75
|
+
classifier = new NaiveBayes().toJsonObject();
|
|
76
|
+
try {
|
|
77
|
+
classifier = JSON.parse(readFileSync2("./classifier.json", "utf8"));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
debug6(error);
|
|
80
|
+
}
|
|
81
|
+
get_classifier_default = classifier;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// src/enhanced-idn-detector.js
|
|
86
|
+
var enhanced_idn_detector_exports = {};
|
|
87
|
+
__export(enhanced_idn_detector_exports, {
|
|
88
|
+
default: () => enhanced_idn_detector_default
|
|
89
|
+
});
|
|
90
|
+
import { createHash } from "node:crypto";
|
|
91
|
+
import confusables from "confusables";
|
|
92
|
+
var CONFUSABLE_CHARS, LEGITIMATE_IDN_DOMAINS, POPULAR_BRANDS, EnhancedIDNDetector, enhanced_idn_detector_default;
|
|
93
|
+
var init_enhanced_idn_detector = __esm({
|
|
94
|
+
"src/enhanced-idn-detector.js"() {
|
|
95
|
+
CONFUSABLE_CHARS = /* @__PURE__ */ new Map([
|
|
96
|
+
// Cyrillic to Latin confusables
|
|
97
|
+
["\u0430", "a"],
|
|
98
|
+
["\u0435", "e"],
|
|
99
|
+
["\u043E", "o"],
|
|
100
|
+
["\u0440", "p"],
|
|
101
|
+
["\u0441", "c"],
|
|
102
|
+
["\u0445", "x"],
|
|
103
|
+
["\u0443", "y"],
|
|
104
|
+
["\u0410", "A"],
|
|
105
|
+
["\u0412", "B"],
|
|
106
|
+
["\u0415", "E"],
|
|
107
|
+
["\u041A", "K"],
|
|
108
|
+
["\u041C", "M"],
|
|
109
|
+
["\u041D", "H"],
|
|
110
|
+
["\u041E", "O"],
|
|
111
|
+
["\u0420", "P"],
|
|
112
|
+
["\u0421", "C"],
|
|
113
|
+
["\u0422", "T"],
|
|
114
|
+
["\u0425", "X"],
|
|
115
|
+
["\u0423", "Y"],
|
|
116
|
+
// Greek to Latin confusables
|
|
117
|
+
["\u03B1", "a"],
|
|
118
|
+
["\u03BF", "o"],
|
|
119
|
+
["\u03C1", "p"],
|
|
120
|
+
["\u03C5", "u"],
|
|
121
|
+
["\u03BD", "v"],
|
|
122
|
+
["\u03B9", "i"],
|
|
123
|
+
["\u0391", "A"],
|
|
124
|
+
["\u0392", "B"],
|
|
125
|
+
["\u0395", "E"],
|
|
126
|
+
["\u0396", "Z"],
|
|
127
|
+
["\u0397", "H"],
|
|
128
|
+
["\u0399", "I"],
|
|
129
|
+
["\u039A", "K"],
|
|
130
|
+
["\u039C", "M"],
|
|
131
|
+
["\u039D", "N"],
|
|
132
|
+
["\u039F", "O"],
|
|
133
|
+
["\u03A1", "P"],
|
|
134
|
+
["\u03A4", "T"],
|
|
135
|
+
["\u03A5", "Y"],
|
|
136
|
+
// Mathematical symbols
|
|
137
|
+
["\u{1D41A}", "a"],
|
|
138
|
+
["\u{1D41B}", "b"],
|
|
139
|
+
["\u{1D41C}", "c"],
|
|
140
|
+
["\u{1D41D}", "d"],
|
|
141
|
+
["\u{1D41E}", "e"],
|
|
142
|
+
["\u{1D7CE}", "0"],
|
|
143
|
+
["\u{1D7CF}", "1"],
|
|
144
|
+
["\u{1D7D0}", "2"],
|
|
145
|
+
["\u{1D7D1}", "3"],
|
|
146
|
+
["\u{1D7D2}", "4"],
|
|
147
|
+
// Other common confusables
|
|
148
|
+
["\u212F", "e"],
|
|
149
|
+
["\u210A", "g"],
|
|
150
|
+
["\u210E", "h"],
|
|
151
|
+
["\u2113", "l"],
|
|
152
|
+
["\u2134", "o"],
|
|
153
|
+
["\u212F", "e"],
|
|
154
|
+
["\u2170", "i"],
|
|
155
|
+
["\u2171", "ii"],
|
|
156
|
+
["\u2172", "iii"],
|
|
157
|
+
["\u2173", "iv"],
|
|
158
|
+
["\u2174", "v"]
|
|
159
|
+
]);
|
|
160
|
+
LEGITIMATE_IDN_DOMAINS = /* @__PURE__ */ new Set([
|
|
161
|
+
"xn--fsq.xn--0zwm56d",
|
|
162
|
+
// 中国
|
|
163
|
+
"xn--fiqs8s",
|
|
164
|
+
// 中国
|
|
165
|
+
"xn--fiqz9s",
|
|
166
|
+
// 中囯
|
|
167
|
+
"xn--j6w193g",
|
|
168
|
+
// 香港
|
|
169
|
+
"xn--55qx5d",
|
|
170
|
+
// 公司
|
|
171
|
+
"xn--io0a7i"
|
|
172
|
+
// 网络
|
|
173
|
+
// Add more legitimate domains as needed
|
|
174
|
+
]);
|
|
175
|
+
POPULAR_BRANDS = [
|
|
176
|
+
"google",
|
|
177
|
+
"facebook",
|
|
178
|
+
"amazon",
|
|
179
|
+
"apple",
|
|
180
|
+
"microsoft",
|
|
181
|
+
"twitter",
|
|
182
|
+
"instagram",
|
|
183
|
+
"linkedin",
|
|
184
|
+
"youtube",
|
|
185
|
+
"netflix",
|
|
186
|
+
"paypal",
|
|
187
|
+
"ebay",
|
|
188
|
+
"yahoo",
|
|
189
|
+
"adobe",
|
|
190
|
+
"salesforce",
|
|
191
|
+
"oracle",
|
|
192
|
+
"ibm",
|
|
193
|
+
"cisco",
|
|
194
|
+
"intel",
|
|
195
|
+
"nvidia",
|
|
196
|
+
"tesla",
|
|
197
|
+
"citibank",
|
|
198
|
+
"bankofamerica",
|
|
199
|
+
"wellsfargo",
|
|
200
|
+
"chase",
|
|
201
|
+
"americanexpress"
|
|
202
|
+
];
|
|
203
|
+
EnhancedIDNDetector = class {
|
|
204
|
+
constructor(options = {}) {
|
|
205
|
+
this.options = {
|
|
206
|
+
strictMode: false,
|
|
207
|
+
enableWhitelist: true,
|
|
208
|
+
enableBrandProtection: true,
|
|
209
|
+
enableContextAnalysis: true,
|
|
210
|
+
maxSimilarityThreshold: 0.8,
|
|
211
|
+
minDomainAge: 30,
|
|
212
|
+
// Days
|
|
213
|
+
...options
|
|
214
|
+
};
|
|
215
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Main detection method with comprehensive analysis
|
|
219
|
+
*/
|
|
220
|
+
detectHomographAttack(domain, context = {}) {
|
|
221
|
+
const cacheKey = this.getCacheKey(domain, context);
|
|
222
|
+
if (this.cache.has(cacheKey)) {
|
|
223
|
+
return this.cache.get(cacheKey);
|
|
224
|
+
}
|
|
225
|
+
const result = this.analyzeComprehensive(domain, context);
|
|
226
|
+
this.cache.set(cacheKey, result);
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Comprehensive analysis combining multiple detection methods
|
|
231
|
+
*/
|
|
232
|
+
analyzeComprehensive(domain, context) {
|
|
233
|
+
const analysis = {
|
|
234
|
+
domain,
|
|
235
|
+
isIDN: this.isIDNDomain(domain),
|
|
236
|
+
riskScore: 0,
|
|
237
|
+
riskFactors: [],
|
|
238
|
+
recommendations: [],
|
|
239
|
+
confidence: 0
|
|
240
|
+
};
|
|
241
|
+
if (this.options.enableWhitelist && this.isWhitelisted(domain)) {
|
|
242
|
+
analysis.riskScore = 0;
|
|
243
|
+
analysis.confidence = 1;
|
|
244
|
+
analysis.recommendations.push("Domain is whitelisted as legitimate");
|
|
245
|
+
return analysis;
|
|
246
|
+
}
|
|
247
|
+
if (analysis.isIDN) {
|
|
248
|
+
analysis.riskScore += 0.3;
|
|
249
|
+
analysis.riskFactors.push("Contains non-ASCII characters");
|
|
250
|
+
}
|
|
251
|
+
const confusableAnalysis = this.analyzeConfusableCharacters(domain);
|
|
252
|
+
analysis.riskScore += confusableAnalysis.score;
|
|
253
|
+
analysis.riskFactors.push(...confusableAnalysis.factors);
|
|
254
|
+
if (this.options.enableBrandProtection) {
|
|
255
|
+
const brandAnalysis = this.analyzeBrandSimilarity(domain);
|
|
256
|
+
analysis.riskScore += brandAnalysis.score;
|
|
257
|
+
analysis.riskFactors.push(...brandAnalysis.factors);
|
|
258
|
+
}
|
|
259
|
+
const scriptAnalysis = this.analyzeScriptMixing(domain);
|
|
260
|
+
analysis.riskScore += scriptAnalysis.score;
|
|
261
|
+
analysis.riskFactors.push(...scriptAnalysis.factors);
|
|
262
|
+
if (this.options.enableContextAnalysis && context) {
|
|
263
|
+
const contextAnalysis = this.analyzeContext(domain, context);
|
|
264
|
+
analysis.riskScore += contextAnalysis.score;
|
|
265
|
+
analysis.riskFactors.push(...contextAnalysis.factors);
|
|
266
|
+
}
|
|
267
|
+
if (domain.includes("xn--")) {
|
|
268
|
+
const punycodeAnalysis = this.analyzePunycode(domain);
|
|
269
|
+
analysis.riskScore += punycodeAnalysis.score;
|
|
270
|
+
analysis.riskFactors.push(...punycodeAnalysis.factors);
|
|
271
|
+
}
|
|
272
|
+
analysis.confidence = Math.min(analysis.riskScore, 1);
|
|
273
|
+
analysis.recommendations = this.generateRecommendations(analysis);
|
|
274
|
+
return analysis;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Detect if domain contains IDN characters
|
|
278
|
+
*/
|
|
279
|
+
isIDNDomain(domain) {
|
|
280
|
+
return domain.includes("xn--") || /[^\u0000-\u007F]/.test(domain);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Check if domain is in whitelist
|
|
284
|
+
*/
|
|
285
|
+
isWhitelisted(domain) {
|
|
286
|
+
const normalized = domain.toLowerCase();
|
|
287
|
+
return LEGITIMATE_IDN_DOMAINS.has(normalized);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Analyze confusable characters
|
|
291
|
+
*/
|
|
292
|
+
analyzeConfusableCharacters(domain) {
|
|
293
|
+
const analysis = { score: 0, factors: [] };
|
|
294
|
+
let confusableCount = 0;
|
|
295
|
+
let totalChars = 0;
|
|
296
|
+
try {
|
|
297
|
+
const normalized = confusables(domain);
|
|
298
|
+
if (normalized !== domain) {
|
|
299
|
+
for (const char of domain) {
|
|
300
|
+
totalChars++;
|
|
301
|
+
const normalizedChar = confusables(char);
|
|
302
|
+
if (normalizedChar !== char) {
|
|
303
|
+
confusableCount++;
|
|
304
|
+
analysis.factors.push(`Confusable character: ${char} \u2192 ${normalizedChar}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (confusableCount > 0) {
|
|
308
|
+
const ratio = confusableCount / totalChars;
|
|
309
|
+
analysis.score = Math.min(ratio * 0.8, 0.6);
|
|
310
|
+
analysis.factors.push(`${confusableCount}/${totalChars} characters are confusable`, `Normalized domain: ${normalized}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
for (const char of domain) {
|
|
315
|
+
totalChars++;
|
|
316
|
+
if (CONFUSABLE_CHARS.has(char)) {
|
|
317
|
+
confusableCount++;
|
|
318
|
+
analysis.factors.push(`Confusable character: ${char} \u2192 ${CONFUSABLE_CHARS.get(char)}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (confusableCount > 0) {
|
|
322
|
+
const ratio = confusableCount / totalChars;
|
|
323
|
+
analysis.score = Math.min(ratio * 0.8, 0.6);
|
|
324
|
+
analysis.factors.push(`${confusableCount}/${totalChars} characters are confusable`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return analysis;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Analyze similarity to popular brands
|
|
331
|
+
*/
|
|
332
|
+
analyzeBrandSimilarity(domain) {
|
|
333
|
+
const analysis = { score: 0, factors: [] };
|
|
334
|
+
const cleanDomain = this.normalizeDomain(domain);
|
|
335
|
+
for (const brand of POPULAR_BRANDS) {
|
|
336
|
+
const similarity = this.calculateSimilarity(cleanDomain, brand);
|
|
337
|
+
if (similarity > this.options.maxSimilarityThreshold) {
|
|
338
|
+
analysis.score = Math.max(analysis.score, similarity * 0.7);
|
|
339
|
+
analysis.factors.push(`High similarity to ${brand}: ${(similarity * 100).toFixed(1)}%`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return analysis;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Analyze script mixing patterns
|
|
346
|
+
*/
|
|
347
|
+
analyzeScriptMixing(domain) {
|
|
348
|
+
const analysis = { score: 0, factors: [] };
|
|
349
|
+
const scripts = this.detectScripts(domain);
|
|
350
|
+
if (scripts.size > 1) {
|
|
351
|
+
const scriptList = [...scripts].join(", ");
|
|
352
|
+
analysis.factors.push(`Mixed scripts detected: ${scriptList}`);
|
|
353
|
+
if (scripts.has("Latin") && (scripts.has("Cyrillic") || scripts.has("Greek"))) {
|
|
354
|
+
analysis.score += 0.4;
|
|
355
|
+
analysis.factors.push("Suspicious Latin/Cyrillic or Latin/Greek mixing");
|
|
356
|
+
} else {
|
|
357
|
+
analysis.score += 0.2;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return analysis;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Analyze context (email headers, content, etc.)
|
|
364
|
+
*/
|
|
365
|
+
analyzeContext(domain, context) {
|
|
366
|
+
const analysis = { score: 0, factors: [] };
|
|
367
|
+
if (context.displayText && context.displayText !== domain) {
|
|
368
|
+
analysis.score += 0.3;
|
|
369
|
+
analysis.factors.push("Display text differs from actual domain");
|
|
370
|
+
}
|
|
371
|
+
if (context.senderReputation && context.senderReputation < 0.5) {
|
|
372
|
+
analysis.score += 0.2;
|
|
373
|
+
analysis.factors.push("Low sender reputation");
|
|
374
|
+
}
|
|
375
|
+
if (context.emailContent) {
|
|
376
|
+
const suspiciousPatterns = [
|
|
377
|
+
/urgent/i,
|
|
378
|
+
/verify.*account/i,
|
|
379
|
+
/suspended/i,
|
|
380
|
+
/click.*here/i,
|
|
381
|
+
/limited.*time/i,
|
|
382
|
+
/act.*now/i,
|
|
383
|
+
/confirm.*identity/i
|
|
384
|
+
];
|
|
385
|
+
for (const pattern of suspiciousPatterns) {
|
|
386
|
+
if (pattern.test(context.emailContent)) {
|
|
387
|
+
analysis.score += 0.1;
|
|
388
|
+
analysis.factors.push(`Suspicious email pattern: ${pattern.source}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return analysis;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Analyze punycode domains
|
|
396
|
+
*/
|
|
397
|
+
analyzePunycode(domain) {
|
|
398
|
+
const analysis = { score: 0, factors: [] };
|
|
399
|
+
try {
|
|
400
|
+
const decoded = this.decodePunycode(domain);
|
|
401
|
+
analysis.factors.push(`Punycode decoded: ${decoded}`);
|
|
402
|
+
const decodedAnalysis = this.analyzeConfusableCharacters(decoded);
|
|
403
|
+
analysis.score += decodedAnalysis.score * 0.8;
|
|
404
|
+
analysis.factors.push(...decodedAnalysis.factors);
|
|
405
|
+
} catch {
|
|
406
|
+
analysis.score += 0.2;
|
|
407
|
+
analysis.factors.push("Invalid punycode encoding");
|
|
408
|
+
}
|
|
409
|
+
return analysis;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Normalize domain for comparison
|
|
413
|
+
*/
|
|
414
|
+
normalizeDomain(domain) {
|
|
415
|
+
let normalized = domain.toLowerCase();
|
|
416
|
+
try {
|
|
417
|
+
normalized = confusables(normalized);
|
|
418
|
+
} catch {
|
|
419
|
+
for (const [confusable, latin] of CONFUSABLE_CHARS) {
|
|
420
|
+
normalized = normalized.replaceAll(confusable, latin);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
normalized = normalized.replace(/\.(com|org|net|edu|gov)$/, "");
|
|
424
|
+
return normalized;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Calculate string similarity using Levenshtein distance
|
|
428
|
+
*/
|
|
429
|
+
calculateSimilarity(string1, string2) {
|
|
430
|
+
const matrix = [];
|
|
431
|
+
const length1 = string1.length;
|
|
432
|
+
const length2 = string2.length;
|
|
433
|
+
for (let i = 0; i <= length2; i++) {
|
|
434
|
+
matrix[i] = [i];
|
|
435
|
+
}
|
|
436
|
+
for (let j = 0; j <= length1; j++) {
|
|
437
|
+
matrix[0][j] = j;
|
|
438
|
+
}
|
|
439
|
+
for (let i = 1; i <= length2; i++) {
|
|
440
|
+
for (let j = 1; j <= length1; j++) {
|
|
441
|
+
if (string2.charAt(i - 1) === string1.charAt(j - 1)) {
|
|
442
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
443
|
+
} else {
|
|
444
|
+
matrix[i][j] = Math.min(
|
|
445
|
+
matrix[i - 1][j - 1] + 1,
|
|
446
|
+
matrix[i][j - 1] + 1,
|
|
447
|
+
matrix[i - 1][j] + 1
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const maxLength = Math.max(length1, length2);
|
|
453
|
+
return maxLength === 0 ? 1 : (maxLength - matrix[length2][length1]) / maxLength;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Detect scripts used in domain
|
|
457
|
+
*/
|
|
458
|
+
detectScripts(domain) {
|
|
459
|
+
const scripts = /* @__PURE__ */ new Set();
|
|
460
|
+
for (const char of domain) {
|
|
461
|
+
const code = char.codePointAt(0);
|
|
462
|
+
if (code >= 65 && code <= 90 || code >= 97 && code <= 122) {
|
|
463
|
+
scripts.add("Latin");
|
|
464
|
+
} else if (code >= 1024 && code <= 1279) {
|
|
465
|
+
scripts.add("Cyrillic");
|
|
466
|
+
} else if (code >= 880 && code <= 1023) {
|
|
467
|
+
scripts.add("Greek");
|
|
468
|
+
} else if (code >= 19968 && code <= 40959) {
|
|
469
|
+
scripts.add("CJK");
|
|
470
|
+
} else if (code >= 1424 && code <= 1535) {
|
|
471
|
+
scripts.add("Hebrew");
|
|
472
|
+
} else if (code >= 1536 && code <= 1791) {
|
|
473
|
+
scripts.add("Arabic");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return scripts;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Simple punycode decoder (basic implementation)
|
|
480
|
+
*/
|
|
481
|
+
decodePunycode(domain) {
|
|
482
|
+
try {
|
|
483
|
+
const url = new URL(`http://${domain}`);
|
|
484
|
+
return url.hostname;
|
|
485
|
+
} catch {
|
|
486
|
+
return domain;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Generate recommendations based on analysis
|
|
491
|
+
*/
|
|
492
|
+
generateRecommendations(analysis) {
|
|
493
|
+
const recommendations = [];
|
|
494
|
+
if (analysis.riskScore > 0.8) {
|
|
495
|
+
recommendations.push("HIGH RISK: Likely homograph attack - block or quarantine");
|
|
496
|
+
} else if (analysis.riskScore > 0.6) {
|
|
497
|
+
recommendations.push("MEDIUM RISK: Suspicious domain - flag for review");
|
|
498
|
+
} else if (analysis.riskScore > 0.3) {
|
|
499
|
+
recommendations.push("LOW RISK: Monitor domain activity");
|
|
500
|
+
} else {
|
|
501
|
+
recommendations.push("SAFE: Domain appears legitimate");
|
|
502
|
+
}
|
|
503
|
+
if (analysis.isIDN) {
|
|
504
|
+
recommendations.push("Consider displaying punycode representation to users");
|
|
505
|
+
}
|
|
506
|
+
if (analysis.riskFactors.some((f) => f.includes("brand"))) {
|
|
507
|
+
recommendations.push("Verify domain authenticity through official channels");
|
|
508
|
+
}
|
|
509
|
+
return recommendations;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Generate cache key
|
|
513
|
+
*/
|
|
514
|
+
getCacheKey(domain, context) {
|
|
515
|
+
const contextHash = createHash("md5").update(JSON.stringify(context)).digest("hex").slice(0, 8);
|
|
516
|
+
return `${domain}:${contextHash}`;
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
enhanced_idn_detector_default = EnhancedIDNDetector;
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// src/cli.js
|
|
524
|
+
import { Buffer as Buffer4 } from "node:buffer";
|
|
525
|
+
import {
|
|
526
|
+
createReadStream,
|
|
527
|
+
readFileSync as readFileSync3,
|
|
528
|
+
writeFileSync,
|
|
529
|
+
existsSync,
|
|
530
|
+
mkdirSync
|
|
531
|
+
} from "node:fs";
|
|
532
|
+
import { createServer } from "node:net";
|
|
533
|
+
import { homedir } from "node:os";
|
|
534
|
+
import path2 from "node:path";
|
|
535
|
+
import process2 from "node:process";
|
|
536
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
537
|
+
|
|
538
|
+
// src/index.js
|
|
539
|
+
import { Buffer as Buffer3 } from "node:buffer";
|
|
540
|
+
import fs from "node:fs";
|
|
541
|
+
import path from "node:path";
|
|
542
|
+
import process from "node:process";
|
|
543
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
544
|
+
import { debuglog as debuglog7 } from "node:util";
|
|
545
|
+
import { fileURLToPath } from "node:url";
|
|
546
|
+
import autoBind from "auto-bind";
|
|
547
|
+
import AFHConvert from "ascii-fullwidth-halfwidth-convert";
|
|
548
|
+
import ClamScan from "clamscan";
|
|
549
|
+
import NaiveBayes2 from "@ladjs/naivebayes";
|
|
550
|
+
import arrayJoinConjunction from "array-join-conjunction";
|
|
551
|
+
import bitcoinRegex from "bitcoin-regex";
|
|
552
|
+
import creditCardRegex from "credit-card-regex";
|
|
553
|
+
import emailRegexSafe from "email-regex-safe";
|
|
554
|
+
import escapeStringRegexp from "escape-string-regexp";
|
|
555
|
+
import expandContractions from "@stdlib/nlp-expand-contractions";
|
|
556
|
+
import fileExtension from "file-extension";
|
|
557
|
+
import floatingPointRegex from "floating-point-regex";
|
|
558
|
+
import lande from "lande";
|
|
559
|
+
import hexaColorRegex from "hexa-color-regex";
|
|
560
|
+
import { parse as parseTldts } from "tldts";
|
|
561
|
+
import ipRegex from "ip-regex";
|
|
562
|
+
import isBuffer from "is-buffer";
|
|
563
|
+
import isSANB from "is-string-and-not-blank";
|
|
564
|
+
import macRegex from "mac-regex";
|
|
565
|
+
import natural from "natural";
|
|
566
|
+
import normalizeUrl from "normalize-url";
|
|
567
|
+
import phoneRegex from "phone-regex";
|
|
568
|
+
import snowball from "node-snowball";
|
|
569
|
+
import striptags from "striptags";
|
|
570
|
+
import superagent from "superagent";
|
|
571
|
+
import sw from "stopword";
|
|
572
|
+
import urlRegexSafe from "url-regex-safe";
|
|
573
|
+
import { simpleParser } from "mailparser";
|
|
574
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
575
|
+
|
|
576
|
+
// src/auth.js
|
|
577
|
+
import { Buffer as Buffer2 } from "node:buffer";
|
|
578
|
+
import { debuglog } from "node:util";
|
|
579
|
+
import dns from "node:dns";
|
|
580
|
+
var debug = debuglog("spamscanner:auth");
|
|
581
|
+
var mailauth;
|
|
582
|
+
var getMailauth = async () => {
|
|
583
|
+
mailauth ||= await import("mailauth");
|
|
584
|
+
return mailauth;
|
|
585
|
+
};
|
|
586
|
+
var createResolver = (timeout = 1e4) => {
|
|
587
|
+
const resolver = new dns.promises.Resolver();
|
|
588
|
+
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
589
|
+
return async (name, type) => {
|
|
590
|
+
try {
|
|
591
|
+
const controller = new AbortController();
|
|
592
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
593
|
+
let result;
|
|
594
|
+
switch (type) {
|
|
595
|
+
case "TXT": {
|
|
596
|
+
result = await resolver.resolveTxt(name);
|
|
597
|
+
result = result.map((r) => Array.isArray(r) ? r.join("") : r);
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
case "MX": {
|
|
601
|
+
result = await resolver.resolveMx(name);
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
case "A": {
|
|
605
|
+
result = await resolver.resolve4(name);
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
case "AAAA": {
|
|
609
|
+
result = await resolver.resolve6(name);
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
case "PTR": {
|
|
613
|
+
result = await resolver.resolvePtr(name);
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
case "CNAME": {
|
|
617
|
+
result = await resolver.resolveCname(name);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
default: {
|
|
621
|
+
result = await resolver.resolve(name, type);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
clearTimeout(timeoutId);
|
|
625
|
+
return result;
|
|
626
|
+
} catch (error) {
|
|
627
|
+
debug("DNS lookup failed for %s %s: %s", type, name, error.message);
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
};
|
|
632
|
+
async function authenticate(message, options = {}) {
|
|
633
|
+
const {
|
|
634
|
+
ip,
|
|
635
|
+
helo,
|
|
636
|
+
mta,
|
|
637
|
+
sender,
|
|
638
|
+
resolver = createResolver(options.timeout || 1e4)
|
|
639
|
+
} = options;
|
|
640
|
+
const defaultResult = {
|
|
641
|
+
dkim: {
|
|
642
|
+
results: [],
|
|
643
|
+
status: { result: "none", comment: "No DKIM signature found" }
|
|
644
|
+
},
|
|
645
|
+
spf: {
|
|
646
|
+
status: { result: "none", comment: "SPF check not performed" },
|
|
647
|
+
domain: null
|
|
648
|
+
},
|
|
649
|
+
dmarc: {
|
|
650
|
+
status: { result: "none", comment: "DMARC check not performed" },
|
|
651
|
+
policy: null,
|
|
652
|
+
domain: null
|
|
653
|
+
},
|
|
654
|
+
arc: {
|
|
655
|
+
status: { result: "none", comment: "No ARC chain found" },
|
|
656
|
+
chain: []
|
|
657
|
+
},
|
|
658
|
+
bimi: {
|
|
659
|
+
status: { result: "none", comment: "No BIMI record found" },
|
|
660
|
+
location: null,
|
|
661
|
+
authority: null
|
|
662
|
+
},
|
|
663
|
+
receivedChain: [],
|
|
664
|
+
headers: {}
|
|
665
|
+
};
|
|
666
|
+
if (!ip) {
|
|
667
|
+
debug("No IP address provided, skipping authentication");
|
|
668
|
+
return defaultResult;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const { authenticate: mailauthAuthenticate } = await getMailauth();
|
|
672
|
+
const messageBuffer = Buffer2.isBuffer(message) ? message : Buffer2.from(message);
|
|
673
|
+
const authResult = await mailauthAuthenticate(messageBuffer, {
|
|
674
|
+
ip,
|
|
675
|
+
helo: helo || "unknown",
|
|
676
|
+
mta: mta || "spamscanner",
|
|
677
|
+
sender,
|
|
678
|
+
resolver
|
|
679
|
+
});
|
|
680
|
+
debug("Authentication result: %o", authResult);
|
|
681
|
+
return {
|
|
682
|
+
dkim: normalizeResult(authResult.dkim, "dkim"),
|
|
683
|
+
spf: normalizeResult(authResult.spf, "spf"),
|
|
684
|
+
dmarc: normalizeResult(authResult.dmarc, "dmarc"),
|
|
685
|
+
arc: normalizeResult(authResult.arc, "arc"),
|
|
686
|
+
bimi: normalizeResult(authResult.bimi, "bimi"),
|
|
687
|
+
receivedChain: authResult.receivedChain || [],
|
|
688
|
+
headers: authResult.headers || {}
|
|
689
|
+
};
|
|
690
|
+
} catch (error) {
|
|
691
|
+
debug("Authentication failed: %s", error.message);
|
|
692
|
+
return defaultResult;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function normalizeResult(result, type) {
|
|
696
|
+
if (!result) {
|
|
697
|
+
return {
|
|
698
|
+
status: { result: "none", comment: `No ${type.toUpperCase()} result` }
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
switch (type) {
|
|
702
|
+
case "dkim": {
|
|
703
|
+
return {
|
|
704
|
+
results: result.results || [],
|
|
705
|
+
status: result.status || { result: "none", comment: "No DKIM signature found" }
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
case "spf": {
|
|
709
|
+
return {
|
|
710
|
+
status: result.status || { result: "none", comment: "SPF check not performed" },
|
|
711
|
+
domain: result.domain || null,
|
|
712
|
+
explanation: result.explanation || null
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
case "dmarc": {
|
|
716
|
+
return {
|
|
717
|
+
status: result.status || { result: "none", comment: "DMARC check not performed" },
|
|
718
|
+
policy: result.policy || null,
|
|
719
|
+
domain: result.domain || null,
|
|
720
|
+
p: result.p || null,
|
|
721
|
+
sp: result.sp || null,
|
|
722
|
+
pct: result.pct || null
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
case "arc": {
|
|
726
|
+
return {
|
|
727
|
+
status: result.status || { result: "none", comment: "No ARC chain found" },
|
|
728
|
+
chain: result.chain || [],
|
|
729
|
+
i: result.i || null
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
case "bimi": {
|
|
733
|
+
return {
|
|
734
|
+
status: result.status || { result: "none", comment: "No BIMI record found" },
|
|
735
|
+
location: result.location || null,
|
|
736
|
+
authority: result.authority || null,
|
|
737
|
+
selector: result.selector || null
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
default: {
|
|
741
|
+
return result;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function calculateAuthScore(authResult, weights = {}) {
|
|
746
|
+
const defaultWeights = {
|
|
747
|
+
dkimPass: -2,
|
|
748
|
+
// Reduce spam score if DKIM passes
|
|
749
|
+
dkimFail: 3,
|
|
750
|
+
// Increase spam score if DKIM fails
|
|
751
|
+
spfPass: -1,
|
|
752
|
+
spfFail: 2,
|
|
753
|
+
spfSoftfail: 1,
|
|
754
|
+
dmarcPass: -2,
|
|
755
|
+
dmarcFail: 4,
|
|
756
|
+
arcPass: -1,
|
|
757
|
+
arcFail: 1,
|
|
758
|
+
...weights
|
|
759
|
+
};
|
|
760
|
+
let score = 0;
|
|
761
|
+
const tests = [];
|
|
762
|
+
const dkimResult = authResult.dkim?.status?.result;
|
|
763
|
+
if (dkimResult === "pass") {
|
|
764
|
+
score += defaultWeights.dkimPass;
|
|
765
|
+
tests.push(`DKIM_PASS(${defaultWeights.dkimPass})`);
|
|
766
|
+
} else if (dkimResult === "fail") {
|
|
767
|
+
score += defaultWeights.dkimFail;
|
|
768
|
+
tests.push(`DKIM_FAIL(${defaultWeights.dkimFail})`);
|
|
769
|
+
}
|
|
770
|
+
const spfResult = authResult.spf?.status?.result;
|
|
771
|
+
switch (spfResult) {
|
|
772
|
+
case "pass": {
|
|
773
|
+
score += defaultWeights.spfPass;
|
|
774
|
+
tests.push(`SPF_PASS(${defaultWeights.spfPass})`);
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
case "fail": {
|
|
778
|
+
score += defaultWeights.spfFail;
|
|
779
|
+
tests.push(`SPF_FAIL(${defaultWeights.spfFail})`);
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
case "softfail": {
|
|
783
|
+
score += defaultWeights.spfSoftfail;
|
|
784
|
+
tests.push(`SPF_SOFTFAIL(${defaultWeights.spfSoftfail})`);
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const dmarcResult = authResult.dmarc?.status?.result;
|
|
789
|
+
if (dmarcResult === "pass") {
|
|
790
|
+
score += defaultWeights.dmarcPass;
|
|
791
|
+
tests.push(`DMARC_PASS(${defaultWeights.dmarcPass})`);
|
|
792
|
+
} else if (dmarcResult === "fail") {
|
|
793
|
+
score += defaultWeights.dmarcFail;
|
|
794
|
+
tests.push(`DMARC_FAIL(${defaultWeights.dmarcFail})`);
|
|
795
|
+
}
|
|
796
|
+
const arcResult = authResult.arc?.status?.result;
|
|
797
|
+
if (arcResult === "pass") {
|
|
798
|
+
score += defaultWeights.arcPass;
|
|
799
|
+
tests.push(`ARC_PASS(${defaultWeights.arcPass})`);
|
|
800
|
+
} else if (arcResult === "fail") {
|
|
801
|
+
score += defaultWeights.arcFail;
|
|
802
|
+
tests.push(`ARC_FAIL(${defaultWeights.arcFail})`);
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
score,
|
|
806
|
+
tests,
|
|
807
|
+
details: {
|
|
808
|
+
dkim: dkimResult || "none",
|
|
809
|
+
spf: spfResult || "none",
|
|
810
|
+
dmarc: dmarcResult || "none",
|
|
811
|
+
arc: arcResult || "none"
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
function formatAuthResultsHeader(authResult, hostname = "spamscanner") {
|
|
816
|
+
const parts = [hostname];
|
|
817
|
+
if (authResult.dkim?.status?.result) {
|
|
818
|
+
const dkimResult = authResult.dkim.status.result;
|
|
819
|
+
let dkimPart = `dkim=${dkimResult}`;
|
|
820
|
+
if (authResult.dkim.results?.[0]?.signingDomain) {
|
|
821
|
+
dkimPart += ` header.d=${authResult.dkim.results[0].signingDomain}`;
|
|
822
|
+
}
|
|
823
|
+
parts.push(dkimPart);
|
|
824
|
+
}
|
|
825
|
+
if (authResult.spf?.status?.result) {
|
|
826
|
+
let spfPart = `spf=${authResult.spf.status.result}`;
|
|
827
|
+
if (authResult.spf.domain) {
|
|
828
|
+
spfPart += ` smtp.mailfrom=${authResult.spf.domain}`;
|
|
829
|
+
}
|
|
830
|
+
parts.push(spfPart);
|
|
831
|
+
}
|
|
832
|
+
if (authResult.dmarc?.status?.result) {
|
|
833
|
+
let dmarcPart = `dmarc=${authResult.dmarc.status.result}`;
|
|
834
|
+
if (authResult.dmarc.domain) {
|
|
835
|
+
dmarcPart += ` header.from=${authResult.dmarc.domain}`;
|
|
836
|
+
}
|
|
837
|
+
parts.push(dmarcPart);
|
|
838
|
+
}
|
|
839
|
+
if (authResult.arc?.status?.result) {
|
|
840
|
+
parts.push(`arc=${authResult.arc.status.result}`);
|
|
841
|
+
}
|
|
842
|
+
return parts.join(";\n ");
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/reputation.js
|
|
846
|
+
import { debuglog as debuglog2 } from "node:util";
|
|
847
|
+
var debug2 = debuglog2("spamscanner:reputation");
|
|
848
|
+
var DEFAULT_API_URL = "https://api.forwardemail.net/v1/reputation";
|
|
849
|
+
var cache = /* @__PURE__ */ new Map();
|
|
850
|
+
var CACHE_TTL = 5 * 60 * 1e3;
|
|
851
|
+
async function checkReputation(value, options = {}) {
|
|
852
|
+
const {
|
|
853
|
+
apiUrl = DEFAULT_API_URL,
|
|
854
|
+
timeout = 1e4
|
|
855
|
+
} = options;
|
|
856
|
+
if (!value || typeof value !== "string") {
|
|
857
|
+
return {
|
|
858
|
+
isTruthSource: false,
|
|
859
|
+
truthSourceValue: null,
|
|
860
|
+
isAllowlisted: false,
|
|
861
|
+
allowlistValue: null,
|
|
862
|
+
isDenylisted: false,
|
|
863
|
+
denylistValue: null
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
const cacheKey = `${apiUrl}:${value}`;
|
|
867
|
+
const cached = cache.get(cacheKey);
|
|
868
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
869
|
+
debug2("Cache hit for %s", value);
|
|
870
|
+
return cached.result;
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
const url = new URL(apiUrl);
|
|
874
|
+
url.searchParams.set("q", value);
|
|
875
|
+
const controller = new AbortController();
|
|
876
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
877
|
+
const response = await fetch(url.toString(), {
|
|
878
|
+
method: "GET",
|
|
879
|
+
headers: {
|
|
880
|
+
Accept: "application/json",
|
|
881
|
+
"User-Agent": "SpamScanner/6.0"
|
|
882
|
+
},
|
|
883
|
+
signal: controller.signal
|
|
884
|
+
});
|
|
885
|
+
clearTimeout(timeoutId);
|
|
886
|
+
if (!response.ok) {
|
|
887
|
+
debug2("API returned status %d for %s", response.status, value);
|
|
888
|
+
return {
|
|
889
|
+
isTruthSource: false,
|
|
890
|
+
truthSourceValue: null,
|
|
891
|
+
isAllowlisted: false,
|
|
892
|
+
allowlistValue: null,
|
|
893
|
+
isDenylisted: false,
|
|
894
|
+
denylistValue: null
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
const result = await response.json();
|
|
898
|
+
const normalizedResult = {
|
|
899
|
+
isTruthSource: Boolean(result.isTruthSource),
|
|
900
|
+
truthSourceValue: result.truthSourceValue || null,
|
|
901
|
+
isAllowlisted: Boolean(result.isAllowlisted),
|
|
902
|
+
allowlistValue: result.allowlistValue || null,
|
|
903
|
+
isDenylisted: Boolean(result.isDenylisted),
|
|
904
|
+
denylistValue: result.denylistValue || null
|
|
905
|
+
};
|
|
906
|
+
cache.set(cacheKey, {
|
|
907
|
+
result: normalizedResult,
|
|
908
|
+
timestamp: Date.now()
|
|
909
|
+
});
|
|
910
|
+
debug2("Reputation check for %s: %o", value, normalizedResult);
|
|
911
|
+
return normalizedResult;
|
|
912
|
+
} catch (error) {
|
|
913
|
+
debug2("Reputation check failed for %s: %s", value, error.message);
|
|
914
|
+
return {
|
|
915
|
+
isTruthSource: false,
|
|
916
|
+
truthSourceValue: null,
|
|
917
|
+
isAllowlisted: false,
|
|
918
|
+
allowlistValue: null,
|
|
919
|
+
isDenylisted: false,
|
|
920
|
+
denylistValue: null
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
async function checkReputationBatch(values, options = {}) {
|
|
925
|
+
const uniqueValues = [...new Set(values.filter(Boolean))];
|
|
926
|
+
const results = await Promise.all(uniqueValues.map(async (value) => {
|
|
927
|
+
const result = await checkReputation(value, options);
|
|
928
|
+
return [value, result];
|
|
929
|
+
}));
|
|
930
|
+
return new Map(results);
|
|
931
|
+
}
|
|
932
|
+
function aggregateReputationResults(results) {
|
|
933
|
+
const aggregated = {
|
|
934
|
+
isTruthSource: false,
|
|
935
|
+
truthSourceValue: null,
|
|
936
|
+
isAllowlisted: false,
|
|
937
|
+
allowlistValue: null,
|
|
938
|
+
isDenylisted: false,
|
|
939
|
+
denylistValue: null
|
|
940
|
+
};
|
|
941
|
+
for (const result of results) {
|
|
942
|
+
if (result.isTruthSource) {
|
|
943
|
+
aggregated.isTruthSource = true;
|
|
944
|
+
aggregated.truthSourceValue ||= result.truthSourceValue;
|
|
945
|
+
}
|
|
946
|
+
if (result.isAllowlisted) {
|
|
947
|
+
aggregated.isAllowlisted = true;
|
|
948
|
+
aggregated.allowlistValue ||= result.allowlistValue;
|
|
949
|
+
}
|
|
950
|
+
if (result.isDenylisted) {
|
|
951
|
+
aggregated.isDenylisted = true;
|
|
952
|
+
aggregated.denylistValue ||= result.denylistValue;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return aggregated;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// src/is-arbitrary.js
|
|
959
|
+
import { debuglog as debuglog3 } from "node:util";
|
|
960
|
+
var debug3 = debuglog3("spamscanner:arbitrary");
|
|
961
|
+
var BLOCKED_PHRASES_PATTERN = /cheecck y0ur acc0untt|recorded you|you've been hacked|account is hacked|personal data has leaked|private information has been stolen/im;
|
|
962
|
+
var SYSADMIN_SUBJECT_PATTERN = /please moderate|mdadm monitoring|weekly report|wordfence|wordpress|wpforms|docker|graylog|digest|event notification|package update manager|event alert|system events|monit alert|ping|monitor|cron|yum|sendmail|exim|backup|logwatch|unattended-upgrades/im;
|
|
963
|
+
var SPAM_PATTERNS = {
|
|
964
|
+
// Subject line patterns
|
|
965
|
+
subjectPatterns: [
|
|
966
|
+
// Urgency patterns
|
|
967
|
+
/\b(urgent|immediate|action required|act now|limited time|expires?|deadline)\b/i,
|
|
968
|
+
// Money patterns
|
|
969
|
+
/\b(free|winner|won|prize|lottery|million|billion|cash|money|investment|profit)\b/i,
|
|
970
|
+
// Phishing patterns
|
|
971
|
+
/\b(verify|confirm|update|suspend|locked|unusual activity|security alert)\b/i,
|
|
972
|
+
// Adult content
|
|
973
|
+
/\b(viagra|cialis|pharmacy|pills|medication|prescription)\b/i,
|
|
974
|
+
// Crypto spam
|
|
975
|
+
/\b(bitcoin|crypto|btc|eth|nft|blockchain|wallet)\b/i
|
|
976
|
+
],
|
|
977
|
+
// Body patterns
|
|
978
|
+
bodyPatterns: [
|
|
979
|
+
// Nigerian prince / advance fee fraud
|
|
980
|
+
/\b(nigerian?|prince|inheritance|beneficiary|next of kin|deceased|unclaimed)\b/i,
|
|
981
|
+
// Lottery scams
|
|
982
|
+
/\b(congratulations.*won|you have been selected|claim your prize)\b/i,
|
|
983
|
+
// Phishing
|
|
984
|
+
/\b(click here to verify|confirm your identity|update your account|suspended.*account)\b/i,
|
|
985
|
+
// Urgency
|
|
986
|
+
/\b(act now|limited time offer|expires in \d+|only \d+ left)\b/i,
|
|
987
|
+
// Financial scams
|
|
988
|
+
/\b(wire transfer|western union|moneygram|bank transfer|routing number)\b/i,
|
|
989
|
+
// Adult/pharma spam
|
|
990
|
+
/\b(enlarge|enhancement|erectile|dysfunction|weight loss|diet pills)\b/i
|
|
991
|
+
],
|
|
992
|
+
// Suspicious sender patterns
|
|
993
|
+
senderPatterns: [
|
|
994
|
+
// Random numbers in email
|
|
995
|
+
/^[a-z]+\d{4,}@/i,
|
|
996
|
+
// Very long local parts
|
|
997
|
+
/^.{30,}@/,
|
|
998
|
+
// Suspicious domains
|
|
999
|
+
/@.*(\.ru|\.cn|\.tk|\.ml|\.ga|\.cf|\.gq)$/i,
|
|
1000
|
+
// Numeric domains
|
|
1001
|
+
/@(?:\d+\.){3}\d+/
|
|
1002
|
+
]
|
|
1003
|
+
};
|
|
1004
|
+
var SUSPICIOUS_TLDS = /* @__PURE__ */ new Set([
|
|
1005
|
+
"tk",
|
|
1006
|
+
"ml",
|
|
1007
|
+
"ga",
|
|
1008
|
+
"cf",
|
|
1009
|
+
"gq",
|
|
1010
|
+
// Free TLDs often abused
|
|
1011
|
+
"xyz",
|
|
1012
|
+
"top",
|
|
1013
|
+
"wang",
|
|
1014
|
+
"win",
|
|
1015
|
+
"bid",
|
|
1016
|
+
"loan",
|
|
1017
|
+
"click",
|
|
1018
|
+
"link",
|
|
1019
|
+
"work",
|
|
1020
|
+
"date",
|
|
1021
|
+
"racing",
|
|
1022
|
+
"download",
|
|
1023
|
+
"stream",
|
|
1024
|
+
"trade"
|
|
1025
|
+
]);
|
|
1026
|
+
var SPAM_KEYWORDS = /* @__PURE__ */ new Map([
|
|
1027
|
+
["free", 1],
|
|
1028
|
+
["winner", 2],
|
|
1029
|
+
["prize", 2],
|
|
1030
|
+
["lottery", 3],
|
|
1031
|
+
["urgent", 1],
|
|
1032
|
+
["act now", 2],
|
|
1033
|
+
["limited time", 1],
|
|
1034
|
+
["click here", 1],
|
|
1035
|
+
["unsubscribe", -1],
|
|
1036
|
+
// Legitimate emails often have this
|
|
1037
|
+
["verify your account", 2],
|
|
1038
|
+
["suspended", 2],
|
|
1039
|
+
["inheritance", 3],
|
|
1040
|
+
["million dollars", 3],
|
|
1041
|
+
["wire transfer", 3],
|
|
1042
|
+
["western union", 3],
|
|
1043
|
+
["nigerian", 3],
|
|
1044
|
+
["prince", 2],
|
|
1045
|
+
["beneficiary", 2],
|
|
1046
|
+
["congratulations", 1],
|
|
1047
|
+
["selected", 1],
|
|
1048
|
+
["viagra", 3],
|
|
1049
|
+
["cialis", 3],
|
|
1050
|
+
["pharmacy", 2],
|
|
1051
|
+
["bitcoin", 1],
|
|
1052
|
+
["crypto", 1],
|
|
1053
|
+
["investment opportunity", 2],
|
|
1054
|
+
["guaranteed", 1],
|
|
1055
|
+
["risk free", 2],
|
|
1056
|
+
["no obligation", 1],
|
|
1057
|
+
["dear friend", 2],
|
|
1058
|
+
["dear customer", 1],
|
|
1059
|
+
["dear user", 1]
|
|
1060
|
+
]);
|
|
1061
|
+
var PAYPAL_SPAM_TYPE_IDS = /* @__PURE__ */ new Set(["PPC001017", "RT000238", "RT000542", "RT002947"]);
|
|
1062
|
+
var MS_SPAM_CATEGORIES = {
|
|
1063
|
+
// High-confidence threats (highest priority)
|
|
1064
|
+
highConfidence: ["cat:malw", "cat:hphsh", "cat:hphish", "cat:hspm"],
|
|
1065
|
+
// Impersonation attempts
|
|
1066
|
+
impersonation: ["cat:bimp", "cat:dimp", "cat:gimp", "cat:uimp"],
|
|
1067
|
+
// Phishing and spoofing
|
|
1068
|
+
phishingAndSpoofing: ["cat:phsh", "cat:spoof"],
|
|
1069
|
+
// Spam classifications
|
|
1070
|
+
spam: ["cat:ospm", "cat:spm"]
|
|
1071
|
+
};
|
|
1072
|
+
var MS_SPAM_VERDICTS = ["sfv:spm", "sfv:skb", "sfv:sks"];
|
|
1073
|
+
function isArbitrary(parsed, options = {}) {
|
|
1074
|
+
const {
|
|
1075
|
+
threshold = 5,
|
|
1076
|
+
checkSubject = true,
|
|
1077
|
+
checkBody = true,
|
|
1078
|
+
checkSender = true,
|
|
1079
|
+
checkHeaders = true,
|
|
1080
|
+
checkLinks = true,
|
|
1081
|
+
checkMicrosoftHeaders = true,
|
|
1082
|
+
checkVendorSpam = true,
|
|
1083
|
+
checkSpoofing = true,
|
|
1084
|
+
session = {}
|
|
1085
|
+
} = options;
|
|
1086
|
+
const reasons = [];
|
|
1087
|
+
let score = 0;
|
|
1088
|
+
let category = null;
|
|
1089
|
+
const getHeader = (name) => {
|
|
1090
|
+
if (parsed.headers?.get) {
|
|
1091
|
+
return parsed.headers.get(name);
|
|
1092
|
+
}
|
|
1093
|
+
if (parsed.headerLines) {
|
|
1094
|
+
const header = parsed.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
|
|
1095
|
+
return header?.line?.split(":").slice(1).join(":").trim();
|
|
1096
|
+
}
|
|
1097
|
+
return null;
|
|
1098
|
+
};
|
|
1099
|
+
const subject = parsed.subject || getHeader("subject") || "";
|
|
1100
|
+
const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
|
|
1101
|
+
const sessionInfo = buildSessionInfo(parsed, session, getHeader);
|
|
1102
|
+
if (subject && BLOCKED_PHRASES_PATTERN.test(subject)) {
|
|
1103
|
+
reasons.push("BLOCKED_PHRASE_IN_SUBJECT");
|
|
1104
|
+
score += 10;
|
|
1105
|
+
category = "SPAM";
|
|
1106
|
+
}
|
|
1107
|
+
if (checkMicrosoftHeaders) {
|
|
1108
|
+
const msResult = checkMicrosoftExchangeHeaders(getHeader, sessionInfo);
|
|
1109
|
+
if (msResult.blocked) {
|
|
1110
|
+
reasons.push(...msResult.reasons);
|
|
1111
|
+
score += msResult.score;
|
|
1112
|
+
category = msResult.category || category;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (checkVendorSpam) {
|
|
1116
|
+
const vendorResult = checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from);
|
|
1117
|
+
if (vendorResult.blocked) {
|
|
1118
|
+
reasons.push(...vendorResult.reasons);
|
|
1119
|
+
score += vendorResult.score;
|
|
1120
|
+
category = vendorResult.category || category;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (checkSpoofing) {
|
|
1124
|
+
const spoofResult = checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject);
|
|
1125
|
+
if (spoofResult.blocked) {
|
|
1126
|
+
reasons.push(...spoofResult.reasons);
|
|
1127
|
+
score += spoofResult.score;
|
|
1128
|
+
category = spoofResult.category || category;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (checkSubject && subject) {
|
|
1132
|
+
const subjectResult = checkSubjectLine(subject);
|
|
1133
|
+
score += subjectResult.score;
|
|
1134
|
+
reasons.push(...subjectResult.reasons);
|
|
1135
|
+
}
|
|
1136
|
+
if (checkBody) {
|
|
1137
|
+
const bodyText = parsed.text || "";
|
|
1138
|
+
const bodyHtml = parsed.html || "";
|
|
1139
|
+
const bodyResult = checkBodyContent(bodyText, bodyHtml);
|
|
1140
|
+
score += bodyResult.score;
|
|
1141
|
+
reasons.push(...bodyResult.reasons);
|
|
1142
|
+
}
|
|
1143
|
+
if (checkSender) {
|
|
1144
|
+
const replyTo = parsed.replyTo?.value?.[0]?.address || parsed.replyTo?.text || "";
|
|
1145
|
+
const senderResult = checkSenderPatterns(from, replyTo);
|
|
1146
|
+
score += senderResult.score;
|
|
1147
|
+
reasons.push(...senderResult.reasons);
|
|
1148
|
+
}
|
|
1149
|
+
if (checkHeaders) {
|
|
1150
|
+
const headerResult = checkHeaderAnomalies(parsed, getHeader);
|
|
1151
|
+
score += headerResult.score;
|
|
1152
|
+
reasons.push(...headerResult.reasons);
|
|
1153
|
+
}
|
|
1154
|
+
if (checkLinks) {
|
|
1155
|
+
const bodyHtml = parsed.html || parsed.text || "";
|
|
1156
|
+
const linkResult = checkSuspiciousLinks(bodyHtml);
|
|
1157
|
+
score += linkResult.score;
|
|
1158
|
+
reasons.push(...linkResult.reasons);
|
|
1159
|
+
}
|
|
1160
|
+
const isArbitrarySpam = score >= threshold;
|
|
1161
|
+
debug3(
|
|
1162
|
+
"Arbitrary check result: score=%d, threshold=%d, isArbitrary=%s, category=%s, reasons=%o",
|
|
1163
|
+
score,
|
|
1164
|
+
threshold,
|
|
1165
|
+
isArbitrarySpam,
|
|
1166
|
+
category,
|
|
1167
|
+
reasons
|
|
1168
|
+
);
|
|
1169
|
+
return {
|
|
1170
|
+
isArbitrary: isArbitrarySpam,
|
|
1171
|
+
reasons,
|
|
1172
|
+
score,
|
|
1173
|
+
category
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function buildSessionInfo(parsed, session, getHeader) {
|
|
1177
|
+
const info = { ...session };
|
|
1178
|
+
const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
|
|
1179
|
+
if (from && !info.originalFromAddress) {
|
|
1180
|
+
info.originalFromAddress = from.toLowerCase();
|
|
1181
|
+
const atIndex = from.indexOf("@");
|
|
1182
|
+
if (atIndex > 0) {
|
|
1183
|
+
info.originalFromAddressDomain = from.slice(atIndex + 1).toLowerCase();
|
|
1184
|
+
info.originalFromAddressRootDomain = getRootDomain(info.originalFromAddressDomain);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (!info.resolvedClientHostname) {
|
|
1188
|
+
info.resolvedClientHostname = extractClientHostname(parsed);
|
|
1189
|
+
if (info.resolvedClientHostname) {
|
|
1190
|
+
info.resolvedRootClientHostname = getRootDomain(info.resolvedClientHostname);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
info.remoteAddress ||= extractRemoteIp(parsed);
|
|
1194
|
+
return info;
|
|
1195
|
+
}
|
|
1196
|
+
function checkMicrosoftExchangeHeaders(getHeader, sessionInfo) {
|
|
1197
|
+
const result = {
|
|
1198
|
+
blocked: false,
|
|
1199
|
+
reasons: [],
|
|
1200
|
+
score: 0,
|
|
1201
|
+
category: null
|
|
1202
|
+
};
|
|
1203
|
+
const isFromMicrosoft = sessionInfo.resolvedClientHostname && sessionInfo.resolvedClientHostname.endsWith(".outbound.protection.outlook.com");
|
|
1204
|
+
if (!isFromMicrosoft) {
|
|
1205
|
+
return result;
|
|
1206
|
+
}
|
|
1207
|
+
const msAuthHeader = getHeader("x-ms-exchange-authentication-results");
|
|
1208
|
+
const forefrontHeader = getHeader("x-forefront-antispam-report");
|
|
1209
|
+
if (forefrontHeader) {
|
|
1210
|
+
const lowerForefront = forefrontHeader.toLowerCase();
|
|
1211
|
+
const sclMatch = lowerForefront.match(/scl:(\d+)/);
|
|
1212
|
+
const scl = sclMatch ? Number.parseInt(sclMatch[1], 10) : null;
|
|
1213
|
+
const sfvNotSpam = lowerForefront.includes("sfv:nspm");
|
|
1214
|
+
const microsoftSaysNotSpam = sfvNotSpam || scl !== null && scl <= 2;
|
|
1215
|
+
if (!microsoftSaysNotSpam && msAuthHeader) {
|
|
1216
|
+
const lowerMsAuth = msAuthHeader.toLowerCase();
|
|
1217
|
+
const spfPass = lowerMsAuth.includes("spf=pass");
|
|
1218
|
+
const dkimPass = lowerMsAuth.includes("dkim=pass");
|
|
1219
|
+
const dmarcPass = lowerMsAuth.includes("dmarc=pass");
|
|
1220
|
+
if (!spfPass && !dkimPass && !dmarcPass) {
|
|
1221
|
+
const spfFailed = lowerMsAuth.includes("spf=fail");
|
|
1222
|
+
const dkimFailed = lowerMsAuth.includes("dkim=fail");
|
|
1223
|
+
const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
|
|
1224
|
+
if (spfFailed || dkimFailed || dmarcFailed) {
|
|
1225
|
+
result.blocked = true;
|
|
1226
|
+
result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
|
|
1227
|
+
result.score += 10;
|
|
1228
|
+
result.category = "AUTHENTICATION_FAILURE";
|
|
1229
|
+
return result;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
for (const cat of MS_SPAM_CATEGORIES.highConfidence) {
|
|
1234
|
+
if (lowerForefront.includes(cat)) {
|
|
1235
|
+
result.blocked = true;
|
|
1236
|
+
result.reasons.push(`MS_HIGH_CONFIDENCE_THREAT: ${cat.toUpperCase()}`);
|
|
1237
|
+
result.score += 15;
|
|
1238
|
+
result.category = cat.includes("malw") ? "MALWARE" : cat.includes("phish") || cat.includes("phsh") ? "PHISHING" : "HIGH_CONFIDENCE_SPAM";
|
|
1239
|
+
return result;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
for (const cat of MS_SPAM_CATEGORIES.impersonation) {
|
|
1243
|
+
if (lowerForefront.includes(cat)) {
|
|
1244
|
+
result.blocked = true;
|
|
1245
|
+
result.reasons.push(`MS_IMPERSONATION: ${cat.toUpperCase()}`);
|
|
1246
|
+
result.score += 12;
|
|
1247
|
+
result.category = "IMPERSONATION";
|
|
1248
|
+
return result;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
for (const cat of MS_SPAM_CATEGORIES.phishingAndSpoofing) {
|
|
1252
|
+
if (lowerForefront.includes(cat)) {
|
|
1253
|
+
result.blocked = true;
|
|
1254
|
+
result.reasons.push(`MS_PHISHING_SPOOF: ${cat.toUpperCase()}`);
|
|
1255
|
+
result.score += 12;
|
|
1256
|
+
result.category = cat.includes("phsh") ? "PHISHING" : "SPOOFING";
|
|
1257
|
+
return result;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
for (const verdict of MS_SPAM_VERDICTS) {
|
|
1261
|
+
if (lowerForefront.includes(verdict)) {
|
|
1262
|
+
result.blocked = true;
|
|
1263
|
+
result.reasons.push(`MS_SPAM_VERDICT: ${verdict.toUpperCase()}`);
|
|
1264
|
+
result.score += 10;
|
|
1265
|
+
result.category = "SPAM";
|
|
1266
|
+
return result;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
for (const cat of MS_SPAM_CATEGORIES.spam) {
|
|
1270
|
+
if (lowerForefront.includes(cat)) {
|
|
1271
|
+
result.blocked = true;
|
|
1272
|
+
result.reasons.push(`MS_SPAM_CATEGORY: ${cat.toUpperCase()}`);
|
|
1273
|
+
result.score += 10;
|
|
1274
|
+
result.category = "SPAM";
|
|
1275
|
+
return result;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (scl !== null && scl >= 5) {
|
|
1279
|
+
result.blocked = true;
|
|
1280
|
+
result.reasons.push(`MS_HIGH_SCL: ${scl}`);
|
|
1281
|
+
result.score += 8;
|
|
1282
|
+
result.category = "SPAM";
|
|
1283
|
+
return result;
|
|
1284
|
+
}
|
|
1285
|
+
} else if (msAuthHeader) {
|
|
1286
|
+
const lowerMsAuth = msAuthHeader.toLowerCase();
|
|
1287
|
+
const spfPass = lowerMsAuth.includes("spf=pass");
|
|
1288
|
+
const dkimPass = lowerMsAuth.includes("dkim=pass");
|
|
1289
|
+
const dmarcPass = lowerMsAuth.includes("dmarc=pass");
|
|
1290
|
+
if (!spfPass && !dkimPass && !dmarcPass) {
|
|
1291
|
+
const spfFailed = lowerMsAuth.includes("spf=fail");
|
|
1292
|
+
const dkimFailed = lowerMsAuth.includes("dkim=fail");
|
|
1293
|
+
const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
|
|
1294
|
+
if (spfFailed || dkimFailed || dmarcFailed) {
|
|
1295
|
+
result.blocked = true;
|
|
1296
|
+
result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
|
|
1297
|
+
result.score += 10;
|
|
1298
|
+
result.category = "AUTHENTICATION_FAILURE";
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
return result;
|
|
1303
|
+
}
|
|
1304
|
+
function checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from) {
|
|
1305
|
+
const result = {
|
|
1306
|
+
blocked: false,
|
|
1307
|
+
reasons: [],
|
|
1308
|
+
score: 0,
|
|
1309
|
+
category: null
|
|
1310
|
+
};
|
|
1311
|
+
const fromLower = from.toLowerCase();
|
|
1312
|
+
if (sessionInfo.originalFromAddressRootDomain === "paypal.com" && getHeader("x-email-type-id")) {
|
|
1313
|
+
const typeId = getHeader("x-email-type-id");
|
|
1314
|
+
if (PAYPAL_SPAM_TYPE_IDS.has(typeId)) {
|
|
1315
|
+
result.blocked = true;
|
|
1316
|
+
result.reasons.push(`PAYPAL_INVOICE_SPAM: ${typeId}`);
|
|
1317
|
+
result.score += 15;
|
|
1318
|
+
result.category = "VENDOR_SPAM";
|
|
1319
|
+
return result;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
if (sessionInfo.originalFromAddress === "invoice@authorize.net" && sessionInfo.resolvedRootClientHostname === "visa.com") {
|
|
1323
|
+
result.blocked = true;
|
|
1324
|
+
result.reasons.push("AUTHORIZE_VISA_PHISHING");
|
|
1325
|
+
result.score += 15;
|
|
1326
|
+
result.category = "PHISHING";
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
if (fromLower.includes("amazon.co.jp") && (!sessionInfo.resolvedRootClientHostname || !sessionInfo.resolvedRootClientHostname.startsWith("amazon."))) {
|
|
1330
|
+
result.blocked = true;
|
|
1331
|
+
result.reasons.push("AMAZON_JP_IMPERSONATION");
|
|
1332
|
+
result.score += 12;
|
|
1333
|
+
result.category = "IMPERSONATION";
|
|
1334
|
+
return result;
|
|
1335
|
+
}
|
|
1336
|
+
if (subject && subject.includes("pCloud") && sessionInfo.originalFromAddressRootDomain !== "pcloud.com" && fromLower.includes("pcloud")) {
|
|
1337
|
+
result.blocked = true;
|
|
1338
|
+
result.reasons.push("PCLOUD_IMPERSONATION");
|
|
1339
|
+
result.score += 12;
|
|
1340
|
+
result.category = "IMPERSONATION";
|
|
1341
|
+
return result;
|
|
1342
|
+
}
|
|
1343
|
+
if ((sessionInfo.originalFromAddress === "postmaster@outlook.com" || sessionInfo.resolvedClientHostname && sessionInfo.resolvedClientHostname.endsWith(".outbound.protection.outlook.com") || sessionInfo.originalFromAddress?.startsWith("postmaster@") && sessionInfo.originalFromAddress?.endsWith(".onmicrosoft.com")) && isAutoReply(getHeader) && subject && (subject.startsWith("Undeliverable: ") || subject.startsWith("No se puede entregar: "))) {
|
|
1344
|
+
result.blocked = true;
|
|
1345
|
+
result.reasons.push("MS_BOUNCE_SPAM");
|
|
1346
|
+
result.score += 10;
|
|
1347
|
+
result.category = "BOUNCE_SPAM";
|
|
1348
|
+
return result;
|
|
1349
|
+
}
|
|
1350
|
+
if (sessionInfo.originalFromAddress === "postmaster@163.com" && subject && subject.includes("\u7CFB\u7EDF\u9000\u4FE1")) {
|
|
1351
|
+
result.blocked = true;
|
|
1352
|
+
result.reasons.push("163_BOUNCE_SPAM");
|
|
1353
|
+
result.score += 10;
|
|
1354
|
+
result.category = "BOUNCE_SPAM";
|
|
1355
|
+
return result;
|
|
1356
|
+
}
|
|
1357
|
+
if (sessionInfo.originalFromAddress === "dse_na4@docusign.net" && sessionInfo.spf?.domain && (sessionInfo.spf.domain.endsWith(".onmicrosoft.com") || sessionInfo.spf.domain === "onmicrosoft.com")) {
|
|
1358
|
+
result.blocked = true;
|
|
1359
|
+
result.reasons.push("DOCUSIGN_MS_SCAM");
|
|
1360
|
+
result.score += 12;
|
|
1361
|
+
result.category = "PHISHING";
|
|
1362
|
+
return result;
|
|
1363
|
+
}
|
|
1364
|
+
return result;
|
|
1365
|
+
}
|
|
1366
|
+
function checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject) {
|
|
1367
|
+
const result = {
|
|
1368
|
+
blocked: false,
|
|
1369
|
+
reasons: [],
|
|
1370
|
+
score: 0,
|
|
1371
|
+
category: null
|
|
1372
|
+
};
|
|
1373
|
+
if (sessionInfo.hadAlignedAndPassingDKIM || sessionInfo.isAllowlisted) {
|
|
1374
|
+
return result;
|
|
1375
|
+
}
|
|
1376
|
+
if (sessionInfo.hasSameHostnameAsFrom) {
|
|
1377
|
+
return result;
|
|
1378
|
+
}
|
|
1379
|
+
const rcptTo = sessionInfo.envelope?.rcptTo || [];
|
|
1380
|
+
const fromRootDomain = sessionInfo.originalFromAddressRootDomain;
|
|
1381
|
+
if (!fromRootDomain || rcptTo.length === 0) {
|
|
1382
|
+
return result;
|
|
1383
|
+
}
|
|
1384
|
+
const hasSameRcptToAsFrom = rcptTo.some((to) => {
|
|
1385
|
+
if (!to.address) {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
const toRootDomain = getRootDomain(parseHostFromAddress(to.address));
|
|
1389
|
+
return toRootDomain === fromRootDomain;
|
|
1390
|
+
});
|
|
1391
|
+
if (!hasSameRcptToAsFrom) {
|
|
1392
|
+
return result;
|
|
1393
|
+
}
|
|
1394
|
+
const spfResult = sessionInfo.spfFromHeader?.status?.result;
|
|
1395
|
+
if (spfResult === "pass") {
|
|
1396
|
+
return result;
|
|
1397
|
+
}
|
|
1398
|
+
sessionInfo.isPotentialPhishing = true;
|
|
1399
|
+
const xPhpScript = getHeader("x-php-script");
|
|
1400
|
+
const xMailer = getHeader("x-mailer");
|
|
1401
|
+
if (xPhpScript) {
|
|
1402
|
+
return result;
|
|
1403
|
+
}
|
|
1404
|
+
if (xMailer) {
|
|
1405
|
+
const mailerLower = xMailer.toLowerCase();
|
|
1406
|
+
if (mailerLower.includes("php") || mailerLower.includes("drupal")) {
|
|
1407
|
+
return result;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
if (subject && SYSADMIN_SUBJECT_PATTERN.test(subject)) {
|
|
1411
|
+
return result;
|
|
1412
|
+
}
|
|
1413
|
+
result.blocked = true;
|
|
1414
|
+
result.reasons.push("SPOOFING_ATTACK");
|
|
1415
|
+
result.score += 12;
|
|
1416
|
+
result.category = "SPOOFING";
|
|
1417
|
+
return result;
|
|
1418
|
+
}
|
|
1419
|
+
function isAutoReply(getHeader) {
|
|
1420
|
+
const autoSubmitted = getHeader("auto-submitted");
|
|
1421
|
+
if (autoSubmitted && autoSubmitted !== "no") {
|
|
1422
|
+
return true;
|
|
1423
|
+
}
|
|
1424
|
+
const autoResponseSuppress = getHeader("x-auto-response-suppress");
|
|
1425
|
+
if (autoResponseSuppress) {
|
|
1426
|
+
return true;
|
|
1427
|
+
}
|
|
1428
|
+
const precedence = getHeader("precedence");
|
|
1429
|
+
if (precedence && ["bulk", "junk", "list", "auto_reply"].includes(precedence.toLowerCase())) {
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
if (getHeader("list-unsubscribe")) {
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1437
|
+
function checkSubjectLine(subject) {
|
|
1438
|
+
const reasons = [];
|
|
1439
|
+
let score = 0;
|
|
1440
|
+
for (const pattern of SPAM_PATTERNS.subjectPatterns) {
|
|
1441
|
+
if (pattern.test(subject)) {
|
|
1442
|
+
const match = subject.match(pattern);
|
|
1443
|
+
reasons.push(`SUBJECT_SPAM_PATTERN: ${match[0]}`);
|
|
1444
|
+
score += 1;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const upperCount = (subject.match(/[A-Z]/g) || []).length;
|
|
1448
|
+
const letterCount = (subject.match(/[a-zA-Z]/g) || []).length;
|
|
1449
|
+
if (letterCount > 10 && upperCount / letterCount > 0.7) {
|
|
1450
|
+
reasons.push("SUBJECT_ALL_CAPS");
|
|
1451
|
+
score += 2;
|
|
1452
|
+
}
|
|
1453
|
+
const punctCount = (subject.match(/[!?$]/g) || []).length;
|
|
1454
|
+
if (punctCount >= 3) {
|
|
1455
|
+
reasons.push("SUBJECT_EXCESSIVE_PUNCTUATION");
|
|
1456
|
+
score += 1;
|
|
1457
|
+
}
|
|
1458
|
+
if (/^(re|fw|fwd):/i.test(subject) && subject.length < 20) {
|
|
1459
|
+
reasons.push("SUBJECT_FAKE_REPLY");
|
|
1460
|
+
score += 1;
|
|
1461
|
+
}
|
|
1462
|
+
return { score, reasons };
|
|
1463
|
+
}
|
|
1464
|
+
function checkBodyContent(text, html) {
|
|
1465
|
+
const reasons = [];
|
|
1466
|
+
let score = 0;
|
|
1467
|
+
const content = text || html || "";
|
|
1468
|
+
const contentLower = content.toLowerCase();
|
|
1469
|
+
for (const pattern of SPAM_PATTERNS.bodyPatterns) {
|
|
1470
|
+
if (pattern.test(content)) {
|
|
1471
|
+
const match = content.match(pattern);
|
|
1472
|
+
reasons.push(`BODY_SPAM_PATTERN: ${match[0].slice(0, 50)}`);
|
|
1473
|
+
score += 1;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
for (const [keyword, weight] of SPAM_KEYWORDS) {
|
|
1477
|
+
if (contentLower.includes(keyword.toLowerCase())) {
|
|
1478
|
+
reasons.push(`SPAM_KEYWORD: ${keyword}`);
|
|
1479
|
+
score += weight;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (html) {
|
|
1483
|
+
if (/color:\s*#fff|color:\s*white|font-size:\s*[01]px/i.test(html)) {
|
|
1484
|
+
reasons.push("HIDDEN_TEXT");
|
|
1485
|
+
score += 3;
|
|
1486
|
+
}
|
|
1487
|
+
const imgCount = (html.match(/<img/gi) || []).length;
|
|
1488
|
+
const textLength = (text || "").length;
|
|
1489
|
+
if (imgCount > 5 && textLength < 100) {
|
|
1490
|
+
reasons.push("IMAGE_HEAVY_LOW_TEXT");
|
|
1491
|
+
score += 2;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if (/data:image\/[^;]+;base64,/i.test(html || "")) {
|
|
1495
|
+
reasons.push("BASE64_IMAGES");
|
|
1496
|
+
score += 1;
|
|
1497
|
+
}
|
|
1498
|
+
const shortenerPatterns = /\b(bit\.ly|tinyurl|goo\.gl|t\.co|ow\.ly|is\.gd|buff\.ly|adf\.ly|j\.mp)\b/i;
|
|
1499
|
+
if (shortenerPatterns.test(content)) {
|
|
1500
|
+
reasons.push("URL_SHORTENER");
|
|
1501
|
+
score += 2;
|
|
1502
|
+
}
|
|
1503
|
+
return { score, reasons };
|
|
1504
|
+
}
|
|
1505
|
+
function checkSenderPatterns(from, replyTo) {
|
|
1506
|
+
const reasons = [];
|
|
1507
|
+
let score = 0;
|
|
1508
|
+
if (!from) {
|
|
1509
|
+
reasons.push("MISSING_FROM");
|
|
1510
|
+
score += 2;
|
|
1511
|
+
return { score, reasons };
|
|
1512
|
+
}
|
|
1513
|
+
for (const pattern of SPAM_PATTERNS.senderPatterns) {
|
|
1514
|
+
if (pattern.test(from)) {
|
|
1515
|
+
reasons.push("SUSPICIOUS_SENDER_PATTERN");
|
|
1516
|
+
score += 2;
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
const tldMatch = from.match(/@[^.]+\.([a-z]+)$/i);
|
|
1521
|
+
if (tldMatch && SUSPICIOUS_TLDS.has(tldMatch[1].toLowerCase())) {
|
|
1522
|
+
reasons.push(`SUSPICIOUS_TLD: ${tldMatch[1]}`);
|
|
1523
|
+
score += 2;
|
|
1524
|
+
}
|
|
1525
|
+
if (replyTo && from) {
|
|
1526
|
+
const fromDomain = from.split("@")[1]?.toLowerCase();
|
|
1527
|
+
const replyDomain = replyTo.split("@")[1]?.toLowerCase();
|
|
1528
|
+
if (fromDomain && replyDomain && fromDomain !== replyDomain) {
|
|
1529
|
+
reasons.push("FROM_REPLY_TO_MISMATCH");
|
|
1530
|
+
score += 2;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const spoofPatterns = /^(paypal|amazon|apple|microsoft|google|bank|security)/i;
|
|
1534
|
+
if (spoofPatterns.test(from) && !/@(paypal|amazon|apple|microsoft|google)\.com$/i.test(from)) {
|
|
1535
|
+
reasons.push("DISPLAY_NAME_SPOOFING");
|
|
1536
|
+
score += 3;
|
|
1537
|
+
}
|
|
1538
|
+
return { score, reasons };
|
|
1539
|
+
}
|
|
1540
|
+
function checkHeaderAnomalies(parsed, getHeader) {
|
|
1541
|
+
const reasons = [];
|
|
1542
|
+
let score = 0;
|
|
1543
|
+
if (!parsed.messageId && !getHeader("message-id")) {
|
|
1544
|
+
reasons.push("MISSING_MESSAGE_ID");
|
|
1545
|
+
score += 1;
|
|
1546
|
+
}
|
|
1547
|
+
if (parsed.date) {
|
|
1548
|
+
const messageDate = new Date(parsed.date);
|
|
1549
|
+
const now = /* @__PURE__ */ new Date();
|
|
1550
|
+
if (messageDate > now) {
|
|
1551
|
+
const hoursDiff = (messageDate - now) / (1e3 * 60 * 60);
|
|
1552
|
+
if (hoursDiff > 24) {
|
|
1553
|
+
reasons.push("FUTURE_DATE");
|
|
1554
|
+
score += 2;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const daysDiff = (now - messageDate) / (1e3 * 60 * 60 * 24);
|
|
1558
|
+
if (daysDiff > 365) {
|
|
1559
|
+
reasons.push("VERY_OLD_DATE");
|
|
1560
|
+
score += 1;
|
|
1561
|
+
}
|
|
1562
|
+
} else {
|
|
1563
|
+
reasons.push("MISSING_DATE");
|
|
1564
|
+
score += 1;
|
|
1565
|
+
}
|
|
1566
|
+
const xMailer = getHeader("x-mailer") || "";
|
|
1567
|
+
if (xMailer) {
|
|
1568
|
+
const suspiciousMailers = /mass mail|bulk mail|email blast/i;
|
|
1569
|
+
if (suspiciousMailers.test(xMailer)) {
|
|
1570
|
+
reasons.push("SUSPICIOUS_MAILER");
|
|
1571
|
+
score += 1;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
const mimeVersion = getHeader("mime-version");
|
|
1575
|
+
if (!mimeVersion && (parsed.html || parsed.attachments?.length > 0)) {
|
|
1576
|
+
reasons.push("MISSING_MIME_VERSION");
|
|
1577
|
+
score += 1;
|
|
1578
|
+
}
|
|
1579
|
+
const toCount = parsed.to?.value?.length || 0;
|
|
1580
|
+
const ccCount = parsed.cc?.value?.length || 0;
|
|
1581
|
+
if (toCount + ccCount > 50) {
|
|
1582
|
+
reasons.push("EXCESSIVE_RECIPIENTS");
|
|
1583
|
+
score += 2;
|
|
1584
|
+
}
|
|
1585
|
+
return { score, reasons };
|
|
1586
|
+
}
|
|
1587
|
+
function checkSuspiciousLinks(content) {
|
|
1588
|
+
const reasons = [];
|
|
1589
|
+
let score = 0;
|
|
1590
|
+
const urlPattern = /https?:\/\/[^\s<>"']+/gi;
|
|
1591
|
+
const urls = content.match(urlPattern) || [];
|
|
1592
|
+
if (urls.length === 0) {
|
|
1593
|
+
return { score, reasons };
|
|
1594
|
+
}
|
|
1595
|
+
const suspiciousUrls = /* @__PURE__ */ new Set();
|
|
1596
|
+
for (const url of urls) {
|
|
1597
|
+
try {
|
|
1598
|
+
const parsed = new URL(url);
|
|
1599
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
1600
|
+
if (/^(?:\d+\.){3}\d+$/.test(hostname)) {
|
|
1601
|
+
suspiciousUrls.add("IP_ADDRESS_URL");
|
|
1602
|
+
}
|
|
1603
|
+
const tld = hostname.split(".").pop();
|
|
1604
|
+
if (SUSPICIOUS_TLDS.has(tld)) {
|
|
1605
|
+
suspiciousUrls.add(`SUSPICIOUS_URL_TLD: ${tld}`);
|
|
1606
|
+
}
|
|
1607
|
+
if (parsed.port && !["80", "443", ""].includes(parsed.port)) {
|
|
1608
|
+
suspiciousUrls.add("URL_WITH_PORT");
|
|
1609
|
+
}
|
|
1610
|
+
if (url.length > 200) {
|
|
1611
|
+
suspiciousUrls.add("VERY_LONG_URL");
|
|
1612
|
+
}
|
|
1613
|
+
const subdomainCount = hostname.split(".").length - 2;
|
|
1614
|
+
if (subdomainCount > 3) {
|
|
1615
|
+
suspiciousUrls.add("EXCESSIVE_SUBDOMAINS");
|
|
1616
|
+
}
|
|
1617
|
+
if (/%[\da-f]{2}/i.test(url) && /%[\da-f]{2}.*%[\da-f]{2}/i.test(url)) {
|
|
1618
|
+
suspiciousUrls.add("URL_OBFUSCATION");
|
|
1619
|
+
}
|
|
1620
|
+
} catch {
|
|
1621
|
+
suspiciousUrls.add("INVALID_URL");
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
for (const reason of suspiciousUrls) {
|
|
1625
|
+
reasons.push(reason);
|
|
1626
|
+
score += 1;
|
|
1627
|
+
}
|
|
1628
|
+
const linkPattern = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi;
|
|
1629
|
+
let match;
|
|
1630
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
1631
|
+
const href = match[1];
|
|
1632
|
+
const text = match[2];
|
|
1633
|
+
if (/^https?:\/\//i.test(text)) {
|
|
1634
|
+
try {
|
|
1635
|
+
const textUrl = new URL(text);
|
|
1636
|
+
const hrefUrl = new URL(href);
|
|
1637
|
+
if (textUrl.hostname.toLowerCase() !== hrefUrl.hostname.toLowerCase()) {
|
|
1638
|
+
reasons.push("LINK_TEXT_URL_MISMATCH");
|
|
1639
|
+
score += 3;
|
|
1640
|
+
break;
|
|
1641
|
+
}
|
|
1642
|
+
} catch {
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
return { score, reasons };
|
|
1647
|
+
}
|
|
1648
|
+
function getRootDomain(hostname) {
|
|
1649
|
+
if (!hostname) {
|
|
1650
|
+
return "";
|
|
1651
|
+
}
|
|
1652
|
+
const parts = hostname.toLowerCase().split(".");
|
|
1653
|
+
if (parts.length <= 2) {
|
|
1654
|
+
return hostname.toLowerCase();
|
|
1655
|
+
}
|
|
1656
|
+
const multiPartTlds = ["co.uk", "com.au", "co.nz", "co.jp", "com.br", "co.in"];
|
|
1657
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
1658
|
+
if (multiPartTlds.includes(lastTwo)) {
|
|
1659
|
+
return parts.slice(-3).join(".");
|
|
1660
|
+
}
|
|
1661
|
+
return parts.slice(-2).join(".");
|
|
1662
|
+
}
|
|
1663
|
+
function parseHostFromAddress(address) {
|
|
1664
|
+
if (!address) {
|
|
1665
|
+
return "";
|
|
1666
|
+
}
|
|
1667
|
+
const atIndex = address.indexOf("@");
|
|
1668
|
+
if (atIndex === -1) {
|
|
1669
|
+
return "";
|
|
1670
|
+
}
|
|
1671
|
+
return address.slice(atIndex + 1).toLowerCase();
|
|
1672
|
+
}
|
|
1673
|
+
function extractClientHostname(parsed) {
|
|
1674
|
+
let receivedHeaders = null;
|
|
1675
|
+
if (parsed.headers?.get) {
|
|
1676
|
+
receivedHeaders = parsed.headers.get("received");
|
|
1677
|
+
} else if (parsed.headerLines) {
|
|
1678
|
+
const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
|
|
1679
|
+
receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
|
|
1680
|
+
}
|
|
1681
|
+
if (!receivedHeaders) {
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
|
|
1685
|
+
if (!received) {
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
const fromMatch = received.match(/from\s+([^\s(]+)/i);
|
|
1689
|
+
if (fromMatch) {
|
|
1690
|
+
return fromMatch[1].toLowerCase();
|
|
1691
|
+
}
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
function extractRemoteIp(parsed) {
|
|
1695
|
+
let receivedHeaders = null;
|
|
1696
|
+
if (parsed.headers?.get) {
|
|
1697
|
+
receivedHeaders = parsed.headers.get("received");
|
|
1698
|
+
} else if (parsed.headerLines) {
|
|
1699
|
+
const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
|
|
1700
|
+
receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
|
|
1701
|
+
}
|
|
1702
|
+
if (!receivedHeaders) {
|
|
1703
|
+
return null;
|
|
1704
|
+
}
|
|
1705
|
+
const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
|
|
1706
|
+
if (!received) {
|
|
1707
|
+
return null;
|
|
1708
|
+
}
|
|
1709
|
+
const ipv4Match = received.match(/\[((?:\d+\.){3}\d+)]/);
|
|
1710
|
+
if (ipv4Match) {
|
|
1711
|
+
return ipv4Match[1];
|
|
1712
|
+
}
|
|
1713
|
+
const ipv6Match = received.match(/\[([a-f\d:]+)]/i);
|
|
1714
|
+
if (ipv6Match) {
|
|
1715
|
+
return ipv6Match[1];
|
|
1716
|
+
}
|
|
1717
|
+
return null;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// src/get-attributes.js
|
|
1721
|
+
import { debuglog as debuglog4 } from "node:util";
|
|
1722
|
+
var debug4 = debuglog4("spamscanner:attributes");
|
|
1723
|
+
function checkSRS(address) {
|
|
1724
|
+
if (!address) {
|
|
1725
|
+
return "";
|
|
1726
|
+
}
|
|
1727
|
+
const srs0Match = address.match(/^srs0=[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
|
|
1728
|
+
if (srs0Match) {
|
|
1729
|
+
return `${srs0Match[3]}@${srs0Match[2]}`;
|
|
1730
|
+
}
|
|
1731
|
+
const srs1Match = address.match(/^srs1=[^=]+=[^=]+==[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
|
|
1732
|
+
if (srs1Match) {
|
|
1733
|
+
return `${srs1Match[3]}@${srs1Match[2]}`;
|
|
1734
|
+
}
|
|
1735
|
+
return address;
|
|
1736
|
+
}
|
|
1737
|
+
function parseHostFromDomainOrAddress(addressOrDomain) {
|
|
1738
|
+
if (!addressOrDomain) {
|
|
1739
|
+
return "";
|
|
1740
|
+
}
|
|
1741
|
+
const atIndex = addressOrDomain.indexOf("@");
|
|
1742
|
+
if (atIndex !== -1) {
|
|
1743
|
+
return addressOrDomain.slice(atIndex + 1).toLowerCase();
|
|
1744
|
+
}
|
|
1745
|
+
return addressOrDomain.toLowerCase();
|
|
1746
|
+
}
|
|
1747
|
+
function parseRootDomain(hostname) {
|
|
1748
|
+
if (!hostname) {
|
|
1749
|
+
return "";
|
|
1750
|
+
}
|
|
1751
|
+
const parts = hostname.toLowerCase().split(".");
|
|
1752
|
+
if (parts.length <= 2) {
|
|
1753
|
+
return hostname.toLowerCase();
|
|
1754
|
+
}
|
|
1755
|
+
const multiPartTlds = /* @__PURE__ */ new Set([
|
|
1756
|
+
"co.uk",
|
|
1757
|
+
"com.au",
|
|
1758
|
+
"co.nz",
|
|
1759
|
+
"co.jp",
|
|
1760
|
+
"com.br",
|
|
1761
|
+
"co.in",
|
|
1762
|
+
"org.uk",
|
|
1763
|
+
"net.au",
|
|
1764
|
+
"com.mx",
|
|
1765
|
+
"com.cn",
|
|
1766
|
+
"com.tw",
|
|
1767
|
+
"com.hk",
|
|
1768
|
+
"co.za",
|
|
1769
|
+
"com.sg"
|
|
1770
|
+
]);
|
|
1771
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
1772
|
+
if (multiPartTlds.has(lastTwo)) {
|
|
1773
|
+
return parts.slice(-3).join(".");
|
|
1774
|
+
}
|
|
1775
|
+
return parts.slice(-2).join(".");
|
|
1776
|
+
}
|
|
1777
|
+
function parseAddresses(headerValue) {
|
|
1778
|
+
if (!headerValue) {
|
|
1779
|
+
return [];
|
|
1780
|
+
}
|
|
1781
|
+
if (Array.isArray(headerValue)) {
|
|
1782
|
+
return headerValue.flatMap((item) => {
|
|
1783
|
+
if (typeof item === "string") {
|
|
1784
|
+
return item;
|
|
1785
|
+
}
|
|
1786
|
+
if (item.address) {
|
|
1787
|
+
return item.address;
|
|
1788
|
+
}
|
|
1789
|
+
if (item.value && Array.isArray(item.value)) {
|
|
1790
|
+
return item.value.map((v) => v.address).filter(Boolean);
|
|
1791
|
+
}
|
|
1792
|
+
return null;
|
|
1793
|
+
}).filter(Boolean);
|
|
1794
|
+
}
|
|
1795
|
+
if (headerValue.value && Array.isArray(headerValue.value)) {
|
|
1796
|
+
return headerValue.value.map((v) => v.address).filter(Boolean);
|
|
1797
|
+
}
|
|
1798
|
+
if (typeof headerValue === "string") {
|
|
1799
|
+
const emailPattern = /[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi;
|
|
1800
|
+
return headerValue.match(emailPattern) || [];
|
|
1801
|
+
}
|
|
1802
|
+
return [];
|
|
1803
|
+
}
|
|
1804
|
+
function getHeaders(headers, name) {
|
|
1805
|
+
if (!headers) {
|
|
1806
|
+
return null;
|
|
1807
|
+
}
|
|
1808
|
+
if (headers.get) {
|
|
1809
|
+
const value = headers.get(name);
|
|
1810
|
+
if (value) {
|
|
1811
|
+
if (typeof value === "string") {
|
|
1812
|
+
return value;
|
|
1813
|
+
}
|
|
1814
|
+
if (value.text) {
|
|
1815
|
+
return value.text;
|
|
1816
|
+
}
|
|
1817
|
+
if (value.value && Array.isArray(value.value)) {
|
|
1818
|
+
return value.value.map((v) => v.address || v.text || v).join(", ");
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1823
|
+
if (headers.headerLines) {
|
|
1824
|
+
const header = headers.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
|
|
1825
|
+
if (header) {
|
|
1826
|
+
return header.line?.split(":").slice(1).join(":").trim();
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (typeof headers === "object") {
|
|
1830
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
1831
|
+
if (key) {
|
|
1832
|
+
const value = headers[key];
|
|
1833
|
+
if (typeof value === "string") {
|
|
1834
|
+
return value;
|
|
1835
|
+
}
|
|
1836
|
+
if (Array.isArray(value)) {
|
|
1837
|
+
return value[0];
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
return null;
|
|
1842
|
+
}
|
|
1843
|
+
async function getAttributes(parsed, session = {}, options = {}) {
|
|
1844
|
+
const { isAligned = false, authResults = null } = options;
|
|
1845
|
+
const headers = parsed.headers || parsed;
|
|
1846
|
+
const replyToHeader = getHeaders(headers, "reply-to");
|
|
1847
|
+
const replyToAddresses = parseAddresses(parsed.replyTo || (replyToHeader ? { value: [{ address: replyToHeader }] } : null));
|
|
1848
|
+
const array = [
|
|
1849
|
+
session.resolvedClientHostname,
|
|
1850
|
+
session.resolvedRootClientHostname,
|
|
1851
|
+
session.remoteAddress
|
|
1852
|
+
];
|
|
1853
|
+
const from = [
|
|
1854
|
+
session.originalFromAddress,
|
|
1855
|
+
session.originalFromAddressDomain,
|
|
1856
|
+
session.originalFromAddressRootDomain
|
|
1857
|
+
];
|
|
1858
|
+
const replyTo = [];
|
|
1859
|
+
for (const addr of replyToAddresses) {
|
|
1860
|
+
const checked = checkSRS(addr);
|
|
1861
|
+
replyTo.push(
|
|
1862
|
+
checked.toLowerCase(),
|
|
1863
|
+
parseHostFromDomainOrAddress(checked),
|
|
1864
|
+
parseRootDomain(parseHostFromDomainOrAddress(checked))
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
const mailFrom = [];
|
|
1868
|
+
const mailFromAddress = session.envelope?.mailFrom?.address;
|
|
1869
|
+
if (mailFromAddress) {
|
|
1870
|
+
const checked = checkSRS(mailFromAddress);
|
|
1871
|
+
mailFrom.push(
|
|
1872
|
+
checked.toLowerCase(),
|
|
1873
|
+
parseHostFromDomainOrAddress(checked),
|
|
1874
|
+
parseRootDomain(parseHostFromDomainOrAddress(checked))
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
if (isAligned) {
|
|
1878
|
+
const signingDomains = session.signingDomains || /* @__PURE__ */ new Set();
|
|
1879
|
+
const spfResult = session.spfFromHeader?.status?.result;
|
|
1880
|
+
const fromHasSpfPass = spfResult === "pass";
|
|
1881
|
+
const fromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(session.originalFromAddressDomain) || signingDomains.has(session.originalFromAddressRootDomain));
|
|
1882
|
+
if (fromHasSpfPass || fromHasDkimAlignment) {
|
|
1883
|
+
array.push(...from);
|
|
1884
|
+
}
|
|
1885
|
+
let hasAlignedReplyTo = false;
|
|
1886
|
+
for (const addr of replyToAddresses) {
|
|
1887
|
+
const checked = checkSRS(addr);
|
|
1888
|
+
const domain = parseHostFromDomainOrAddress(checked);
|
|
1889
|
+
const rootDomain = parseRootDomain(domain);
|
|
1890
|
+
if (signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain))) {
|
|
1891
|
+
hasAlignedReplyTo = true;
|
|
1892
|
+
break;
|
|
1893
|
+
}
|
|
1894
|
+
if (authResults?.spf) {
|
|
1895
|
+
const spfForReplyTo = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
|
|
1896
|
+
if (spfForReplyTo?.result === "pass") {
|
|
1897
|
+
hasAlignedReplyTo = true;
|
|
1898
|
+
break;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
if (hasAlignedReplyTo) {
|
|
1903
|
+
array.push(...replyTo);
|
|
1904
|
+
}
|
|
1905
|
+
if (mailFromAddress) {
|
|
1906
|
+
const checked = checkSRS(mailFromAddress);
|
|
1907
|
+
const domain = parseHostFromDomainOrAddress(checked);
|
|
1908
|
+
const rootDomain = parseRootDomain(domain);
|
|
1909
|
+
const mailFromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain));
|
|
1910
|
+
let mailFromHasSpfPass = false;
|
|
1911
|
+
if (authResults?.spf) {
|
|
1912
|
+
const spfForMailFrom = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
|
|
1913
|
+
mailFromHasSpfPass = spfForMailFrom?.result === "pass";
|
|
1914
|
+
}
|
|
1915
|
+
if (mailFromHasDkimAlignment || mailFromHasSpfPass) {
|
|
1916
|
+
array.push(...mailFrom);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
} else {
|
|
1920
|
+
array.push(...from, ...replyTo, ...mailFrom);
|
|
1921
|
+
}
|
|
1922
|
+
const normalized = array.filter((string_) => typeof string_ === "string" && string_.length > 0).map((string_) => {
|
|
1923
|
+
try {
|
|
1924
|
+
return string_.toLowerCase().trim();
|
|
1925
|
+
} catch {
|
|
1926
|
+
return string_.toLowerCase().trim();
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
const unique = [...new Set(normalized)];
|
|
1930
|
+
debug4("Extracted %d unique attributes (isAligned=%s): %o", unique.length, isAligned, unique);
|
|
1931
|
+
return unique;
|
|
1932
|
+
}
|
|
1933
|
+
function buildSessionFromParsed(parsed, existingSession = {}) {
|
|
1934
|
+
const session = { ...existingSession };
|
|
1935
|
+
const headers = parsed.headers || parsed;
|
|
1936
|
+
const fromHeader = getHeaders(headers, "from");
|
|
1937
|
+
const fromAddresses = parseAddresses(parsed.from || fromHeader);
|
|
1938
|
+
const fromAddress = fromAddresses[0];
|
|
1939
|
+
if (fromAddress && !session.originalFromAddress) {
|
|
1940
|
+
session.originalFromAddress = checkSRS(fromAddress).toLowerCase();
|
|
1941
|
+
session.originalFromAddressDomain = parseHostFromDomainOrAddress(session.originalFromAddress);
|
|
1942
|
+
session.originalFromAddressRootDomain = parseRootDomain(session.originalFromAddressDomain);
|
|
1943
|
+
}
|
|
1944
|
+
if (!session.resolvedClientHostname) {
|
|
1945
|
+
const receivedHeader = getHeaders(headers, "received");
|
|
1946
|
+
if (receivedHeader) {
|
|
1947
|
+
const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
|
|
1948
|
+
const fromMatch = received?.match(/from\s+([^\s(]+)/i);
|
|
1949
|
+
if (fromMatch) {
|
|
1950
|
+
session.resolvedClientHostname = fromMatch[1].toLowerCase();
|
|
1951
|
+
session.resolvedRootClientHostname = parseRootDomain(session.resolvedClientHostname);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
if (!session.remoteAddress) {
|
|
1956
|
+
const receivedHeader = getHeaders(headers, "received");
|
|
1957
|
+
if (receivedHeader) {
|
|
1958
|
+
const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
|
|
1959
|
+
const ipv4Match = received?.match(/\[((?:\d+\.){3}\d+)]/);
|
|
1960
|
+
if (ipv4Match) {
|
|
1961
|
+
session.remoteAddress = ipv4Match[1];
|
|
1962
|
+
} else {
|
|
1963
|
+
const ipv6Match = received?.match(/\[([a-f\d:]+)]/i);
|
|
1964
|
+
if (ipv6Match) {
|
|
1965
|
+
session.remoteAddress = ipv6Match[1];
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if (!session.envelope) {
|
|
1971
|
+
session.envelope = {
|
|
1972
|
+
mailFrom: { address: session.originalFromAddress || "" },
|
|
1973
|
+
rcptTo: []
|
|
1974
|
+
};
|
|
1975
|
+
const toAddresses = parseAddresses(parsed.to || getHeaders(headers, "to"));
|
|
1976
|
+
const ccAddresses = parseAddresses(parsed.cc || getHeaders(headers, "cc"));
|
|
1977
|
+
for (const addr of [...toAddresses, ...ccAddresses]) {
|
|
1978
|
+
if (addr) {
|
|
1979
|
+
session.envelope.rcptTo.push({ address: addr });
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
return session;
|
|
1984
|
+
}
|
|
1985
|
+
async function extractAttributes(parsed, options = {}) {
|
|
1986
|
+
const { isAligned = false, senderIp, senderHostname, authResults } = options;
|
|
1987
|
+
const session = buildSessionFromParsed(parsed, {
|
|
1988
|
+
remoteAddress: senderIp,
|
|
1989
|
+
resolvedClientHostname: senderHostname,
|
|
1990
|
+
resolvedRootClientHostname: senderHostname ? parseRootDomain(senderHostname) : void 0
|
|
1991
|
+
});
|
|
1992
|
+
if (authResults?.dkim) {
|
|
1993
|
+
session.signingDomains = /* @__PURE__ */ new Set();
|
|
1994
|
+
for (const dkimResult of authResults.dkim) {
|
|
1995
|
+
if (dkimResult.result === "pass" && dkimResult.domain) {
|
|
1996
|
+
session.signingDomains.add(dkimResult.domain);
|
|
1997
|
+
session.signingDomains.add(parseRootDomain(dkimResult.domain));
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
session.hadAlignedAndPassingDKIM = session.signingDomains.has(session.originalFromAddressDomain) || session.signingDomains.has(session.originalFromAddressRootDomain);
|
|
2001
|
+
}
|
|
2002
|
+
if (authResults?.spf) {
|
|
2003
|
+
const spfForFrom = authResults.spf.find((r) => r.domain === session.originalFromAddressDomain || r.domain === session.originalFromAddressRootDomain);
|
|
2004
|
+
if (spfForFrom) {
|
|
2005
|
+
session.spfFromHeader = {
|
|
2006
|
+
status: { result: spfForFrom.result }
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
const attributes = await getAttributes(parsed, session, { isAligned, authResults });
|
|
2011
|
+
return { attributes, session };
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// src/index.js
|
|
2015
|
+
var __filename = import.meta.url ? fileURLToPath(import.meta.url) : "";
|
|
2016
|
+
var __dirname = __filename ? path.dirname(__filename) : process.cwd();
|
|
2017
|
+
var findPackageRoot = (startDir) => {
|
|
2018
|
+
let dir = startDir;
|
|
2019
|
+
while (dir !== path.dirname(dir)) {
|
|
2020
|
+
if (fs.existsSync(path.join(dir, "package.json"))) {
|
|
2021
|
+
return dir;
|
|
2022
|
+
}
|
|
2023
|
+
dir = path.dirname(dir);
|
|
2024
|
+
}
|
|
2025
|
+
return startDir;
|
|
2026
|
+
};
|
|
2027
|
+
var packageRoot = findPackageRoot(__dirname);
|
|
2028
|
+
var executablesData = JSON.parse(fs.readFileSync(path.join(packageRoot, "executables.json"), "utf8"));
|
|
2029
|
+
var EXECUTABLES = new Set(executablesData);
|
|
2030
|
+
var getReplacements = async () => {
|
|
2031
|
+
const { default: replacements2 } = await Promise.resolve().then(() => (init_replacements(), replacements_exports));
|
|
2032
|
+
return replacements2;
|
|
2033
|
+
};
|
|
2034
|
+
var getClassifier = async () => {
|
|
2035
|
+
const { default: classifier2 } = await Promise.resolve().then(() => (init_get_classifier(), get_classifier_exports));
|
|
2036
|
+
return classifier2;
|
|
2037
|
+
};
|
|
2038
|
+
var debug7 = debuglog7("spamscanner");
|
|
2039
|
+
var GENERIC_TOKENIZER = /[^a-zá-úÁ-Úà-úÀ-Úñü\dа-яёæøåàáảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđäöëïîûœçążśźęćńł-]+/i;
|
|
2040
|
+
var converter = new AFHConvert();
|
|
2041
|
+
var chineseTokenizer = { tokenize: (text) => text.split(/\s+/) };
|
|
2042
|
+
var stopwordsMap = /* @__PURE__ */ new Map([
|
|
2043
|
+
["ar", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.ar || []])],
|
|
2044
|
+
["bg", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.bg || []])],
|
|
2045
|
+
["bn", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.bn || []])],
|
|
2046
|
+
["ca", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.ca || []])],
|
|
2047
|
+
["cs", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.cs || []])],
|
|
2048
|
+
["da", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.da || []])],
|
|
2049
|
+
["de", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.de || []])],
|
|
2050
|
+
["el", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.el || []])],
|
|
2051
|
+
["en", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.en || []])],
|
|
2052
|
+
["es", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.es || []])],
|
|
2053
|
+
["fa", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.fa || []])],
|
|
2054
|
+
["fi", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.fi || []])],
|
|
2055
|
+
["fr", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.fr || []])],
|
|
2056
|
+
["ga", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.ga || []])],
|
|
2057
|
+
["gl", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.gl || []])],
|
|
2058
|
+
["gu", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.gu || []])],
|
|
2059
|
+
["he", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.he || []])],
|
|
2060
|
+
["hi", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.hi || []])],
|
|
2061
|
+
["hr", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.hr || []])],
|
|
2062
|
+
["hu", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.hu || []])],
|
|
2063
|
+
["hy", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.hy || []])],
|
|
2064
|
+
["it", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.it || []])],
|
|
2065
|
+
["ja", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.ja || []])],
|
|
2066
|
+
["ko", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.ko || []])],
|
|
2067
|
+
["la", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.la || []])],
|
|
2068
|
+
["lt", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.lt || []])],
|
|
2069
|
+
["lv", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.lv || []])],
|
|
2070
|
+
["mr", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.mr || []])],
|
|
2071
|
+
["nl", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.nl || []])],
|
|
2072
|
+
["no", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.nob || []])],
|
|
2073
|
+
["pl", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.pl || []])],
|
|
2074
|
+
["pt", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.pt || []])],
|
|
2075
|
+
["ro", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.ro || []])],
|
|
2076
|
+
["ru", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.ru || []])],
|
|
2077
|
+
["sk", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.sk || []])],
|
|
2078
|
+
["sl", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.sl || []])],
|
|
2079
|
+
["sv", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.sv || []])],
|
|
2080
|
+
["th", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.th || []])],
|
|
2081
|
+
["tr", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.tr || []])],
|
|
2082
|
+
["uk", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.uk || []])],
|
|
2083
|
+
["vi", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.vi || []])],
|
|
2084
|
+
["zh", /* @__PURE__ */ new Set([...natural.stopwords || [], ...sw.zh || []])]
|
|
2085
|
+
]);
|
|
2086
|
+
var URL_ENDING_RESERVED_CHARS = /[).,;!?]+$/;
|
|
2087
|
+
var DATE_PATTERNS = [
|
|
2088
|
+
/\b(?:\d{1,2}[/-]){2}\d{2,4}\b/g,
|
|
2089
|
+
// MM/DD/YYYY or DD/MM/YYYY
|
|
2090
|
+
/\b\d{4}(?:[/-]\d{1,2}){2}\b/g,
|
|
2091
|
+
// YYYY/MM/DD
|
|
2092
|
+
/\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d{2,4}\b/gi,
|
|
2093
|
+
// DD MMM YYYY
|
|
2094
|
+
/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d{1,2},?\s+\d{2,4}\b/gi
|
|
2095
|
+
// MMM DD, YYYY
|
|
2096
|
+
];
|
|
2097
|
+
var FILE_PATH_PATTERNS = [
|
|
2098
|
+
/[a-z]:\\\\[^\\s<>:"|?*]+/gi,
|
|
2099
|
+
// Windows paths
|
|
2100
|
+
/\/[^\\s<>:"|?*]+/g,
|
|
2101
|
+
// Unix paths
|
|
2102
|
+
/~\/[^\\s<>:"|?*]+/g
|
|
2103
|
+
// Home directory paths
|
|
2104
|
+
];
|
|
2105
|
+
var CREDIT_CARD_PATTERN = creditCardRegex({ exact: false });
|
|
2106
|
+
var PHONE_PATTERN = phoneRegex({ exact: false });
|
|
2107
|
+
var EMAIL_PATTERN = emailRegexSafe({ exact: false });
|
|
2108
|
+
var IP_PATTERN = ipRegex({ exact: false });
|
|
2109
|
+
var URL_PATTERN = urlRegexSafe({ exact: false });
|
|
2110
|
+
var BITCOIN_PATTERN = bitcoinRegex({ exact: false });
|
|
2111
|
+
var MAC_PATTERN = macRegex({ exact: false });
|
|
2112
|
+
var HEX_COLOR_PATTERN = hexaColorRegex({ exact: false });
|
|
2113
|
+
var FLOATING_POINT_PATTERN = floatingPointRegex;
|
|
2114
|
+
var SpamScanner = class {
|
|
2115
|
+
constructor(options = {}) {
|
|
2116
|
+
this.config = {
|
|
2117
|
+
// Enhanced configuration options
|
|
2118
|
+
enableMacroDetection: true,
|
|
2119
|
+
enablePerformanceMetrics: false,
|
|
2120
|
+
timeout: 3e4,
|
|
2121
|
+
supportedLanguages: ["en"],
|
|
2122
|
+
enableMixedLanguageDetection: false,
|
|
2123
|
+
enableAdvancedPatternRecognition: true,
|
|
2124
|
+
// Authentication options (mailauth)
|
|
2125
|
+
enableAuthentication: false,
|
|
2126
|
+
authOptions: {
|
|
2127
|
+
ip: null,
|
|
2128
|
+
// Remote IP address (required for auth)
|
|
2129
|
+
helo: null,
|
|
2130
|
+
// HELO/EHLO hostname
|
|
2131
|
+
mta: "spamscanner",
|
|
2132
|
+
// MTA hostname
|
|
2133
|
+
sender: null,
|
|
2134
|
+
// Envelope sender (MAIL FROM)
|
|
2135
|
+
timeout: 1e4
|
|
2136
|
+
// DNS lookup timeout
|
|
2137
|
+
},
|
|
2138
|
+
authScoreWeights: {
|
|
2139
|
+
dkimPass: -2,
|
|
2140
|
+
dkimFail: 3,
|
|
2141
|
+
spfPass: -1,
|
|
2142
|
+
spfFail: 2,
|
|
2143
|
+
spfSoftfail: 1,
|
|
2144
|
+
dmarcPass: -2,
|
|
2145
|
+
dmarcFail: 4,
|
|
2146
|
+
arcPass: -1,
|
|
2147
|
+
arcFail: 1
|
|
2148
|
+
},
|
|
2149
|
+
// Reputation API options (Forward Email)
|
|
2150
|
+
enableReputation: false,
|
|
2151
|
+
reputationOptions: {
|
|
2152
|
+
apiUrl: "https://api.forwardemail.net/v1/reputation",
|
|
2153
|
+
timeout: 1e4,
|
|
2154
|
+
onlyAligned: true
|
|
2155
|
+
},
|
|
2156
|
+
// Arbitrary spam detection options
|
|
2157
|
+
enableArbitraryDetection: true,
|
|
2158
|
+
arbitraryThreshold: 5,
|
|
2159
|
+
// Existing options
|
|
2160
|
+
debug: false,
|
|
2161
|
+
logger: console,
|
|
2162
|
+
clamscan: {
|
|
2163
|
+
removeInfected: false,
|
|
2164
|
+
quarantineInfected: false,
|
|
2165
|
+
scanLog: null,
|
|
2166
|
+
debugMode: false,
|
|
2167
|
+
fileList: null,
|
|
2168
|
+
scanRecursively: true,
|
|
2169
|
+
clamscanPath: "/usr/bin/clamscan",
|
|
2170
|
+
clamdscanPath: "/usr/bin/clamdscan",
|
|
2171
|
+
preference: "clamdscan"
|
|
2172
|
+
},
|
|
2173
|
+
classifier: null,
|
|
2174
|
+
replacements: null,
|
|
2175
|
+
...options
|
|
2176
|
+
};
|
|
2177
|
+
this.classifier = null;
|
|
2178
|
+
this.clamscan = null;
|
|
2179
|
+
this.isInitialized = false;
|
|
2180
|
+
this.replacements = /* @__PURE__ */ new Map();
|
|
2181
|
+
this.metrics = {
|
|
2182
|
+
totalScans: 0,
|
|
2183
|
+
averageTime: 0,
|
|
2184
|
+
lastScanTime: 0
|
|
2185
|
+
};
|
|
2186
|
+
autoBind(this);
|
|
2187
|
+
}
|
|
2188
|
+
async initializeClassifier() {
|
|
2189
|
+
if (this.classifier) {
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
try {
|
|
2193
|
+
if (this.config.classifier) {
|
|
2194
|
+
this.classifier = new NaiveBayes2(this.config.classifier);
|
|
2195
|
+
} else {
|
|
2196
|
+
const classifierData = await getClassifier();
|
|
2197
|
+
this.classifier = new NaiveBayes2(classifierData);
|
|
2198
|
+
}
|
|
2199
|
+
this.classifier.tokenizer = function(tokens) {
|
|
2200
|
+
if (typeof tokens === "string") {
|
|
2201
|
+
return tokens.split(/\s+/);
|
|
2202
|
+
}
|
|
2203
|
+
return Array.isArray(tokens) ? tokens : [];
|
|
2204
|
+
};
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
debug7("Failed to initialize classifier:", error);
|
|
2207
|
+
this.classifier = new NaiveBayes2();
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
// Initialize replacements
|
|
2211
|
+
async initializeReplacements() {
|
|
2212
|
+
if (this.replacements && this.replacements.size > 0) {
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
try {
|
|
2216
|
+
const replacements2 = this.config.replacements || await getReplacements();
|
|
2217
|
+
if (replacements2 instanceof Map) {
|
|
2218
|
+
this.replacements = replacements2;
|
|
2219
|
+
} else if (typeof replacements2 === "object" && replacements2 !== null) {
|
|
2220
|
+
this.replacements = new Map(Object.entries(replacements2));
|
|
2221
|
+
} else {
|
|
2222
|
+
throw new Error("Invalid replacements format");
|
|
2223
|
+
}
|
|
2224
|
+
} catch (error) {
|
|
2225
|
+
debug7("Failed to initialize replacements:", error);
|
|
2226
|
+
this.replacements = /* @__PURE__ */ new Map();
|
|
2227
|
+
const basicReplacements = {
|
|
2228
|
+
u: "you",
|
|
2229
|
+
ur: "your",
|
|
2230
|
+
r: "are",
|
|
2231
|
+
n: "and",
|
|
2232
|
+
"w/": "with",
|
|
2233
|
+
b4: "before",
|
|
2234
|
+
2: "to",
|
|
2235
|
+
4: "for"
|
|
2236
|
+
};
|
|
2237
|
+
for (const [word, replacement] of Object.entries(basicReplacements)) {
|
|
2238
|
+
this.replacements.set(word, replacement);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
// Initialize regex helpers
|
|
2243
|
+
initializeRegex() {
|
|
2244
|
+
this.regexCache = /* @__PURE__ */ new Map();
|
|
2245
|
+
this.urlCache = /* @__PURE__ */ new Map();
|
|
2246
|
+
}
|
|
2247
|
+
// Enhanced virus scanning with timeout protection
|
|
2248
|
+
async getVirusResults(mail) {
|
|
2249
|
+
if (!this.clamscan) {
|
|
2250
|
+
try {
|
|
2251
|
+
this.clamscan = await new ClamScan().init(this.config.clamscan);
|
|
2252
|
+
} catch (error) {
|
|
2253
|
+
debug7("ClamScan initialization failed:", error);
|
|
2254
|
+
return [];
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
const results = [];
|
|
2258
|
+
const attachments = mail.attachments || [];
|
|
2259
|
+
for (const attachment of attachments) {
|
|
2260
|
+
try {
|
|
2261
|
+
if (attachment.content && isBuffer(attachment.content)) {
|
|
2262
|
+
const scanResult = await Promise.race([
|
|
2263
|
+
this.clamscan.scanBuffer(attachment.content),
|
|
2264
|
+
new Promise((_resolve, reject) => {
|
|
2265
|
+
setTimeout(() => reject(new Error("Virus scan timeout")), this.config.timeout);
|
|
2266
|
+
})
|
|
2267
|
+
]);
|
|
2268
|
+
if (scanResult.isInfected) {
|
|
2269
|
+
results.push({
|
|
2270
|
+
filename: attachment.filename || "unknown",
|
|
2271
|
+
virus: scanResult.viruses || ["Unknown virus"],
|
|
2272
|
+
type: "virus"
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
debug7("Virus scan error:", error);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
return results;
|
|
2281
|
+
}
|
|
2282
|
+
// Macro detection (DONE)
|
|
2283
|
+
async getMacroResults(mail) {
|
|
2284
|
+
const results = [];
|
|
2285
|
+
const attachments = mail.attachments || [];
|
|
2286
|
+
const textContent = mail.text || "";
|
|
2287
|
+
const htmlContent = mail.html || "";
|
|
2288
|
+
const vbaPatterns = [
|
|
2289
|
+
/sub\s+\w+\s*\(/gi,
|
|
2290
|
+
/function\s+\w+\s*\(/gi,
|
|
2291
|
+
/dim\s+\w+\s+as\s+\w+/gi,
|
|
2292
|
+
/application\.run/gi,
|
|
2293
|
+
/shell\s*\(/gi
|
|
2294
|
+
];
|
|
2295
|
+
const powershellPatterns = [
|
|
2296
|
+
/powershell/gi,
|
|
2297
|
+
/invoke-expression/gi,
|
|
2298
|
+
/iex\s*\(/gi,
|
|
2299
|
+
/start-process/gi,
|
|
2300
|
+
/new-object\s+system\./gi
|
|
2301
|
+
];
|
|
2302
|
+
const jsPatterns = [
|
|
2303
|
+
/eval\s*\(/gi,
|
|
2304
|
+
/document\.write/gi,
|
|
2305
|
+
/activexobject/gi,
|
|
2306
|
+
/wscript\./gi,
|
|
2307
|
+
/new\s+activexobject/gi
|
|
2308
|
+
];
|
|
2309
|
+
const batchPatterns = [/@echo\s+off/gi, /cmd\s*\/c/gi, /start\s+\/b/gi, /for\s+\/[lrf]/gi];
|
|
2310
|
+
let allContent = textContent + " " + htmlContent;
|
|
2311
|
+
if (mail.headerLines && Array.isArray(mail.headerLines)) {
|
|
2312
|
+
for (const headerLine of mail.headerLines) {
|
|
2313
|
+
if (headerLine.line) {
|
|
2314
|
+
allContent += " " + headerLine.line;
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
for (const pattern of vbaPatterns) {
|
|
2319
|
+
if (pattern.test(allContent)) {
|
|
2320
|
+
results.push({
|
|
2321
|
+
type: "macro",
|
|
2322
|
+
subtype: "vba",
|
|
2323
|
+
description: "VBA macro detected"
|
|
2324
|
+
});
|
|
2325
|
+
break;
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
for (const pattern of powershellPatterns) {
|
|
2329
|
+
if (pattern.test(allContent)) {
|
|
2330
|
+
results.push({
|
|
2331
|
+
type: "macro",
|
|
2332
|
+
subtype: "powershell",
|
|
2333
|
+
description: "PowerShell script detected"
|
|
2334
|
+
});
|
|
2335
|
+
break;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
for (const pattern of jsPatterns) {
|
|
2339
|
+
if (pattern.test(allContent)) {
|
|
2340
|
+
results.push({
|
|
2341
|
+
type: "macro",
|
|
2342
|
+
subtype: "javascript",
|
|
2343
|
+
description: "JavaScript macro detected"
|
|
2344
|
+
});
|
|
2345
|
+
break;
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
for (const pattern of batchPatterns) {
|
|
2349
|
+
if (pattern.test(allContent)) {
|
|
2350
|
+
results.push({
|
|
2351
|
+
type: "macro",
|
|
2352
|
+
subtype: "batch",
|
|
2353
|
+
description: "Batch script detected"
|
|
2354
|
+
});
|
|
2355
|
+
break;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
for (const attachment of attachments) {
|
|
2359
|
+
if (attachment.filename) {
|
|
2360
|
+
const extension = fileExtension(attachment.filename).toLowerCase();
|
|
2361
|
+
const macroExtensions = ["vbs", "vba", "ps1", "bat", "cmd", "scr", "pif"];
|
|
2362
|
+
const officeMacroExtensions = ["docm", "xlsm", "pptm", "xlam", "dotm", "xltm", "potm"];
|
|
2363
|
+
const legacyOfficeExtensions = ["doc", "xls", "ppt", "dot", "xlt", "pot", "xla", "ppa"];
|
|
2364
|
+
if (macroExtensions.includes(extension)) {
|
|
2365
|
+
results.push({
|
|
2366
|
+
type: "macro",
|
|
2367
|
+
subtype: "script",
|
|
2368
|
+
filename: attachment.filename,
|
|
2369
|
+
description: `Macro script attachment detected: ${extension}`
|
|
2370
|
+
});
|
|
2371
|
+
} else if (officeMacroExtensions.includes(extension)) {
|
|
2372
|
+
results.push({
|
|
2373
|
+
type: "macro",
|
|
2374
|
+
subtype: "office_document",
|
|
2375
|
+
filename: attachment.filename,
|
|
2376
|
+
description: `Office document with macro capability: ${extension}`
|
|
2377
|
+
});
|
|
2378
|
+
} else if (legacyOfficeExtensions.includes(extension)) {
|
|
2379
|
+
results.push({
|
|
2380
|
+
type: "macro",
|
|
2381
|
+
subtype: "legacy_office",
|
|
2382
|
+
filename: attachment.filename,
|
|
2383
|
+
description: `Legacy Office document (macro-capable): ${extension}`,
|
|
2384
|
+
risk: "high"
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
if (extension === "pdf" && attachment.content && isBuffer(attachment.content)) {
|
|
2388
|
+
try {
|
|
2389
|
+
const pdfContent = attachment.content.toString("latin1");
|
|
2390
|
+
if (pdfContent.includes("/JavaScript") || pdfContent.includes("/JS")) {
|
|
2391
|
+
results.push({
|
|
2392
|
+
type: "macro",
|
|
2393
|
+
subtype: "pdf_javascript",
|
|
2394
|
+
filename: attachment.filename,
|
|
2395
|
+
description: "PDF with embedded JavaScript detected",
|
|
2396
|
+
risk: "medium"
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
} catch (error) {
|
|
2400
|
+
debug7("PDF JavaScript detection error:", error);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
return results;
|
|
2406
|
+
}
|
|
2407
|
+
// File path detection (DONE)
|
|
2408
|
+
async getFilePathResults(mail) {
|
|
2409
|
+
const results = [];
|
|
2410
|
+
const textContent = mail.text || "";
|
|
2411
|
+
const htmlContent = mail.html || "";
|
|
2412
|
+
const allContent = textContent + " " + htmlContent;
|
|
2413
|
+
for (const pattern of FILE_PATH_PATTERNS) {
|
|
2414
|
+
const matches = allContent.match(pattern);
|
|
2415
|
+
if (matches) {
|
|
2416
|
+
for (const match of matches) {
|
|
2417
|
+
if (this.isValidFilePath(match)) {
|
|
2418
|
+
results.push({
|
|
2419
|
+
type: "file_path",
|
|
2420
|
+
path: match,
|
|
2421
|
+
description: "Suspicious file path detected"
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
return results;
|
|
2428
|
+
}
|
|
2429
|
+
// Check if a path is a valid file path (not HTML tag or false positive)
|
|
2430
|
+
isValidFilePath(path3) {
|
|
2431
|
+
const htmlTags = [
|
|
2432
|
+
"a",
|
|
2433
|
+
"abbr",
|
|
2434
|
+
"address",
|
|
2435
|
+
"area",
|
|
2436
|
+
"article",
|
|
2437
|
+
"aside",
|
|
2438
|
+
"audio",
|
|
2439
|
+
"b",
|
|
2440
|
+
"base",
|
|
2441
|
+
"bdi",
|
|
2442
|
+
"bdo",
|
|
2443
|
+
"blockquote",
|
|
2444
|
+
"body",
|
|
2445
|
+
"br",
|
|
2446
|
+
"button",
|
|
2447
|
+
"canvas",
|
|
2448
|
+
"caption",
|
|
2449
|
+
"cite",
|
|
2450
|
+
"code",
|
|
2451
|
+
"col",
|
|
2452
|
+
"colgroup",
|
|
2453
|
+
"data",
|
|
2454
|
+
"datalist",
|
|
2455
|
+
"dd",
|
|
2456
|
+
"del",
|
|
2457
|
+
"details",
|
|
2458
|
+
"dfn",
|
|
2459
|
+
"dialog",
|
|
2460
|
+
"div",
|
|
2461
|
+
"dl",
|
|
2462
|
+
"dt",
|
|
2463
|
+
"em",
|
|
2464
|
+
"embed",
|
|
2465
|
+
"fieldset",
|
|
2466
|
+
"figcaption",
|
|
2467
|
+
"figure",
|
|
2468
|
+
"footer",
|
|
2469
|
+
"form",
|
|
2470
|
+
"h1",
|
|
2471
|
+
"h2",
|
|
2472
|
+
"h3",
|
|
2473
|
+
"h4",
|
|
2474
|
+
"h5",
|
|
2475
|
+
"h6",
|
|
2476
|
+
"head",
|
|
2477
|
+
"header",
|
|
2478
|
+
"hr",
|
|
2479
|
+
"html",
|
|
2480
|
+
"i",
|
|
2481
|
+
"iframe",
|
|
2482
|
+
"img",
|
|
2483
|
+
"input",
|
|
2484
|
+
"ins",
|
|
2485
|
+
"kbd",
|
|
2486
|
+
"label",
|
|
2487
|
+
"legend",
|
|
2488
|
+
"li",
|
|
2489
|
+
"link",
|
|
2490
|
+
"main",
|
|
2491
|
+
"map",
|
|
2492
|
+
"mark",
|
|
2493
|
+
"meta",
|
|
2494
|
+
"meter",
|
|
2495
|
+
"nav",
|
|
2496
|
+
"noscript",
|
|
2497
|
+
"object",
|
|
2498
|
+
"ol",
|
|
2499
|
+
"optgroup",
|
|
2500
|
+
"option",
|
|
2501
|
+
"output",
|
|
2502
|
+
"p",
|
|
2503
|
+
"param",
|
|
2504
|
+
"picture",
|
|
2505
|
+
"pre",
|
|
2506
|
+
"progress",
|
|
2507
|
+
"q",
|
|
2508
|
+
"rp",
|
|
2509
|
+
"rt",
|
|
2510
|
+
"ruby",
|
|
2511
|
+
"s",
|
|
2512
|
+
"samp",
|
|
2513
|
+
"script",
|
|
2514
|
+
"section",
|
|
2515
|
+
"select",
|
|
2516
|
+
"small",
|
|
2517
|
+
"source",
|
|
2518
|
+
"span",
|
|
2519
|
+
"strong",
|
|
2520
|
+
"style",
|
|
2521
|
+
"sub",
|
|
2522
|
+
"summary",
|
|
2523
|
+
"sup",
|
|
2524
|
+
"svg",
|
|
2525
|
+
"table",
|
|
2526
|
+
"tbody",
|
|
2527
|
+
"td",
|
|
2528
|
+
"template",
|
|
2529
|
+
"textarea",
|
|
2530
|
+
"tfoot",
|
|
2531
|
+
"th",
|
|
2532
|
+
"thead",
|
|
2533
|
+
"time",
|
|
2534
|
+
"title",
|
|
2535
|
+
"tr",
|
|
2536
|
+
"track",
|
|
2537
|
+
"u",
|
|
2538
|
+
"ul",
|
|
2539
|
+
"var",
|
|
2540
|
+
"video",
|
|
2541
|
+
"wbr"
|
|
2542
|
+
];
|
|
2543
|
+
const tagMatch = path3.match(/^\/([a-z\d]+)$/i);
|
|
2544
|
+
if (tagMatch && htmlTags.includes(tagMatch[1].toLowerCase())) {
|
|
2545
|
+
return false;
|
|
2546
|
+
}
|
|
2547
|
+
if (path3.length < 4) {
|
|
2548
|
+
return false;
|
|
2549
|
+
}
|
|
2550
|
+
if (/^\/\/[a-z\d.-]+$/i.test(path3)) {
|
|
2551
|
+
return false;
|
|
2552
|
+
}
|
|
2553
|
+
if (!path3.includes(".") && !path3.includes("/")) {
|
|
2554
|
+
return false;
|
|
2555
|
+
}
|
|
2556
|
+
return true;
|
|
2557
|
+
}
|
|
2558
|
+
// Optimize URL parsing with timeout protection (DONE)
|
|
2559
|
+
async optimizeUrlParsing(url) {
|
|
2560
|
+
try {
|
|
2561
|
+
return await Promise.race([
|
|
2562
|
+
normalizeUrl(url, {
|
|
2563
|
+
stripHash: true,
|
|
2564
|
+
stripWWW: false,
|
|
2565
|
+
removeQueryParameters: false
|
|
2566
|
+
}),
|
|
2567
|
+
new Promise((_resolve, reject) => {
|
|
2568
|
+
setTimeout(() => reject(new Error("URL parsing timeout")), 5e3);
|
|
2569
|
+
})
|
|
2570
|
+
]);
|
|
2571
|
+
} catch {
|
|
2572
|
+
return url;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
// Enhanced Cloudflare blocked domain checking with timeout
|
|
2576
|
+
async isCloudflareBlocked(hostname) {
|
|
2577
|
+
try {
|
|
2578
|
+
const response = await Promise.race([
|
|
2579
|
+
superagent.get(`https://1.1.1.3/dns-query?name=${hostname}&type=A`).set("Accept", "application/dns-json").timeout(5e3),
|
|
2580
|
+
new Promise((_resolve, reject) => {
|
|
2581
|
+
setTimeout(() => reject(new Error("DNS timeout")), 5e3);
|
|
2582
|
+
})
|
|
2583
|
+
]);
|
|
2584
|
+
return response.body?.Status === 3;
|
|
2585
|
+
} catch {
|
|
2586
|
+
return false;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
// Extract URLs from all possible sources
|
|
2590
|
+
extractAllUrls(mail, originalSource) {
|
|
2591
|
+
let allText = "";
|
|
2592
|
+
allText += (mail.text || "") + " " + (mail.html || "");
|
|
2593
|
+
if (mail.headerLines && Array.isArray(mail.headerLines)) {
|
|
2594
|
+
for (const headerLine of mail.headerLines) {
|
|
2595
|
+
if (headerLine.line) {
|
|
2596
|
+
allText += " " + headerLine.line;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
if (typeof originalSource === "string") {
|
|
2601
|
+
allText += " " + originalSource;
|
|
2602
|
+
}
|
|
2603
|
+
return this.getUrls(allText);
|
|
2604
|
+
}
|
|
2605
|
+
// Enhanced URL extraction with improved parsing using tldts
|
|
2606
|
+
getUrls(string_) {
|
|
2607
|
+
if (!isSANB(string_)) {
|
|
2608
|
+
return [];
|
|
2609
|
+
}
|
|
2610
|
+
const urls = [];
|
|
2611
|
+
const matches = string_.match(URL_PATTERN);
|
|
2612
|
+
if (matches) {
|
|
2613
|
+
for (let url of matches) {
|
|
2614
|
+
url = url.replace(URL_ENDING_RESERVED_CHARS, "");
|
|
2615
|
+
try {
|
|
2616
|
+
const normalizedUrl = normalizeUrl(url, {
|
|
2617
|
+
stripHash: false,
|
|
2618
|
+
stripWWW: false
|
|
2619
|
+
});
|
|
2620
|
+
urls.push(normalizedUrl);
|
|
2621
|
+
} catch {
|
|
2622
|
+
urls.push(url);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
return [...new Set(urls)];
|
|
2627
|
+
}
|
|
2628
|
+
// Parse URL using tldts for accurate domain extraction
|
|
2629
|
+
parseUrlWithTldts(url) {
|
|
2630
|
+
try {
|
|
2631
|
+
const parsed = parseTldts(url, { allowPrivateDomains: true });
|
|
2632
|
+
return {
|
|
2633
|
+
domain: parsed.domain,
|
|
2634
|
+
domainWithoutSuffix: parsed.domainWithoutSuffix,
|
|
2635
|
+
hostname: parsed.hostname,
|
|
2636
|
+
publicSuffix: parsed.publicSuffix,
|
|
2637
|
+
subdomain: parsed.subdomain,
|
|
2638
|
+
isIp: parsed.isIp,
|
|
2639
|
+
isIcann: parsed.isIcann,
|
|
2640
|
+
isPrivate: parsed.isPrivate
|
|
2641
|
+
};
|
|
2642
|
+
} catch (error) {
|
|
2643
|
+
debug7("tldts parsing error:", error);
|
|
2644
|
+
return null;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
// Enhanced tokenization with language detection
|
|
2648
|
+
async getTokens(string_, locale = "en", isHtml = false) {
|
|
2649
|
+
if (!isSANB(string_)) {
|
|
2650
|
+
return [];
|
|
2651
|
+
}
|
|
2652
|
+
let text = string_;
|
|
2653
|
+
if (isHtml) {
|
|
2654
|
+
text = striptags(text);
|
|
2655
|
+
}
|
|
2656
|
+
if (!locale || this.config.enableMixedLanguageDetection) {
|
|
2657
|
+
try {
|
|
2658
|
+
const detected = lande(text);
|
|
2659
|
+
if (detected && detected.length > 0) {
|
|
2660
|
+
locale = detected[0][0];
|
|
2661
|
+
}
|
|
2662
|
+
} catch {
|
|
2663
|
+
locale ||= "en";
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
locale = this.parseLocale(locale);
|
|
2667
|
+
text = converter.toHalfWidth(text);
|
|
2668
|
+
try {
|
|
2669
|
+
text = expandContractions(text);
|
|
2670
|
+
} catch {
|
|
2671
|
+
}
|
|
2672
|
+
let tokens = [];
|
|
2673
|
+
if (locale === "ja") {
|
|
2674
|
+
try {
|
|
2675
|
+
tokens = chineseTokenizer.tokenize(text);
|
|
2676
|
+
} catch {
|
|
2677
|
+
tokens = text.split(GENERIC_TOKENIZER);
|
|
2678
|
+
}
|
|
2679
|
+
} else if (locale === "zh") {
|
|
2680
|
+
try {
|
|
2681
|
+
tokens = chineseTokenizer.tokenize(text);
|
|
2682
|
+
} catch {
|
|
2683
|
+
tokens = text.split(GENERIC_TOKENIZER);
|
|
2684
|
+
}
|
|
2685
|
+
} else {
|
|
2686
|
+
tokens = text.split(GENERIC_TOKENIZER);
|
|
2687
|
+
}
|
|
2688
|
+
let processedTokens = tokens.map((token) => token.toLowerCase().trim()).filter((token) => token.length > 0 && token.length <= 50);
|
|
2689
|
+
const stopwordSet = stopwordsMap.get(locale) || stopwordsMap.get("en");
|
|
2690
|
+
if (stopwordSet) {
|
|
2691
|
+
processedTokens = processedTokens.filter((token) => !stopwordSet.has(token));
|
|
2692
|
+
}
|
|
2693
|
+
try {
|
|
2694
|
+
if (["en", "es", "fr", "de", "it", "pt", "ru"].includes(locale)) {
|
|
2695
|
+
processedTokens = processedTokens.map((token) => {
|
|
2696
|
+
try {
|
|
2697
|
+
return snowball.stemword(token, locale);
|
|
2698
|
+
} catch {
|
|
2699
|
+
return token;
|
|
2700
|
+
}
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
} catch {
|
|
2704
|
+
}
|
|
2705
|
+
if (this.config.hashTokens) {
|
|
2706
|
+
processedTokens = processedTokens.map((token) => createHash2("sha256").update(token).digest("hex").slice(0, 16));
|
|
2707
|
+
}
|
|
2708
|
+
return processedTokens;
|
|
2709
|
+
}
|
|
2710
|
+
// Enhanced text preprocessing with pattern recognition
|
|
2711
|
+
async preprocessText(string_) {
|
|
2712
|
+
if (!isSANB(string_)) {
|
|
2713
|
+
return "";
|
|
2714
|
+
}
|
|
2715
|
+
let text = string_;
|
|
2716
|
+
if (this.replacements) {
|
|
2717
|
+
for (const [original, replacement] of this.replacements) {
|
|
2718
|
+
text = text.replaceAll(new RegExp(escapeStringRegexp(original), "gi"), replacement);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
if (this.config.enableAdvancedPatternRecognition) {
|
|
2722
|
+
text = text.replaceAll(DATE_PATTERNS[0], " DATE_PATTERN ");
|
|
2723
|
+
text = text.replace(CREDIT_CARD_PATTERN, " CREDIT_CARD ");
|
|
2724
|
+
text = text.replace(PHONE_PATTERN, " PHONE_NUMBER ");
|
|
2725
|
+
text = text.replace(EMAIL_PATTERN, " EMAIL_ADDRESS ");
|
|
2726
|
+
text = text.replace(IP_PATTERN, " IP_ADDRESS ");
|
|
2727
|
+
text = text.replace(URL_PATTERN, " URL_LINK ");
|
|
2728
|
+
text = text.replace(BITCOIN_PATTERN, " BITCOIN_ADDRESS ");
|
|
2729
|
+
text = text.replace(MAC_PATTERN, " MAC_ADDRESS ");
|
|
2730
|
+
text = text.replace(HEX_COLOR_PATTERN, " HEX_COLOR ");
|
|
2731
|
+
text = text.replace(FLOATING_POINT_PATTERN, " FLOATING_POINT ");
|
|
2732
|
+
}
|
|
2733
|
+
return text;
|
|
2734
|
+
}
|
|
2735
|
+
// Main scan method - enhanced with performance metrics, auth, and reputation
|
|
2736
|
+
async scan(source, scanOptions = {}) {
|
|
2737
|
+
const startTime = Date.now();
|
|
2738
|
+
try {
|
|
2739
|
+
await this.initializeClassifier();
|
|
2740
|
+
await this.initializeReplacements();
|
|
2741
|
+
const { tokens, mail } = await this.getTokensAndMailFromSource(source);
|
|
2742
|
+
const authOptions = { ...this.config.authOptions, ...scanOptions.authOptions };
|
|
2743
|
+
const reputationOptions = { ...this.config.reputationOptions, ...scanOptions.reputationOptions };
|
|
2744
|
+
const detectionPromises = [
|
|
2745
|
+
this.getClassification(tokens),
|
|
2746
|
+
this.getPhishingResults(mail),
|
|
2747
|
+
this.getExecutableResults(mail),
|
|
2748
|
+
this.config.enableMacroDetection ? this.getMacroResults(mail) : [],
|
|
2749
|
+
this.config.enableArbitraryDetection ? this.getArbitraryResults(mail, { remoteAddress: authOptions.ip, resolvedClientHostname: authOptions.hostname }) : [],
|
|
2750
|
+
this.getVirusResults(mail),
|
|
2751
|
+
this.getPatternResults(mail),
|
|
2752
|
+
this.getIDNHomographResults(mail),
|
|
2753
|
+
this.getToxicityResults(mail),
|
|
2754
|
+
this.getNSFWResults(mail)
|
|
2755
|
+
];
|
|
2756
|
+
const enableAuth = scanOptions.enableAuthentication ?? this.config.enableAuthentication;
|
|
2757
|
+
if (enableAuth && authOptions.ip) {
|
|
2758
|
+
detectionPromises.push(this.getAuthenticationResults(source, mail, authOptions));
|
|
2759
|
+
} else {
|
|
2760
|
+
detectionPromises.push(Promise.resolve(null));
|
|
2761
|
+
}
|
|
2762
|
+
const enableReputation = scanOptions.enableReputation ?? this.config.enableReputation;
|
|
2763
|
+
if (enableReputation) {
|
|
2764
|
+
detectionPromises.push(this.getReputationResults(mail, authOptions, reputationOptions));
|
|
2765
|
+
} else {
|
|
2766
|
+
detectionPromises.push(Promise.resolve(null));
|
|
2767
|
+
}
|
|
2768
|
+
const [
|
|
2769
|
+
classification,
|
|
2770
|
+
phishing,
|
|
2771
|
+
executables,
|
|
2772
|
+
macros,
|
|
2773
|
+
arbitrary,
|
|
2774
|
+
viruses,
|
|
2775
|
+
patterns,
|
|
2776
|
+
idnHomographAttack,
|
|
2777
|
+
toxicity,
|
|
2778
|
+
nsfw,
|
|
2779
|
+
authResult,
|
|
2780
|
+
reputationResult
|
|
2781
|
+
] = await Promise.all(detectionPromises);
|
|
2782
|
+
let isSpam = classification.category === "spam" || phishing.length > 0 || executables.length > 0 || macros.length > 0 || arbitrary.length > 0 || viruses.length > 0 || patterns.length > 0 || idnHomographAttack && idnHomographAttack.detected || toxicity.length > 0 || nsfw.length > 0;
|
|
2783
|
+
if (reputationResult && reputationResult.isDenylisted) {
|
|
2784
|
+
isSpam = true;
|
|
2785
|
+
}
|
|
2786
|
+
let message = "Ham";
|
|
2787
|
+
if (isSpam) {
|
|
2788
|
+
const reasons = [];
|
|
2789
|
+
if (classification.category === "spam") {
|
|
2790
|
+
reasons.push("spam classification");
|
|
2791
|
+
}
|
|
2792
|
+
if (phishing.length > 0) {
|
|
2793
|
+
reasons.push("phishing detected");
|
|
2794
|
+
}
|
|
2795
|
+
if (executables.length > 0) {
|
|
2796
|
+
reasons.push("executable content");
|
|
2797
|
+
}
|
|
2798
|
+
if (macros.length > 0) {
|
|
2799
|
+
reasons.push("macro detected");
|
|
2800
|
+
}
|
|
2801
|
+
if (arbitrary.length > 0) {
|
|
2802
|
+
reasons.push("arbitrary patterns");
|
|
2803
|
+
}
|
|
2804
|
+
if (viruses.length > 0) {
|
|
2805
|
+
reasons.push("virus detected");
|
|
2806
|
+
}
|
|
2807
|
+
if (patterns.length > 0) {
|
|
2808
|
+
reasons.push("suspicious patterns");
|
|
2809
|
+
}
|
|
2810
|
+
if (idnHomographAttack && idnHomographAttack.detected) {
|
|
2811
|
+
reasons.push("IDN homograph attack");
|
|
2812
|
+
}
|
|
2813
|
+
if (toxicity.length > 0) {
|
|
2814
|
+
reasons.push("toxic content");
|
|
2815
|
+
}
|
|
2816
|
+
if (nsfw.length > 0) {
|
|
2817
|
+
reasons.push("NSFW content");
|
|
2818
|
+
}
|
|
2819
|
+
if (reputationResult?.isDenylisted) {
|
|
2820
|
+
reasons.push("denylisted sender");
|
|
2821
|
+
}
|
|
2822
|
+
message = `Spam (${arrayJoinConjunction(reasons)})`;
|
|
2823
|
+
} else if (reputationResult?.isTruthSource) {
|
|
2824
|
+
message = "Ham (truth source)";
|
|
2825
|
+
} else if (reputationResult?.isAllowlisted) {
|
|
2826
|
+
message = "Ham (allowlisted)";
|
|
2827
|
+
}
|
|
2828
|
+
const endTime = Date.now();
|
|
2829
|
+
const processingTime = endTime - startTime;
|
|
2830
|
+
this.metrics.totalScans++;
|
|
2831
|
+
this.metrics.lastScanTime = processingTime;
|
|
2832
|
+
this.metrics.averageTime = (this.metrics.averageTime * (this.metrics.totalScans - 1) + processingTime) / this.metrics.totalScans;
|
|
2833
|
+
const result = {
|
|
2834
|
+
isSpam,
|
|
2835
|
+
message,
|
|
2836
|
+
results: {
|
|
2837
|
+
classification,
|
|
2838
|
+
phishing,
|
|
2839
|
+
executables,
|
|
2840
|
+
macros,
|
|
2841
|
+
arbitrary,
|
|
2842
|
+
viruses,
|
|
2843
|
+
patterns,
|
|
2844
|
+
idnHomographAttack,
|
|
2845
|
+
toxicity,
|
|
2846
|
+
nsfw,
|
|
2847
|
+
authentication: authResult,
|
|
2848
|
+
reputation: reputationResult
|
|
2849
|
+
},
|
|
2850
|
+
links: this.extractAllUrls(mail, source),
|
|
2851
|
+
tokens,
|
|
2852
|
+
mail
|
|
2853
|
+
};
|
|
2854
|
+
if (this.config.enablePerformanceMetrics) {
|
|
2855
|
+
result.metrics = {
|
|
2856
|
+
totalTime: processingTime,
|
|
2857
|
+
classificationTime: 0,
|
|
2858
|
+
// Would need to measure individually
|
|
2859
|
+
phishingTime: 0,
|
|
2860
|
+
executableTime: 0,
|
|
2861
|
+
macroTime: 0,
|
|
2862
|
+
virusTime: 0,
|
|
2863
|
+
patternTime: 0,
|
|
2864
|
+
idnTime: 0,
|
|
2865
|
+
memoryUsage: process.memoryUsage()
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
return result;
|
|
2869
|
+
} catch (error) {
|
|
2870
|
+
debug7("Scan error:", error);
|
|
2871
|
+
throw error;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
// Get authentication results using mailauth
|
|
2875
|
+
async getAuthenticationResults(source, mail, options = {}) {
|
|
2876
|
+
try {
|
|
2877
|
+
const messageBuffer = typeof source === "string" ? Buffer3.from(source) : source;
|
|
2878
|
+
const sender = options.sender || mail.from?.value?.[0]?.address || mail.from?.text;
|
|
2879
|
+
const authResult = await authenticate(messageBuffer, {
|
|
2880
|
+
ip: options.ip,
|
|
2881
|
+
helo: options.helo,
|
|
2882
|
+
mta: options.mta || "spamscanner",
|
|
2883
|
+
sender,
|
|
2884
|
+
timeout: options.timeout || 1e4
|
|
2885
|
+
});
|
|
2886
|
+
const scoreResult = calculateAuthScore(authResult, this.config.authScoreWeights);
|
|
2887
|
+
return {
|
|
2888
|
+
...authResult,
|
|
2889
|
+
score: scoreResult,
|
|
2890
|
+
authResultsHeader: formatAuthResultsHeader(authResult, options.mta || "spamscanner")
|
|
2891
|
+
};
|
|
2892
|
+
} catch (error) {
|
|
2893
|
+
debug7("Authentication error:", error);
|
|
2894
|
+
return null;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
// Get reputation results from Forward Email API
|
|
2898
|
+
// Uses get-attributes module to extract comprehensive attributes for checking
|
|
2899
|
+
async getReputationResults(mail, authOptions = {}, reputationOptions = {}) {
|
|
2900
|
+
try {
|
|
2901
|
+
const { attributes, session } = await extractAttributes(mail, {
|
|
2902
|
+
isAligned: reputationOptions.onlyAligned ?? true,
|
|
2903
|
+
senderIp: authOptions.ip,
|
|
2904
|
+
senderHostname: authOptions.hostname,
|
|
2905
|
+
authResults: authOptions.authResults
|
|
2906
|
+
});
|
|
2907
|
+
const valuesToCheck = [...attributes];
|
|
2908
|
+
if (authOptions.sender) {
|
|
2909
|
+
const senderLower = authOptions.sender.toLowerCase();
|
|
2910
|
+
if (!valuesToCheck.includes(senderLower)) {
|
|
2911
|
+
valuesToCheck.push(senderLower);
|
|
2912
|
+
}
|
|
2913
|
+
const envelopeDomain = senderLower.split("@")[1];
|
|
2914
|
+
if (envelopeDomain && !valuesToCheck.includes(envelopeDomain)) {
|
|
2915
|
+
valuesToCheck.push(envelopeDomain);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
const replyTo = mail.replyTo?.value || [];
|
|
2919
|
+
for (const addr of replyTo) {
|
|
2920
|
+
if (addr.address) {
|
|
2921
|
+
const addrLower = addr.address.toLowerCase();
|
|
2922
|
+
if (!valuesToCheck.includes(addrLower)) {
|
|
2923
|
+
valuesToCheck.push(addrLower);
|
|
2924
|
+
}
|
|
2925
|
+
const domain = addrLower.split("@")[1];
|
|
2926
|
+
if (domain && !valuesToCheck.includes(domain)) {
|
|
2927
|
+
valuesToCheck.push(domain);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
if (valuesToCheck.length === 0) {
|
|
2932
|
+
return null;
|
|
2933
|
+
}
|
|
2934
|
+
debug7("Checking reputation for %d attributes: %o", valuesToCheck.length, valuesToCheck);
|
|
2935
|
+
const resultsMap = await checkReputationBatch(valuesToCheck, reputationOptions);
|
|
2936
|
+
const aggregated = aggregateReputationResults([...resultsMap.values()]);
|
|
2937
|
+
return {
|
|
2938
|
+
...aggregated,
|
|
2939
|
+
checkedValues: valuesToCheck,
|
|
2940
|
+
details: Object.fromEntries(resultsMap),
|
|
2941
|
+
session
|
|
2942
|
+
// Include session info for debugging
|
|
2943
|
+
};
|
|
2944
|
+
} catch (error) {
|
|
2945
|
+
debug7("Reputation check error:", error);
|
|
2946
|
+
return null;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
// Get pattern recognition results
|
|
2950
|
+
async getPatternResults(mail) {
|
|
2951
|
+
const results = [];
|
|
2952
|
+
const textContent = mail.text || "";
|
|
2953
|
+
const htmlContent = mail.html || "";
|
|
2954
|
+
const allContent = textContent + " " + htmlContent;
|
|
2955
|
+
for (const pattern of DATE_PATTERNS) {
|
|
2956
|
+
const matches = allContent.match(pattern);
|
|
2957
|
+
if (matches && matches.length > 5) {
|
|
2958
|
+
results.push({
|
|
2959
|
+
type: "pattern",
|
|
2960
|
+
subtype: "date_spam",
|
|
2961
|
+
count: matches.length,
|
|
2962
|
+
description: "Excessive date patterns detected"
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
const filePathResults = await this.getFilePathResults(mail);
|
|
2967
|
+
results.push(...filePathResults);
|
|
2968
|
+
return results;
|
|
2969
|
+
}
|
|
2970
|
+
// Enhanced mail parsing with better error handling
|
|
2971
|
+
async getTokensAndMailFromSource(source) {
|
|
2972
|
+
let mail;
|
|
2973
|
+
if (typeof source === "string" && fs.existsSync(source)) {
|
|
2974
|
+
source = fs.readFileSync(source);
|
|
2975
|
+
}
|
|
2976
|
+
if (isBuffer(source)) {
|
|
2977
|
+
source = source.toString();
|
|
2978
|
+
}
|
|
2979
|
+
if (!source || typeof source !== "string") {
|
|
2980
|
+
source = "";
|
|
2981
|
+
}
|
|
2982
|
+
try {
|
|
2983
|
+
mail = await simpleParser(source);
|
|
2984
|
+
} catch (error) {
|
|
2985
|
+
debug7("Mail parsing error:", error);
|
|
2986
|
+
mail = {
|
|
2987
|
+
text: source,
|
|
2988
|
+
html: "",
|
|
2989
|
+
subject: "",
|
|
2990
|
+
from: {},
|
|
2991
|
+
to: [],
|
|
2992
|
+
attachments: []
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
const textContent = await this.preprocessText(mail.text || "");
|
|
2996
|
+
const htmlContent = await this.preprocessText(striptags(mail.html || ""));
|
|
2997
|
+
const subjectContent = await this.preprocessText(mail.subject || "");
|
|
2998
|
+
const allContent = [textContent, htmlContent, subjectContent].join(" ");
|
|
2999
|
+
const tokens = await this.getTokens(allContent, "en");
|
|
3000
|
+
return { tokens, mail };
|
|
3001
|
+
}
|
|
3002
|
+
// Enhanced classification with better error handling
|
|
3003
|
+
async getClassification(tokens) {
|
|
3004
|
+
if (!this.classifier) {
|
|
3005
|
+
await this.initializeClassifier();
|
|
3006
|
+
}
|
|
3007
|
+
try {
|
|
3008
|
+
const text = Array.isArray(tokens) ? tokens.join(" ") : String(tokens);
|
|
3009
|
+
const result = this.classifier.categorize(text);
|
|
3010
|
+
return {
|
|
3011
|
+
category: result,
|
|
3012
|
+
probability: 0.5
|
|
3013
|
+
// Default probability
|
|
3014
|
+
};
|
|
3015
|
+
} catch (error) {
|
|
3016
|
+
debug7("Classification error:", error);
|
|
3017
|
+
return {
|
|
3018
|
+
category: "ham",
|
|
3019
|
+
probability: 0.5
|
|
3020
|
+
};
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
// Enhanced phishing detection
|
|
3024
|
+
async getPhishingResults(mail) {
|
|
3025
|
+
const results = [];
|
|
3026
|
+
const links = this.getUrls(mail.text || "");
|
|
3027
|
+
for (const url of links) {
|
|
3028
|
+
try {
|
|
3029
|
+
const normalizedUrl = await this.optimizeUrlParsing(url);
|
|
3030
|
+
const parsed = new URL(normalizedUrl);
|
|
3031
|
+
const isBlocked = await this.isCloudflareBlocked(parsed.hostname);
|
|
3032
|
+
if (isBlocked) {
|
|
3033
|
+
results.push({
|
|
3034
|
+
type: "phishing",
|
|
3035
|
+
url: normalizedUrl,
|
|
3036
|
+
description: "Blocked by security filters"
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
const idnDetector = await this.getIDNDetector();
|
|
3040
|
+
if (idnDetector && parsed.hostname) {
|
|
3041
|
+
const context = {
|
|
3042
|
+
emailContent: mail.text || mail.html || "",
|
|
3043
|
+
displayText: url === normalizedUrl ? null : url,
|
|
3044
|
+
senderReputation: 0.5
|
|
3045
|
+
// Default neutral reputation
|
|
3046
|
+
};
|
|
3047
|
+
const idnAnalysis = idnDetector.detectHomographAttack(parsed.hostname, context);
|
|
3048
|
+
if (idnAnalysis.riskScore > 0.6) {
|
|
3049
|
+
results.push({
|
|
3050
|
+
type: "phishing",
|
|
3051
|
+
url: normalizedUrl,
|
|
3052
|
+
description: `IDN homograph attack detected (risk: ${(idnAnalysis.riskScore * 100).toFixed(1)}%)`,
|
|
3053
|
+
details: {
|
|
3054
|
+
riskFactors: idnAnalysis.riskFactors,
|
|
3055
|
+
recommendations: idnAnalysis.recommendations,
|
|
3056
|
+
confidence: idnAnalysis.confidence
|
|
3057
|
+
}
|
|
3058
|
+
});
|
|
3059
|
+
} else if (idnAnalysis.riskScore > 0.3) {
|
|
3060
|
+
results.push({
|
|
3061
|
+
type: "suspicious",
|
|
3062
|
+
url: normalizedUrl,
|
|
3063
|
+
description: `Suspicious IDN domain (risk: ${(idnAnalysis.riskScore * 100).toFixed(1)}%)`,
|
|
3064
|
+
details: {
|
|
3065
|
+
riskFactors: idnAnalysis.riskFactors,
|
|
3066
|
+
recommendations: idnAnalysis.recommendations
|
|
3067
|
+
}
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
} catch (error) {
|
|
3072
|
+
debug7("Phishing check error:", error);
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
return results;
|
|
3076
|
+
}
|
|
3077
|
+
// Enhanced executable detection
|
|
3078
|
+
async getExecutableResults(mail) {
|
|
3079
|
+
const results = [];
|
|
3080
|
+
const attachments = mail.attachments || [];
|
|
3081
|
+
for (const attachment of attachments) {
|
|
3082
|
+
if (attachment.filename) {
|
|
3083
|
+
const extension = fileExtension(attachment.filename).toLowerCase();
|
|
3084
|
+
if (EXECUTABLES.has(extension)) {
|
|
3085
|
+
results.push({
|
|
3086
|
+
type: "executable",
|
|
3087
|
+
filename: attachment.filename,
|
|
3088
|
+
extension,
|
|
3089
|
+
description: "Executable file attachment"
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
const archiveExtensions = ["zip", "rar", "7z", "tar", "gz", "bz2", "xz", "arj", "cab", "lzh", "ace", "iso"];
|
|
3093
|
+
if (archiveExtensions.includes(extension)) {
|
|
3094
|
+
results.push({
|
|
3095
|
+
type: "archive",
|
|
3096
|
+
filename: attachment.filename,
|
|
3097
|
+
extension,
|
|
3098
|
+
description: "Archive attachment (contents not scanned)",
|
|
3099
|
+
risk: "medium",
|
|
3100
|
+
warning: "Archive contents should be extracted and scanned separately"
|
|
3101
|
+
});
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
if (attachment.content && isBuffer(attachment.content)) {
|
|
3105
|
+
try {
|
|
3106
|
+
const fileType = await fileTypeFromBuffer(attachment.content);
|
|
3107
|
+
if (fileType && EXECUTABLES.has(fileType.ext)) {
|
|
3108
|
+
results.push({
|
|
3109
|
+
type: "executable",
|
|
3110
|
+
filename: attachment.filename || "unknown",
|
|
3111
|
+
detectedType: fileType.ext,
|
|
3112
|
+
description: "Executable content detected"
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
} catch (error) {
|
|
3116
|
+
debug7("File type detection error:", error);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
return results;
|
|
3121
|
+
}
|
|
3122
|
+
// Arbitrary results (GTUBE, spam patterns, etc.)
|
|
3123
|
+
// Updated to use session info for Microsoft Exchange spam detection
|
|
3124
|
+
async getArbitraryResults(mail, sessionInfo = {}) {
|
|
3125
|
+
const results = [];
|
|
3126
|
+
let content = (mail.text || "") + (mail.html || "");
|
|
3127
|
+
if (mail.headerLines && Array.isArray(mail.headerLines)) {
|
|
3128
|
+
for (const headerLine of mail.headerLines) {
|
|
3129
|
+
if (headerLine.line) {
|
|
3130
|
+
content += " " + headerLine.line;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
if (content.includes("XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X")) {
|
|
3135
|
+
results.push({
|
|
3136
|
+
type: "arbitrary",
|
|
3137
|
+
subtype: "gtube",
|
|
3138
|
+
description: "GTUBE spam test pattern detected",
|
|
3139
|
+
score: 100
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
try {
|
|
3143
|
+
const session = buildSessionInfo(mail, sessionInfo);
|
|
3144
|
+
const arbitraryResult = isArbitrary(mail, {
|
|
3145
|
+
threshold: this.config.arbitraryThreshold || 5,
|
|
3146
|
+
checkSubject: true,
|
|
3147
|
+
checkBody: true,
|
|
3148
|
+
checkSender: true,
|
|
3149
|
+
checkHeaders: true,
|
|
3150
|
+
checkLinks: true,
|
|
3151
|
+
checkMicrosoftHeaders: true,
|
|
3152
|
+
// Enable Microsoft Exchange spam detection
|
|
3153
|
+
checkVendorSpam: true,
|
|
3154
|
+
// Enable vendor-specific spam detection
|
|
3155
|
+
checkSpoofing: true,
|
|
3156
|
+
// Enable spoofing attack detection
|
|
3157
|
+
session
|
|
3158
|
+
// Pass session info for advanced checks
|
|
3159
|
+
});
|
|
3160
|
+
if (arbitraryResult.isArbitrary) {
|
|
3161
|
+
results.push({
|
|
3162
|
+
type: "arbitrary",
|
|
3163
|
+
subtype: arbitraryResult.category ? arbitraryResult.category.toLowerCase() : "pattern",
|
|
3164
|
+
description: `Arbitrary spam patterns detected (score: ${arbitraryResult.score})`,
|
|
3165
|
+
score: arbitraryResult.score,
|
|
3166
|
+
reasons: arbitraryResult.reasons,
|
|
3167
|
+
category: arbitraryResult.category
|
|
3168
|
+
});
|
|
3169
|
+
}
|
|
3170
|
+
} catch (error) {
|
|
3171
|
+
debug7("Arbitrary detection error:", error);
|
|
3172
|
+
}
|
|
3173
|
+
return results;
|
|
3174
|
+
}
|
|
3175
|
+
// Parse and normalize locale
|
|
3176
|
+
parseLocale(locale) {
|
|
3177
|
+
if (!locale || typeof locale !== "string") {
|
|
3178
|
+
return "en";
|
|
3179
|
+
}
|
|
3180
|
+
const normalized = locale.toLowerCase().split("-")[0];
|
|
3181
|
+
const localeMap = {
|
|
3182
|
+
nb: "no",
|
|
3183
|
+
// Norwegian Bokmål
|
|
3184
|
+
nn: "no",
|
|
3185
|
+
// Norwegian Nynorsk
|
|
3186
|
+
"zh-cn": "zh",
|
|
3187
|
+
"zh-tw": "zh"
|
|
3188
|
+
};
|
|
3189
|
+
return localeMap[normalized] || normalized;
|
|
3190
|
+
}
|
|
3191
|
+
// Get IDN homograph attack results
|
|
3192
|
+
async getIDNHomographResults(mail) {
|
|
3193
|
+
const result = {
|
|
3194
|
+
detected: false,
|
|
3195
|
+
domains: [],
|
|
3196
|
+
riskScore: 0,
|
|
3197
|
+
details: []
|
|
3198
|
+
};
|
|
3199
|
+
try {
|
|
3200
|
+
const idnDetector = await this.getIDNDetector();
|
|
3201
|
+
if (!idnDetector) {
|
|
3202
|
+
return result;
|
|
3203
|
+
}
|
|
3204
|
+
const textContent = mail.text || "";
|
|
3205
|
+
const htmlContent = mail.html || "";
|
|
3206
|
+
const allContent = textContent + " " + htmlContent;
|
|
3207
|
+
const urls = this.getUrls(allContent);
|
|
3208
|
+
for (const url of urls) {
|
|
3209
|
+
try {
|
|
3210
|
+
const normalizedUrl = await this.optimizeUrlParsing(url);
|
|
3211
|
+
const parsed = new URL(normalizedUrl);
|
|
3212
|
+
const domain = parsed.hostname;
|
|
3213
|
+
if (!domain) {
|
|
3214
|
+
continue;
|
|
3215
|
+
}
|
|
3216
|
+
const context = {
|
|
3217
|
+
emailContent: allContent,
|
|
3218
|
+
displayText: url === normalizedUrl ? null : url,
|
|
3219
|
+
senderReputation: 0.5,
|
|
3220
|
+
// Default neutral reputation
|
|
3221
|
+
emailHeaders: mail.headers || {}
|
|
3222
|
+
};
|
|
3223
|
+
const analysis = idnDetector.detectHomographAttack(domain, context);
|
|
3224
|
+
if (analysis.riskScore > 0.3) {
|
|
3225
|
+
result.detected = true;
|
|
3226
|
+
result.domains.push({
|
|
3227
|
+
domain,
|
|
3228
|
+
originalUrl: url,
|
|
3229
|
+
normalizedUrl,
|
|
3230
|
+
riskScore: analysis.riskScore,
|
|
3231
|
+
riskFactors: analysis.riskFactors,
|
|
3232
|
+
recommendations: analysis.recommendations,
|
|
3233
|
+
confidence: analysis.confidence
|
|
3234
|
+
});
|
|
3235
|
+
result.riskScore = Math.max(result.riskScore, analysis.riskScore);
|
|
3236
|
+
}
|
|
3237
|
+
} catch (error) {
|
|
3238
|
+
debug7("IDN analysis error for URL:", url, error);
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
if (result.detected) {
|
|
3242
|
+
result.details.push(
|
|
3243
|
+
`Found ${result.domains.length} suspicious domain(s)`,
|
|
3244
|
+
`Highest risk score: ${(result.riskScore * 100).toFixed(1)}%`
|
|
3245
|
+
);
|
|
3246
|
+
const allRiskFactors = /* @__PURE__ */ new Set();
|
|
3247
|
+
for (const domain of result.domains) {
|
|
3248
|
+
for (const factor of domain.riskFactors) {
|
|
3249
|
+
allRiskFactors.add(factor);
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
result.details.push(...allRiskFactors);
|
|
3253
|
+
}
|
|
3254
|
+
} catch (error) {
|
|
3255
|
+
debug7("IDN homograph detection error:", error);
|
|
3256
|
+
}
|
|
3257
|
+
return result;
|
|
3258
|
+
}
|
|
3259
|
+
// Get IDN detector instance
|
|
3260
|
+
async getIDNDetector() {
|
|
3261
|
+
if (!this.idnDetector) {
|
|
3262
|
+
try {
|
|
3263
|
+
const { default: EnhancedIDNDetector2 } = await Promise.resolve().then(() => (init_enhanced_idn_detector(), enhanced_idn_detector_exports));
|
|
3264
|
+
this.idnDetector = new EnhancedIDNDetector2({
|
|
3265
|
+
strictMode: this.config.strictIDNDetection || false,
|
|
3266
|
+
enableWhitelist: true,
|
|
3267
|
+
enableBrandProtection: true,
|
|
3268
|
+
enableContextAnalysis: true
|
|
3269
|
+
});
|
|
3270
|
+
} catch (error) {
|
|
3271
|
+
debug7("Failed to load IDN detector:", error);
|
|
3272
|
+
return null;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
return this.idnDetector;
|
|
3276
|
+
}
|
|
3277
|
+
// Hybrid language detection using both lande and franc
|
|
3278
|
+
async detectLanguageHybrid(text) {
|
|
3279
|
+
if (!text || typeof text !== "string" || text.length < 3) {
|
|
3280
|
+
return "en";
|
|
3281
|
+
}
|
|
3282
|
+
const cleanText = text.trim();
|
|
3283
|
+
if (!cleanText || /^[\d\s\W]+$/.test(cleanText)) {
|
|
3284
|
+
return "en";
|
|
3285
|
+
}
|
|
3286
|
+
try {
|
|
3287
|
+
if (text.length < 50) {
|
|
3288
|
+
const landeResult = lande(text);
|
|
3289
|
+
if (landeResult && landeResult.length > 0) {
|
|
3290
|
+
const detected2 = landeResult[0][0];
|
|
3291
|
+
const normalized = this.normalizeLanguageCode(detected2);
|
|
3292
|
+
if (this.isValidShortTextDetection(text, normalized)) {
|
|
3293
|
+
if (this.config.supportedLanguages && this.config.supportedLanguages.length > 0) {
|
|
3294
|
+
if (this.config.supportedLanguages.includes(normalized)) {
|
|
3295
|
+
return normalized;
|
|
3296
|
+
}
|
|
3297
|
+
return this.config.supportedLanguages[0];
|
|
3298
|
+
}
|
|
3299
|
+
return normalized;
|
|
3300
|
+
}
|
|
3301
|
+
return "en";
|
|
3302
|
+
}
|
|
3303
|
+
return "en";
|
|
3304
|
+
}
|
|
3305
|
+
const { franc } = await import("franc");
|
|
3306
|
+
const francResult = franc(text);
|
|
3307
|
+
if (francResult === "und") {
|
|
3308
|
+
const landeResult = lande(text);
|
|
3309
|
+
if (landeResult && landeResult.length > 0) {
|
|
3310
|
+
return this.normalizeLanguageCode(landeResult[0][0]);
|
|
3311
|
+
}
|
|
3312
|
+
return "en";
|
|
3313
|
+
}
|
|
3314
|
+
const detected = this.normalizeLanguageCode(francResult);
|
|
3315
|
+
if (this.config.supportedLanguages && this.config.supportedLanguages.length > 0) {
|
|
3316
|
+
if (this.config.supportedLanguages.includes(detected)) {
|
|
3317
|
+
return detected;
|
|
3318
|
+
}
|
|
3319
|
+
return this.config.supportedLanguages[0];
|
|
3320
|
+
}
|
|
3321
|
+
return detected;
|
|
3322
|
+
} catch (error) {
|
|
3323
|
+
debug7("Language detection error:", error);
|
|
3324
|
+
try {
|
|
3325
|
+
const landeResult = lande(text);
|
|
3326
|
+
if (landeResult && landeResult.length > 0) {
|
|
3327
|
+
return this.normalizeLanguageCode(landeResult[0][0]);
|
|
3328
|
+
}
|
|
3329
|
+
return "en";
|
|
3330
|
+
} catch {
|
|
3331
|
+
return "en";
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
// Validate short text language detection
|
|
3336
|
+
isValidShortTextDetection(text, detectedLang) {
|
|
3337
|
+
const hasNonLatin = /[^\u0000-\u024F\u1E00-\u1EFF]/.test(text);
|
|
3338
|
+
if (hasNonLatin) {
|
|
3339
|
+
return true;
|
|
3340
|
+
}
|
|
3341
|
+
if (text.length < 7 && detectedLang !== "en") {
|
|
3342
|
+
return false;
|
|
3343
|
+
}
|
|
3344
|
+
return true;
|
|
3345
|
+
}
|
|
3346
|
+
// Get toxicity detection results
|
|
3347
|
+
async getToxicityResults(mail) {
|
|
3348
|
+
const results = [];
|
|
3349
|
+
try {
|
|
3350
|
+
if (!this.toxicityModel) {
|
|
3351
|
+
const _tf = await import("@tensorflow/tfjs-node");
|
|
3352
|
+
const toxicity = await import("@tensorflow-models/toxicity");
|
|
3353
|
+
const threshold = this.config.toxicityThreshold || 0.7;
|
|
3354
|
+
this.toxicityModel = await toxicity.load(threshold);
|
|
3355
|
+
}
|
|
3356
|
+
const textContent = mail.text || "";
|
|
3357
|
+
const htmlContent = striptags(mail.html || "");
|
|
3358
|
+
const subjectContent = mail.subject || "";
|
|
3359
|
+
const allContent = [subjectContent, textContent, htmlContent].filter((text) => text && text.trim().length > 0).join(" ").slice(0, 5e3);
|
|
3360
|
+
if (!allContent || allContent.trim().length < 10) {
|
|
3361
|
+
return results;
|
|
3362
|
+
}
|
|
3363
|
+
const predictions = await Promise.race([
|
|
3364
|
+
this.toxicityModel.classify([allContent]),
|
|
3365
|
+
new Promise((_resolve, reject) => {
|
|
3366
|
+
setTimeout(() => reject(new Error("Toxicity detection timeout")), this.config.timeout);
|
|
3367
|
+
})
|
|
3368
|
+
]);
|
|
3369
|
+
for (const prediction of predictions) {
|
|
3370
|
+
const { label } = prediction;
|
|
3371
|
+
const matches = prediction.results[0].match;
|
|
3372
|
+
if (matches) {
|
|
3373
|
+
const { probabilities } = prediction.results[0];
|
|
3374
|
+
const toxicProbability = probabilities[1];
|
|
3375
|
+
results.push({
|
|
3376
|
+
type: "toxicity",
|
|
3377
|
+
category: label,
|
|
3378
|
+
probability: toxicProbability,
|
|
3379
|
+
description: `Toxic content detected: ${label} (${(toxicProbability * 100).toFixed(1)}%)`
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
} catch (error) {
|
|
3384
|
+
debug7("Toxicity detection error:", error);
|
|
3385
|
+
}
|
|
3386
|
+
return results;
|
|
3387
|
+
}
|
|
3388
|
+
// Get NSFW image detection results
|
|
3389
|
+
async getNSFWResults(mail) {
|
|
3390
|
+
const results = [];
|
|
3391
|
+
try {
|
|
3392
|
+
if (!this.nsfwModel) {
|
|
3393
|
+
const _tf = await import("@tensorflow/tfjs-node");
|
|
3394
|
+
const nsfw = await import("nsfwjs");
|
|
3395
|
+
this.nsfwModel = await nsfw.load();
|
|
3396
|
+
}
|
|
3397
|
+
const attachments = mail.attachments || [];
|
|
3398
|
+
for (const attachment of attachments) {
|
|
3399
|
+
try {
|
|
3400
|
+
if (!attachment.content || !isBuffer(attachment.content)) {
|
|
3401
|
+
continue;
|
|
3402
|
+
}
|
|
3403
|
+
const fileType = await fileTypeFromBuffer(attachment.content);
|
|
3404
|
+
if (!fileType || !fileType.mime.startsWith("image/")) {
|
|
3405
|
+
continue;
|
|
3406
|
+
}
|
|
3407
|
+
const sharp = (await import("sharp")).default;
|
|
3408
|
+
const processedImage = await sharp(attachment.content).resize(224, 224).raw().toBuffer();
|
|
3409
|
+
const _tf = await import("@tensorflow/tfjs-node");
|
|
3410
|
+
const imageTensor = _tf.tensor3d(
|
|
3411
|
+
new Uint8Array(processedImage),
|
|
3412
|
+
[224, 224, 3]
|
|
3413
|
+
);
|
|
3414
|
+
const predictions = await Promise.race([
|
|
3415
|
+
this.nsfwModel.classify(imageTensor),
|
|
3416
|
+
new Promise((_resolve, reject) => {
|
|
3417
|
+
setTimeout(() => reject(new Error("NSFW detection timeout")), this.config.timeout);
|
|
3418
|
+
})
|
|
3419
|
+
]);
|
|
3420
|
+
imageTensor.dispose();
|
|
3421
|
+
const nsfwThreshold = this.config.nsfwThreshold || 0.6;
|
|
3422
|
+
for (const prediction of predictions) {
|
|
3423
|
+
if ((prediction.className === "Porn" || prediction.className === "Hentai" || prediction.className === "Sexy") && prediction.probability > nsfwThreshold) {
|
|
3424
|
+
results.push({
|
|
3425
|
+
type: "nsfw",
|
|
3426
|
+
filename: attachment.filename || "unknown",
|
|
3427
|
+
category: prediction.className,
|
|
3428
|
+
probability: prediction.probability,
|
|
3429
|
+
description: `NSFW image detected: ${prediction.className} (${(prediction.probability * 100).toFixed(1)}%)`
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
} catch (error) {
|
|
3434
|
+
debug7("NSFW detection error for attachment:", attachment.filename, error);
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
} catch (error) {
|
|
3438
|
+
debug7("NSFW detection error:", error);
|
|
3439
|
+
}
|
|
3440
|
+
return results;
|
|
3441
|
+
}
|
|
3442
|
+
// Normalize language codes from 3-letter to 2-letter format
|
|
3443
|
+
normalizeLanguageCode(code) {
|
|
3444
|
+
if (!code || typeof code !== "string") {
|
|
3445
|
+
return "en";
|
|
3446
|
+
}
|
|
3447
|
+
if (code.length === 2) {
|
|
3448
|
+
return code.toLowerCase();
|
|
3449
|
+
}
|
|
3450
|
+
const codeMap = {
|
|
3451
|
+
// Common language mappings
|
|
3452
|
+
eng: "en",
|
|
3453
|
+
// English
|
|
3454
|
+
fra: "fr",
|
|
3455
|
+
// French
|
|
3456
|
+
fre: "fr",
|
|
3457
|
+
// French (alternative)
|
|
3458
|
+
spa: "es",
|
|
3459
|
+
// Spanish
|
|
3460
|
+
deu: "de",
|
|
3461
|
+
// German
|
|
3462
|
+
ger: "de",
|
|
3463
|
+
// German (alternative)
|
|
3464
|
+
ita: "it",
|
|
3465
|
+
// Italian
|
|
3466
|
+
por: "pt",
|
|
3467
|
+
// Portuguese
|
|
3468
|
+
rus: "ru",
|
|
3469
|
+
// Russian
|
|
3470
|
+
jpn: "ja",
|
|
3471
|
+
// Japanese
|
|
3472
|
+
kor: "ko",
|
|
3473
|
+
// Korean
|
|
3474
|
+
cmn: "zh",
|
|
3475
|
+
// Chinese (Mandarin)
|
|
3476
|
+
zho: "zh",
|
|
3477
|
+
// Chinese
|
|
3478
|
+
chi: "zh",
|
|
3479
|
+
// Chinese (alternative)
|
|
3480
|
+
ara: "ar",
|
|
3481
|
+
// Arabic
|
|
3482
|
+
hin: "hi",
|
|
3483
|
+
// Hindi
|
|
3484
|
+
ben: "bn",
|
|
3485
|
+
// Bengali
|
|
3486
|
+
urd: "ur",
|
|
3487
|
+
// Urdu
|
|
3488
|
+
tur: "tr",
|
|
3489
|
+
// Turkish
|
|
3490
|
+
pol: "pl",
|
|
3491
|
+
// Polish
|
|
3492
|
+
nld: "nl",
|
|
3493
|
+
// Dutch
|
|
3494
|
+
dut: "nl",
|
|
3495
|
+
// Dutch (alternative)
|
|
3496
|
+
swe: "sv",
|
|
3497
|
+
// Swedish
|
|
3498
|
+
nor: "no",
|
|
3499
|
+
// Norwegian
|
|
3500
|
+
dan: "da",
|
|
3501
|
+
// Danish
|
|
3502
|
+
fin: "fi",
|
|
3503
|
+
// Finnish
|
|
3504
|
+
hun: "hu",
|
|
3505
|
+
// Hungarian
|
|
3506
|
+
ces: "cs",
|
|
3507
|
+
// Czech
|
|
3508
|
+
cze: "cs",
|
|
3509
|
+
// Czech (alternative)
|
|
3510
|
+
slk: "sk",
|
|
3511
|
+
// Slovak
|
|
3512
|
+
slo: "sk",
|
|
3513
|
+
// Slovak (alternative)
|
|
3514
|
+
slv: "sl",
|
|
3515
|
+
// Slovenian
|
|
3516
|
+
hrv: "hr",
|
|
3517
|
+
// Croatian
|
|
3518
|
+
srp: "sr",
|
|
3519
|
+
// Serbian
|
|
3520
|
+
bul: "bg",
|
|
3521
|
+
// Bulgarian
|
|
3522
|
+
ron: "ro",
|
|
3523
|
+
// Romanian
|
|
3524
|
+
rum: "ro",
|
|
3525
|
+
// Romanian (alternative)
|
|
3526
|
+
ell: "el",
|
|
3527
|
+
// Greek
|
|
3528
|
+
gre: "el",
|
|
3529
|
+
// Greek (alternative)
|
|
3530
|
+
heb: "he",
|
|
3531
|
+
// Hebrew
|
|
3532
|
+
tha: "th",
|
|
3533
|
+
// Thai
|
|
3534
|
+
vie: "vi",
|
|
3535
|
+
// Vietnamese
|
|
3536
|
+
ind: "id",
|
|
3537
|
+
// Indonesian
|
|
3538
|
+
msa: "ms",
|
|
3539
|
+
// Malay
|
|
3540
|
+
may: "ms",
|
|
3541
|
+
// Malay (alternative)
|
|
3542
|
+
tgl: "tl",
|
|
3543
|
+
// Tagalog
|
|
3544
|
+
ukr: "uk",
|
|
3545
|
+
// Ukrainian
|
|
3546
|
+
bel: "be",
|
|
3547
|
+
// Belarusian
|
|
3548
|
+
lit: "lt",
|
|
3549
|
+
// Lithuanian
|
|
3550
|
+
lav: "lv",
|
|
3551
|
+
// Latvian
|
|
3552
|
+
est: "et",
|
|
3553
|
+
// Estonian
|
|
3554
|
+
cat: "ca",
|
|
3555
|
+
// Catalan
|
|
3556
|
+
eus: "eu",
|
|
3557
|
+
// Basque
|
|
3558
|
+
baq: "eu",
|
|
3559
|
+
// Basque (alternative)
|
|
3560
|
+
glg: "gl",
|
|
3561
|
+
// Galician
|
|
3562
|
+
gle: "ga",
|
|
3563
|
+
// Irish
|
|
3564
|
+
gla: "gd",
|
|
3565
|
+
// Scottish Gaelic
|
|
3566
|
+
cym: "cy",
|
|
3567
|
+
// Welsh
|
|
3568
|
+
wel: "cy",
|
|
3569
|
+
// Welsh (alternative)
|
|
3570
|
+
isl: "is",
|
|
3571
|
+
// Icelandic
|
|
3572
|
+
ice: "is",
|
|
3573
|
+
// Icelandic (alternative)
|
|
3574
|
+
mlt: "mt",
|
|
3575
|
+
// Maltese
|
|
3576
|
+
afr: "af",
|
|
3577
|
+
// Afrikaans
|
|
3578
|
+
swa: "sw",
|
|
3579
|
+
// Swahili
|
|
3580
|
+
amh: "am",
|
|
3581
|
+
// Amharic
|
|
3582
|
+
hau: "ha",
|
|
3583
|
+
// Hausa
|
|
3584
|
+
yor: "yo",
|
|
3585
|
+
// Yoruba
|
|
3586
|
+
ibo: "ig",
|
|
3587
|
+
// Igbo
|
|
3588
|
+
som: "so",
|
|
3589
|
+
// Somali
|
|
3590
|
+
orm: "om",
|
|
3591
|
+
// Oromo
|
|
3592
|
+
tig: "ti",
|
|
3593
|
+
// Tigrinya
|
|
3594
|
+
mlg: "mg",
|
|
3595
|
+
// Malagasy
|
|
3596
|
+
nya: "ny",
|
|
3597
|
+
// Chichewa
|
|
3598
|
+
sna: "sn",
|
|
3599
|
+
// Shona
|
|
3600
|
+
xho: "xh",
|
|
3601
|
+
// Xhosa
|
|
3602
|
+
zul: "zu",
|
|
3603
|
+
// Zulu
|
|
3604
|
+
nso: "nso",
|
|
3605
|
+
// Northern Sotho
|
|
3606
|
+
sot: "st",
|
|
3607
|
+
// Southern Sotho
|
|
3608
|
+
tsn: "tn",
|
|
3609
|
+
// Tswana
|
|
3610
|
+
ven: "ve",
|
|
3611
|
+
// Venda
|
|
3612
|
+
tso: "ts",
|
|
3613
|
+
// Tsonga
|
|
3614
|
+
ssw: "ss",
|
|
3615
|
+
// Swati
|
|
3616
|
+
nde: "nr",
|
|
3617
|
+
// Southern Ndebele
|
|
3618
|
+
nbl: "nd"
|
|
3619
|
+
// Northern Ndebele
|
|
3620
|
+
};
|
|
3621
|
+
const normalized = code.toLowerCase();
|
|
3622
|
+
return codeMap[normalized] || "en";
|
|
3623
|
+
}
|
|
3624
|
+
};
|
|
3625
|
+
var index_default = SpamScanner;
|
|
3626
|
+
|
|
3627
|
+
// src/cli.js
|
|
3628
|
+
var __filename2 = fileURLToPath2(import.meta.url);
|
|
3629
|
+
var __dirname2 = path2.dirname(__filename2);
|
|
3630
|
+
var SUPPORTED_LANGUAGES = {
|
|
3631
|
+
en: "English",
|
|
3632
|
+
fr: "French",
|
|
3633
|
+
es: "Spanish",
|
|
3634
|
+
de: "German",
|
|
3635
|
+
it: "Italian",
|
|
3636
|
+
pt: "Portuguese",
|
|
3637
|
+
ru: "Russian",
|
|
3638
|
+
ja: "Japanese",
|
|
3639
|
+
ko: "Korean",
|
|
3640
|
+
zh: "Chinese",
|
|
3641
|
+
ar: "Arabic",
|
|
3642
|
+
hi: "Hindi",
|
|
3643
|
+
bn: "Bengali",
|
|
3644
|
+
ur: "Urdu",
|
|
3645
|
+
tr: "Turkish",
|
|
3646
|
+
pl: "Polish",
|
|
3647
|
+
nl: "Dutch",
|
|
3648
|
+
sv: "Swedish",
|
|
3649
|
+
no: "Norwegian",
|
|
3650
|
+
da: "Danish",
|
|
3651
|
+
fi: "Finnish",
|
|
3652
|
+
hu: "Hungarian",
|
|
3653
|
+
cs: "Czech",
|
|
3654
|
+
sk: "Slovak",
|
|
3655
|
+
sl: "Slovenian",
|
|
3656
|
+
hr: "Croatian",
|
|
3657
|
+
sr: "Serbian",
|
|
3658
|
+
bg: "Bulgarian",
|
|
3659
|
+
ro: "Romanian",
|
|
3660
|
+
el: "Greek",
|
|
3661
|
+
he: "Hebrew",
|
|
3662
|
+
th: "Thai",
|
|
3663
|
+
vi: "Vietnamese",
|
|
3664
|
+
id: "Indonesian",
|
|
3665
|
+
ms: "Malay",
|
|
3666
|
+
tl: "Tagalog",
|
|
3667
|
+
uk: "Ukrainian",
|
|
3668
|
+
be: "Belarusian",
|
|
3669
|
+
lt: "Lithuanian",
|
|
3670
|
+
lv: "Latvian",
|
|
3671
|
+
et: "Estonian",
|
|
3672
|
+
ca: "Catalan",
|
|
3673
|
+
eu: "Basque",
|
|
3674
|
+
gl: "Galician",
|
|
3675
|
+
ga: "Irish",
|
|
3676
|
+
gd: "Scottish Gaelic",
|
|
3677
|
+
cy: "Welsh",
|
|
3678
|
+
is: "Icelandic",
|
|
3679
|
+
mt: "Maltese",
|
|
3680
|
+
af: "Afrikaans",
|
|
3681
|
+
sw: "Swahili",
|
|
3682
|
+
am: "Amharic",
|
|
3683
|
+
ha: "Hausa",
|
|
3684
|
+
yo: "Yoruba",
|
|
3685
|
+
ig: "Igbo",
|
|
3686
|
+
so: "Somali",
|
|
3687
|
+
om: "Oromo",
|
|
3688
|
+
ti: "Tigrinya",
|
|
3689
|
+
mg: "Malagasy",
|
|
3690
|
+
ny: "Chichewa",
|
|
3691
|
+
sn: "Shona",
|
|
3692
|
+
xh: "Xhosa",
|
|
3693
|
+
zu: "Zulu",
|
|
3694
|
+
st: "Southern Sotho",
|
|
3695
|
+
tn: "Tswana"
|
|
3696
|
+
};
|
|
3697
|
+
var DEFAULT_SCORES = {
|
|
3698
|
+
classifier: 5,
|
|
3699
|
+
// Base score when classifier says spam
|
|
3700
|
+
phishing: 5,
|
|
3701
|
+
// Per phishing issue detected
|
|
3702
|
+
executable: 10,
|
|
3703
|
+
// Per dangerous executable detected
|
|
3704
|
+
macro: 5,
|
|
3705
|
+
// Per macro detected
|
|
3706
|
+
virus: 100,
|
|
3707
|
+
// Per virus detected
|
|
3708
|
+
nsfw: 3,
|
|
3709
|
+
// Per NSFW content detected
|
|
3710
|
+
toxicity: 3
|
|
3711
|
+
// Per toxic content detected
|
|
3712
|
+
};
|
|
3713
|
+
var UPDATE_CACHE_DIR = path2.join(homedir(), ".spamscanner");
|
|
3714
|
+
var UPDATE_CACHE_FILE = path2.join(UPDATE_CACHE_DIR, "update-check.json");
|
|
3715
|
+
var UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1e3;
|
|
3716
|
+
function getVersion() {
|
|
3717
|
+
const possiblePaths = [
|
|
3718
|
+
path2.join(__dirname2, "..", "package.json"),
|
|
3719
|
+
path2.join(__dirname2, "..", "..", "package.json"),
|
|
3720
|
+
path2.join(__dirname2, "..", "..", "..", "package.json")
|
|
3721
|
+
];
|
|
3722
|
+
for (const pkgPath of possiblePaths) {
|
|
3723
|
+
try {
|
|
3724
|
+
const content = readFileSync3(pkgPath, "utf8");
|
|
3725
|
+
const pkg = JSON.parse(content);
|
|
3726
|
+
if (pkg.name === "spamscanner" && pkg.version) {
|
|
3727
|
+
return pkg.version;
|
|
3728
|
+
}
|
|
3729
|
+
} catch {
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
return "unknown";
|
|
3733
|
+
}
|
|
3734
|
+
var VERSION = getVersion();
|
|
3735
|
+
function compareVersions(v1, v2) {
|
|
3736
|
+
const parts1 = v1.replace(/^v/, "").split(".").map(Number);
|
|
3737
|
+
const parts2 = v2.replace(/^v/, "").split(".").map(Number);
|
|
3738
|
+
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
|
3739
|
+
const p1 = parts1[i] || 0;
|
|
3740
|
+
const p2 = parts2[i] || 0;
|
|
3741
|
+
if (p1 < p2) {
|
|
3742
|
+
return -1;
|
|
3743
|
+
}
|
|
3744
|
+
if (p1 > p2) {
|
|
3745
|
+
return 1;
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
return 0;
|
|
3749
|
+
}
|
|
3750
|
+
function getBinaryName() {
|
|
3751
|
+
const { platform } = process2;
|
|
3752
|
+
const { arch } = process2;
|
|
3753
|
+
if (platform === "win32") {
|
|
3754
|
+
return "spamscanner-win-x64.exe";
|
|
3755
|
+
}
|
|
3756
|
+
if (platform === "darwin") {
|
|
3757
|
+
return arch === "arm64" ? "spamscanner-darwin-arm64" : "spamscanner-darwin-x64";
|
|
3758
|
+
}
|
|
3759
|
+
return "spamscanner-linux-x64";
|
|
3760
|
+
}
|
|
3761
|
+
async function checkForUpdates(force = false) {
|
|
3762
|
+
try {
|
|
3763
|
+
if (!force && existsSync(UPDATE_CACHE_FILE)) {
|
|
3764
|
+
const cache2 = JSON.parse(readFileSync3(UPDATE_CACHE_FILE, "utf8"));
|
|
3765
|
+
const age = Date.now() - cache2.timestamp;
|
|
3766
|
+
if (age < UPDATE_CHECK_INTERVAL) {
|
|
3767
|
+
if (cache2.latestVersion && compareVersions(cache2.latestVersion, VERSION) > 0) {
|
|
3768
|
+
return {
|
|
3769
|
+
currentVersion: VERSION,
|
|
3770
|
+
latestVersion: cache2.latestVersion,
|
|
3771
|
+
releaseUrl: cache2.releaseUrl,
|
|
3772
|
+
downloadUrl: cache2.downloadUrl,
|
|
3773
|
+
cached: true
|
|
3774
|
+
};
|
|
3775
|
+
}
|
|
3776
|
+
return null;
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
const response = await fetch("https://api.github.com/repos/spamscanner/spamscanner/releases/latest", {
|
|
3780
|
+
headers: {
|
|
3781
|
+
Accept: "application/vnd.github.v3+json",
|
|
3782
|
+
"User-Agent": `spamscanner-cli/${VERSION}`
|
|
3783
|
+
}
|
|
3784
|
+
});
|
|
3785
|
+
if (!response.ok) {
|
|
3786
|
+
return null;
|
|
3787
|
+
}
|
|
3788
|
+
const release = await response.json();
|
|
3789
|
+
const latestVersion = release.tag_name.replace(/^v/, "");
|
|
3790
|
+
const binaryName = getBinaryName();
|
|
3791
|
+
const asset = release.assets.find((a) => a.name === binaryName);
|
|
3792
|
+
const downloadUrl = asset?.browser_download_url;
|
|
3793
|
+
const cacheData = {
|
|
3794
|
+
timestamp: Date.now(),
|
|
3795
|
+
latestVersion,
|
|
3796
|
+
releaseUrl: release.html_url,
|
|
3797
|
+
downloadUrl
|
|
3798
|
+
};
|
|
3799
|
+
try {
|
|
3800
|
+
if (!existsSync(UPDATE_CACHE_DIR)) {
|
|
3801
|
+
mkdirSync(UPDATE_CACHE_DIR, { recursive: true });
|
|
3802
|
+
}
|
|
3803
|
+
writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(cacheData, null, 2));
|
|
3804
|
+
} catch {
|
|
3805
|
+
}
|
|
3806
|
+
if (compareVersions(latestVersion, VERSION) > 0) {
|
|
3807
|
+
return {
|
|
3808
|
+
currentVersion: VERSION,
|
|
3809
|
+
latestVersion,
|
|
3810
|
+
releaseUrl: release.html_url,
|
|
3811
|
+
downloadUrl,
|
|
3812
|
+
cached: false
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
return null;
|
|
3816
|
+
} catch {
|
|
3817
|
+
return null;
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
async function printUpdateNotification(force = false) {
|
|
3821
|
+
const update = await checkForUpdates(force);
|
|
3822
|
+
if (update) {
|
|
3823
|
+
const { platform } = process2;
|
|
3824
|
+
console.error("");
|
|
3825
|
+
console.error("\u256D\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E");
|
|
3826
|
+
console.error(`\u2502 Update available: ${update.currentVersion} \u2192 ${update.latestVersion.padEnd(37)}\u2502`);
|
|
3827
|
+
console.error("\u2502 \u2502");
|
|
3828
|
+
if (update.downloadUrl) {
|
|
3829
|
+
console.error("\u2502 To update, run one of: \u2502");
|
|
3830
|
+
if (platform === "darwin") {
|
|
3831
|
+
console.error("\u2502 curl -fsSL https://spamscanner.net/install.sh | sh \u2502");
|
|
3832
|
+
} else if (platform === "win32") {
|
|
3833
|
+
console.error("\u2502 irm https://spamscanner.net/install.ps1 | iex \u2502");
|
|
3834
|
+
} else {
|
|
3835
|
+
console.error("\u2502 curl -fsSL https://spamscanner.net/install.sh | sh \u2502");
|
|
3836
|
+
}
|
|
3837
|
+
console.error("\u2502 \u2502");
|
|
3838
|
+
console.error("\u2502 Or download manually from: \u2502");
|
|
3839
|
+
} else {
|
|
3840
|
+
console.error("\u2502 Download from: \u2502");
|
|
3841
|
+
}
|
|
3842
|
+
console.error("\u2502 https://github.com/spamscanner/spamscanner/releases \u2502");
|
|
3843
|
+
console.error("\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F");
|
|
3844
|
+
console.error("");
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
function formatLanguageList() {
|
|
3848
|
+
const entries = Object.entries(SUPPORTED_LANGUAGES);
|
|
3849
|
+
const lines = [];
|
|
3850
|
+
for (let i = 0; i < entries.length; i += 4) {
|
|
3851
|
+
const chunk = entries.slice(i, i + 4);
|
|
3852
|
+
const formatted = chunk.map(([code, name]) => `${code} (${name})`).join(", ");
|
|
3853
|
+
lines.push(` ${formatted}`);
|
|
3854
|
+
}
|
|
3855
|
+
return lines.join("\n");
|
|
3856
|
+
}
|
|
3857
|
+
var HELP_TEXT = `
|
|
3858
|
+
SpamScanner CLI v${VERSION}
|
|
3859
|
+
|
|
3860
|
+
Usage:
|
|
3861
|
+
spamscanner <command> [options]
|
|
3862
|
+
|
|
3863
|
+
Commands:
|
|
3864
|
+
scan <file> Scan an email file for spam
|
|
3865
|
+
scan - Scan email from stdin
|
|
3866
|
+
server Start TCP server mode
|
|
3867
|
+
update Check for updates
|
|
3868
|
+
help Show this help message
|
|
3869
|
+
version Show version number
|
|
3870
|
+
|
|
3871
|
+
General Options:
|
|
3872
|
+
-h, --help Show help
|
|
3873
|
+
-v, --version Show version
|
|
3874
|
+
-j, --json Output results as JSON
|
|
3875
|
+
--verbose Show detailed output
|
|
3876
|
+
--debug Enable debug mode
|
|
3877
|
+
--timeout <ms> Scan timeout in milliseconds (default: 30000)
|
|
3878
|
+
--no-update-check Disable automatic update check
|
|
3879
|
+
|
|
3880
|
+
Spam Detection Options:
|
|
3881
|
+
--threshold <score> Spam score threshold (default: 5.0)
|
|
3882
|
+
--check-classifier Include Bayesian classifier in scoring (default: true)
|
|
3883
|
+
--check-phishing Include phishing detection in scoring (default: true)
|
|
3884
|
+
--check-executables Include executable detection in scoring (default: true)
|
|
3885
|
+
--check-macros Include macro detection in scoring (default: true)
|
|
3886
|
+
--check-virus Include virus detection in scoring (default: true)
|
|
3887
|
+
--check-nsfw Include NSFW detection in scoring (default: false)
|
|
3888
|
+
--check-toxicity Include toxicity detection in scoring (default: false)
|
|
3889
|
+
--no-classifier Disable Bayesian classifier scoring
|
|
3890
|
+
--no-phishing Disable phishing scoring
|
|
3891
|
+
--no-executables Disable executable scoring
|
|
3892
|
+
--no-macros Disable macro scoring
|
|
3893
|
+
--no-virus Disable virus scoring
|
|
3894
|
+
|
|
3895
|
+
Score Weights (customize scoring):
|
|
3896
|
+
--score-classifier <n> Classifier spam score weight (default: 5.0)
|
|
3897
|
+
--score-phishing <n> Phishing score per issue (default: 5.0)
|
|
3898
|
+
--score-executable <n> Executable score per file (default: 10.0)
|
|
3899
|
+
--score-macro <n> Macro score per detection (default: 5.0)
|
|
3900
|
+
--score-virus <n> Virus score per detection (default: 100.0)
|
|
3901
|
+
--score-nsfw <n> NSFW score per detection (default: 3.0)
|
|
3902
|
+
--score-toxicity <n> Toxicity score per detection (default: 3.0)
|
|
3903
|
+
|
|
3904
|
+
Scanner Configuration Options:
|
|
3905
|
+
--languages <list> Comma-separated list of supported language codes (default: all)
|
|
3906
|
+
Use empty string or 'all' for all languages
|
|
3907
|
+
--mixed-language Enable mixed language detection in emails
|
|
3908
|
+
--no-macro-detection Disable macro detection in attachments
|
|
3909
|
+
--no-pattern-recognition Disable advanced pattern recognition
|
|
3910
|
+
--strict-idn Enable strict IDN/homograph detection
|
|
3911
|
+
--nsfw-threshold <n> NSFW detection threshold 0.0-1.0 (default: 0.6)
|
|
3912
|
+
--toxicity-threshold <n> Toxicity detection threshold 0.0-1.0 (default: 0.7)
|
|
3913
|
+
--clamscan-path <path> Path to clamscan binary (default: /usr/bin/clamscan)
|
|
3914
|
+
--clamdscan-path <path> Path to clamdscan binary (default: /usr/bin/clamdscan)
|
|
3915
|
+
|
|
3916
|
+
Authentication Options (mailauth):
|
|
3917
|
+
--enable-auth Enable DKIM/SPF/ARC/DMARC/BIMI authentication
|
|
3918
|
+
--sender-ip <ip> Remote IP address of the sender (required for auth)
|
|
3919
|
+
--sender-hostname <host> Resolved hostname of the sender (from reverse DNS)
|
|
3920
|
+
--helo <hostname> HELO/EHLO hostname
|
|
3921
|
+
--sender <email> Envelope sender (MAIL FROM)
|
|
3922
|
+
--mta <hostname> MTA hostname for auth headers (default: spamscanner)
|
|
3923
|
+
--auth-timeout <ms> DNS lookup timeout for auth (default: 10000)
|
|
3924
|
+
|
|
3925
|
+
Reputation Options (Forward Email API):
|
|
3926
|
+
--enable-reputation Enable Forward Email reputation checking
|
|
3927
|
+
--reputation-url <url> Custom reputation API URL
|
|
3928
|
+
--reputation-timeout <ms> Reputation API timeout (default: 10000)
|
|
3929
|
+
--only-aligned Only check aligned/authenticated attributes for reputation (default: true)
|
|
3930
|
+
--no-only-aligned Check all attributes regardless of alignment
|
|
3931
|
+
|
|
3932
|
+
Header Options:
|
|
3933
|
+
--add-headers Add X-Spam-* headers to output (for mail server integration)
|
|
3934
|
+
--add-auth-headers Add Authentication-Results header to output
|
|
3935
|
+
--prepend-subject Prepend [SPAM] to subject if spam detected
|
|
3936
|
+
--subject-tag <tag> Custom subject tag (default: [SPAM])
|
|
3937
|
+
|
|
3938
|
+
Server Options:
|
|
3939
|
+
--port <port> TCP server port (default: 7830)
|
|
3940
|
+
--host <host> TCP server host (default: 127.0.0.1)
|
|
3941
|
+
|
|
3942
|
+
Supported Languages (use ISO 639-1 codes with --languages):
|
|
3943
|
+
${formatLanguageList()}
|
|
3944
|
+
|
|
3945
|
+
Examples:
|
|
3946
|
+
# Scan a file
|
|
3947
|
+
spamscanner scan email.eml
|
|
3948
|
+
|
|
3949
|
+
# Scan from stdin (for Postfix integration)
|
|
3950
|
+
cat email.eml | spamscanner scan -
|
|
3951
|
+
|
|
3952
|
+
# Scan with JSON output
|
|
3953
|
+
spamscanner scan email.eml --json
|
|
3954
|
+
|
|
3955
|
+
# Scan with custom threshold
|
|
3956
|
+
spamscanner scan email.eml --threshold 3.0
|
|
3957
|
+
|
|
3958
|
+
# Scan with only classifier and phishing checks
|
|
3959
|
+
spamscanner scan email.eml --no-executables --no-macros --no-virus
|
|
3960
|
+
|
|
3961
|
+
# Scan and add spam headers (for mail server integration)
|
|
3962
|
+
spamscanner scan email.eml --add-headers --prepend-subject
|
|
3963
|
+
|
|
3964
|
+
# Scan with specific language support
|
|
3965
|
+
spamscanner scan email.eml --languages en,es,fr
|
|
3966
|
+
|
|
3967
|
+
# Scan with mixed language detection
|
|
3968
|
+
spamscanner scan email.eml --mixed-language
|
|
3969
|
+
|
|
3970
|
+
# Start TCP server
|
|
3971
|
+
spamscanner server --port 7830
|
|
3972
|
+
|
|
3973
|
+
# Scan with authentication (DKIM/SPF/DMARC)
|
|
3974
|
+
spamscanner scan email.eml --enable-auth --sender-ip 192.168.1.1 --sender user@example.com
|
|
3975
|
+
|
|
3976
|
+
# Scan with reputation checking
|
|
3977
|
+
spamscanner scan email.eml --enable-reputation
|
|
3978
|
+
|
|
3979
|
+
# Full mail server integration
|
|
3980
|
+
spamscanner scan email.eml --enable-auth --enable-reputation --sender-ip 192.168.1.1 --add-headers --add-auth-headers
|
|
3981
|
+
|
|
3982
|
+
# Check for updates
|
|
3983
|
+
spamscanner update
|
|
3984
|
+
|
|
3985
|
+
Exit Codes:
|
|
3986
|
+
0 - Clean (not spam)
|
|
3987
|
+
1 - Spam detected
|
|
3988
|
+
2 - Error occurred
|
|
3989
|
+
|
|
3990
|
+
X-Spam Headers (when --add-headers is used):
|
|
3991
|
+
X-Spam-Status: Yes/No, score=X.X required=Y.Y tests=TEST1,TEST2,...
|
|
3992
|
+
X-Spam-Score: X.X
|
|
3993
|
+
X-Spam-Flag: YES/NO
|
|
3994
|
+
X-Spam-Tests: Comma-separated list of triggered tests
|
|
3995
|
+
`;
|
|
3996
|
+
function parseArgs(args) {
|
|
3997
|
+
const result = {
|
|
3998
|
+
command: null,
|
|
3999
|
+
file: null,
|
|
4000
|
+
json: false,
|
|
4001
|
+
verbose: false,
|
|
4002
|
+
debug: false,
|
|
4003
|
+
port: 7830,
|
|
4004
|
+
host: "127.0.0.1",
|
|
4005
|
+
timeout: 3e4,
|
|
4006
|
+
help: false,
|
|
4007
|
+
version: false,
|
|
4008
|
+
noUpdateCheck: false,
|
|
4009
|
+
// Spam detection options
|
|
4010
|
+
threshold: 5,
|
|
4011
|
+
checkClassifier: true,
|
|
4012
|
+
checkPhishing: true,
|
|
4013
|
+
checkExecutables: true,
|
|
4014
|
+
checkMacros: true,
|
|
4015
|
+
checkVirus: true,
|
|
4016
|
+
checkNsfw: false,
|
|
4017
|
+
checkToxicity: false,
|
|
4018
|
+
// Score weights
|
|
4019
|
+
scores: { ...DEFAULT_SCORES },
|
|
4020
|
+
// Header options
|
|
4021
|
+
addHeaders: false,
|
|
4022
|
+
prependSubject: false,
|
|
4023
|
+
subjectTag: "[SPAM]",
|
|
4024
|
+
// Scanner configuration options
|
|
4025
|
+
supportedLanguages: [],
|
|
4026
|
+
// Empty = all languages
|
|
4027
|
+
enableMixedLanguageDetection: false,
|
|
4028
|
+
enableMacroDetection: true,
|
|
4029
|
+
enableAdvancedPatternRecognition: true,
|
|
4030
|
+
strictIdnDetection: false,
|
|
4031
|
+
nsfwThreshold: 0.6,
|
|
4032
|
+
toxicityThreshold: 0.7,
|
|
4033
|
+
clamscanPath: "/usr/bin/clamscan",
|
|
4034
|
+
clamdscanPath: "/usr/bin/clamdscan",
|
|
4035
|
+
// Authentication options
|
|
4036
|
+
enableAuth: false,
|
|
4037
|
+
senderIp: null,
|
|
4038
|
+
senderHostname: null,
|
|
4039
|
+
helo: null,
|
|
4040
|
+
sender: null,
|
|
4041
|
+
mta: "spamscanner",
|
|
4042
|
+
authTimeout: 1e4,
|
|
4043
|
+
// Reputation options
|
|
4044
|
+
enableReputation: false,
|
|
4045
|
+
reputationUrl: "https://api.forwardemail.net/v1/reputation",
|
|
4046
|
+
reputationTimeout: 1e4,
|
|
4047
|
+
onlyAligned: true,
|
|
4048
|
+
// Additional header options
|
|
4049
|
+
addAuthHeaders: false
|
|
4050
|
+
};
|
|
4051
|
+
for (let index = 0; index < args.length; index++) {
|
|
4052
|
+
const arg = args[index];
|
|
4053
|
+
switch (arg) {
|
|
4054
|
+
case "scan":
|
|
4055
|
+
case "server":
|
|
4056
|
+
case "help":
|
|
4057
|
+
case "version":
|
|
4058
|
+
case "update": {
|
|
4059
|
+
result.command = arg;
|
|
4060
|
+
break;
|
|
4061
|
+
}
|
|
4062
|
+
case "-h":
|
|
4063
|
+
case "--help": {
|
|
4064
|
+
result.help = true;
|
|
4065
|
+
break;
|
|
4066
|
+
}
|
|
4067
|
+
case "-v":
|
|
4068
|
+
case "--version": {
|
|
4069
|
+
result.version = true;
|
|
4070
|
+
break;
|
|
4071
|
+
}
|
|
4072
|
+
case "-j":
|
|
4073
|
+
case "--json": {
|
|
4074
|
+
result.json = true;
|
|
4075
|
+
break;
|
|
4076
|
+
}
|
|
4077
|
+
case "--verbose": {
|
|
4078
|
+
result.verbose = true;
|
|
4079
|
+
break;
|
|
4080
|
+
}
|
|
4081
|
+
case "--debug": {
|
|
4082
|
+
result.debug = true;
|
|
4083
|
+
break;
|
|
4084
|
+
}
|
|
4085
|
+
case "--no-update-check": {
|
|
4086
|
+
result.noUpdateCheck = true;
|
|
4087
|
+
break;
|
|
4088
|
+
}
|
|
4089
|
+
case "--port": {
|
|
4090
|
+
result.port = Number.parseInt(args[++index], 10);
|
|
4091
|
+
break;
|
|
4092
|
+
}
|
|
4093
|
+
case "--host": {
|
|
4094
|
+
result.host = args[++index];
|
|
4095
|
+
break;
|
|
4096
|
+
}
|
|
4097
|
+
case "--timeout": {
|
|
4098
|
+
result.timeout = Number.parseInt(args[++index], 10);
|
|
4099
|
+
break;
|
|
4100
|
+
}
|
|
4101
|
+
// Spam detection options
|
|
4102
|
+
case "--threshold": {
|
|
4103
|
+
result.threshold = Number.parseFloat(args[++index]);
|
|
4104
|
+
break;
|
|
4105
|
+
}
|
|
4106
|
+
case "--check-classifier": {
|
|
4107
|
+
result.checkClassifier = true;
|
|
4108
|
+
break;
|
|
4109
|
+
}
|
|
4110
|
+
case "--check-phishing": {
|
|
4111
|
+
result.checkPhishing = true;
|
|
4112
|
+
break;
|
|
4113
|
+
}
|
|
4114
|
+
case "--check-executables": {
|
|
4115
|
+
result.checkExecutables = true;
|
|
4116
|
+
break;
|
|
4117
|
+
}
|
|
4118
|
+
case "--check-macros": {
|
|
4119
|
+
result.checkMacros = true;
|
|
4120
|
+
break;
|
|
4121
|
+
}
|
|
4122
|
+
case "--check-virus": {
|
|
4123
|
+
result.checkVirus = true;
|
|
4124
|
+
break;
|
|
4125
|
+
}
|
|
4126
|
+
case "--check-nsfw": {
|
|
4127
|
+
result.checkNsfw = true;
|
|
4128
|
+
break;
|
|
4129
|
+
}
|
|
4130
|
+
case "--check-toxicity": {
|
|
4131
|
+
result.checkToxicity = true;
|
|
4132
|
+
break;
|
|
4133
|
+
}
|
|
4134
|
+
case "--no-classifier": {
|
|
4135
|
+
result.checkClassifier = false;
|
|
4136
|
+
break;
|
|
4137
|
+
}
|
|
4138
|
+
case "--no-phishing": {
|
|
4139
|
+
result.checkPhishing = false;
|
|
4140
|
+
break;
|
|
4141
|
+
}
|
|
4142
|
+
case "--no-executables": {
|
|
4143
|
+
result.checkExecutables = false;
|
|
4144
|
+
break;
|
|
4145
|
+
}
|
|
4146
|
+
case "--no-macros": {
|
|
4147
|
+
result.checkMacros = false;
|
|
4148
|
+
break;
|
|
4149
|
+
}
|
|
4150
|
+
case "--no-virus": {
|
|
4151
|
+
result.checkVirus = false;
|
|
4152
|
+
break;
|
|
4153
|
+
}
|
|
4154
|
+
// Score weights
|
|
4155
|
+
case "--score-classifier": {
|
|
4156
|
+
result.scores.classifier = Number.parseFloat(args[++index]);
|
|
4157
|
+
break;
|
|
4158
|
+
}
|
|
4159
|
+
case "--score-phishing": {
|
|
4160
|
+
result.scores.phishing = Number.parseFloat(args[++index]);
|
|
4161
|
+
break;
|
|
4162
|
+
}
|
|
4163
|
+
case "--score-executable": {
|
|
4164
|
+
result.scores.executable = Number.parseFloat(args[++index]);
|
|
4165
|
+
break;
|
|
4166
|
+
}
|
|
4167
|
+
case "--score-macro": {
|
|
4168
|
+
result.scores.macro = Number.parseFloat(args[++index]);
|
|
4169
|
+
break;
|
|
4170
|
+
}
|
|
4171
|
+
case "--score-virus": {
|
|
4172
|
+
result.scores.virus = Number.parseFloat(args[++index]);
|
|
4173
|
+
break;
|
|
4174
|
+
}
|
|
4175
|
+
case "--score-nsfw": {
|
|
4176
|
+
result.scores.nsfw = Number.parseFloat(args[++index]);
|
|
4177
|
+
break;
|
|
4178
|
+
}
|
|
4179
|
+
case "--score-toxicity": {
|
|
4180
|
+
result.scores.toxicity = Number.parseFloat(args[++index]);
|
|
4181
|
+
break;
|
|
4182
|
+
}
|
|
4183
|
+
// Header options
|
|
4184
|
+
case "--add-headers": {
|
|
4185
|
+
result.addHeaders = true;
|
|
4186
|
+
break;
|
|
4187
|
+
}
|
|
4188
|
+
case "--prepend-subject": {
|
|
4189
|
+
result.prependSubject = true;
|
|
4190
|
+
break;
|
|
4191
|
+
}
|
|
4192
|
+
case "--subject-tag": {
|
|
4193
|
+
result.subjectTag = args[++index];
|
|
4194
|
+
break;
|
|
4195
|
+
}
|
|
4196
|
+
// Scanner configuration options
|
|
4197
|
+
case "--languages": {
|
|
4198
|
+
const langArg = args[++index];
|
|
4199
|
+
result.supportedLanguages = langArg && langArg !== "all" && langArg !== "" ? langArg.split(",").map((l) => l.trim().toLowerCase()) : [];
|
|
4200
|
+
break;
|
|
4201
|
+
}
|
|
4202
|
+
case "--mixed-language": {
|
|
4203
|
+
result.enableMixedLanguageDetection = true;
|
|
4204
|
+
break;
|
|
4205
|
+
}
|
|
4206
|
+
case "--no-macro-detection": {
|
|
4207
|
+
result.enableMacroDetection = false;
|
|
4208
|
+
break;
|
|
4209
|
+
}
|
|
4210
|
+
case "--no-pattern-recognition": {
|
|
4211
|
+
result.enableAdvancedPatternRecognition = false;
|
|
4212
|
+
break;
|
|
4213
|
+
}
|
|
4214
|
+
case "--strict-idn": {
|
|
4215
|
+
result.strictIdnDetection = true;
|
|
4216
|
+
break;
|
|
4217
|
+
}
|
|
4218
|
+
case "--nsfw-threshold": {
|
|
4219
|
+
result.nsfwThreshold = Number.parseFloat(args[++index]);
|
|
4220
|
+
break;
|
|
4221
|
+
}
|
|
4222
|
+
case "--toxicity-threshold": {
|
|
4223
|
+
result.toxicityThreshold = Number.parseFloat(args[++index]);
|
|
4224
|
+
break;
|
|
4225
|
+
}
|
|
4226
|
+
case "--clamscan-path": {
|
|
4227
|
+
result.clamscanPath = args[++index];
|
|
4228
|
+
break;
|
|
4229
|
+
}
|
|
4230
|
+
case "--clamdscan-path": {
|
|
4231
|
+
result.clamdscanPath = args[++index];
|
|
4232
|
+
break;
|
|
4233
|
+
}
|
|
4234
|
+
// Authentication options
|
|
4235
|
+
case "--enable-auth": {
|
|
4236
|
+
result.enableAuth = true;
|
|
4237
|
+
break;
|
|
4238
|
+
}
|
|
4239
|
+
case "--sender-ip": {
|
|
4240
|
+
result.senderIp = args[++index];
|
|
4241
|
+
break;
|
|
4242
|
+
}
|
|
4243
|
+
case "--sender-hostname": {
|
|
4244
|
+
result.senderHostname = args[++index];
|
|
4245
|
+
break;
|
|
4246
|
+
}
|
|
4247
|
+
case "--helo": {
|
|
4248
|
+
result.helo = args[++index];
|
|
4249
|
+
break;
|
|
4250
|
+
}
|
|
4251
|
+
case "--sender": {
|
|
4252
|
+
result.sender = args[++index];
|
|
4253
|
+
break;
|
|
4254
|
+
}
|
|
4255
|
+
case "--mta": {
|
|
4256
|
+
result.mta = args[++index];
|
|
4257
|
+
break;
|
|
4258
|
+
}
|
|
4259
|
+
case "--auth-timeout": {
|
|
4260
|
+
result.authTimeout = Number.parseInt(args[++index], 10);
|
|
4261
|
+
break;
|
|
4262
|
+
}
|
|
4263
|
+
// Reputation options
|
|
4264
|
+
case "--enable-reputation": {
|
|
4265
|
+
result.enableReputation = true;
|
|
4266
|
+
break;
|
|
4267
|
+
}
|
|
4268
|
+
case "--reputation-url": {
|
|
4269
|
+
result.reputationUrl = args[++index];
|
|
4270
|
+
break;
|
|
4271
|
+
}
|
|
4272
|
+
case "--reputation-timeout": {
|
|
4273
|
+
result.reputationTimeout = Number.parseInt(args[++index], 10);
|
|
4274
|
+
break;
|
|
4275
|
+
}
|
|
4276
|
+
case "--only-aligned": {
|
|
4277
|
+
result.onlyAligned = true;
|
|
4278
|
+
break;
|
|
4279
|
+
}
|
|
4280
|
+
case "--no-only-aligned": {
|
|
4281
|
+
result.onlyAligned = false;
|
|
4282
|
+
break;
|
|
4283
|
+
}
|
|
4284
|
+
// Additional header options
|
|
4285
|
+
case "--add-auth-headers": {
|
|
4286
|
+
result.addAuthHeaders = true;
|
|
4287
|
+
break;
|
|
4288
|
+
}
|
|
4289
|
+
default: {
|
|
4290
|
+
if (!result.file && result.command === "scan" && (arg === "-" || !arg.startsWith("-"))) {
|
|
4291
|
+
result.file = arg;
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
return result;
|
|
4297
|
+
}
|
|
4298
|
+
async function readEmail(file) {
|
|
4299
|
+
if (file === "-") {
|
|
4300
|
+
const chunks2 = [];
|
|
4301
|
+
for await (const chunk of process2.stdin) {
|
|
4302
|
+
chunks2.push(chunk);
|
|
4303
|
+
}
|
|
4304
|
+
return Buffer4.concat(chunks2);
|
|
4305
|
+
}
|
|
4306
|
+
const chunks = [];
|
|
4307
|
+
const stream = createReadStream(file);
|
|
4308
|
+
for await (const chunk of stream) {
|
|
4309
|
+
chunks.push(chunk);
|
|
4310
|
+
}
|
|
4311
|
+
return Buffer4.concat(chunks);
|
|
4312
|
+
}
|
|
4313
|
+
function calculateScore(result, options) {
|
|
4314
|
+
const { scores } = options;
|
|
4315
|
+
const tests = [];
|
|
4316
|
+
let totalScore = 0;
|
|
4317
|
+
if (options.checkClassifier && result.results?.classification) {
|
|
4318
|
+
const { category, probability } = result.results.classification;
|
|
4319
|
+
if (category === "spam") {
|
|
4320
|
+
const scaledScore = scores.classifier * Math.max(0, (probability - 0.5) * 2);
|
|
4321
|
+
totalScore += scaledScore;
|
|
4322
|
+
tests.push(`BAYES_SPAM(${scaledScore.toFixed(1)})`);
|
|
4323
|
+
} else if (category === "ham" && probability > 0.8) {
|
|
4324
|
+
const hamBonus = -1 * (probability - 0.8) * 5;
|
|
4325
|
+
totalScore += hamBonus;
|
|
4326
|
+
tests.push(`BAYES_HAM(${hamBonus.toFixed(1)})`);
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
if (options.checkPhishing && result.results?.phishing?.length > 0) {
|
|
4330
|
+
const phishingScore = result.results.phishing.length * scores.phishing;
|
|
4331
|
+
totalScore += phishingScore;
|
|
4332
|
+
tests.push(`PHISHING_DETECTED(${phishingScore.toFixed(1)})`);
|
|
4333
|
+
}
|
|
4334
|
+
if (options.checkExecutables && result.results?.executables?.length > 0) {
|
|
4335
|
+
const execScore = result.results.executables.length * scores.executable;
|
|
4336
|
+
totalScore += execScore;
|
|
4337
|
+
tests.push(`EXECUTABLE_ATTACHMENT(${execScore.toFixed(1)})`);
|
|
4338
|
+
}
|
|
4339
|
+
if (options.checkMacros && result.results?.macros?.length > 0) {
|
|
4340
|
+
const macroScore = result.results.macros.length * scores.macro;
|
|
4341
|
+
totalScore += macroScore;
|
|
4342
|
+
tests.push(`MACRO_DETECTED(${macroScore.toFixed(1)})`);
|
|
4343
|
+
}
|
|
4344
|
+
if (options.checkVirus && result.results?.viruses?.length > 0) {
|
|
4345
|
+
const virusScore = result.results.viruses.length * scores.virus;
|
|
4346
|
+
totalScore += virusScore;
|
|
4347
|
+
tests.push(`VIRUS_DETECTED(${virusScore.toFixed(1)})`);
|
|
4348
|
+
}
|
|
4349
|
+
if (options.checkNsfw && result.results?.nsfw?.length > 0) {
|
|
4350
|
+
const nsfwScore = result.results.nsfw.length * scores.nsfw;
|
|
4351
|
+
totalScore += nsfwScore;
|
|
4352
|
+
tests.push(`NSFW_CONTENT(${nsfwScore.toFixed(1)})`);
|
|
4353
|
+
}
|
|
4354
|
+
if (options.checkToxicity && result.results?.toxicity?.length > 0) {
|
|
4355
|
+
const toxicScore = result.results.toxicity.length * scores.toxicity;
|
|
4356
|
+
totalScore += toxicScore;
|
|
4357
|
+
tests.push(`TOXIC_CONTENT(${toxicScore.toFixed(1)})`);
|
|
4358
|
+
}
|
|
4359
|
+
if (result.results?.authentication?.score) {
|
|
4360
|
+
const authScore = result.results.authentication.score;
|
|
4361
|
+
totalScore += authScore.score;
|
|
4362
|
+
tests.push(...authScore.tests);
|
|
4363
|
+
}
|
|
4364
|
+
if (result.results?.reputation) {
|
|
4365
|
+
const rep = result.results.reputation;
|
|
4366
|
+
if (rep.isDenylisted) {
|
|
4367
|
+
totalScore += 10;
|
|
4368
|
+
tests.push("DENYLISTED(10.0)");
|
|
4369
|
+
}
|
|
4370
|
+
if (rep.isTruthSource) {
|
|
4371
|
+
totalScore -= 5;
|
|
4372
|
+
tests.push("TRUTH_SOURCE(-5.0)");
|
|
4373
|
+
} else if (rep.isAllowlisted) {
|
|
4374
|
+
totalScore -= 3;
|
|
4375
|
+
tests.push("ALLOWLISTED(-3.0)");
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4378
|
+
let isSpam = totalScore >= options.threshold;
|
|
4379
|
+
if (result.results?.reputation) {
|
|
4380
|
+
const rep = result.results.reputation;
|
|
4381
|
+
if (rep.isDenylisted) {
|
|
4382
|
+
isSpam = true;
|
|
4383
|
+
} else if ((rep.isTruthSource || rep.isAllowlisted) && !result.results?.viruses?.length && !result.results?.executables?.length) {
|
|
4384
|
+
isSpam = false;
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
return {
|
|
4388
|
+
score: totalScore,
|
|
4389
|
+
threshold: options.threshold,
|
|
4390
|
+
isSpam,
|
|
4391
|
+
tests
|
|
4392
|
+
};
|
|
4393
|
+
}
|
|
4394
|
+
function generateSpamHeaders(scoreDetails) {
|
|
4395
|
+
const { score, threshold, isSpam, tests } = scoreDetails;
|
|
4396
|
+
const status = isSpam ? "Yes" : "No";
|
|
4397
|
+
const flag = isSpam ? "YES" : "NO";
|
|
4398
|
+
return {
|
|
4399
|
+
"X-Spam-Status": `${status}, score=${score.toFixed(1)} required=${threshold.toFixed(1)} tests=${tests.join(",")} version=${VERSION}`,
|
|
4400
|
+
"X-Spam-Score": score.toFixed(1),
|
|
4401
|
+
"X-Spam-Flag": flag,
|
|
4402
|
+
"X-Spam-Tests": tests.join(", ")
|
|
4403
|
+
};
|
|
4404
|
+
}
|
|
4405
|
+
function modifyEmail(emailContent, options, scoreDetails, authResultsHeader = null) {
|
|
4406
|
+
const emailString = emailContent.toString("utf8");
|
|
4407
|
+
const headers = generateSpamHeaders(scoreDetails);
|
|
4408
|
+
if (options.addAuthHeaders && authResultsHeader) {
|
|
4409
|
+
headers["Authentication-Results"] = authResultsHeader;
|
|
4410
|
+
}
|
|
4411
|
+
const headerEndMatch = emailString.match(/\r?\n\r?\n/);
|
|
4412
|
+
if (!headerEndMatch) {
|
|
4413
|
+
return emailString + "\r\n" + Object.entries(headers).map(([key, value]) => `${key}: ${value}`).join("\r\n");
|
|
4414
|
+
}
|
|
4415
|
+
const headerEndIndex = headerEndMatch.index;
|
|
4416
|
+
const lineEnding = headerEndMatch[0].startsWith("\r\n") ? "\r\n" : "\n";
|
|
4417
|
+
const headerPart = emailString.slice(0, headerEndIndex);
|
|
4418
|
+
const bodyPart = emailString.slice(headerEndIndex);
|
|
4419
|
+
let newHeaders = headerPart;
|
|
4420
|
+
if (options.addHeaders) {
|
|
4421
|
+
const headerLines = Object.entries(headers).map(([key, value]) => `${key}: ${value}`).join(lineEnding);
|
|
4422
|
+
newHeaders = headerPart + lineEnding + headerLines;
|
|
4423
|
+
}
|
|
4424
|
+
if (options.prependSubject && scoreDetails.isSpam) {
|
|
4425
|
+
const subjectMatch = newHeaders.match(/^(subject:\s*)(.*)$/im);
|
|
4426
|
+
if (subjectMatch) {
|
|
4427
|
+
const [fullMatch, prefix, subject] = subjectMatch;
|
|
4428
|
+
if (!subject.startsWith(options.subjectTag)) {
|
|
4429
|
+
const newSubject = `${prefix}${options.subjectTag} ${subject}`;
|
|
4430
|
+
newHeaders = newHeaders.replace(fullMatch, newSubject);
|
|
4431
|
+
}
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
return newHeaders + bodyPart;
|
|
4435
|
+
}
|
|
4436
|
+
function formatResult(result, scoreDetails, verbose) {
|
|
4437
|
+
const lines = [];
|
|
4438
|
+
const { score, threshold, isSpam, tests } = scoreDetails;
|
|
4439
|
+
if (isSpam) {
|
|
4440
|
+
lines.push(`SPAM DETECTED (score: ${score.toFixed(1)}, threshold: ${threshold.toFixed(1)})`);
|
|
4441
|
+
} else {
|
|
4442
|
+
lines.push(`Clean (score: ${score.toFixed(1)}, threshold: ${threshold.toFixed(1)})`);
|
|
4443
|
+
}
|
|
4444
|
+
if (tests.length > 0) {
|
|
4445
|
+
lines.push(`Tests: ${tests.join(", ")}`);
|
|
4446
|
+
}
|
|
4447
|
+
if (verbose) {
|
|
4448
|
+
lines.push("", "Details:");
|
|
4449
|
+
if (result.results?.classification) {
|
|
4450
|
+
const prob = (result.results.classification.probability * 100).toFixed(1);
|
|
4451
|
+
lines.push(` Classification: ${result.results.classification.category} (${prob}%)`);
|
|
4452
|
+
}
|
|
4453
|
+
if (result.results?.phishing?.length > 0) {
|
|
4454
|
+
lines.push(` Phishing: ${result.results.phishing.length} issue(s) detected`);
|
|
4455
|
+
for (const issue of result.results.phishing) {
|
|
4456
|
+
lines.push(` - ${issue.type}: ${issue.description || issue.message || "N/A"}`);
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4459
|
+
if (result.results?.executables?.length > 0) {
|
|
4460
|
+
lines.push(` Executables: ${result.results.executables.length} dangerous file(s) detected`);
|
|
4461
|
+
for (const exec of result.results.executables) {
|
|
4462
|
+
lines.push(` - ${exec.filename || exec.extension || "Unknown"}`);
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4465
|
+
if (result.results?.viruses?.length > 0) {
|
|
4466
|
+
lines.push(` Viruses: ${result.results.viruses.length} virus(es) detected`);
|
|
4467
|
+
for (const virus of result.results.viruses) {
|
|
4468
|
+
lines.push(` - ${virus.name || virus.message || "Unknown"}`);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
if (result.results?.macros?.length > 0) {
|
|
4472
|
+
lines.push(` Macros: ${result.results.macros.length} macro(s) detected`);
|
|
4473
|
+
}
|
|
4474
|
+
if (result.results?.toxicity?.length > 0) {
|
|
4475
|
+
lines.push(` Toxicity: ${result.results.toxicity.length} toxic content detected`);
|
|
4476
|
+
}
|
|
4477
|
+
if (result.results?.nsfw?.length > 0) {
|
|
4478
|
+
lines.push(` NSFW: ${result.results.nsfw.length} NSFW content detected`);
|
|
4479
|
+
}
|
|
4480
|
+
if (result.results?.authentication) {
|
|
4481
|
+
const auth = result.results.authentication;
|
|
4482
|
+
lines.push("", " Authentication:");
|
|
4483
|
+
if (auth.dkim?.status?.result) {
|
|
4484
|
+
lines.push(` DKIM: ${auth.dkim.status.result}`);
|
|
4485
|
+
}
|
|
4486
|
+
if (auth.spf?.status?.result) {
|
|
4487
|
+
lines.push(` SPF: ${auth.spf.status.result}`);
|
|
4488
|
+
}
|
|
4489
|
+
if (auth.dmarc?.status?.result) {
|
|
4490
|
+
lines.push(` DMARC: ${auth.dmarc.status.result}`);
|
|
4491
|
+
}
|
|
4492
|
+
if (auth.arc?.status?.result) {
|
|
4493
|
+
lines.push(` ARC: ${auth.arc.status.result}`);
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
if (result.results?.reputation) {
|
|
4497
|
+
const rep = result.results.reputation;
|
|
4498
|
+
lines.push("", " Reputation:");
|
|
4499
|
+
if (rep.isTruthSource) {
|
|
4500
|
+
lines.push(" Status: Truth Source");
|
|
4501
|
+
} else if (rep.isAllowlisted) {
|
|
4502
|
+
lines.push(` Status: Allowlisted (${rep.allowlistValue || "N/A"})`);
|
|
4503
|
+
} else if (rep.isDenylisted) {
|
|
4504
|
+
lines.push(` Status: DENYLISTED (${rep.denylistValue || "N/A"})`);
|
|
4505
|
+
} else {
|
|
4506
|
+
lines.push(" Status: Unknown");
|
|
4507
|
+
}
|
|
4508
|
+
if (rep.checkedValues?.length > 0) {
|
|
4509
|
+
lines.push(` Checked: ${rep.checkedValues.join(", ")}`);
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
return lines.join("\n");
|
|
4514
|
+
}
|
|
4515
|
+
function buildScannerConfig(options) {
|
|
4516
|
+
return {
|
|
4517
|
+
debug: options.debug,
|
|
4518
|
+
timeout: options.timeout,
|
|
4519
|
+
supportedLanguages: options.supportedLanguages,
|
|
4520
|
+
enableMixedLanguageDetection: options.enableMixedLanguageDetection,
|
|
4521
|
+
enableMacroDetection: options.enableMacroDetection,
|
|
4522
|
+
enableAdvancedPatternRecognition: options.enableAdvancedPatternRecognition,
|
|
4523
|
+
strictIDNDetection: options.strictIdnDetection,
|
|
4524
|
+
nsfwThreshold: options.nsfwThreshold,
|
|
4525
|
+
toxicityThreshold: options.toxicityThreshold,
|
|
4526
|
+
clamscan: {
|
|
4527
|
+
clamscanPath: options.clamscanPath,
|
|
4528
|
+
clamdscanPath: options.clamdscanPath
|
|
4529
|
+
},
|
|
4530
|
+
// Authentication options
|
|
4531
|
+
enableAuthentication: options.enableAuth,
|
|
4532
|
+
authOptions: {
|
|
4533
|
+
ip: options.senderIp,
|
|
4534
|
+
hostname: options.senderHostname,
|
|
4535
|
+
helo: options.helo,
|
|
4536
|
+
mta: options.mta,
|
|
4537
|
+
sender: options.sender,
|
|
4538
|
+
timeout: options.authTimeout
|
|
4539
|
+
},
|
|
4540
|
+
// Reputation options
|
|
4541
|
+
enableReputation: options.enableReputation,
|
|
4542
|
+
reputationOptions: {
|
|
4543
|
+
apiUrl: options.reputationUrl,
|
|
4544
|
+
timeout: options.reputationTimeout,
|
|
4545
|
+
onlyAligned: options.onlyAligned
|
|
4546
|
+
}
|
|
4547
|
+
};
|
|
4548
|
+
}
|
|
4549
|
+
async function scanCommand(options) {
|
|
4550
|
+
const { file, json, verbose, addHeaders, prependSubject } = options;
|
|
4551
|
+
if (!file) {
|
|
4552
|
+
console.error('Error: No file specified. Use "spamscanner scan <file>" or "spamscanner scan -" for stdin.');
|
|
4553
|
+
process2.exit(2);
|
|
4554
|
+
}
|
|
4555
|
+
try {
|
|
4556
|
+
if (file !== "-") {
|
|
4557
|
+
try {
|
|
4558
|
+
readFileSync3(file);
|
|
4559
|
+
} catch {
|
|
4560
|
+
console.error(`Error: File not found: ${file}`);
|
|
4561
|
+
process2.exit(2);
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
const scannerConfig = buildScannerConfig(options);
|
|
4565
|
+
const scanner = new index_default(scannerConfig);
|
|
4566
|
+
const emailContent = await readEmail(file);
|
|
4567
|
+
const result = await scanner.scan(emailContent);
|
|
4568
|
+
const scoreDetails = calculateScore(result, options);
|
|
4569
|
+
const output = {
|
|
4570
|
+
isSpam: scoreDetails.isSpam,
|
|
4571
|
+
score: scoreDetails.score,
|
|
4572
|
+
threshold: scoreDetails.threshold,
|
|
4573
|
+
tests: scoreDetails.tests,
|
|
4574
|
+
message: result.message,
|
|
4575
|
+
results: result.results,
|
|
4576
|
+
links: result.links,
|
|
4577
|
+
tokens: result.tokens,
|
|
4578
|
+
mail: result.mail
|
|
4579
|
+
};
|
|
4580
|
+
if (addHeaders || prependSubject || options.addAuthHeaders) {
|
|
4581
|
+
output.headers = generateSpamHeaders(scoreDetails);
|
|
4582
|
+
const authResultsHeader = result.results?.authentication?.authResultsHeader || null;
|
|
4583
|
+
output.modifiedEmail = modifyEmail(emailContent, options, scoreDetails, authResultsHeader);
|
|
4584
|
+
}
|
|
4585
|
+
if (json) {
|
|
4586
|
+
console.log(JSON.stringify(output, null, 2));
|
|
4587
|
+
} else if (addHeaders || prependSubject || options.addAuthHeaders) {
|
|
4588
|
+
console.log(output.modifiedEmail);
|
|
4589
|
+
} else {
|
|
4590
|
+
console.log(formatResult(result, scoreDetails, verbose));
|
|
4591
|
+
}
|
|
4592
|
+
process2.exit(scoreDetails.isSpam ? 1 : 0);
|
|
4593
|
+
} catch (error) {
|
|
4594
|
+
console.error(`Error scanning email: ${error.message}`);
|
|
4595
|
+
if (options.debug) {
|
|
4596
|
+
console.error(error.stack);
|
|
4597
|
+
}
|
|
4598
|
+
process2.exit(2);
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
async function serverCommand(options) {
|
|
4602
|
+
const { port, host, json, verbose, debug: debug8 } = options;
|
|
4603
|
+
const scannerConfig = buildScannerConfig(options);
|
|
4604
|
+
const scanner = new index_default(scannerConfig);
|
|
4605
|
+
const server = createServer((socket) => {
|
|
4606
|
+
const chunks = [];
|
|
4607
|
+
socket.on("data", (chunk) => {
|
|
4608
|
+
chunks.push(chunk);
|
|
4609
|
+
});
|
|
4610
|
+
socket.on("end", async () => {
|
|
4611
|
+
try {
|
|
4612
|
+
const emailContent = Buffer4.concat(chunks);
|
|
4613
|
+
const result = await scanner.scan(emailContent);
|
|
4614
|
+
const scoreDetails = calculateScore(result, options);
|
|
4615
|
+
const output = {
|
|
4616
|
+
isSpam: scoreDetails.isSpam,
|
|
4617
|
+
score: scoreDetails.score,
|
|
4618
|
+
threshold: scoreDetails.threshold,
|
|
4619
|
+
tests: scoreDetails.tests,
|
|
4620
|
+
message: result.message
|
|
4621
|
+
};
|
|
4622
|
+
if (options.addHeaders) {
|
|
4623
|
+
output.headers = generateSpamHeaders(scoreDetails);
|
|
4624
|
+
}
|
|
4625
|
+
if (json) {
|
|
4626
|
+
socket.write(JSON.stringify(output));
|
|
4627
|
+
} else {
|
|
4628
|
+
socket.write(formatResult(result, scoreDetails, verbose));
|
|
4629
|
+
}
|
|
4630
|
+
} catch (error) {
|
|
4631
|
+
const errorResponse = json ? JSON.stringify({ error: error.message }) : `Error: ${error.message}`;
|
|
4632
|
+
socket.write(errorResponse);
|
|
4633
|
+
if (debug8) {
|
|
4634
|
+
console.error(error.stack);
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
socket.end();
|
|
4638
|
+
});
|
|
4639
|
+
socket.on("error", (error) => {
|
|
4640
|
+
console.error(`Socket error: ${error.message}`);
|
|
4641
|
+
});
|
|
4642
|
+
});
|
|
4643
|
+
server.listen(port, host, () => {
|
|
4644
|
+
console.log(`SpamScanner TCP server listening on ${host}:${port}`);
|
|
4645
|
+
console.log("Send email content to scan, close connection to receive results.");
|
|
4646
|
+
console.log("Press Ctrl+C to stop.");
|
|
4647
|
+
});
|
|
4648
|
+
server.on("error", (error) => {
|
|
4649
|
+
console.error(`Server error: ${error.message}`);
|
|
4650
|
+
process2.exit(2);
|
|
4651
|
+
});
|
|
4652
|
+
}
|
|
4653
|
+
async function main() {
|
|
4654
|
+
const args = process2.argv.slice(2);
|
|
4655
|
+
const options = parseArgs(args);
|
|
4656
|
+
if (options.help) {
|
|
4657
|
+
console.log(HELP_TEXT);
|
|
4658
|
+
process2.exit(0);
|
|
4659
|
+
}
|
|
4660
|
+
if (options.version) {
|
|
4661
|
+
console.log(`SpamScanner v${VERSION}`);
|
|
4662
|
+
process2.exit(0);
|
|
4663
|
+
}
|
|
4664
|
+
if (!options.noUpdateCheck && options.command !== "update") {
|
|
4665
|
+
void printUpdateNotification().catch(() => {
|
|
4666
|
+
});
|
|
4667
|
+
}
|
|
4668
|
+
switch (options.command) {
|
|
4669
|
+
case "scan": {
|
|
4670
|
+
await scanCommand(options);
|
|
4671
|
+
break;
|
|
4672
|
+
}
|
|
4673
|
+
case "server": {
|
|
4674
|
+
await serverCommand(options);
|
|
4675
|
+
break;
|
|
4676
|
+
}
|
|
4677
|
+
case "update": {
|
|
4678
|
+
console.log(`SpamScanner v${VERSION}`);
|
|
4679
|
+
console.log("Checking for updates...");
|
|
4680
|
+
const update = await checkForUpdates(true);
|
|
4681
|
+
if (update) {
|
|
4682
|
+
console.log(`New version available: ${update.latestVersion}`);
|
|
4683
|
+
console.log(`Download from: ${update.releaseUrl}`);
|
|
4684
|
+
if (update.downloadUrl) {
|
|
4685
|
+
console.log(`Direct download: ${update.downloadUrl}`);
|
|
4686
|
+
}
|
|
4687
|
+
} else {
|
|
4688
|
+
console.log("You are running the latest version.");
|
|
4689
|
+
}
|
|
4690
|
+
process2.exit(0);
|
|
4691
|
+
break;
|
|
4692
|
+
}
|
|
4693
|
+
case "help": {
|
|
4694
|
+
console.log(HELP_TEXT);
|
|
4695
|
+
process2.exit(0);
|
|
4696
|
+
break;
|
|
4697
|
+
}
|
|
4698
|
+
case "version": {
|
|
4699
|
+
console.log(`SpamScanner v${VERSION}`);
|
|
4700
|
+
process2.exit(0);
|
|
4701
|
+
break;
|
|
4702
|
+
}
|
|
4703
|
+
default: {
|
|
4704
|
+
console.error('Unknown command. Use "spamscanner help" for usage information.');
|
|
4705
|
+
process2.exit(2);
|
|
4706
|
+
}
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
main().catch((error) => {
|
|
4710
|
+
console.error(`Fatal error: ${error.message}`);
|
|
4711
|
+
process2.exit(2);
|
|
4712
|
+
});
|
|
4713
|
+
//# sourceMappingURL=cli.js.map
|