@blamejs/core 0.8.7 → 0.8.9
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 +4 -0
- package/index.js +2 -0
- package/lib/audit-sign.js +154 -0
- package/lib/circuit-breaker.js +55 -0
- package/lib/html-balance.js +38 -1
- package/lib/incident-report.js +314 -0
- package/lib/middleware/require-bound-key.js +249 -0
- package/lib/permissions.js +23 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **0.8.9** (2026-05-07) — `b.incident.report` — generic 3-stage incident-reporting primitive. The three stages mirror the deadline pattern that recurs across regulatory regimes: initial / early-warning notification (within 24h of detection), intermediate / status update (within 72h), final report (within 30d or per-regime deadline). Built-in per-regime deadlines for `gdpr` (Article 33), `nis2` (Article 23), `dora` (Article 19), `cra` (Article 14), `hipaa` (Breach Notification Rule); operators select via `regime: "gdpr"` and `opts.deadlines` can override per-stage. Each stage records a tamper-evident audit event (`incident.report.stage_recorded`) with a `late: bool` + `lateBy: ms` flag — late filings are recorded with `outcome: "late"` so regulator audits can distinguish on-time from late-but-eventually filings. Operator-supplied `persist(record)` writes to a DB / SIEM / SOAR system; `onStage(event)` fires for synchronous routing. `status()` returns aggregate counts (open / closed / late-per-stage) for dashboards.
|
|
12
|
+
|
|
13
|
+
- **0.8.8** (2026-05-07) — `b.middleware.requireBoundKey` + `b.audit.rotateSigningKey` / `reSignAll` + `b.circuitBreaker` top-level surface + `b.htmlBalance.checkSafe` + permissions predicate-shape audit + FIPS 140-3 boundary docs + ESLint pin. **`b.middleware.requireBoundKey`** — Bearer-API-key middleware with three-axis binding: required scopes, bound-field equality (operator pulls values from headers / query / body via `getBoundField` getters; bound-fields registered on the key are checked with constant-time match), and peer-cert fingerprint allowlist (composes with v0.8.4's `b.crypto.hashCertFingerprint` / `isCertRevoked`). Operator-supplied async `resolver(apiKey)` returns the registered record `{ id, scopes, boundFields, peerCertFingerprints }` or `null` when revoked. Refusals carry structured reasons (`no-bearer-token`, `key-unknown-or-revoked`, `missing-scope`, `bound-field-missing`, `bound-field-mismatch`, `peer-cert-required`, `peer-cert-not-pinned`); audit chain captures the keyId + reason on every refusal. **`b.audit.rotateSigningKey`** — operator-callable rotation of the audit-signing keypair. Generates (or accepts BYO) a new keypair, copies the existing sealed file to a timestamped history path so historical signatures remain verifiable, re-seals with the operator's passphrase, and atomic-swaps the in-memory keys. Companion `reSignAll(iter)` walks an operator-supplied async iterable of `{ payload, signature, oldPublicKeyPem }` and re-signs each entry with the new key — the audit module's checkpoint store calls this to re-stamp historical checkpoints after a rotation. **`b.circuitBreaker`** — top-level re-export of `b.retry.CircuitBreaker` so operators discover it alongside `b.retry`; same state machine, same `wrap()` API, ergonomic `create(opts)` factory. **`b.htmlBalance.checkSafe(html, opts)`** — combines structural `balance()` check with a `b.guardHtml.gate` security pass under the same `{ profile, posture }` opt shape used by `b.fileUpload({ contentSafety })` / `b.staticServe({ contentSafety })`. **`b.permissions.policy(scope, predicate)`** — emits `permissions.policy_predicate_shape_warning` audit on register-time when the predicate's `.length < 2` (operators commonly forget the `context` argument and ship a predicate that's always-true on the actor parameter). **`SECURITY.md`** — new "FIPS 140-3 cryptographic boundary" section explaining the dual boundary (Node.js OpenSSL FIPS provider for classical primitives, vendored noble-* implementations for PQ algorithms — the latter implement FIPS-published algorithms but the *implementations themselves* are not CMVP-validated). Operator path for FIPS-mandated environments documented. **CI** — eslint pinned to `10.3.0` across `ci.yml` / `npm-publish.yml` / `release-container.yml`; `eslint@latest` was silently letting new rule additions break the publish gate on releases that had passed the day before. The pin moves on operator-confirmed bumps.
|
|
14
|
+
|
|
11
15
|
- **0.8.7** (2026-05-06) — `b.auth.accessLock` — three-mode access-lock primitive for stop-the-world / read-only / role-restricted operator interventions. `"open"` is normal operation; `"read-only"` refuses non-idempotent methods (POST/PUT/PATCH/DELETE) with 503 + Retry-After while letting GET/HEAD/OPTIONS pass; `"locked"` refuses everything except an operator-supplied `passthroughPaths` allowlist (status / health / unlock endpoint). Operators flip modes during incident response, schema-migration windows, or break-glass review via `lock.set("locked", { actor, reason })`; the transition emits `auth.access_lock.mode_changed` audit + metric. `unlockRoles: ["sre", ...]` lets a privileged role bypass all three modes via `getRole(req)` so a break-glass operator can always reach the unlock endpoint to flip back. The boot-time mode emits `auth.access_lock.boot` so the audit chain captures the deploy posture.
|
|
12
16
|
|
|
13
17
|
- **0.8.6** (2026-05-06) — New primitives: `b.middleware.dailyByteQuota` + `b.appShutdown` extensions. **`b.middleware.dailyByteQuota`** — per-IP rolling 24-hour byte budget (24 hourly bins, slides per-second so a peer can't reset by waiting past midnight). Memory backend single-node by default; `opts.cache` wires `b.cache` for cluster-shared accounting. Refuses with 429 + `Retry-After` when peers exceed the quota; emits `middleware.daily_byte_quota.refused` audit + `middleware.daily_byte_quota.refused` metric. Inbound + outbound bytes counted (header bytes + content-length + outbound write/end byte counts). Fail-open on cache backend errors with audited reason — a flaky cache no longer takes the framework down. **`b.appShutdown` extensions** — `onUncaught` hook fires on `uncaughtException` / `unhandledRejection`; default is graceful-shutdown with exitCode=1, operators can wire a hook for relay to PagerDuty / observability before exit. `opts.signals` now accepts a custom signal list (defaults to `["SIGTERM","SIGINT"]`); operators add `SIGUSR2` (nodemon restart), `SIGHUP` (terminal disconnect), `SIGQUIT` (`kill -3`) without subclassing. `b.appShutdown.pidLock(lockPath)` — single-instance file lock that writes `process.pid`, refuses to acquire when another live process holds the lock, reaps stale lock files (PID gone), and releases on shutdown. **`b.observability.otlpExporter`** — new `system.observability.otlp_exporter.post_failed` audit emission distinguishes timeout / abort from generic network failure (operators can route timeout-rooted exporter degradation to a different alert channel). `stats()` now reports `droppedTotal` (queue overflow + export failed) and emits a `dropped_total` metric on every call so dashboards chart the running drop count. **`b.auth.oauth.fetchUserInfo`** — refuses on OIDC IdPs unless the caller threads `ufiOpts.idTokenSub` (the verified `sub` claim from `exchangeCode`'s `id_token`); cross-checks userinfo `sub === idTokenSub` to defend against token-substitution where a hostile IdP returns a different user's profile. Non-OIDC OAuth 2.0 deployments mis-flagged as `isOidc` opt out via `{ skipSubCheck: true }` with audited reason.
|
package/index.js
CHANGED
|
@@ -247,6 +247,8 @@ module.exports = {
|
|
|
247
247
|
storage: storage,
|
|
248
248
|
objectStore: objectStore,
|
|
249
249
|
retry: retry,
|
|
250
|
+
circuitBreaker: require("./lib/circuit-breaker"),
|
|
251
|
+
incident: { report: require("./lib/incident-report") },
|
|
250
252
|
queue: queue,
|
|
251
253
|
logStream: logStream,
|
|
252
254
|
redact: redact,
|
package/lib/audit-sign.js
CHANGED
|
@@ -326,6 +326,158 @@ function getPublicKeyFingerprint() { _requireInit(); return keys.fingerprint; }
|
|
|
326
326
|
function getMode() { return currentMode; }
|
|
327
327
|
function getAlgorithm() { _requireInit(); return keys.algorithm; }
|
|
328
328
|
|
|
329
|
+
// Re-sign every payload in the operator-supplied iterable using the
|
|
330
|
+
// CURRENT in-memory key. Returns { reSigned: number, skipped: number,
|
|
331
|
+
// errors: number } so the caller (audit module's checkpoint store)
|
|
332
|
+
// can log a summary. Each iteration is wrapped in try/catch — a
|
|
333
|
+
// payload that fails to verify under the OLD key is skipped (already
|
|
334
|
+
// tampered or never signed under the historical key) rather than
|
|
335
|
+
// aborting the whole walk.
|
|
336
|
+
//
|
|
337
|
+
// The iterable yields { payload, signature, oldPublicKeyPem } so the
|
|
338
|
+
// caller's storage layer doesn't need to reach into audit-sign's
|
|
339
|
+
// internal key-history. The caller persists the new signature in
|
|
340
|
+
// place — this primitive returns the new bytes without touching
|
|
341
|
+
// storage.
|
|
342
|
+
async function reSignAll(iter, opts) {
|
|
343
|
+
_requireInit();
|
|
344
|
+
opts = opts || {};
|
|
345
|
+
var summary = { reSigned: 0, skipped: 0, errors: 0 };
|
|
346
|
+
var onProgress = typeof opts.onProgress === "function" ? opts.onProgress : null;
|
|
347
|
+
for await (var entry of iter) {
|
|
348
|
+
try {
|
|
349
|
+
if (!entry || !entry.payload || !entry.signature) {
|
|
350
|
+
summary.skipped += 1;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
var oldPub = entry.oldPublicKeyPem || keys.publicKey;
|
|
354
|
+
if (!verify(entry.payload, entry.signature, oldPub)) {
|
|
355
|
+
summary.skipped += 1;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
var newSig = sign(entry.payload);
|
|
359
|
+
summary.reSigned += 1;
|
|
360
|
+
if (onProgress) {
|
|
361
|
+
try { onProgress({ id: entry.id, newSignature: newSig }); }
|
|
362
|
+
catch (_e) { /* operator hook, drop-silent */ }
|
|
363
|
+
}
|
|
364
|
+
} catch (_e) {
|
|
365
|
+
summary.errors += 1;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return summary;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Rotate the in-memory + on-disk keypair. Generates a fresh keypair
|
|
372
|
+
// (or accepts operator-supplied keypair via opts.privateKeyPem +
|
|
373
|
+
// publicKeyPem for the BYO-key case), writes the OLD sealed file
|
|
374
|
+
// to a timestamped history path so historical checkpoints can still
|
|
375
|
+
// be verified, then re-seals with the new keypair.
|
|
376
|
+
//
|
|
377
|
+
// rotation does NOT walk and re-sign existing audit checkpoints —
|
|
378
|
+
// the audit module orchestrates that via reSignAll() above so the
|
|
379
|
+
// per-row storage transactions stay in one place. Operators rotating
|
|
380
|
+
// the audit key in production typically:
|
|
381
|
+
// 1. Read existing audit checkpoints
|
|
382
|
+
// 2. Call rotateSigningKey() — gets new keys live
|
|
383
|
+
// 3. Walk checkpoints through reSignAll()
|
|
384
|
+
// 4. Write back the new signatures atomically
|
|
385
|
+
async function rotateSigningKey(rotOpts) {
|
|
386
|
+
_requireInit();
|
|
387
|
+
rotOpts = rotOpts || {};
|
|
388
|
+
var prevFingerprint = keys.fingerprint;
|
|
389
|
+
var prevPublicKey = keys.publicKey;
|
|
390
|
+
var prevAlgorithm = keys.algorithm;
|
|
391
|
+
|
|
392
|
+
// Operator may supply the new keypair (BYO; useful for a hardware-
|
|
393
|
+
// backed signer) or let the framework generate. The algorithm
|
|
394
|
+
// defaults to the current keypair's algorithm; operators upgrading
|
|
395
|
+
// the algorithm pass the new alg explicitly.
|
|
396
|
+
var newAlg;
|
|
397
|
+
var newPair;
|
|
398
|
+
if (typeof rotOpts.privateKeyPem === "string" && typeof rotOpts.publicKeyPem === "string") {
|
|
399
|
+
newAlg = rotOpts.algorithm || prevAlgorithm;
|
|
400
|
+
newPair = { publicKey: rotOpts.publicKeyPem, privateKey: rotOpts.privateKeyPem };
|
|
401
|
+
} else {
|
|
402
|
+
newAlg = rotOpts.algorithm || prevAlgorithm;
|
|
403
|
+
newPair = nodeCrypto.generateKeyPairSync(newAlg, {
|
|
404
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
405
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (SUPPORTED_SIGNING_ALGS.indexOf(newAlg) === -1) {
|
|
409
|
+
throw _err("ROTATE_BAD_ALG",
|
|
410
|
+
"audit-sign.rotateSigningKey: algorithm '" + newAlg + "' is not in SUPPORTED_SIGNING_ALGS");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
var newFingerprint = _computeFingerprint(newPair.publicKey);
|
|
414
|
+
if (newFingerprint === prevFingerprint) {
|
|
415
|
+
throw _err("ROTATE_NOOP",
|
|
416
|
+
"audit-sign.rotateSigningKey: new keypair has identical fingerprint to the current — refusing to write a no-op rotation");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Move the existing sealed/plaintext file to a timestamped history
|
|
420
|
+
// path so historical checkpoints can still be verified by readers
|
|
421
|
+
// that load the old key. We keep the history forever — the file is
|
|
422
|
+
// small (a few KB) and signed audit checkpoints can be decades old.
|
|
423
|
+
var iso = new Date().toISOString().replace(/[:.]/g, "-");
|
|
424
|
+
if (currentMode === "wrapped" && paths && paths.sealed) {
|
|
425
|
+
var historyPath = paths.sealed + ".history-" + iso + "-" + prevFingerprint.slice(0, 16) /* allow:raw-byte-literal — fingerprint hex truncation count */;
|
|
426
|
+
try { await atomicFile.copy(paths.sealed, historyPath); }
|
|
427
|
+
catch (_e) { /* history copy is best-effort; the in-memory rotation still proceeds */ }
|
|
428
|
+
} else if (currentMode === "plaintext" && paths && paths.plaintext) {
|
|
429
|
+
var historyPathP = paths.plaintext + ".history-" + iso + "-" + prevFingerprint.slice(0, 16) /* allow:raw-byte-literal — fingerprint hex truncation count */;
|
|
430
|
+
try { await atomicFile.copy(paths.plaintext, historyPathP); }
|
|
431
|
+
catch (_e) { /* history copy is best-effort */ }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Persist the new keypair through the same path as boot — sealed
|
|
435
|
+
// mode re-wraps with the operator's passphrase; plaintext mode
|
|
436
|
+
// writes JSON. We don't accept a passphrase override here; the
|
|
437
|
+
// existing in-process passphrase derivation runs again.
|
|
438
|
+
if (currentMode === "wrapped") {
|
|
439
|
+
var passphrase = await _getPassphrase("Audit-signing passphrase (rotate): ");
|
|
440
|
+
try {
|
|
441
|
+
var sealed = await vaultWrap.wrap(
|
|
442
|
+
JSON.stringify({ algorithm: newAlg, publicKey: newPair.publicKey, privateKey: newPair.privateKey }, null, 2),
|
|
443
|
+
passphrase
|
|
444
|
+
);
|
|
445
|
+
atomicFile.writeSync(paths.sealed, sealed, { fileMode: 0o600 });
|
|
446
|
+
} finally { safeBuffer.secureZero(passphrase); }
|
|
447
|
+
} else if (currentMode === "plaintext") {
|
|
448
|
+
atomicFile.writeSync(
|
|
449
|
+
paths.plaintext,
|
|
450
|
+
JSON.stringify({ algorithm: newAlg, publicKey: newPair.publicKey, privateKey: newPair.privateKey }, null, 2),
|
|
451
|
+
{ fileMode: 0o600 }
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Atomic in-memory swap last (so a write failure above doesn't
|
|
456
|
+
// leave a half-rotated state where memory has the new key but the
|
|
457
|
+
// disk has the old one).
|
|
458
|
+
keys = {
|
|
459
|
+
publicKey: newPair.publicKey,
|
|
460
|
+
privateKey: newPair.privateKey,
|
|
461
|
+
algorithm: newAlg,
|
|
462
|
+
fingerprint: newFingerprint,
|
|
463
|
+
};
|
|
464
|
+
log("audit-signing keypair rotated (alg=" + newAlg + ", fp=" + newFingerprint.slice(0, 16) + "...)"); /* allow:raw-byte-literal — fingerprint hex truncation count */
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
previousFingerprint: prevFingerprint,
|
|
468
|
+
previousPublicKey: prevPublicKey,
|
|
469
|
+
newFingerprint: newFingerprint,
|
|
470
|
+
newPublicKey: newPair.publicKey,
|
|
471
|
+
algorithm: newAlg,
|
|
472
|
+
rotatedAt: new Date().toISOString(),
|
|
473
|
+
historyPath: (currentMode === "wrapped" && paths && paths.sealed)
|
|
474
|
+
? paths.sealed + ".history-" + iso + "-" + prevFingerprint.slice(0, 16) /* allow:raw-byte-literal — fingerprint hex truncation count */
|
|
475
|
+
: (currentMode === "plaintext" && paths && paths.plaintext)
|
|
476
|
+
? paths.plaintext + ".history-" + iso + "-" + prevFingerprint.slice(0, 16) /* allow:raw-byte-literal — fingerprint hex truncation count */
|
|
477
|
+
: null,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
329
481
|
function _resetForTest() {
|
|
330
482
|
keys = null;
|
|
331
483
|
initialized = false;
|
|
@@ -338,6 +490,8 @@ module.exports = {
|
|
|
338
490
|
init: init,
|
|
339
491
|
sign: sign,
|
|
340
492
|
verify: verify,
|
|
493
|
+
rotateSigningKey: rotateSigningKey,
|
|
494
|
+
reSignAll: reSignAll,
|
|
341
495
|
getPublicKey: getPublicKey,
|
|
342
496
|
getPublicKeyFingerprint: getPublicKeyFingerprint,
|
|
343
497
|
getMode: getMode,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.circuitBreaker — top-level circuit-breaker primitive.
|
|
4
|
+
*
|
|
5
|
+
* Re-exports the CircuitBreaker class previously only reachable as
|
|
6
|
+
* b.retry.CircuitBreaker, plus a `create(opts)` factory that matches
|
|
7
|
+
* every other framework primitive's `create()` shape. The
|
|
8
|
+
* implementation lives in lib/retry.js to keep the retry-classifier
|
|
9
|
+
* + CircuitBreaker close to each other (they share the
|
|
10
|
+
* isRetryable / observability emit conventions). This module is the
|
|
11
|
+
* top-level surface so operators don't have to know that retry
|
|
12
|
+
* happens to be the home of the circuit-breaker class.
|
|
13
|
+
*
|
|
14
|
+
* State machine:
|
|
15
|
+
* closed — normal flow; failures count up to failureThreshold
|
|
16
|
+
* open — fast-fail every call for cooldownMs
|
|
17
|
+
* half — first probe succeeds → close; first probe fails → re-open
|
|
18
|
+
*
|
|
19
|
+
* var cb = b.circuitBreaker.create({
|
|
20
|
+
* name: "upstream-billing",
|
|
21
|
+
* failureThreshold: 5,
|
|
22
|
+
* cooldownMs: b.constants.TIME.seconds(30),
|
|
23
|
+
* successThreshold: 2,
|
|
24
|
+
* audit: b.audit,
|
|
25
|
+
* onStateChange: function (event) {
|
|
26
|
+
* // event = { name, from, to, at }
|
|
27
|
+
* log("breaker " + event.name + " " + event.from + " -> " + event.to);
|
|
28
|
+
* },
|
|
29
|
+
* });
|
|
30
|
+
* await cb.wrap(function () { return upstream.callRiskyOp(); });
|
|
31
|
+
*
|
|
32
|
+
* The circuit-breaker is intended for per-target use (one instance
|
|
33
|
+
* per upstream service); operators sharing a breaker across
|
|
34
|
+
* unrelated targets defeat the failure-threshold semantic.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var retry = require("./retry");
|
|
38
|
+
|
|
39
|
+
// Pass-through factory — operators get the same instance shape as
|
|
40
|
+
// b.retry.CircuitBreaker but with the framework's `create(opts)`
|
|
41
|
+
// vocabulary. The breaker class is unchanged; this is a thin
|
|
42
|
+
// surface re-export so b.circuitBreaker is operator-discoverable
|
|
43
|
+
// alongside b.retry.
|
|
44
|
+
function create(opts) {
|
|
45
|
+
return new retry.CircuitBreaker(opts || {});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
create: create,
|
|
50
|
+
CircuitBreaker: retry.CircuitBreaker,
|
|
51
|
+
// Forward the error class so operators catching breaker rejections
|
|
52
|
+
// can `instanceof` against the framework's RetryError without
|
|
53
|
+
// requiring a separate b.retry import.
|
|
54
|
+
RetryError: retry.RetryError,
|
|
55
|
+
};
|
package/lib/html-balance.js
CHANGED
|
@@ -224,4 +224,41 @@ function check(html) {
|
|
|
224
224
|
return null;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
// Optional content-safety pass for HTML rendered through the framework.
|
|
228
|
+
// Mirrors the same opt shape as b.fileUpload({ contentSafety }) /
|
|
229
|
+
// b.staticServe({ contentSafety }) so operators wiring guards across
|
|
230
|
+
// the stack pass a single { profile, posture } object — the pass-
|
|
231
|
+
// through to b.guardHtml.gate validates the HTML against the same
|
|
232
|
+
// strict / balanced / permissive vocabulary, plus the configured
|
|
233
|
+
// compliance posture.
|
|
234
|
+
//
|
|
235
|
+
// var safe = b.htmlBalance.checkSafe(html, { profile: "strict" });
|
|
236
|
+
// if (safe.issues.length) refuseRequest();
|
|
237
|
+
//
|
|
238
|
+
// checkSafe runs balance() first (cheap structural well-formedness),
|
|
239
|
+
// then guardHtml.gate({ profile }) for the security-class checks. The
|
|
240
|
+
// returned shape is { balanceIssue, guardIssues } so callers can
|
|
241
|
+
// distinguish a structural problem from a content-safety reject.
|
|
242
|
+
var lazyRequire = require("./lazy-require");
|
|
243
|
+
var _guardHtml = lazyRequire(function () { return require("./guard-html"); });
|
|
244
|
+
|
|
245
|
+
function checkSafe(html, opts) {
|
|
246
|
+
opts = opts || {};
|
|
247
|
+
var balanceIssue = check(html);
|
|
248
|
+
var guardIssues = [];
|
|
249
|
+
if (opts.profile || opts.contentSafety) {
|
|
250
|
+
var profile = opts.profile || (opts.contentSafety && opts.contentSafety.profile) || "strict";
|
|
251
|
+
var posture = opts.posture || (opts.contentSafety && opts.contentSafety.posture) || null;
|
|
252
|
+
var validateOpts = { profile: profile };
|
|
253
|
+
if (posture) validateOpts.compliancePosture = posture;
|
|
254
|
+
var rv = _guardHtml().validate(html, validateOpts);
|
|
255
|
+
if (rv && Array.isArray(rv.issues)) guardIssues = rv.issues;
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
balanceIssue: balanceIssue,
|
|
259
|
+
guardIssues: guardIssues,
|
|
260
|
+
ok: !balanceIssue && guardIssues.length === 0,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { check: check, checkSafe: checkSafe };
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.incident.report — generic 3-stage incident-reporting primitive.
|
|
4
|
+
*
|
|
5
|
+
* The three stages mirror the deadline pattern that recurs across
|
|
6
|
+
* regulatory regimes (GDPR Article 33 / NIS2 Article 23 / DORA
|
|
7
|
+
* Article 19 / CRA Article 14 / HIPAA Breach Notification Rule):
|
|
8
|
+
*
|
|
9
|
+
* 1. Initial / early-warning notification — within 24 hours of
|
|
10
|
+
* detection. Operators report what they know: scope, suspected
|
|
11
|
+
* cause, immediate-mitigation plan.
|
|
12
|
+
* 2. Intermediate / status update — within 72 hours of detection.
|
|
13
|
+
* Updated impact assessment, root-cause analysis progress,
|
|
14
|
+
* operator's response posture.
|
|
15
|
+
* 3. Final report — within 30 days of detection (or per-regime
|
|
16
|
+
* deadline). Full incident narrative, affected-data-subject
|
|
17
|
+
* count, remediation, lessons learned.
|
|
18
|
+
*
|
|
19
|
+
* var ir = b.incident.report.create({
|
|
20
|
+
* audit: b.audit,
|
|
21
|
+
* persist: async function (record) { await db.run("INSERT ..."); },
|
|
22
|
+
* onStage: function (event) {
|
|
23
|
+
* // event = { incidentId, stage: "initial"|"intermediate"|"final",
|
|
24
|
+
* // dueBy, regime, fields }
|
|
25
|
+
* },
|
|
26
|
+
* deadlines: { // operator-overridable per regime
|
|
27
|
+
* initial: C.TIME.hours(24),
|
|
28
|
+
* intermediate: C.TIME.hours(72),
|
|
29
|
+
* final: C.TIME.days(30),
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
* var incident = await ir.open({
|
|
33
|
+
* regime: "gdpr", // identifies the regulatory regime
|
|
34
|
+
* detectedAt: Date.now(),
|
|
35
|
+
* scope: "data-confidentiality-breach",
|
|
36
|
+
* summary: "...",
|
|
37
|
+
* impact: { dataSubjects: 1200, categories: ["pii", "phi"] },
|
|
38
|
+
* });
|
|
39
|
+
* await ir.recordInitial(incident.id, { ... });
|
|
40
|
+
* await ir.recordIntermediate(incident.id, { ... });
|
|
41
|
+
* await ir.recordFinal(incident.id, { ... });
|
|
42
|
+
*
|
|
43
|
+
* Each stage records a tamper-evident audit event with the regime,
|
|
44
|
+
* incident ID, stage, due-by timestamp, and operator-supplied
|
|
45
|
+
* payload. The `persist` hook writes to the operator's incident
|
|
46
|
+
* registry (DB / SIEM / SOAR system); audits give regulator-friendly
|
|
47
|
+
* proof-of-process when the primitive is queried later.
|
|
48
|
+
*
|
|
49
|
+
* Late-stage detection (filing past the due-by) is recorded with a
|
|
50
|
+
* `lateBy` field on the audit metadata and `outcome: "late"`, so
|
|
51
|
+
* regulator audits can distinguish "filed on time" from "filed late
|
|
52
|
+
* but eventually". Refusing to record at all would lose the data;
|
|
53
|
+
* the framework's posture is "always record, always flag late".
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
var C = require("./constants");
|
|
57
|
+
var defineClass = require("./framework-error").defineClass;
|
|
58
|
+
var lazyRequire = require("./lazy-require");
|
|
59
|
+
var validateOpts = require("./validate-opts");
|
|
60
|
+
|
|
61
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
62
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
63
|
+
|
|
64
|
+
var IncidentReportError = defineClass("IncidentReportError", { alwaysPermanent: true });
|
|
65
|
+
|
|
66
|
+
var DEFAULT_DEADLINES = Object.freeze({
|
|
67
|
+
initial: C.TIME.hours(24),
|
|
68
|
+
intermediate: C.TIME.hours(72),
|
|
69
|
+
final: C.TIME.days(30),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
var VALID_STAGES = Object.freeze({ initial: 1, intermediate: 1, final: 1 });
|
|
73
|
+
|
|
74
|
+
// Regime defaults — operators select these via opts.regime so the
|
|
75
|
+
// per-regime deadline shape is the framework default, not something
|
|
76
|
+
// the operator has to redefine each time. Operators with mixed
|
|
77
|
+
// regimes per incident pick the shortest deadline (the "first wall
|
|
78
|
+
// to hit" rule). Per-regime overrides go on opts.deadlines and
|
|
79
|
+
// override the regime defaults.
|
|
80
|
+
var REGIME_DEADLINES = Object.freeze({
|
|
81
|
+
// GDPR Article 33 §1: notify within 72 hours of awareness.
|
|
82
|
+
// No formal "initial" stage — the framework adds an internal
|
|
83
|
+
// 24-hour initial-warning checkpoint as a practical posture.
|
|
84
|
+
gdpr: Object.freeze({
|
|
85
|
+
initial: C.TIME.hours(24),
|
|
86
|
+
intermediate: C.TIME.hours(72),
|
|
87
|
+
final: C.TIME.days(30),
|
|
88
|
+
}),
|
|
89
|
+
// NIS2 Directive Article 23 §4: early warning within 24h, full
|
|
90
|
+
// notification within 72h, final report within 1 month.
|
|
91
|
+
nis2: Object.freeze({
|
|
92
|
+
initial: C.TIME.hours(24),
|
|
93
|
+
intermediate: C.TIME.hours(72),
|
|
94
|
+
final: C.TIME.days(30),
|
|
95
|
+
}),
|
|
96
|
+
// DORA (EU Digital Operational Resilience Act) Article 19: initial
|
|
97
|
+
// within 4h of classification, intermediate within 24h, final
|
|
98
|
+
// within 1 month.
|
|
99
|
+
dora: Object.freeze({
|
|
100
|
+
initial: C.TIME.hours(4),
|
|
101
|
+
intermediate: C.TIME.hours(24),
|
|
102
|
+
final: C.TIME.days(30),
|
|
103
|
+
}),
|
|
104
|
+
// CRA (EU Cyber Resilience Act) Article 14: early warning within
|
|
105
|
+
// 24h, vulnerability/incident notification within 72h, final
|
|
106
|
+
// report within 14 days.
|
|
107
|
+
cra: Object.freeze({
|
|
108
|
+
initial: C.TIME.hours(24),
|
|
109
|
+
intermediate: C.TIME.hours(72),
|
|
110
|
+
final: C.TIME.days(14),
|
|
111
|
+
}),
|
|
112
|
+
// HIPAA Breach Notification Rule (45 CFR §164.404 etc.): notify
|
|
113
|
+
// affected individuals within 60 days; HHS within 60 days for
|
|
114
|
+
// breaches affecting 500+ individuals (annually otherwise). The
|
|
115
|
+
// initial / intermediate stages have no statutory deadline; we
|
|
116
|
+
// adopt the EU 24/72-hour internal checkpoints as good operator
|
|
117
|
+
// practice.
|
|
118
|
+
hipaa: Object.freeze({
|
|
119
|
+
initial: C.TIME.hours(24),
|
|
120
|
+
intermediate: C.TIME.hours(72),
|
|
121
|
+
final: C.TIME.days(60),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function _resolveDeadlines(regime, override) {
|
|
126
|
+
var base = (regime && REGIME_DEADLINES[regime]) || DEFAULT_DEADLINES;
|
|
127
|
+
if (!override || typeof override !== "object") return base;
|
|
128
|
+
return Object.freeze({
|
|
129
|
+
initial: typeof override.initial === "number" ? override.initial : base.initial,
|
|
130
|
+
intermediate: typeof override.intermediate === "number" ? override.intermediate : base.intermediate,
|
|
131
|
+
final: typeof override.final === "number" ? override.final : base.final,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function create(opts) {
|
|
136
|
+
opts = opts || {};
|
|
137
|
+
validateOpts(opts, [
|
|
138
|
+
"audit", "persist", "onStage", "deadlines", "now",
|
|
139
|
+
], "incident.report");
|
|
140
|
+
|
|
141
|
+
var auditOn = opts.audit !== false;
|
|
142
|
+
var persist = typeof opts.persist === "function" ? opts.persist : null;
|
|
143
|
+
var onStage = typeof opts.onStage === "function" ? opts.onStage : null;
|
|
144
|
+
var deadlinesOverride = opts.deadlines || null;
|
|
145
|
+
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
146
|
+
|
|
147
|
+
// In-memory registry — operators wire `persist` for durable
|
|
148
|
+
// storage. The in-memory map is for unit tests + small operator
|
|
149
|
+
// deployments that don't yet wire a DB-backed registry.
|
|
150
|
+
var incidents = new Map();
|
|
151
|
+
var seq = 0;
|
|
152
|
+
|
|
153
|
+
function _emitAudit(action, outcome, metadata) {
|
|
154
|
+
if (!auditOn) return;
|
|
155
|
+
try {
|
|
156
|
+
audit().safeEmit({
|
|
157
|
+
action: "incident.report." + action,
|
|
158
|
+
outcome: outcome,
|
|
159
|
+
metadata: metadata || {},
|
|
160
|
+
});
|
|
161
|
+
} catch (_e) { /* drop-silent */ }
|
|
162
|
+
}
|
|
163
|
+
function _emitMetric(verb, n, labels) {
|
|
164
|
+
try { observability().safeEvent("incident.report." + verb, n || 1, labels || {}); }
|
|
165
|
+
catch (_e) { /* drop-silent */ }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _genIncidentId(regime, detectedAt) {
|
|
169
|
+
seq += 1;
|
|
170
|
+
var ts = new Date(detectedAt).toISOString().replace(/[:.]/g, "-");
|
|
171
|
+
return "incident-" + (regime || "generic") + "-" + ts + "-" + seq;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function open(spec) {
|
|
175
|
+
if (!spec || typeof spec !== "object") {
|
|
176
|
+
throw new IncidentReportError("incident-report/bad-spec",
|
|
177
|
+
"incident.report.open: spec must be an object with { regime, detectedAt, scope, summary, impact }");
|
|
178
|
+
}
|
|
179
|
+
if (typeof spec.regime !== "string" || spec.regime.length === 0) {
|
|
180
|
+
throw new IncidentReportError("incident-report/bad-regime",
|
|
181
|
+
"incident.report.open: spec.regime must be a non-empty string (gdpr / nis2 / dora / cra / hipaa or operator-defined)");
|
|
182
|
+
}
|
|
183
|
+
if (typeof spec.detectedAt !== "number" || !isFinite(spec.detectedAt)) {
|
|
184
|
+
throw new IncidentReportError("incident-report/bad-detected-at",
|
|
185
|
+
"incident.report.open: spec.detectedAt must be a finite Unix-ms timestamp");
|
|
186
|
+
}
|
|
187
|
+
var deadlines = _resolveDeadlines(spec.regime, deadlinesOverride);
|
|
188
|
+
var id = _genIncidentId(spec.regime, spec.detectedAt);
|
|
189
|
+
var record = {
|
|
190
|
+
id: id,
|
|
191
|
+
regime: spec.regime,
|
|
192
|
+
detectedAt: spec.detectedAt,
|
|
193
|
+
scope: spec.scope || null,
|
|
194
|
+
summary: spec.summary || null,
|
|
195
|
+
impact: spec.impact || null,
|
|
196
|
+
deadlines: deadlines,
|
|
197
|
+
dueBy: {
|
|
198
|
+
initial: spec.detectedAt + deadlines.initial,
|
|
199
|
+
intermediate: spec.detectedAt + deadlines.intermediate,
|
|
200
|
+
final: spec.detectedAt + deadlines.final,
|
|
201
|
+
},
|
|
202
|
+
stages: {}, // populated by recordInitial / recordIntermediate / recordFinal
|
|
203
|
+
openedAt: now(),
|
|
204
|
+
closedAt: null,
|
|
205
|
+
};
|
|
206
|
+
incidents.set(id, record);
|
|
207
|
+
_emitAudit("opened", "success", {
|
|
208
|
+
incidentId: id, regime: spec.regime, detectedAt: spec.detectedAt,
|
|
209
|
+
dueByInitial: record.dueBy.initial,
|
|
210
|
+
dueByIntermediate: record.dueBy.intermediate,
|
|
211
|
+
dueByFinal: record.dueBy.final,
|
|
212
|
+
});
|
|
213
|
+
_emitMetric("opened", 1, { regime: spec.regime });
|
|
214
|
+
if (persist) {
|
|
215
|
+
try { await persist(record); }
|
|
216
|
+
catch (e) { _emitAudit("persist_failed", "failure", { incidentId: id, error: (e && e.message) || String(e) }); }
|
|
217
|
+
}
|
|
218
|
+
return record;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function _recordStage(incidentId, stage, payload) {
|
|
222
|
+
if (!VALID_STAGES[stage]) {
|
|
223
|
+
throw new IncidentReportError("incident-report/bad-stage",
|
|
224
|
+
"incident.report._recordStage: stage must be one of " + Object.keys(VALID_STAGES).join(", "));
|
|
225
|
+
}
|
|
226
|
+
var rec = incidents.get(incidentId);
|
|
227
|
+
if (!rec) {
|
|
228
|
+
throw new IncidentReportError("incident-report/unknown-incident",
|
|
229
|
+
"incident.report: no incident with id '" + incidentId + "'");
|
|
230
|
+
}
|
|
231
|
+
if (rec.stages[stage]) {
|
|
232
|
+
throw new IncidentReportError("incident-report/stage-already-filed",
|
|
233
|
+
"incident.report: incident '" + incidentId + "' already has a '" + stage + "' stage filing");
|
|
234
|
+
}
|
|
235
|
+
var nowMs = now();
|
|
236
|
+
var dueBy = rec.dueBy[stage];
|
|
237
|
+
var late = nowMs > dueBy;
|
|
238
|
+
var lateBy = late ? (nowMs - dueBy) : 0;
|
|
239
|
+
rec.stages[stage] = {
|
|
240
|
+
filedAt: nowMs,
|
|
241
|
+
dueBy: dueBy,
|
|
242
|
+
late: late,
|
|
243
|
+
lateBy: lateBy,
|
|
244
|
+
payload: payload || {},
|
|
245
|
+
};
|
|
246
|
+
if (stage === "final") rec.closedAt = nowMs;
|
|
247
|
+
|
|
248
|
+
_emitAudit("stage_recorded", late ? "late" : "success", {
|
|
249
|
+
incidentId: incidentId, regime: rec.regime, stage: stage,
|
|
250
|
+
dueBy: dueBy, filedAt: nowMs, late: late, lateBy: lateBy,
|
|
251
|
+
});
|
|
252
|
+
_emitMetric("stage_recorded", 1, { regime: rec.regime, stage: stage, late: String(late) });
|
|
253
|
+
if (onStage) {
|
|
254
|
+
try { onStage({ incidentId: incidentId, stage: stage, dueBy: dueBy, late: late, regime: rec.regime, fields: payload }); }
|
|
255
|
+
catch (_e) { /* drop-silent — operator hook */ }
|
|
256
|
+
}
|
|
257
|
+
if (persist) {
|
|
258
|
+
try { await persist(rec); }
|
|
259
|
+
catch (e) { _emitAudit("persist_failed", "failure", { incidentId: incidentId, stage: stage, error: (e && e.message) || String(e) }); }
|
|
260
|
+
}
|
|
261
|
+
return rec;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function recordInitial(incidentId, payload) { return _recordStage(incidentId, "initial", payload); }
|
|
265
|
+
function recordIntermediate(incidentId, payload) { return _recordStage(incidentId, "intermediate", payload); }
|
|
266
|
+
function recordFinal(incidentId, payload) { return _recordStage(incidentId, "final", payload); }
|
|
267
|
+
|
|
268
|
+
function get(incidentId) { return incidents.get(incidentId) || null; }
|
|
269
|
+
function list() {
|
|
270
|
+
var out = [];
|
|
271
|
+
incidents.forEach(function (rec) { out.push(rec); });
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Operator-facing summary for dashboards / regulator-prep — counts
|
|
276
|
+
// open / late / closed across all tracked regimes.
|
|
277
|
+
function status() {
|
|
278
|
+
var nowMs = now();
|
|
279
|
+
var summary = {
|
|
280
|
+
total: incidents.size,
|
|
281
|
+
open: 0,
|
|
282
|
+
closed: 0,
|
|
283
|
+
late: { initial: 0, intermediate: 0, final: 0 },
|
|
284
|
+
};
|
|
285
|
+
incidents.forEach(function (rec) {
|
|
286
|
+
if (rec.closedAt) summary.closed += 1; else summary.open += 1;
|
|
287
|
+
["initial", "intermediate", "final"].forEach(function (s) {
|
|
288
|
+
if (!rec.stages[s] && nowMs > rec.dueBy[s]) summary.late[s] += 1;
|
|
289
|
+
else if (rec.stages[s] && rec.stages[s].late) summary.late[s] += 1;
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
return summary;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
open: open,
|
|
297
|
+
recordInitial: recordInitial,
|
|
298
|
+
recordIntermediate: recordIntermediate,
|
|
299
|
+
recordFinal: recordFinal,
|
|
300
|
+
get: get,
|
|
301
|
+
list: list,
|
|
302
|
+
status: status,
|
|
303
|
+
REGIME_DEADLINES: REGIME_DEADLINES,
|
|
304
|
+
DEFAULT_DEADLINES: DEFAULT_DEADLINES,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
create: create,
|
|
310
|
+
IncidentReportError: IncidentReportError,
|
|
311
|
+
REGIME_DEADLINES: REGIME_DEADLINES,
|
|
312
|
+
DEFAULT_DEADLINES: DEFAULT_DEADLINES,
|
|
313
|
+
VALID_STAGES: Object.keys(VALID_STAGES),
|
|
314
|
+
};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* requireBoundKey middleware — Bearer-API-key auth with scope + bound-
|
|
4
|
+
* fields + cert-fingerprint binding.
|
|
5
|
+
*
|
|
6
|
+
* The framework's bearer-auth + dpop + requireMtls cover JWT, DPoP, and
|
|
7
|
+
* mTLS. requireBoundKey covers the API-key-with-binding case that
|
|
8
|
+
* shows up on internal service-to-service endpoints, partner API
|
|
9
|
+
* webhook receivers, and CI runners shipping events to a hosted
|
|
10
|
+
* blamejs deployment:
|
|
11
|
+
*
|
|
12
|
+
* - Authorization: Bearer <api-key>
|
|
13
|
+
* - the API key is registered in an operator-supplied resolver
|
|
14
|
+
* with: { scopes: [...], boundFields: { ... }, peerCertFingerprints: [...] }
|
|
15
|
+
* - the request must present a peer cert whose fingerprint is in
|
|
16
|
+
* peerCertFingerprints (when set)
|
|
17
|
+
* - the request must include the boundFields with the registered
|
|
18
|
+
* values (e.g. { tenantId: "acme", region: "us-east-1" }) — bound
|
|
19
|
+
* fields are pulled from headers / query / body via getter
|
|
20
|
+
* - the request must hold one of the operator-required scopes
|
|
21
|
+
*
|
|
22
|
+
* Failure mode: 401 / 403 with a structured JSON body identifying
|
|
23
|
+
* which check failed (operator audit trail). Audits emit
|
|
24
|
+
* `auth.require_bound_key.allowed` / `auth.require_bound_key.refused`
|
|
25
|
+
* with the api-key-id (not the secret) + reason metadata.
|
|
26
|
+
*
|
|
27
|
+
* var keys = b.middleware.requireBoundKey({
|
|
28
|
+
* resolver: async function (apiKey) {
|
|
29
|
+
* // operator-supplied — usually a DB lookup with timing-safe
|
|
30
|
+
* // compare. Returns { id, scopes, boundFields, peerCertFingerprints }
|
|
31
|
+
* // or null when the key is unknown / revoked.
|
|
32
|
+
* return await keyDb.findByKey(apiKey);
|
|
33
|
+
* },
|
|
34
|
+
* requiredScopes: ["webhook.ingest"],
|
|
35
|
+
* getBoundField: {
|
|
36
|
+
* tenantId: function (req) { return req.headers["x-tenant-id"]; },
|
|
37
|
+
* region: function (req) { return req.query.region; },
|
|
38
|
+
* },
|
|
39
|
+
* audit: b.audit,
|
|
40
|
+
* });
|
|
41
|
+
* router.post("/webhook/ingest", keys, ingestHandler);
|
|
42
|
+
*
|
|
43
|
+
* Composition with other middleware:
|
|
44
|
+
* - b.middleware.requireMtls runs FIRST (so req.peerCert is set)
|
|
45
|
+
* - b.middleware.requireBoundKey reads req.peerCert and cross-checks
|
|
46
|
+
*
|
|
47
|
+
* Defaults to fail-closed on every check; resolver throws / returns
|
|
48
|
+
* undefined → refused with reason "resolver-unavailable".
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
var defineClass = require("../framework-error").defineClass;
|
|
52
|
+
var lazyRequire = require("../lazy-require");
|
|
53
|
+
var validateOpts = require("../validate-opts");
|
|
54
|
+
|
|
55
|
+
var crypto = lazyRequire(function () { return require("../crypto"); });
|
|
56
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
57
|
+
|
|
58
|
+
var RequireBoundKeyError = defineClass("RequireBoundKeyError", { alwaysPermanent: true });
|
|
59
|
+
|
|
60
|
+
function _parseBearer(req) {
|
|
61
|
+
var h = req.headers && (req.headers.authorization || req.headers.Authorization);
|
|
62
|
+
if (typeof h !== "string" || h.length === 0) return null;
|
|
63
|
+
var m = h.match(/^Bearer\s+([\x21-\x7e]+)$/); // RFC 6750 token68
|
|
64
|
+
return m ? m[1] : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _timingSafeStringEqual(a, b) {
|
|
68
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
69
|
+
if (a.length !== b.length) return false;
|
|
70
|
+
return crypto().timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function create(opts) {
|
|
74
|
+
opts = opts || {};
|
|
75
|
+
validateOpts(opts, [
|
|
76
|
+
"resolver", "requiredScopes", "getBoundField",
|
|
77
|
+
"audit", "auditAction", "errorMessage",
|
|
78
|
+
"tolerateMissingPeerCert",
|
|
79
|
+
], "middleware.requireBoundKey");
|
|
80
|
+
|
|
81
|
+
if (typeof opts.resolver !== "function") {
|
|
82
|
+
throw new RequireBoundKeyError("require-bound-key/bad-resolver",
|
|
83
|
+
"middleware.requireBoundKey: opts.resolver must be an async function (apiKey) -> {id, scopes, boundFields, peerCertFingerprints} | null");
|
|
84
|
+
}
|
|
85
|
+
var resolver = opts.resolver;
|
|
86
|
+
var requiredScopes = Array.isArray(opts.requiredScopes) ? opts.requiredScopes.slice() : [];
|
|
87
|
+
for (var rs = 0; rs < requiredScopes.length; rs++) {
|
|
88
|
+
if (typeof requiredScopes[rs] !== "string" || requiredScopes[rs].length === 0) {
|
|
89
|
+
throw new RequireBoundKeyError("require-bound-key/bad-scope",
|
|
90
|
+
"middleware.requireBoundKey: requiredScopes[" + rs + "] must be a non-empty string");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
var getBoundField = (opts.getBoundField && typeof opts.getBoundField === "object")
|
|
94
|
+
? opts.getBoundField : {};
|
|
95
|
+
var boundFieldNames = Object.keys(getBoundField);
|
|
96
|
+
for (var bf = 0; bf < boundFieldNames.length; bf++) {
|
|
97
|
+
if (typeof getBoundField[boundFieldNames[bf]] !== "function") {
|
|
98
|
+
throw new RequireBoundKeyError("require-bound-key/bad-bound-field-getter",
|
|
99
|
+
"middleware.requireBoundKey: getBoundField." + boundFieldNames[bf] + " must be a function (req) -> string");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
var auditOn = opts.audit !== false;
|
|
103
|
+
var actionBase = typeof opts.auditAction === "string" && opts.auditAction.length > 0
|
|
104
|
+
? opts.auditAction : "auth.require_bound_key";
|
|
105
|
+
var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
|
|
106
|
+
? opts.errorMessage : "api key required";
|
|
107
|
+
// For operator-side fixtures and dev environments without an mTLS
|
|
108
|
+
// termination layer, allow disabling the peer-cert cross-check
|
|
109
|
+
// even when peerCertFingerprints is set on the registered key.
|
|
110
|
+
// Production deployments leave this at default false.
|
|
111
|
+
var tolerateMissingPeerCert = !!opts.tolerateMissingPeerCert;
|
|
112
|
+
|
|
113
|
+
function _emitAudit(outcome, metadata) {
|
|
114
|
+
if (!auditOn) return;
|
|
115
|
+
try {
|
|
116
|
+
audit().safeEmit({
|
|
117
|
+
action: actionBase + (outcome === "success" ? ".allowed" : ".refused"),
|
|
118
|
+
outcome: outcome,
|
|
119
|
+
metadata: metadata || {},
|
|
120
|
+
});
|
|
121
|
+
} catch (_e) { /* drop-silent */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function _refuse(res, status, reason, metadata) {
|
|
125
|
+
_emitAudit("denied", Object.assign({ reason: reason }, metadata || {}));
|
|
126
|
+
if (res.writableEnded || typeof res.writeHead !== "function") return;
|
|
127
|
+
res.writeHead(status, {
|
|
128
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
129
|
+
"WWW-Authenticate": 'Bearer realm="api", error="invalid_request"',
|
|
130
|
+
"Cache-Control": "no-store",
|
|
131
|
+
});
|
|
132
|
+
res.end(JSON.stringify({ error: errorMessage, reason: reason }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return async function requireBoundKeyMiddleware(req, res, next) {
|
|
136
|
+
var apiKey = _parseBearer(req);
|
|
137
|
+
if (!apiKey) return _refuse(res, 401, "no-bearer-token", {});
|
|
138
|
+
|
|
139
|
+
var record;
|
|
140
|
+
try { record = await resolver(apiKey); }
|
|
141
|
+
catch (e) {
|
|
142
|
+
return _refuse(res, 503, "resolver-unavailable", {
|
|
143
|
+
error: (e && e.message) || String(e),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (!record || typeof record !== "object") {
|
|
147
|
+
return _refuse(res, 401, "key-unknown-or-revoked", {});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Required-scope check — operator-supplied requiredScopes must be
|
|
151
|
+
// a subset of the registered key's scopes.
|
|
152
|
+
var keyScopes = Array.isArray(record.scopes) ? record.scopes : [];
|
|
153
|
+
for (var rsi = 0; rsi < requiredScopes.length; rsi++) {
|
|
154
|
+
if (keyScopes.indexOf(requiredScopes[rsi]) === -1) {
|
|
155
|
+
return _refuse(res, 403, "missing-scope", {
|
|
156
|
+
requiredScope: requiredScopes[rsi], keyId: record.id || null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Bound-field check — every key in the registered boundFields map
|
|
162
|
+
// must be present on the request and match. The operator's
|
|
163
|
+
// getBoundField extracts each value from headers / query / body.
|
|
164
|
+
var registered = (record.boundFields && typeof record.boundFields === "object") ? record.boundFields : {};
|
|
165
|
+
var registeredKeys = Object.keys(registered);
|
|
166
|
+
for (var bfi = 0; bfi < registeredKeys.length; bfi++) {
|
|
167
|
+
var fieldName = registeredKeys[bfi];
|
|
168
|
+
var getter = getBoundField[fieldName];
|
|
169
|
+
if (!getter) {
|
|
170
|
+
return _refuse(res, 500, "bound-field-no-getter", {
|
|
171
|
+
field: fieldName, keyId: record.id || null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
var presented;
|
|
175
|
+
try { presented = getter(req); }
|
|
176
|
+
catch (e) {
|
|
177
|
+
return _refuse(res, 400, "bound-field-getter-threw", { // allow:raw-byte-literal — HTTP 400
|
|
178
|
+
field: fieldName, error: (e && e.message) || String(e),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (typeof presented !== "string" || presented.length === 0) {
|
|
182
|
+
return _refuse(res, 400, "bound-field-missing", { // allow:raw-byte-literal — HTTP 400
|
|
183
|
+
field: fieldName, keyId: record.id || null,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
var expected = String(registered[fieldName]);
|
|
187
|
+
if (!_timingSafeStringEqual(presented, expected)) {
|
|
188
|
+
return _refuse(res, 403, "bound-field-mismatch", {
|
|
189
|
+
field: fieldName, keyId: record.id || null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Peer-cert fingerprint check — if the registered key pins peer
|
|
195
|
+
// certs, the request must come over mTLS with a fingerprint on
|
|
196
|
+
// the allowlist. b.middleware.requireMtls running upstream
|
|
197
|
+
// attaches req.peerCert / req.peerFingerprint; we re-derive when
|
|
198
|
+
// a downstream middleware order leaves them unset.
|
|
199
|
+
var pinned = Array.isArray(record.peerCertFingerprints) ? record.peerCertFingerprints : [];
|
|
200
|
+
if (pinned.length > 0) {
|
|
201
|
+
var fpHex = req.peerFingerprint && req.peerFingerprint.hex;
|
|
202
|
+
var fpColon = req.peerFingerprint && req.peerFingerprint.colon;
|
|
203
|
+
if (!fpHex && req.peerCert && req.peerCert.raw) {
|
|
204
|
+
try {
|
|
205
|
+
var fp = crypto().hashCertFingerprint(req.peerCert.raw);
|
|
206
|
+
fpHex = fp.hex; fpColon = fp.colon;
|
|
207
|
+
} catch (_e) { /* fall through to refused below */ }
|
|
208
|
+
}
|
|
209
|
+
if (!fpHex) {
|
|
210
|
+
if (tolerateMissingPeerCert) {
|
|
211
|
+
// Audited bypass for dev fixtures.
|
|
212
|
+
_emitAudit("denied", { reason: "peer-cert-bypass-tolerated", keyId: record.id });
|
|
213
|
+
} else {
|
|
214
|
+
return _refuse(res, 401, "peer-cert-required", {
|
|
215
|
+
keyId: record.id || null,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} else if (!crypto().isCertRevoked(req.peerCert.raw, pinned)) {
|
|
219
|
+
// isCertRevoked returns true on MATCH against the deny-list
|
|
220
|
+
// shape; we use it here as a fingerprint-set membership test
|
|
221
|
+
// because it does the same constant-time hex/colon comparison
|
|
222
|
+
// we want for an allow-list. A future refactor can rename to
|
|
223
|
+
// isCertFingerprintInSet — semantically identical.
|
|
224
|
+
return _refuse(res, 403, "peer-cert-not-pinned", {
|
|
225
|
+
fingerprint: fpColon, keyId: record.id || null,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// All checks passed. Attach the resolved record to req.apiKey for
|
|
231
|
+
// downstream handlers (without the secret — the resolver returned
|
|
232
|
+
// a normalized record, the middleware never re-exposes the bearer).
|
|
233
|
+
req.apiKey = {
|
|
234
|
+
id: record.id || null,
|
|
235
|
+
scopes: keyScopes.slice(),
|
|
236
|
+
boundFields: Object.assign({}, registered),
|
|
237
|
+
};
|
|
238
|
+
_emitAudit("success", {
|
|
239
|
+
keyId: record.id || null,
|
|
240
|
+
scopesGranted: keyScopes,
|
|
241
|
+
});
|
|
242
|
+
return next();
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
create: create,
|
|
248
|
+
RequireBoundKeyError: RequireBoundKeyError,
|
|
249
|
+
};
|
package/lib/permissions.js
CHANGED
|
@@ -300,6 +300,29 @@ function create(opts) {
|
|
|
300
300
|
if (policies[scope]) {
|
|
301
301
|
throw _err("DUPLICATE_POLICY", "permissions.policy: '" + scope + "' is already registered");
|
|
302
302
|
}
|
|
303
|
+
// Predicate-shape sanity check — predicate(actor, context) is the
|
|
304
|
+
// documented contract. Operator-supplied 0-arg or 1-arg predicates
|
|
305
|
+
// typically indicate the operator forgot the context parameter
|
|
306
|
+
// and is silently always-true for any actor (the predicate
|
|
307
|
+
// returns based on closure state instead of inspecting the
|
|
308
|
+
// actor / context). Emit a one-time audit warning at register-
|
|
309
|
+
// time so operator-side tooling sees the misconfiguration.
|
|
310
|
+
if (predicate.length < 2) {
|
|
311
|
+
try {
|
|
312
|
+
if (audit && typeof audit.safeEmit === "function") {
|
|
313
|
+
audit.safeEmit({
|
|
314
|
+
action: "permissions.policy_predicate_shape_warning",
|
|
315
|
+
outcome: "warning",
|
|
316
|
+
metadata: {
|
|
317
|
+
scope: scope,
|
|
318
|
+
arity: predicate.length,
|
|
319
|
+
expected: "predicate(actor, context) -> bool",
|
|
320
|
+
hint: "predicate has " + predicate.length + " formal arg(s); the framework calls it as predicate(actor, context). Confirm the predicate inspects both args.",
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
} catch (_e) { /* drop-silent — audit is best-effort */ }
|
|
325
|
+
}
|
|
303
326
|
policies[scope] = predicate;
|
|
304
327
|
}
|
|
305
328
|
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:b2addb57-b40a-4a44-9c34-7f7bd7d741c3",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-07T02:18:24.599Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.9",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.9",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.9",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.9",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|