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/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
- function isLookupName(name) {
3909
- if (typeof name !== "string") {
3910
- return false;
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 host = name.replaceAll(/^\[|]$/g, "").replace(/\.$/, "");
3913
- return host !== "" && isIP(host) === 0 && host.length <= 253 && host.split(".").every((label) => label.length > 0 && label.length <= 63);
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. A name blocked by
4113
- * the malware resolver is "malware"; one blocked only by the family
4114
- * resolver is "adult". The hosts that got no answer (from the malware
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 results = await Promise.all(hosts.map(async (host) => {
4128
- if (!isLookupName(host)) {
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 = domainToASCII(lower);
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.6",
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
- seenHosts.add(destination(link));
8976
+ const name = lookupName(destination(link));
8977
+ if (name) {
8978
+ seenHosts.add(name);
8979
+ }
8862
8980
  }
8863
- for (const link of [...links.filter((link2) => link2.text), ...links.filter((link2) => !link2.text)]) {
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([...links.filter((link) => link.text), ...links.filter((link) => !link.text)].map((link) => destination(link)))];
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 found = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
8884
- blocked.push(...found);
8885
- if (!found.unanswered?.length) {
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
- links: checkedLinks.map((link) => link.url),
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,