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/cli.js
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
statSync as statSync2
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import process3 from "node:process";
|
|
16
|
-
import { pipeline } from "node:stream";
|
|
17
16
|
import { format, parseArgs } from "node:util";
|
|
18
17
|
|
|
19
18
|
// src/classifier.js
|
|
@@ -3002,13 +3001,15 @@ function endsInsideTag(html, end, link) {
|
|
|
3002
3001
|
return joined !== host && (!isHost(host) || isHost(joined) && registrableDomain(joined) !== registrableDomain(host));
|
|
3003
3002
|
}
|
|
3004
3003
|
var MAX_SHOWN_HTML = 2e5;
|
|
3005
|
-
function extractLinks(text, html, { maxLength = 5e5 } = {}) {
|
|
3004
|
+
function extractLinks(text, html, { maxLength = 5e5, bareHosts = false } = {}) {
|
|
3006
3005
|
text = typeof text === "string" ? foldToAscii(text) : text;
|
|
3007
3006
|
html = typeof html === "string" ? foldToAscii(html) : html;
|
|
3008
3007
|
const links = [];
|
|
3009
3008
|
const seen = /* @__PURE__ */ new Set();
|
|
3009
|
+
const linkHosts = /* @__PURE__ */ new Set();
|
|
3010
|
+
const bareFound = [];
|
|
3010
3011
|
const names = /* @__PURE__ */ new Map();
|
|
3011
|
-
const add = (raw, shown = null) => {
|
|
3012
|
+
const add = (raw, shown = null, bare = false) => {
|
|
3012
3013
|
let href = trimEndOf(trimControls(raw.replaceAll(/[\t\n\r]/g, "")).trim(), `).,;:!?'"]`);
|
|
3013
3014
|
if (/^www\./i.test(href)) {
|
|
3014
3015
|
href = `http://${href}`;
|
|
@@ -3032,11 +3033,13 @@ ${shown || ""}`;
|
|
|
3032
3033
|
}
|
|
3033
3034
|
seen.add(key);
|
|
3034
3035
|
const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[(.*)]$/, "$1");
|
|
3036
|
+
linkHosts.add(host);
|
|
3035
3037
|
links.push({
|
|
3036
3038
|
url: url.href.slice(0, MAX_URL),
|
|
3037
3039
|
host,
|
|
3038
3040
|
text: shown,
|
|
3039
|
-
userinfo: Boolean(url.username || url.password)
|
|
3041
|
+
userinfo: Boolean(url.username || url.password),
|
|
3042
|
+
...bare && { bare: true }
|
|
3040
3043
|
});
|
|
3041
3044
|
};
|
|
3042
3045
|
if (typeof html === "string" && html) {
|
|
@@ -3065,10 +3068,92 @@ ${shown || ""}`;
|
|
|
3065
3068
|
}
|
|
3066
3069
|
add(link);
|
|
3067
3070
|
}
|
|
3071
|
+
if (bareHosts && index < 2) {
|
|
3072
|
+
bareLinkHosts(read, (host) => {
|
|
3073
|
+
bareFound.push(host);
|
|
3074
|
+
});
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
for (const host of bareFound) {
|
|
3079
|
+
if (!linkHosts.has(host)) {
|
|
3080
|
+
add(`http://${host}/`, null, true);
|
|
3068
3081
|
}
|
|
3069
3082
|
}
|
|
3070
3083
|
return links;
|
|
3071
3084
|
}
|
|
3085
|
+
var IN_WORD = /[\p{L}\p{N}\p{M}_%]/u;
|
|
3086
|
+
var MAX_BARE_NAME = 760;
|
|
3087
|
+
function isHostCode(code) {
|
|
3088
|
+
return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
|
|
3089
|
+
}
|
|
3090
|
+
function endsLink(char) {
|
|
3091
|
+
return /[\s<>"'\u0060]/u.test(char);
|
|
3092
|
+
}
|
|
3093
|
+
function bareLinkHosts(text, add) {
|
|
3094
|
+
const found = /* @__PURE__ */ new Set();
|
|
3095
|
+
let previous = -1;
|
|
3096
|
+
let read = 0;
|
|
3097
|
+
let space = -1;
|
|
3098
|
+
let scheme = -1;
|
|
3099
|
+
for (let slash = text.indexOf("/"); slash !== -1; slash = text.indexOf("/", slash + 1)) {
|
|
3100
|
+
const floor = Math.max(previous + 1, slash - MAX_BARE_NAME);
|
|
3101
|
+
previous = slash;
|
|
3102
|
+
let end = slash;
|
|
3103
|
+
while (end > floor && text.codePointAt(end - 1) >= 48 && text.codePointAt(end - 1) <= 57) {
|
|
3104
|
+
end--;
|
|
3105
|
+
}
|
|
3106
|
+
end = end > floor && text[end - 1] === ":" ? end - 1 : slash;
|
|
3107
|
+
let start = end;
|
|
3108
|
+
while (start > floor && isHostCode(text.codePointAt(start - 1))) {
|
|
3109
|
+
start--;
|
|
3110
|
+
}
|
|
3111
|
+
if (start === end || start > 0 && (text[start - 1] === "/" || IN_WORD.test(text[start - 1]))) {
|
|
3112
|
+
continue;
|
|
3113
|
+
}
|
|
3114
|
+
for (; read < start; read++) {
|
|
3115
|
+
if (endsLink(text[read])) {
|
|
3116
|
+
space = read;
|
|
3117
|
+
} else if (text[read] === "/" && text[read - 1] === "/" && text[read - 2] === ":") {
|
|
3118
|
+
scheme = read;
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
if (scheme > space) {
|
|
3122
|
+
continue;
|
|
3123
|
+
}
|
|
3124
|
+
while (end > start && text[end - 1] === ".") {
|
|
3125
|
+
end--;
|
|
3126
|
+
}
|
|
3127
|
+
const labels = text.slice(start, end).split(".");
|
|
3128
|
+
const tld = labels.pop();
|
|
3129
|
+
if (!/^(?:[a-z]{2,24}|xn--[a-z\d-]{1,59})$/i.test(tld)) {
|
|
3130
|
+
continue;
|
|
3131
|
+
}
|
|
3132
|
+
const parts = [];
|
|
3133
|
+
while (labels.length > 0 && parts.length < 11) {
|
|
3134
|
+
const label = labels.pop();
|
|
3135
|
+
if (!/^[a-z\d-]{1,63}$/i.test(label)) {
|
|
3136
|
+
break;
|
|
3137
|
+
}
|
|
3138
|
+
parts.unshift(label);
|
|
3139
|
+
}
|
|
3140
|
+
if (parts.length === 0) {
|
|
3141
|
+
continue;
|
|
3142
|
+
}
|
|
3143
|
+
parts[0] = parts[0].replace(/^-+/, "");
|
|
3144
|
+
if (!parts[0]) {
|
|
3145
|
+
continue;
|
|
3146
|
+
}
|
|
3147
|
+
const host = `${parts.join(".")}.${tld}`.toLowerCase();
|
|
3148
|
+
if (found.has(host)) {
|
|
3149
|
+
continue;
|
|
3150
|
+
}
|
|
3151
|
+
found.add(host);
|
|
3152
|
+
if (isHost(host)) {
|
|
3153
|
+
add(host);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3072
3157
|
function hostOf(link) {
|
|
3073
3158
|
const authority = link.replace(/^https?:\/\//i, "").split(/[/?#\\]/)[0];
|
|
3074
3159
|
return trimEndOf(authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, ""), `).,;:!?'"]`);
|
|
@@ -5105,17 +5190,47 @@ async function expandAttachments(attachments = [], errors = [], parse = parseMes
|
|
|
5105
5190
|
|
|
5106
5191
|
// src/reputation.js
|
|
5107
5192
|
import { isIP as isIP2 } from "node:net";
|
|
5108
|
-
import { domainToASCII } from "node:url";
|
|
5193
|
+
import { domainToASCII as domainToASCII2 } from "node:url";
|
|
5109
5194
|
|
|
5110
5195
|
// src/dnsbl.js
|
|
5111
5196
|
import dns from "node:dns";
|
|
5112
5197
|
import { isIP } from "node:net";
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5198
|
+
import { domainToASCII } from "node:url";
|
|
5199
|
+
import { getPublicSuffix } from "tldts";
|
|
5200
|
+
var LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
|
|
5201
|
+
function lookupName(host) {
|
|
5202
|
+
if (typeof host !== "string") {
|
|
5203
|
+
return null;
|
|
5204
|
+
}
|
|
5205
|
+
let end = host.length;
|
|
5206
|
+
while (end > 0 && host[end - 1] === ".") {
|
|
5207
|
+
end--;
|
|
5208
|
+
}
|
|
5209
|
+
let name = host.slice(0, end).toLowerCase();
|
|
5210
|
+
if (/[\u0080-\u{10FFFF}]/u.test(name)) {
|
|
5211
|
+
name = domainToASCII(name) || name;
|
|
5212
|
+
}
|
|
5213
|
+
if (isIP(name.replaceAll(/^\[|]$/g, "")) !== 0) {
|
|
5214
|
+
return null;
|
|
5215
|
+
}
|
|
5216
|
+
const labels = name.split(".");
|
|
5217
|
+
let start = labels.length;
|
|
5218
|
+
let length = -1;
|
|
5219
|
+
while (start > 0 && LOOKUP_LABEL.test(labels[start - 1]) && length + 1 + labels[start - 1].length <= 253) {
|
|
5220
|
+
start--;
|
|
5221
|
+
length += 1 + labels[start].length;
|
|
5116
5222
|
}
|
|
5117
|
-
|
|
5118
|
-
|
|
5223
|
+
if (start === labels.length) {
|
|
5224
|
+
return null;
|
|
5225
|
+
}
|
|
5226
|
+
if (start === 0) {
|
|
5227
|
+
return name;
|
|
5228
|
+
}
|
|
5229
|
+
const suffix = labels.slice(start).join(".");
|
|
5230
|
+
if (start === labels.length - 1 || getPublicSuffix(suffix, { allowPrivateDomains: true }) === suffix) {
|
|
5231
|
+
return null;
|
|
5232
|
+
}
|
|
5233
|
+
return suffix;
|
|
5119
5234
|
}
|
|
5120
5235
|
var CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
|
|
5121
5236
|
var CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
|
|
@@ -5314,9 +5429,10 @@ var DnsChecker = class {
|
|
|
5314
5429
|
return results.filter(Boolean);
|
|
5315
5430
|
}
|
|
5316
5431
|
/**
|
|
5317
|
-
* Ask Cloudflare's filtering resolvers about host names
|
|
5318
|
-
*
|
|
5319
|
-
* resolver is "
|
|
5432
|
+
* Ask Cloudflare's filtering resolvers about host names, each by the name
|
|
5433
|
+
* it is looked up as (see lookupName), once; the findings name it. A name
|
|
5434
|
+
* blocked by the malware resolver is "malware"; one blocked only by the
|
|
5435
|
+
* family resolver is "adult". The hosts that got no answer (from the malware
|
|
5320
5436
|
* resolver, or from the family resolver when asked) are listed in the
|
|
5321
5437
|
* result's (non-enumerable) `unanswered` property.
|
|
5322
5438
|
* @param {string[]} hosts
|
|
@@ -5329,10 +5445,8 @@ var DnsChecker = class {
|
|
|
5329
5445
|
async checkCloudflare(hosts = [], options = {}) {
|
|
5330
5446
|
const { adult = true, malwareServers = CLOUDFLARE_MALWARE, familyServers = CLOUDFLARE_FAMILY } = options;
|
|
5331
5447
|
const unanswered = [];
|
|
5332
|
-
const
|
|
5333
|
-
|
|
5334
|
-
return null;
|
|
5335
|
-
}
|
|
5448
|
+
const names = [...new Set(hosts.map((host) => lookupName(host)).filter(Boolean))];
|
|
5449
|
+
const results = await Promise.all(names.map(async (host) => {
|
|
5336
5450
|
const malware = await this.answer(host, malwareServers);
|
|
5337
5451
|
if (malware?.includes("0.0.0.0")) {
|
|
5338
5452
|
return { host, category: "malware" };
|
|
@@ -5367,7 +5481,7 @@ function asciiHostname(value) {
|
|
|
5367
5481
|
if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
|
|
5368
5482
|
return null;
|
|
5369
5483
|
}
|
|
5370
|
-
const ascii =
|
|
5484
|
+
const ascii = domainToASCII2(lower);
|
|
5371
5485
|
return ascii.length <= 253 && /^[\w-]{1,63}(?:\.[\w-]{1,63})*$/.test(ascii) ? ascii : null;
|
|
5372
5486
|
}
|
|
5373
5487
|
function normalizeEntry(value) {
|
|
@@ -5946,7 +6060,7 @@ import net from "node:net";
|
|
|
5946
6060
|
var package_default = {
|
|
5947
6061
|
name: "spamscanner",
|
|
5948
6062
|
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.",
|
|
5949
|
-
version: "7.0.
|
|
6063
|
+
version: "7.0.7",
|
|
5950
6064
|
author: "Forward Email (https://forwardemail.net)",
|
|
5951
6065
|
bin: {
|
|
5952
6066
|
spamscanner: "./dist/esm/cli.js"
|
|
@@ -8888,15 +9002,23 @@ var SpamScanner = class {
|
|
|
8888
9002
|
message: `A link shows "${link.text}" but goes to ${link.host}`
|
|
8889
9003
|
}));
|
|
8890
9004
|
}
|
|
8891
|
-
|
|
9005
|
+
const name = lookupName(destination(link));
|
|
9006
|
+
if (name) {
|
|
9007
|
+
seenHosts.add(name);
|
|
9008
|
+
}
|
|
8892
9009
|
}
|
|
8893
|
-
|
|
9010
|
+
const found = links.filter((link) => !link.bare);
|
|
9011
|
+
const bare = links.filter((link) => link.bare);
|
|
9012
|
+
for (const link of [...found.filter((link2) => link2.text), ...found.filter((link2) => !link2.text)]) {
|
|
8894
9013
|
lookalike(destination(link), { url: link.url });
|
|
8895
9014
|
}
|
|
9015
|
+
for (const link of bare) {
|
|
9016
|
+
lookalike(link.host, { source: "text" });
|
|
9017
|
+
}
|
|
8896
9018
|
for (const host of hosts) {
|
|
8897
9019
|
lookalike(host, { source: "text" });
|
|
8898
9020
|
}
|
|
8899
|
-
const ordered = [...new Set([...
|
|
9021
|
+
const ordered = [...new Set([...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean))];
|
|
8900
9022
|
const firsts = /* @__PURE__ */ new Map();
|
|
8901
9023
|
for (const host of ordered) {
|
|
8902
9024
|
const domain = registrableDomain(host);
|
|
@@ -8910,9 +9032,9 @@ var SpamScanner = class {
|
|
|
8910
9032
|
let answered = 0;
|
|
8911
9033
|
const cloudflare = async () => {
|
|
8912
9034
|
await withTimeout(Promise.all(checked.map(async (host) => {
|
|
8913
|
-
const
|
|
8914
|
-
blocked.push(...
|
|
8915
|
-
if (!
|
|
9035
|
+
const found2 = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
|
|
9036
|
+
blocked.push(...found2);
|
|
9037
|
+
if (!found2.unanswered?.length) {
|
|
8916
9038
|
answered++;
|
|
8917
9039
|
}
|
|
8918
9040
|
})), config.timeout, null);
|
|
@@ -9096,7 +9218,7 @@ var SpamScanner = class {
|
|
|
9096
9218
|
const linkLength = Number.isFinite(config.phishing.maxLength) && config.phishing.maxLength > 0 ? config.phishing.maxLength : 1e6;
|
|
9097
9219
|
const text = typeof mail.text === "string" ? mail.text : "";
|
|
9098
9220
|
const html = typeof mail.html === "string" ? mail.html : "";
|
|
9099
|
-
const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2 });
|
|
9221
|
+
const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2, bareHosts: true });
|
|
9100
9222
|
const linksTruncated = text.length > linkLength || html.length > linkLength * 2;
|
|
9101
9223
|
const plainObfuscation = obfuscationOf(`${mail.subject || ""}
|
|
9102
9224
|
${typeof mail.text === "string" ? mail.text : ""}`, { maxLength: config.maxLength });
|
|
@@ -9237,7 +9359,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
|
|
|
9237
9359
|
riskScore: Math.max(0, ...links.homographs.map((item) => item.riskScore))
|
|
9238
9360
|
}
|
|
9239
9361
|
},
|
|
9240
|
-
|
|
9362
|
+
// (the links of the message, not hosts found without one)
|
|
9363
|
+
links: checkedLinks.filter((link) => !link.bare).map((link) => link.url),
|
|
9241
9364
|
language: extracted.language,
|
|
9242
9365
|
tokens: extracted.words,
|
|
9243
9366
|
mail,
|
|
@@ -9645,24 +9768,44 @@ function listen(server, values, defaultPort, io) {
|
|
|
9645
9768
|
function deliver(sendmail, args, message, rest) {
|
|
9646
9769
|
return new Promise((resolve, reject) => {
|
|
9647
9770
|
const child = spawn(sendmail, args, { stdio: ["pipe", "ignore", "inherit"] });
|
|
9771
|
+
let written = Promise.resolve(true);
|
|
9648
9772
|
child.on("error", reject);
|
|
9649
|
-
child.on("close", (code) =>
|
|
9773
|
+
child.on("close", async (code) => {
|
|
9774
|
+
resolve(await written ? code ?? "a signal" : "a signal");
|
|
9775
|
+
});
|
|
9650
9776
|
child.stdin.on("error", () => {
|
|
9651
9777
|
});
|
|
9652
9778
|
if (!rest) {
|
|
9653
9779
|
child.stdin.end(message);
|
|
9654
9780
|
return;
|
|
9655
9781
|
}
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9659
|
-
|
|
9660
|
-
|
|
9661
|
-
|
|
9782
|
+
written = (async () => {
|
|
9783
|
+
try {
|
|
9784
|
+
for await (const chunk2 of prepend(message, rest)) {
|
|
9785
|
+
await new Promise((resolve2, reject2) => {
|
|
9786
|
+
child.stdin.write(chunk2, (error) => {
|
|
9787
|
+
if (error) {
|
|
9788
|
+
reject2(error);
|
|
9789
|
+
} else {
|
|
9790
|
+
resolve2();
|
|
9791
|
+
}
|
|
9792
|
+
});
|
|
9793
|
+
});
|
|
9794
|
+
}
|
|
9795
|
+
child.stdin.end();
|
|
9796
|
+
return true;
|
|
9797
|
+
} catch {
|
|
9798
|
+
child.kill("SIGKILL");
|
|
9799
|
+
child.stdin.destroy();
|
|
9800
|
+
return false;
|
|
9662
9801
|
}
|
|
9663
|
-
});
|
|
9802
|
+
})();
|
|
9664
9803
|
});
|
|
9665
9804
|
}
|
|
9805
|
+
async function* prepend(first, rest) {
|
|
9806
|
+
yield first;
|
|
9807
|
+
yield* rest;
|
|
9808
|
+
}
|
|
9666
9809
|
async function deliverOrDefer({ sendmail, args, message, rest }, stderr) {
|
|
9667
9810
|
const code = await deliver(sendmail, args, message, rest);
|
|
9668
9811
|
if (code !== 0) {
|