spamscanner 6.0.0 → 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 +569 -42
- 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} +1677 -52
- 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 +1676 -51
- 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/enhanced-idn-detector.d.ts +236 -0
- package/dist/types/get-attributes.d.ts +148 -0
- package/dist/types/index.d.ts +959 -0
- package/dist/types/is-arbitrary.d.ts +231 -0
- package/dist/types/reputation.d.ts +60 -0
- package/package.json +13 -4
- package/dist/cjs/index.js.map +0 -7
package/dist/esm/index.js
CHANGED
|
@@ -34,14 +34,14 @@ var replacements_exports = {};
|
|
|
34
34
|
__export(replacements_exports, {
|
|
35
35
|
default: () => replacements_default
|
|
36
36
|
});
|
|
37
|
-
import { debuglog } from "node:util";
|
|
37
|
+
import { debuglog as debuglog5 } from "node:util";
|
|
38
38
|
import { readFileSync } from "node:fs";
|
|
39
39
|
import cryptoRandomString from "crypto-random-string";
|
|
40
|
-
var
|
|
40
|
+
var debug5, randomOptions, replacements, replacements_default;
|
|
41
41
|
var init_replacements = __esm({
|
|
42
42
|
"replacements.js"() {
|
|
43
43
|
init_replacement_words();
|
|
44
|
-
|
|
44
|
+
debug5 = debuglog5("spamscanner");
|
|
45
45
|
randomOptions = {
|
|
46
46
|
length: 10,
|
|
47
47
|
characters: "abcdefghijklmnopqrstuvwxyz"
|
|
@@ -50,7 +50,7 @@ var init_replacements = __esm({
|
|
|
50
50
|
try {
|
|
51
51
|
replacements = JSON.parse(readFileSync("./replacements.json", "utf8"));
|
|
52
52
|
} catch (error) {
|
|
53
|
-
|
|
53
|
+
debug5(error);
|
|
54
54
|
for (const replacement of replacement_words_default) {
|
|
55
55
|
replacements[replacement] = `${replacement}${cryptoRandomString(randomOptions)}`;
|
|
56
56
|
}
|
|
@@ -64,18 +64,18 @@ var get_classifier_exports = {};
|
|
|
64
64
|
__export(get_classifier_exports, {
|
|
65
65
|
default: () => get_classifier_default
|
|
66
66
|
});
|
|
67
|
-
import { debuglog as
|
|
67
|
+
import { debuglog as debuglog6 } from "node:util";
|
|
68
68
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
69
69
|
import NaiveBayes from "@ladjs/naivebayes";
|
|
70
|
-
var
|
|
70
|
+
var debug6, classifier, get_classifier_default;
|
|
71
71
|
var init_get_classifier = __esm({
|
|
72
72
|
"get-classifier.js"() {
|
|
73
|
-
|
|
73
|
+
debug6 = debuglog6("spamscanner");
|
|
74
74
|
classifier = new NaiveBayes().toJsonObject();
|
|
75
75
|
try {
|
|
76
76
|
classifier = JSON.parse(readFileSync2("./classifier.json", "utf8"));
|
|
77
77
|
} catch (error) {
|
|
78
|
-
|
|
78
|
+
debug6(error);
|
|
79
79
|
}
|
|
80
80
|
get_classifier_default = classifier;
|
|
81
81
|
}
|
|
@@ -520,11 +520,12 @@ var init_enhanced_idn_detector = __esm({
|
|
|
520
520
|
});
|
|
521
521
|
|
|
522
522
|
// src/index.js
|
|
523
|
+
import { Buffer as Buffer3 } from "node:buffer";
|
|
523
524
|
import fs from "node:fs";
|
|
524
525
|
import path from "node:path";
|
|
525
526
|
import process from "node:process";
|
|
526
527
|
import { createHash as createHash2 } from "node:crypto";
|
|
527
|
-
import { debuglog as
|
|
528
|
+
import { debuglog as debuglog7 } from "node:util";
|
|
528
529
|
import { fileURLToPath } from "node:url";
|
|
529
530
|
import autoBind from "auto-bind";
|
|
530
531
|
import AFHConvert from "ascii-fullwidth-halfwidth-convert";
|
|
@@ -555,9 +556,1460 @@ import sw from "stopword";
|
|
|
555
556
|
import urlRegexSafe from "url-regex-safe";
|
|
556
557
|
import { simpleParser } from "mailparser";
|
|
557
558
|
import { fileTypeFromBuffer } from "file-type";
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
559
|
+
|
|
560
|
+
// src/auth.js
|
|
561
|
+
import { Buffer as Buffer2 } from "node:buffer";
|
|
562
|
+
import { debuglog } from "node:util";
|
|
563
|
+
import dns from "node:dns";
|
|
564
|
+
var debug = debuglog("spamscanner:auth");
|
|
565
|
+
var mailauth;
|
|
566
|
+
var getMailauth = async () => {
|
|
567
|
+
mailauth ||= await import("mailauth");
|
|
568
|
+
return mailauth;
|
|
569
|
+
};
|
|
570
|
+
var createResolver = (timeout = 1e4) => {
|
|
571
|
+
const resolver = new dns.promises.Resolver();
|
|
572
|
+
resolver.setServers(["8.8.8.8", "1.1.1.1"]);
|
|
573
|
+
return async (name, type) => {
|
|
574
|
+
try {
|
|
575
|
+
const controller = new AbortController();
|
|
576
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
577
|
+
let result;
|
|
578
|
+
switch (type) {
|
|
579
|
+
case "TXT": {
|
|
580
|
+
result = await resolver.resolveTxt(name);
|
|
581
|
+
result = result.map((r) => Array.isArray(r) ? r.join("") : r);
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case "MX": {
|
|
585
|
+
result = await resolver.resolveMx(name);
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
case "A": {
|
|
589
|
+
result = await resolver.resolve4(name);
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
case "AAAA": {
|
|
593
|
+
result = await resolver.resolve6(name);
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
case "PTR": {
|
|
597
|
+
result = await resolver.resolvePtr(name);
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
case "CNAME": {
|
|
601
|
+
result = await resolver.resolveCname(name);
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
default: {
|
|
605
|
+
result = await resolver.resolve(name, type);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
clearTimeout(timeoutId);
|
|
609
|
+
return result;
|
|
610
|
+
} catch (error) {
|
|
611
|
+
debug("DNS lookup failed for %s %s: %s", type, name, error.message);
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
async function authenticate(message, options = {}) {
|
|
617
|
+
const {
|
|
618
|
+
ip,
|
|
619
|
+
helo,
|
|
620
|
+
mta,
|
|
621
|
+
sender,
|
|
622
|
+
resolver = createResolver(options.timeout || 1e4)
|
|
623
|
+
} = options;
|
|
624
|
+
const defaultResult = {
|
|
625
|
+
dkim: {
|
|
626
|
+
results: [],
|
|
627
|
+
status: { result: "none", comment: "No DKIM signature found" }
|
|
628
|
+
},
|
|
629
|
+
spf: {
|
|
630
|
+
status: { result: "none", comment: "SPF check not performed" },
|
|
631
|
+
domain: null
|
|
632
|
+
},
|
|
633
|
+
dmarc: {
|
|
634
|
+
status: { result: "none", comment: "DMARC check not performed" },
|
|
635
|
+
policy: null,
|
|
636
|
+
domain: null
|
|
637
|
+
},
|
|
638
|
+
arc: {
|
|
639
|
+
status: { result: "none", comment: "No ARC chain found" },
|
|
640
|
+
chain: []
|
|
641
|
+
},
|
|
642
|
+
bimi: {
|
|
643
|
+
status: { result: "none", comment: "No BIMI record found" },
|
|
644
|
+
location: null,
|
|
645
|
+
authority: null
|
|
646
|
+
},
|
|
647
|
+
receivedChain: [],
|
|
648
|
+
headers: {}
|
|
649
|
+
};
|
|
650
|
+
if (!ip) {
|
|
651
|
+
debug("No IP address provided, skipping authentication");
|
|
652
|
+
return defaultResult;
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
const { authenticate: mailauthAuthenticate } = await getMailauth();
|
|
656
|
+
const messageBuffer = Buffer2.isBuffer(message) ? message : Buffer2.from(message);
|
|
657
|
+
const authResult = await mailauthAuthenticate(messageBuffer, {
|
|
658
|
+
ip,
|
|
659
|
+
helo: helo || "unknown",
|
|
660
|
+
mta: mta || "spamscanner",
|
|
661
|
+
sender,
|
|
662
|
+
resolver
|
|
663
|
+
});
|
|
664
|
+
debug("Authentication result: %o", authResult);
|
|
665
|
+
return {
|
|
666
|
+
dkim: normalizeResult(authResult.dkim, "dkim"),
|
|
667
|
+
spf: normalizeResult(authResult.spf, "spf"),
|
|
668
|
+
dmarc: normalizeResult(authResult.dmarc, "dmarc"),
|
|
669
|
+
arc: normalizeResult(authResult.arc, "arc"),
|
|
670
|
+
bimi: normalizeResult(authResult.bimi, "bimi"),
|
|
671
|
+
receivedChain: authResult.receivedChain || [],
|
|
672
|
+
headers: authResult.headers || {}
|
|
673
|
+
};
|
|
674
|
+
} catch (error) {
|
|
675
|
+
debug("Authentication failed: %s", error.message);
|
|
676
|
+
return defaultResult;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
function normalizeResult(result, type) {
|
|
680
|
+
if (!result) {
|
|
681
|
+
return {
|
|
682
|
+
status: { result: "none", comment: `No ${type.toUpperCase()} result` }
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
switch (type) {
|
|
686
|
+
case "dkim": {
|
|
687
|
+
return {
|
|
688
|
+
results: result.results || [],
|
|
689
|
+
status: result.status || { result: "none", comment: "No DKIM signature found" }
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
case "spf": {
|
|
693
|
+
return {
|
|
694
|
+
status: result.status || { result: "none", comment: "SPF check not performed" },
|
|
695
|
+
domain: result.domain || null,
|
|
696
|
+
explanation: result.explanation || null
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
case "dmarc": {
|
|
700
|
+
return {
|
|
701
|
+
status: result.status || { result: "none", comment: "DMARC check not performed" },
|
|
702
|
+
policy: result.policy || null,
|
|
703
|
+
domain: result.domain || null,
|
|
704
|
+
p: result.p || null,
|
|
705
|
+
sp: result.sp || null,
|
|
706
|
+
pct: result.pct || null
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
case "arc": {
|
|
710
|
+
return {
|
|
711
|
+
status: result.status || { result: "none", comment: "No ARC chain found" },
|
|
712
|
+
chain: result.chain || [],
|
|
713
|
+
i: result.i || null
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
case "bimi": {
|
|
717
|
+
return {
|
|
718
|
+
status: result.status || { result: "none", comment: "No BIMI record found" },
|
|
719
|
+
location: result.location || null,
|
|
720
|
+
authority: result.authority || null,
|
|
721
|
+
selector: result.selector || null
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
default: {
|
|
725
|
+
return result;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
function calculateAuthScore(authResult, weights = {}) {
|
|
730
|
+
const defaultWeights = {
|
|
731
|
+
dkimPass: -2,
|
|
732
|
+
// Reduce spam score if DKIM passes
|
|
733
|
+
dkimFail: 3,
|
|
734
|
+
// Increase spam score if DKIM fails
|
|
735
|
+
spfPass: -1,
|
|
736
|
+
spfFail: 2,
|
|
737
|
+
spfSoftfail: 1,
|
|
738
|
+
dmarcPass: -2,
|
|
739
|
+
dmarcFail: 4,
|
|
740
|
+
arcPass: -1,
|
|
741
|
+
arcFail: 1,
|
|
742
|
+
...weights
|
|
743
|
+
};
|
|
744
|
+
let score = 0;
|
|
745
|
+
const tests = [];
|
|
746
|
+
const dkimResult = authResult.dkim?.status?.result;
|
|
747
|
+
if (dkimResult === "pass") {
|
|
748
|
+
score += defaultWeights.dkimPass;
|
|
749
|
+
tests.push(`DKIM_PASS(${defaultWeights.dkimPass})`);
|
|
750
|
+
} else if (dkimResult === "fail") {
|
|
751
|
+
score += defaultWeights.dkimFail;
|
|
752
|
+
tests.push(`DKIM_FAIL(${defaultWeights.dkimFail})`);
|
|
753
|
+
}
|
|
754
|
+
const spfResult = authResult.spf?.status?.result;
|
|
755
|
+
switch (spfResult) {
|
|
756
|
+
case "pass": {
|
|
757
|
+
score += defaultWeights.spfPass;
|
|
758
|
+
tests.push(`SPF_PASS(${defaultWeights.spfPass})`);
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
case "fail": {
|
|
762
|
+
score += defaultWeights.spfFail;
|
|
763
|
+
tests.push(`SPF_FAIL(${defaultWeights.spfFail})`);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
case "softfail": {
|
|
767
|
+
score += defaultWeights.spfSoftfail;
|
|
768
|
+
tests.push(`SPF_SOFTFAIL(${defaultWeights.spfSoftfail})`);
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const dmarcResult = authResult.dmarc?.status?.result;
|
|
773
|
+
if (dmarcResult === "pass") {
|
|
774
|
+
score += defaultWeights.dmarcPass;
|
|
775
|
+
tests.push(`DMARC_PASS(${defaultWeights.dmarcPass})`);
|
|
776
|
+
} else if (dmarcResult === "fail") {
|
|
777
|
+
score += defaultWeights.dmarcFail;
|
|
778
|
+
tests.push(`DMARC_FAIL(${defaultWeights.dmarcFail})`);
|
|
779
|
+
}
|
|
780
|
+
const arcResult = authResult.arc?.status?.result;
|
|
781
|
+
if (arcResult === "pass") {
|
|
782
|
+
score += defaultWeights.arcPass;
|
|
783
|
+
tests.push(`ARC_PASS(${defaultWeights.arcPass})`);
|
|
784
|
+
} else if (arcResult === "fail") {
|
|
785
|
+
score += defaultWeights.arcFail;
|
|
786
|
+
tests.push(`ARC_FAIL(${defaultWeights.arcFail})`);
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
score,
|
|
790
|
+
tests,
|
|
791
|
+
details: {
|
|
792
|
+
dkim: dkimResult || "none",
|
|
793
|
+
spf: spfResult || "none",
|
|
794
|
+
dmarc: dmarcResult || "none",
|
|
795
|
+
arc: arcResult || "none"
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
function formatAuthResultsHeader(authResult, hostname = "spamscanner") {
|
|
800
|
+
const parts = [hostname];
|
|
801
|
+
if (authResult.dkim?.status?.result) {
|
|
802
|
+
const dkimResult = authResult.dkim.status.result;
|
|
803
|
+
let dkimPart = `dkim=${dkimResult}`;
|
|
804
|
+
if (authResult.dkim.results?.[0]?.signingDomain) {
|
|
805
|
+
dkimPart += ` header.d=${authResult.dkim.results[0].signingDomain}`;
|
|
806
|
+
}
|
|
807
|
+
parts.push(dkimPart);
|
|
808
|
+
}
|
|
809
|
+
if (authResult.spf?.status?.result) {
|
|
810
|
+
let spfPart = `spf=${authResult.spf.status.result}`;
|
|
811
|
+
if (authResult.spf.domain) {
|
|
812
|
+
spfPart += ` smtp.mailfrom=${authResult.spf.domain}`;
|
|
813
|
+
}
|
|
814
|
+
parts.push(spfPart);
|
|
815
|
+
}
|
|
816
|
+
if (authResult.dmarc?.status?.result) {
|
|
817
|
+
let dmarcPart = `dmarc=${authResult.dmarc.status.result}`;
|
|
818
|
+
if (authResult.dmarc.domain) {
|
|
819
|
+
dmarcPart += ` header.from=${authResult.dmarc.domain}`;
|
|
820
|
+
}
|
|
821
|
+
parts.push(dmarcPart);
|
|
822
|
+
}
|
|
823
|
+
if (authResult.arc?.status?.result) {
|
|
824
|
+
parts.push(`arc=${authResult.arc.status.result}`);
|
|
825
|
+
}
|
|
826
|
+
return parts.join(";\n ");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// src/reputation.js
|
|
830
|
+
import { debuglog as debuglog2 } from "node:util";
|
|
831
|
+
var debug2 = debuglog2("spamscanner:reputation");
|
|
832
|
+
var DEFAULT_API_URL = "https://api.forwardemail.net/v1/reputation";
|
|
833
|
+
var cache = /* @__PURE__ */ new Map();
|
|
834
|
+
var CACHE_TTL = 5 * 60 * 1e3;
|
|
835
|
+
async function checkReputation(value, options = {}) {
|
|
836
|
+
const {
|
|
837
|
+
apiUrl = DEFAULT_API_URL,
|
|
838
|
+
timeout = 1e4
|
|
839
|
+
} = options;
|
|
840
|
+
if (!value || typeof value !== "string") {
|
|
841
|
+
return {
|
|
842
|
+
isTruthSource: false,
|
|
843
|
+
truthSourceValue: null,
|
|
844
|
+
isAllowlisted: false,
|
|
845
|
+
allowlistValue: null,
|
|
846
|
+
isDenylisted: false,
|
|
847
|
+
denylistValue: null
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const cacheKey = `${apiUrl}:${value}`;
|
|
851
|
+
const cached = cache.get(cacheKey);
|
|
852
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
853
|
+
debug2("Cache hit for %s", value);
|
|
854
|
+
return cached.result;
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
const url = new URL(apiUrl);
|
|
858
|
+
url.searchParams.set("q", value);
|
|
859
|
+
const controller = new AbortController();
|
|
860
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
861
|
+
const response = await fetch(url.toString(), {
|
|
862
|
+
method: "GET",
|
|
863
|
+
headers: {
|
|
864
|
+
Accept: "application/json",
|
|
865
|
+
"User-Agent": "SpamScanner/6.0"
|
|
866
|
+
},
|
|
867
|
+
signal: controller.signal
|
|
868
|
+
});
|
|
869
|
+
clearTimeout(timeoutId);
|
|
870
|
+
if (!response.ok) {
|
|
871
|
+
debug2("API returned status %d for %s", response.status, value);
|
|
872
|
+
return {
|
|
873
|
+
isTruthSource: false,
|
|
874
|
+
truthSourceValue: null,
|
|
875
|
+
isAllowlisted: false,
|
|
876
|
+
allowlistValue: null,
|
|
877
|
+
isDenylisted: false,
|
|
878
|
+
denylistValue: null
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
const result = await response.json();
|
|
882
|
+
const normalizedResult = {
|
|
883
|
+
isTruthSource: Boolean(result.isTruthSource),
|
|
884
|
+
truthSourceValue: result.truthSourceValue || null,
|
|
885
|
+
isAllowlisted: Boolean(result.isAllowlisted),
|
|
886
|
+
allowlistValue: result.allowlistValue || null,
|
|
887
|
+
isDenylisted: Boolean(result.isDenylisted),
|
|
888
|
+
denylistValue: result.denylistValue || null
|
|
889
|
+
};
|
|
890
|
+
cache.set(cacheKey, {
|
|
891
|
+
result: normalizedResult,
|
|
892
|
+
timestamp: Date.now()
|
|
893
|
+
});
|
|
894
|
+
debug2("Reputation check for %s: %o", value, normalizedResult);
|
|
895
|
+
return normalizedResult;
|
|
896
|
+
} catch (error) {
|
|
897
|
+
debug2("Reputation check failed for %s: %s", value, error.message);
|
|
898
|
+
return {
|
|
899
|
+
isTruthSource: false,
|
|
900
|
+
truthSourceValue: null,
|
|
901
|
+
isAllowlisted: false,
|
|
902
|
+
allowlistValue: null,
|
|
903
|
+
isDenylisted: false,
|
|
904
|
+
denylistValue: null
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
async function checkReputationBatch(values, options = {}) {
|
|
909
|
+
const uniqueValues = [...new Set(values.filter(Boolean))];
|
|
910
|
+
const results = await Promise.all(uniqueValues.map(async (value) => {
|
|
911
|
+
const result = await checkReputation(value, options);
|
|
912
|
+
return [value, result];
|
|
913
|
+
}));
|
|
914
|
+
return new Map(results);
|
|
915
|
+
}
|
|
916
|
+
function aggregateReputationResults(results) {
|
|
917
|
+
const aggregated = {
|
|
918
|
+
isTruthSource: false,
|
|
919
|
+
truthSourceValue: null,
|
|
920
|
+
isAllowlisted: false,
|
|
921
|
+
allowlistValue: null,
|
|
922
|
+
isDenylisted: false,
|
|
923
|
+
denylistValue: null
|
|
924
|
+
};
|
|
925
|
+
for (const result of results) {
|
|
926
|
+
if (result.isTruthSource) {
|
|
927
|
+
aggregated.isTruthSource = true;
|
|
928
|
+
aggregated.truthSourceValue ||= result.truthSourceValue;
|
|
929
|
+
}
|
|
930
|
+
if (result.isAllowlisted) {
|
|
931
|
+
aggregated.isAllowlisted = true;
|
|
932
|
+
aggregated.allowlistValue ||= result.allowlistValue;
|
|
933
|
+
}
|
|
934
|
+
if (result.isDenylisted) {
|
|
935
|
+
aggregated.isDenylisted = true;
|
|
936
|
+
aggregated.denylistValue ||= result.denylistValue;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return aggregated;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/is-arbitrary.js
|
|
943
|
+
import { debuglog as debuglog3 } from "node:util";
|
|
944
|
+
var debug3 = debuglog3("spamscanner:arbitrary");
|
|
945
|
+
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;
|
|
946
|
+
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;
|
|
947
|
+
var SPAM_PATTERNS = {
|
|
948
|
+
// Subject line patterns
|
|
949
|
+
subjectPatterns: [
|
|
950
|
+
// Urgency patterns
|
|
951
|
+
/\b(urgent|immediate|action required|act now|limited time|expires?|deadline)\b/i,
|
|
952
|
+
// Money patterns
|
|
953
|
+
/\b(free|winner|won|prize|lottery|million|billion|cash|money|investment|profit)\b/i,
|
|
954
|
+
// Phishing patterns
|
|
955
|
+
/\b(verify|confirm|update|suspend|locked|unusual activity|security alert)\b/i,
|
|
956
|
+
// Adult content
|
|
957
|
+
/\b(viagra|cialis|pharmacy|pills|medication|prescription)\b/i,
|
|
958
|
+
// Crypto spam
|
|
959
|
+
/\b(bitcoin|crypto|btc|eth|nft|blockchain|wallet)\b/i
|
|
960
|
+
],
|
|
961
|
+
// Body patterns
|
|
962
|
+
bodyPatterns: [
|
|
963
|
+
// Nigerian prince / advance fee fraud
|
|
964
|
+
/\b(nigerian?|prince|inheritance|beneficiary|next of kin|deceased|unclaimed)\b/i,
|
|
965
|
+
// Lottery scams
|
|
966
|
+
/\b(congratulations.*won|you have been selected|claim your prize)\b/i,
|
|
967
|
+
// Phishing
|
|
968
|
+
/\b(click here to verify|confirm your identity|update your account|suspended.*account)\b/i,
|
|
969
|
+
// Urgency
|
|
970
|
+
/\b(act now|limited time offer|expires in \d+|only \d+ left)\b/i,
|
|
971
|
+
// Financial scams
|
|
972
|
+
/\b(wire transfer|western union|moneygram|bank transfer|routing number)\b/i,
|
|
973
|
+
// Adult/pharma spam
|
|
974
|
+
/\b(enlarge|enhancement|erectile|dysfunction|weight loss|diet pills)\b/i
|
|
975
|
+
],
|
|
976
|
+
// Suspicious sender patterns
|
|
977
|
+
senderPatterns: [
|
|
978
|
+
// Random numbers in email
|
|
979
|
+
/^[a-z]+\d{4,}@/i,
|
|
980
|
+
// Very long local parts
|
|
981
|
+
/^.{30,}@/,
|
|
982
|
+
// Suspicious domains
|
|
983
|
+
/@.*(\.ru|\.cn|\.tk|\.ml|\.ga|\.cf|\.gq)$/i,
|
|
984
|
+
// Numeric domains
|
|
985
|
+
/@(?:\d+\.){3}\d+/
|
|
986
|
+
]
|
|
987
|
+
};
|
|
988
|
+
var SUSPICIOUS_TLDS = /* @__PURE__ */ new Set([
|
|
989
|
+
"tk",
|
|
990
|
+
"ml",
|
|
991
|
+
"ga",
|
|
992
|
+
"cf",
|
|
993
|
+
"gq",
|
|
994
|
+
// Free TLDs often abused
|
|
995
|
+
"xyz",
|
|
996
|
+
"top",
|
|
997
|
+
"wang",
|
|
998
|
+
"win",
|
|
999
|
+
"bid",
|
|
1000
|
+
"loan",
|
|
1001
|
+
"click",
|
|
1002
|
+
"link",
|
|
1003
|
+
"work",
|
|
1004
|
+
"date",
|
|
1005
|
+
"racing",
|
|
1006
|
+
"download",
|
|
1007
|
+
"stream",
|
|
1008
|
+
"trade"
|
|
1009
|
+
]);
|
|
1010
|
+
var SPAM_KEYWORDS = /* @__PURE__ */ new Map([
|
|
1011
|
+
["free", 1],
|
|
1012
|
+
["winner", 2],
|
|
1013
|
+
["prize", 2],
|
|
1014
|
+
["lottery", 3],
|
|
1015
|
+
["urgent", 1],
|
|
1016
|
+
["act now", 2],
|
|
1017
|
+
["limited time", 1],
|
|
1018
|
+
["click here", 1],
|
|
1019
|
+
["unsubscribe", -1],
|
|
1020
|
+
// Legitimate emails often have this
|
|
1021
|
+
["verify your account", 2],
|
|
1022
|
+
["suspended", 2],
|
|
1023
|
+
["inheritance", 3],
|
|
1024
|
+
["million dollars", 3],
|
|
1025
|
+
["wire transfer", 3],
|
|
1026
|
+
["western union", 3],
|
|
1027
|
+
["nigerian", 3],
|
|
1028
|
+
["prince", 2],
|
|
1029
|
+
["beneficiary", 2],
|
|
1030
|
+
["congratulations", 1],
|
|
1031
|
+
["selected", 1],
|
|
1032
|
+
["viagra", 3],
|
|
1033
|
+
["cialis", 3],
|
|
1034
|
+
["pharmacy", 2],
|
|
1035
|
+
["bitcoin", 1],
|
|
1036
|
+
["crypto", 1],
|
|
1037
|
+
["investment opportunity", 2],
|
|
1038
|
+
["guaranteed", 1],
|
|
1039
|
+
["risk free", 2],
|
|
1040
|
+
["no obligation", 1],
|
|
1041
|
+
["dear friend", 2],
|
|
1042
|
+
["dear customer", 1],
|
|
1043
|
+
["dear user", 1]
|
|
1044
|
+
]);
|
|
1045
|
+
var PAYPAL_SPAM_TYPE_IDS = /* @__PURE__ */ new Set(["PPC001017", "RT000238", "RT000542", "RT002947"]);
|
|
1046
|
+
var MS_SPAM_CATEGORIES = {
|
|
1047
|
+
// High-confidence threats (highest priority)
|
|
1048
|
+
highConfidence: ["cat:malw", "cat:hphsh", "cat:hphish", "cat:hspm"],
|
|
1049
|
+
// Impersonation attempts
|
|
1050
|
+
impersonation: ["cat:bimp", "cat:dimp", "cat:gimp", "cat:uimp"],
|
|
1051
|
+
// Phishing and spoofing
|
|
1052
|
+
phishingAndSpoofing: ["cat:phsh", "cat:spoof"],
|
|
1053
|
+
// Spam classifications
|
|
1054
|
+
spam: ["cat:ospm", "cat:spm"]
|
|
1055
|
+
};
|
|
1056
|
+
var MS_SPAM_VERDICTS = ["sfv:spm", "sfv:skb", "sfv:sks"];
|
|
1057
|
+
function isArbitrary(parsed, options = {}) {
|
|
1058
|
+
const {
|
|
1059
|
+
threshold = 5,
|
|
1060
|
+
checkSubject = true,
|
|
1061
|
+
checkBody = true,
|
|
1062
|
+
checkSender = true,
|
|
1063
|
+
checkHeaders = true,
|
|
1064
|
+
checkLinks = true,
|
|
1065
|
+
checkMicrosoftHeaders = true,
|
|
1066
|
+
checkVendorSpam = true,
|
|
1067
|
+
checkSpoofing = true,
|
|
1068
|
+
session = {}
|
|
1069
|
+
} = options;
|
|
1070
|
+
const reasons = [];
|
|
1071
|
+
let score = 0;
|
|
1072
|
+
let category = null;
|
|
1073
|
+
const getHeader = (name) => {
|
|
1074
|
+
if (parsed.headers?.get) {
|
|
1075
|
+
return parsed.headers.get(name);
|
|
1076
|
+
}
|
|
1077
|
+
if (parsed.headerLines) {
|
|
1078
|
+
const header = parsed.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
|
|
1079
|
+
return header?.line?.split(":").slice(1).join(":").trim();
|
|
1080
|
+
}
|
|
1081
|
+
return null;
|
|
1082
|
+
};
|
|
1083
|
+
const subject = parsed.subject || getHeader("subject") || "";
|
|
1084
|
+
const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
|
|
1085
|
+
const sessionInfo = buildSessionInfo(parsed, session, getHeader);
|
|
1086
|
+
if (subject && BLOCKED_PHRASES_PATTERN.test(subject)) {
|
|
1087
|
+
reasons.push("BLOCKED_PHRASE_IN_SUBJECT");
|
|
1088
|
+
score += 10;
|
|
1089
|
+
category = "SPAM";
|
|
1090
|
+
}
|
|
1091
|
+
if (checkMicrosoftHeaders) {
|
|
1092
|
+
const msResult = checkMicrosoftExchangeHeaders(getHeader, sessionInfo);
|
|
1093
|
+
if (msResult.blocked) {
|
|
1094
|
+
reasons.push(...msResult.reasons);
|
|
1095
|
+
score += msResult.score;
|
|
1096
|
+
category = msResult.category || category;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (checkVendorSpam) {
|
|
1100
|
+
const vendorResult = checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from);
|
|
1101
|
+
if (vendorResult.blocked) {
|
|
1102
|
+
reasons.push(...vendorResult.reasons);
|
|
1103
|
+
score += vendorResult.score;
|
|
1104
|
+
category = vendorResult.category || category;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (checkSpoofing) {
|
|
1108
|
+
const spoofResult = checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject);
|
|
1109
|
+
if (spoofResult.blocked) {
|
|
1110
|
+
reasons.push(...spoofResult.reasons);
|
|
1111
|
+
score += spoofResult.score;
|
|
1112
|
+
category = spoofResult.category || category;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (checkSubject && subject) {
|
|
1116
|
+
const subjectResult = checkSubjectLine(subject);
|
|
1117
|
+
score += subjectResult.score;
|
|
1118
|
+
reasons.push(...subjectResult.reasons);
|
|
1119
|
+
}
|
|
1120
|
+
if (checkBody) {
|
|
1121
|
+
const bodyText = parsed.text || "";
|
|
1122
|
+
const bodyHtml = parsed.html || "";
|
|
1123
|
+
const bodyResult = checkBodyContent(bodyText, bodyHtml);
|
|
1124
|
+
score += bodyResult.score;
|
|
1125
|
+
reasons.push(...bodyResult.reasons);
|
|
1126
|
+
}
|
|
1127
|
+
if (checkSender) {
|
|
1128
|
+
const replyTo = parsed.replyTo?.value?.[0]?.address || parsed.replyTo?.text || "";
|
|
1129
|
+
const senderResult = checkSenderPatterns(from, replyTo);
|
|
1130
|
+
score += senderResult.score;
|
|
1131
|
+
reasons.push(...senderResult.reasons);
|
|
1132
|
+
}
|
|
1133
|
+
if (checkHeaders) {
|
|
1134
|
+
const headerResult = checkHeaderAnomalies(parsed, getHeader);
|
|
1135
|
+
score += headerResult.score;
|
|
1136
|
+
reasons.push(...headerResult.reasons);
|
|
1137
|
+
}
|
|
1138
|
+
if (checkLinks) {
|
|
1139
|
+
const bodyHtml = parsed.html || parsed.text || "";
|
|
1140
|
+
const linkResult = checkSuspiciousLinks(bodyHtml);
|
|
1141
|
+
score += linkResult.score;
|
|
1142
|
+
reasons.push(...linkResult.reasons);
|
|
1143
|
+
}
|
|
1144
|
+
const isArbitrarySpam = score >= threshold;
|
|
1145
|
+
debug3(
|
|
1146
|
+
"Arbitrary check result: score=%d, threshold=%d, isArbitrary=%s, category=%s, reasons=%o",
|
|
1147
|
+
score,
|
|
1148
|
+
threshold,
|
|
1149
|
+
isArbitrarySpam,
|
|
1150
|
+
category,
|
|
1151
|
+
reasons
|
|
1152
|
+
);
|
|
1153
|
+
return {
|
|
1154
|
+
isArbitrary: isArbitrarySpam,
|
|
1155
|
+
reasons,
|
|
1156
|
+
score,
|
|
1157
|
+
category
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
function buildSessionInfo(parsed, session, getHeader) {
|
|
1161
|
+
const info = { ...session };
|
|
1162
|
+
const from = parsed.from?.value?.[0]?.address || parsed.from?.text || getHeader("from") || "";
|
|
1163
|
+
if (from && !info.originalFromAddress) {
|
|
1164
|
+
info.originalFromAddress = from.toLowerCase();
|
|
1165
|
+
const atIndex = from.indexOf("@");
|
|
1166
|
+
if (atIndex > 0) {
|
|
1167
|
+
info.originalFromAddressDomain = from.slice(atIndex + 1).toLowerCase();
|
|
1168
|
+
info.originalFromAddressRootDomain = getRootDomain(info.originalFromAddressDomain);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (!info.resolvedClientHostname) {
|
|
1172
|
+
info.resolvedClientHostname = extractClientHostname(parsed);
|
|
1173
|
+
if (info.resolvedClientHostname) {
|
|
1174
|
+
info.resolvedRootClientHostname = getRootDomain(info.resolvedClientHostname);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
info.remoteAddress ||= extractRemoteIp(parsed);
|
|
1178
|
+
return info;
|
|
1179
|
+
}
|
|
1180
|
+
function checkMicrosoftExchangeHeaders(getHeader, sessionInfo) {
|
|
1181
|
+
const result = {
|
|
1182
|
+
blocked: false,
|
|
1183
|
+
reasons: [],
|
|
1184
|
+
score: 0,
|
|
1185
|
+
category: null
|
|
1186
|
+
};
|
|
1187
|
+
const isFromMicrosoft = sessionInfo.resolvedClientHostname && sessionInfo.resolvedClientHostname.endsWith(".outbound.protection.outlook.com");
|
|
1188
|
+
if (!isFromMicrosoft) {
|
|
1189
|
+
return result;
|
|
1190
|
+
}
|
|
1191
|
+
const msAuthHeader = getHeader("x-ms-exchange-authentication-results");
|
|
1192
|
+
const forefrontHeader = getHeader("x-forefront-antispam-report");
|
|
1193
|
+
if (forefrontHeader) {
|
|
1194
|
+
const lowerForefront = forefrontHeader.toLowerCase();
|
|
1195
|
+
const sclMatch = lowerForefront.match(/scl:(\d+)/);
|
|
1196
|
+
const scl = sclMatch ? Number.parseInt(sclMatch[1], 10) : null;
|
|
1197
|
+
const sfvNotSpam = lowerForefront.includes("sfv:nspm");
|
|
1198
|
+
const microsoftSaysNotSpam = sfvNotSpam || scl !== null && scl <= 2;
|
|
1199
|
+
if (!microsoftSaysNotSpam && msAuthHeader) {
|
|
1200
|
+
const lowerMsAuth = msAuthHeader.toLowerCase();
|
|
1201
|
+
const spfPass = lowerMsAuth.includes("spf=pass");
|
|
1202
|
+
const dkimPass = lowerMsAuth.includes("dkim=pass");
|
|
1203
|
+
const dmarcPass = lowerMsAuth.includes("dmarc=pass");
|
|
1204
|
+
if (!spfPass && !dkimPass && !dmarcPass) {
|
|
1205
|
+
const spfFailed = lowerMsAuth.includes("spf=fail");
|
|
1206
|
+
const dkimFailed = lowerMsAuth.includes("dkim=fail");
|
|
1207
|
+
const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
|
|
1208
|
+
if (spfFailed || dkimFailed || dmarcFailed) {
|
|
1209
|
+
result.blocked = true;
|
|
1210
|
+
result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
|
|
1211
|
+
result.score += 10;
|
|
1212
|
+
result.category = "AUTHENTICATION_FAILURE";
|
|
1213
|
+
return result;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
for (const cat of MS_SPAM_CATEGORIES.highConfidence) {
|
|
1218
|
+
if (lowerForefront.includes(cat)) {
|
|
1219
|
+
result.blocked = true;
|
|
1220
|
+
result.reasons.push(`MS_HIGH_CONFIDENCE_THREAT: ${cat.toUpperCase()}`);
|
|
1221
|
+
result.score += 15;
|
|
1222
|
+
result.category = cat.includes("malw") ? "MALWARE" : cat.includes("phish") || cat.includes("phsh") ? "PHISHING" : "HIGH_CONFIDENCE_SPAM";
|
|
1223
|
+
return result;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
for (const cat of MS_SPAM_CATEGORIES.impersonation) {
|
|
1227
|
+
if (lowerForefront.includes(cat)) {
|
|
1228
|
+
result.blocked = true;
|
|
1229
|
+
result.reasons.push(`MS_IMPERSONATION: ${cat.toUpperCase()}`);
|
|
1230
|
+
result.score += 12;
|
|
1231
|
+
result.category = "IMPERSONATION";
|
|
1232
|
+
return result;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
for (const cat of MS_SPAM_CATEGORIES.phishingAndSpoofing) {
|
|
1236
|
+
if (lowerForefront.includes(cat)) {
|
|
1237
|
+
result.blocked = true;
|
|
1238
|
+
result.reasons.push(`MS_PHISHING_SPOOF: ${cat.toUpperCase()}`);
|
|
1239
|
+
result.score += 12;
|
|
1240
|
+
result.category = cat.includes("phsh") ? "PHISHING" : "SPOOFING";
|
|
1241
|
+
return result;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
for (const verdict of MS_SPAM_VERDICTS) {
|
|
1245
|
+
if (lowerForefront.includes(verdict)) {
|
|
1246
|
+
result.blocked = true;
|
|
1247
|
+
result.reasons.push(`MS_SPAM_VERDICT: ${verdict.toUpperCase()}`);
|
|
1248
|
+
result.score += 10;
|
|
1249
|
+
result.category = "SPAM";
|
|
1250
|
+
return result;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
for (const cat of MS_SPAM_CATEGORIES.spam) {
|
|
1254
|
+
if (lowerForefront.includes(cat)) {
|
|
1255
|
+
result.blocked = true;
|
|
1256
|
+
result.reasons.push(`MS_SPAM_CATEGORY: ${cat.toUpperCase()}`);
|
|
1257
|
+
result.score += 10;
|
|
1258
|
+
result.category = "SPAM";
|
|
1259
|
+
return result;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
if (scl !== null && scl >= 5) {
|
|
1263
|
+
result.blocked = true;
|
|
1264
|
+
result.reasons.push(`MS_HIGH_SCL: ${scl}`);
|
|
1265
|
+
result.score += 8;
|
|
1266
|
+
result.category = "SPAM";
|
|
1267
|
+
return result;
|
|
1268
|
+
}
|
|
1269
|
+
} else if (msAuthHeader) {
|
|
1270
|
+
const lowerMsAuth = msAuthHeader.toLowerCase();
|
|
1271
|
+
const spfPass = lowerMsAuth.includes("spf=pass");
|
|
1272
|
+
const dkimPass = lowerMsAuth.includes("dkim=pass");
|
|
1273
|
+
const dmarcPass = lowerMsAuth.includes("dmarc=pass");
|
|
1274
|
+
if (!spfPass && !dkimPass && !dmarcPass) {
|
|
1275
|
+
const spfFailed = lowerMsAuth.includes("spf=fail");
|
|
1276
|
+
const dkimFailed = lowerMsAuth.includes("dkim=fail");
|
|
1277
|
+
const dmarcFailed = lowerMsAuth.includes("dmarc=fail");
|
|
1278
|
+
if (spfFailed || dkimFailed || dmarcFailed) {
|
|
1279
|
+
result.blocked = true;
|
|
1280
|
+
result.reasons.push("MS_EXCHANGE_AUTH_FAILURE");
|
|
1281
|
+
result.score += 10;
|
|
1282
|
+
result.category = "AUTHENTICATION_FAILURE";
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return result;
|
|
1287
|
+
}
|
|
1288
|
+
function checkVendorSpam_(parsed, sessionInfo, getHeader, subject, from) {
|
|
1289
|
+
const result = {
|
|
1290
|
+
blocked: false,
|
|
1291
|
+
reasons: [],
|
|
1292
|
+
score: 0,
|
|
1293
|
+
category: null
|
|
1294
|
+
};
|
|
1295
|
+
const fromLower = from.toLowerCase();
|
|
1296
|
+
if (sessionInfo.originalFromAddressRootDomain === "paypal.com" && getHeader("x-email-type-id")) {
|
|
1297
|
+
const typeId = getHeader("x-email-type-id");
|
|
1298
|
+
if (PAYPAL_SPAM_TYPE_IDS.has(typeId)) {
|
|
1299
|
+
result.blocked = true;
|
|
1300
|
+
result.reasons.push(`PAYPAL_INVOICE_SPAM: ${typeId}`);
|
|
1301
|
+
result.score += 15;
|
|
1302
|
+
result.category = "VENDOR_SPAM";
|
|
1303
|
+
return result;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (sessionInfo.originalFromAddress === "invoice@authorize.net" && sessionInfo.resolvedRootClientHostname === "visa.com") {
|
|
1307
|
+
result.blocked = true;
|
|
1308
|
+
result.reasons.push("AUTHORIZE_VISA_PHISHING");
|
|
1309
|
+
result.score += 15;
|
|
1310
|
+
result.category = "PHISHING";
|
|
1311
|
+
return result;
|
|
1312
|
+
}
|
|
1313
|
+
if (fromLower.includes("amazon.co.jp") && (!sessionInfo.resolvedRootClientHostname || !sessionInfo.resolvedRootClientHostname.startsWith("amazon."))) {
|
|
1314
|
+
result.blocked = true;
|
|
1315
|
+
result.reasons.push("AMAZON_JP_IMPERSONATION");
|
|
1316
|
+
result.score += 12;
|
|
1317
|
+
result.category = "IMPERSONATION";
|
|
1318
|
+
return result;
|
|
1319
|
+
}
|
|
1320
|
+
if (subject && subject.includes("pCloud") && sessionInfo.originalFromAddressRootDomain !== "pcloud.com" && fromLower.includes("pcloud")) {
|
|
1321
|
+
result.blocked = true;
|
|
1322
|
+
result.reasons.push("PCLOUD_IMPERSONATION");
|
|
1323
|
+
result.score += 12;
|
|
1324
|
+
result.category = "IMPERSONATION";
|
|
1325
|
+
return result;
|
|
1326
|
+
}
|
|
1327
|
+
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: "))) {
|
|
1328
|
+
result.blocked = true;
|
|
1329
|
+
result.reasons.push("MS_BOUNCE_SPAM");
|
|
1330
|
+
result.score += 10;
|
|
1331
|
+
result.category = "BOUNCE_SPAM";
|
|
1332
|
+
return result;
|
|
1333
|
+
}
|
|
1334
|
+
if (sessionInfo.originalFromAddress === "postmaster@163.com" && subject && subject.includes("\u7CFB\u7EDF\u9000\u4FE1")) {
|
|
1335
|
+
result.blocked = true;
|
|
1336
|
+
result.reasons.push("163_BOUNCE_SPAM");
|
|
1337
|
+
result.score += 10;
|
|
1338
|
+
result.category = "BOUNCE_SPAM";
|
|
1339
|
+
return result;
|
|
1340
|
+
}
|
|
1341
|
+
if (sessionInfo.originalFromAddress === "dse_na4@docusign.net" && sessionInfo.spf?.domain && (sessionInfo.spf.domain.endsWith(".onmicrosoft.com") || sessionInfo.spf.domain === "onmicrosoft.com")) {
|
|
1342
|
+
result.blocked = true;
|
|
1343
|
+
result.reasons.push("DOCUSIGN_MS_SCAM");
|
|
1344
|
+
result.score += 12;
|
|
1345
|
+
result.category = "PHISHING";
|
|
1346
|
+
return result;
|
|
1347
|
+
}
|
|
1348
|
+
return result;
|
|
1349
|
+
}
|
|
1350
|
+
function checkSpoofingAttacks(parsed, sessionInfo, getHeader, subject) {
|
|
1351
|
+
const result = {
|
|
1352
|
+
blocked: false,
|
|
1353
|
+
reasons: [],
|
|
1354
|
+
score: 0,
|
|
1355
|
+
category: null
|
|
1356
|
+
};
|
|
1357
|
+
if (sessionInfo.hadAlignedAndPassingDKIM || sessionInfo.isAllowlisted) {
|
|
1358
|
+
return result;
|
|
1359
|
+
}
|
|
1360
|
+
if (sessionInfo.hasSameHostnameAsFrom) {
|
|
1361
|
+
return result;
|
|
1362
|
+
}
|
|
1363
|
+
const rcptTo = sessionInfo.envelope?.rcptTo || [];
|
|
1364
|
+
const fromRootDomain = sessionInfo.originalFromAddressRootDomain;
|
|
1365
|
+
if (!fromRootDomain || rcptTo.length === 0) {
|
|
1366
|
+
return result;
|
|
1367
|
+
}
|
|
1368
|
+
const hasSameRcptToAsFrom = rcptTo.some((to) => {
|
|
1369
|
+
if (!to.address) {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
const toRootDomain = getRootDomain(parseHostFromAddress(to.address));
|
|
1373
|
+
return toRootDomain === fromRootDomain;
|
|
1374
|
+
});
|
|
1375
|
+
if (!hasSameRcptToAsFrom) {
|
|
1376
|
+
return result;
|
|
1377
|
+
}
|
|
1378
|
+
const spfResult = sessionInfo.spfFromHeader?.status?.result;
|
|
1379
|
+
if (spfResult === "pass") {
|
|
1380
|
+
return result;
|
|
1381
|
+
}
|
|
1382
|
+
sessionInfo.isPotentialPhishing = true;
|
|
1383
|
+
const xPhpScript = getHeader("x-php-script");
|
|
1384
|
+
const xMailer = getHeader("x-mailer");
|
|
1385
|
+
if (xPhpScript) {
|
|
1386
|
+
return result;
|
|
1387
|
+
}
|
|
1388
|
+
if (xMailer) {
|
|
1389
|
+
const mailerLower = xMailer.toLowerCase();
|
|
1390
|
+
if (mailerLower.includes("php") || mailerLower.includes("drupal")) {
|
|
1391
|
+
return result;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (subject && SYSADMIN_SUBJECT_PATTERN.test(subject)) {
|
|
1395
|
+
return result;
|
|
1396
|
+
}
|
|
1397
|
+
result.blocked = true;
|
|
1398
|
+
result.reasons.push("SPOOFING_ATTACK");
|
|
1399
|
+
result.score += 12;
|
|
1400
|
+
result.category = "SPOOFING";
|
|
1401
|
+
return result;
|
|
1402
|
+
}
|
|
1403
|
+
function isAutoReply(getHeader) {
|
|
1404
|
+
const autoSubmitted = getHeader("auto-submitted");
|
|
1405
|
+
if (autoSubmitted && autoSubmitted !== "no") {
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
const autoResponseSuppress = getHeader("x-auto-response-suppress");
|
|
1409
|
+
if (autoResponseSuppress) {
|
|
1410
|
+
return true;
|
|
1411
|
+
}
|
|
1412
|
+
const precedence = getHeader("precedence");
|
|
1413
|
+
if (precedence && ["bulk", "junk", "list", "auto_reply"].includes(precedence.toLowerCase())) {
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
if (getHeader("list-unsubscribe")) {
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
return false;
|
|
1420
|
+
}
|
|
1421
|
+
function checkSubjectLine(subject) {
|
|
1422
|
+
const reasons = [];
|
|
1423
|
+
let score = 0;
|
|
1424
|
+
for (const pattern of SPAM_PATTERNS.subjectPatterns) {
|
|
1425
|
+
if (pattern.test(subject)) {
|
|
1426
|
+
const match = subject.match(pattern);
|
|
1427
|
+
reasons.push(`SUBJECT_SPAM_PATTERN: ${match[0]}`);
|
|
1428
|
+
score += 1;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const upperCount = (subject.match(/[A-Z]/g) || []).length;
|
|
1432
|
+
const letterCount = (subject.match(/[a-zA-Z]/g) || []).length;
|
|
1433
|
+
if (letterCount > 10 && upperCount / letterCount > 0.7) {
|
|
1434
|
+
reasons.push("SUBJECT_ALL_CAPS");
|
|
1435
|
+
score += 2;
|
|
1436
|
+
}
|
|
1437
|
+
const punctCount = (subject.match(/[!?$]/g) || []).length;
|
|
1438
|
+
if (punctCount >= 3) {
|
|
1439
|
+
reasons.push("SUBJECT_EXCESSIVE_PUNCTUATION");
|
|
1440
|
+
score += 1;
|
|
1441
|
+
}
|
|
1442
|
+
if (/^(re|fw|fwd):/i.test(subject) && subject.length < 20) {
|
|
1443
|
+
reasons.push("SUBJECT_FAKE_REPLY");
|
|
1444
|
+
score += 1;
|
|
1445
|
+
}
|
|
1446
|
+
return { score, reasons };
|
|
1447
|
+
}
|
|
1448
|
+
function checkBodyContent(text, html) {
|
|
1449
|
+
const reasons = [];
|
|
1450
|
+
let score = 0;
|
|
1451
|
+
const content = text || html || "";
|
|
1452
|
+
const contentLower = content.toLowerCase();
|
|
1453
|
+
for (const pattern of SPAM_PATTERNS.bodyPatterns) {
|
|
1454
|
+
if (pattern.test(content)) {
|
|
1455
|
+
const match = content.match(pattern);
|
|
1456
|
+
reasons.push(`BODY_SPAM_PATTERN: ${match[0].slice(0, 50)}`);
|
|
1457
|
+
score += 1;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
for (const [keyword, weight] of SPAM_KEYWORDS) {
|
|
1461
|
+
if (contentLower.includes(keyword.toLowerCase())) {
|
|
1462
|
+
reasons.push(`SPAM_KEYWORD: ${keyword}`);
|
|
1463
|
+
score += weight;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (html) {
|
|
1467
|
+
if (/color:\s*#fff|color:\s*white|font-size:\s*[01]px/i.test(html)) {
|
|
1468
|
+
reasons.push("HIDDEN_TEXT");
|
|
1469
|
+
score += 3;
|
|
1470
|
+
}
|
|
1471
|
+
const imgCount = (html.match(/<img/gi) || []).length;
|
|
1472
|
+
const textLength = (text || "").length;
|
|
1473
|
+
if (imgCount > 5 && textLength < 100) {
|
|
1474
|
+
reasons.push("IMAGE_HEAVY_LOW_TEXT");
|
|
1475
|
+
score += 2;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (/data:image\/[^;]+;base64,/i.test(html || "")) {
|
|
1479
|
+
reasons.push("BASE64_IMAGES");
|
|
1480
|
+
score += 1;
|
|
1481
|
+
}
|
|
1482
|
+
const shortenerPatterns = /\b(bit\.ly|tinyurl|goo\.gl|t\.co|ow\.ly|is\.gd|buff\.ly|adf\.ly|j\.mp)\b/i;
|
|
1483
|
+
if (shortenerPatterns.test(content)) {
|
|
1484
|
+
reasons.push("URL_SHORTENER");
|
|
1485
|
+
score += 2;
|
|
1486
|
+
}
|
|
1487
|
+
return { score, reasons };
|
|
1488
|
+
}
|
|
1489
|
+
function checkSenderPatterns(from, replyTo) {
|
|
1490
|
+
const reasons = [];
|
|
1491
|
+
let score = 0;
|
|
1492
|
+
if (!from) {
|
|
1493
|
+
reasons.push("MISSING_FROM");
|
|
1494
|
+
score += 2;
|
|
1495
|
+
return { score, reasons };
|
|
1496
|
+
}
|
|
1497
|
+
for (const pattern of SPAM_PATTERNS.senderPatterns) {
|
|
1498
|
+
if (pattern.test(from)) {
|
|
1499
|
+
reasons.push("SUSPICIOUS_SENDER_PATTERN");
|
|
1500
|
+
score += 2;
|
|
1501
|
+
break;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
const tldMatch = from.match(/@[^.]+\.([a-z]+)$/i);
|
|
1505
|
+
if (tldMatch && SUSPICIOUS_TLDS.has(tldMatch[1].toLowerCase())) {
|
|
1506
|
+
reasons.push(`SUSPICIOUS_TLD: ${tldMatch[1]}`);
|
|
1507
|
+
score += 2;
|
|
1508
|
+
}
|
|
1509
|
+
if (replyTo && from) {
|
|
1510
|
+
const fromDomain = from.split("@")[1]?.toLowerCase();
|
|
1511
|
+
const replyDomain = replyTo.split("@")[1]?.toLowerCase();
|
|
1512
|
+
if (fromDomain && replyDomain && fromDomain !== replyDomain) {
|
|
1513
|
+
reasons.push("FROM_REPLY_TO_MISMATCH");
|
|
1514
|
+
score += 2;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const spoofPatterns = /^(paypal|amazon|apple|microsoft|google|bank|security)/i;
|
|
1518
|
+
if (spoofPatterns.test(from) && !/@(paypal|amazon|apple|microsoft|google)\.com$/i.test(from)) {
|
|
1519
|
+
reasons.push("DISPLAY_NAME_SPOOFING");
|
|
1520
|
+
score += 3;
|
|
1521
|
+
}
|
|
1522
|
+
return { score, reasons };
|
|
1523
|
+
}
|
|
1524
|
+
function checkHeaderAnomalies(parsed, getHeader) {
|
|
1525
|
+
const reasons = [];
|
|
1526
|
+
let score = 0;
|
|
1527
|
+
if (!parsed.messageId && !getHeader("message-id")) {
|
|
1528
|
+
reasons.push("MISSING_MESSAGE_ID");
|
|
1529
|
+
score += 1;
|
|
1530
|
+
}
|
|
1531
|
+
if (parsed.date) {
|
|
1532
|
+
const messageDate = new Date(parsed.date);
|
|
1533
|
+
const now = /* @__PURE__ */ new Date();
|
|
1534
|
+
if (messageDate > now) {
|
|
1535
|
+
const hoursDiff = (messageDate - now) / (1e3 * 60 * 60);
|
|
1536
|
+
if (hoursDiff > 24) {
|
|
1537
|
+
reasons.push("FUTURE_DATE");
|
|
1538
|
+
score += 2;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const daysDiff = (now - messageDate) / (1e3 * 60 * 60 * 24);
|
|
1542
|
+
if (daysDiff > 365) {
|
|
1543
|
+
reasons.push("VERY_OLD_DATE");
|
|
1544
|
+
score += 1;
|
|
1545
|
+
}
|
|
1546
|
+
} else {
|
|
1547
|
+
reasons.push("MISSING_DATE");
|
|
1548
|
+
score += 1;
|
|
1549
|
+
}
|
|
1550
|
+
const xMailer = getHeader("x-mailer") || "";
|
|
1551
|
+
if (xMailer) {
|
|
1552
|
+
const suspiciousMailers = /mass mail|bulk mail|email blast/i;
|
|
1553
|
+
if (suspiciousMailers.test(xMailer)) {
|
|
1554
|
+
reasons.push("SUSPICIOUS_MAILER");
|
|
1555
|
+
score += 1;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const mimeVersion = getHeader("mime-version");
|
|
1559
|
+
if (!mimeVersion && (parsed.html || parsed.attachments?.length > 0)) {
|
|
1560
|
+
reasons.push("MISSING_MIME_VERSION");
|
|
1561
|
+
score += 1;
|
|
1562
|
+
}
|
|
1563
|
+
const toCount = parsed.to?.value?.length || 0;
|
|
1564
|
+
const ccCount = parsed.cc?.value?.length || 0;
|
|
1565
|
+
if (toCount + ccCount > 50) {
|
|
1566
|
+
reasons.push("EXCESSIVE_RECIPIENTS");
|
|
1567
|
+
score += 2;
|
|
1568
|
+
}
|
|
1569
|
+
return { score, reasons };
|
|
1570
|
+
}
|
|
1571
|
+
function checkSuspiciousLinks(content) {
|
|
1572
|
+
const reasons = [];
|
|
1573
|
+
let score = 0;
|
|
1574
|
+
const urlPattern = /https?:\/\/[^\s<>"']+/gi;
|
|
1575
|
+
const urls = content.match(urlPattern) || [];
|
|
1576
|
+
if (urls.length === 0) {
|
|
1577
|
+
return { score, reasons };
|
|
1578
|
+
}
|
|
1579
|
+
const suspiciousUrls = /* @__PURE__ */ new Set();
|
|
1580
|
+
for (const url of urls) {
|
|
1581
|
+
try {
|
|
1582
|
+
const parsed = new URL(url);
|
|
1583
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
1584
|
+
if (/^(?:\d+\.){3}\d+$/.test(hostname)) {
|
|
1585
|
+
suspiciousUrls.add("IP_ADDRESS_URL");
|
|
1586
|
+
}
|
|
1587
|
+
const tld = hostname.split(".").pop();
|
|
1588
|
+
if (SUSPICIOUS_TLDS.has(tld)) {
|
|
1589
|
+
suspiciousUrls.add(`SUSPICIOUS_URL_TLD: ${tld}`);
|
|
1590
|
+
}
|
|
1591
|
+
if (parsed.port && !["80", "443", ""].includes(parsed.port)) {
|
|
1592
|
+
suspiciousUrls.add("URL_WITH_PORT");
|
|
1593
|
+
}
|
|
1594
|
+
if (url.length > 200) {
|
|
1595
|
+
suspiciousUrls.add("VERY_LONG_URL");
|
|
1596
|
+
}
|
|
1597
|
+
const subdomainCount = hostname.split(".").length - 2;
|
|
1598
|
+
if (subdomainCount > 3) {
|
|
1599
|
+
suspiciousUrls.add("EXCESSIVE_SUBDOMAINS");
|
|
1600
|
+
}
|
|
1601
|
+
if (/%[\da-f]{2}/i.test(url) && /%[\da-f]{2}.*%[\da-f]{2}/i.test(url)) {
|
|
1602
|
+
suspiciousUrls.add("URL_OBFUSCATION");
|
|
1603
|
+
}
|
|
1604
|
+
} catch {
|
|
1605
|
+
suspiciousUrls.add("INVALID_URL");
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
for (const reason of suspiciousUrls) {
|
|
1609
|
+
reasons.push(reason);
|
|
1610
|
+
score += 1;
|
|
1611
|
+
}
|
|
1612
|
+
const linkPattern = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi;
|
|
1613
|
+
let match;
|
|
1614
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
1615
|
+
const href = match[1];
|
|
1616
|
+
const text = match[2];
|
|
1617
|
+
if (/^https?:\/\//i.test(text)) {
|
|
1618
|
+
try {
|
|
1619
|
+
const textUrl = new URL(text);
|
|
1620
|
+
const hrefUrl = new URL(href);
|
|
1621
|
+
if (textUrl.hostname.toLowerCase() !== hrefUrl.hostname.toLowerCase()) {
|
|
1622
|
+
reasons.push("LINK_TEXT_URL_MISMATCH");
|
|
1623
|
+
score += 3;
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1626
|
+
} catch {
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return { score, reasons };
|
|
1631
|
+
}
|
|
1632
|
+
function getRootDomain(hostname) {
|
|
1633
|
+
if (!hostname) {
|
|
1634
|
+
return "";
|
|
1635
|
+
}
|
|
1636
|
+
const parts = hostname.toLowerCase().split(".");
|
|
1637
|
+
if (parts.length <= 2) {
|
|
1638
|
+
return hostname.toLowerCase();
|
|
1639
|
+
}
|
|
1640
|
+
const multiPartTlds = ["co.uk", "com.au", "co.nz", "co.jp", "com.br", "co.in"];
|
|
1641
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
1642
|
+
if (multiPartTlds.includes(lastTwo)) {
|
|
1643
|
+
return parts.slice(-3).join(".");
|
|
1644
|
+
}
|
|
1645
|
+
return parts.slice(-2).join(".");
|
|
1646
|
+
}
|
|
1647
|
+
function parseHostFromAddress(address) {
|
|
1648
|
+
if (!address) {
|
|
1649
|
+
return "";
|
|
1650
|
+
}
|
|
1651
|
+
const atIndex = address.indexOf("@");
|
|
1652
|
+
if (atIndex === -1) {
|
|
1653
|
+
return "";
|
|
1654
|
+
}
|
|
1655
|
+
return address.slice(atIndex + 1).toLowerCase();
|
|
1656
|
+
}
|
|
1657
|
+
function extractClientHostname(parsed) {
|
|
1658
|
+
let receivedHeaders = null;
|
|
1659
|
+
if (parsed.headers?.get) {
|
|
1660
|
+
receivedHeaders = parsed.headers.get("received");
|
|
1661
|
+
} else if (parsed.headerLines) {
|
|
1662
|
+
const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
|
|
1663
|
+
receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
|
|
1664
|
+
}
|
|
1665
|
+
if (!receivedHeaders) {
|
|
1666
|
+
return null;
|
|
1667
|
+
}
|
|
1668
|
+
const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
|
|
1669
|
+
if (!received) {
|
|
1670
|
+
return null;
|
|
1671
|
+
}
|
|
1672
|
+
const fromMatch = received.match(/from\s+([^\s(]+)/i);
|
|
1673
|
+
if (fromMatch) {
|
|
1674
|
+
return fromMatch[1].toLowerCase();
|
|
1675
|
+
}
|
|
1676
|
+
return null;
|
|
1677
|
+
}
|
|
1678
|
+
function extractRemoteIp(parsed) {
|
|
1679
|
+
let receivedHeaders = null;
|
|
1680
|
+
if (parsed.headers?.get) {
|
|
1681
|
+
receivedHeaders = parsed.headers.get("received");
|
|
1682
|
+
} else if (parsed.headerLines) {
|
|
1683
|
+
const headers = parsed.headerLines.filter((h) => h.key.toLowerCase() === "received");
|
|
1684
|
+
receivedHeaders = headers.map((h) => h.line?.split(":").slice(1).join(":").trim());
|
|
1685
|
+
}
|
|
1686
|
+
if (!receivedHeaders) {
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
const received = Array.isArray(receivedHeaders) ? receivedHeaders[0] : receivedHeaders;
|
|
1690
|
+
if (!received) {
|
|
1691
|
+
return null;
|
|
1692
|
+
}
|
|
1693
|
+
const ipv4Match = received.match(/\[((?:\d+\.){3}\d+)]/);
|
|
1694
|
+
if (ipv4Match) {
|
|
1695
|
+
return ipv4Match[1];
|
|
1696
|
+
}
|
|
1697
|
+
const ipv6Match = received.match(/\[([a-f\d:]+)]/i);
|
|
1698
|
+
if (ipv6Match) {
|
|
1699
|
+
return ipv6Match[1];
|
|
1700
|
+
}
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// src/get-attributes.js
|
|
1705
|
+
import { debuglog as debuglog4 } from "node:util";
|
|
1706
|
+
var debug4 = debuglog4("spamscanner:attributes");
|
|
1707
|
+
function checkSRS(address) {
|
|
1708
|
+
if (!address) {
|
|
1709
|
+
return "";
|
|
1710
|
+
}
|
|
1711
|
+
const srs0Match = address.match(/^srs0=[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
|
|
1712
|
+
if (srs0Match) {
|
|
1713
|
+
return `${srs0Match[3]}@${srs0Match[2]}`;
|
|
1714
|
+
}
|
|
1715
|
+
const srs1Match = address.match(/^srs1=[^=]+=[^=]+==[^=]+=([^=]+)=([^=]+)=([^@]+)@/i);
|
|
1716
|
+
if (srs1Match) {
|
|
1717
|
+
return `${srs1Match[3]}@${srs1Match[2]}`;
|
|
1718
|
+
}
|
|
1719
|
+
return address;
|
|
1720
|
+
}
|
|
1721
|
+
function parseHostFromDomainOrAddress(addressOrDomain) {
|
|
1722
|
+
if (!addressOrDomain) {
|
|
1723
|
+
return "";
|
|
1724
|
+
}
|
|
1725
|
+
const atIndex = addressOrDomain.indexOf("@");
|
|
1726
|
+
if (atIndex !== -1) {
|
|
1727
|
+
return addressOrDomain.slice(atIndex + 1).toLowerCase();
|
|
1728
|
+
}
|
|
1729
|
+
return addressOrDomain.toLowerCase();
|
|
1730
|
+
}
|
|
1731
|
+
function parseRootDomain(hostname) {
|
|
1732
|
+
if (!hostname) {
|
|
1733
|
+
return "";
|
|
1734
|
+
}
|
|
1735
|
+
const parts = hostname.toLowerCase().split(".");
|
|
1736
|
+
if (parts.length <= 2) {
|
|
1737
|
+
return hostname.toLowerCase();
|
|
1738
|
+
}
|
|
1739
|
+
const multiPartTlds = /* @__PURE__ */ new Set([
|
|
1740
|
+
"co.uk",
|
|
1741
|
+
"com.au",
|
|
1742
|
+
"co.nz",
|
|
1743
|
+
"co.jp",
|
|
1744
|
+
"com.br",
|
|
1745
|
+
"co.in",
|
|
1746
|
+
"org.uk",
|
|
1747
|
+
"net.au",
|
|
1748
|
+
"com.mx",
|
|
1749
|
+
"com.cn",
|
|
1750
|
+
"com.tw",
|
|
1751
|
+
"com.hk",
|
|
1752
|
+
"co.za",
|
|
1753
|
+
"com.sg"
|
|
1754
|
+
]);
|
|
1755
|
+
const lastTwo = parts.slice(-2).join(".");
|
|
1756
|
+
if (multiPartTlds.has(lastTwo)) {
|
|
1757
|
+
return parts.slice(-3).join(".");
|
|
1758
|
+
}
|
|
1759
|
+
return parts.slice(-2).join(".");
|
|
1760
|
+
}
|
|
1761
|
+
function parseAddresses(headerValue) {
|
|
1762
|
+
if (!headerValue) {
|
|
1763
|
+
return [];
|
|
1764
|
+
}
|
|
1765
|
+
if (Array.isArray(headerValue)) {
|
|
1766
|
+
return headerValue.flatMap((item) => {
|
|
1767
|
+
if (typeof item === "string") {
|
|
1768
|
+
return item;
|
|
1769
|
+
}
|
|
1770
|
+
if (item.address) {
|
|
1771
|
+
return item.address;
|
|
1772
|
+
}
|
|
1773
|
+
if (item.value && Array.isArray(item.value)) {
|
|
1774
|
+
return item.value.map((v) => v.address).filter(Boolean);
|
|
1775
|
+
}
|
|
1776
|
+
return null;
|
|
1777
|
+
}).filter(Boolean);
|
|
1778
|
+
}
|
|
1779
|
+
if (headerValue.value && Array.isArray(headerValue.value)) {
|
|
1780
|
+
return headerValue.value.map((v) => v.address).filter(Boolean);
|
|
1781
|
+
}
|
|
1782
|
+
if (typeof headerValue === "string") {
|
|
1783
|
+
const emailPattern = /[\w.+-]+@[\w.-]+\.[a-z]{2,}/gi;
|
|
1784
|
+
return headerValue.match(emailPattern) || [];
|
|
1785
|
+
}
|
|
1786
|
+
return [];
|
|
1787
|
+
}
|
|
1788
|
+
function getHeaders(headers, name) {
|
|
1789
|
+
if (!headers) {
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
if (headers.get) {
|
|
1793
|
+
const value = headers.get(name);
|
|
1794
|
+
if (value) {
|
|
1795
|
+
if (typeof value === "string") {
|
|
1796
|
+
return value;
|
|
1797
|
+
}
|
|
1798
|
+
if (value.text) {
|
|
1799
|
+
return value.text;
|
|
1800
|
+
}
|
|
1801
|
+
if (value.value && Array.isArray(value.value)) {
|
|
1802
|
+
return value.value.map((v) => v.address || v.text || v).join(", ");
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
return null;
|
|
1806
|
+
}
|
|
1807
|
+
if (headers.headerLines) {
|
|
1808
|
+
const header = headers.headerLines.find((h) => h.key.toLowerCase() === name.toLowerCase());
|
|
1809
|
+
if (header) {
|
|
1810
|
+
return header.line?.split(":").slice(1).join(":").trim();
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
if (typeof headers === "object") {
|
|
1814
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase());
|
|
1815
|
+
if (key) {
|
|
1816
|
+
const value = headers[key];
|
|
1817
|
+
if (typeof value === "string") {
|
|
1818
|
+
return value;
|
|
1819
|
+
}
|
|
1820
|
+
if (Array.isArray(value)) {
|
|
1821
|
+
return value[0];
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
async function getAttributes(parsed, session = {}, options = {}) {
|
|
1828
|
+
const { isAligned = false, authResults = null } = options;
|
|
1829
|
+
const headers = parsed.headers || parsed;
|
|
1830
|
+
const replyToHeader = getHeaders(headers, "reply-to");
|
|
1831
|
+
const replyToAddresses = parseAddresses(parsed.replyTo || (replyToHeader ? { value: [{ address: replyToHeader }] } : null));
|
|
1832
|
+
const array = [
|
|
1833
|
+
session.resolvedClientHostname,
|
|
1834
|
+
session.resolvedRootClientHostname,
|
|
1835
|
+
session.remoteAddress
|
|
1836
|
+
];
|
|
1837
|
+
const from = [
|
|
1838
|
+
session.originalFromAddress,
|
|
1839
|
+
session.originalFromAddressDomain,
|
|
1840
|
+
session.originalFromAddressRootDomain
|
|
1841
|
+
];
|
|
1842
|
+
const replyTo = [];
|
|
1843
|
+
for (const addr of replyToAddresses) {
|
|
1844
|
+
const checked = checkSRS(addr);
|
|
1845
|
+
replyTo.push(
|
|
1846
|
+
checked.toLowerCase(),
|
|
1847
|
+
parseHostFromDomainOrAddress(checked),
|
|
1848
|
+
parseRootDomain(parseHostFromDomainOrAddress(checked))
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
const mailFrom = [];
|
|
1852
|
+
const mailFromAddress = session.envelope?.mailFrom?.address;
|
|
1853
|
+
if (mailFromAddress) {
|
|
1854
|
+
const checked = checkSRS(mailFromAddress);
|
|
1855
|
+
mailFrom.push(
|
|
1856
|
+
checked.toLowerCase(),
|
|
1857
|
+
parseHostFromDomainOrAddress(checked),
|
|
1858
|
+
parseRootDomain(parseHostFromDomainOrAddress(checked))
|
|
1859
|
+
);
|
|
1860
|
+
}
|
|
1861
|
+
if (isAligned) {
|
|
1862
|
+
const signingDomains = session.signingDomains || /* @__PURE__ */ new Set();
|
|
1863
|
+
const spfResult = session.spfFromHeader?.status?.result;
|
|
1864
|
+
const fromHasSpfPass = spfResult === "pass";
|
|
1865
|
+
const fromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(session.originalFromAddressDomain) || signingDomains.has(session.originalFromAddressRootDomain));
|
|
1866
|
+
if (fromHasSpfPass || fromHasDkimAlignment) {
|
|
1867
|
+
array.push(...from);
|
|
1868
|
+
}
|
|
1869
|
+
let hasAlignedReplyTo = false;
|
|
1870
|
+
for (const addr of replyToAddresses) {
|
|
1871
|
+
const checked = checkSRS(addr);
|
|
1872
|
+
const domain = parseHostFromDomainOrAddress(checked);
|
|
1873
|
+
const rootDomain = parseRootDomain(domain);
|
|
1874
|
+
if (signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain))) {
|
|
1875
|
+
hasAlignedReplyTo = true;
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
1878
|
+
if (authResults?.spf) {
|
|
1879
|
+
const spfForReplyTo = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
|
|
1880
|
+
if (spfForReplyTo?.result === "pass") {
|
|
1881
|
+
hasAlignedReplyTo = true;
|
|
1882
|
+
break;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if (hasAlignedReplyTo) {
|
|
1887
|
+
array.push(...replyTo);
|
|
1888
|
+
}
|
|
1889
|
+
if (mailFromAddress) {
|
|
1890
|
+
const checked = checkSRS(mailFromAddress);
|
|
1891
|
+
const domain = parseHostFromDomainOrAddress(checked);
|
|
1892
|
+
const rootDomain = parseRootDomain(domain);
|
|
1893
|
+
const mailFromHasDkimAlignment = signingDomains.size > 0 && (signingDomains.has(domain) || signingDomains.has(rootDomain));
|
|
1894
|
+
let mailFromHasSpfPass = false;
|
|
1895
|
+
if (authResults?.spf) {
|
|
1896
|
+
const spfForMailFrom = authResults.spf.find((r) => r.domain === domain || r.domain === rootDomain);
|
|
1897
|
+
mailFromHasSpfPass = spfForMailFrom?.result === "pass";
|
|
1898
|
+
}
|
|
1899
|
+
if (mailFromHasDkimAlignment || mailFromHasSpfPass) {
|
|
1900
|
+
array.push(...mailFrom);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
} else {
|
|
1904
|
+
array.push(...from, ...replyTo, ...mailFrom);
|
|
1905
|
+
}
|
|
1906
|
+
const normalized = array.filter((string_) => typeof string_ === "string" && string_.length > 0).map((string_) => {
|
|
1907
|
+
try {
|
|
1908
|
+
return string_.toLowerCase().trim();
|
|
1909
|
+
} catch {
|
|
1910
|
+
return string_.toLowerCase().trim();
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
const unique = [...new Set(normalized)];
|
|
1914
|
+
debug4("Extracted %d unique attributes (isAligned=%s): %o", unique.length, isAligned, unique);
|
|
1915
|
+
return unique;
|
|
1916
|
+
}
|
|
1917
|
+
function buildSessionFromParsed(parsed, existingSession = {}) {
|
|
1918
|
+
const session = { ...existingSession };
|
|
1919
|
+
const headers = parsed.headers || parsed;
|
|
1920
|
+
const fromHeader = getHeaders(headers, "from");
|
|
1921
|
+
const fromAddresses = parseAddresses(parsed.from || fromHeader);
|
|
1922
|
+
const fromAddress = fromAddresses[0];
|
|
1923
|
+
if (fromAddress && !session.originalFromAddress) {
|
|
1924
|
+
session.originalFromAddress = checkSRS(fromAddress).toLowerCase();
|
|
1925
|
+
session.originalFromAddressDomain = parseHostFromDomainOrAddress(session.originalFromAddress);
|
|
1926
|
+
session.originalFromAddressRootDomain = parseRootDomain(session.originalFromAddressDomain);
|
|
1927
|
+
}
|
|
1928
|
+
if (!session.resolvedClientHostname) {
|
|
1929
|
+
const receivedHeader = getHeaders(headers, "received");
|
|
1930
|
+
if (receivedHeader) {
|
|
1931
|
+
const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
|
|
1932
|
+
const fromMatch = received?.match(/from\s+([^\s(]+)/i);
|
|
1933
|
+
if (fromMatch) {
|
|
1934
|
+
session.resolvedClientHostname = fromMatch[1].toLowerCase();
|
|
1935
|
+
session.resolvedRootClientHostname = parseRootDomain(session.resolvedClientHostname);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
if (!session.remoteAddress) {
|
|
1940
|
+
const receivedHeader = getHeaders(headers, "received");
|
|
1941
|
+
if (receivedHeader) {
|
|
1942
|
+
const received = Array.isArray(receivedHeader) ? receivedHeader[0] : receivedHeader;
|
|
1943
|
+
const ipv4Match = received?.match(/\[((?:\d+\.){3}\d+)]/);
|
|
1944
|
+
if (ipv4Match) {
|
|
1945
|
+
session.remoteAddress = ipv4Match[1];
|
|
1946
|
+
} else {
|
|
1947
|
+
const ipv6Match = received?.match(/\[([a-f\d:]+)]/i);
|
|
1948
|
+
if (ipv6Match) {
|
|
1949
|
+
session.remoteAddress = ipv6Match[1];
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
if (!session.envelope) {
|
|
1955
|
+
session.envelope = {
|
|
1956
|
+
mailFrom: { address: session.originalFromAddress || "" },
|
|
1957
|
+
rcptTo: []
|
|
1958
|
+
};
|
|
1959
|
+
const toAddresses = parseAddresses(parsed.to || getHeaders(headers, "to"));
|
|
1960
|
+
const ccAddresses = parseAddresses(parsed.cc || getHeaders(headers, "cc"));
|
|
1961
|
+
for (const addr of [...toAddresses, ...ccAddresses]) {
|
|
1962
|
+
if (addr) {
|
|
1963
|
+
session.envelope.rcptTo.push({ address: addr });
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
return session;
|
|
1968
|
+
}
|
|
1969
|
+
async function extractAttributes(parsed, options = {}) {
|
|
1970
|
+
const { isAligned = false, senderIp, senderHostname, authResults } = options;
|
|
1971
|
+
const session = buildSessionFromParsed(parsed, {
|
|
1972
|
+
remoteAddress: senderIp,
|
|
1973
|
+
resolvedClientHostname: senderHostname,
|
|
1974
|
+
resolvedRootClientHostname: senderHostname ? parseRootDomain(senderHostname) : void 0
|
|
1975
|
+
});
|
|
1976
|
+
if (authResults?.dkim) {
|
|
1977
|
+
session.signingDomains = /* @__PURE__ */ new Set();
|
|
1978
|
+
for (const dkimResult of authResults.dkim) {
|
|
1979
|
+
if (dkimResult.result === "pass" && dkimResult.domain) {
|
|
1980
|
+
session.signingDomains.add(dkimResult.domain);
|
|
1981
|
+
session.signingDomains.add(parseRootDomain(dkimResult.domain));
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
session.hadAlignedAndPassingDKIM = session.signingDomains.has(session.originalFromAddressDomain) || session.signingDomains.has(session.originalFromAddressRootDomain);
|
|
1985
|
+
}
|
|
1986
|
+
if (authResults?.spf) {
|
|
1987
|
+
const spfForFrom = authResults.spf.find((r) => r.domain === session.originalFromAddressDomain || r.domain === session.originalFromAddressRootDomain);
|
|
1988
|
+
if (spfForFrom) {
|
|
1989
|
+
session.spfFromHeader = {
|
|
1990
|
+
status: { result: spfForFrom.result }
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
const attributes = await getAttributes(parsed, session, { isAligned, authResults });
|
|
1995
|
+
return { attributes, session };
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// src/index.js
|
|
1999
|
+
var __filename = import.meta.url ? fileURLToPath(import.meta.url) : "";
|
|
2000
|
+
var __dirname = __filename ? path.dirname(__filename) : process.cwd();
|
|
2001
|
+
var findPackageRoot = (startDir) => {
|
|
2002
|
+
let dir = startDir;
|
|
2003
|
+
while (dir !== path.dirname(dir)) {
|
|
2004
|
+
if (fs.existsSync(path.join(dir, "package.json"))) {
|
|
2005
|
+
return dir;
|
|
2006
|
+
}
|
|
2007
|
+
dir = path.dirname(dir);
|
|
2008
|
+
}
|
|
2009
|
+
return startDir;
|
|
2010
|
+
};
|
|
2011
|
+
var packageRoot = findPackageRoot(__dirname);
|
|
2012
|
+
var executablesData = JSON.parse(fs.readFileSync(path.join(packageRoot, "executables.json"), "utf8"));
|
|
561
2013
|
var EXECUTABLES = new Set(executablesData);
|
|
562
2014
|
var getReplacements = async () => {
|
|
563
2015
|
const { default: replacements2 } = await Promise.resolve().then(() => (init_replacements(), replacements_exports));
|
|
@@ -567,7 +2019,7 @@ var getClassifier = async () => {
|
|
|
567
2019
|
const { default: classifier2 } = await Promise.resolve().then(() => (init_get_classifier(), get_classifier_exports));
|
|
568
2020
|
return classifier2;
|
|
569
2021
|
};
|
|
570
|
-
var
|
|
2022
|
+
var debug7 = debuglog7("spamscanner");
|
|
571
2023
|
var GENERIC_TOKENIZER = /[^a-zá-úÁ-Úà-úÀ-Úñü\dа-яёæøåàáảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđäöëïîûœçążśźęćńł-]+/i;
|
|
572
2024
|
var converter = new AFHConvert();
|
|
573
2025
|
var chineseTokenizer = { tokenize: (text) => text.split(/\s+/) };
|
|
@@ -653,6 +2105,41 @@ var SpamScanner = class {
|
|
|
653
2105
|
supportedLanguages: ["en"],
|
|
654
2106
|
enableMixedLanguageDetection: false,
|
|
655
2107
|
enableAdvancedPatternRecognition: true,
|
|
2108
|
+
// Authentication options (mailauth)
|
|
2109
|
+
enableAuthentication: false,
|
|
2110
|
+
authOptions: {
|
|
2111
|
+
ip: null,
|
|
2112
|
+
// Remote IP address (required for auth)
|
|
2113
|
+
helo: null,
|
|
2114
|
+
// HELO/EHLO hostname
|
|
2115
|
+
mta: "spamscanner",
|
|
2116
|
+
// MTA hostname
|
|
2117
|
+
sender: null,
|
|
2118
|
+
// Envelope sender (MAIL FROM)
|
|
2119
|
+
timeout: 1e4
|
|
2120
|
+
// DNS lookup timeout
|
|
2121
|
+
},
|
|
2122
|
+
authScoreWeights: {
|
|
2123
|
+
dkimPass: -2,
|
|
2124
|
+
dkimFail: 3,
|
|
2125
|
+
spfPass: -1,
|
|
2126
|
+
spfFail: 2,
|
|
2127
|
+
spfSoftfail: 1,
|
|
2128
|
+
dmarcPass: -2,
|
|
2129
|
+
dmarcFail: 4,
|
|
2130
|
+
arcPass: -1,
|
|
2131
|
+
arcFail: 1
|
|
2132
|
+
},
|
|
2133
|
+
// Reputation API options (Forward Email)
|
|
2134
|
+
enableReputation: false,
|
|
2135
|
+
reputationOptions: {
|
|
2136
|
+
apiUrl: "https://api.forwardemail.net/v1/reputation",
|
|
2137
|
+
timeout: 1e4,
|
|
2138
|
+
onlyAligned: true
|
|
2139
|
+
},
|
|
2140
|
+
// Arbitrary spam detection options
|
|
2141
|
+
enableArbitraryDetection: true,
|
|
2142
|
+
arbitraryThreshold: 5,
|
|
656
2143
|
// Existing options
|
|
657
2144
|
debug: false,
|
|
658
2145
|
logger: console,
|
|
@@ -700,7 +2187,7 @@ var SpamScanner = class {
|
|
|
700
2187
|
return Array.isArray(tokens) ? tokens : [];
|
|
701
2188
|
};
|
|
702
2189
|
} catch (error) {
|
|
703
|
-
|
|
2190
|
+
debug7("Failed to initialize classifier:", error);
|
|
704
2191
|
this.classifier = new NaiveBayes2();
|
|
705
2192
|
}
|
|
706
2193
|
}
|
|
@@ -719,7 +2206,7 @@ var SpamScanner = class {
|
|
|
719
2206
|
throw new Error("Invalid replacements format");
|
|
720
2207
|
}
|
|
721
2208
|
} catch (error) {
|
|
722
|
-
|
|
2209
|
+
debug7("Failed to initialize replacements:", error);
|
|
723
2210
|
this.replacements = /* @__PURE__ */ new Map();
|
|
724
2211
|
const basicReplacements = {
|
|
725
2212
|
u: "you",
|
|
@@ -747,7 +2234,7 @@ var SpamScanner = class {
|
|
|
747
2234
|
try {
|
|
748
2235
|
this.clamscan = await new ClamScan().init(this.config.clamscan);
|
|
749
2236
|
} catch (error) {
|
|
750
|
-
|
|
2237
|
+
debug7("ClamScan initialization failed:", error);
|
|
751
2238
|
return [];
|
|
752
2239
|
}
|
|
753
2240
|
}
|
|
@@ -771,7 +2258,7 @@ var SpamScanner = class {
|
|
|
771
2258
|
}
|
|
772
2259
|
}
|
|
773
2260
|
} catch (error) {
|
|
774
|
-
|
|
2261
|
+
debug7("Virus scan error:", error);
|
|
775
2262
|
}
|
|
776
2263
|
}
|
|
777
2264
|
return results;
|
|
@@ -894,7 +2381,7 @@ var SpamScanner = class {
|
|
|
894
2381
|
});
|
|
895
2382
|
}
|
|
896
2383
|
} catch (error) {
|
|
897
|
-
|
|
2384
|
+
debug7("PDF JavaScript detection error:", error);
|
|
898
2385
|
}
|
|
899
2386
|
}
|
|
900
2387
|
}
|
|
@@ -1137,7 +2624,7 @@ var SpamScanner = class {
|
|
|
1137
2624
|
isPrivate: parsed.isPrivate
|
|
1138
2625
|
};
|
|
1139
2626
|
} catch (error) {
|
|
1140
|
-
|
|
2627
|
+
debug7("tldts parsing error:", error);
|
|
1141
2628
|
return null;
|
|
1142
2629
|
}
|
|
1143
2630
|
}
|
|
@@ -1229,13 +2716,39 @@ var SpamScanner = class {
|
|
|
1229
2716
|
}
|
|
1230
2717
|
return text;
|
|
1231
2718
|
}
|
|
1232
|
-
// Main scan method - enhanced with performance metrics and
|
|
1233
|
-
async scan(source) {
|
|
2719
|
+
// Main scan method - enhanced with performance metrics, auth, and reputation
|
|
2720
|
+
async scan(source, scanOptions = {}) {
|
|
1234
2721
|
const startTime = Date.now();
|
|
1235
2722
|
try {
|
|
1236
2723
|
await this.initializeClassifier();
|
|
1237
2724
|
await this.initializeReplacements();
|
|
1238
2725
|
const { tokens, mail } = await this.getTokensAndMailFromSource(source);
|
|
2726
|
+
const authOptions = { ...this.config.authOptions, ...scanOptions.authOptions };
|
|
2727
|
+
const reputationOptions = { ...this.config.reputationOptions, ...scanOptions.reputationOptions };
|
|
2728
|
+
const detectionPromises = [
|
|
2729
|
+
this.getClassification(tokens),
|
|
2730
|
+
this.getPhishingResults(mail),
|
|
2731
|
+
this.getExecutableResults(mail),
|
|
2732
|
+
this.config.enableMacroDetection ? this.getMacroResults(mail) : [],
|
|
2733
|
+
this.config.enableArbitraryDetection ? this.getArbitraryResults(mail, { remoteAddress: authOptions.ip, resolvedClientHostname: authOptions.hostname }) : [],
|
|
2734
|
+
this.getVirusResults(mail),
|
|
2735
|
+
this.getPatternResults(mail),
|
|
2736
|
+
this.getIDNHomographResults(mail),
|
|
2737
|
+
this.getToxicityResults(mail),
|
|
2738
|
+
this.getNSFWResults(mail)
|
|
2739
|
+
];
|
|
2740
|
+
const enableAuth = scanOptions.enableAuthentication ?? this.config.enableAuthentication;
|
|
2741
|
+
if (enableAuth && authOptions.ip) {
|
|
2742
|
+
detectionPromises.push(this.getAuthenticationResults(source, mail, authOptions));
|
|
2743
|
+
} else {
|
|
2744
|
+
detectionPromises.push(Promise.resolve(null));
|
|
2745
|
+
}
|
|
2746
|
+
const enableReputation = scanOptions.enableReputation ?? this.config.enableReputation;
|
|
2747
|
+
if (enableReputation) {
|
|
2748
|
+
detectionPromises.push(this.getReputationResults(mail, authOptions, reputationOptions));
|
|
2749
|
+
} else {
|
|
2750
|
+
detectionPromises.push(Promise.resolve(null));
|
|
2751
|
+
}
|
|
1239
2752
|
const [
|
|
1240
2753
|
classification,
|
|
1241
2754
|
phishing,
|
|
@@ -1246,20 +2759,14 @@ var SpamScanner = class {
|
|
|
1246
2759
|
patterns,
|
|
1247
2760
|
idnHomographAttack,
|
|
1248
2761
|
toxicity,
|
|
1249
|
-
nsfw
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
this.getPatternResults(mail),
|
|
1258
|
-
this.getIDNHomographResults(mail),
|
|
1259
|
-
this.getToxicityResults(mail),
|
|
1260
|
-
this.getNSFWResults(mail)
|
|
1261
|
-
]);
|
|
1262
|
-
const 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;
|
|
2762
|
+
nsfw,
|
|
2763
|
+
authResult,
|
|
2764
|
+
reputationResult
|
|
2765
|
+
] = await Promise.all(detectionPromises);
|
|
2766
|
+
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;
|
|
2767
|
+
if (reputationResult && reputationResult.isDenylisted) {
|
|
2768
|
+
isSpam = true;
|
|
2769
|
+
}
|
|
1263
2770
|
let message = "Ham";
|
|
1264
2771
|
if (isSpam) {
|
|
1265
2772
|
const reasons = [];
|
|
@@ -1293,7 +2800,14 @@ var SpamScanner = class {
|
|
|
1293
2800
|
if (nsfw.length > 0) {
|
|
1294
2801
|
reasons.push("NSFW content");
|
|
1295
2802
|
}
|
|
2803
|
+
if (reputationResult?.isDenylisted) {
|
|
2804
|
+
reasons.push("denylisted sender");
|
|
2805
|
+
}
|
|
1296
2806
|
message = `Spam (${arrayJoinConjunction(reasons)})`;
|
|
2807
|
+
} else if (reputationResult?.isTruthSource) {
|
|
2808
|
+
message = "Ham (truth source)";
|
|
2809
|
+
} else if (reputationResult?.isAllowlisted) {
|
|
2810
|
+
message = "Ham (allowlisted)";
|
|
1297
2811
|
}
|
|
1298
2812
|
const endTime = Date.now();
|
|
1299
2813
|
const processingTime = endTime - startTime;
|
|
@@ -1313,7 +2827,9 @@ var SpamScanner = class {
|
|
|
1313
2827
|
patterns,
|
|
1314
2828
|
idnHomographAttack,
|
|
1315
2829
|
toxicity,
|
|
1316
|
-
nsfw
|
|
2830
|
+
nsfw,
|
|
2831
|
+
authentication: authResult,
|
|
2832
|
+
reputation: reputationResult
|
|
1317
2833
|
},
|
|
1318
2834
|
links: this.extractAllUrls(mail, source),
|
|
1319
2835
|
tokens,
|
|
@@ -1335,10 +2851,85 @@ var SpamScanner = class {
|
|
|
1335
2851
|
}
|
|
1336
2852
|
return result;
|
|
1337
2853
|
} catch (error) {
|
|
1338
|
-
|
|
2854
|
+
debug7("Scan error:", error);
|
|
1339
2855
|
throw error;
|
|
1340
2856
|
}
|
|
1341
2857
|
}
|
|
2858
|
+
// Get authentication results using mailauth
|
|
2859
|
+
async getAuthenticationResults(source, mail, options = {}) {
|
|
2860
|
+
try {
|
|
2861
|
+
const messageBuffer = typeof source === "string" ? Buffer3.from(source) : source;
|
|
2862
|
+
const sender = options.sender || mail.from?.value?.[0]?.address || mail.from?.text;
|
|
2863
|
+
const authResult = await authenticate(messageBuffer, {
|
|
2864
|
+
ip: options.ip,
|
|
2865
|
+
helo: options.helo,
|
|
2866
|
+
mta: options.mta || "spamscanner",
|
|
2867
|
+
sender,
|
|
2868
|
+
timeout: options.timeout || 1e4
|
|
2869
|
+
});
|
|
2870
|
+
const scoreResult = calculateAuthScore(authResult, this.config.authScoreWeights);
|
|
2871
|
+
return {
|
|
2872
|
+
...authResult,
|
|
2873
|
+
score: scoreResult,
|
|
2874
|
+
authResultsHeader: formatAuthResultsHeader(authResult, options.mta || "spamscanner")
|
|
2875
|
+
};
|
|
2876
|
+
} catch (error) {
|
|
2877
|
+
debug7("Authentication error:", error);
|
|
2878
|
+
return null;
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
// Get reputation results from Forward Email API
|
|
2882
|
+
// Uses get-attributes module to extract comprehensive attributes for checking
|
|
2883
|
+
async getReputationResults(mail, authOptions = {}, reputationOptions = {}) {
|
|
2884
|
+
try {
|
|
2885
|
+
const { attributes, session } = await extractAttributes(mail, {
|
|
2886
|
+
isAligned: reputationOptions.onlyAligned ?? true,
|
|
2887
|
+
senderIp: authOptions.ip,
|
|
2888
|
+
senderHostname: authOptions.hostname,
|
|
2889
|
+
authResults: authOptions.authResults
|
|
2890
|
+
});
|
|
2891
|
+
const valuesToCheck = [...attributes];
|
|
2892
|
+
if (authOptions.sender) {
|
|
2893
|
+
const senderLower = authOptions.sender.toLowerCase();
|
|
2894
|
+
if (!valuesToCheck.includes(senderLower)) {
|
|
2895
|
+
valuesToCheck.push(senderLower);
|
|
2896
|
+
}
|
|
2897
|
+
const envelopeDomain = senderLower.split("@")[1];
|
|
2898
|
+
if (envelopeDomain && !valuesToCheck.includes(envelopeDomain)) {
|
|
2899
|
+
valuesToCheck.push(envelopeDomain);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
const replyTo = mail.replyTo?.value || [];
|
|
2903
|
+
for (const addr of replyTo) {
|
|
2904
|
+
if (addr.address) {
|
|
2905
|
+
const addrLower = addr.address.toLowerCase();
|
|
2906
|
+
if (!valuesToCheck.includes(addrLower)) {
|
|
2907
|
+
valuesToCheck.push(addrLower);
|
|
2908
|
+
}
|
|
2909
|
+
const domain = addrLower.split("@")[1];
|
|
2910
|
+
if (domain && !valuesToCheck.includes(domain)) {
|
|
2911
|
+
valuesToCheck.push(domain);
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
if (valuesToCheck.length === 0) {
|
|
2916
|
+
return null;
|
|
2917
|
+
}
|
|
2918
|
+
debug7("Checking reputation for %d attributes: %o", valuesToCheck.length, valuesToCheck);
|
|
2919
|
+
const resultsMap = await checkReputationBatch(valuesToCheck, reputationOptions);
|
|
2920
|
+
const aggregated = aggregateReputationResults([...resultsMap.values()]);
|
|
2921
|
+
return {
|
|
2922
|
+
...aggregated,
|
|
2923
|
+
checkedValues: valuesToCheck,
|
|
2924
|
+
details: Object.fromEntries(resultsMap),
|
|
2925
|
+
session
|
|
2926
|
+
// Include session info for debugging
|
|
2927
|
+
};
|
|
2928
|
+
} catch (error) {
|
|
2929
|
+
debug7("Reputation check error:", error);
|
|
2930
|
+
return null;
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
1342
2933
|
// Get pattern recognition results
|
|
1343
2934
|
async getPatternResults(mail) {
|
|
1344
2935
|
const results = [];
|
|
@@ -1375,7 +2966,7 @@ var SpamScanner = class {
|
|
|
1375
2966
|
try {
|
|
1376
2967
|
mail = await simpleParser(source);
|
|
1377
2968
|
} catch (error) {
|
|
1378
|
-
|
|
2969
|
+
debug7("Mail parsing error:", error);
|
|
1379
2970
|
mail = {
|
|
1380
2971
|
text: source,
|
|
1381
2972
|
html: "",
|
|
@@ -1406,7 +2997,7 @@ var SpamScanner = class {
|
|
|
1406
2997
|
// Default probability
|
|
1407
2998
|
};
|
|
1408
2999
|
} catch (error) {
|
|
1409
|
-
|
|
3000
|
+
debug7("Classification error:", error);
|
|
1410
3001
|
return {
|
|
1411
3002
|
category: "ham",
|
|
1412
3003
|
probability: 0.5
|
|
@@ -1462,7 +3053,7 @@ var SpamScanner = class {
|
|
|
1462
3053
|
}
|
|
1463
3054
|
}
|
|
1464
3055
|
} catch (error) {
|
|
1465
|
-
|
|
3056
|
+
debug7("Phishing check error:", error);
|
|
1466
3057
|
}
|
|
1467
3058
|
}
|
|
1468
3059
|
return results;
|
|
@@ -1506,14 +3097,15 @@ var SpamScanner = class {
|
|
|
1506
3097
|
});
|
|
1507
3098
|
}
|
|
1508
3099
|
} catch (error) {
|
|
1509
|
-
|
|
3100
|
+
debug7("File type detection error:", error);
|
|
1510
3101
|
}
|
|
1511
3102
|
}
|
|
1512
3103
|
}
|
|
1513
3104
|
return results;
|
|
1514
3105
|
}
|
|
1515
|
-
// Arbitrary results (GTUBE, etc.)
|
|
1516
|
-
|
|
3106
|
+
// Arbitrary results (GTUBE, spam patterns, etc.)
|
|
3107
|
+
// Updated to use session info for Microsoft Exchange spam detection
|
|
3108
|
+
async getArbitraryResults(mail, sessionInfo = {}) {
|
|
1517
3109
|
const results = [];
|
|
1518
3110
|
let content = (mail.text || "") + (mail.html || "");
|
|
1519
3111
|
if (mail.headerLines && Array.isArray(mail.headerLines)) {
|
|
@@ -1526,9 +3118,42 @@ var SpamScanner = class {
|
|
|
1526
3118
|
if (content.includes("XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X")) {
|
|
1527
3119
|
results.push({
|
|
1528
3120
|
type: "arbitrary",
|
|
1529
|
-
|
|
3121
|
+
subtype: "gtube",
|
|
3122
|
+
description: "GTUBE spam test pattern detected",
|
|
3123
|
+
score: 100
|
|
1530
3124
|
});
|
|
1531
3125
|
}
|
|
3126
|
+
try {
|
|
3127
|
+
const session = buildSessionInfo(mail, sessionInfo);
|
|
3128
|
+
const arbitraryResult = isArbitrary(mail, {
|
|
3129
|
+
threshold: this.config.arbitraryThreshold || 5,
|
|
3130
|
+
checkSubject: true,
|
|
3131
|
+
checkBody: true,
|
|
3132
|
+
checkSender: true,
|
|
3133
|
+
checkHeaders: true,
|
|
3134
|
+
checkLinks: true,
|
|
3135
|
+
checkMicrosoftHeaders: true,
|
|
3136
|
+
// Enable Microsoft Exchange spam detection
|
|
3137
|
+
checkVendorSpam: true,
|
|
3138
|
+
// Enable vendor-specific spam detection
|
|
3139
|
+
checkSpoofing: true,
|
|
3140
|
+
// Enable spoofing attack detection
|
|
3141
|
+
session
|
|
3142
|
+
// Pass session info for advanced checks
|
|
3143
|
+
});
|
|
3144
|
+
if (arbitraryResult.isArbitrary) {
|
|
3145
|
+
results.push({
|
|
3146
|
+
type: "arbitrary",
|
|
3147
|
+
subtype: arbitraryResult.category ? arbitraryResult.category.toLowerCase() : "pattern",
|
|
3148
|
+
description: `Arbitrary spam patterns detected (score: ${arbitraryResult.score})`,
|
|
3149
|
+
score: arbitraryResult.score,
|
|
3150
|
+
reasons: arbitraryResult.reasons,
|
|
3151
|
+
category: arbitraryResult.category
|
|
3152
|
+
});
|
|
3153
|
+
}
|
|
3154
|
+
} catch (error) {
|
|
3155
|
+
debug7("Arbitrary detection error:", error);
|
|
3156
|
+
}
|
|
1532
3157
|
return results;
|
|
1533
3158
|
}
|
|
1534
3159
|
// Parse and normalize locale
|
|
@@ -1594,7 +3219,7 @@ var SpamScanner = class {
|
|
|
1594
3219
|
result.riskScore = Math.max(result.riskScore, analysis.riskScore);
|
|
1595
3220
|
}
|
|
1596
3221
|
} catch (error) {
|
|
1597
|
-
|
|
3222
|
+
debug7("IDN analysis error for URL:", url, error);
|
|
1598
3223
|
}
|
|
1599
3224
|
}
|
|
1600
3225
|
if (result.detected) {
|
|
@@ -1611,7 +3236,7 @@ var SpamScanner = class {
|
|
|
1611
3236
|
result.details.push(...allRiskFactors);
|
|
1612
3237
|
}
|
|
1613
3238
|
} catch (error) {
|
|
1614
|
-
|
|
3239
|
+
debug7("IDN homograph detection error:", error);
|
|
1615
3240
|
}
|
|
1616
3241
|
return result;
|
|
1617
3242
|
}
|
|
@@ -1627,7 +3252,7 @@ var SpamScanner = class {
|
|
|
1627
3252
|
enableContextAnalysis: true
|
|
1628
3253
|
});
|
|
1629
3254
|
} catch (error) {
|
|
1630
|
-
|
|
3255
|
+
debug7("Failed to load IDN detector:", error);
|
|
1631
3256
|
return null;
|
|
1632
3257
|
}
|
|
1633
3258
|
}
|
|
@@ -1679,7 +3304,7 @@ var SpamScanner = class {
|
|
|
1679
3304
|
}
|
|
1680
3305
|
return detected;
|
|
1681
3306
|
} catch (error) {
|
|
1682
|
-
|
|
3307
|
+
debug7("Language detection error:", error);
|
|
1683
3308
|
try {
|
|
1684
3309
|
const landeResult = lande(text);
|
|
1685
3310
|
if (landeResult && landeResult.length > 0) {
|
|
@@ -1740,7 +3365,7 @@ var SpamScanner = class {
|
|
|
1740
3365
|
}
|
|
1741
3366
|
}
|
|
1742
3367
|
} catch (error) {
|
|
1743
|
-
|
|
3368
|
+
debug7("Toxicity detection error:", error);
|
|
1744
3369
|
}
|
|
1745
3370
|
return results;
|
|
1746
3371
|
}
|
|
@@ -1790,11 +3415,11 @@ var SpamScanner = class {
|
|
|
1790
3415
|
}
|
|
1791
3416
|
}
|
|
1792
3417
|
} catch (error) {
|
|
1793
|
-
|
|
3418
|
+
debug7("NSFW detection error for attachment:", attachment.filename, error);
|
|
1794
3419
|
}
|
|
1795
3420
|
}
|
|
1796
3421
|
} catch (error) {
|
|
1797
|
-
|
|
3422
|
+
debug7("NSFW detection error:", error);
|
|
1798
3423
|
}
|
|
1799
3424
|
return results;
|
|
1800
3425
|
}
|