@blamejs/core 0.9.19 → 0.9.21
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 +2 -0
- package/index.js +14 -0
- package/lib/agent-orchestrator.js +469 -0
- package/lib/guard-agent-registry.js +179 -0
- package/lib/guard-mail-compose.js +282 -0
- package/lib/guard-mail-move.js +202 -0
- package/lib/guard-mail-query.js +296 -0
- package/lib/guard-mail-reply.js +172 -0
- package/lib/guard-mail-sieve.js +207 -0
- package/lib/mail-agent.js +638 -0
- package/lib/mail-store.js +67 -0
- package/lib/mail.js +6 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardAgentRegistry
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Agent Registry
|
|
6
|
+
* @order 435
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Registry-op shape validator for `b.agent.orchestrator.register` /
|
|
10
|
+
* `lookup` / `unregister`. Refuses agent names that wouldn't be
|
|
11
|
+
* safe to surface in audit logs, registry queries, or routing
|
|
12
|
+
* keys:
|
|
13
|
+
*
|
|
14
|
+
* - non-ASCII (NFC-normalized + ASCII-only — operator-greppable)
|
|
15
|
+
* - path-traversal shapes (`..` / `/` / `\` / NUL / C0 / DEL)
|
|
16
|
+
* - oversized (default 64 bytes per name)
|
|
17
|
+
* - reserved `FRAMEWORK.*` / `ROOT` / `*` prefix from operator code
|
|
18
|
+
* - duplicate-on-register (caller must `unregister` first)
|
|
19
|
+
*
|
|
20
|
+
* @card
|
|
21
|
+
* Validates `b.agent.orchestrator.register` op shapes. Path-traversal
|
|
22
|
+
* refusal, reserved-prefix refusal, non-ASCII refusal, oversize cap.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var { defineClass } = require("./framework-error");
|
|
26
|
+
|
|
27
|
+
var GuardAgentRegistryError = defineClass("GuardAgentRegistryError", { alwaysPermanent: true });
|
|
28
|
+
|
|
29
|
+
var DEFAULT_PROFILE = "strict";
|
|
30
|
+
|
|
31
|
+
var PROFILES = Object.freeze({
|
|
32
|
+
strict: { maxNameBytes: 64, maxKindBytes: 32 }, // allow:raw-byte-literal
|
|
33
|
+
balanced: { maxNameBytes: 128, maxKindBytes: 64 }, // allow:raw-byte-literal
|
|
34
|
+
permissive: { maxNameBytes: 512, maxKindBytes: 128 }, // allow:raw-byte-literal
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
38
|
+
hipaa: "strict",
|
|
39
|
+
"pci-dss": "strict",
|
|
40
|
+
gdpr: "strict",
|
|
41
|
+
soc2: "strict",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
var RESERVED_PREFIXES = Object.freeze(["FRAMEWORK.", "ROOT.", "framework.", "root."]);
|
|
45
|
+
var RESERVED_EXACT = Object.freeze({ "ROOT": true, "FRAMEWORK": true, "*": true });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @primitive b.guardAgentRegistry.validate
|
|
49
|
+
* @signature b.guardAgentRegistry.validate(op, opts?)
|
|
50
|
+
* @since 0.9.21
|
|
51
|
+
* @status stable
|
|
52
|
+
* @related b.agent.orchestrator.create
|
|
53
|
+
*
|
|
54
|
+
* Validate a `{ kind, name, agent, opts }` registry op shape. Returns
|
|
55
|
+
* the op on success; throws `GuardAgentRegistryError` on refusal.
|
|
56
|
+
*
|
|
57
|
+
* @opts
|
|
58
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
59
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* b.guardAgentRegistry.validate({
|
|
63
|
+
* kind: "register",
|
|
64
|
+
* name: "tenant-acme-mail",
|
|
65
|
+
* agentKind: "mail",
|
|
66
|
+
* });
|
|
67
|
+
*/
|
|
68
|
+
function validate(op, opts) {
|
|
69
|
+
opts = opts || {};
|
|
70
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
71
|
+
if (!op || typeof op !== "object") {
|
|
72
|
+
throw new GuardAgentRegistryError("agent-registry/bad-input",
|
|
73
|
+
"guardAgentRegistry.validate: op required");
|
|
74
|
+
}
|
|
75
|
+
if (op.kind !== "register" && op.kind !== "lookup" && op.kind !== "unregister" && op.kind !== "list") {
|
|
76
|
+
throw new GuardAgentRegistryError("agent-registry/bad-kind",
|
|
77
|
+
"guardAgentRegistry.validate: op.kind must be 'register' | 'lookup' | 'unregister' | 'list'");
|
|
78
|
+
}
|
|
79
|
+
if (op.kind === "list") return op; // list takes optional filters only
|
|
80
|
+
|
|
81
|
+
_checkName(op.name, profile);
|
|
82
|
+
if (op.kind === "register") {
|
|
83
|
+
if (typeof op.agentKind !== "string" || op.agentKind.length === 0) {
|
|
84
|
+
throw new GuardAgentRegistryError("agent-registry/no-kind",
|
|
85
|
+
"guardAgentRegistry.validate: register op requires agentKind");
|
|
86
|
+
}
|
|
87
|
+
_checkKind(op.agentKind, profile);
|
|
88
|
+
}
|
|
89
|
+
return op;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @primitive b.guardAgentRegistry.compliancePosture
|
|
94
|
+
* @signature b.guardAgentRegistry.compliancePosture(posture)
|
|
95
|
+
* @since 0.9.21
|
|
96
|
+
* @status stable
|
|
97
|
+
*
|
|
98
|
+
* Return the effective profile for a given compliance posture name.
|
|
99
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
100
|
+
* here instead of silently falling through to the default profile.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* b.guardAgentRegistry.compliancePosture("hipaa"); // → "strict"
|
|
104
|
+
*/
|
|
105
|
+
function compliancePosture(posture) {
|
|
106
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _checkName(name, profile) {
|
|
110
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
111
|
+
throw new GuardAgentRegistryError("agent-registry/bad-name",
|
|
112
|
+
"guardAgentRegistry.validate: op.name must be a non-empty string");
|
|
113
|
+
}
|
|
114
|
+
if (Buffer.byteLength(name, "utf8") > profile.maxNameBytes) {
|
|
115
|
+
throw new GuardAgentRegistryError("agent-registry/name-too-long",
|
|
116
|
+
"guardAgentRegistry.validate: name exceeds maxNameBytes=" + profile.maxNameBytes);
|
|
117
|
+
}
|
|
118
|
+
if (RESERVED_EXACT[name]) {
|
|
119
|
+
throw new GuardAgentRegistryError("agent-registry/reserved-name",
|
|
120
|
+
"guardAgentRegistry.validate: name '" + name + "' is framework-reserved");
|
|
121
|
+
}
|
|
122
|
+
for (var p = 0; p < RESERVED_PREFIXES.length; p += 1) {
|
|
123
|
+
if (name.indexOf(RESERVED_PREFIXES[p]) === 0) {
|
|
124
|
+
throw new GuardAgentRegistryError("agent-registry/reserved-prefix",
|
|
125
|
+
"guardAgentRegistry.validate: name '" + name + "' uses reserved prefix '" +
|
|
126
|
+
RESERVED_PREFIXES[p] + "'");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (name.indexOf("..") >= 0) {
|
|
130
|
+
throw new GuardAgentRegistryError("agent-registry/path-traversal",
|
|
131
|
+
"guardAgentRegistry.validate: name contains '..'");
|
|
132
|
+
}
|
|
133
|
+
for (var i = 0; i < name.length; i += 1) {
|
|
134
|
+
var c = name.charCodeAt(i);
|
|
135
|
+
if (c > 0x7F) { // allow:raw-byte-literal — ASCII-only cap
|
|
136
|
+
throw new GuardAgentRegistryError("agent-registry/non-ascii",
|
|
137
|
+
"guardAgentRegistry.validate: name contains non-ASCII codepoint at offset " + i);
|
|
138
|
+
}
|
|
139
|
+
if (c < 0x20 || c === 0x7F || c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — C0 / DEL / slash / backslash
|
|
140
|
+
throw new GuardAgentRegistryError("agent-registry/bad-name-char",
|
|
141
|
+
"guardAgentRegistry.validate: name contains forbidden char 0x" + c.toString(16));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _checkKind(kind, profile) {
|
|
147
|
+
if (Buffer.byteLength(kind, "utf8") > profile.maxKindBytes) {
|
|
148
|
+
throw new GuardAgentRegistryError("agent-registry/kind-too-long",
|
|
149
|
+
"guardAgentRegistry.validate: agentKind exceeds maxKindBytes=" + profile.maxKindBytes);
|
|
150
|
+
}
|
|
151
|
+
if (!/^[a-z][a-z0-9-]*$/.test(kind)) { // allow:regex-no-length-cap — kind length bounded above
|
|
152
|
+
throw new GuardAgentRegistryError("agent-registry/bad-kind-shape",
|
|
153
|
+
"guardAgentRegistry.validate: agentKind must match /^[a-z][a-z0-9-]*$/");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _resolveProfile(opts) {
|
|
158
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
159
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
160
|
+
}
|
|
161
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
162
|
+
if (!PROFILES[p]) {
|
|
163
|
+
throw new GuardAgentRegistryError("agent-registry/bad-profile",
|
|
164
|
+
"guardAgentRegistry: unknown profile '" + p + "'");
|
|
165
|
+
}
|
|
166
|
+
return p;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = {
|
|
170
|
+
validate: validate,
|
|
171
|
+
compliancePosture: compliancePosture,
|
|
172
|
+
PROFILES: PROFILES,
|
|
173
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
174
|
+
RESERVED_PREFIXES: RESERVED_PREFIXES,
|
|
175
|
+
RESERVED_EXACT: RESERVED_EXACT,
|
|
176
|
+
GuardAgentRegistryError: GuardAgentRegistryError,
|
|
177
|
+
NAME: "agentRegistry",
|
|
178
|
+
KIND: "agent-registry",
|
|
179
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardMailCompose
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Mail Compose
|
|
6
|
+
* @order 431
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Outbound draft validator for `b.mail.agent.compose` /
|
|
10
|
+
* `b.mail.agent.reply` / `b.mail.agent.forward`. Composes the
|
|
11
|
+
* existing `b.guardEmail.validateMessage` for address + header shape
|
|
12
|
+
* and adds compose-specific rules:
|
|
13
|
+
*
|
|
14
|
+
* - identity vs From alignment — operator-supplied `identity.email`
|
|
15
|
+
* must equal the From header local-part + domain (defends spoof-
|
|
16
|
+
* at-submission)
|
|
17
|
+
* - recipient deduplication — Sender / To / Cc / Bcc combined
|
|
18
|
+
* cardinality cap (default 100; envelope-from never duplicated)
|
|
19
|
+
* - attachment byte cap — sum of `body.attachments[*].size_bytes`
|
|
20
|
+
* must not exceed `maxAttachmentBytes` (default 25 MiB to match
|
|
21
|
+
* the RFC 5321 §4.5.3.1.10 receiver cap)
|
|
22
|
+
* - body shape — exactly one of `text` / `html` required (multipart
|
|
23
|
+
* at submission-time per RFC 2046 §5.1.3); both allowed when
|
|
24
|
+
* operator explicitly opts in via `allowMultipartAlternative`
|
|
25
|
+
* - Subject control-char refusal — same C0 / DEL rule the existing
|
|
26
|
+
* `b.guardEmail` applies to header values
|
|
27
|
+
*
|
|
28
|
+
* Profile vocabulary mirrors the rest of the guard family
|
|
29
|
+
* (`strict` / `balanced` / `permissive`); posture vocabulary
|
|
30
|
+
* (`hipaa` / `pci-dss` / `gdpr` / `soc2`) pins `strict`.
|
|
31
|
+
*
|
|
32
|
+
* @card
|
|
33
|
+
* Validates outbound mail drafts at `b.mail.agent.compose`.
|
|
34
|
+
* Identity-vs-From alignment, recipient dedup, attachment byte cap,
|
|
35
|
+
* body shape, header control-char refusal.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
var { defineClass } = require("./framework-error");
|
|
39
|
+
|
|
40
|
+
var GuardMailComposeError = defineClass("GuardMailComposeError", { alwaysPermanent: true });
|
|
41
|
+
|
|
42
|
+
var DEFAULT_PROFILE = "strict";
|
|
43
|
+
|
|
44
|
+
var PROFILES = Object.freeze({
|
|
45
|
+
strict: { maxRecipients: 100, maxAttachmentBytes: 26214400, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 25 MiB, RFC 5322 §2.1.1 line cap
|
|
46
|
+
balanced: { maxRecipients: 500, maxAttachmentBytes: 52428800, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 50 MiB
|
|
47
|
+
permissive: { maxRecipients: 2000, maxAttachmentBytes: 104857600, maxSubjectBytes: 998 }, // allow:raw-byte-literal — 100 MiB
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
51
|
+
hipaa: "strict",
|
|
52
|
+
"pci-dss": "strict",
|
|
53
|
+
gdpr: "strict",
|
|
54
|
+
soc2: "strict",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @primitive b.guardMailCompose.validate
|
|
59
|
+
* @signature b.guardMailCompose.validate(draft, opts?)
|
|
60
|
+
* @since 0.9.20
|
|
61
|
+
* @status stable
|
|
62
|
+
* @related b.guardMailReply, b.guardEmail
|
|
63
|
+
*
|
|
64
|
+
* Validate an outbound draft envelope. Returns the input on success;
|
|
65
|
+
* throws `GuardMailComposeError` on refusal.
|
|
66
|
+
*
|
|
67
|
+
* @opts
|
|
68
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
69
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
70
|
+
* identity: { email: string, name?: string }, // required if checkIdentity
|
|
71
|
+
* checkIdentity: boolean, // default true
|
|
72
|
+
* allowMultipartAlternative: boolean, // default false
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* b.guardMailCompose.validate({
|
|
76
|
+
* from: "alice@example.com",
|
|
77
|
+
* to: ["bob@example.com"],
|
|
78
|
+
* subject: "hello",
|
|
79
|
+
* body: { text: "hi" },
|
|
80
|
+
* }, { identity: { email: "alice@example.com" } });
|
|
81
|
+
*/
|
|
82
|
+
function validate(draft, opts) {
|
|
83
|
+
opts = opts || {};
|
|
84
|
+
var profileName = _resolveProfile(opts);
|
|
85
|
+
var profile = PROFILES[profileName];
|
|
86
|
+
if (!draft || typeof draft !== "object") {
|
|
87
|
+
throw new GuardMailComposeError("mail-compose/bad-input",
|
|
88
|
+
"guardMailCompose.validate: draft required");
|
|
89
|
+
}
|
|
90
|
+
if (typeof draft.from !== "string" || draft.from.length === 0) {
|
|
91
|
+
throw new GuardMailComposeError("mail-compose/no-from",
|
|
92
|
+
"guardMailCompose.validate: draft.from required");
|
|
93
|
+
}
|
|
94
|
+
_checkHeaderValue(draft.from, "from");
|
|
95
|
+
_checkAddrList(draft.to, "to", profile);
|
|
96
|
+
_checkAddrList(draft.cc, "cc", profile);
|
|
97
|
+
_checkAddrList(draft.bcc, "bcc", profile);
|
|
98
|
+
if (!_anyRecipient(draft)) {
|
|
99
|
+
throw new GuardMailComposeError("mail-compose/no-recipient",
|
|
100
|
+
"guardMailCompose.validate: at least one to/cc/bcc required");
|
|
101
|
+
}
|
|
102
|
+
_checkRecipientCardinality(draft, profile);
|
|
103
|
+
|
|
104
|
+
if (typeof draft.subject !== "undefined") {
|
|
105
|
+
if (typeof draft.subject !== "string") {
|
|
106
|
+
throw new GuardMailComposeError("mail-compose/bad-subject",
|
|
107
|
+
"guardMailCompose.validate: subject must be a string");
|
|
108
|
+
}
|
|
109
|
+
if (Buffer.byteLength(draft.subject, "utf8") > profile.maxSubjectBytes) {
|
|
110
|
+
throw new GuardMailComposeError("mail-compose/subject-too-long",
|
|
111
|
+
"guardMailCompose.validate: subject exceeds maxSubjectBytes=" + profile.maxSubjectBytes);
|
|
112
|
+
}
|
|
113
|
+
_checkHeaderValue(draft.subject, "subject");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_checkBody(draft.body, profile, !!opts.allowMultipartAlternative);
|
|
117
|
+
|
|
118
|
+
// Identity vs From alignment — defends spoof-at-submission. When the
|
|
119
|
+
// operator wires an identity for the actor, the draft's From: header
|
|
120
|
+
// must match that identity's email. Disable explicitly via
|
|
121
|
+
// checkIdentity: false (e.g. shared-mailbox submission roles).
|
|
122
|
+
var checkIdentity = opts.checkIdentity !== false;
|
|
123
|
+
if (checkIdentity && opts.identity && opts.identity.email) {
|
|
124
|
+
var fromAddr = _extractAddr(draft.from);
|
|
125
|
+
if (fromAddr.toLowerCase() !== String(opts.identity.email).toLowerCase()) {
|
|
126
|
+
throw new GuardMailComposeError("mail-compose/identity-mismatch",
|
|
127
|
+
"guardMailCompose.validate: From '" + fromAddr +
|
|
128
|
+
"' does not match identity '" + opts.identity.email + "'");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return draft;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @primitive b.guardMailCompose.compliancePosture
|
|
136
|
+
* @signature b.guardMailCompose.compliancePosture(posture)
|
|
137
|
+
* @since 0.9.20
|
|
138
|
+
* @status stable
|
|
139
|
+
*
|
|
140
|
+
* Return the effective profile for a given compliance posture name.
|
|
141
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
142
|
+
* here instead of silently falling through to the default profile.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* b.guardMailCompose.compliancePosture("hipaa"); // → "strict"
|
|
146
|
+
*/
|
|
147
|
+
function compliancePosture(posture) {
|
|
148
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _checkAddrList(list, label, profile) {
|
|
152
|
+
if (typeof list === "undefined" || list === null) return;
|
|
153
|
+
if (!Array.isArray(list)) {
|
|
154
|
+
throw new GuardMailComposeError("mail-compose/bad-addr-list",
|
|
155
|
+
"guardMailCompose.validate: " + label + " must be an array of strings");
|
|
156
|
+
}
|
|
157
|
+
if (list.length > profile.maxRecipients) {
|
|
158
|
+
throw new GuardMailComposeError("mail-compose/too-many-recipients",
|
|
159
|
+
"guardMailCompose.validate: " + label + " count " + list.length +
|
|
160
|
+
" exceeds maxRecipients=" + profile.maxRecipients);
|
|
161
|
+
}
|
|
162
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
163
|
+
if (typeof list[i] !== "string" || list[i].length === 0) {
|
|
164
|
+
throw new GuardMailComposeError("mail-compose/bad-addr",
|
|
165
|
+
"guardMailCompose.validate: " + label + "[" + i + "] must be a non-empty string");
|
|
166
|
+
}
|
|
167
|
+
_checkHeaderValue(list[i], label + "[" + i + "]");
|
|
168
|
+
var addr = _extractAddr(list[i]);
|
|
169
|
+
if (addr.indexOf("@") < 0) {
|
|
170
|
+
throw new GuardMailComposeError("mail-compose/bad-addr",
|
|
171
|
+
"guardMailCompose.validate: " + label + "[" + i + "] missing '@'");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _checkRecipientCardinality(draft, profile) {
|
|
177
|
+
var all = [];
|
|
178
|
+
["to", "cc", "bcc"].forEach(function (k) {
|
|
179
|
+
if (Array.isArray(draft[k])) {
|
|
180
|
+
for (var i = 0; i < draft[k].length; i += 1) {
|
|
181
|
+
all.push(_extractAddr(draft[k][i]).toLowerCase());
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (all.length > profile.maxRecipients) {
|
|
186
|
+
throw new GuardMailComposeError("mail-compose/too-many-recipients",
|
|
187
|
+
"guardMailCompose.validate: combined recipient count " + all.length +
|
|
188
|
+
" exceeds maxRecipients=" + profile.maxRecipients);
|
|
189
|
+
}
|
|
190
|
+
var seen = Object.create(null);
|
|
191
|
+
for (var j = 0; j < all.length; j += 1) {
|
|
192
|
+
if (seen[all[j]]) {
|
|
193
|
+
throw new GuardMailComposeError("mail-compose/duplicate-recipient",
|
|
194
|
+
"guardMailCompose.validate: '" + all[j] + "' appears in multiple recipient fields");
|
|
195
|
+
}
|
|
196
|
+
seen[all[j]] = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _checkBody(body, profile, allowAlt) {
|
|
201
|
+
if (!body || typeof body !== "object") {
|
|
202
|
+
throw new GuardMailComposeError("mail-compose/no-body",
|
|
203
|
+
"guardMailCompose.validate: draft.body required");
|
|
204
|
+
}
|
|
205
|
+
var hasText = typeof body.text === "string" && body.text.length > 0;
|
|
206
|
+
var hasHtml = typeof body.html === "string" && body.html.length > 0;
|
|
207
|
+
if (!hasText && !hasHtml) {
|
|
208
|
+
throw new GuardMailComposeError("mail-compose/empty-body",
|
|
209
|
+
"guardMailCompose.validate: body.text or body.html required");
|
|
210
|
+
}
|
|
211
|
+
if (hasText && hasHtml && !allowAlt) {
|
|
212
|
+
throw new GuardMailComposeError("mail-compose/multipart-alternative-disallowed",
|
|
213
|
+
"guardMailCompose.validate: both text + html supplied — set allowMultipartAlternative: true");
|
|
214
|
+
}
|
|
215
|
+
if (Array.isArray(body.attachments)) {
|
|
216
|
+
var total = 0;
|
|
217
|
+
for (var i = 0; i < body.attachments.length; i += 1) {
|
|
218
|
+
var a = body.attachments[i];
|
|
219
|
+
if (!a || typeof a !== "object") {
|
|
220
|
+
throw new GuardMailComposeError("mail-compose/bad-attachment",
|
|
221
|
+
"guardMailCompose.validate: attachment[" + i + "] must be an object");
|
|
222
|
+
}
|
|
223
|
+
var size = typeof a.sizeBytes === "number" ? a.sizeBytes :
|
|
224
|
+
(typeof a.size_bytes === "number" ? a.size_bytes : 0);
|
|
225
|
+
if (size < 0 || !isFinite(size)) {
|
|
226
|
+
throw new GuardMailComposeError("mail-compose/bad-attachment-size",
|
|
227
|
+
"guardMailCompose.validate: attachment[" + i + "].sizeBytes invalid");
|
|
228
|
+
}
|
|
229
|
+
total += size;
|
|
230
|
+
if (total > profile.maxAttachmentBytes) {
|
|
231
|
+
throw new GuardMailComposeError("mail-compose/attachment-too-big",
|
|
232
|
+
"guardMailCompose.validate: attachment total " + total +
|
|
233
|
+
" exceeds maxAttachmentBytes=" + profile.maxAttachmentBytes);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function _checkHeaderValue(v, label) {
|
|
240
|
+
for (var i = 0; i < v.length; i += 1) {
|
|
241
|
+
var c = v.charCodeAt(i);
|
|
242
|
+
if ((c < 0x20 && c !== 0x09) || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal in header
|
|
243
|
+
throw new GuardMailComposeError("mail-compose/control-char-in-header",
|
|
244
|
+
"guardMailCompose.validate: control char 0x" + c.toString(16) + " in " + label);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _extractAddr(s) {
|
|
250
|
+
var lt = s.indexOf("<");
|
|
251
|
+
var gt = s.lastIndexOf(">");
|
|
252
|
+
if (lt >= 0 && gt > lt) return s.slice(lt + 1, gt).trim();
|
|
253
|
+
return s.trim();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function _anyRecipient(draft) {
|
|
257
|
+
return ["to", "cc", "bcc"].some(function (k) {
|
|
258
|
+
return Array.isArray(draft[k]) && draft[k].length > 0;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function _resolveProfile(opts) {
|
|
263
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
264
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
265
|
+
}
|
|
266
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
267
|
+
if (!PROFILES[p]) {
|
|
268
|
+
throw new GuardMailComposeError("mail-compose/bad-profile",
|
|
269
|
+
"guardMailCompose: unknown profile '" + p + "'");
|
|
270
|
+
}
|
|
271
|
+
return p;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
validate: validate,
|
|
276
|
+
compliancePosture: compliancePosture,
|
|
277
|
+
PROFILES: PROFILES,
|
|
278
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
279
|
+
GuardMailComposeError: GuardMailComposeError,
|
|
280
|
+
NAME: "mailCompose",
|
|
281
|
+
KIND: "mail-compose",
|
|
282
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardMailMove
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Mail Move
|
|
6
|
+
* @order 433
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Destination-folder allowlist validator for `b.mail.agent.move`.
|
|
10
|
+
* Refuses moves to system folders the actor doesn't have admin
|
|
11
|
+
* scope for, refuses cross-account moves (`fromFolder` and
|
|
12
|
+
* `toFolder` must belong to the same agent context), and refuses
|
|
13
|
+
* path-traversal-shaped folder names (`..` / leading `.` / NUL /
|
|
14
|
+
* bidi).
|
|
15
|
+
*
|
|
16
|
+
* System folders the framework treats specially:
|
|
17
|
+
*
|
|
18
|
+
* - **INBOX / Sent / Drafts**: always writable by the owner; no
|
|
19
|
+
* admin scope required.
|
|
20
|
+
* - **Junk / Trash**: always writable (Junk is the default Sieve
|
|
21
|
+
* junk destination; Trash is the soft-delete target).
|
|
22
|
+
* - **Archive**: always writable.
|
|
23
|
+
* - any operator-created folder: writable when in the actor's
|
|
24
|
+
* allowed-folders list (per the operator's RBAC) OR when the
|
|
25
|
+
* actor has `mailScope: "admin"`.
|
|
26
|
+
*
|
|
27
|
+
* The guard does NOT touch the underlying mail-store; that
|
|
28
|
+
* composition lives in `b.mail.agent.move`. The guard validates
|
|
29
|
+
* the SHAPE of the move call.
|
|
30
|
+
*
|
|
31
|
+
* @card
|
|
32
|
+
* Validates `b.mail.agent.move` destination. System-folder allowlist,
|
|
33
|
+
* path-traversal refusal, admin-scope gate for arbitrary destinations.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var { defineClass } = require("./framework-error");
|
|
37
|
+
|
|
38
|
+
var GuardMailMoveError = defineClass("GuardMailMoveError", { alwaysPermanent: true });
|
|
39
|
+
|
|
40
|
+
var DEFAULT_PROFILE = "strict";
|
|
41
|
+
|
|
42
|
+
var PROFILES = Object.freeze({
|
|
43
|
+
strict: { maxObjectIds: 1000, maxFolderNameBytes: 255 }, // allow:raw-byte-literal
|
|
44
|
+
balanced: { maxObjectIds: 5000, maxFolderNameBytes: 255 }, // allow:raw-byte-literal
|
|
45
|
+
permissive: { maxObjectIds: 50000, maxFolderNameBytes: 1024 }, // allow:raw-byte-literal
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
49
|
+
hipaa: "strict",
|
|
50
|
+
"pci-dss": "strict",
|
|
51
|
+
gdpr: "strict",
|
|
52
|
+
soc2: "strict",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// System folders every actor may write to without admin scope.
|
|
56
|
+
var SYSTEM_FOLDERS = Object.freeze({
|
|
57
|
+
INBOX: true, Sent: true, Drafts: true, Trash: true, Junk: true, Archive: true,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @primitive b.guardMailMove.validate
|
|
62
|
+
* @signature b.guardMailMove.validate(move, opts?)
|
|
63
|
+
* @since 0.9.20
|
|
64
|
+
* @status stable
|
|
65
|
+
* @related b.mail.agent.create
|
|
66
|
+
*
|
|
67
|
+
* Validate a `{ actor, fromFolder, toFolder, objectIds }` shape.
|
|
68
|
+
*
|
|
69
|
+
* @opts
|
|
70
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
71
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* b.guardMailMove.validate({
|
|
75
|
+
* actor: { id: "u1", mailScope: "user" },
|
|
76
|
+
* fromFolder: "INBOX",
|
|
77
|
+
* toFolder: "Archive",
|
|
78
|
+
* objectIds: ["abc123"],
|
|
79
|
+
* });
|
|
80
|
+
*/
|
|
81
|
+
function validate(move, opts) {
|
|
82
|
+
opts = opts || {};
|
|
83
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
84
|
+
if (!move || typeof move !== "object") {
|
|
85
|
+
throw new GuardMailMoveError("mail-move/bad-input",
|
|
86
|
+
"guardMailMove.validate: move required");
|
|
87
|
+
}
|
|
88
|
+
if (!move.actor || typeof move.actor !== "object" || typeof move.actor.id !== "string") {
|
|
89
|
+
throw new GuardMailMoveError("mail-move/no-actor",
|
|
90
|
+
"guardMailMove.validate: move.actor with .id required");
|
|
91
|
+
}
|
|
92
|
+
_checkFolderName(move.fromFolder, "fromFolder", profile);
|
|
93
|
+
_checkFolderName(move.toFolder, "toFolder", profile);
|
|
94
|
+
if (move.fromFolder === move.toFolder) {
|
|
95
|
+
throw new GuardMailMoveError("mail-move/same-folder",
|
|
96
|
+
"guardMailMove.validate: fromFolder and toFolder are the same");
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(move.objectIds)) {
|
|
99
|
+
throw new GuardMailMoveError("mail-move/bad-objectids",
|
|
100
|
+
"guardMailMove.validate: objectIds must be an array");
|
|
101
|
+
}
|
|
102
|
+
if (move.objectIds.length === 0) {
|
|
103
|
+
throw new GuardMailMoveError("mail-move/empty-objectids",
|
|
104
|
+
"guardMailMove.validate: objectIds must be non-empty");
|
|
105
|
+
}
|
|
106
|
+
if (move.objectIds.length > profile.maxObjectIds) {
|
|
107
|
+
throw new GuardMailMoveError("mail-move/too-many-objectids",
|
|
108
|
+
"guardMailMove.validate: objectIds count " + move.objectIds.length +
|
|
109
|
+
" exceeds maxObjectIds=" + profile.maxObjectIds);
|
|
110
|
+
}
|
|
111
|
+
for (var i = 0; i < move.objectIds.length; i += 1) {
|
|
112
|
+
var oid = move.objectIds[i];
|
|
113
|
+
if (typeof oid !== "string" || oid.length === 0) {
|
|
114
|
+
throw new GuardMailMoveError("mail-move/bad-objectid",
|
|
115
|
+
"guardMailMove.validate: objectIds[" + i + "] must be a non-empty string");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// System-folder allowlist OR admin scope OR allowed-folders.
|
|
120
|
+
var dest = move.toFolder;
|
|
121
|
+
if (SYSTEM_FOLDERS[dest]) return move;
|
|
122
|
+
var isAdmin = move.actor.mailScope === "admin";
|
|
123
|
+
if (isAdmin) return move;
|
|
124
|
+
var allowed = Array.isArray(move.actor.allowedFolders) ? move.actor.allowedFolders : null;
|
|
125
|
+
if (allowed && allowed.indexOf(dest) >= 0) return move;
|
|
126
|
+
throw new GuardMailMoveError("mail-move/destination-not-allowed",
|
|
127
|
+
"guardMailMove.validate: destination '" + dest +
|
|
128
|
+
"' requires mailScope:'admin' or membership in actor.allowedFolders");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @primitive b.guardMailMove.compliancePosture
|
|
133
|
+
* @signature b.guardMailMove.compliancePosture(posture)
|
|
134
|
+
* @since 0.9.20
|
|
135
|
+
* @status stable
|
|
136
|
+
*
|
|
137
|
+
* Return the effective profile for a given compliance posture name.
|
|
138
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
139
|
+
* here instead of silently falling through to the default profile.
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* b.guardMailMove.compliancePosture("hipaa"); // → "strict"
|
|
143
|
+
*/
|
|
144
|
+
function compliancePosture(posture) {
|
|
145
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _checkFolderName(name, label, profile) {
|
|
149
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
150
|
+
throw new GuardMailMoveError("mail-move/bad-folder-name",
|
|
151
|
+
"guardMailMove.validate: " + label + " must be a non-empty string");
|
|
152
|
+
}
|
|
153
|
+
if (Buffer.byteLength(name, "utf8") > profile.maxFolderNameBytes) {
|
|
154
|
+
throw new GuardMailMoveError("mail-move/folder-name-too-long",
|
|
155
|
+
"guardMailMove.validate: " + label + " exceeds maxFolderNameBytes=" + profile.maxFolderNameBytes);
|
|
156
|
+
}
|
|
157
|
+
// Path-traversal / control-char refusal. C0 controls, slash, NUL,
|
|
158
|
+
// leading `.`, and `..` segments are all refused regardless of
|
|
159
|
+
// profile.
|
|
160
|
+
if (name.indexOf("..") >= 0) {
|
|
161
|
+
throw new GuardMailMoveError("mail-move/path-traversal",
|
|
162
|
+
"guardMailMove.validate: " + label + " contains '..'");
|
|
163
|
+
}
|
|
164
|
+
if (name.charAt(0) === ".") {
|
|
165
|
+
throw new GuardMailMoveError("mail-move/hidden-name",
|
|
166
|
+
"guardMailMove.validate: " + label + " starts with '.' (hidden-folder shape refused)");
|
|
167
|
+
}
|
|
168
|
+
for (var i = 0; i < name.length; i += 1) {
|
|
169
|
+
var c = name.charCodeAt(i);
|
|
170
|
+
if (c < 0x20 || c === 0x7F) { // allow:raw-byte-literal — C0 + DEL refusal
|
|
171
|
+
throw new GuardMailMoveError("mail-move/control-char-in-name",
|
|
172
|
+
"guardMailMove.validate: " + label + " contains control char 0x" + c.toString(16));
|
|
173
|
+
}
|
|
174
|
+
if (c === 0x2F) { // allow:raw-byte-literal — '/' refusal
|
|
175
|
+
throw new GuardMailMoveError("mail-move/slash-in-name",
|
|
176
|
+
"guardMailMove.validate: " + label + " contains '/' (use IMAP '.' hierarchy separator)");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _resolveProfile(opts) {
|
|
182
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
183
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
184
|
+
}
|
|
185
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
186
|
+
if (!PROFILES[p]) {
|
|
187
|
+
throw new GuardMailMoveError("mail-move/bad-profile",
|
|
188
|
+
"guardMailMove: unknown profile '" + p + "'");
|
|
189
|
+
}
|
|
190
|
+
return p;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
validate: validate,
|
|
195
|
+
compliancePosture: compliancePosture,
|
|
196
|
+
PROFILES: PROFILES,
|
|
197
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
198
|
+
SYSTEM_FOLDERS: SYSTEM_FOLDERS,
|
|
199
|
+
GuardMailMoveError: GuardMailMoveError,
|
|
200
|
+
NAME: "mailMove",
|
|
201
|
+
KIND: "mail-move",
|
|
202
|
+
};
|