spamscanner 7.0.7 → 7.0.9

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
@@ -2102,11 +2102,12 @@ function gatewayTarget(url) {
2102
2102
  }
2103
2103
  if (host === "urldefense.com" && url.pathname.startsWith("/v3/__")) {
2104
2104
  const rest = url.href.slice(url.href.indexOf("/v3/__") + 6);
2105
- const end = rest.indexOf("__;");
2105
+ const end = rest.lastIndexOf("__;");
2106
2106
  return end === -1 ? null : rest.slice(0, end);
2107
2107
  }
2108
2108
  return null;
2109
2109
  }
2110
+ var MAX_GATEWAYS = 16;
2110
2111
  function unwrapLink(address2) {
2111
2112
  let url;
2112
2113
  try {
@@ -2114,8 +2115,11 @@ function unwrapLink(address2) {
2114
2115
  } catch {
2115
2116
  return null;
2116
2117
  }
2117
- for (let depth = 0; depth < 3; depth++) {
2118
+ for (let depth = 0; depth < MAX_GATEWAYS; depth++) {
2118
2119
  const target = gatewayTarget(url);
2120
+ if (!target) {
2121
+ break;
2122
+ }
2119
2123
  let next;
2120
2124
  try {
2121
2125
  next = new URL(target);
@@ -3429,8 +3433,13 @@ function oleHasMacros(buffer) {
3429
3433
  return false;
3430
3434
  }
3431
3435
  var PDF_ACTIVE = linear(String.raw`\/(?:JavaScript|JS|Launch)\b`);
3432
- var PDF_ESCAPED_NAME = /\/[^\s/<>[\]()%{}]*#[\dA-Fa-f]{2}[^\s/<>[\]()%{}]*/g;
3433
- var decodePdfNames = (text) => text.replaceAll(PDF_ESCAPED_NAME, (name) => name.replaceAll(/#([\dA-Fa-f]{2})/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))));
3436
+ var escaped = (character) => {
3437
+ const hex = character.codePointAt(0).toString(16);
3438
+ return `(?:${character}|#${hex[0]}[${hex[1].toLowerCase()}${hex[1].toUpperCase()}])`;
3439
+ };
3440
+ var WORD_ESCAPE = String.raw`#(?:3\d|4[1-9a-fA-F]|5[\daA]|5[fF]|6[1-9a-fA-F]|7[\daA])`;
3441
+ var PDF_ACTIVE_ESCAPED = new RegExp(`/(?:${["JavaScript", "JS", "Launch"].map((name) => [...name].map((character) => escaped(character)).join("")).join("|")})(?![\\w]|${WORD_ESCAPE})`);
3442
+ var pdfActive = (text) => PDF_ACTIVE.test(text) || PDF_ACTIVE_ESCAPED.test(text);
3434
3443
  function inspectAttachments(attachments = []) {
3435
3444
  const findings = [];
3436
3445
  for (const attachment of attachments) {
@@ -3488,7 +3497,7 @@ function inspectAttachments(attachments = []) {
3488
3497
  add("macro", `Attachment "${name}" is an Office document with a VBA macro project`, { extension });
3489
3498
  }
3490
3499
  } else if (detected?.type === "pdf" || extension === "pdf") {
3491
- if (PDF_ACTIVE.test(decodePdfNames(content.toString("latin1")))) {
3500
+ if (pdfActive(content.toString("latin1"))) {
3492
3501
  add("pdf_active", `PDF "${name}" contains JavaScript or a launch action`, { extension: "pdf" });
3493
3502
  }
3494
3503
  } else if (detected?.type === "rtf" && /\\object\b|\\objdata\b|\\objupdate\b/.test(content.toString("latin1"))) {
@@ -3536,7 +3545,7 @@ function boundAddressField(line, room) {
3536
3545
  const limit = Math.min(line.length, MAX_ADDRESS_FIELD, room);
3537
3546
  let end = start;
3538
3547
  let expecting = "";
3539
- let escaped = false;
3548
+ let escaped2 = false;
3540
3549
  let separators = 0;
3541
3550
  let groups = 0;
3542
3551
  let index = start;
@@ -3545,8 +3554,8 @@ function boundAddressField(line, room) {
3545
3554
  if ((chr === "," || chr === ";") && ++separators > MAX_ADDRESS_SEPARATORS) {
3546
3555
  break;
3547
3556
  }
3548
- if (escaped) {
3549
- escaped = false;
3557
+ if (escaped2) {
3558
+ escaped2 = false;
3550
3559
  } else if (expecting !== "" && chr === expecting) {
3551
3560
  expecting = "";
3552
3561
  end = chr === ";" ? index + 1 : end;
@@ -3554,7 +3563,7 @@ function boundAddressField(line, room) {
3554
3563
  expecting = ADDRESS_OPERATORS.get(chr);
3555
3564
  end = chr === "," || chr === ";" ? index : end;
3556
3565
  } else if (expecting === '"' && chr === "\\") {
3557
- escaped = true;
3566
+ escaped2 = true;
3558
3567
  }
3559
3568
  if (chr === ":" && expecting === ";" && ++groups > MAX_ADDRESS_GROUPS) {
3560
3569
  break;
@@ -3697,9 +3706,14 @@ var SIGNATURE_FIELDS = /* @__PURE__ */ new Set(["dkim-signature", "arc-message-s
3697
3706
  var AUTH_FIELDS = /* @__PURE__ */ new Set([...KEPT_FIELDS, "arc-seal", "arc-message-signature"]);
3698
3707
  var MAX_SIGNATURES = 10;
3699
3708
  var MAX_DKIM_SIGNATURES = 8;
3709
+ function isAttachedMessage(attachment) {
3710
+ const type = String(attachment.contentType).toLowerCase();
3711
+ return type === "message/rfc822" || type === "message/global" || /\.(?:eml|u8msg)$/i.test(String(attachment.filename));
3712
+ }
3700
3713
  var MAX_NESTED_DEPTH = 3;
3701
3714
  var MAX_ATTACHMENTS = 100;
3702
3715
  var MAX_NESTED_BYTES = 25 * 1024 * 1024;
3716
+ var MAX_ATTACHED_MESSAGES = 100;
3703
3717
  var PARSE_TIMEOUT = 6e4;
3704
3718
  async function simpleParse(raw, { timeout = PARSE_TIMEOUT, parser = simpleParser } = {}) {
3705
3719
  const input = Readable.from([finishable(unfoldHeaders(raw))], { objectMode: false, autoDestroy: false });
@@ -3757,18 +3771,18 @@ function headerValue2(line) {
3757
3771
  let key = null;
3758
3772
  let inKey = false;
3759
3773
  let quoted = false;
3760
- let escaped = false;
3774
+ let escaped2 = false;
3761
3775
  let value = "";
3762
3776
  for (const chr of line.slice(line.indexOf(":") + 1).replaceAll(/\r?\n[ \t]*/g, " ")) {
3763
3777
  if (inKey && chr === "=") {
3764
3778
  key = value.trim().toLowerCase();
3765
3779
  inKey = false;
3766
3780
  value = "";
3767
- } else if (inKey || escaped) {
3781
+ } else if (inKey || escaped2) {
3768
3782
  value += chr;
3769
- escaped = false;
3783
+ escaped2 = false;
3770
3784
  } else if (chr === "\\") {
3771
- escaped = true;
3785
+ escaped2 = true;
3772
3786
  } else if (chr === '"') {
3773
3787
  quoted = !quoted;
3774
3788
  } else if (chr === ";" && !quoted) {
@@ -3816,7 +3830,7 @@ function unfoldHeaders(raw) {
3816
3830
  if (blank && type.boundary) {
3817
3831
  boundaries.push(Buffer2.from(type.boundary));
3818
3832
  }
3819
- head = blank && type.value === "message/rfc822" && ["", "7bit", "8bit", "binary"].includes(encoding) && first.get("content-disposition")?.value === "inline";
3833
+ head = blank && ["message/rfc822", "message/global"].includes(type.value) && ["", "7bit", "8bit", "binary"].includes(encoding) && first.get("content-disposition")?.value === "inline";
3820
3834
  };
3821
3835
  for (let start = 0; start < raw.length; ) {
3822
3836
  const lf = raw.indexOf(10, start);
@@ -3947,25 +3961,45 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
3947
3961
  }
3948
3962
  }
3949
3963
  }
3950
- async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
3964
+ async function expandAttachments(attachments = [], errors = [], parse = parseMessage, unopened = []) {
3951
3965
  const all = [];
3952
3966
  const deadline = Date.now() + PARSE_TIMEOUT;
3953
3967
  let budget = MAX_NESTED_BYTES;
3954
3968
  let nested = 0;
3969
+ let tooMany = false;
3970
+ let opened = 0;
3955
3971
  const visit = async (list, prefix, depth) => {
3956
3972
  for (const attachment of list) {
3957
3973
  if (prefix) {
3958
3974
  if (nested >= MAX_ATTACHMENTS) {
3975
+ if (!tooMany) {
3976
+ tooMany = true;
3977
+ unopened.push({ filename: prefix, reason: `more than ${MAX_ATTACHMENTS} attachments in attached messages` });
3978
+ }
3959
3979
  return;
3960
3980
  }
3961
3981
  nested++;
3962
3982
  }
3963
3983
  const name = attachment.filename || (prefix ? "unnamed attachment" : "");
3984
+ const filename = prefix ? `${prefix}/${name}` : name || "attached message";
3964
3985
  all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
3965
3986
  const { content } = attachment;
3966
- const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
3967
- if (attached && depth < MAX_NESTED_DEPTH && Buffer2.isBuffer(content) && content.length <= budget) {
3987
+ const attached = isAttachedMessage(attachment);
3988
+ if (attached && Buffer2.isBuffer(content) && depth >= MAX_NESTED_DEPTH) {
3989
+ unopened.push({ filename, reason: `nested more than ${MAX_NESTED_DEPTH} deep` });
3990
+ continue;
3991
+ }
3992
+ if (attached && Buffer2.isBuffer(content) && opened >= MAX_ATTACHED_MESSAGES) {
3993
+ unopened.push({ filename, reason: `more than ${MAX_ATTACHED_MESSAGES} attached messages` });
3994
+ continue;
3995
+ }
3996
+ if (attached && Buffer2.isBuffer(content) && content.length > budget) {
3997
+ unopened.push({ filename, reason: `past ${MAX_NESTED_BYTES / 1024 / 1024} MB of attached messages` });
3998
+ continue;
3999
+ }
4000
+ if (attached && Buffer2.isBuffer(content)) {
3968
4001
  budget -= content.length;
4002
+ opened++;
3969
4003
  let inner;
3970
4004
  try {
3971
4005
  inner = await parseWithin(content, parse, deadline);
@@ -5651,9 +5685,12 @@ ${visibleText(html.slice(0, 1e5)).text}`)) {
5651
5685
  hit("SEXTORTION_SUBJECT", 2, "Subject used by sextortion and account takeover scams, without a demand for payment", "SCAM");
5652
5686
  }
5653
5687
  const markup = html.slice(0, 1e5);
5688
+ const readable = readableText(`${subject}
5689
+ ${text.slice(0, 5e4)}
5690
+ ${visibleText(withoutHelp(markup)).text}`);
5654
5691
  if (isInjection(`${subject}
5655
5692
  ${text.slice(0, 5e4)}
5656
- ${withoutHelp(markup)}`) || attributeText(markup).some(({ text: text2, start, end, help }) => (!help || isOrder(text2)) && isInjection(unquoted(text2), AI_ACTION_OBJECT, () => shownNear(markup, start, end)))) {
5693
+ ${withoutHelp(markup)}`) || isInjection(readable) || attributeText(markup).some(({ text: text2, start, end, help }) => (!help || isOrder(text2)) && isInjection(unquoted(text2), AI_ACTION_OBJECT, () => shownNear(markup, start, end)))) {
5657
5694
  hit("PROMPT_INJECTION", 3, "Contains instructions addressed to an AI filter", "SPAM");
5658
5695
  }
5659
5696
  if (session.resolvedClientHostname?.endsWith(".outbound.protection.outlook.com")) {
@@ -7031,7 +7068,7 @@ function scoreResults(results, options) {
7031
7068
  var package_default = {
7032
7069
  name: "spamscanner",
7033
7070
  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.",
7034
- version: "7.0.7",
7071
+ version: "7.0.9",
7035
7072
  author: "Forward Email (https://forwardemail.net)",
7036
7073
  bin: {
7037
7074
  spamscanner: "./dist/esm/cli.js"
@@ -8833,14 +8870,16 @@ var SpamScanner = class {
8833
8870
  * a header field ("Name: value", or an mbox "From " line) is read as the
8834
8871
  * body of a message with no header, so its text is the input; other
8835
8872
  * input mailparser cannot read gives a message with no text. Other errors
8836
- * are thrown.
8873
+ * are thrown. `error` is the parser's limit the message exceeds, so that
8874
+ * only its first parts were read (as scan() reports it, see parseError),
8875
+ * or null.
8837
8876
  * @param {Buffer|string|Uint8Array|AsyncIterable} source
8838
- * @returns {Promise<{raw: Buffer, mail: object}>}
8877
+ * @returns {Promise<{raw: Buffer, mail: object, error: string|null}>}
8839
8878
  */
8840
8879
  async parse(source) {
8841
8880
  const raw = await toBuffer(source);
8842
- const { mail } = await parseWithin(withHeader(raw));
8843
- return { raw, mail };
8881
+ const { mail, error } = await parseWithin(withHeader(raw));
8882
+ return { raw, mail, error: error ?? null };
8844
8883
  }
8845
8884
  /**
8846
8885
  * Classifier features of a parsed message.
@@ -9079,7 +9118,7 @@ var SpamScanner = class {
9079
9118
  message: `Attachment "${filename}" contains ${result.viruses.join(", ")}`
9080
9119
  });
9081
9120
  viruses.push(found);
9082
- if (attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "")) {
9121
+ if (isAttachedMessage(attachment)) {
9083
9122
  carriers.set(found, attachment.filename || "attached message");
9084
9123
  }
9085
9124
  }
@@ -9200,7 +9239,8 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
9200
9239
  mixed: Math.max(plainObfuscation.mixed, shownObfuscation.mixed),
9201
9240
  styled: plainObfuscation.styled || shownObfuscation.styled
9202
9241
  };
9203
- const attached = await expandAttachments(mail.attachments, parseErrors);
9242
+ const unopened = [];
9243
+ const attached = await expandAttachments(mail.attachments, parseErrors, void 0, unopened);
9204
9244
  const attachments = config.attachments === false ? [] : inspectAttachments(attached).filter((item) => config.macros !== false || !["macro", "pdf_active", "rtf_object"].includes(item.type)).map((item) => finding(item));
9205
9245
  const [links, authentication, checkedReputation, dnsbl, virusScan, toxicity, nsfw] = await Promise.all([
9206
9246
  this.checkLinks(checkedLinks, config, extracted.hosts, fromDomain(mail)),
@@ -9321,6 +9361,13 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
9321
9361
  // scanned, as {filename, reason}: the same on every try, so not a
9322
9362
  // failure.
9323
9363
  virusScanSkipped: virusScan.skipped,
9364
+ // Attached messages not opened, past the limits attached messages
9365
+ // are read within (nested more than three deep, more than 100
9366
+ // attachments in them, or more than 25 MB of them), as
9367
+ // {filename, reason}: their attachments were not inspected. Not
9368
+ // scored (forwarded messages can be nested so), for a caller that
9369
+ // needs a message read whole, e.g. to block what it carries.
9370
+ unopenedAttachments: unopened,
9324
9371
  // Whether the message has more text than phishing.maxLength, whose
9325
9372
  // links were not read.
9326
9373
  linksTruncated,
@@ -9369,6 +9416,7 @@ export {
9369
9416
  scoreResults,
9370
9417
  segmentWords,
9371
9418
  spamHeaders,
9372
- train
9419
+ train,
9420
+ unwrapLink
9373
9421
  };
9374
9422
  //# sourceMappingURL=index.js.map