spamscanner 7.0.13 → 7.0.15

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
@@ -3305,6 +3305,7 @@ var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
3305
3305
  "apk",
3306
3306
  "app",
3307
3307
  "appimage",
3308
+ "appinstaller",
3308
3309
  "application",
3309
3310
  "appref-ms",
3310
3311
  "appx",
@@ -3341,6 +3342,7 @@ var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
3341
3342
  "inf",
3342
3343
  "ins",
3343
3344
  "ipa",
3345
+ "iqy",
3344
3346
  "iso",
3345
3347
  "isp",
3346
3348
  "its",
@@ -3349,6 +3351,7 @@ var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
3349
3351
  "js",
3350
3352
  "jse",
3351
3353
  "ksh",
3354
+ "library-ms",
3352
3355
  "lnk",
3353
3356
  "mad",
3354
3357
  "maf",
@@ -3380,6 +3383,8 @@ var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
3380
3383
  "mst",
3381
3384
  "msu",
3382
3385
  "nsh",
3386
+ "one",
3387
+ "onepkg",
3383
3388
  "ops",
3384
3389
  "osd",
3385
3390
  "pcd",
@@ -3410,6 +3415,8 @@ var EXECUTABLE_EXTENSIONS = /* @__PURE__ */ new Set([
3410
3415
  "scpt",
3411
3416
  "scr",
3412
3417
  "sct",
3418
+ "search-ms",
3419
+ "searchconnector-ms",
3413
3420
  "settingcontent-ms",
3414
3421
  "sh",
3415
3422
  "shb",
@@ -3637,6 +3644,14 @@ function extensionOf(name) {
3637
3644
  }
3638
3645
  return whole;
3639
3646
  }
3647
+ var COMPRESSED_EXTENSIONS = /* @__PURE__ */ new Set(["gz", "bz2", "xz", "zst"]);
3648
+ function uncompressedName(name) {
3649
+ if (!COMPRESSED_EXTENSIONS.has(extensionOf(name))) {
3650
+ return String(name);
3651
+ }
3652
+ const trimmed = trimEndOf(String(name).replace(ALL_BIDI_CONTROLS, ""), ". ");
3653
+ return trimmed.slice(0, trimmed.lastIndexOf("."));
3654
+ }
3640
3655
  function lastExtension(base) {
3641
3656
  const dot = base.lastIndexOf(".");
3642
3657
  return dot === -1 ? "" : base.slice(dot + 1).trim();
@@ -3689,7 +3704,20 @@ function inspectAttachments(attachments = []) {
3689
3704
  } else if (RISKY_EXTENSIONS.has(extension)) {
3690
3705
  add("risky_attachment", `Attachment "${name}" is a risky file type (.${extension})`, { extension });
3691
3706
  }
3707
+ const held = COMPRESSED_EXTENSIONS.has(extension) ? extensionOf(uncompressedName(filename)) : "";
3708
+ if (EXECUTABLE_EXTENSIONS.has(held)) {
3709
+ const script = SCRIPT_EXTENSIONS.has(held);
3710
+ add(script ? "script_in_archive" : "executable_in_archive", `Archive "${name}" contains ${script ? "a script" : "a program"} (${shorten(uncompressedName(filename).split(/[/\\]/).pop())})`, { extension: held, extensions: [held] });
3711
+ }
3712
+ const webPage = detected?.type === "html" || detected?.type === "svg" || ["htm", "html", "mht", "mhtml", "shtml", "xht", "xhtml", "svg"].includes(extension);
3713
+ const addWebPage = () => {
3714
+ const active = content !== null && /<script\b|<form\b|\bon(?:load|error|click)\s*=|javascript:|window\.location|document\.location|atob\(/i.test(content.subarray(0, 2e5).toString("utf8"));
3715
+ add("html_attachment", `Attachment "${name}" is ${detected?.type === "svg" || extension === "svg" ? "an SVG image" : "a web page"}${active ? " with scripts or forms" : ""}`, { extension: extension || detected?.type, active });
3716
+ };
3692
3717
  if (!content) {
3718
+ if (webPage) {
3719
+ addWebPage();
3720
+ }
3693
3721
  continue;
3694
3722
  }
3695
3723
  const zipFamily = ZIP_FAMILY.has(detected?.type) || extension === "zip" && !detected?.executable && entriesOf(content).length > 0;
@@ -3701,10 +3729,10 @@ function inspectAttachments(attachments = []) {
3701
3729
  } else if (office && entries.some((entry) => MACRO_EXTENSIONS.has(extensionOf(entry.name)))) {
3702
3730
  add("macro", `Attachment "${name}" is an Office document holding another with macros`, { extension });
3703
3731
  }
3704
- const inner = entries.filter((entry) => EXECUTABLE_EXTENSIONS.has(extensionOf(entry.name)) && !(office && OFFICE_EMBEDDED.has(extensionOf(entry.name))));
3705
- const details = { extension: extensionOf(inner[0]?.name ?? ""), extensions: [...new Set(inner.map((entry) => extensionOf(entry.name)))] };
3732
+ const inner = entries.filter((entry) => EXECUTABLE_EXTENSIONS.has(extensionOf(uncompressedName(entry.name))) && !(office && OFFICE_EMBEDDED.has(extensionOf(entry.name))));
3733
+ const details = { extension: extensionOf(uncompressedName(inner[0]?.name ?? "")), extensions: [...new Set(inner.map((entry) => extensionOf(uncompressedName(entry.name))))] };
3706
3734
  const listed = inner.slice(0, 3).map((entry) => shorten(entry.name.split("/").pop())).join(", ");
3707
- if (inner.some((entry) => !SCRIPT_EXTENSIONS.has(extensionOf(entry.name)))) {
3735
+ if (inner.some((entry) => !SCRIPT_EXTENSIONS.has(extensionOf(uncompressedName(entry.name))))) {
3708
3736
  add("executable_in_archive", `Archive "${name}" contains ${inner.length === 1 ? "a program" : `${inner.length} programs`} (${listed})`, details);
3709
3737
  } else if (inner.length > 0) {
3710
3738
  add("script_in_archive", `Archive "${name}" contains ${inner.length === 1 ? "a script" : `${inner.length} scripts`} (${listed})`, details);
@@ -3723,10 +3751,8 @@ function inspectAttachments(attachments = []) {
3723
3751
  } else if (detected?.type === "rtf" && /\\object\b|\\objdata\b|\\objupdate\b/.test(content.toString("latin1"))) {
3724
3752
  add("rtf_object", `RTF document "${name}" embeds an OLE object`, { extension: "rtf" });
3725
3753
  }
3726
- if (detected?.type === "html" || detected?.type === "svg" || ["htm", "html", "shtml", "xhtml", "svg"].includes(extension)) {
3727
- const text = content.subarray(0, 2e5).toString("utf8");
3728
- const active = /<script\b|<form\b|\bon(?:load|error|click)\s*=|javascript:|window\.location|document\.location|atob\(/i.test(text);
3729
- add("html_attachment", `Attachment "${name}" is ${detected?.type === "svg" || extension === "svg" ? "an SVG image" : "a web page"}${active ? " with scripts or forms" : ""}`, { extension: extension || detected?.type, active });
3754
+ if (webPage) {
3755
+ addWebPage();
3730
3756
  }
3731
3757
  }
3732
3758
  return findings;
@@ -3901,6 +3927,7 @@ var MAX_LINE = 998;
3901
3927
  var MAX_FIELD = 8192;
3902
3928
  var MAX_HEADER = 512 * 1024;
3903
3929
  var MAX_REPEATED = 100;
3930
+ var MIN_LINE_COST = 64;
3904
3931
  var KEPT_FIELDS = /* @__PURE__ */ new Set([
3905
3932
  "from",
3906
3933
  "sender",
@@ -3935,18 +3962,106 @@ var MAX_ATTACHMENTS = 100;
3935
3962
  var MAX_NESTED_BYTES = 25 * 1024 * 1024;
3936
3963
  var MAX_ATTACHED_MESSAGES = 100;
3937
3964
  var PARSE_TIMEOUT = 6e4;
3965
+ var NAMED_TEXT = /* @__PURE__ */ Symbol("named text parts");
3966
+ var ENCODED_FILES = /^(?:begin [0-7]{3,4} |=ybegin\b[^\r\n\u2028\u2029]*?\bname=)([^\r\n\u2028\u2029]+)\r?$/gm;
3967
+ var MAX_ENCODED_FILES = 1e3;
3968
+ var foundIn = /* @__PURE__ */ new WeakMap();
3969
+ function addFinding(list, part) {
3970
+ const found = foundIn.get(list) ?? /* @__PURE__ */ new Set();
3971
+ foundIn.set(list, found);
3972
+ const types = inspectAttachments([part]).map((finding2) => finding2.type);
3973
+ if (types.some((type) => !found.has(type))) {
3974
+ list.push(part);
3975
+ for (const type of types) {
3976
+ found.add(type);
3977
+ }
3978
+ }
3979
+ }
3980
+ function parametersOf(value) {
3981
+ const parameters = [];
3982
+ let start = -1;
3983
+ let quoted = false;
3984
+ for (let i = 0; i < value.length; i++) {
3985
+ if (quoted && value[i] === "\\") {
3986
+ i++;
3987
+ } else if (value[i] === '"') {
3988
+ quoted = !quoted;
3989
+ } else if (value[i] === ";" && !quoted) {
3990
+ if (start !== -1) {
3991
+ parameters.push(value.slice(start, i));
3992
+ }
3993
+ start = i + 1;
3994
+ }
3995
+ }
3996
+ if (start !== -1) {
3997
+ parameters.push(value.slice(start));
3998
+ }
3999
+ return parameters;
4000
+ }
4001
+ var MAX_NAME_FIELDS = 8;
4002
+ var MAX_NAME_PARAMETERS = 32;
4003
+ function repeatedNames(node) {
4004
+ const names = [];
4005
+ const { headers, libmime } = node ?? {};
4006
+ if (typeof headers?.get !== "function" || typeof libmime?.parseHeaderValue !== "function") {
4007
+ return names;
4008
+ }
4009
+ const read = (value, parameter) => {
4010
+ let name = "";
4011
+ try {
4012
+ name = libmime.decodeWords(String(libmime.parseHeaderValue(value, { stripComments: true }).params[parameter] ?? "")).trim();
4013
+ } catch {
4014
+ }
4015
+ if (name !== "") {
4016
+ names.push(name);
4017
+ }
4018
+ };
4019
+ for (const [field, parameter] of [["content-disposition", "filename"], ["content-type", "name"]]) {
4020
+ const values = headers.get(field).slice(0, MAX_NAME_FIELDS);
4021
+ for (const value of values) {
4022
+ const parameters = parametersOf(value).slice(0, MAX_NAME_PARAMETERS).map((text) => ({ text, key: text.slice(0, Math.max(0, text.indexOf("="))).trim().toLowerCase() }));
4023
+ const repeated = parameters.filter(({ key }) => (key === parameter || key.startsWith(`${parameter}*`)) && parameters.filter((other) => other.key === key).length > 1);
4024
+ if (values.length > 1) {
4025
+ read(value, parameter);
4026
+ }
4027
+ for (const { text } of repeated) {
4028
+ read(`x;${text}`, text.slice(0, text.indexOf("=")).trim().toLowerCase().replace(/\*.*$/, ""));
4029
+ }
4030
+ }
4031
+ }
4032
+ return names;
4033
+ }
4034
+ function namedTextParts(mail) {
4035
+ return mail[NAMED_TEXT] ?? [];
4036
+ }
3938
4037
  async function simpleParse(raw, { timeout = PARSE_TIMEOUT, parser = simpleParser } = {}) {
3939
4038
  const input = Readable.from([finishable(unfoldHeaders(raw))], { objectMode: false, autoDestroy: false });
3940
4039
  input.on("error", () => {
3941
4040
  });
4041
+ const named = [];
3942
4042
  const { pipe } = input;
3943
4043
  input.pipe = function(parser2, options) {
3944
4044
  parser2.splitter?.on("error", (error) => input.destroy(error));
4045
+ const { createNode } = parser2;
4046
+ if (typeof createNode === "function") {
4047
+ parser2.createNode = function(node) {
4048
+ const created = createNode.call(this, node);
4049
+ if (created?.isAttachment === false && node?.filename) {
4050
+ addFinding(named, { filename: String(node.filename), contentType: created.contentType, content: null });
4051
+ }
4052
+ for (const filename of repeatedNames(node)) {
4053
+ if (filename !== node?.filename) {
4054
+ addFinding(named, { filename, contentType: created?.contentType || "application/octet-stream", content: null });
4055
+ }
4056
+ }
4057
+ return created;
4058
+ };
4059
+ }
3945
4060
  return pipe.call(this, parser2, options);
3946
4061
  };
3947
4062
  let timer;
3948
4063
  try {
3949
- return await Promise.race([
4064
+ const mail = await Promise.race([
3950
4065
  parser(input, PARSER_OPTIONS),
3951
4066
  new Promise((resolve, reject) => {
3952
4067
  timer = setTimeout(() => {
@@ -3956,6 +4071,17 @@ async function simpleParse(raw, { timeout = PARSE_TIMEOUT, parser = simpleParser
3956
4071
  }, timeout);
3957
4072
  })
3958
4073
  ]);
4074
+ if (mail && typeof mail === "object") {
4075
+ let count = 0;
4076
+ for (const [, filename] of String(mail.text ?? "").matchAll(ENCODED_FILES)) {
4077
+ addFinding(named, { filename: filename.trim(), contentType: "application/octet-stream", content: null });
4078
+ if (++count >= MAX_ENCODED_FILES) {
4079
+ break;
4080
+ }
4081
+ }
4082
+ Object.defineProperty(mail, NAMED_TEXT, { value: named });
4083
+ }
4084
+ return mail;
3959
4085
  } finally {
3960
4086
  clearTimeout(timer);
3961
4087
  input.destroy();
@@ -4088,6 +4214,9 @@ function unfoldHeaders(raw) {
4088
4214
  chunks.push(raw.subarray(copied));
4089
4215
  return Buffer2.concat(chunks);
4090
4216
  }
4217
+ function lineCost(line) {
4218
+ return Math.max(line.length, MIN_LINE_COST);
4219
+ }
4091
4220
  function upToLine(raw, offset) {
4092
4221
  return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
4093
4222
  }
@@ -4114,9 +4243,9 @@ function smallerHeader(raw) {
4114
4243
  const whole = text.slice(start, stop).replace(/\r$/, "");
4115
4244
  if (last && (whole.startsWith(" ") || whole.startsWith(" "))) {
4116
4245
  const line = last.address ? whole : whole.slice(0, MAX_LINE);
4117
- if (last.address || last.size + line.length <= MAX_FIELD) {
4246
+ if (last.address || last.size + lineCost(line) <= MAX_FIELD) {
4118
4247
  last.lines.push(line);
4119
- last.size += line.length;
4248
+ last.size += lineCost(line);
4120
4249
  }
4121
4250
  } else {
4122
4251
  const name = whole.slice(0, whole.indexOf(":")).trim().toLowerCase();
@@ -4126,7 +4255,7 @@ function smallerHeader(raw) {
4126
4255
  name,
4127
4256
  address: address2,
4128
4257
  lines: [line],
4129
- size: line.length
4258
+ size: lineCost(line)
4130
4259
  });
4131
4260
  }
4132
4261
  start = stop + 1;
@@ -4137,7 +4266,7 @@ function smallerHeader(raw) {
4137
4266
  const bounded = boundAddressField(line, Number.POSITIVE_INFINITY);
4138
4267
  fromCut ||= field.name === "from" && bounded !== line;
4139
4268
  field.lines = [bounded];
4140
- field.size = bounded.length;
4269
+ field.size = lineCost(bounded);
4141
4270
  }
4142
4271
  const total = fields.reduce((sum, field) => sum + field.size, 0);
4143
4272
  const counts = /* @__PURE__ */ new Map();
@@ -4170,7 +4299,7 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
4170
4299
  shorter = fewerParts(input);
4171
4300
  } else if (limit === HEADER_TOO_LARGE) {
4172
4301
  const smaller = smallerHeader(input);
4173
- shorter = smaller.raw.length < input.length ? smaller.raw : upToLine(input, MAX_PREFIX_BYTES);
4302
+ shorter = smaller.raw.length < input.length ? smaller.raw : upToLine(input, input.length > MAX_PREFIX_BYTES ? MAX_PREFIX_BYTES : Math.floor(input.length / 2));
4174
4303
  fromCut ||= smaller.raw === shorter && smaller.fromCut;
4175
4304
  }
4176
4305
  if (shorter.length >= input.length) {
@@ -4182,7 +4311,13 @@ async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PA
4182
4311
  }
4183
4312
  }
4184
4313
  async function expandAttachments(attachments = [], errors = [], parse = parseMessage, gets = {}) {
4185
- const { unopened = [], mails = [] } = Array.isArray(gets) ? { unopened: gets } : gets ?? {};
4314
+ const { unopened = [], mails = [], named = [] } = Array.isArray(gets) ? { unopened: gets } : gets ?? {};
4315
+ const addNamed = (...parts) => {
4316
+ for (const part of parts) {
4317
+ addFinding(named, part);
4318
+ }
4319
+ };
4320
+ const nameOf = (value) => trimEndOf(String(value ?? "").trim().toLowerCase(), ". ");
4186
4321
  const all = [];
4187
4322
  const deadline = Date.now() + PARSE_TIMEOUT;
4188
4323
  let budget = MAX_NESTED_BYTES;
@@ -4204,6 +4339,10 @@ async function expandAttachments(attachments = [], errors = [], parse = parseMes
4204
4339
  const name = attachment.filename || (prefix ? "unnamed attachment" : "");
4205
4340
  const filename = prefix ? `${prefix}/${name}` : name || "attached message";
4206
4341
  all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
4342
+ const typeName = String(attachment.headers?.get?.("content-type")?.params?.name ?? "").trim();
4343
+ if (typeName && nameOf(typeName) !== nameOf(attachment.filename)) {
4344
+ addNamed({ filename: prefix ? `${prefix}/${typeName}` : typeName, contentType: attachment.contentType, content: null });
4345
+ }
4207
4346
  const { content } = attachment;
4208
4347
  const attached = isAttachedMessage(attachment);
4209
4348
  if (attached && Buffer2.isBuffer(content) && depth >= MAX_NESTED_DEPTH) {
@@ -4232,7 +4371,9 @@ async function expandAttachments(attachments = [], errors = [], parse = parseMes
4232
4371
  errors.push(inner.error);
4233
4372
  }
4234
4373
  mails.push(inner.mail);
4235
- await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
4374
+ const carrier = prefix ? `${prefix}/${name}` : name || "attached message";
4375
+ addNamed(...namedTextParts(inner.mail).map((part) => ({ ...part, filename: `${carrier}/${part.filename}` })));
4376
+ await visit(inner.mail.attachments, carrier, depth + 1);
4236
4377
  }
4237
4378
  }
4238
4379
  };
@@ -6354,7 +6495,7 @@ function withoutBinaryValues(text, limit = Number.POSITIVE_INFINITY) {
6354
6495
  }
6355
6496
  return out.join("\n");
6356
6497
  }
6357
- var HTML_TYPES = /* @__PURE__ */ new Set(["text/html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml"]);
6498
+ var HTML_TYPES = /* @__PURE__ */ new Set(["text/html", "text/x-amp-html", "application/xhtml+xml", "image/svg+xml", "text/xml", "application/xml"]);
6358
6499
  var HTML_EXTENSIONS = /* @__PURE__ */ new Set(["html", "htm", "shtml", "xhtml", "xht", "svg", "xml"]);
6359
6500
  var CARD_TYPES = /* @__PURE__ */ new Set(["text/calendar", "text/x-vcalendar", "application/ics", "text/vcard", "text/x-vcard", "text/directory"]);
6360
6501
  var CARD_EXTENSIONS = /* @__PURE__ */ new Set(["ics", "ical", "ifb", "vcs", "vcf", "vcard"]);
@@ -7609,18 +7750,27 @@ function parseVerdict(text) {
7609
7750
  sources.push([text.slice(0, 2e4), answered]);
7610
7751
  }
7611
7752
  for (const [source, wanted] of sources) {
7753
+ let found = null;
7754
+ let end = -1;
7612
7755
  for (const candidate of jsonObjects(source)) {
7613
- if (!wanted(candidate)) {
7756
+ if (!wanted(candidate) || candidate[0] < end) {
7614
7757
  continue;
7615
7758
  }
7616
7759
  try {
7617
7760
  const result = normalizeVerdict(JSON.parse(candidate[1]));
7618
7761
  if (result) {
7619
- return result;
7762
+ if (found && found.verdict !== result.verdict) {
7763
+ return null;
7764
+ }
7765
+ found ??= result;
7766
+ end = candidate[0] + candidate[1].length;
7620
7767
  }
7621
7768
  } catch {
7622
7769
  }
7623
7770
  }
7771
+ if (found) {
7772
+ return found;
7773
+ }
7624
7774
  }
7625
7775
  const prose = visible.toLowerCase();
7626
7776
  let deniedEnd = -1;
@@ -8319,7 +8469,7 @@ function scoreResults(results, options) {
8319
8469
  var package_default = {
8320
8470
  name: "spamscanner",
8321
8471
  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.",
8322
- version: "7.0.13",
8472
+ version: "7.0.15",
8323
8473
  author: "Forward Email (https://forwardemail.net)",
8324
8474
  bin: {
8325
8475
  spamscanner: "./dist/esm/cli.js"
@@ -10523,14 +10673,15 @@ ${visibleText(html.slice(0, config.maxLength * 2)).text}`, { maxLength: config.m
10523
10673
  };
10524
10674
  const unopened = [];
10525
10675
  const opened = [];
10526
- const attached = await expandAttachments(mail.attachments, parseErrors, void 0, { unopened, mails: opened });
10676
+ const named = [];
10677
+ const attached = await expandAttachments(mail.attachments, parseErrors, void 0, { unopened, mails: opened, named });
10527
10678
  const carried = config.phishing.attachments === true && (config.phishing.cloudflare || config.dnsbl.domain?.length > 0) ? carriedLinkHosts(mail, {
10528
10679
  mails: opened,
10529
10680
  attachments: attached,
10530
10681
  maxHosts: config.phishing.maxHosts,
10531
10682
  hostsInText: config.phishing.hostsInText === true
10532
10683
  }) : null;
10533
- const attachments = config.attachments === false ? [] : inspectAttachments(attached).filter((item) => config.macros !== false || !["macro", "pdf_active", "rtf_object"].includes(item.type)).map((item) => finding(item));
10684
+ const attachments = config.attachments === false ? [] : inspectAttachments([...attached, ...namedTextParts(mail), ...named]).filter((item) => config.macros !== false || !["macro", "pdf_active", "rtf_object"].includes(item.type)).map((item) => finding(item));
10534
10685
  const [links, authentication, checkedReputation, dnsbl, virusScan, toxicity, nsfw] = await Promise.all([
10535
10686
  this.checkLinks(checkedLinks, config, extracted.hosts, { from: fromDomain(mail), carried }),
10536
10687
  config.authenticationResults && typeof config.authenticationResults === "object" ? normalizeAuthOutput(config.authenticationResults) : config.authentication && session.remoteAddress ? withTimeout(authenticate(raw, {