spamscanner 7.0.1 → 7.0.3

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.
@@ -16,6 +16,12 @@ export function sniff(buffer: Buffer): {
16
16
  /**
17
17
  * The entries of a ZIP file, read from its central directory without
18
18
  * decompressing anything: name, sizes, and whether the entry is encrypted.
19
+ *
20
+ * Every end record that ends the file (its comment running to the last byte)
21
+ * is read, as well as the last one found: archive programs differ in which
22
+ * they use, so a directory hidden behind another record is read too. ZIP64
23
+ * archives, and archives after other data (self-extracting archives), are
24
+ * read as well.
19
25
  * @param {Buffer} buffer
20
26
  * @param {number} [limit] - most entries to read
21
27
  * @returns {Array<{name: string, encrypted: boolean, size: number}>}
@@ -34,6 +40,7 @@ export function zipEntries(buffer: Buffer, limit?: number): Array<{
34
40
  * - double_extension: "invoice.pdf.exe"
35
41
  * - rtl_override: a right-to-left override hides the real extension (a file named "invoice", U+202E, "fdp.exe" shows as "invoiceexe.pdf")
36
42
  * - executable_in_archive: a ZIP holds a program, read without unpacking
43
+ * (extension: the first program's, extensions: every program's)
37
44
  * - encrypted_archive: a password-protected ZIP, which virus scanners cannot open
38
45
  * - macro: an Office document with a VBA project
39
46
  * - pdf_active: a PDF with JavaScript, launch actions or embedded files
@@ -41,7 +48,7 @@ export function zipEntries(buffer: Buffer, limit?: number): Array<{
41
48
  * - html_attachment: an HTML or SVG file, the usual carrier of fake login pages
42
49
  *
43
50
  * @param {Array<{filename?: string, contentType?: string, content?: Buffer}>} attachments
44
- * @returns {Array<{type: string, filename: string, message: string, extension?: string}>}
51
+ * @returns {Array<{type: string, filename: string, message: string, extension?: string, extensions?: string[]}>}
45
52
  */
46
53
  export function inspectAttachments(attachments?: Array<{
47
54
  filename?: string;
@@ -52,6 +59,7 @@ export function inspectAttachments(attachments?: Array<{
52
59
  filename: string;
53
60
  message: string;
54
61
  extension?: string;
62
+ extensions?: string[];
55
63
  }>;
56
64
  /**
57
65
  * File extensions that run code when opened, on Windows, macOS, Linux and
@@ -6,13 +6,24 @@
6
6
  */
7
7
  export function skeleton(label: string): string;
8
8
  /**
9
- * Levenshtein distance, giving up once it exceeds `max`.
9
+ * Edit distance (Levenshtein, with two neighbouring letters swapped counting
10
+ * as one edit: "paypla" is one from "paypal"), giving up once it exceeds
11
+ * `max`.
10
12
  * @param {string} a
11
13
  * @param {string} b
12
14
  * @param {number} [max]
13
15
  * @returns {number}
14
16
  */
15
17
  export function editDistance(a: string, b: string, max?: number): number;
18
+ /**
19
+ * The brand a name joins to a phishing word ("paypallogin", "verifyamazon"),
20
+ * or null. Brands of five letters or more only, and not brands that are words
21
+ * themselves.
22
+ * @param {string} name
23
+ * @param {Set<string>} brands
24
+ * @returns {string|null}
25
+ */
26
+ export function brandWithPhishingWord(name: string, brands: Set<string>): string | null;
16
27
  /**
17
28
  * Brands most often imitated in phishing: banks, payment services, mail and
18
29
  * cloud providers, shops, couriers and tax offices. A domain whose name is a
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Text with its character references decoded, as browsers decode them: by
3
+ * number, also without a ";", and by name (the names of Latin-1 and those
4
+ * mail commonly uses; also without a ";" for the names browsers read so,
5
+ * except in an attribute where a letter, digit or "=" follows, as in a link
6
+ * "?a=1&copy=2"). An unknown name stays as it is.
7
+ * @param {string} value
8
+ * @param {object} [options]
9
+ * @param {boolean} [options.attribute] - the value of an attribute
10
+ * @returns {string}
11
+ */
12
+ export function decodeEntities(value: string, { attribute }?: {
13
+ attribute?: boolean;
14
+ }): string;
15
+ /**
16
+ * The text of HTML as a mail client shows it, read in linear time: without
17
+ * tags, comments, scripts, styles and the title, with character references
18
+ * decoded, without a break inside a word at an inline tag
19
+ * ("Free Sp<span>ins</span>" is "Free Spins") but with one between blocks
20
+ * ("<td>a</td><td>b</td>" is "a" and "b"), and without text that is hidden:
21
+ * by an element that hides what it holds (display, opacity, the hidden
22
+ * attribute, a clipped box of no size, text moved off the page), or that is
23
+ * invisible, transparent or in a font of a pixel or less where it is (which
24
+ * an element inside can set again). Not colours on a background, or classes
25
+ * of a style sheet: text hidden so is read as shown.
26
+ * @param {string} html
27
+ * @returns {{text: string, hidden: number}} the text, and how many
28
+ * characters of text were hidden
29
+ */
30
+ export function visibleText(html: string): {
31
+ text: string;
32
+ hidden: number;
33
+ };
@@ -129,9 +129,10 @@ export class SpamScanner {
129
129
  * @returns {ReturnType<SpamScanner['scan']>}
130
130
  */
131
131
  scanFile(file: string, options?: object): ReturnType<SpamScanner["scan"]>;
132
- checkLinks(links: any, config: any): Promise<{
132
+ checkLinks(links: any, config: any, hosts?: any[]): Promise<{
133
133
  phishing: any[];
134
134
  homographs: any[];
135
+ uncheckedHosts: number;
135
136
  }>;
136
137
  scanViruses(mail: any, config: any): Promise<any[]>;
137
138
  scanToxicity(mail: any, config: any): Promise<any>;
@@ -144,12 +145,17 @@ export class SpamScanner {
144
145
  * @param {object} [options.session] - what the receiving server knows:
145
146
  * remoteAddress (client IP), resolvedClientHostname (verified reverse
146
147
  * DNS), helo, and envelope { mailFrom: {address}, rcptTo: [{address}] }
148
+ * @param {object} [options.authenticationResults] - SPF, DKIM, DMARC and
149
+ * ARC results the receiving server already has, as mailauth's
150
+ * authenticate() gives them ({spf, dkim: {results}, dmarc, arc}): scored
151
+ * as if checked here, without checking again
147
152
  * @returns {Promise<object>} isSpam, score, threshold, action ("accept",
148
153
  * "tag" or "reject"), message, tests, results (per detector), links,
149
154
  * language, tokens, mail and metrics
150
155
  */
151
156
  scan(source: Buffer | string | Uint8Array | AsyncIterable<any>, options?: {
152
157
  session?: object;
158
+ authenticationResults?: object;
153
159
  }): Promise<object>;
154
160
  }
155
161
  export default SpamScanner;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Regular expressions for message content.
3
+ *
4
+ * A message can be made of anything, so every pattern used on its content is
5
+ * written to match in linear time as a JavaScript regular expression: no two
6
+ * quantifiers that can match the same text in a row, and repetition bounded
7
+ * where a run of characters could otherwise be scanned again from every
8
+ * position in it. When the optional re2 package is installed, the patterns
9
+ * run on RE2, which guarantees linear time (and every pattern here is valid
10
+ * RE2: no backreferences, no lookaround, repetition counts up to 1000).
11
+ */
12
+ /**
13
+ * The RE2 class from the re2 package, or null when it is not installed (or
14
+ * cannot load, as in a single executable binary).
15
+ * @param {Function} [load] - require()
16
+ * @returns {Function|null}
17
+ */
18
+ export function loadRe2(load?: Function): Function | null;
19
+ /**
20
+ * A JavaScript pattern for RE2, matching the same text: \s and \S as in
21
+ * JavaScript, and repetition bounded for JavaScript only made unbounded.
22
+ * @param {string} source
23
+ * @returns {string}
24
+ */
25
+ export function re2Source(source: string): string;
26
+ /**
27
+ * A regular expression on RE2 when it is installed, otherwise a JavaScript
28
+ * one (written to run in linear time either way).
29
+ * @param {string} source
30
+ * @param {string} [flags]
31
+ * @param {Function|null} [Engine] - the RE2 class, or null for JavaScript
32
+ * @returns {RegExp}
33
+ */
34
+ export function linear(source: string, flags?: string, Engine?: Function | null): RegExp;
35
+ /**
36
+ * The string without any of `characters` at its end, by scanning back from
37
+ * the end (a pattern such as /[).,]+$/ is tried at every position of a long
38
+ * run of those characters).
39
+ * @param {string} value
40
+ * @param {string} characters
41
+ * @returns {string}
42
+ */
43
+ export function trimEndOf(value: string, characters: string): string;
44
+ /**
45
+ * Every tag replaced by `replacement`, as `html.replace(/<[^>]*>/g, ...)`
46
+ * does: from each "<" to the next ">". Found with indexOf, which reads each
47
+ * character once: once no ">" follows, no later "<" has one either (the
48
+ * regular expression would read the rest again from every "<").
49
+ * @param {string} html
50
+ * @param {string} [replacement]
51
+ * @returns {string}
52
+ */
53
+ export function replaceTags(html: string, replacement?: string): string;
54
+ /**
55
+ * A pattern for a word of ASCII letters in either case, ASCII only. (RE2
56
+ * folds case as Unicode does, where "\u017F" is an "s" and "\u212A" a "k";
57
+ * JavaScript does only with the u flag.)
58
+ * @param {string} word
59
+ * @returns {string}
60
+ */
61
+ export function caseless(word: string): string;
62
+ /**
63
+ * Text with the two characters outside ASCII that fold to ASCII letters,
64
+ * "\u017F" and "\u212A", as "s" and "k", as host names map them (UTS #46),
65
+ * so that case-insensitive patterns read them alike on either engine. (Each
66
+ * is one UTF-16 unit, so positions in the text stay.)
67
+ * @param {string} text
68
+ * @returns {string}
69
+ */
70
+ export function foldToAscii(text: string): string;
71
+ /**
72
+ * HTML without its script and style elements (their content included), with
73
+ * every tag replaced by `replacement`.
74
+ * @param {string} html
75
+ * @param {string} [replacement]
76
+ * @returns {string}
77
+ */
78
+ export function stripTags(html: string, replacement?: string): string;
79
+ /**
80
+ * Whether patterns run on RE2.
81
+ * @type {boolean}
82
+ */
83
+ export const usesRe2: boolean;
@@ -19,6 +19,15 @@ export function characterClass(ranges: number[][], flags?: string): RegExp;
19
19
  * @returns {string[]}
20
20
  */
21
21
  export function chunk(text: string, size?: number): string[];
22
+ /**
23
+ * Text as a person reads it, for the language model: without the invisible
24
+ * characters put inside words to break filters (not the joiners scripts and
25
+ * emoji need), with compatibility forms folded ("𝐅𝐑𝐄𝐄" and "FREE" are
26
+ * "FREE"), case kept.
27
+ * @param {string} text
28
+ * @returns {string}
29
+ */
30
+ export function readableText(text: string): string;
22
31
  /**
23
32
  * Normalize text the way the classifier sees it: invisible characters
24
33
  * removed, compatibility forms folded (NFKC turns "free" and "𝐅𝐑𝐄𝐄" into
@@ -54,6 +63,22 @@ export function segmentWords(text: string, options?: {
54
63
  mixed: number;
55
64
  leet: number;
56
65
  };
66
+ /**
67
+ * The obfuscation in text, as segmentWords counts it ({invisible, mixed,
68
+ * styled}), segmenting into words only the parts of the text that could have
69
+ * a word mixing scripts: those with Latin letters and Cyrillic or Greek ones.
70
+ * @param {string} text
71
+ * @param {object} [options]
72
+ * @param {number} [options.maxLength] - characters of text to read
73
+ * @returns {{invisible: number, mixed: number, styled: boolean}}
74
+ */
75
+ export function obfuscationOf(text: string, options?: {
76
+ maxLength?: number;
77
+ }): {
78
+ invisible: number;
79
+ mixed: number;
80
+ styled: boolean;
81
+ };
57
82
  /**
58
83
  * The registrable domain of a host name ("mail.example.co.uk" gives
59
84
  * "example.co.uk"), or the host itself for IP addresses and unknown suffixes.
@@ -73,6 +98,45 @@ export function extractLinks(text: string, html: string): Array<{
73
98
  host: string;
74
99
  text: string | null;
75
100
  }>;
101
+ /**
102
+ * The end of the tag that starts at `start` (just after its ">"), or -1 when
103
+ * no ">" follows. A quoted attribute may hold ">" ("?a=>b"); a "<" outside
104
+ * quotes is part of the tag, as browsers read it. A quote not closed within
105
+ * 6000 characters is not one: then the tag ends at the next ">".
106
+ * @param {string} html
107
+ * @param {number} start
108
+ * @returns {number}
109
+ */
110
+ export function tagEnd(html: string, start: number): number;
111
+ /**
112
+ * The links of HTML: each <a> tag with an href (up to 4000 characters, in a
113
+ * tag of up to 6000), and its text without tags when "</a>" follows within
114
+ * 2000 characters.
115
+ *
116
+ * Read in about one pass: a tag is read up to its end, and the next tag
117
+ * looked for after it, and the closing tags are found with one search, so
118
+ * the HTML is not read again for each tag. (A tag whose quote is not closed
119
+ * may read up to 6000 characters, but another such tag within them needs the
120
+ * other quote mark, so at most two do.)
121
+ * @param {string} html
122
+ * @returns {Array<{href: string, inner: string}>}
123
+ */
124
+ export function anchors(html: string): Array<{
125
+ href: string;
126
+ inner: string;
127
+ }>;
128
+ /**
129
+ * Host names in text that are not links: written without "http://"
130
+ * ("paypa1-secure.example") or the domain of an email address. Only names
131
+ * under a real public suffix count ("node.js" and "file.txt" do not), written
132
+ * in lowercase as addresses are (so "end.Click" in text missing a space is
133
+ * not taken for one), not the local part of an address, and at most 50.
134
+ * These are for lookalike checks, not classifier features.
135
+ * @param {string} text
136
+ * @param {Set<string>} [known] - hosts of the links already found
137
+ * @returns {string[]}
138
+ */
139
+ export function extractHosts(text: string, known?: Set<string>): string[];
76
140
  /**
77
141
  * Whether the visible text of a link names a different site than the link
78
142
  * goes to ("https://paypal.com" linking to another domain).
@@ -86,8 +150,9 @@ export function isDeceptiveLink(link: {
86
150
  /**
87
151
  * Classifier features of a message parsed by mailparser.
88
152
  *
89
- * Every word of the body is a feature, every pair of neighbouring words is
90
- * another, and the subject's words are features of their own ("s:free").
153
+ * Every word of the text a mail client shows (see `visibleText`) is a
154
+ * feature, every pair of neighbouring words is another, and the subject's
155
+ * words are features of their own ("s:free").
91
156
  * The rest describe structure: link domains and tricks, the sender's domain,
92
157
  * HTML forms and hidden text, attachment types, headers, language and script,
93
158
  * and obfuscation. The same message always gives the same features.
@@ -96,7 +161,7 @@ export function isDeceptiveLink(link: {
96
161
  * @param {object} [options]
97
162
  * @param {number} [options.maxLength] - characters of body text to read
98
163
  * @param {number} [options.maxBigrams] - word pairs to emit
99
- * @returns {{features: string[], words: string[], language: string|null, script: string|null, links: Array}}
164
+ * @returns {{features: string[], words: string[], language: string|null, script: string|null, links: Array, hosts: string[], text: string, hidden: number}}
100
165
  */
101
166
  export function getFeatures(mail?: object, options?: {
102
167
  maxLength?: number;
@@ -107,4 +172,11 @@ export function getFeatures(mail?: object, options?: {
107
172
  language: string | null;
108
173
  script: string | null;
109
174
  links: any[];
175
+ hosts: string[];
176
+ text: string;
177
+ hidden: number;
110
178
  };
179
+ export const EMAIL_SOURCE: string;
180
+ export const ADDRESS_SOURCE: string;
181
+ export const NAMED_ADDRESS: RegExp;
182
+ export { decodeEntities, visibleText } from "./html.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spamscanner",
3
3
  "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.",
4
- "version": "7.0.1",
4
+ "version": "7.0.3",
5
5
  "author": "Forward Email (https://forwardemail.net)",
6
6
  "bin": {
7
7
  "spamscanner": "./dist/esm/cli.js"
@@ -104,6 +104,9 @@
104
104
  "license": "BUSL-1.1",
105
105
  "main": "./dist/cjs/index.cjs",
106
106
  "module": "./dist/esm/index.js",
107
+ "optionalDependencies": {
108
+ "re2": "^1.22.1"
109
+ },
107
110
  "repository": {
108
111
  "type": "git",
109
112
  "url": "git+https://github.com/spamscanner/spamscanner.git"