@blamejs/core 0.9.46 → 0.10.1
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/CHANGELOG.md +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-scan.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.scan
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title Mail Scan
|
|
6
|
+
* @order 555
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Anti-virus / content-scan facade for inbound + outbound mail.
|
|
10
|
+
* Operators wire `b.mail.scan.create({...})` once at boot, then call
|
|
11
|
+
* `.scan(messageBytes, opts)` from the MX listener (v0.9.45),
|
|
12
|
+
* submission listener (v0.9.47), or any custom pipeline that needs a
|
|
13
|
+
* verdict before delivering / forwarding a message.
|
|
14
|
+
*
|
|
15
|
+
* ## Backends
|
|
16
|
+
*
|
|
17
|
+
* Two transport shapes are supported out of the box:
|
|
18
|
+
*
|
|
19
|
+
* - **ICAP** (`protocol: "icap"` — default) — RFC 3507 Internet
|
|
20
|
+
* Content Adaptation Protocol. The framework speaks to a c-icap
|
|
21
|
+
* (or commercial) daemon over TCP, sends a REQMOD or RESPMOD
|
|
22
|
+
* request with the message body encapsulated, and parses the
|
|
23
|
+
* response through `b.safeIcap.parse`. The de-facto standard for
|
|
24
|
+
* Sophos / Symantec / Trend Micro / McAfee ICAP integrations.
|
|
25
|
+
*
|
|
26
|
+
* - **ClamAV INSTREAM** (`protocol: "clamav-instream"`) — the
|
|
27
|
+
* native ClamAV daemon protocol (no ICAP layer). Operator points
|
|
28
|
+
* at a clamd instance; the framework sends `zINSTREAM\0`, framed
|
|
29
|
+
* 4-byte-length-prefix chunks, then a zero-length terminator, and
|
|
30
|
+
* parses the line-shaped `<id>: stream: OK` / `<id>: stream:
|
|
31
|
+
* <virus> FOUND` response.
|
|
32
|
+
* See https://docs.clamav.net/manual/Usage/Configuration.html#instream
|
|
33
|
+
*
|
|
34
|
+
* ## Composition
|
|
35
|
+
*
|
|
36
|
+
* - **`b.safeIcap`** owns the ICAP wire-protocol bounded parser
|
|
37
|
+
* (CRLF discipline, status-allowlist, body cap). Every ICAP byte
|
|
38
|
+
* routes through it before any field is trusted.
|
|
39
|
+
* - **`b.guardArchive`** is composed when `opts.archiveEntries` is
|
|
40
|
+
* supplied — the scanner refuses an archive with hostile entry
|
|
41
|
+
* metadata BEFORE shipping bytes to the AV daemon, so a zip-bomb
|
|
42
|
+
* can't reach the scanner's parser. Recursion-depth cap is the
|
|
43
|
+
* guard's profile-default.
|
|
44
|
+
* - **`b.audit`** receives every request / verdict / error /
|
|
45
|
+
* timeout via `audit.safeEmit` (the audit failure is drop-silent
|
|
46
|
+
* per the hot-path rule).
|
|
47
|
+
*
|
|
48
|
+
* ## Threat model
|
|
49
|
+
*
|
|
50
|
+
* - **ICAP-response-injection (raw bytes → header injection)**:
|
|
51
|
+
* defended by `b.safeIcap` — bare-CR / bare-LF / NUL refused;
|
|
52
|
+
* status-code allowlist; bounded header / body / count caps.
|
|
53
|
+
* - **Parser-bomb on Encapsulated res-body** (hostile daemon ships
|
|
54
|
+
* arbitrary body length): defended by profile-tunable
|
|
55
|
+
* `maxBodyBytes` cap on the safeIcap parse path.
|
|
56
|
+
* - **DoS via slow daemon**: per-request wall-clock timeout (default
|
|
57
|
+
* 30s strict / 60s balanced / 120s permissive). After the timeout
|
|
58
|
+
* the scan resolves with `{ verdict: "error" }` and the listener
|
|
59
|
+
* fails the message-handling step (operator's choice: tempfail /
|
|
60
|
+
* reject / accept-with-tag).
|
|
61
|
+
* - **Archive-bomb / zip-slip pre-AV**: defended by optional
|
|
62
|
+
* `b.guardArchive.validateEntries` composition when the operator
|
|
63
|
+
* enumerates entries before the AV scan.
|
|
64
|
+
*
|
|
65
|
+
* ## Why not "vendor an AV signature engine"?
|
|
66
|
+
*
|
|
67
|
+
* AV signature databases are operator state, not framework state.
|
|
68
|
+
* ClamAV's signature set changes hourly; commercial scanners refresh
|
|
69
|
+
* their state through their own update channel. The framework's job
|
|
70
|
+
* is the wire-protocol parser + the operator-facing facade — the AV
|
|
71
|
+
* intelligence belongs to whatever daemon the operator deploys.
|
|
72
|
+
*
|
|
73
|
+
* @card
|
|
74
|
+
* ICAP (RFC 3507) + ClamAV-INSTREAM AV-scan facade. Composes
|
|
75
|
+
* b.safeIcap for wire-bytes hardening, b.guardArchive for pre-scan
|
|
76
|
+
* archive-metadata refusal, b.audit for verdict emission. Two
|
|
77
|
+
* built-in backends; operator points at their existing daemon.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
var net = require("node:net");
|
|
81
|
+
var safeBuffer = require("./safe-buffer");
|
|
82
|
+
var C = require("./constants");
|
|
83
|
+
var { defineClass } = require("./framework-error");
|
|
84
|
+
var lazyRequire = require("./lazy-require");
|
|
85
|
+
var validateOpts = require("./validate-opts");
|
|
86
|
+
var numericBounds = require("./numeric-bounds");
|
|
87
|
+
var safeIcap = require("./safe-icap");
|
|
88
|
+
|
|
89
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
90
|
+
var guardArchive = lazyRequire(function () { return require("./guard-archive"); });
|
|
91
|
+
|
|
92
|
+
var MailScanError = defineClass("MailScanError", { alwaysPermanent: true });
|
|
93
|
+
|
|
94
|
+
var DEFAULT_PROFILE = "strict";
|
|
95
|
+
var DEFAULT_PROTOCOL = "icap";
|
|
96
|
+
var DEFAULT_ICAP_SERVICE = "srv_clamav";
|
|
97
|
+
|
|
98
|
+
// allow:raw-byte-literal — ClamAV INSTREAM 4-byte length prefix.
|
|
99
|
+
var CLAMAV_LENGTH_PREFIX_BYTES = 4;
|
|
100
|
+
|
|
101
|
+
// allow:raw-byte-literal — ClamAV INSTREAM chunk size for streaming.
|
|
102
|
+
var CLAMAV_CHUNK_BYTES = 65536;
|
|
103
|
+
|
|
104
|
+
var PROFILES = Object.freeze({
|
|
105
|
+
strict: { timeoutMs: C.TIME.seconds(30), maxMessageBytes: C.BYTES.mib(25), maxResponseBytes: C.BYTES.mib(50) }, // allow:raw-byte-literal — operator-facing default mailbox cap
|
|
106
|
+
balanced: { timeoutMs: C.TIME.seconds(60), maxMessageBytes: C.BYTES.mib(50), maxResponseBytes: C.BYTES.mib(100) }, // allow:raw-byte-literal — operator-facing default mailbox cap
|
|
107
|
+
permissive: { timeoutMs: C.TIME.seconds(120), maxMessageBytes: C.BYTES.mib(150), maxResponseBytes: C.BYTES.mib(300) }, // allow:raw-byte-literal — operator-facing default mailbox cap
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
111
|
+
hipaa: "strict",
|
|
112
|
+
"pci-dss": "strict",
|
|
113
|
+
gdpr: "strict",
|
|
114
|
+
soc2: "strict",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
var ALLOWED_PROTOCOLS = Object.freeze({
|
|
118
|
+
"icap": true,
|
|
119
|
+
"clamav-instream": true,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @primitive b.mail.scan.create
|
|
124
|
+
* @signature b.mail.scan.create(opts)
|
|
125
|
+
* @since 0.9.81
|
|
126
|
+
* @status stable
|
|
127
|
+
* @related b.safeIcap.parse, b.guardArchive.validateEntries
|
|
128
|
+
*
|
|
129
|
+
* Build a mail-scan handle. Returns `{ scan(messageBytes, opts),
|
|
130
|
+
* profile, protocol, MailScanError }` where `.scan` resolves to
|
|
131
|
+
* `{ verdict, icapResponse?, threats?, durationMs }`:
|
|
132
|
+
*
|
|
133
|
+
* - `verdict`: `"clean"` | `"infected"` | `"error"`.
|
|
134
|
+
* - `icapResponse`: the structured `b.safeIcap.parse` result on
|
|
135
|
+
* ICAP backend (omitted on clamav-instream).
|
|
136
|
+
* - `threats`: Array<string> of threat names when infected.
|
|
137
|
+
* - `durationMs`: round-trip ms (audit / metrics).
|
|
138
|
+
*
|
|
139
|
+
* @opts
|
|
140
|
+
* host: string — required. ICAP / clamd hostname or IP.
|
|
141
|
+
* port: number — required. ICAP port (default 1344) /
|
|
142
|
+
* clamd port (default 3310).
|
|
143
|
+
* service: string — ICAP service name (default "srv_clamav").
|
|
144
|
+
* protocol: "icap" | "clamav-instream" — default "icap".
|
|
145
|
+
* timeoutMs: number — per-request wall clock; default per profile.
|
|
146
|
+
* profile: "strict" | "balanced" | "permissive".
|
|
147
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2".
|
|
148
|
+
* audit: b.audit instance (drop-silent on failure).
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* var scanner = b.mail.scan.create({
|
|
152
|
+
* host: "av.internal",
|
|
153
|
+
* port: 1344,
|
|
154
|
+
* service: "srv_clamav",
|
|
155
|
+
* });
|
|
156
|
+
* var verdict = await scanner.scan(rawMessage);
|
|
157
|
+
* if (verdict.verdict === "infected") refuseMessage(verdict.threats);
|
|
158
|
+
*/
|
|
159
|
+
function create(opts) {
|
|
160
|
+
opts = validateOpts.requireObject(opts || {}, "mail.scan.create", MailScanError, "mail-scan/bad-opts");
|
|
161
|
+
validateOpts(opts, [
|
|
162
|
+
"host", "port", "service", "protocol",
|
|
163
|
+
"timeoutMs", "profile", "posture", "audit",
|
|
164
|
+
], "mail.scan.create");
|
|
165
|
+
|
|
166
|
+
validateOpts.requireNonEmptyString(opts.host, "mail.scan.create.host",
|
|
167
|
+
MailScanError, "mail-scan/bad-host");
|
|
168
|
+
if (!numericBounds.isPositiveFiniteInt(opts.port) || opts.port > 65535) { // allow:raw-byte-literal — TCP port-number range cap
|
|
169
|
+
throw new MailScanError("mail-scan/bad-port",
|
|
170
|
+
"mail.scan.create.port must be a positive integer in [1,65535]; got " +
|
|
171
|
+
numericBounds.shape(opts.port));
|
|
172
|
+
}
|
|
173
|
+
var protocol = opts.protocol || DEFAULT_PROTOCOL;
|
|
174
|
+
if (!ALLOWED_PROTOCOLS[protocol]) {
|
|
175
|
+
throw new MailScanError("mail-scan/bad-protocol",
|
|
176
|
+
"mail.scan.create.protocol must be 'icap' or 'clamav-instream'; got '" + protocol + "'");
|
|
177
|
+
}
|
|
178
|
+
var service = opts.service || DEFAULT_ICAP_SERVICE;
|
|
179
|
+
if (protocol === "icap") {
|
|
180
|
+
validateOpts.requireNonEmptyString(service, "mail.scan.create.service",
|
|
181
|
+
MailScanError, "mail-scan/bad-service");
|
|
182
|
+
}
|
|
183
|
+
var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
|
|
184
|
+
if (!PROFILES[profile]) {
|
|
185
|
+
throw new MailScanError("mail-scan/bad-profile",
|
|
186
|
+
"mail.scan.create.profile: unknown '" + profile + "' (valid: strict / balanced / permissive)");
|
|
187
|
+
}
|
|
188
|
+
var caps = PROFILES[profile];
|
|
189
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.timeoutMs,
|
|
190
|
+
"mail.scan.create.timeoutMs", MailScanError, "mail-scan/bad-timeout");
|
|
191
|
+
var timeoutMs = opts.timeoutMs || caps.timeoutMs;
|
|
192
|
+
var auditImpl = opts.audit || audit();
|
|
193
|
+
|
|
194
|
+
function scan(messageBytes, scanOpts) {
|
|
195
|
+
scanOpts = scanOpts || {};
|
|
196
|
+
if (!Buffer.isBuffer(messageBytes)) {
|
|
197
|
+
throw new MailScanError("mail-scan/bad-input",
|
|
198
|
+
"mail.scan.scan: messageBytes must be a Buffer; got " + (typeof messageBytes));
|
|
199
|
+
}
|
|
200
|
+
if (messageBytes.length === 0) {
|
|
201
|
+
throw new MailScanError("mail-scan/bad-input",
|
|
202
|
+
"mail.scan.scan: messageBytes must be non-empty");
|
|
203
|
+
}
|
|
204
|
+
if (messageBytes.length > caps.maxMessageBytes) {
|
|
205
|
+
throw new MailScanError("mail-scan/oversize-message",
|
|
206
|
+
"mail.scan.scan: messageBytes=" + messageBytes.length + " exceeds maxMessageBytes=" +
|
|
207
|
+
caps.maxMessageBytes);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Optional archive-entries gate — compose b.guardArchive when the
|
|
211
|
+
// operator enumerates entries before scanning. Hostile entry metadata
|
|
212
|
+
// (zip-slip / hardlink-escape / decompression-bomb-ratio) refuses
|
|
213
|
+
// before the AV daemon ever sees the bytes.
|
|
214
|
+
if (Array.isArray(scanOpts.archiveEntries)) {
|
|
215
|
+
var gv = guardArchive().validateEntries(scanOpts.archiveEntries, { profile: profile });
|
|
216
|
+
if (gv && gv.issues && gv.issues.length > 0) {
|
|
217
|
+
var infectedThreats = gv.issues.map(function (i) { return "archive:" + (i.code || "unknown"); });
|
|
218
|
+
_emitAudit(auditImpl, "mail.scan.infected", "success", {
|
|
219
|
+
reason: "archive-pre-scan", threats: infectedThreats,
|
|
220
|
+
});
|
|
221
|
+
return Promise.resolve({
|
|
222
|
+
verdict: "infected",
|
|
223
|
+
threats: infectedThreats,
|
|
224
|
+
durationMs: 0,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_emitAudit(auditImpl, "mail.scan.request", "success", {
|
|
230
|
+
protocol: protocol, host: opts.host, port: opts.port, bytes: messageBytes.length,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
var t0 = Date.now();
|
|
234
|
+
if (protocol === "icap") {
|
|
235
|
+
return _scanIcap(messageBytes, scanOpts).then(function (rv) {
|
|
236
|
+
rv.durationMs = Date.now() - t0;
|
|
237
|
+
_emitScanResult(auditImpl, rv);
|
|
238
|
+
return rv;
|
|
239
|
+
}, function (e) {
|
|
240
|
+
var ms = Date.now() - t0;
|
|
241
|
+
return _failTo(auditImpl, e, ms);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return _scanClamavInstream(messageBytes).then(function (rv) {
|
|
245
|
+
rv.durationMs = Date.now() - t0;
|
|
246
|
+
_emitScanResult(auditImpl, rv);
|
|
247
|
+
return rv;
|
|
248
|
+
}, function (e) {
|
|
249
|
+
var ms = Date.now() - t0;
|
|
250
|
+
return _failTo(auditImpl, e, ms);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _scanIcap(messageBytes, scanOpts) {
|
|
255
|
+
return new Promise(function (resolve, reject) {
|
|
256
|
+
var sock = (scanOpts._socket && typeof scanOpts._socket.write === "function")
|
|
257
|
+
? scanOpts._socket
|
|
258
|
+
: net.createConnection({ host: opts.host, port: opts.port });
|
|
259
|
+
|
|
260
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
261
|
+
maxBytes: caps.maxResponseBytes,
|
|
262
|
+
errorClass: MailScanError,
|
|
263
|
+
sizeCode: "mail-scan/icap-response-too-large",
|
|
264
|
+
sizeMessage: "mail.scan.scan: ICAP response exceeded maxResponseBytes",
|
|
265
|
+
});
|
|
266
|
+
var done = false;
|
|
267
|
+
var to = setTimeout(function () {
|
|
268
|
+
if (done) return;
|
|
269
|
+
done = true;
|
|
270
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
271
|
+
reject(new MailScanError("mail-scan/timeout",
|
|
272
|
+
"mail.scan.scan: ICAP timeout after " + timeoutMs + "ms"));
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
|
|
275
|
+
sock.on("error", function (e) {
|
|
276
|
+
if (done) return;
|
|
277
|
+
done = true;
|
|
278
|
+
clearTimeout(to);
|
|
279
|
+
reject(new MailScanError("mail-scan/transport",
|
|
280
|
+
"mail.scan.scan: ICAP socket error: " + (e && e.message || e)));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
sock.on("data", function (chunk) {
|
|
284
|
+
try { collector.push(chunk); }
|
|
285
|
+
catch (e) {
|
|
286
|
+
if (done) return;
|
|
287
|
+
done = true;
|
|
288
|
+
clearTimeout(to);
|
|
289
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
290
|
+
reject(e);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
sock.on("end", function () {
|
|
295
|
+
if (done) return;
|
|
296
|
+
done = true;
|
|
297
|
+
clearTimeout(to);
|
|
298
|
+
try {
|
|
299
|
+
var raw = collector.result();
|
|
300
|
+
var parsed = safeIcap.parse(raw, { profile: profile });
|
|
301
|
+
var threats = [];
|
|
302
|
+
if (parsed.threatName) threats.push(parsed.threatName);
|
|
303
|
+
resolve({
|
|
304
|
+
verdict: parsed.threatFound ? "infected" : (parsed.statusCode === 200 || parsed.statusCode === 204 ? "clean" : "error"),
|
|
305
|
+
icapResponse: parsed,
|
|
306
|
+
threats: threats,
|
|
307
|
+
});
|
|
308
|
+
} catch (e) {
|
|
309
|
+
reject(e);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// RFC 3507 §4.3.2 — RESPMOD request: ICAP-Version, Host header,
|
|
314
|
+
// Encapsulated header pointing at res-hdr=0, res-body=N. We send
|
|
315
|
+
// a minimal HTTP-response wrapper around the raw mail bytes; the
|
|
316
|
+
// ICAP daemon scans the body region.
|
|
317
|
+
var httpHdr = "HTTP/1.1 200 OK\r\nContent-Type: message/rfc822\r\nContent-Length: " +
|
|
318
|
+
messageBytes.length + "\r\n\r\n";
|
|
319
|
+
var resBodyOffset = Buffer.byteLength(httpHdr, "ascii");
|
|
320
|
+
var icapHdr =
|
|
321
|
+
"RESPMOD icap://" + opts.host + ":" + opts.port + "/" + service + " ICAP/1.0\r\n" +
|
|
322
|
+
"Host: " + opts.host + "\r\n" +
|
|
323
|
+
"Allow: 204\r\n" +
|
|
324
|
+
"Encapsulated: res-hdr=0, res-body=" + resBodyOffset + "\r\n" +
|
|
325
|
+
"\r\n";
|
|
326
|
+
try {
|
|
327
|
+
sock.write(icapHdr);
|
|
328
|
+
sock.write(httpHdr);
|
|
329
|
+
// RFC 3507 §4.4.3 — body is chunked-transfer; we write a single
|
|
330
|
+
// chunk + terminator for simplicity. The wire format is the same
|
|
331
|
+
// as HTTP/1.1 chunked: `<hex-length>\r\n<bytes>\r\n0\r\n\r\n`.
|
|
332
|
+
var lenHex = messageBytes.length.toString(16); // allow:raw-byte-literal — hex radix
|
|
333
|
+
sock.write(lenHex + "\r\n");
|
|
334
|
+
sock.write(messageBytes);
|
|
335
|
+
sock.write("\r\n0\r\n\r\n");
|
|
336
|
+
if (typeof sock.end === "function") sock.end();
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (done) return;
|
|
339
|
+
done = true;
|
|
340
|
+
clearTimeout(to);
|
|
341
|
+
reject(new MailScanError("mail-scan/transport",
|
|
342
|
+
"mail.scan.scan: ICAP write error: " + (e && e.message || e)));
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _scanClamavInstream(messageBytes) {
|
|
348
|
+
return new Promise(function (resolve, reject) {
|
|
349
|
+
var sock = net.createConnection({ host: opts.host, port: opts.port });
|
|
350
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
351
|
+
maxBytes: caps.maxResponseBytes,
|
|
352
|
+
errorClass: MailScanError,
|
|
353
|
+
sizeCode: "mail-scan/clamav-response-too-large",
|
|
354
|
+
sizeMessage: "mail.scan.scan: clamav reply exceeded maxResponseBytes",
|
|
355
|
+
});
|
|
356
|
+
var done = false;
|
|
357
|
+
var to = setTimeout(function () {
|
|
358
|
+
if (done) return;
|
|
359
|
+
done = true;
|
|
360
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
361
|
+
reject(new MailScanError("mail-scan/timeout",
|
|
362
|
+
"mail.scan.scan: clamav-instream timeout after " + timeoutMs + "ms"));
|
|
363
|
+
}, timeoutMs);
|
|
364
|
+
|
|
365
|
+
sock.on("error", function (e) {
|
|
366
|
+
if (done) return;
|
|
367
|
+
done = true;
|
|
368
|
+
clearTimeout(to);
|
|
369
|
+
reject(new MailScanError("mail-scan/transport",
|
|
370
|
+
"mail.scan.scan: clamav socket error: " + (e && e.message || e)));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
sock.on("data", function (chunk) {
|
|
374
|
+
try { collector.push(chunk); }
|
|
375
|
+
catch (e) {
|
|
376
|
+
if (done) return;
|
|
377
|
+
done = true;
|
|
378
|
+
clearTimeout(to);
|
|
379
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
380
|
+
reject(e);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
sock.on("end", function () {
|
|
385
|
+
if (done) return;
|
|
386
|
+
done = true;
|
|
387
|
+
clearTimeout(to);
|
|
388
|
+
var reply = collector.result().toString("utf8").replace(/[\r\n\0]+$/g, ""); // allow:regex-no-length-cap — trailing-trim anchored
|
|
389
|
+
// ClamAV INSTREAM reply: "<id>: <verdict>" where verdict is
|
|
390
|
+
// "stream: OK", "stream: <Sig.Name> FOUND", or "INSTREAM size
|
|
391
|
+
// limit exceeded. ERROR".
|
|
392
|
+
if (/stream:\s+OK\b/.test(reply)) { // allow:regex-no-length-cap — anchored to fixed token
|
|
393
|
+
resolve({ verdict: "clean", threats: [] });
|
|
394
|
+
} else {
|
|
395
|
+
var m = reply.match(/stream:\s+(.+?)\s+FOUND\b/); // allow:regex-no-length-cap — anchored to fixed FOUND token
|
|
396
|
+
if (m) {
|
|
397
|
+
resolve({ verdict: "infected", threats: [m[1]] });
|
|
398
|
+
} else if (/ERROR/i.test(reply)) { // allow:regex-no-length-cap — anchored to fixed ERROR token
|
|
399
|
+
resolve({ verdict: "error", threats: [] });
|
|
400
|
+
} else {
|
|
401
|
+
// Unrecognized reply shape — treat as error so the caller
|
|
402
|
+
// gets a definite "do not deliver" signal instead of a
|
|
403
|
+
// silent clean verdict.
|
|
404
|
+
resolve({ verdict: "error", threats: [] });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
sock.write("zINSTREAM\0");
|
|
411
|
+
var off = 0;
|
|
412
|
+
while (off < messageBytes.length) {
|
|
413
|
+
var endOff = Math.min(off + CLAMAV_CHUNK_BYTES, messageBytes.length);
|
|
414
|
+
var lenBuf = Buffer.alloc(CLAMAV_LENGTH_PREFIX_BYTES);
|
|
415
|
+
lenBuf.writeUInt32BE(endOff - off, 0);
|
|
416
|
+
sock.write(lenBuf);
|
|
417
|
+
sock.write(messageBytes.slice(off, endOff));
|
|
418
|
+
off = endOff;
|
|
419
|
+
}
|
|
420
|
+
var term = Buffer.alloc(CLAMAV_LENGTH_PREFIX_BYTES);
|
|
421
|
+
term.writeUInt32BE(0, 0);
|
|
422
|
+
sock.write(term);
|
|
423
|
+
if (typeof sock.end === "function") sock.end();
|
|
424
|
+
} catch (e) {
|
|
425
|
+
if (done) return;
|
|
426
|
+
done = true;
|
|
427
|
+
clearTimeout(to);
|
|
428
|
+
reject(new MailScanError("mail-scan/transport",
|
|
429
|
+
"mail.scan.scan: clamav write error: " + (e && e.message || e)));
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
scan: scan,
|
|
436
|
+
profile: profile,
|
|
437
|
+
protocol: protocol,
|
|
438
|
+
host: opts.host,
|
|
439
|
+
port: opts.port,
|
|
440
|
+
service: service,
|
|
441
|
+
timeoutMs: timeoutMs,
|
|
442
|
+
MailScanError: MailScanError,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* @primitive b.mail.scan.compliancePosture
|
|
448
|
+
* @signature b.mail.scan.compliancePosture(posture)
|
|
449
|
+
* @since 0.9.81
|
|
450
|
+
* @status stable
|
|
451
|
+
*
|
|
452
|
+
* Return the effective profile name for a compliance posture, or
|
|
453
|
+
* `null` for unknown posture names.
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* b.mail.scan.compliancePosture("hipaa"); // → "strict"
|
|
457
|
+
*/
|
|
458
|
+
function compliancePosture(posture) {
|
|
459
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function _emitScanResult(auditImpl, rv) {
|
|
463
|
+
if (rv.verdict === "clean") {
|
|
464
|
+
_emitAudit(auditImpl, "mail.scan.clean", "success", { durationMs: rv.durationMs });
|
|
465
|
+
} else if (rv.verdict === "infected") {
|
|
466
|
+
_emitAudit(auditImpl, "mail.scan.infected", "success", {
|
|
467
|
+
durationMs: rv.durationMs, threats: rv.threats,
|
|
468
|
+
});
|
|
469
|
+
} else {
|
|
470
|
+
_emitAudit(auditImpl, "mail.scan.error", "failure", { durationMs: rv.durationMs });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function _failTo(auditImpl, e, ms) {
|
|
475
|
+
if (e && e.code === "mail-scan/timeout") {
|
|
476
|
+
_emitAudit(auditImpl, "mail.scan.timeout", "failure", { durationMs: ms });
|
|
477
|
+
} else {
|
|
478
|
+
_emitAudit(auditImpl, "mail.scan.error", "failure", {
|
|
479
|
+
durationMs: ms, message: (e && e.message) || String(e),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return { verdict: "error", threats: [], durationMs: ms,
|
|
483
|
+
errorCode: (e && e.code) || "mail-scan/unknown",
|
|
484
|
+
errorMessage: (e && e.message) || String(e) };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function _emitAudit(auditImpl, action, outcome, metadata) {
|
|
488
|
+
try {
|
|
489
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
490
|
+
auditImpl.safeEmit({ action: action, outcome: outcome, metadata: metadata });
|
|
491
|
+
}
|
|
492
|
+
} catch (_e) { /* drop-silent — audit failures don't break scan path */ }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
module.exports = {
|
|
496
|
+
create: create,
|
|
497
|
+
compliancePosture: compliancePosture,
|
|
498
|
+
PROFILES: PROFILES,
|
|
499
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
500
|
+
ALLOWED_PROTOCOLS: ALLOWED_PROTOCOLS,
|
|
501
|
+
MailScanError: MailScanError,
|
|
502
|
+
};
|