spamscanner 7.0.5 → 7.0.7
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/index.cjs +161 -37
- package/dist/cjs/index.cjs.map +3 -3
- package/dist/esm/cli.js +178 -35
- package/dist/esm/cli.js.map +3 -3
- package/dist/esm/index.js +150 -26
- package/dist/esm/index.js.map +3 -3
- package/dist/types/dnsbl.d.ts +19 -3
- package/dist/types/tokenizer.d.ts +2 -1
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -2378,13 +2378,15 @@ function endsInsideTag(html, end, link) {
|
|
|
2378
2378
|
return joined !== host && (!isHost(host) || isHost(joined) && registrableDomain(joined) !== registrableDomain(host));
|
|
2379
2379
|
}
|
|
2380
2380
|
var MAX_SHOWN_HTML = 2e5;
|
|
2381
|
-
function extractLinks(text, html, { maxLength = 5e5 } = {}) {
|
|
2381
|
+
function extractLinks(text, html, { maxLength = 5e5, bareHosts = false } = {}) {
|
|
2382
2382
|
text = typeof text === "string" ? foldToAscii(text) : text;
|
|
2383
2383
|
html = typeof html === "string" ? foldToAscii(html) : html;
|
|
2384
2384
|
const links = [];
|
|
2385
2385
|
const seen = /* @__PURE__ */ new Set();
|
|
2386
|
+
const linkHosts = /* @__PURE__ */ new Set();
|
|
2387
|
+
const bareFound = [];
|
|
2386
2388
|
const names = /* @__PURE__ */ new Map();
|
|
2387
|
-
const add = (raw, shown = null) => {
|
|
2389
|
+
const add = (raw, shown = null, bare = false) => {
|
|
2388
2390
|
let href = trimEndOf(trimControls(raw.replaceAll(/[\t\n\r]/g, "")).trim(), `).,;:!?'"]`);
|
|
2389
2391
|
if (/^www\./i.test(href)) {
|
|
2390
2392
|
href = `http://${href}`;
|
|
@@ -2408,11 +2410,13 @@ ${shown || ""}`;
|
|
|
2408
2410
|
}
|
|
2409
2411
|
seen.add(key);
|
|
2410
2412
|
const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[(.*)]$/, "$1");
|
|
2413
|
+
linkHosts.add(host);
|
|
2411
2414
|
links.push({
|
|
2412
2415
|
url: url.href.slice(0, MAX_URL),
|
|
2413
2416
|
host,
|
|
2414
2417
|
text: shown,
|
|
2415
|
-
userinfo: Boolean(url.username || url.password)
|
|
2418
|
+
userinfo: Boolean(url.username || url.password),
|
|
2419
|
+
...bare && { bare: true }
|
|
2416
2420
|
});
|
|
2417
2421
|
};
|
|
2418
2422
|
if (typeof html === "string" && html) {
|
|
@@ -2441,10 +2445,92 @@ ${shown || ""}`;
|
|
|
2441
2445
|
}
|
|
2442
2446
|
add(link);
|
|
2443
2447
|
}
|
|
2448
|
+
if (bareHosts && index < 2) {
|
|
2449
|
+
bareLinkHosts(read, (host) => {
|
|
2450
|
+
bareFound.push(host);
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
for (const host of bareFound) {
|
|
2456
|
+
if (!linkHosts.has(host)) {
|
|
2457
|
+
add(`http://${host}/`, null, true);
|
|
2444
2458
|
}
|
|
2445
2459
|
}
|
|
2446
2460
|
return links;
|
|
2447
2461
|
}
|
|
2462
|
+
var IN_WORD = /[\p{L}\p{N}\p{M}_%]/u;
|
|
2463
|
+
var MAX_BARE_NAME = 760;
|
|
2464
|
+
function isHostCode(code) {
|
|
2465
|
+
return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
|
|
2466
|
+
}
|
|
2467
|
+
function endsLink(char) {
|
|
2468
|
+
return /[\s<>"'\u0060]/u.test(char);
|
|
2469
|
+
}
|
|
2470
|
+
function bareLinkHosts(text, add) {
|
|
2471
|
+
const found = /* @__PURE__ */ new Set();
|
|
2472
|
+
let previous = -1;
|
|
2473
|
+
let read = 0;
|
|
2474
|
+
let space = -1;
|
|
2475
|
+
let scheme = -1;
|
|
2476
|
+
for (let slash = text.indexOf("/"); slash !== -1; slash = text.indexOf("/", slash + 1)) {
|
|
2477
|
+
const floor = Math.max(previous + 1, slash - MAX_BARE_NAME);
|
|
2478
|
+
previous = slash;
|
|
2479
|
+
let end = slash;
|
|
2480
|
+
while (end > floor && text.codePointAt(end - 1) >= 48 && text.codePointAt(end - 1) <= 57) {
|
|
2481
|
+
end--;
|
|
2482
|
+
}
|
|
2483
|
+
end = end > floor && text[end - 1] === ":" ? end - 1 : slash;
|
|
2484
|
+
let start = end;
|
|
2485
|
+
while (start > floor && isHostCode(text.codePointAt(start - 1))) {
|
|
2486
|
+
start--;
|
|
2487
|
+
}
|
|
2488
|
+
if (start === end || start > 0 && (text[start - 1] === "/" || IN_WORD.test(text[start - 1]))) {
|
|
2489
|
+
continue;
|
|
2490
|
+
}
|
|
2491
|
+
for (; read < start; read++) {
|
|
2492
|
+
if (endsLink(text[read])) {
|
|
2493
|
+
space = read;
|
|
2494
|
+
} else if (text[read] === "/" && text[read - 1] === "/" && text[read - 2] === ":") {
|
|
2495
|
+
scheme = read;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
if (scheme > space) {
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
while (end > start && text[end - 1] === ".") {
|
|
2502
|
+
end--;
|
|
2503
|
+
}
|
|
2504
|
+
const labels = text.slice(start, end).split(".");
|
|
2505
|
+
const tld = labels.pop();
|
|
2506
|
+
if (!/^(?:[a-z]{2,24}|xn--[a-z\d-]{1,59})$/i.test(tld)) {
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
const parts = [];
|
|
2510
|
+
while (labels.length > 0 && parts.length < 11) {
|
|
2511
|
+
const label = labels.pop();
|
|
2512
|
+
if (!/^[a-z\d-]{1,63}$/i.test(label)) {
|
|
2513
|
+
break;
|
|
2514
|
+
}
|
|
2515
|
+
parts.unshift(label);
|
|
2516
|
+
}
|
|
2517
|
+
if (parts.length === 0) {
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
parts[0] = parts[0].replace(/^-+/, "");
|
|
2521
|
+
if (!parts[0]) {
|
|
2522
|
+
continue;
|
|
2523
|
+
}
|
|
2524
|
+
const host = `${parts.join(".")}.${tld}`.toLowerCase();
|
|
2525
|
+
if (found.has(host)) {
|
|
2526
|
+
continue;
|
|
2527
|
+
}
|
|
2528
|
+
found.add(host);
|
|
2529
|
+
if (isHost(host)) {
|
|
2530
|
+
add(host);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2448
2534
|
function hostOf(link) {
|
|
2449
2535
|
const authority = link.replace(/^https?:\/\//i, "").split(/[/?#\\]/)[0];
|
|
2450
2536
|
return trimEndOf(authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, ""), `).,;:!?'"]`);
|
|
@@ -3900,17 +3986,47 @@ async function expandAttachments(attachments = [], errors = [], parse = parseMes
|
|
|
3900
3986
|
|
|
3901
3987
|
// src/reputation.js
|
|
3902
3988
|
import { isIP as isIP2 } from "node:net";
|
|
3903
|
-
import { domainToASCII } from "node:url";
|
|
3989
|
+
import { domainToASCII as domainToASCII2 } from "node:url";
|
|
3904
3990
|
|
|
3905
3991
|
// src/dnsbl.js
|
|
3906
3992
|
import dns from "node:dns";
|
|
3907
3993
|
import { isIP } from "node:net";
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3994
|
+
import { domainToASCII } from "node:url";
|
|
3995
|
+
import { getPublicSuffix } from "tldts";
|
|
3996
|
+
var LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
|
|
3997
|
+
function lookupName(host) {
|
|
3998
|
+
if (typeof host !== "string") {
|
|
3999
|
+
return null;
|
|
4000
|
+
}
|
|
4001
|
+
let end = host.length;
|
|
4002
|
+
while (end > 0 && host[end - 1] === ".") {
|
|
4003
|
+
end--;
|
|
4004
|
+
}
|
|
4005
|
+
let name = host.slice(0, end).toLowerCase();
|
|
4006
|
+
if (/[\u0080-\u{10FFFF}]/u.test(name)) {
|
|
4007
|
+
name = domainToASCII(name) || name;
|
|
4008
|
+
}
|
|
4009
|
+
if (isIP(name.replaceAll(/^\[|]$/g, "")) !== 0) {
|
|
4010
|
+
return null;
|
|
4011
|
+
}
|
|
4012
|
+
const labels = name.split(".");
|
|
4013
|
+
let start = labels.length;
|
|
4014
|
+
let length = -1;
|
|
4015
|
+
while (start > 0 && LOOKUP_LABEL.test(labels[start - 1]) && length + 1 + labels[start - 1].length <= 253) {
|
|
4016
|
+
start--;
|
|
4017
|
+
length += 1 + labels[start].length;
|
|
4018
|
+
}
|
|
4019
|
+
if (start === labels.length) {
|
|
4020
|
+
return null;
|
|
4021
|
+
}
|
|
4022
|
+
if (start === 0) {
|
|
4023
|
+
return name;
|
|
3911
4024
|
}
|
|
3912
|
-
const
|
|
3913
|
-
|
|
4025
|
+
const suffix = labels.slice(start).join(".");
|
|
4026
|
+
if (start === labels.length - 1 || getPublicSuffix(suffix, { allowPrivateDomains: true }) === suffix) {
|
|
4027
|
+
return null;
|
|
4028
|
+
}
|
|
4029
|
+
return suffix;
|
|
3914
4030
|
}
|
|
3915
4031
|
var CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
|
|
3916
4032
|
var CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
|
|
@@ -4109,9 +4225,10 @@ var DnsChecker = class {
|
|
|
4109
4225
|
return results.filter(Boolean);
|
|
4110
4226
|
}
|
|
4111
4227
|
/**
|
|
4112
|
-
* Ask Cloudflare's filtering resolvers about host names
|
|
4113
|
-
*
|
|
4114
|
-
* resolver is "
|
|
4228
|
+
* Ask Cloudflare's filtering resolvers about host names, each by the name
|
|
4229
|
+
* it is looked up as (see lookupName), once; the findings name it. A name
|
|
4230
|
+
* blocked by the malware resolver is "malware"; one blocked only by the
|
|
4231
|
+
* family resolver is "adult". The hosts that got no answer (from the malware
|
|
4115
4232
|
* resolver, or from the family resolver when asked) are listed in the
|
|
4116
4233
|
* result's (non-enumerable) `unanswered` property.
|
|
4117
4234
|
* @param {string[]} hosts
|
|
@@ -4124,10 +4241,8 @@ var DnsChecker = class {
|
|
|
4124
4241
|
async checkCloudflare(hosts = [], options = {}) {
|
|
4125
4242
|
const { adult = true, malwareServers = CLOUDFLARE_MALWARE, familyServers = CLOUDFLARE_FAMILY } = options;
|
|
4126
4243
|
const unanswered = [];
|
|
4127
|
-
const
|
|
4128
|
-
|
|
4129
|
-
return null;
|
|
4130
|
-
}
|
|
4244
|
+
const names = [...new Set(hosts.map((host) => lookupName(host)).filter(Boolean))];
|
|
4245
|
+
const results = await Promise.all(names.map(async (host) => {
|
|
4131
4246
|
const malware = await this.answer(host, malwareServers);
|
|
4132
4247
|
if (malware?.includes("0.0.0.0")) {
|
|
4133
4248
|
return { host, category: "malware" };
|
|
@@ -4231,7 +4346,7 @@ function asciiHostname(value) {
|
|
|
4231
4346
|
if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
|
|
4232
4347
|
return null;
|
|
4233
4348
|
}
|
|
4234
|
-
const ascii =
|
|
4349
|
+
const ascii = domainToASCII2(lower);
|
|
4235
4350
|
return ascii.length <= 253 && /^[\w-]{1,63}(?:\.[\w-]{1,63})*$/.test(ascii) ? ascii : null;
|
|
4236
4351
|
}
|
|
4237
4352
|
function normalizeEntry(value) {
|
|
@@ -6916,7 +7031,7 @@ function scoreResults(results, options) {
|
|
|
6916
7031
|
var package_default = {
|
|
6917
7032
|
name: "spamscanner",
|
|
6918
7033
|
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.",
|
|
6919
|
-
version: "7.0.
|
|
7034
|
+
version: "7.0.7",
|
|
6920
7035
|
author: "Forward Email (https://forwardemail.net)",
|
|
6921
7036
|
bin: {
|
|
6922
7037
|
spamscanner: "./dist/esm/cli.js"
|
|
@@ -8858,15 +8973,23 @@ var SpamScanner = class {
|
|
|
8858
8973
|
message: `A link shows "${link.text}" but goes to ${link.host}`
|
|
8859
8974
|
}));
|
|
8860
8975
|
}
|
|
8861
|
-
|
|
8976
|
+
const name = lookupName(destination(link));
|
|
8977
|
+
if (name) {
|
|
8978
|
+
seenHosts.add(name);
|
|
8979
|
+
}
|
|
8862
8980
|
}
|
|
8863
|
-
|
|
8981
|
+
const found = links.filter((link) => !link.bare);
|
|
8982
|
+
const bare = links.filter((link) => link.bare);
|
|
8983
|
+
for (const link of [...found.filter((link2) => link2.text), ...found.filter((link2) => !link2.text)]) {
|
|
8864
8984
|
lookalike(destination(link), { url: link.url });
|
|
8865
8985
|
}
|
|
8986
|
+
for (const link of bare) {
|
|
8987
|
+
lookalike(link.host, { source: "text" });
|
|
8988
|
+
}
|
|
8866
8989
|
for (const host of hosts) {
|
|
8867
8990
|
lookalike(host, { source: "text" });
|
|
8868
8991
|
}
|
|
8869
|
-
const ordered = [...new Set([...
|
|
8992
|
+
const ordered = [...new Set([...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean))];
|
|
8870
8993
|
const firsts = /* @__PURE__ */ new Map();
|
|
8871
8994
|
for (const host of ordered) {
|
|
8872
8995
|
const domain = registrableDomain(host);
|
|
@@ -8880,9 +9003,9 @@ var SpamScanner = class {
|
|
|
8880
9003
|
let answered = 0;
|
|
8881
9004
|
const cloudflare = async () => {
|
|
8882
9005
|
await withTimeout(Promise.all(checked.map(async (host) => {
|
|
8883
|
-
const
|
|
8884
|
-
blocked.push(...
|
|
8885
|
-
if (!
|
|
9006
|
+
const found2 = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
|
|
9007
|
+
blocked.push(...found2);
|
|
9008
|
+
if (!found2.unanswered?.length) {
|
|
8886
9009
|
answered++;
|
|
8887
9010
|
}
|
|
8888
9011
|
})), config.timeout, null);
|
|
@@ -9066,7 +9189,7 @@ var SpamScanner = class {
|
|
|
9066
9189
|
const linkLength = Number.isFinite(config.phishing.maxLength) && config.phishing.maxLength > 0 ? config.phishing.maxLength : 1e6;
|
|
9067
9190
|
const text = typeof mail.text === "string" ? mail.text : "";
|
|
9068
9191
|
const html = typeof mail.html === "string" ? mail.html : "";
|
|
9069
|
-
const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2 });
|
|
9192
|
+
const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2, bareHosts: true });
|
|
9070
9193
|
const linksTruncated = text.length > linkLength || html.length > linkLength * 2;
|
|
9071
9194
|
const plainObfuscation = obfuscationOf(`${mail.subject || ""}
|
|
9072
9195
|
${typeof mail.text === "string" ? mail.text : ""}`, { maxLength: config.maxLength });
|
|
@@ -9207,7 +9330,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
|
|
|
9207
9330
|
riskScore: Math.max(0, ...links.homographs.map((item) => item.riskScore))
|
|
9208
9331
|
}
|
|
9209
9332
|
},
|
|
9210
|
-
|
|
9333
|
+
// (the links of the message, not hosts found without one)
|
|
9334
|
+
links: checkedLinks.filter((link) => !link.bare).map((link) => link.url),
|
|
9211
9335
|
language: extracted.language,
|
|
9212
9336
|
tokens: extracted.words,
|
|
9213
9337
|
mail,
|