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