spamscanner 7.0.6 → 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 +150 -26
- 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
|
@@ -3001,13 +3001,15 @@ function endsInsideTag(html, end, link) {
|
|
|
3001
3001
|
return joined !== host && (!isHost(host) || isHost(joined) && registrableDomain(joined) !== registrableDomain(host));
|
|
3002
3002
|
}
|
|
3003
3003
|
var MAX_SHOWN_HTML = 2e5;
|
|
3004
|
-
function extractLinks(text, html, { maxLength = 5e5 } = {}) {
|
|
3004
|
+
function extractLinks(text, html, { maxLength = 5e5, bareHosts = false } = {}) {
|
|
3005
3005
|
text = typeof text === "string" ? foldToAscii(text) : text;
|
|
3006
3006
|
html = typeof html === "string" ? foldToAscii(html) : html;
|
|
3007
3007
|
const links = [];
|
|
3008
3008
|
const seen = /* @__PURE__ */ new Set();
|
|
3009
|
+
const linkHosts = /* @__PURE__ */ new Set();
|
|
3010
|
+
const bareFound = [];
|
|
3009
3011
|
const names = /* @__PURE__ */ new Map();
|
|
3010
|
-
const add = (raw, shown = null) => {
|
|
3012
|
+
const add = (raw, shown = null, bare = false) => {
|
|
3011
3013
|
let href = trimEndOf(trimControls(raw.replaceAll(/[\t\n\r]/g, "")).trim(), `).,;:!?'"]`);
|
|
3012
3014
|
if (/^www\./i.test(href)) {
|
|
3013
3015
|
href = `http://${href}`;
|
|
@@ -3031,11 +3033,13 @@ ${shown || ""}`;
|
|
|
3031
3033
|
}
|
|
3032
3034
|
seen.add(key);
|
|
3033
3035
|
const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[(.*)]$/, "$1");
|
|
3036
|
+
linkHosts.add(host);
|
|
3034
3037
|
links.push({
|
|
3035
3038
|
url: url.href.slice(0, MAX_URL),
|
|
3036
3039
|
host,
|
|
3037
3040
|
text: shown,
|
|
3038
|
-
userinfo: Boolean(url.username || url.password)
|
|
3041
|
+
userinfo: Boolean(url.username || url.password),
|
|
3042
|
+
...bare && { bare: true }
|
|
3039
3043
|
});
|
|
3040
3044
|
};
|
|
3041
3045
|
if (typeof html === "string" && html) {
|
|
@@ -3064,10 +3068,92 @@ ${shown || ""}`;
|
|
|
3064
3068
|
}
|
|
3065
3069
|
add(link);
|
|
3066
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);
|
|
3067
3081
|
}
|
|
3068
3082
|
}
|
|
3069
3083
|
return links;
|
|
3070
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
|
+
}
|
|
3071
3157
|
function hostOf(link) {
|
|
3072
3158
|
const authority = link.replace(/^https?:\/\//i, "").split(/[/?#\\]/)[0];
|
|
3073
3159
|
return trimEndOf(authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, ""), `).,;:!?'"]`);
|
|
@@ -5104,17 +5190,47 @@ async function expandAttachments(attachments = [], errors = [], parse = parseMes
|
|
|
5104
5190
|
|
|
5105
5191
|
// src/reputation.js
|
|
5106
5192
|
import { isIP as isIP2 } from "node:net";
|
|
5107
|
-
import { domainToASCII } from "node:url";
|
|
5193
|
+
import { domainToASCII as domainToASCII2 } from "node:url";
|
|
5108
5194
|
|
|
5109
5195
|
// src/dnsbl.js
|
|
5110
5196
|
import dns from "node:dns";
|
|
5111
5197
|
import { isIP } from "node:net";
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
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;
|
|
5222
|
+
}
|
|
5223
|
+
if (start === labels.length) {
|
|
5224
|
+
return null;
|
|
5225
|
+
}
|
|
5226
|
+
if (start === 0) {
|
|
5227
|
+
return name;
|
|
5115
5228
|
}
|
|
5116
|
-
const
|
|
5117
|
-
|
|
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;
|
|
5118
5234
|
}
|
|
5119
5235
|
var CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
|
|
5120
5236
|
var CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
|
|
@@ -5313,9 +5429,10 @@ var DnsChecker = class {
|
|
|
5313
5429
|
return results.filter(Boolean);
|
|
5314
5430
|
}
|
|
5315
5431
|
/**
|
|
5316
|
-
* Ask Cloudflare's filtering resolvers about host names
|
|
5317
|
-
*
|
|
5318
|
-
* 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
|
|
5319
5436
|
* resolver, or from the family resolver when asked) are listed in the
|
|
5320
5437
|
* result's (non-enumerable) `unanswered` property.
|
|
5321
5438
|
* @param {string[]} hosts
|
|
@@ -5328,10 +5445,8 @@ var DnsChecker = class {
|
|
|
5328
5445
|
async checkCloudflare(hosts = [], options = {}) {
|
|
5329
5446
|
const { adult = true, malwareServers = CLOUDFLARE_MALWARE, familyServers = CLOUDFLARE_FAMILY } = options;
|
|
5330
5447
|
const unanswered = [];
|
|
5331
|
-
const
|
|
5332
|
-
|
|
5333
|
-
return null;
|
|
5334
|
-
}
|
|
5448
|
+
const names = [...new Set(hosts.map((host) => lookupName(host)).filter(Boolean))];
|
|
5449
|
+
const results = await Promise.all(names.map(async (host) => {
|
|
5335
5450
|
const malware = await this.answer(host, malwareServers);
|
|
5336
5451
|
if (malware?.includes("0.0.0.0")) {
|
|
5337
5452
|
return { host, category: "malware" };
|
|
@@ -5366,7 +5481,7 @@ function asciiHostname(value) {
|
|
|
5366
5481
|
if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
|
|
5367
5482
|
return null;
|
|
5368
5483
|
}
|
|
5369
|
-
const ascii =
|
|
5484
|
+
const ascii = domainToASCII2(lower);
|
|
5370
5485
|
return ascii.length <= 253 && /^[\w-]{1,63}(?:\.[\w-]{1,63})*$/.test(ascii) ? ascii : null;
|
|
5371
5486
|
}
|
|
5372
5487
|
function normalizeEntry(value) {
|
|
@@ -5945,7 +6060,7 @@ import net from "node:net";
|
|
|
5945
6060
|
var package_default = {
|
|
5946
6061
|
name: "spamscanner",
|
|
5947
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.",
|
|
5948
|
-
version: "7.0.
|
|
6063
|
+
version: "7.0.7",
|
|
5949
6064
|
author: "Forward Email (https://forwardemail.net)",
|
|
5950
6065
|
bin: {
|
|
5951
6066
|
spamscanner: "./dist/esm/cli.js"
|
|
@@ -8887,15 +9002,23 @@ var SpamScanner = class {
|
|
|
8887
9002
|
message: `A link shows "${link.text}" but goes to ${link.host}`
|
|
8888
9003
|
}));
|
|
8889
9004
|
}
|
|
8890
|
-
|
|
9005
|
+
const name = lookupName(destination(link));
|
|
9006
|
+
if (name) {
|
|
9007
|
+
seenHosts.add(name);
|
|
9008
|
+
}
|
|
8891
9009
|
}
|
|
8892
|
-
|
|
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)]) {
|
|
8893
9013
|
lookalike(destination(link), { url: link.url });
|
|
8894
9014
|
}
|
|
9015
|
+
for (const link of bare) {
|
|
9016
|
+
lookalike(link.host, { source: "text" });
|
|
9017
|
+
}
|
|
8895
9018
|
for (const host of hosts) {
|
|
8896
9019
|
lookalike(host, { source: "text" });
|
|
8897
9020
|
}
|
|
8898
|
-
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))];
|
|
8899
9022
|
const firsts = /* @__PURE__ */ new Map();
|
|
8900
9023
|
for (const host of ordered) {
|
|
8901
9024
|
const domain = registrableDomain(host);
|
|
@@ -8909,9 +9032,9 @@ var SpamScanner = class {
|
|
|
8909
9032
|
let answered = 0;
|
|
8910
9033
|
const cloudflare = async () => {
|
|
8911
9034
|
await withTimeout(Promise.all(checked.map(async (host) => {
|
|
8912
|
-
const
|
|
8913
|
-
blocked.push(...
|
|
8914
|
-
if (!
|
|
9035
|
+
const found2 = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
|
|
9036
|
+
blocked.push(...found2);
|
|
9037
|
+
if (!found2.unanswered?.length) {
|
|
8915
9038
|
answered++;
|
|
8916
9039
|
}
|
|
8917
9040
|
})), config.timeout, null);
|
|
@@ -9095,7 +9218,7 @@ var SpamScanner = class {
|
|
|
9095
9218
|
const linkLength = Number.isFinite(config.phishing.maxLength) && config.phishing.maxLength > 0 ? config.phishing.maxLength : 1e6;
|
|
9096
9219
|
const text = typeof mail.text === "string" ? mail.text : "";
|
|
9097
9220
|
const html = typeof mail.html === "string" ? mail.html : "";
|
|
9098
|
-
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 });
|
|
9099
9222
|
const linksTruncated = text.length > linkLength || html.length > linkLength * 2;
|
|
9100
9223
|
const plainObfuscation = obfuscationOf(`${mail.subject || ""}
|
|
9101
9224
|
${typeof mail.text === "string" ? mail.text : ""}`, { maxLength: config.maxLength });
|
|
@@ -9236,7 +9359,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
|
|
|
9236
9359
|
riskScore: Math.max(0, ...links.homographs.map((item) => item.riskScore))
|
|
9237
9360
|
}
|
|
9238
9361
|
},
|
|
9239
|
-
|
|
9362
|
+
// (the links of the message, not hosts found without one)
|
|
9363
|
+
links: checkedLinks.filter((link) => !link.bare).map((link) => link.url),
|
|
9240
9364
|
language: extracted.language,
|
|
9241
9365
|
tokens: extracted.words,
|
|
9242
9366
|
mail,
|