spamscanner 7.0.6 → 7.0.8
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/dist/cjs/arf.cjs.map +1 -1
- package/dist/cjs/index.cjs +197 -45
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/esm/arf.js.map +1 -1
- package/dist/esm/cli.js +184 -33
- package/dist/esm/cli.js.map +3 -3
- package/dist/esm/index.js +186 -34
- package/dist/esm/index.js.map +3 -3
- package/dist/types/dnsbl.d.ts +19 -3
- package/dist/types/index.d.ts +13 -3
- package/dist/types/parse.d.ts +1 -1
- package/dist/types/tokenizer.d.ts +2 -1
- package/package.json +1 -1
package/dist/cjs/index.cjs
CHANGED
|
@@ -2541,6 +2541,9 @@ function unwrapLink(address2) {
|
|
|
2541
2541
|
}
|
|
2542
2542
|
for (let depth = 0; depth < 3; depth++) {
|
|
2543
2543
|
const target = gatewayTarget(url);
|
|
2544
|
+
if (!target) {
|
|
2545
|
+
break;
|
|
2546
|
+
}
|
|
2544
2547
|
let next;
|
|
2545
2548
|
try {
|
|
2546
2549
|
next = new URL(target);
|
|
@@ -2787,13 +2790,15 @@ function endsInsideTag(html2, end, link) {
|
|
|
2787
2790
|
const joined = trimEndOf(link.replace(/^https?:\/\//i, "") + after, ".-");
|
|
2788
2791
|
return joined !== host && (!isHost(host) || isHost(joined) && registrableDomain(joined) !== registrableDomain(host));
|
|
2789
2792
|
}
|
|
2790
|
-
function extractLinks(text, html2, { maxLength = 5e5 } = {}) {
|
|
2793
|
+
function extractLinks(text, html2, { maxLength = 5e5, bareHosts = false } = {}) {
|
|
2791
2794
|
text = typeof text === "string" ? foldToAscii(text) : text;
|
|
2792
2795
|
html2 = typeof html2 === "string" ? foldToAscii(html2) : html2;
|
|
2793
2796
|
const links = [];
|
|
2794
2797
|
const seen = /* @__PURE__ */ new Set();
|
|
2798
|
+
const linkHosts = /* @__PURE__ */ new Set();
|
|
2799
|
+
const bareFound = [];
|
|
2795
2800
|
const names = /* @__PURE__ */ new Map();
|
|
2796
|
-
const add = (raw, shown = null) => {
|
|
2801
|
+
const add = (raw, shown = null, bare = false) => {
|
|
2797
2802
|
let href = trimEndOf(trimControls(raw.replaceAll(/[\t\n\r]/g, "")).trim(), `).,;:!?'"]`);
|
|
2798
2803
|
if (/^www\./i.test(href)) {
|
|
2799
2804
|
href = `http://${href}`;
|
|
@@ -2817,11 +2822,13 @@ ${shown || ""}`;
|
|
|
2817
2822
|
}
|
|
2818
2823
|
seen.add(key);
|
|
2819
2824
|
const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[(.*)]$/, "$1");
|
|
2825
|
+
linkHosts.add(host);
|
|
2820
2826
|
links.push({
|
|
2821
2827
|
url: url.href.slice(0, MAX_URL),
|
|
2822
2828
|
host,
|
|
2823
2829
|
text: shown,
|
|
2824
|
-
userinfo: Boolean(url.username || url.password)
|
|
2830
|
+
userinfo: Boolean(url.username || url.password),
|
|
2831
|
+
...bare && { bare: true }
|
|
2825
2832
|
});
|
|
2826
2833
|
};
|
|
2827
2834
|
if (typeof html2 === "string" && html2) {
|
|
@@ -2850,10 +2857,90 @@ ${shown || ""}`;
|
|
|
2850
2857
|
}
|
|
2851
2858
|
add(link);
|
|
2852
2859
|
}
|
|
2860
|
+
if (bareHosts && index < 2) {
|
|
2861
|
+
bareLinkHosts(read, (host) => {
|
|
2862
|
+
bareFound.push(host);
|
|
2863
|
+
});
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
for (const host of bareFound) {
|
|
2868
|
+
if (!linkHosts.has(host)) {
|
|
2869
|
+
add(`http://${host}/`, null, true);
|
|
2853
2870
|
}
|
|
2854
2871
|
}
|
|
2855
2872
|
return links;
|
|
2856
2873
|
}
|
|
2874
|
+
function isHostCode(code) {
|
|
2875
|
+
return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
|
|
2876
|
+
}
|
|
2877
|
+
function endsLink(char) {
|
|
2878
|
+
return /[\s<>"'\u0060]/u.test(char);
|
|
2879
|
+
}
|
|
2880
|
+
function bareLinkHosts(text, add) {
|
|
2881
|
+
const found = /* @__PURE__ */ new Set();
|
|
2882
|
+
let previous = -1;
|
|
2883
|
+
let read = 0;
|
|
2884
|
+
let space = -1;
|
|
2885
|
+
let scheme = -1;
|
|
2886
|
+
for (let slash = text.indexOf("/"); slash !== -1; slash = text.indexOf("/", slash + 1)) {
|
|
2887
|
+
const floor = Math.max(previous + 1, slash - MAX_BARE_NAME);
|
|
2888
|
+
previous = slash;
|
|
2889
|
+
let end = slash;
|
|
2890
|
+
while (end > floor && text.codePointAt(end - 1) >= 48 && text.codePointAt(end - 1) <= 57) {
|
|
2891
|
+
end--;
|
|
2892
|
+
}
|
|
2893
|
+
end = end > floor && text[end - 1] === ":" ? end - 1 : slash;
|
|
2894
|
+
let start = end;
|
|
2895
|
+
while (start > floor && isHostCode(text.codePointAt(start - 1))) {
|
|
2896
|
+
start--;
|
|
2897
|
+
}
|
|
2898
|
+
if (start === end || start > 0 && (text[start - 1] === "/" || IN_WORD.test(text[start - 1]))) {
|
|
2899
|
+
continue;
|
|
2900
|
+
}
|
|
2901
|
+
for (; read < start; read++) {
|
|
2902
|
+
if (endsLink(text[read])) {
|
|
2903
|
+
space = read;
|
|
2904
|
+
} else if (text[read] === "/" && text[read - 1] === "/" && text[read - 2] === ":") {
|
|
2905
|
+
scheme = read;
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
if (scheme > space) {
|
|
2909
|
+
continue;
|
|
2910
|
+
}
|
|
2911
|
+
while (end > start && text[end - 1] === ".") {
|
|
2912
|
+
end--;
|
|
2913
|
+
}
|
|
2914
|
+
const labels = text.slice(start, end).split(".");
|
|
2915
|
+
const tld = labels.pop();
|
|
2916
|
+
if (!/^(?:[a-z]{2,24}|xn--[a-z\d-]{1,59})$/i.test(tld)) {
|
|
2917
|
+
continue;
|
|
2918
|
+
}
|
|
2919
|
+
const parts = [];
|
|
2920
|
+
while (labels.length > 0 && parts.length < 11) {
|
|
2921
|
+
const label = labels.pop();
|
|
2922
|
+
if (!/^[a-z\d-]{1,63}$/i.test(label)) {
|
|
2923
|
+
break;
|
|
2924
|
+
}
|
|
2925
|
+
parts.unshift(label);
|
|
2926
|
+
}
|
|
2927
|
+
if (parts.length === 0) {
|
|
2928
|
+
continue;
|
|
2929
|
+
}
|
|
2930
|
+
parts[0] = parts[0].replace(/^-+/, "");
|
|
2931
|
+
if (!parts[0]) {
|
|
2932
|
+
continue;
|
|
2933
|
+
}
|
|
2934
|
+
const host = `${parts.join(".")}.${tld}`.toLowerCase();
|
|
2935
|
+
if (found.has(host)) {
|
|
2936
|
+
continue;
|
|
2937
|
+
}
|
|
2938
|
+
found.add(host);
|
|
2939
|
+
if (isHost(host)) {
|
|
2940
|
+
add(host);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2857
2944
|
function hostOf(link) {
|
|
2858
2945
|
const authority = link.replace(/^https?:\/\//i, "").split(/[/?#\\]/)[0];
|
|
2859
2946
|
return trimEndOf(authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, ""), `).,;:!?'"]`);
|
|
@@ -3392,7 +3479,7 @@ ${body}`);
|
|
|
3392
3479
|
hidden: visible.hidden
|
|
3393
3480
|
};
|
|
3394
3481
|
}
|
|
3395
|
-
var import_tldts2, INVISIBLE, OBFUSCATING, JOINERS, INVISIBLE_OR_JOINER, ZERO_WIDTH_SPACE, ZERO_WIDTH_JOINERS, LATIN_END, LATIN_START, ZWSP_SCRIPTS, ZWSP_SCRIPT_END, ZWSP_SCRIPT_START, SOFT_HYPHEN, BIDI, RIGHT_TO_LEFT, LETTER_END, LETTER_START, DIGIT_END, DIGIT_START, STYLED_LETTERS, WIDE_LETTERS, CJK, LOOKALIKES, UNIT_LETTERS, UNIT_WORD, LEET, LEET_DIGITS, UNSPACED, URL_PATTERN, EMAIL_SOURCE, EMAIL_PATTERN, PHONE_PATTERN, IP_PATTERN, MONEY_PATTERN, BITCOIN_PATTERN, CARD_PATTERN, ANCHOR_START, MAX_LINK_TEXT, MAX_URL, SHOWN_URL, IMG_PIXEL, IMG, BARE_HOST, MAX_BARE_HOSTS, NAME_END, FILE_TLDS, LINK_FILE_TLDS, CLICK_TRACKERS, isUnder, BRAND_NAMES, SHORTENERS, FREEMAIL, segmenters, LATIN_LOOKALIKES, LATIN, CYRILLIC_OR_GREEK, INLINE_TAG, HOST_CHAR, MAX_SHOWN_HTML, LINKED_BRANDS, SIGN_IN_PART, signInPath, SENDING_DOMAINS, ADDRESS_SOURCE, NAMED_ADDRESS, MIN_SHOWN_WORDS, PART_WORD;
|
|
3482
|
+
var import_tldts2, INVISIBLE, OBFUSCATING, JOINERS, INVISIBLE_OR_JOINER, ZERO_WIDTH_SPACE, ZERO_WIDTH_JOINERS, LATIN_END, LATIN_START, ZWSP_SCRIPTS, ZWSP_SCRIPT_END, ZWSP_SCRIPT_START, SOFT_HYPHEN, BIDI, RIGHT_TO_LEFT, LETTER_END, LETTER_START, DIGIT_END, DIGIT_START, STYLED_LETTERS, WIDE_LETTERS, CJK, LOOKALIKES, UNIT_LETTERS, UNIT_WORD, LEET, LEET_DIGITS, UNSPACED, URL_PATTERN, EMAIL_SOURCE, EMAIL_PATTERN, PHONE_PATTERN, IP_PATTERN, MONEY_PATTERN, BITCOIN_PATTERN, CARD_PATTERN, ANCHOR_START, MAX_LINK_TEXT, MAX_URL, SHOWN_URL, IMG_PIXEL, IMG, BARE_HOST, MAX_BARE_HOSTS, NAME_END, FILE_TLDS, LINK_FILE_TLDS, CLICK_TRACKERS, isUnder, BRAND_NAMES, SHORTENERS, FREEMAIL, segmenters, LATIN_LOOKALIKES, LATIN, CYRILLIC_OR_GREEK, INLINE_TAG, HOST_CHAR, MAX_SHOWN_HTML, IN_WORD, MAX_BARE_NAME, LINKED_BRANDS, SIGN_IN_PART, signInPath, SENDING_DOMAINS, ADDRESS_SOURCE, NAMED_ADDRESS, MIN_SHOWN_WORDS, PART_WORD;
|
|
3396
3483
|
var init_tokenizer = __esm({
|
|
3397
3484
|
"src/tokenizer.js"() {
|
|
3398
3485
|
import_tldts2 = require("tldts");
|
|
@@ -3515,6 +3602,8 @@ var init_tokenizer = __esm({
|
|
|
3515
3602
|
INLINE_TAG = /^<\/?(?:abbr|b|bdi|bdo|big|cite|code|data|del|dfn|em|font|i|ins|kbd|mark|nobr|q|s|samp|small|span|strike|strong|sub|sup|time|tt|u|var|wbr)(?=[\t\n\f\r />])|^<!/i;
|
|
3516
3603
|
HOST_CHAR = /[\p{L}\p{N}.-]/u;
|
|
3517
3604
|
MAX_SHOWN_HTML = 2e5;
|
|
3605
|
+
IN_WORD = /[\p{L}\p{N}\p{M}_%]/u;
|
|
3606
|
+
MAX_BARE_NAME = 760;
|
|
3518
3607
|
LINKED_BRANDS = /* @__PURE__ */ new Set(["airbnb", "aliexpress", "alibaba", "bestbuy", "booking", "costco", "discord", "ebay", "facebook", "github", "gitlab", "instagram", "linkedin", "roblox", "shopify", "skype", "spotify", "steam", "telegram", "tiktok", "twitter", "uber", "walmart", "whatsapp", "zoom"]);
|
|
3519
3608
|
SIGN_IN_PART = /^(?:your|my|service|secure)?(?:login|logon|signin|signon|accounts?|verify|verification|security|secure|auth|authenticate|authorize|oauth2?|billing|password|recover|recovery|unlock|confirm|update|wallet|webscr)(?:verification|payment|info|help|page|\d{1,2})?$/;
|
|
3520
3609
|
signInPath = (path4) => path4.toLowerCase().replaceAll(/\b(log|sign)-(in|on)\b/g, "$1$2").split(/[/?&=._-]/).some((part) => SIGN_IN_PART.test(part));
|
|
@@ -4314,24 +4403,38 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
|
|
|
4314
4403
|
}
|
|
4315
4404
|
}
|
|
4316
4405
|
}
|
|
4317
|
-
async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
|
|
4406
|
+
async function expandAttachments(attachments = [], errors = [], parse = parseMessage, unopened = []) {
|
|
4318
4407
|
const all = [];
|
|
4319
4408
|
const deadline = Date.now() + PARSE_TIMEOUT;
|
|
4320
4409
|
let budget = MAX_NESTED_BYTES;
|
|
4321
4410
|
let nested = 0;
|
|
4411
|
+
let tooMany = false;
|
|
4322
4412
|
const visit = async (list, prefix, depth) => {
|
|
4323
4413
|
for (const attachment of list) {
|
|
4324
4414
|
if (prefix) {
|
|
4325
4415
|
if (nested >= MAX_ATTACHMENTS) {
|
|
4416
|
+
if (!tooMany) {
|
|
4417
|
+
tooMany = true;
|
|
4418
|
+
unopened.push({ filename: prefix, reason: `more than ${MAX_ATTACHMENTS} attachments in attached messages` });
|
|
4419
|
+
}
|
|
4326
4420
|
return;
|
|
4327
4421
|
}
|
|
4328
4422
|
nested++;
|
|
4329
4423
|
}
|
|
4330
4424
|
const name = attachment.filename || (prefix ? "unnamed attachment" : "");
|
|
4425
|
+
const filename = prefix ? `${prefix}/${name}` : name || "attached message";
|
|
4331
4426
|
all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
|
|
4332
4427
|
const { content } = attachment;
|
|
4333
4428
|
const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
|
|
4334
|
-
if (attached &&
|
|
4429
|
+
if (attached && import_node_buffer2.Buffer.isBuffer(content) && depth >= MAX_NESTED_DEPTH) {
|
|
4430
|
+
unopened.push({ filename, reason: `nested more than ${MAX_NESTED_DEPTH} deep` });
|
|
4431
|
+
continue;
|
|
4432
|
+
}
|
|
4433
|
+
if (attached && import_node_buffer2.Buffer.isBuffer(content) && content.length > budget) {
|
|
4434
|
+
unopened.push({ filename, reason: `past ${MAX_NESTED_BYTES / 1024 / 1024} MB of attached messages` });
|
|
4435
|
+
continue;
|
|
4436
|
+
}
|
|
4437
|
+
if (attached && import_node_buffer2.Buffer.isBuffer(content)) {
|
|
4335
4438
|
budget -= content.length;
|
|
4336
4439
|
let inner;
|
|
4337
4440
|
try {
|
|
@@ -4441,12 +4544,39 @@ var init_parse = __esm({
|
|
|
4441
4544
|
});
|
|
4442
4545
|
|
|
4443
4546
|
// src/dnsbl.js
|
|
4444
|
-
function
|
|
4445
|
-
if (typeof
|
|
4446
|
-
return
|
|
4547
|
+
function lookupName(host) {
|
|
4548
|
+
if (typeof host !== "string") {
|
|
4549
|
+
return null;
|
|
4550
|
+
}
|
|
4551
|
+
let end = host.length;
|
|
4552
|
+
while (end > 0 && host[end - 1] === ".") {
|
|
4553
|
+
end--;
|
|
4554
|
+
}
|
|
4555
|
+
let name = host.slice(0, end).toLowerCase();
|
|
4556
|
+
if (/[\u0080-\u{10FFFF}]/u.test(name)) {
|
|
4557
|
+
name = (0, import_node_url2.domainToASCII)(name) || name;
|
|
4558
|
+
}
|
|
4559
|
+
if ((0, import_node_net.isIP)(name.replaceAll(/^\[|]$/g, "")) !== 0) {
|
|
4560
|
+
return null;
|
|
4561
|
+
}
|
|
4562
|
+
const labels = name.split(".");
|
|
4563
|
+
let start = labels.length;
|
|
4564
|
+
let length = -1;
|
|
4565
|
+
while (start > 0 && LOOKUP_LABEL.test(labels[start - 1]) && length + 1 + labels[start - 1].length <= 253) {
|
|
4566
|
+
start--;
|
|
4567
|
+
length += 1 + labels[start].length;
|
|
4568
|
+
}
|
|
4569
|
+
if (start === labels.length) {
|
|
4570
|
+
return null;
|
|
4447
4571
|
}
|
|
4448
|
-
|
|
4449
|
-
|
|
4572
|
+
if (start === 0) {
|
|
4573
|
+
return name;
|
|
4574
|
+
}
|
|
4575
|
+
const suffix = labels.slice(start).join(".");
|
|
4576
|
+
if (start === labels.length - 1 || (0, import_tldts3.getPublicSuffix)(suffix, { allowPrivateDomains: true }) === suffix) {
|
|
4577
|
+
return null;
|
|
4578
|
+
}
|
|
4579
|
+
return suffix;
|
|
4450
4580
|
}
|
|
4451
4581
|
function ipv6Groups(address2) {
|
|
4452
4582
|
const expanded = address2.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/, (...match) => {
|
|
@@ -4523,11 +4653,14 @@ function isListedAnswer(address2, zone) {
|
|
|
4523
4653
|
}
|
|
4524
4654
|
return true;
|
|
4525
4655
|
}
|
|
4526
|
-
var import_node_dns, import_node_net, CLOUDFLARE_MALWARE, CLOUDFLARE_FAMILY, DnsChecker;
|
|
4656
|
+
var import_node_dns, import_node_net, import_node_url2, import_tldts3, LOOKUP_LABEL, CLOUDFLARE_MALWARE, CLOUDFLARE_FAMILY, DnsChecker;
|
|
4527
4657
|
var init_dnsbl = __esm({
|
|
4528
4658
|
"src/dnsbl.js"() {
|
|
4529
4659
|
import_node_dns = __toESM(require("node:dns"), 1);
|
|
4530
4660
|
import_node_net = require("node:net");
|
|
4661
|
+
import_node_url2 = require("node:url");
|
|
4662
|
+
import_tldts3 = require("tldts");
|
|
4663
|
+
LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
|
|
4531
4664
|
CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
|
|
4532
4665
|
CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
|
|
4533
4666
|
DnsChecker = class {
|
|
@@ -4650,9 +4783,10 @@ var init_dnsbl = __esm({
|
|
|
4650
4783
|
return results.filter(Boolean);
|
|
4651
4784
|
}
|
|
4652
4785
|
/**
|
|
4653
|
-
* Ask Cloudflare's filtering resolvers about host names
|
|
4654
|
-
*
|
|
4655
|
-
* resolver is "
|
|
4786
|
+
* Ask Cloudflare's filtering resolvers about host names, each by the name
|
|
4787
|
+
* it is looked up as (see lookupName), once; the findings name it. A name
|
|
4788
|
+
* blocked by the malware resolver is "malware"; one blocked only by the
|
|
4789
|
+
* family resolver is "adult". The hosts that got no answer (from the malware
|
|
4656
4790
|
* resolver, or from the family resolver when asked) are listed in the
|
|
4657
4791
|
* result's (non-enumerable) `unanswered` property.
|
|
4658
4792
|
* @param {string[]} hosts
|
|
@@ -4665,10 +4799,8 @@ var init_dnsbl = __esm({
|
|
|
4665
4799
|
async checkCloudflare(hosts = [], options = {}) {
|
|
4666
4800
|
const { adult = true, malwareServers = CLOUDFLARE_MALWARE, familyServers = CLOUDFLARE_FAMILY } = options;
|
|
4667
4801
|
const unanswered = [];
|
|
4668
|
-
const
|
|
4669
|
-
|
|
4670
|
-
return null;
|
|
4671
|
-
}
|
|
4802
|
+
const names = [...new Set(hosts.map((host) => lookupName(host)).filter(Boolean))];
|
|
4803
|
+
const results = await Promise.all(names.map(async (host) => {
|
|
4672
4804
|
const malware = await this.answer(host, malwareServers);
|
|
4673
4805
|
if (malware?.includes("0.0.0.0")) {
|
|
4674
4806
|
return { host, category: "malware" };
|
|
@@ -4771,7 +4903,7 @@ function asciiHostname(value) {
|
|
|
4771
4903
|
if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
|
|
4772
4904
|
return null;
|
|
4773
4905
|
}
|
|
4774
|
-
const ascii = (0,
|
|
4906
|
+
const ascii = (0, import_node_url3.domainToASCII)(lower);
|
|
4775
4907
|
return ascii.length <= 253 && /^[\w-]{1,63}(?:\.[\w-]{1,63})*$/.test(ascii) ? ascii : null;
|
|
4776
4908
|
}
|
|
4777
4909
|
function normalizeEntry(value) {
|
|
@@ -4920,11 +5052,11 @@ function trustedReputationValues(mail = {}, session = {}, authentication = null)
|
|
|
4920
5052
|
envelope: trustedSender ? { mailFrom: { address: sender } } : void 0
|
|
4921
5053
|
}), ...fromValues];
|
|
4922
5054
|
}
|
|
4923
|
-
var import_node_net2,
|
|
5055
|
+
var import_node_net2, import_node_url3, EMPTY, ReputationChecker, domainOf, sameDomain;
|
|
4924
5056
|
var init_reputation = __esm({
|
|
4925
5057
|
"src/reputation.js"() {
|
|
4926
5058
|
import_node_net2 = require("node:net");
|
|
4927
|
-
|
|
5059
|
+
import_node_url3 = require("node:url");
|
|
4928
5060
|
init_dnsbl();
|
|
4929
5061
|
init_http();
|
|
4930
5062
|
init_tokenizer();
|
|
@@ -5920,13 +6052,13 @@ function joinsDisplay(brand, display, name) {
|
|
|
5920
6052
|
return false;
|
|
5921
6053
|
}
|
|
5922
6054
|
function isOfficial({ brand, official }, domain, display, shared = true) {
|
|
5923
|
-
const parsed = (0,
|
|
6055
|
+
const parsed = (0, import_tldts4.parse)(domain);
|
|
5924
6056
|
const name = parsed.domainWithoutSuffix ?? "";
|
|
5925
6057
|
const compact = brand.replaceAll(" ", "");
|
|
5926
6058
|
if (parsed.publicSuffix === compact || compact.length >= 5 && joinsDisplay(brand, display, name) && !JOINED_WORDS.some((word) => name.slice(compact.length).includes(word))) {
|
|
5927
6059
|
return true;
|
|
5928
6060
|
}
|
|
5929
|
-
return official.some((entry) => entry instanceof RegExp ? entry.test(registrableDomain(domain)) : entry.includes(".") ? (shared || (0,
|
|
6061
|
+
return official.some((entry) => entry instanceof RegExp ? entry.test(registrableDomain(domain)) : entry.includes(".") ? (shared || (0, import_tldts4.parse)(entry).domain !== null) && (domain === entry || domain.endsWith(`.${entry}`)) : name === entry);
|
|
5930
6062
|
}
|
|
5931
6063
|
function headerString(mail, name) {
|
|
5932
6064
|
const value = mail.headers?.get?.(name);
|
|
@@ -6025,7 +6157,7 @@ ${withoutHelp(markup)}`) || attributeText(markup).some(({ text: text2, start, en
|
|
|
6025
6157
|
}
|
|
6026
6158
|
} else if (claimed && isWholeName(name, claimed)) {
|
|
6027
6159
|
const domain = claimed[1];
|
|
6028
|
-
const brand = MAILBOXES.has((0,
|
|
6160
|
+
const brand = MAILBOXES.has((0, import_tldts4.parse)(domain).domainWithoutSuffix) ? null : DISPLAY_BRANDS.find((entry) => isOfficial(entry, domain, "", false) && !isOfficial(entry, session.originalFromAddressDomain, ""));
|
|
6029
6161
|
if (brand) {
|
|
6030
6162
|
hit("FROM_NAME_BRAND", 2, `Display name is an address of "${brand.brand}" but the message is from ${fromRoot}`, "PHISHING");
|
|
6031
6163
|
}
|
|
@@ -6058,10 +6190,10 @@ ${withoutHelp(markup)}`) || attributeText(markup).some(({ text: text2, start, en
|
|
|
6058
6190
|
category
|
|
6059
6191
|
};
|
|
6060
6192
|
}
|
|
6061
|
-
var
|
|
6193
|
+
var import_tldts4, GTUBE, BLOCKED_SUBJECT, HACKED_SUBJECT, COIN, CODE, CURRENCY, MONEY, CRYPTOCURRENCY, AMOUNT_DEMAND, AMOUNT_GIVEN, HARM_VERB, HARMED, RECORDING, THREAT, HOURS, COUNT, DEADLINE, isPaymentDemand, BITCOIN_ADDRESS, IGNORE_INSTRUCTIONS, AI_ACTION, AI_OBJECT, AI_ACTION_OBJECT, AI_NEAR, VERDICT, VERDICT_WORD, VERDICT_ORDER, VERDICT_CLAIM, CLASSIFY, SYSTEM_PROMPT, YOU_ARE, QUOTE, CONDITION, DISCUSSED, DISCUSSED_SHOWN, DISCUSSED_NEAR, isDiscussed, isInjection, QUOTE_OR_SPACE, SHOWN_NEAR, isOrder, PAYPAL_DOMAIN, PAYPAL_SPAM_TYPE_IDS, PAYPAL_OWN_REQUEST, PAYPAL_INVOICE_SUBJECT, MS_SPAM_VERDICT, MS_SPAM_CATEGORY, APPLE_NEXT, APPLE_FILLER, APPLE_STRONG, APPLE_BUSINESS, APPLE_ALONE, APPLE_BESIDE, APPLE_PART, NEXT_WORD, MAX_AFTER, APPLE, DISPLAY_BRANDS, ADDRESSES, LIST_HOSTS, VIA_LIST, MAILBOXES, BEFORE_ADDRESS, AFTER_ADDRESS, isWholeName, JOINED_WORDS, MAX_DISPLAY;
|
|
6062
6194
|
var init_is_arbitrary = __esm({
|
|
6063
6195
|
"src/is-arbitrary.js"() {
|
|
6064
|
-
|
|
6196
|
+
import_tldts4 = require("tldts");
|
|
6065
6197
|
init_homograph();
|
|
6066
6198
|
init_html();
|
|
6067
6199
|
init_linear();
|
|
@@ -7195,7 +7327,7 @@ Answer with exactly one word: ham, spam, phishing, scam or malware.`;
|
|
|
7195
7327
|
|
|
7196
7328
|
// src/model.js
|
|
7197
7329
|
function moduleDirectory() {
|
|
7198
|
-
return import_node_path.default.dirname((0,
|
|
7330
|
+
return import_node_path.default.dirname((0, import_node_url4.fileURLToPath)(__importMetaUrl));
|
|
7199
7331
|
}
|
|
7200
7332
|
function defaultModelPath(here = moduleDirectory()) {
|
|
7201
7333
|
if (import_node_process2.default.env.SPAMSCANNER_MODEL) {
|
|
@@ -7276,7 +7408,7 @@ function saveModel(classifier, file, options = {}) {
|
|
|
7276
7408
|
}
|
|
7277
7409
|
loaded.delete(resolved);
|
|
7278
7410
|
}
|
|
7279
|
-
var import_node_fs2, import_node_crypto3, import_node_module2, import_node_path, import_node_process2,
|
|
7411
|
+
var import_node_fs2, import_node_crypto3, import_node_module2, import_node_path, import_node_process2, import_node_url4, loaded;
|
|
7280
7412
|
var init_model = __esm({
|
|
7281
7413
|
"src/model.js"() {
|
|
7282
7414
|
import_node_fs2 = require("node:fs");
|
|
@@ -7284,7 +7416,7 @@ var init_model = __esm({
|
|
|
7284
7416
|
import_node_module2 = require("node:module");
|
|
7285
7417
|
import_node_path = __toESM(require("node:path"), 1);
|
|
7286
7418
|
import_node_process2 = __toESM(require("node:process"), 1);
|
|
7287
|
-
|
|
7419
|
+
import_node_url4 = require("node:url");
|
|
7288
7420
|
init_classifier();
|
|
7289
7421
|
loaded = /* @__PURE__ */ new Map();
|
|
7290
7422
|
}
|
|
@@ -7514,7 +7646,7 @@ var init_package = __esm({
|
|
|
7514
7646
|
package_default = {
|
|
7515
7647
|
name: "spamscanner",
|
|
7516
7648
|
description: "Spam, phishing and malware filter for email in any language: a trainable classifier, link and attachment checks, SPF/DKIM/DMARC, and an optional AI second opinion from any LLM, including local Ollama models. Works as a library, CLI, Postfix milter or HTTP API.",
|
|
7517
|
-
version: "7.0.
|
|
7649
|
+
version: "7.0.8",
|
|
7518
7650
|
author: "Forward Email (https://forwardemail.net)",
|
|
7519
7651
|
bin: {
|
|
7520
7652
|
spamscanner: "./dist/esm/cli.js"
|
|
@@ -9165,7 +9297,8 @@ __export(src_exports, {
|
|
|
9165
9297
|
scoreResults: () => scoreResults,
|
|
9166
9298
|
segmentWords: () => segmentWords,
|
|
9167
9299
|
spamHeaders: () => spamHeaders,
|
|
9168
|
-
train: () => train
|
|
9300
|
+
train: () => train,
|
|
9301
|
+
unwrapLink: () => unwrapLink
|
|
9169
9302
|
});
|
|
9170
9303
|
function finding(fields) {
|
|
9171
9304
|
Object.defineProperty(fields, "toString", {
|
|
@@ -9434,14 +9567,16 @@ var init_src = __esm({
|
|
|
9434
9567
|
* a header field ("Name: value", or an mbox "From " line) is read as the
|
|
9435
9568
|
* body of a message with no header, so its text is the input; other
|
|
9436
9569
|
* input mailparser cannot read gives a message with no text. Other errors
|
|
9437
|
-
* are thrown.
|
|
9570
|
+
* are thrown. `error` is the parser's limit the message exceeds, so that
|
|
9571
|
+
* only its first parts were read (as scan() reports it, see parseError),
|
|
9572
|
+
* or null.
|
|
9438
9573
|
* @param {Buffer|string|Uint8Array|AsyncIterable} source
|
|
9439
|
-
* @returns {Promise<{raw: Buffer, mail: object}>}
|
|
9574
|
+
* @returns {Promise<{raw: Buffer, mail: object, error: string|null}>}
|
|
9440
9575
|
*/
|
|
9441
9576
|
async parse(source) {
|
|
9442
9577
|
const raw = await toBuffer(source);
|
|
9443
|
-
const { mail } = await parseWithin(withHeader(raw));
|
|
9444
|
-
return { raw, mail };
|
|
9578
|
+
const { mail, error } = await parseWithin(withHeader(raw));
|
|
9579
|
+
return { raw, mail, error: error ?? null };
|
|
9445
9580
|
}
|
|
9446
9581
|
/**
|
|
9447
9582
|
* Classifier features of a parsed message.
|
|
@@ -9574,15 +9709,23 @@ var init_src = __esm({
|
|
|
9574
9709
|
message: `A link shows "${link.text}" but goes to ${link.host}`
|
|
9575
9710
|
}));
|
|
9576
9711
|
}
|
|
9577
|
-
|
|
9712
|
+
const name = lookupName(destination(link));
|
|
9713
|
+
if (name) {
|
|
9714
|
+
seenHosts.add(name);
|
|
9715
|
+
}
|
|
9578
9716
|
}
|
|
9579
|
-
|
|
9717
|
+
const found = links.filter((link) => !link.bare);
|
|
9718
|
+
const bare = links.filter((link) => link.bare);
|
|
9719
|
+
for (const link of [...found.filter((link2) => link2.text), ...found.filter((link2) => !link2.text)]) {
|
|
9580
9720
|
lookalike(destination(link), { url: link.url });
|
|
9581
9721
|
}
|
|
9722
|
+
for (const link of bare) {
|
|
9723
|
+
lookalike(link.host, { source: "text" });
|
|
9724
|
+
}
|
|
9582
9725
|
for (const host of hosts) {
|
|
9583
9726
|
lookalike(host, { source: "text" });
|
|
9584
9727
|
}
|
|
9585
|
-
const ordered = [...new Set([...
|
|
9728
|
+
const ordered = [...new Set([...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean))];
|
|
9586
9729
|
const firsts = /* @__PURE__ */ new Map();
|
|
9587
9730
|
for (const host of ordered) {
|
|
9588
9731
|
const domain = registrableDomain(host);
|
|
@@ -9596,9 +9739,9 @@ var init_src = __esm({
|
|
|
9596
9739
|
let answered = 0;
|
|
9597
9740
|
const cloudflare = async () => {
|
|
9598
9741
|
await withTimeout(Promise.all(checked.map(async (host) => {
|
|
9599
|
-
const
|
|
9600
|
-
blocked.push(...
|
|
9601
|
-
if (!
|
|
9742
|
+
const found2 = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
|
|
9743
|
+
blocked.push(...found2);
|
|
9744
|
+
if (!found2.unanswered?.length) {
|
|
9602
9745
|
answered++;
|
|
9603
9746
|
}
|
|
9604
9747
|
})), config.timeout, null);
|
|
@@ -9782,7 +9925,7 @@ var init_src = __esm({
|
|
|
9782
9925
|
const linkLength = Number.isFinite(config.phishing.maxLength) && config.phishing.maxLength > 0 ? config.phishing.maxLength : 1e6;
|
|
9783
9926
|
const text = typeof mail.text === "string" ? mail.text : "";
|
|
9784
9927
|
const html2 = typeof mail.html === "string" ? mail.html : "";
|
|
9785
|
-
const checkedLinks = extractLinks(text.slice(0, linkLength), html2.slice(0, linkLength * 2), { maxLength: linkLength * 2 });
|
|
9928
|
+
const checkedLinks = extractLinks(text.slice(0, linkLength), html2.slice(0, linkLength * 2), { maxLength: linkLength * 2, bareHosts: true });
|
|
9786
9929
|
const linksTruncated = text.length > linkLength || html2.length > linkLength * 2;
|
|
9787
9930
|
const plainObfuscation = obfuscationOf(`${mail.subject || ""}
|
|
9788
9931
|
${typeof mail.text === "string" ? mail.text : ""}`, { maxLength: config.maxLength });
|
|
@@ -9793,7 +9936,8 @@ ${visibleText(html2.slice(0, config.maxLength * 2)).text}`, { maxLength: config.
|
|
|
9793
9936
|
mixed: Math.max(plainObfuscation.mixed, shownObfuscation.mixed),
|
|
9794
9937
|
styled: plainObfuscation.styled || shownObfuscation.styled
|
|
9795
9938
|
};
|
|
9796
|
-
const
|
|
9939
|
+
const unopened = [];
|
|
9940
|
+
const attached = await expandAttachments(mail.attachments, parseErrors, void 0, unopened);
|
|
9797
9941
|
const attachments = config.attachments === false ? [] : inspectAttachments(attached).filter((item) => config.macros !== false || !["macro", "pdf_active", "rtf_object"].includes(item.type)).map((item) => finding(item));
|
|
9798
9942
|
const [links, authentication, checkedReputation, dnsbl, virusScan, toxicity, nsfw] = await Promise.all([
|
|
9799
9943
|
this.checkLinks(checkedLinks, config, extracted.hosts, fromDomain(mail)),
|
|
@@ -9914,6 +10058,13 @@ ${visibleText(html2.slice(0, config.maxLength * 2)).text}`, { maxLength: config.
|
|
|
9914
10058
|
// scanned, as {filename, reason}: the same on every try, so not a
|
|
9915
10059
|
// failure.
|
|
9916
10060
|
virusScanSkipped: virusScan.skipped,
|
|
10061
|
+
// Attached messages not opened, past the limits attached messages
|
|
10062
|
+
// are read within (nested more than three deep, more than 100
|
|
10063
|
+
// attachments in them, or more than 25 MB of them), as
|
|
10064
|
+
// {filename, reason}: their attachments were not inspected. Not
|
|
10065
|
+
// scored (forwarded messages can be nested so), for a caller that
|
|
10066
|
+
// needs a message read whole, e.g. to block what it carries.
|
|
10067
|
+
unopenedAttachments: unopened,
|
|
9917
10068
|
// Whether the message has more text than phishing.maxLength, whose
|
|
9918
10069
|
// links were not read.
|
|
9919
10070
|
linksTruncated,
|
|
@@ -9923,7 +10074,8 @@ ${visibleText(html2.slice(0, config.maxLength * 2)).text}`, { maxLength: config.
|
|
|
9923
10074
|
riskScore: Math.max(0, ...links.homographs.map((item) => item.riskScore))
|
|
9924
10075
|
}
|
|
9925
10076
|
},
|
|
9926
|
-
|
|
10077
|
+
// (the links of the message, not hosts found without one)
|
|
10078
|
+
links: checkedLinks.filter((link) => !link.bare).map((link) => link.url),
|
|
9927
10079
|
language: extracted.language,
|
|
9928
10080
|
tokens: extracted.words,
|
|
9929
10081
|
mail,
|