@blamejs/core 0.7.24 → 0.7.39
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 +30 -0
- package/index.js +4 -0
- package/lib/asn1-der.js +356 -0
- package/lib/audit.js +2 -0
- package/lib/compliance.js +114 -0
- package/lib/constants.js +8 -0
- package/lib/crypto.js +92 -0
- package/lib/dora.js +347 -0
- package/lib/framework-error.js +22 -0
- package/lib/gate-contract.js +20 -4
- package/lib/mail-auth.js +661 -0
- package/lib/mail-dkim.js +309 -4
- package/lib/mail.js +8 -0
- package/lib/network-smtp-policy.js +551 -0
- package/lib/network-tls.js +1169 -0
- package/lib/network.js +7 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/crypto.js
CHANGED
|
@@ -203,6 +203,21 @@ function decryptEnvelope(packed, privateKeys) {
|
|
|
203
203
|
symmetricKey = kdf(Buffer.concat([mlkemSs, ecSs]), C.BYTES.bytes(32));
|
|
204
204
|
} else if (kemId === C.KEM_IDS.ML_KEM_1024) {
|
|
205
205
|
symmetricKey = kdf(mlkemSs, C.BYTES.bytes(32));
|
|
206
|
+
} else if (kemId === C.KEM_IDS.ML_KEM_768_X25519) {
|
|
207
|
+
// ML-KEM-768 + X25519 hybrid envelope. The mlkemPriv must be an
|
|
208
|
+
// ML-KEM-768 key (not 1024); operators are responsible for passing
|
|
209
|
+
// the correct keypair via privateKeys when the envelope was sealed
|
|
210
|
+
// with this algorithm. Same length-prefixed shape as the P-384
|
|
211
|
+
// hybrid: 2-byte ec-eph-len + DER X25519 pubkey + nonce + ct.
|
|
212
|
+
var x25519EphLen = packed.readUInt16BE(pos); pos += 2;
|
|
213
|
+
var x25519EphDer = packed.subarray(pos, pos + x25519EphLen); pos += x25519EphLen;
|
|
214
|
+
var x25519PrivPem = typeof privateKeys === "string" ? null : privateKeys.x25519PrivateKey;
|
|
215
|
+
if (!x25519PrivPem) throw new Error("ML-KEM-768 + X25519 hybrid envelope requires x25519PrivateKey");
|
|
216
|
+
var x25519Ss = nodeCrypto.diffieHellman({
|
|
217
|
+
privateKey: nodeCrypto.createPrivateKey(x25519PrivPem),
|
|
218
|
+
publicKey: nodeCrypto.createPublicKey({ key: x25519EphDer, type: "spki", format: "der" }),
|
|
219
|
+
});
|
|
220
|
+
symmetricKey = kdf(Buffer.concat([mlkemSs, x25519Ss]), C.BYTES.bytes(32));
|
|
206
221
|
} else {
|
|
207
222
|
throw new Error("Invalid envelope: unsupported KEM ID " + kemId);
|
|
208
223
|
}
|
|
@@ -241,6 +256,80 @@ function decryptPacked(packed, key, aad) {
|
|
|
241
256
|
);
|
|
242
257
|
}
|
|
243
258
|
|
|
259
|
+
// ---- ML-KEM-768 + X25519 hybrid (TLS-interop envelope) ----
|
|
260
|
+
//
|
|
261
|
+
// The IETF / Cloudflare / Chrome standardized hybrid for TLS 1.3
|
|
262
|
+
// (codepoint 0x11EC). Smaller payload than ML-KEM-1024 + P-384
|
|
263
|
+
// (~1.1 KB vs ~1.6 KB), wider interop with peers using the same
|
|
264
|
+
// hybrid (Cloudflare Workers, Chrome, blamejs-on-the-other-side).
|
|
265
|
+
//
|
|
266
|
+
// Operators wire this when the recipient publishes ML-KEM-768 +
|
|
267
|
+
// X25519 keys. Generation:
|
|
268
|
+
//
|
|
269
|
+
// var pair = b.crypto.generateMlkem768X25519KeyPair();
|
|
270
|
+
// // → { mlkemPublicKey, mlkemPrivateKey,
|
|
271
|
+
// // x25519PublicKey, x25519PrivateKey }
|
|
272
|
+
//
|
|
273
|
+
// var envelope = b.crypto.encryptMlkem768X25519(plaintext, {
|
|
274
|
+
// mlkemPublicKey: recipient.mlkemPublicKey,
|
|
275
|
+
// x25519PublicKey: recipient.x25519PublicKey,
|
|
276
|
+
// });
|
|
277
|
+
//
|
|
278
|
+
// Decryption goes through the existing b.crypto.decrypt(envelope,
|
|
279
|
+
// privateKeys) — the envelope-magic dispatch handles KEM_IDS.
|
|
280
|
+
// ML_KEM_768_X25519. privateKeys MUST shape as { privateKey,
|
|
281
|
+
// x25519PrivateKey } — privateKey is the ML-KEM-768 PEM, NOT the
|
|
282
|
+
// default ML-KEM-1024.
|
|
283
|
+
|
|
284
|
+
function generateMlkem768X25519KeyPair() {
|
|
285
|
+
var mlkem = generateKeyPair("ml-kem-768");
|
|
286
|
+
var x25519 = generateKeyPair("x25519");
|
|
287
|
+
return {
|
|
288
|
+
mlkemPublicKey: mlkem.publicKey,
|
|
289
|
+
mlkemPrivateKey: mlkem.privateKey,
|
|
290
|
+
x25519PublicKey: x25519.publicKey,
|
|
291
|
+
x25519PrivateKey: x25519.privateKey,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function encryptMlkem768X25519(plaintext, recipient) {
|
|
296
|
+
if (!recipient || !recipient.mlkemPublicKey || !recipient.x25519PublicKey) {
|
|
297
|
+
throw new Error("encryptMlkem768X25519 requires { mlkemPublicKey, x25519PublicKey }");
|
|
298
|
+
}
|
|
299
|
+
var mlkemPub = nodeCrypto.createPublicKey(recipient.mlkemPublicKey);
|
|
300
|
+
var kem = nodeCrypto.encapsulate(mlkemPub);
|
|
301
|
+
var ephX25519 = generateKeyPair("x25519", {
|
|
302
|
+
publicKeyEncoding: { type: "spki", format: "der" },
|
|
303
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
304
|
+
});
|
|
305
|
+
var x25519Ss = nodeCrypto.diffieHellman({
|
|
306
|
+
privateKey: nodeCrypto.createPrivateKey(ephX25519.privateKey),
|
|
307
|
+
publicKey: nodeCrypto.createPublicKey(recipient.x25519PublicKey),
|
|
308
|
+
});
|
|
309
|
+
var key = kdf(Buffer.concat([kem.sharedKey, x25519Ss]), C.BYTES.bytes(32));
|
|
310
|
+
var nonce = generateBytes(C.BYTES.bytes(24));
|
|
311
|
+
var ct = xchacha20poly1305(key, nonce).encrypt(Buffer.from(plaintext, "utf8"));
|
|
312
|
+
|
|
313
|
+
var kemCtLen = Buffer.alloc(2); kemCtLen.writeUInt16BE(kem.ciphertext.length);
|
|
314
|
+
var x25519EphDer = ephX25519.publicKey;
|
|
315
|
+
var x25519EphLen = Buffer.alloc(2); x25519EphLen.writeUInt16BE(x25519EphDer.length);
|
|
316
|
+
|
|
317
|
+
return Buffer.concat([
|
|
318
|
+
Buffer.from([C.ENVELOPE_MAGIC, C.KEM_IDS.ML_KEM_768_X25519,
|
|
319
|
+
C.ACTIVE.CIPHER, C.ACTIVE.KDF]),
|
|
320
|
+
kemCtLen, kem.ciphertext, x25519EphLen, x25519EphDer, nonce, Buffer.from(ct),
|
|
321
|
+
]).toString("base64");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Operator-audit accessor — exposes every supported KEM hybrid for
|
|
325
|
+
// compliance audit visibility ("which envelopes does this deploy
|
|
326
|
+
// accept on decrypt?").
|
|
327
|
+
var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
|
|
328
|
+
{ id: "ml-kem-1024", envelopeId: C.KEM_IDS.ML_KEM_1024, description: "ML-KEM-1024 KEM-only (legacy single-component)" },
|
|
329
|
+
{ id: "ml-kem-1024-p384", envelopeId: C.KEM_IDS.ML_KEM_1024_P384, description: "ML-KEM-1024 + ECDH P-384 hybrid (framework default)" },
|
|
330
|
+
{ id: "ml-kem-768-x25519", envelopeId: C.KEM_IDS.ML_KEM_768_X25519, description: "ML-KEM-768 + X25519 hybrid (IETF / Cloudflare / Chrome TLS 1.3 codepoint 0x11EC)" },
|
|
331
|
+
]);
|
|
332
|
+
|
|
244
333
|
module.exports = {
|
|
245
334
|
// Hashing
|
|
246
335
|
sha3Hash: sha3Hash,
|
|
@@ -254,12 +343,15 @@ module.exports = {
|
|
|
254
343
|
// Keys
|
|
255
344
|
generateEncryptionKeyPair: generateEncryptionKeyPair,
|
|
256
345
|
generateSigningKeyPair: generateSigningKeyPair,
|
|
346
|
+
generateMlkem768X25519KeyPair: generateMlkem768X25519KeyPair,
|
|
257
347
|
// Signatures
|
|
258
348
|
sign: sign,
|
|
259
349
|
verify: verify,
|
|
260
350
|
// Envelope encrypt/decrypt
|
|
261
351
|
encrypt: encrypt,
|
|
262
352
|
decrypt: decrypt,
|
|
353
|
+
encryptMlkem768X25519: encryptMlkem768X25519,
|
|
354
|
+
SUPPORTED_KEM_ALGORITHMS: SUPPORTED_KEM_ALGORITHMS,
|
|
263
355
|
// Symmetric buffer encrypt/decrypt
|
|
264
356
|
encryptPacked: encryptPacked,
|
|
265
357
|
decryptPacked: decryptPacked,
|
package/lib/dora.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.dora — DORA Article 17 ICT-related incident-reporting workflow.
|
|
4
|
+
*
|
|
5
|
+
* Digital Operational Resilience Act (Regulation (EU) 2022/2554)
|
|
6
|
+
* Article 17 requires every "financial entity" subject to DORA to
|
|
7
|
+
* classify, document, and report ICT-related incidents according to
|
|
8
|
+
* the harmonized RTS template (Commission Delegated Regulation
|
|
9
|
+
* 2024/1772). This primitive is the framework hook — operators wire
|
|
10
|
+
* it into their incident-management workflow; the framework owns the
|
|
11
|
+
* classification rubric, the three-stage report shape (initial /
|
|
12
|
+
* intermediate / final), and the audit-chain integration.
|
|
13
|
+
*
|
|
14
|
+
* var dora = b.dora.create({ audit: b.audit });
|
|
15
|
+
*
|
|
16
|
+
* var classification = dora.classify({
|
|
17
|
+
* dataAffected: "phi" | "financial" | "personal" | "operational" | "none",
|
|
18
|
+
* systemsAffected: ["payments-gateway", "core-ledger"],
|
|
19
|
+
* durationMs: C.TIME.hours(4),
|
|
20
|
+
* severityIndicator: "critical" | "high" | "medium" | "low",
|
|
21
|
+
* economicImpact: { eur: 50000 },
|
|
22
|
+
* affectedClients: 1200,
|
|
23
|
+
* geographicScope: ["DE", "FR"],
|
|
24
|
+
* reputationalImpact: "media" | "internal" | "none",
|
|
25
|
+
* });
|
|
26
|
+
* // → { classification: "major" | "significant" | "minor",
|
|
27
|
+
* // mustReport: true|false, mustReportInitialBy: ms-since-detection,
|
|
28
|
+
* // reasons: [...] }
|
|
29
|
+
*
|
|
30
|
+
* var initial = dora.report({
|
|
31
|
+
* incidentId: "INC-2026-0042",
|
|
32
|
+
* classification: "major",
|
|
33
|
+
* stage: "initial",
|
|
34
|
+
* detectedAt: Date.now() - C.TIME.minutes(60),
|
|
35
|
+
* description: "Payment-gateway outage — 2-hour customer-facing impact",
|
|
36
|
+
* causeKnown: false,
|
|
37
|
+
* mitigationStarted: true,
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* // 72h after detection: intermediate update
|
|
41
|
+
* dora.report(Object.assign({}, initial, { stage: "intermediate", ... }));
|
|
42
|
+
* // 1 month later (or upon closure): final report
|
|
43
|
+
* dora.report(Object.assign({}, initial, { stage: "final", rootCause: "...", ... }));
|
|
44
|
+
*
|
|
45
|
+
* Audit posture (audit namespace "dora"):
|
|
46
|
+
* - dora.incident.classified — every classify() call
|
|
47
|
+
* - dora.incident.reported — every report() submission
|
|
48
|
+
* - dora.incident.draftFinal — every draftFinalReport() generation
|
|
49
|
+
*
|
|
50
|
+
* The primitive does NOT submit to ESAs / national supervisors — that
|
|
51
|
+
* step is operator-side (channel + credentials are operator-specific).
|
|
52
|
+
* The primitive produces the RTS-template-shaped record that the
|
|
53
|
+
* operator's submission code drops into the regulator's API.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
var lazyRequire = require("./lazy-require");
|
|
57
|
+
var validateOpts = require("./validate-opts");
|
|
58
|
+
var C = require("./constants");
|
|
59
|
+
var { DoraError } = require("./framework-error");
|
|
60
|
+
|
|
61
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
62
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
63
|
+
|
|
64
|
+
// ---- Classification thresholds (RTS 2024/1772 Articles 1-12) ----
|
|
65
|
+
|
|
66
|
+
// Article 1 — major incident criteria. An incident classifies as
|
|
67
|
+
// "major" when ANY of the following thresholds is met:
|
|
68
|
+
// - Critical/severe data integrity / confidentiality / availability
|
|
69
|
+
// impact
|
|
70
|
+
// - >= 100k clients affected OR >= 10% of clients
|
|
71
|
+
// - Economic impact >= 100k EUR
|
|
72
|
+
// - Cross-border (>= 2 EU member states) impact
|
|
73
|
+
// - Critical-process disruption >= 8h
|
|
74
|
+
// - Reputational impact (media coverage)
|
|
75
|
+
var MAJOR_INCIDENT_THRESHOLDS = Object.freeze({
|
|
76
|
+
affectedClientsAbsolute: 100000, // allow:raw-byte-literal — RTS 2024/1772 Art. 1(1)(a) regulator-fixed cap (100k clients)
|
|
77
|
+
affectedClientsPercentile: 0.10, // RTS Art. 1(1)(a) — 10% client base
|
|
78
|
+
economicImpactEur: 100000, // allow:raw-byte-literal — RTS 2024/1772 Art. 1(1)(c) regulator-fixed cap (100k EUR)
|
|
79
|
+
geographicMemberStates: 2, // RTS Art. 1(1)(d) — 2+ member states
|
|
80
|
+
durationCriticalProcessMs: C.TIME.hours(8), // RTS Art. 1(1)(e) — 8h
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Article 8 — significant incident (one threshold below major).
|
|
84
|
+
// Default threshold values per ESA guidelines.
|
|
85
|
+
var SIGNIFICANT_INCIDENT_THRESHOLDS = Object.freeze({
|
|
86
|
+
affectedClientsAbsolute: 10000, // allow:raw-byte-literal — ESA-guideline regulator-fixed cap (10k clients)
|
|
87
|
+
affectedClientsPercentile: 0.01, // 1% client base
|
|
88
|
+
economicImpactEur: 10000, // allow:raw-byte-literal — ESA-guideline regulator-fixed cap (10k EUR)
|
|
89
|
+
durationCriticalProcessMs: C.TIME.hours(2), // 2h
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Article 19 — initial report deadline: 24h from "first awareness".
|
|
93
|
+
// Article 19(4) — intermediate update: within 72h of initial.
|
|
94
|
+
// Article 19(6) — final report: within 1 month of initial.
|
|
95
|
+
var INITIAL_REPORT_DEADLINE_MS = C.TIME.hours(24);
|
|
96
|
+
var INTERMEDIATE_REPORT_DEADLINE_MS = C.TIME.hours(72);
|
|
97
|
+
var FINAL_REPORT_DEADLINE_MS = C.TIME.days(30);
|
|
98
|
+
|
|
99
|
+
var VALID_DATA_AFFECTED = ["phi", "financial", "personal", "operational", "none"];
|
|
100
|
+
var VALID_SEVERITY = ["critical", "high", "medium", "low"];
|
|
101
|
+
var VALID_REPUTATIONAL = ["media", "internal", "none"];
|
|
102
|
+
var VALID_STAGES = ["initial", "intermediate", "final"];
|
|
103
|
+
var VALID_CLASSIFICATIONS = ["major", "significant", "minor"];
|
|
104
|
+
|
|
105
|
+
// ---- Classification rubric ----
|
|
106
|
+
|
|
107
|
+
function _classifyImpl(input) {
|
|
108
|
+
var reasons = [];
|
|
109
|
+
var hitsMajor = 0;
|
|
110
|
+
var hitsSignificant = 0;
|
|
111
|
+
|
|
112
|
+
// 1. Severity indicator — critical alone qualifies as major.
|
|
113
|
+
if (input.severityIndicator === "critical") {
|
|
114
|
+
hitsMajor += 1;
|
|
115
|
+
reasons.push("severity-critical");
|
|
116
|
+
} else if (input.severityIndicator === "high") {
|
|
117
|
+
hitsSignificant += 1;
|
|
118
|
+
reasons.push("severity-high");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. Affected clients (absolute).
|
|
122
|
+
if (typeof input.affectedClients === "number" && input.affectedClients > 0) {
|
|
123
|
+
if (input.affectedClients >= MAJOR_INCIDENT_THRESHOLDS.affectedClientsAbsolute) {
|
|
124
|
+
hitsMajor += 1;
|
|
125
|
+
reasons.push("clients-major-absolute");
|
|
126
|
+
} else if (input.affectedClients >= SIGNIFICANT_INCIDENT_THRESHOLDS.affectedClientsAbsolute) {
|
|
127
|
+
hitsSignificant += 1;
|
|
128
|
+
reasons.push("clients-significant-absolute");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. Economic impact.
|
|
133
|
+
if (input.economicImpact && typeof input.economicImpact.eur === "number") {
|
|
134
|
+
if (input.economicImpact.eur >= MAJOR_INCIDENT_THRESHOLDS.economicImpactEur) {
|
|
135
|
+
hitsMajor += 1;
|
|
136
|
+
reasons.push("economic-major");
|
|
137
|
+
} else if (input.economicImpact.eur >= SIGNIFICANT_INCIDENT_THRESHOLDS.economicImpactEur) {
|
|
138
|
+
hitsSignificant += 1;
|
|
139
|
+
reasons.push("economic-significant");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 4. Geographic scope (cross-border).
|
|
144
|
+
if (Array.isArray(input.geographicScope) &&
|
|
145
|
+
input.geographicScope.length >= MAJOR_INCIDENT_THRESHOLDS.geographicMemberStates) {
|
|
146
|
+
hitsMajor += 1;
|
|
147
|
+
reasons.push("geographic-cross-border");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 5. Duration (critical-process disruption).
|
|
151
|
+
if (typeof input.durationMs === "number" && input.durationMs > 0) {
|
|
152
|
+
if (input.durationMs >= MAJOR_INCIDENT_THRESHOLDS.durationCriticalProcessMs) {
|
|
153
|
+
hitsMajor += 1;
|
|
154
|
+
reasons.push("duration-major");
|
|
155
|
+
} else if (input.durationMs >= SIGNIFICANT_INCIDENT_THRESHOLDS.durationCriticalProcessMs) {
|
|
156
|
+
hitsSignificant += 1;
|
|
157
|
+
reasons.push("duration-significant");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 6. Reputational impact (Article 1(1)(f)).
|
|
162
|
+
if (input.reputationalImpact === "media") {
|
|
163
|
+
hitsMajor += 1;
|
|
164
|
+
reasons.push("reputational-media");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 7. Sensitive data classes — phi / financial → at minimum significant.
|
|
168
|
+
if (input.dataAffected === "phi" || input.dataAffected === "financial") {
|
|
169
|
+
hitsSignificant += 1;
|
|
170
|
+
reasons.push("data-sensitive-" + input.dataAffected);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
var classification;
|
|
174
|
+
if (hitsMajor >= 1) {
|
|
175
|
+
classification = "major";
|
|
176
|
+
} else if (hitsSignificant >= 1) {
|
|
177
|
+
classification = "significant";
|
|
178
|
+
} else {
|
|
179
|
+
classification = "minor";
|
|
180
|
+
}
|
|
181
|
+
var mustReport = classification !== "minor";
|
|
182
|
+
return {
|
|
183
|
+
classification: classification,
|
|
184
|
+
mustReport: mustReport,
|
|
185
|
+
mustReportInitialByMs: mustReport ? INITIAL_REPORT_DEADLINE_MS : null,
|
|
186
|
+
reasons: reasons,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- Report-shape validators (RTS 2024/1772 templates) ----
|
|
191
|
+
|
|
192
|
+
function _validateReportInput(input) {
|
|
193
|
+
if (!input || typeof input !== "object") {
|
|
194
|
+
throw new DoraError("dora/bad-report",
|
|
195
|
+
"report: input must be an object");
|
|
196
|
+
}
|
|
197
|
+
if (typeof input.incidentId !== "string" || input.incidentId.length === 0) {
|
|
198
|
+
throw new DoraError("dora/missing-incident-id",
|
|
199
|
+
"report: incidentId is required (non-empty string)");
|
|
200
|
+
}
|
|
201
|
+
if (VALID_CLASSIFICATIONS.indexOf(input.classification) === -1) {
|
|
202
|
+
throw new DoraError("dora/bad-classification",
|
|
203
|
+
"report: classification must be one of " +
|
|
204
|
+
VALID_CLASSIFICATIONS.join(", ") + ", got " + JSON.stringify(input.classification));
|
|
205
|
+
}
|
|
206
|
+
if (VALID_STAGES.indexOf(input.stage) === -1) {
|
|
207
|
+
throw new DoraError("dora/bad-stage",
|
|
208
|
+
"report: stage must be one of " + VALID_STAGES.join(", ") +
|
|
209
|
+
", got " + JSON.stringify(input.stage));
|
|
210
|
+
}
|
|
211
|
+
if (typeof input.detectedAt !== "number" || !isFinite(input.detectedAt) || input.detectedAt <= 0) {
|
|
212
|
+
throw new DoraError("dora/bad-detected-at",
|
|
213
|
+
"report: detectedAt must be a positive ms-since-epoch number");
|
|
214
|
+
}
|
|
215
|
+
if (typeof input.description !== "string" || input.description.length === 0) {
|
|
216
|
+
throw new DoraError("dora/missing-description",
|
|
217
|
+
"report: description is required");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- Public surface ----
|
|
222
|
+
|
|
223
|
+
function create(opts) {
|
|
224
|
+
opts = opts || {};
|
|
225
|
+
validateOpts(opts, ["audit", "observability"], "dora.create");
|
|
226
|
+
var auditOn = opts.audit !== false;
|
|
227
|
+
|
|
228
|
+
function _emit(action, info) {
|
|
229
|
+
if (!auditOn) return;
|
|
230
|
+
try {
|
|
231
|
+
audit().safeEmit({
|
|
232
|
+
action: action,
|
|
233
|
+
outcome: info.outcome || "success",
|
|
234
|
+
metadata: info.metadata || {},
|
|
235
|
+
});
|
|
236
|
+
} catch (_e) { /* audit best-effort */ }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function classify(input) {
|
|
240
|
+
if (!input || typeof input !== "object") {
|
|
241
|
+
throw new DoraError("dora/bad-classify-input",
|
|
242
|
+
"classify: input must be an object");
|
|
243
|
+
}
|
|
244
|
+
if (input.dataAffected !== undefined &&
|
|
245
|
+
VALID_DATA_AFFECTED.indexOf(input.dataAffected) === -1) {
|
|
246
|
+
throw new DoraError("dora/bad-data-affected",
|
|
247
|
+
"classify: dataAffected must be one of " +
|
|
248
|
+
VALID_DATA_AFFECTED.join(", "));
|
|
249
|
+
}
|
|
250
|
+
if (input.severityIndicator !== undefined &&
|
|
251
|
+
VALID_SEVERITY.indexOf(input.severityIndicator) === -1) {
|
|
252
|
+
throw new DoraError("dora/bad-severity",
|
|
253
|
+
"classify: severityIndicator must be one of " + VALID_SEVERITY.join(", "));
|
|
254
|
+
}
|
|
255
|
+
if (input.reputationalImpact !== undefined &&
|
|
256
|
+
VALID_REPUTATIONAL.indexOf(input.reputationalImpact) === -1) {
|
|
257
|
+
throw new DoraError("dora/bad-reputational",
|
|
258
|
+
"classify: reputationalImpact must be one of " + VALID_REPUTATIONAL.join(", "));
|
|
259
|
+
}
|
|
260
|
+
var rv = _classifyImpl(input);
|
|
261
|
+
_emit("dora.incident.classified", {
|
|
262
|
+
metadata: {
|
|
263
|
+
classification: rv.classification,
|
|
264
|
+
mustReport: rv.mustReport,
|
|
265
|
+
reasons: rv.reasons,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
return rv;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function report(input) {
|
|
272
|
+
_validateReportInput(input);
|
|
273
|
+
var record = {
|
|
274
|
+
incidentId: input.incidentId,
|
|
275
|
+
classification: input.classification,
|
|
276
|
+
stage: input.stage,
|
|
277
|
+
detectedAt: input.detectedAt,
|
|
278
|
+
reportedAt: Date.now(),
|
|
279
|
+
description: input.description,
|
|
280
|
+
causeKnown: input.causeKnown !== undefined ? !!input.causeKnown : null,
|
|
281
|
+
rootCause: input.rootCause || null,
|
|
282
|
+
mitigationStarted: input.mitigationStarted !== undefined ? !!input.mitigationStarted : null,
|
|
283
|
+
systemsAffected: input.systemsAffected || [],
|
|
284
|
+
affectedClients: input.affectedClients || null,
|
|
285
|
+
economicImpact: input.economicImpact || null,
|
|
286
|
+
geographicScope: input.geographicScope || [],
|
|
287
|
+
durationMs: input.durationMs || null,
|
|
288
|
+
reputationalImpact: input.reputationalImpact || null,
|
|
289
|
+
contactPoint: input.contactPoint || null,
|
|
290
|
+
// Article 19 deadline — operator-side scheduler uses this.
|
|
291
|
+
nextStageDueAt: null,
|
|
292
|
+
};
|
|
293
|
+
if (input.stage === "initial") {
|
|
294
|
+
record.nextStageDueAt = input.detectedAt + INTERMEDIATE_REPORT_DEADLINE_MS;
|
|
295
|
+
} else if (input.stage === "intermediate") {
|
|
296
|
+
record.nextStageDueAt = input.detectedAt + FINAL_REPORT_DEADLINE_MS;
|
|
297
|
+
}
|
|
298
|
+
_emit("dora.incident.reported", {
|
|
299
|
+
metadata: {
|
|
300
|
+
incidentId: record.incidentId,
|
|
301
|
+
classification: record.classification,
|
|
302
|
+
stage: record.stage,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
try { observability().count("dora.incident.reported", 1, {
|
|
306
|
+
classification: record.classification, stage: record.stage,
|
|
307
|
+
}); } catch (_e) { /* obs best-effort */ }
|
|
308
|
+
return record;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function draftFinalReport(initialOrIntermediate) {
|
|
312
|
+
if (!initialOrIntermediate || typeof initialOrIntermediate !== "object") {
|
|
313
|
+
throw new DoraError("dora/bad-draft-input",
|
|
314
|
+
"draftFinalReport: input must be a prior report record");
|
|
315
|
+
}
|
|
316
|
+
var draft = Object.assign({}, initialOrIntermediate, {
|
|
317
|
+
stage: "final",
|
|
318
|
+
reportedAt: Date.now(),
|
|
319
|
+
// RTS Article 19(6) final-report shape — operator must fill before
|
|
320
|
+
// submission.
|
|
321
|
+
rootCause: initialOrIntermediate.rootCause || null,
|
|
322
|
+
remediationActions: [],
|
|
323
|
+
lessonsLearned: "",
|
|
324
|
+
preventiveMeasures: [],
|
|
325
|
+
});
|
|
326
|
+
_emit("dora.incident.draftFinal", {
|
|
327
|
+
metadata: { incidentId: draft.incidentId },
|
|
328
|
+
});
|
|
329
|
+
return draft;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
classify: classify,
|
|
334
|
+
report: report,
|
|
335
|
+
draftFinalReport: draftFinalReport,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
create: create,
|
|
341
|
+
MAJOR_INCIDENT_THRESHOLDS: MAJOR_INCIDENT_THRESHOLDS,
|
|
342
|
+
SIGNIFICANT_INCIDENT_THRESHOLDS: SIGNIFICANT_INCIDENT_THRESHOLDS,
|
|
343
|
+
INITIAL_REPORT_DEADLINE_MS: INITIAL_REPORT_DEADLINE_MS,
|
|
344
|
+
INTERMEDIATE_REPORT_DEADLINE_MS: INTERMEDIATE_REPORT_DEADLINE_MS,
|
|
345
|
+
FINAL_REPORT_DEADLINE_MS: FINAL_REPORT_DEADLINE_MS,
|
|
346
|
+
DoraError: DoraError,
|
|
347
|
+
};
|
package/lib/framework-error.js
CHANGED
|
@@ -249,6 +249,24 @@ var GuardMarkdownError = defineClass("GuardMarkdownError", { alwaysPermane
|
|
|
249
249
|
// in addresses, bidi/null/control chars in headers + addresses, header-
|
|
250
250
|
// folding smuggling, BOM injection. alwaysPermanent.
|
|
251
251
|
var GuardEmailError = defineClass("GuardEmailError", { alwaysPermanent: true });
|
|
252
|
+
// DoraError covers DORA Article 17 incident-reporting workflow errors
|
|
253
|
+
// (classification refusal, report-shape validation, ESA-template
|
|
254
|
+
// generation, audit-chain integration). Permanent — these are
|
|
255
|
+
// configuration / submission errors, not transient.
|
|
256
|
+
var DoraError = defineClass("DoraError", { alwaysPermanent: true });
|
|
257
|
+
// ComplianceError covers compliance-coordinator misuse: unknown
|
|
258
|
+
// posture name, runtime-switch refusal, assertion failures.
|
|
259
|
+
// Permanent — these are configuration errors, not transient.
|
|
260
|
+
var ComplianceError = defineClass("ComplianceError", { alwaysPermanent: true });
|
|
261
|
+
// SmtpPolicyError covers MTA-STS / DANE / TLS-RPT misuse: bad-policy
|
|
262
|
+
// shape, fetch failures, TLSA-record format errors, missing records.
|
|
263
|
+
// Permanent — these are policy / DNS configuration errors, not
|
|
264
|
+
// transient.
|
|
265
|
+
var SmtpPolicyError = defineClass("SmtpPolicyError", { alwaysPermanent: true });
|
|
266
|
+
// MailAuthError covers SPF / DKIM-verify / DMARC / ARC misuse: bad
|
|
267
|
+
// record shape, fetch failures, missing keys, alignment issues.
|
|
268
|
+
// Permanent — DNS-config / message-shape errors, not transient.
|
|
269
|
+
var MailAuthError = defineClass("MailAuthError", { alwaysPermanent: true });
|
|
252
270
|
|
|
253
271
|
module.exports = {
|
|
254
272
|
FrameworkError: FrameworkError,
|
|
@@ -290,4 +308,8 @@ module.exports = {
|
|
|
290
308
|
GuardXmlError: GuardXmlError,
|
|
291
309
|
GuardMarkdownError: GuardMarkdownError,
|
|
292
310
|
GuardEmailError: GuardEmailError,
|
|
311
|
+
DoraError: DoraError,
|
|
312
|
+
ComplianceError: ComplianceError,
|
|
313
|
+
SmtpPolicyError: SmtpPolicyError,
|
|
314
|
+
MailAuthError: MailAuthError,
|
|
293
315
|
};
|
package/lib/gate-contract.js
CHANGED
|
@@ -66,6 +66,7 @@ var validateOpts = require("./validate-opts");
|
|
|
66
66
|
var { GateContractError } = require("./framework-error");
|
|
67
67
|
|
|
68
68
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
69
|
+
var compliance = lazyRequire(function () { return require("./compliance"); });
|
|
69
70
|
|
|
70
71
|
// Forensic-id token width (bytes); 64 bits is enough for cross-gate
|
|
71
72
|
// correlation in a single request scope.
|
|
@@ -804,12 +805,27 @@ function resolveProfileAndPosture(opts, cfg) {
|
|
|
804
805
|
}
|
|
805
806
|
overlay = cfg.profiles[opts.profile];
|
|
806
807
|
}
|
|
807
|
-
|
|
808
|
-
|
|
808
|
+
// Compliance-posture resolution — operator-supplied opt wins; if not
|
|
809
|
+
// given, fall back to the global posture set via b.compliance.set().
|
|
810
|
+
// The fallback IS the value-add of the top-level coordinator: every
|
|
811
|
+
// primitive with a compliancePosture opt picks up the deployment's
|
|
812
|
+
// declared posture without per-call wiring.
|
|
813
|
+
var posture = opts.compliancePosture;
|
|
814
|
+
if (typeof posture !== "string") {
|
|
815
|
+
var globalPosture;
|
|
816
|
+
try { globalPosture = compliance().current(); }
|
|
817
|
+
catch (_e) { globalPosture = null; }
|
|
818
|
+
if (typeof globalPosture === "string" &&
|
|
819
|
+
cfg.compliancePostures && cfg.compliancePostures[globalPosture]) {
|
|
820
|
+
posture = globalPosture;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (typeof posture === "string") {
|
|
824
|
+
if (!cfg.compliancePostures || !cfg.compliancePostures[posture]) {
|
|
809
825
|
throw ErrorClass.factory(prefix + ".bad-posture",
|
|
810
|
-
"unknown compliancePosture " + JSON.stringify(
|
|
826
|
+
"unknown compliancePosture " + JSON.stringify(posture));
|
|
811
827
|
}
|
|
812
|
-
overlay = Object.assign({}, overlay, cfg.compliancePostures[
|
|
828
|
+
overlay = Object.assign({}, overlay, cfg.compliancePostures[posture]);
|
|
813
829
|
}
|
|
814
830
|
return Object.assign({}, cfg.defaults || {}, overlay, opts);
|
|
815
831
|
}
|