spamscanner 6.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +515 -0
- package/dist/cjs/arf.cjs +539 -0
- package/dist/cjs/arf.cjs.map +7 -0
- package/dist/cjs/cli.cjs +4729 -0
- package/dist/cjs/cli.cjs.map +7 -0
- package/dist/cjs/{index.js → index.cjs} +1663 -49
- package/dist/cjs/index.cjs.map +7 -0
- package/dist/esm/arf.js +514 -0
- package/dist/esm/arf.js.map +7 -0
- package/dist/esm/cli.js +4713 -0
- package/dist/esm/cli.js.map +7 -0
- package/dist/esm/index.js +1662 -48
- package/dist/esm/index.js.map +4 -4
- package/dist/types/arf.d.ts +129 -0
- package/dist/types/auth.d.ts +157 -0
- package/dist/types/get-attributes.d.ts +148 -0
- package/dist/types/index.d.ts +105 -2
- package/dist/types/is-arbitrary.d.ts +231 -0
- package/dist/types/reputation.d.ts +60 -0
- package/package.json +11 -2
- package/dist/cjs/index.js.map +0 -7
package/dist/esm/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,6 +556,1446 @@ 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";
|
|
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
|
|
558
1999
|
var __filename = import.meta.url ? fileURLToPath(import.meta.url) : "";
|
|
559
2000
|
var __dirname = __filename ? path.dirname(__filename) : process.cwd();
|
|
560
2001
|
var findPackageRoot = (startDir) => {
|
|
@@ -578,7 +2019,7 @@ var getClassifier = async () => {
|
|
|
578
2019
|
const { default: classifier2 } = await Promise.resolve().then(() => (init_get_classifier(), get_classifier_exports));
|
|
579
2020
|
return classifier2;
|
|
580
2021
|
};
|
|
581
|
-
var
|
|
2022
|
+
var debug7 = debuglog7("spamscanner");
|
|
582
2023
|
var GENERIC_TOKENIZER = /[^a-zá-úÁ-Úà-úÀ-Úñü\dа-яёæøåàáảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđäöëïîûœçążśźęćńł-]+/i;
|
|
583
2024
|
var converter = new AFHConvert();
|
|
584
2025
|
var chineseTokenizer = { tokenize: (text) => text.split(/\s+/) };
|
|
@@ -664,6 +2105,41 @@ var SpamScanner = class {
|
|
|
664
2105
|
supportedLanguages: ["en"],
|
|
665
2106
|
enableMixedLanguageDetection: false,
|
|
666
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,
|
|
667
2143
|
// Existing options
|
|
668
2144
|
debug: false,
|
|
669
2145
|
logger: console,
|
|
@@ -711,7 +2187,7 @@ var SpamScanner = class {
|
|
|
711
2187
|
return Array.isArray(tokens) ? tokens : [];
|
|
712
2188
|
};
|
|
713
2189
|
} catch (error) {
|
|
714
|
-
|
|
2190
|
+
debug7("Failed to initialize classifier:", error);
|
|
715
2191
|
this.classifier = new NaiveBayes2();
|
|
716
2192
|
}
|
|
717
2193
|
}
|
|
@@ -730,7 +2206,7 @@ var SpamScanner = class {
|
|
|
730
2206
|
throw new Error("Invalid replacements format");
|
|
731
2207
|
}
|
|
732
2208
|
} catch (error) {
|
|
733
|
-
|
|
2209
|
+
debug7("Failed to initialize replacements:", error);
|
|
734
2210
|
this.replacements = /* @__PURE__ */ new Map();
|
|
735
2211
|
const basicReplacements = {
|
|
736
2212
|
u: "you",
|
|
@@ -758,7 +2234,7 @@ var SpamScanner = class {
|
|
|
758
2234
|
try {
|
|
759
2235
|
this.clamscan = await new ClamScan().init(this.config.clamscan);
|
|
760
2236
|
} catch (error) {
|
|
761
|
-
|
|
2237
|
+
debug7("ClamScan initialization failed:", error);
|
|
762
2238
|
return [];
|
|
763
2239
|
}
|
|
764
2240
|
}
|
|
@@ -782,7 +2258,7 @@ var SpamScanner = class {
|
|
|
782
2258
|
}
|
|
783
2259
|
}
|
|
784
2260
|
} catch (error) {
|
|
785
|
-
|
|
2261
|
+
debug7("Virus scan error:", error);
|
|
786
2262
|
}
|
|
787
2263
|
}
|
|
788
2264
|
return results;
|
|
@@ -905,7 +2381,7 @@ var SpamScanner = class {
|
|
|
905
2381
|
});
|
|
906
2382
|
}
|
|
907
2383
|
} catch (error) {
|
|
908
|
-
|
|
2384
|
+
debug7("PDF JavaScript detection error:", error);
|
|
909
2385
|
}
|
|
910
2386
|
}
|
|
911
2387
|
}
|
|
@@ -1148,7 +2624,7 @@ var SpamScanner = class {
|
|
|
1148
2624
|
isPrivate: parsed.isPrivate
|
|
1149
2625
|
};
|
|
1150
2626
|
} catch (error) {
|
|
1151
|
-
|
|
2627
|
+
debug7("tldts parsing error:", error);
|
|
1152
2628
|
return null;
|
|
1153
2629
|
}
|
|
1154
2630
|
}
|
|
@@ -1240,13 +2716,39 @@ var SpamScanner = class {
|
|
|
1240
2716
|
}
|
|
1241
2717
|
return text;
|
|
1242
2718
|
}
|
|
1243
|
-
// Main scan method - enhanced with performance metrics and
|
|
1244
|
-
async scan(source) {
|
|
2719
|
+
// Main scan method - enhanced with performance metrics, auth, and reputation
|
|
2720
|
+
async scan(source, scanOptions = {}) {
|
|
1245
2721
|
const startTime = Date.now();
|
|
1246
2722
|
try {
|
|
1247
2723
|
await this.initializeClassifier();
|
|
1248
2724
|
await this.initializeReplacements();
|
|
1249
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
|
+
}
|
|
1250
2752
|
const [
|
|
1251
2753
|
classification,
|
|
1252
2754
|
phishing,
|
|
@@ -1257,20 +2759,14 @@ var SpamScanner = class {
|
|
|
1257
2759
|
patterns,
|
|
1258
2760
|
idnHomographAttack,
|
|
1259
2761
|
toxicity,
|
|
1260
|
-
nsfw
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
this.getPatternResults(mail),
|
|
1269
|
-
this.getIDNHomographResults(mail),
|
|
1270
|
-
this.getToxicityResults(mail),
|
|
1271
|
-
this.getNSFWResults(mail)
|
|
1272
|
-
]);
|
|
1273
|
-
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
|
+
}
|
|
1274
2770
|
let message = "Ham";
|
|
1275
2771
|
if (isSpam) {
|
|
1276
2772
|
const reasons = [];
|
|
@@ -1304,7 +2800,14 @@ var SpamScanner = class {
|
|
|
1304
2800
|
if (nsfw.length > 0) {
|
|
1305
2801
|
reasons.push("NSFW content");
|
|
1306
2802
|
}
|
|
2803
|
+
if (reputationResult?.isDenylisted) {
|
|
2804
|
+
reasons.push("denylisted sender");
|
|
2805
|
+
}
|
|
1307
2806
|
message = `Spam (${arrayJoinConjunction(reasons)})`;
|
|
2807
|
+
} else if (reputationResult?.isTruthSource) {
|
|
2808
|
+
message = "Ham (truth source)";
|
|
2809
|
+
} else if (reputationResult?.isAllowlisted) {
|
|
2810
|
+
message = "Ham (allowlisted)";
|
|
1308
2811
|
}
|
|
1309
2812
|
const endTime = Date.now();
|
|
1310
2813
|
const processingTime = endTime - startTime;
|
|
@@ -1324,7 +2827,9 @@ var SpamScanner = class {
|
|
|
1324
2827
|
patterns,
|
|
1325
2828
|
idnHomographAttack,
|
|
1326
2829
|
toxicity,
|
|
1327
|
-
nsfw
|
|
2830
|
+
nsfw,
|
|
2831
|
+
authentication: authResult,
|
|
2832
|
+
reputation: reputationResult
|
|
1328
2833
|
},
|
|
1329
2834
|
links: this.extractAllUrls(mail, source),
|
|
1330
2835
|
tokens,
|
|
@@ -1346,10 +2851,85 @@ var SpamScanner = class {
|
|
|
1346
2851
|
}
|
|
1347
2852
|
return result;
|
|
1348
2853
|
} catch (error) {
|
|
1349
|
-
|
|
2854
|
+
debug7("Scan error:", error);
|
|
1350
2855
|
throw error;
|
|
1351
2856
|
}
|
|
1352
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
|
+
}
|
|
1353
2933
|
// Get pattern recognition results
|
|
1354
2934
|
async getPatternResults(mail) {
|
|
1355
2935
|
const results = [];
|
|
@@ -1386,7 +2966,7 @@ var SpamScanner = class {
|
|
|
1386
2966
|
try {
|
|
1387
2967
|
mail = await simpleParser(source);
|
|
1388
2968
|
} catch (error) {
|
|
1389
|
-
|
|
2969
|
+
debug7("Mail parsing error:", error);
|
|
1390
2970
|
mail = {
|
|
1391
2971
|
text: source,
|
|
1392
2972
|
html: "",
|
|
@@ -1417,7 +2997,7 @@ var SpamScanner = class {
|
|
|
1417
2997
|
// Default probability
|
|
1418
2998
|
};
|
|
1419
2999
|
} catch (error) {
|
|
1420
|
-
|
|
3000
|
+
debug7("Classification error:", error);
|
|
1421
3001
|
return {
|
|
1422
3002
|
category: "ham",
|
|
1423
3003
|
probability: 0.5
|
|
@@ -1473,7 +3053,7 @@ var SpamScanner = class {
|
|
|
1473
3053
|
}
|
|
1474
3054
|
}
|
|
1475
3055
|
} catch (error) {
|
|
1476
|
-
|
|
3056
|
+
debug7("Phishing check error:", error);
|
|
1477
3057
|
}
|
|
1478
3058
|
}
|
|
1479
3059
|
return results;
|
|
@@ -1517,14 +3097,15 @@ var SpamScanner = class {
|
|
|
1517
3097
|
});
|
|
1518
3098
|
}
|
|
1519
3099
|
} catch (error) {
|
|
1520
|
-
|
|
3100
|
+
debug7("File type detection error:", error);
|
|
1521
3101
|
}
|
|
1522
3102
|
}
|
|
1523
3103
|
}
|
|
1524
3104
|
return results;
|
|
1525
3105
|
}
|
|
1526
|
-
// Arbitrary results (GTUBE, etc.)
|
|
1527
|
-
|
|
3106
|
+
// Arbitrary results (GTUBE, spam patterns, etc.)
|
|
3107
|
+
// Updated to use session info for Microsoft Exchange spam detection
|
|
3108
|
+
async getArbitraryResults(mail, sessionInfo = {}) {
|
|
1528
3109
|
const results = [];
|
|
1529
3110
|
let content = (mail.text || "") + (mail.html || "");
|
|
1530
3111
|
if (mail.headerLines && Array.isArray(mail.headerLines)) {
|
|
@@ -1537,9 +3118,42 @@ var SpamScanner = class {
|
|
|
1537
3118
|
if (content.includes("XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X")) {
|
|
1538
3119
|
results.push({
|
|
1539
3120
|
type: "arbitrary",
|
|
1540
|
-
|
|
3121
|
+
subtype: "gtube",
|
|
3122
|
+
description: "GTUBE spam test pattern detected",
|
|
3123
|
+
score: 100
|
|
1541
3124
|
});
|
|
1542
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
|
+
}
|
|
1543
3157
|
return results;
|
|
1544
3158
|
}
|
|
1545
3159
|
// Parse and normalize locale
|
|
@@ -1605,7 +3219,7 @@ var SpamScanner = class {
|
|
|
1605
3219
|
result.riskScore = Math.max(result.riskScore, analysis.riskScore);
|
|
1606
3220
|
}
|
|
1607
3221
|
} catch (error) {
|
|
1608
|
-
|
|
3222
|
+
debug7("IDN analysis error for URL:", url, error);
|
|
1609
3223
|
}
|
|
1610
3224
|
}
|
|
1611
3225
|
if (result.detected) {
|
|
@@ -1622,7 +3236,7 @@ var SpamScanner = class {
|
|
|
1622
3236
|
result.details.push(...allRiskFactors);
|
|
1623
3237
|
}
|
|
1624
3238
|
} catch (error) {
|
|
1625
|
-
|
|
3239
|
+
debug7("IDN homograph detection error:", error);
|
|
1626
3240
|
}
|
|
1627
3241
|
return result;
|
|
1628
3242
|
}
|
|
@@ -1638,7 +3252,7 @@ var SpamScanner = class {
|
|
|
1638
3252
|
enableContextAnalysis: true
|
|
1639
3253
|
});
|
|
1640
3254
|
} catch (error) {
|
|
1641
|
-
|
|
3255
|
+
debug7("Failed to load IDN detector:", error);
|
|
1642
3256
|
return null;
|
|
1643
3257
|
}
|
|
1644
3258
|
}
|
|
@@ -1690,7 +3304,7 @@ var SpamScanner = class {
|
|
|
1690
3304
|
}
|
|
1691
3305
|
return detected;
|
|
1692
3306
|
} catch (error) {
|
|
1693
|
-
|
|
3307
|
+
debug7("Language detection error:", error);
|
|
1694
3308
|
try {
|
|
1695
3309
|
const landeResult = lande(text);
|
|
1696
3310
|
if (landeResult && landeResult.length > 0) {
|
|
@@ -1751,7 +3365,7 @@ var SpamScanner = class {
|
|
|
1751
3365
|
}
|
|
1752
3366
|
}
|
|
1753
3367
|
} catch (error) {
|
|
1754
|
-
|
|
3368
|
+
debug7("Toxicity detection error:", error);
|
|
1755
3369
|
}
|
|
1756
3370
|
return results;
|
|
1757
3371
|
}
|
|
@@ -1801,11 +3415,11 @@ var SpamScanner = class {
|
|
|
1801
3415
|
}
|
|
1802
3416
|
}
|
|
1803
3417
|
} catch (error) {
|
|
1804
|
-
|
|
3418
|
+
debug7("NSFW detection error for attachment:", attachment.filename, error);
|
|
1805
3419
|
}
|
|
1806
3420
|
}
|
|
1807
3421
|
} catch (error) {
|
|
1808
|
-
|
|
3422
|
+
debug7("NSFW detection error:", error);
|
|
1809
3423
|
}
|
|
1810
3424
|
return results;
|
|
1811
3425
|
}
|