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/esm/index.js CHANGED
@@ -2116,6 +2116,9 @@ function unwrapLink(address2) {
2116
2116
  }
2117
2117
  for (let depth = 0; depth < 3; depth++) {
2118
2118
  const target = gatewayTarget(url);
2119
+ if (!target) {
2120
+ break;
2121
+ }
2119
2122
  let next;
2120
2123
  try {
2121
2124
  next = new URL(target);
@@ -2378,13 +2381,15 @@ function endsInsideTag(html, end, link) {
2378
2381
  return joined !== host && (!isHost(host) || isHost(joined) && registrableDomain(joined) !== registrableDomain(host));
2379
2382
  }
2380
2383
  var MAX_SHOWN_HTML = 2e5;
2381
- function extractLinks(text, html, { maxLength = 5e5 } = {}) {
2384
+ function extractLinks(text, html, { maxLength = 5e5, bareHosts = false } = {}) {
2382
2385
  text = typeof text === "string" ? foldToAscii(text) : text;
2383
2386
  html = typeof html === "string" ? foldToAscii(html) : html;
2384
2387
  const links = [];
2385
2388
  const seen = /* @__PURE__ */ new Set();
2389
+ const linkHosts = /* @__PURE__ */ new Set();
2390
+ const bareFound = [];
2386
2391
  const names = /* @__PURE__ */ new Map();
2387
- const add = (raw, shown = null) => {
2392
+ const add = (raw, shown = null, bare = false) => {
2388
2393
  let href = trimEndOf(trimControls(raw.replaceAll(/[\t\n\r]/g, "")).trim(), `).,;:!?'"]`);
2389
2394
  if (/^www\./i.test(href)) {
2390
2395
  href = `http://${href}`;
@@ -2408,11 +2413,13 @@ ${shown || ""}`;
2408
2413
  }
2409
2414
  seen.add(key);
2410
2415
  const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[(.*)]$/, "$1");
2416
+ linkHosts.add(host);
2411
2417
  links.push({
2412
2418
  url: url.href.slice(0, MAX_URL),
2413
2419
  host,
2414
2420
  text: shown,
2415
- userinfo: Boolean(url.username || url.password)
2421
+ userinfo: Boolean(url.username || url.password),
2422
+ ...bare && { bare: true }
2416
2423
  });
2417
2424
  };
2418
2425
  if (typeof html === "string" && html) {
@@ -2441,10 +2448,92 @@ ${shown || ""}`;
2441
2448
  }
2442
2449
  add(link);
2443
2450
  }
2451
+ if (bareHosts && index < 2) {
2452
+ bareLinkHosts(read, (host) => {
2453
+ bareFound.push(host);
2454
+ });
2455
+ }
2456
+ }
2457
+ }
2458
+ for (const host of bareFound) {
2459
+ if (!linkHosts.has(host)) {
2460
+ add(`http://${host}/`, null, true);
2444
2461
  }
2445
2462
  }
2446
2463
  return links;
2447
2464
  }
2465
+ var IN_WORD = /[\p{L}\p{N}\p{M}_%]/u;
2466
+ var MAX_BARE_NAME = 760;
2467
+ function isHostCode(code) {
2468
+ return code >= 97 && code <= 122 || code >= 65 && code <= 90 || code >= 48 && code <= 57 || code === 45 || code === 46;
2469
+ }
2470
+ function endsLink(char) {
2471
+ return /[\s<>"'\u0060]/u.test(char);
2472
+ }
2473
+ function bareLinkHosts(text, add) {
2474
+ const found = /* @__PURE__ */ new Set();
2475
+ let previous = -1;
2476
+ let read = 0;
2477
+ let space = -1;
2478
+ let scheme = -1;
2479
+ for (let slash = text.indexOf("/"); slash !== -1; slash = text.indexOf("/", slash + 1)) {
2480
+ const floor = Math.max(previous + 1, slash - MAX_BARE_NAME);
2481
+ previous = slash;
2482
+ let end = slash;
2483
+ while (end > floor && text.codePointAt(end - 1) >= 48 && text.codePointAt(end - 1) <= 57) {
2484
+ end--;
2485
+ }
2486
+ end = end > floor && text[end - 1] === ":" ? end - 1 : slash;
2487
+ let start = end;
2488
+ while (start > floor && isHostCode(text.codePointAt(start - 1))) {
2489
+ start--;
2490
+ }
2491
+ if (start === end || start > 0 && (text[start - 1] === "/" || IN_WORD.test(text[start - 1]))) {
2492
+ continue;
2493
+ }
2494
+ for (; read < start; read++) {
2495
+ if (endsLink(text[read])) {
2496
+ space = read;
2497
+ } else if (text[read] === "/" && text[read - 1] === "/" && text[read - 2] === ":") {
2498
+ scheme = read;
2499
+ }
2500
+ }
2501
+ if (scheme > space) {
2502
+ continue;
2503
+ }
2504
+ while (end > start && text[end - 1] === ".") {
2505
+ end--;
2506
+ }
2507
+ const labels = text.slice(start, end).split(".");
2508
+ const tld = labels.pop();
2509
+ if (!/^(?:[a-z]{2,24}|xn--[a-z\d-]{1,59})$/i.test(tld)) {
2510
+ continue;
2511
+ }
2512
+ const parts = [];
2513
+ while (labels.length > 0 && parts.length < 11) {
2514
+ const label = labels.pop();
2515
+ if (!/^[a-z\d-]{1,63}$/i.test(label)) {
2516
+ break;
2517
+ }
2518
+ parts.unshift(label);
2519
+ }
2520
+ if (parts.length === 0) {
2521
+ continue;
2522
+ }
2523
+ parts[0] = parts[0].replace(/^-+/, "");
2524
+ if (!parts[0]) {
2525
+ continue;
2526
+ }
2527
+ const host = `${parts.join(".")}.${tld}`.toLowerCase();
2528
+ if (found.has(host)) {
2529
+ continue;
2530
+ }
2531
+ found.add(host);
2532
+ if (isHost(host)) {
2533
+ add(host);
2534
+ }
2535
+ }
2536
+ }
2448
2537
  function hostOf(link) {
2449
2538
  const authority = link.replace(/^https?:\/\//i, "").split(/[/?#\\]/)[0];
2450
2539
  return trimEndOf(authority.slice(authority.lastIndexOf("@") + 1).replace(/:\d*$/, ""), `).,;:!?'"]`);
@@ -3861,24 +3950,38 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
3861
3950
  }
3862
3951
  }
3863
3952
  }
3864
- async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
3953
+ async function expandAttachments(attachments = [], errors = [], parse = parseMessage, unopened = []) {
3865
3954
  const all = [];
3866
3955
  const deadline = Date.now() + PARSE_TIMEOUT;
3867
3956
  let budget = MAX_NESTED_BYTES;
3868
3957
  let nested = 0;
3958
+ let tooMany = false;
3869
3959
  const visit = async (list, prefix, depth) => {
3870
3960
  for (const attachment of list) {
3871
3961
  if (prefix) {
3872
3962
  if (nested >= MAX_ATTACHMENTS) {
3963
+ if (!tooMany) {
3964
+ tooMany = true;
3965
+ unopened.push({ filename: prefix, reason: `more than ${MAX_ATTACHMENTS} attachments in attached messages` });
3966
+ }
3873
3967
  return;
3874
3968
  }
3875
3969
  nested++;
3876
3970
  }
3877
3971
  const name = attachment.filename || (prefix ? "unnamed attachment" : "");
3972
+ const filename = prefix ? `${prefix}/${name}` : name || "attached message";
3878
3973
  all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
3879
3974
  const { content } = attachment;
3880
3975
  const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
3881
- if (attached && depth < MAX_NESTED_DEPTH && Buffer2.isBuffer(content) && content.length <= budget) {
3976
+ if (attached && Buffer2.isBuffer(content) && depth >= MAX_NESTED_DEPTH) {
3977
+ unopened.push({ filename, reason: `nested more than ${MAX_NESTED_DEPTH} deep` });
3978
+ continue;
3979
+ }
3980
+ if (attached && Buffer2.isBuffer(content) && content.length > budget) {
3981
+ unopened.push({ filename, reason: `past ${MAX_NESTED_BYTES / 1024 / 1024} MB of attached messages` });
3982
+ continue;
3983
+ }
3984
+ if (attached && Buffer2.isBuffer(content)) {
3882
3985
  budget -= content.length;
3883
3986
  let inner;
3884
3987
  try {
@@ -3900,17 +4003,47 @@ async function expandAttachments(attachments = [], errors = [], parse = parseMes
3900
4003
 
3901
4004
  // src/reputation.js
3902
4005
  import { isIP as isIP2 } from "node:net";
3903
- import { domainToASCII } from "node:url";
4006
+ import { domainToASCII as domainToASCII2 } from "node:url";
3904
4007
 
3905
4008
  // src/dnsbl.js
3906
4009
  import dns from "node:dns";
3907
4010
  import { isIP } from "node:net";
3908
- function isLookupName(name) {
3909
- if (typeof name !== "string") {
3910
- return false;
4011
+ import { domainToASCII } from "node:url";
4012
+ import { getPublicSuffix } from "tldts";
4013
+ var LOOKUP_LABEL = /^[a-z\d_-]{1,63}$/;
4014
+ function lookupName(host) {
4015
+ if (typeof host !== "string") {
4016
+ return null;
4017
+ }
4018
+ let end = host.length;
4019
+ while (end > 0 && host[end - 1] === ".") {
4020
+ end--;
4021
+ }
4022
+ let name = host.slice(0, end).toLowerCase();
4023
+ if (/[\u0080-\u{10FFFF}]/u.test(name)) {
4024
+ name = domainToASCII(name) || name;
3911
4025
  }
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);
4026
+ if (isIP(name.replaceAll(/^\[|]$/g, "")) !== 0) {
4027
+ return null;
4028
+ }
4029
+ const labels = name.split(".");
4030
+ let start = labels.length;
4031
+ let length = -1;
4032
+ while (start > 0 && LOOKUP_LABEL.test(labels[start - 1]) && length + 1 + labels[start - 1].length <= 253) {
4033
+ start--;
4034
+ length += 1 + labels[start].length;
4035
+ }
4036
+ if (start === labels.length) {
4037
+ return null;
4038
+ }
4039
+ if (start === 0) {
4040
+ return name;
4041
+ }
4042
+ const suffix = labels.slice(start).join(".");
4043
+ if (start === labels.length - 1 || getPublicSuffix(suffix, { allowPrivateDomains: true }) === suffix) {
4044
+ return null;
4045
+ }
4046
+ return suffix;
3914
4047
  }
3915
4048
  var CLOUDFLARE_MALWARE = ["1.1.1.2", "1.0.0.2"];
3916
4049
  var CLOUDFLARE_FAMILY = ["1.1.1.3", "1.0.0.3"];
@@ -4109,9 +4242,10 @@ var DnsChecker = class {
4109
4242
  return results.filter(Boolean);
4110
4243
  }
4111
4244
  /**
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
4245
+ * Ask Cloudflare's filtering resolvers about host names, each by the name
4246
+ * it is looked up as (see lookupName), once; the findings name it. A name
4247
+ * blocked by the malware resolver is "malware"; one blocked only by the
4248
+ * family resolver is "adult". The hosts that got no answer (from the malware
4115
4249
  * resolver, or from the family resolver when asked) are listed in the
4116
4250
  * result's (non-enumerable) `unanswered` property.
4117
4251
  * @param {string[]} hosts
@@ -4124,10 +4258,8 @@ var DnsChecker = class {
4124
4258
  async checkCloudflare(hosts = [], options = {}) {
4125
4259
  const { adult = true, malwareServers = CLOUDFLARE_MALWARE, familyServers = CLOUDFLARE_FAMILY } = options;
4126
4260
  const unanswered = [];
4127
- const results = await Promise.all(hosts.map(async (host) => {
4128
- if (!isLookupName(host)) {
4129
- return null;
4130
- }
4261
+ const names = [...new Set(hosts.map((host) => lookupName(host)).filter(Boolean))];
4262
+ const results = await Promise.all(names.map(async (host) => {
4131
4263
  const malware = await this.answer(host, malwareServers);
4132
4264
  if (malware?.includes("0.0.0.0")) {
4133
4265
  return { host, category: "malware" };
@@ -4231,7 +4363,7 @@ function asciiHostname(value) {
4231
4363
  if (lower === "" || /[\0-,/:-^`{-\u007F]/.test(lower)) {
4232
4364
  return null;
4233
4365
  }
4234
- const ascii = domainToASCII(lower);
4366
+ const ascii = domainToASCII2(lower);
4235
4367
  return ascii.length <= 253 && /^[\w-]{1,63}(?:\.[\w-]{1,63})*$/.test(ascii) ? ascii : null;
4236
4368
  }
4237
4369
  function normalizeEntry(value) {
@@ -6916,7 +7048,7 @@ function scoreResults(results, options) {
6916
7048
  var package_default = {
6917
7049
  name: "spamscanner",
6918
7050
  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",
7051
+ version: "7.0.8",
6920
7052
  author: "Forward Email (https://forwardemail.net)",
6921
7053
  bin: {
6922
7054
  spamscanner: "./dist/esm/cli.js"
@@ -8718,14 +8850,16 @@ var SpamScanner = class {
8718
8850
  * a header field ("Name: value", or an mbox "From " line) is read as the
8719
8851
  * body of a message with no header, so its text is the input; other
8720
8852
  * input mailparser cannot read gives a message with no text. Other errors
8721
- * are thrown.
8853
+ * are thrown. `error` is the parser's limit the message exceeds, so that
8854
+ * only its first parts were read (as scan() reports it, see parseError),
8855
+ * or null.
8722
8856
  * @param {Buffer|string|Uint8Array|AsyncIterable} source
8723
- * @returns {Promise<{raw: Buffer, mail: object}>}
8857
+ * @returns {Promise<{raw: Buffer, mail: object, error: string|null}>}
8724
8858
  */
8725
8859
  async parse(source) {
8726
8860
  const raw = await toBuffer(source);
8727
- const { mail } = await parseWithin(withHeader(raw));
8728
- return { raw, mail };
8861
+ const { mail, error } = await parseWithin(withHeader(raw));
8862
+ return { raw, mail, error: error ?? null };
8729
8863
  }
8730
8864
  /**
8731
8865
  * Classifier features of a parsed message.
@@ -8858,15 +8992,23 @@ var SpamScanner = class {
8858
8992
  message: `A link shows "${link.text}" but goes to ${link.host}`
8859
8993
  }));
8860
8994
  }
8861
- seenHosts.add(destination(link));
8995
+ const name = lookupName(destination(link));
8996
+ if (name) {
8997
+ seenHosts.add(name);
8998
+ }
8862
8999
  }
8863
- for (const link of [...links.filter((link2) => link2.text), ...links.filter((link2) => !link2.text)]) {
9000
+ const found = links.filter((link) => !link.bare);
9001
+ const bare = links.filter((link) => link.bare);
9002
+ for (const link of [...found.filter((link2) => link2.text), ...found.filter((link2) => !link2.text)]) {
8864
9003
  lookalike(destination(link), { url: link.url });
8865
9004
  }
9005
+ for (const link of bare) {
9006
+ lookalike(link.host, { source: "text" });
9007
+ }
8866
9008
  for (const host of hosts) {
8867
9009
  lookalike(host, { source: "text" });
8868
9010
  }
8869
- const ordered = [...new Set([...links.filter((link) => link.text), ...links.filter((link) => !link.text)].map((link) => destination(link)))];
9011
+ const ordered = [...new Set([...found.filter((link) => link.text), ...found.filter((link) => !link.text), ...bare].map((link) => lookupName(destination(link))).filter(Boolean))];
8870
9012
  const firsts = /* @__PURE__ */ new Map();
8871
9013
  for (const host of ordered) {
8872
9014
  const domain = registrableDomain(host);
@@ -8880,9 +9022,9 @@ var SpamScanner = class {
8880
9022
  let answered = 0;
8881
9023
  const cloudflare = async () => {
8882
9024
  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) {
9025
+ const found2 = await this.dns.checkCloudflare([host], { adult: config.phishing.adult });
9026
+ blocked.push(...found2);
9027
+ if (!found2.unanswered?.length) {
8886
9028
  answered++;
8887
9029
  }
8888
9030
  })), config.timeout, null);
@@ -9066,7 +9208,7 @@ var SpamScanner = class {
9066
9208
  const linkLength = Number.isFinite(config.phishing.maxLength) && config.phishing.maxLength > 0 ? config.phishing.maxLength : 1e6;
9067
9209
  const text = typeof mail.text === "string" ? mail.text : "";
9068
9210
  const html = typeof mail.html === "string" ? mail.html : "";
9069
- const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2 });
9211
+ const checkedLinks = extractLinks(text.slice(0, linkLength), html.slice(0, linkLength * 2), { maxLength: linkLength * 2, bareHosts: true });
9070
9212
  const linksTruncated = text.length > linkLength || html.length > linkLength * 2;
9071
9213
  const plainObfuscation = obfuscationOf(`${mail.subject || ""}
9072
9214
  ${typeof mail.text === "string" ? mail.text : ""}`, { maxLength: config.maxLength });
@@ -9077,7 +9219,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
9077
9219
  mixed: Math.max(plainObfuscation.mixed, shownObfuscation.mixed),
9078
9220
  styled: plainObfuscation.styled || shownObfuscation.styled
9079
9221
  };
9080
- const attached = await expandAttachments(mail.attachments, parseErrors);
9222
+ const unopened = [];
9223
+ const attached = await expandAttachments(mail.attachments, parseErrors, void 0, unopened);
9081
9224
  const attachments = config.attachments === false ? [] : inspectAttachments(attached).filter((item) => config.macros !== false || !["macro", "pdf_active", "rtf_object"].includes(item.type)).map((item) => finding(item));
9082
9225
  const [links, authentication, checkedReputation, dnsbl, virusScan, toxicity, nsfw] = await Promise.all([
9083
9226
  this.checkLinks(checkedLinks, config, extracted.hosts, fromDomain(mail)),
@@ -9198,6 +9341,13 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
9198
9341
  // scanned, as {filename, reason}: the same on every try, so not a
9199
9342
  // failure.
9200
9343
  virusScanSkipped: virusScan.skipped,
9344
+ // Attached messages not opened, past the limits attached messages
9345
+ // are read within (nested more than three deep, more than 100
9346
+ // attachments in them, or more than 25 MB of them), as
9347
+ // {filename, reason}: their attachments were not inspected. Not
9348
+ // scored (forwarded messages can be nested so), for a caller that
9349
+ // needs a message read whole, e.g. to block what it carries.
9350
+ unopenedAttachments: unopened,
9201
9351
  // Whether the message has more text than phishing.maxLength, whose
9202
9352
  // links were not read.
9203
9353
  linksTruncated,
@@ -9207,7 +9357,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
9207
9357
  riskScore: Math.max(0, ...links.homographs.map((item) => item.riskScore))
9208
9358
  }
9209
9359
  },
9210
- links: checkedLinks.map((link) => link.url),
9360
+ // (the links of the message, not hosts found without one)
9361
+ links: checkedLinks.filter((link) => !link.bare).map((link) => link.url),
9211
9362
  language: extracted.language,
9212
9363
  tokens: extracted.words,
9213
9364
  mail,
@@ -9245,6 +9396,7 @@ export {
9245
9396
  scoreResults,
9246
9397
  segmentWords,
9247
9398
  spamHeaders,
9248
- train
9399
+ train,
9400
+ unwrapLink
9249
9401
  };
9250
9402
  //# sourceMappingURL=index.js.map