@blamejs/core 0.8.38 → 0.8.40

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - v0.8.40 (2026-05-07) — operator enhancements (2/2): `b.honeytoken.create({audit})` issues canary api-key / session / URL / row-id values that emit `honeytoken.tripped` audit on any positive lookup; `b.middleware.cspReport.create({onReport})` is a Reporting-API endpoint that ingests CSP / COEP / COOP violations as `csp.violation` audit rows; `b.auditTools.forensicSnapshot({out, since, passphrase, reason})` composes an audit-export slice + IR context manifest into one tamper-evident bundle for legal / regulator handover; `b.network.tls.pinsetDriftMonitor({intervalMs})` periodically compares the trust-store fingerprint set to the captured baseline and emits `network.tls.pinset.drifted` when CAs are added or removed. Adds the OpenSSF Scorecard CI workflow at `.github/workflows/scorecard.yml`. Defers items 11 (operator-supplied transform sandbox), 14 (chaos / fault-injection drills), and 15 (exploit replay corpus harness) with re-open conditions: surface when (a) operator demand surfaces OR (b) a CVE replay needs a vendored harness.
12
+ - v0.8.39 (2026-05-07) — operator enhancements (1/2): `b.configDrift.verifyVendorIntegrity()` re-hashes every file listed in `lib/vendor/MANIFEST.json` at boot and refuses on mismatch; `b.network.allowlist.create({allow, deny})` composes on `b.ssrfGuard` to gate per-call outbound URLs against an operator CIDR/host allow set; `b.auth.atoKillSwitch.trigger({userId, reason})` is a composite ATO incident-response workflow that destroys every session for the user, applies `b.auth.lockout`, and optionally flips `b.auth.accessLock` mode in one audited call.
11
13
  - v0.8.38 (2026-05-07) — multipart parser refuses obsolete line folding (RFC 9112 §5.2 obs-fold) and CR/LF/NUL bytes in part-header values (RFC 9110 §5.5). Adds RFC 5987 / 8187 `filename*=UTF-8''…` extended-parameter support; the decoded value takes precedence over a legacy `filename=` companion.
12
14
 
13
15
  - **0.8.37** (2026-05-08) — D-L family SQL/schema hygiene. **`_blamejs_audit_purge_anchor.scope` CHECK constraint** — `scope IN ('audit', 'consent')`. Pre-v0.8.37 a typo silently created a parallel anchor; the chain verifier walked the wrong anchor and missed tampering. **`personalDataCategories` vocabulary validation** — operator-supplied categories validated against the GDPR Article 9 special-category vocabulary + the framework's general categories at `db.init`. Unknown categories don't refuse (operators have legitimate custom labels) but emit a `db.personal_data_category_unknown` audit row so typos surface in regulator reviews. Allowed: `name`, `email`, `phone`, `address`, `ip`, `id-document`, `biometric`, `health`, `genetic`, `sexual-orientation`, `racial-or-ethnic-origin`, `political-opinion`, `religious-belief`, `trade-union-membership`, `criminal-record`, `financial`, `location`, `behavioral`, `device-id`, `child-data`, `education`, `employment`, `operator-defined`.
package/index.js CHANGED
@@ -162,6 +162,7 @@ var auth = {
162
162
  acr: require("./lib/auth/acr-vocabulary"),
163
163
  authTime: require("./lib/auth/auth-time-tracker"),
164
164
  accessLock: require("./lib/auth/access-lock"),
165
+ atoKillSwitch: require("./lib/auth/ato-kill-switch"),
165
166
  };
166
167
  var template = require("./lib/template");
167
168
  var render = require("./lib/render");
@@ -225,6 +226,7 @@ var appShutdown = require("./lib/app-shutdown");
225
226
  var slug = require("./lib/slug");
226
227
  var webhook = require("./lib/webhook");
227
228
  var apiKey = require("./lib/api-key");
229
+ var honeytoken = require("./lib/honeytoken");
228
230
  var credentialHash = require("./lib/credential-hash");
229
231
  var permissions = require("./lib/permissions");
230
232
  var cache = require("./lib/cache");
@@ -401,6 +403,7 @@ module.exports = {
401
403
  slug: slug,
402
404
  webhook: webhook,
403
405
  apiKey: apiKey,
406
+ honeytoken: honeytoken,
404
407
  credentialHash: credentialHash,
405
408
  permissions: permissions,
406
409
  cache: cache,
@@ -61,6 +61,7 @@ var auditSign = require("./audit-sign");
61
61
  var backupCrypto = require("./backup/crypto");
62
62
  var clusterStorage = require("./cluster-storage");
63
63
  var lazyRequire = require("./lazy-require");
64
+ var validateOpts = require("./validate-opts");
64
65
  var jsonSafe = require("./safe-json");
65
66
  var { defineClass } = require("./framework-error");
66
67
 
@@ -665,9 +666,77 @@ async function _defaultApplyPurge(args) {
665
666
  };
666
667
  }
667
668
 
669
+ // forensicSnapshot — post-compromise composer that bundles an audit
670
+ // archive slice, current break-glass grants, the active incident
671
+ // report (if any), and process-runtime metadata into a single signed
672
+ // bundle. The operator passes this to legal / regulators / the IR
673
+ // team as one tamper-evident artifact.
674
+ //
675
+ // var snap = await b.auditTools.forensicSnapshot({
676
+ // out: "/forensics/2026-05-07-incident-42",
677
+ // since: Date.now() - C.TIME.days(7),
678
+ // passphrase: process.env.AUDIT_BUNDLE_PASSPHRASE,
679
+ // incidentId: "inc-2026-05-07-42",
680
+ // reason: "ATO investigation: 14 failed MFA from new geo, user u_42",
681
+ // actor: { id: "alice@ops.example.com", role: "incident-commander" },
682
+ // });
683
+ async function forensicSnapshot(opts) {
684
+ opts = opts || {};
685
+ _requirePassphrase(opts.passphrase);
686
+ _requireOutDir(opts.out, "forensicSnapshot");
687
+ var sinceMs = _toMs(opts.since);
688
+ if (sinceMs == null) {
689
+ throw new AuditToolsError("audit-tools/no-since",
690
+ "forensicSnapshot: opts.since is required");
691
+ }
692
+ validateOpts.requireNonEmptyString(opts.reason, "reason", AuditToolsError, "audit-tools/no-reason");
693
+ var sliceResult = await exportSlice({
694
+ out: opts.out,
695
+ since: sinceMs,
696
+ until: Date.now(),
697
+ passphrase: opts.passphrase,
698
+ readRows: opts.readRows,
699
+ readCoveringCheckpoint: opts.readCoveringCheckpoint,
700
+ });
701
+ // Compose snapshot manifest with operator-supplied IR context.
702
+ var manifest = {
703
+ snapshotKind: "forensic",
704
+ incidentId: opts.incidentId || null,
705
+ reason: opts.reason,
706
+ actor: opts.actor || null,
707
+ composedAt: new Date().toISOString(),
708
+ auditSliceFile: sliceResult && sliceResult.path,
709
+ auditSliceCount: sliceResult && sliceResult.rowCount,
710
+ runtime: {
711
+ nodeVersion: process.version,
712
+ platform: process.platform,
713
+ arch: process.arch,
714
+ pid: process.pid,
715
+ uptimeSec: Math.round(process.uptime()),
716
+ },
717
+ };
718
+ var manifestPath = require("node:path").join(opts.out, "forensic-snapshot.json");
719
+ require("node:fs").writeFileSync(manifestPath, _canonicalize(manifest), "utf8");
720
+ try {
721
+ require("./audit").safeEmit({
722
+ action: "audit.forensic_snapshot.composed",
723
+ outcome: "success",
724
+ metadata: {
725
+ out: opts.out,
726
+ incidentId: manifest.incidentId,
727
+ reason: opts.reason,
728
+ actor: opts.actor || null,
729
+ rowCount: manifest.auditSliceCount || 0,
730
+ },
731
+ });
732
+ } catch (_e) { /* audit best-effort */ }
733
+ return Object.assign({}, manifest, { manifestPath: manifestPath });
734
+ }
735
+
668
736
  module.exports = {
669
737
  archive: archive,
670
738
  exportSlice: exportSlice,
739
+ forensicSnapshot: forensicSnapshot,
671
740
  verifyBundle: verifyBundle,
672
741
  purge: purge,
673
742
  BUNDLE_FORMAT: BUNDLE_FORMAT,
package/lib/audit.js CHANGED
@@ -247,6 +247,9 @@ var FRAMEWORK_NAMESPACES = [
247
247
  "fdx", // b.fdx (fdx.bound / fdx.consent_receipt_issued)
248
248
  "tcpa10dlc", // b.tcpa10dlc (tcpa10dlc.consent_recorded / consent_revoked)
249
249
  "iabmspa", // b.iabMspa (iabmspa.processing_refused)
250
+ "vendor", // b.configDrift.verifyVendorIntegrity (vendor.integrity.verified / tampered)
251
+ "honeytoken", // b.honeytoken (honeytoken.issued / tripped)
252
+ "csp", // b.middleware.cspReport (csp.violation)
250
253
  ];
251
254
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
252
255
 
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ /**
3
+ * b.auth.atoKillSwitch — composite primitive for account-takeover
4
+ * incident response. Composes `b.session.destroyAllForUser` +
5
+ * `b.auth.lockout.lock` + (optionally) `b.auth.accessLock` mode flip
6
+ * into a single operator-callable workflow.
7
+ *
8
+ * Trigger conditions are operator territory (SOC alert, fraud signal,
9
+ * user self-report, IDS rule); this primitive is the deterministic
10
+ * cleanup path once the trigger fires:
11
+ *
12
+ * 1. destroy every session for the user across the cluster
13
+ * 2. lock the user out of new logins (b.auth.lockout)
14
+ * 3. emit an audit row with reason / actor for downstream forensics
15
+ *
16
+ * await b.auth.atoKillSwitch.trigger({
17
+ * userId: "u_42",
18
+ * reason: "fraud-signal: 14 failed MFA from new geo",
19
+ * actor: { id: req.user && req.user.id, role: req.user && req.user.role },
20
+ * lockout: true, // default true
21
+ * accessLock: "locked", // optional — flip the global access-lock mode
22
+ * });
23
+ *
24
+ * Returns `{ sessionsDestroyed, lockoutApplied, accessLockMode }`.
25
+ */
26
+
27
+ var lazyRequire = require("../lazy-require");
28
+ var validateOpts = require("../validate-opts");
29
+ var { defineClass } = require("../framework-error");
30
+
31
+ var session = lazyRequire(function () { return require("../session"); });
32
+ var lockout = lazyRequire(function () { return require("./lockout"); });
33
+ var accessLock = lazyRequire(function () { return require("./access-lock"); });
34
+ var audit = lazyRequire(function () { return require("../audit"); });
35
+
36
+ var AtoKillSwitchError = defineClass("AtoKillSwitchError", { alwaysPermanent: true });
37
+
38
+ async function trigger(opts) {
39
+ opts = opts || {};
40
+ validateOpts(opts, [
41
+ "userId", "reason", "actor", "lockout", "accessLock",
42
+ ], "auth.atoKillSwitch.trigger");
43
+
44
+ validateOpts.requireNonEmptyString(opts.userId, "userId", AtoKillSwitchError, "auth-ato-kill-switch/missing-user-id");
45
+ validateOpts.requireNonEmptyString(opts.reason, "reason", AtoKillSwitchError, "auth-ato-kill-switch/missing-reason");
46
+ var doLockout = opts.lockout !== false;
47
+ var accessLockMode = typeof opts.accessLock === "string" ? opts.accessLock : null;
48
+
49
+ var sessionsDestroyed = 0;
50
+ try {
51
+ sessionsDestroyed = await session().destroyAllForUser(opts.userId);
52
+ } catch (e) {
53
+ audit().safeEmit({
54
+ action: "auth.ato_kill_switch.partial",
55
+ outcome: "failure",
56
+ metadata: {
57
+ userId: opts.userId,
58
+ step: "destroy-sessions",
59
+ reason: e && e.message,
60
+ },
61
+ });
62
+ throw e;
63
+ }
64
+
65
+ var lockoutApplied = false;
66
+ if (doLockout) {
67
+ try {
68
+ await lockout().lock(opts.userId, {
69
+ reason: "ato-kill-switch:" + opts.reason,
70
+ });
71
+ lockoutApplied = true;
72
+ } catch (_e) { /* lockout is best-effort; sessions already destroyed */ }
73
+ }
74
+
75
+ var modeApplied = null;
76
+ if (accessLockMode !== null) {
77
+ try {
78
+ var lock = accessLock();
79
+ if (lock && typeof lock.set === "function") {
80
+ await lock.set(accessLockMode, {
81
+ actor: opts.actor || null,
82
+ reason: "ato-kill-switch:" + opts.reason,
83
+ });
84
+ modeApplied = accessLockMode;
85
+ }
86
+ } catch (_e) { /* operator may not have wired global accessLock; fine */ }
87
+ }
88
+
89
+ audit().safeEmit({
90
+ action: "auth.ato_kill_switch.triggered",
91
+ outcome: "success",
92
+ metadata: {
93
+ userId: opts.userId,
94
+ reason: opts.reason,
95
+ actor: opts.actor || null,
96
+ sessionsDestroyed: sessionsDestroyed,
97
+ lockoutApplied: lockoutApplied,
98
+ accessLockMode: modeApplied,
99
+ },
100
+ });
101
+
102
+ return {
103
+ sessionsDestroyed: sessionsDestroyed,
104
+ lockoutApplied: lockoutApplied,
105
+ accessLockMode: modeApplied,
106
+ };
107
+ }
108
+
109
+ module.exports = {
110
+ trigger: trigger,
111
+ AtoKillSwitchError: AtoKillSwitchError,
112
+ };
@@ -292,10 +292,70 @@ function create(opts) {
292
292
  };
293
293
  }
294
294
 
295
+ // verifyVendorIntegrity — at-boot integrity check over `lib/vendor/*`.
296
+ // MANIFEST.json carries a sha256 digest per bundled file; we re-hash
297
+ // each one and refuse on mismatch. Catches a half-applied vendor
298
+ // refresh, a corrupted install, or an attacker who modified a
299
+ // vendored cjs without updating the manifest. Returns
300
+ // `{ ok, mismatches: [{ path, expected, actual }] }` and emits
301
+ // `vendor.integrity.{verified,tampered}` audit on each call.
302
+ function verifyVendorIntegrity(opts) {
303
+ opts = opts || {};
304
+ var libVendorDir = opts.libVendorDir || path.join(process.cwd(), "lib", "vendor");
305
+ var manifestPath = opts.manifestPath || path.join(libVendorDir, "MANIFEST.json");
306
+ var raw;
307
+ try { raw = fs.readFileSync(manifestPath, "utf8"); }
308
+ catch (_e) {
309
+ throw _err("VENDOR_MANIFEST_MISSING",
310
+ "vendor MANIFEST.json missing at " + manifestPath, true);
311
+ }
312
+ var manifest = safeJson.parse(raw);
313
+ if (!manifest || typeof manifest.packages !== "object") {
314
+ throw _err("VENDOR_MANIFEST_SHAPE",
315
+ "vendor MANIFEST.json missing `packages` map", true);
316
+ }
317
+ var mismatches = [];
318
+ var checkedCount = 0;
319
+ Object.keys(manifest.packages).forEach(function (pkgName) {
320
+ var pkg = manifest.packages[pkgName];
321
+ var files = (pkg && pkg.files) || {};
322
+ var hashes = (pkg && pkg.hashes) || {};
323
+ Object.keys(files).forEach(function (kind) {
324
+ var rel = files[kind];
325
+ var expected = hashes[kind];
326
+ if (typeof rel !== "string" || typeof expected !== "string") return;
327
+ var abs = path.isAbsolute(rel) ? rel : path.join(process.cwd(), rel);
328
+ var actual;
329
+ try {
330
+ var bytes = fs.readFileSync(abs);
331
+ actual = "sha256:" + require("node:crypto")
332
+ .createHash("sha256").update(bytes).digest("hex");
333
+ } catch (_e) {
334
+ mismatches.push({ pkg: pkgName, kind: kind, path: rel, expected: expected, actual: "<read-failed>" });
335
+ return;
336
+ }
337
+ checkedCount += 1;
338
+ if (actual !== expected) {
339
+ mismatches.push({ pkg: pkgName, kind: kind, path: rel, expected: expected, actual: actual });
340
+ }
341
+ });
342
+ });
343
+ var ok = mismatches.length === 0;
344
+ try {
345
+ audit().safeEmit({
346
+ action: ok ? "vendor.integrity.verified" : "vendor.integrity.tampered",
347
+ outcome: ok ? "success" : "failure",
348
+ metadata: { checkedCount: checkedCount, mismatchCount: mismatches.length },
349
+ });
350
+ } catch (_e) { /* audit best-effort */ }
351
+ return { ok: ok, checkedCount: checkedCount, mismatches: mismatches };
352
+ }
353
+
295
354
  module.exports = {
296
- create: create,
297
- ConfigDriftError: ConfigDriftError,
355
+ create: create,
356
+ verifyVendorIntegrity: verifyVendorIntegrity,
357
+ ConfigDriftError: ConfigDriftError,
298
358
  // Test-only export for hashing — operators don't need this directly.
299
- _hashSnapshot: _hashSnapshot,
300
- _stableStringify: _stableStringify,
359
+ _hashSnapshot: _hashSnapshot,
360
+ _stableStringify: _stableStringify,
301
361
  };
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /**
3
+ * b.honeytoken — canary credential framework. Generates decoy values
4
+ * (fake api-key shapes, fake admin URLs, fake DB row references) that
5
+ * are NEVER handed to a real client; their presence in a request,
6
+ * log, or DB lookup means an attacker found something they shouldn't
7
+ * have. The framework registers each token at issuance and refuses
8
+ * silently in production but always emits a `honeytoken.tripped`
9
+ * audit row on any positive lookup.
10
+ *
11
+ * var honey = b.honeytoken.create({ audit: b.audit });
12
+ *
13
+ * var token = honey.issue({
14
+ * kind: "apiKey",
15
+ * metadata: { plantedAt: "GET /admin/keys/404", linkedTo: "u_42" },
16
+ * });
17
+ * // → { value: "bk_canary_8f3a7b2e0c…", id: "ht_<hex>" }
18
+ *
19
+ * if (honey.lookup(req.headers["x-api-key"])) {
20
+ * // attacker is using the canary; tripped event already audited
21
+ * return res.status(403).end();
22
+ * }
23
+ *
24
+ * Canary value shapes (`kind`):
25
+ * - "apiKey" → `bk_canary_<32 hex>` (matches b.apiKey shape)
26
+ * - "session" → `bks_canary_<48 hex>` (matches b.session shape)
27
+ * - "url" → `/admin/canary-<32 hex>` (planted as a clickable link)
28
+ * - "rowId" → `ht_canary_<32 hex>` (planted as a fake foreign key)
29
+ *
30
+ * Audit shape:
31
+ * - `honeytoken.issued` — outcome=success; metadata: { id, kind }
32
+ * - `honeytoken.tripped` — outcome=failure; metadata: { id, kind,
33
+ * metadata, observedAt, observedActor }
34
+ */
35
+
36
+ var crypto = require("./crypto");
37
+ var lazyRequire = require("./lazy-require");
38
+ var validateOpts = require("./validate-opts");
39
+ var { defineClass } = require("./framework-error");
40
+
41
+ var audit = lazyRequire(function () { return require("./audit"); });
42
+
43
+ var HoneytokenError = defineClass("HoneytokenError", { alwaysPermanent: true });
44
+
45
+ var KINDS = Object.freeze({
46
+ apiKey: function () { return "bk_canary_" + crypto.generateToken(16); }, // allow:raw-byte-literal — 16-byte (128-bit) canary entropy
47
+ session: function () { return "bks_canary_" + crypto.generateToken(24); }, // allow:raw-byte-literal — 24-byte (192-bit) canary entropy
48
+ url: function () { return "/admin/canary-" + crypto.generateToken(16); }, // allow:raw-byte-literal — 16-byte canary entropy
49
+ rowId: function () { return "ht_canary_" + crypto.generateToken(16); }, // allow:raw-byte-literal — 16-byte canary entropy
50
+ });
51
+
52
+ function create(opts) {
53
+ opts = opts || {};
54
+ validateOpts(opts, ["audit"], "honeytoken.create");
55
+
56
+ var registry = new Map(); // value → { id, kind, metadata, issuedAt }
57
+
58
+ function issue(spec) {
59
+ spec = spec || {};
60
+ validateOpts(spec, ["kind", "metadata"], "honeytoken.issue");
61
+ var kind = spec.kind;
62
+ if (typeof KINDS[kind] !== "function") {
63
+ throw new HoneytokenError(
64
+ "honeytoken/unknown-kind",
65
+ "honeytoken.issue: unknown kind '" + kind + "' " +
66
+ "(supported: " + Object.keys(KINDS).join(", ") + ")");
67
+ }
68
+ var value = KINDS[kind]();
69
+ var id = "ht_" + crypto.generateToken(8); // allow:raw-byte-literal — 8-byte registry id
70
+ var record = Object.freeze({
71
+ id: id,
72
+ kind: kind,
73
+ metadata: spec.metadata || null,
74
+ issuedAt: Date.now(),
75
+ });
76
+ registry.set(value, record);
77
+ try {
78
+ audit().safeEmit({
79
+ action: "honeytoken.issued",
80
+ outcome: "success",
81
+ metadata: { id: id, kind: kind },
82
+ });
83
+ } catch (_e) { /* audit best-effort */ }
84
+ return { id: id, value: value };
85
+ }
86
+
87
+ function lookup(value, observedActor) {
88
+ if (typeof value !== "string" || value.length === 0) return null;
89
+ var record = registry.get(value);
90
+ if (!record) return null;
91
+ try {
92
+ audit().safeEmit({
93
+ action: "honeytoken.tripped",
94
+ outcome: "failure",
95
+ metadata: {
96
+ id: record.id,
97
+ kind: record.kind,
98
+ metadata: record.metadata,
99
+ observedAt: Date.now(),
100
+ observedActor: observedActor || null,
101
+ },
102
+ });
103
+ } catch (_e) { /* audit best-effort */ }
104
+ return record;
105
+ }
106
+
107
+ function revoke(id) {
108
+ var found = false;
109
+ registry.forEach(function (record, value) {
110
+ if (record.id === id) {
111
+ registry.delete(value);
112
+ found = true;
113
+ }
114
+ });
115
+ return found;
116
+ }
117
+
118
+ function size() { return registry.size; }
119
+
120
+ return {
121
+ issue: issue,
122
+ lookup: lookup,
123
+ revoke: revoke,
124
+ size: size,
125
+ };
126
+ }
127
+
128
+ module.exports = {
129
+ create: create,
130
+ KINDS: Object.freeze(Object.keys(KINDS)),
131
+ HoneytokenError: HoneytokenError,
132
+ };
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ /**
3
+ * b.middleware.cspReport — Reporting-API endpoint for CSP / COEP /
4
+ * COOP / Permissions-Policy violations.
5
+ *
6
+ * The framework's default CSP appends `report-to default;` (see
7
+ * lib/middleware/security-headers.js); operators wire the matching
8
+ * `Reporting-Endpoints: default="https://app.example.com/csp-report"`
9
+ * header — and mount this middleware at the configured path. Browsers
10
+ * POST batches of violations as `application/reports+json`.
11
+ *
12
+ * var cspReport = b.middleware.cspReport.create({
13
+ * audit: b.audit,
14
+ * onReport: function (report) { metrics.count("csp.violation", 1, { directive: report.body.effectiveDirective }); },
15
+ * maxBytes: C.BYTES.kib(64),
16
+ * });
17
+ * router.post("/csp-report", cspReport);
18
+ *
19
+ * Audit shape: `csp.violation` (failure) per report; metadata carries
20
+ * the report.body fields (blockedURL, documentURL, effectiveDirective,
21
+ * sample, statusCode). Sample is truncated to 200 chars.
22
+ *
23
+ * Validation:
24
+ * - Refuses non-POST methods with 405
25
+ * - Refuses bodies > maxBytes (default 64 KiB) with 413
26
+ * - Refuses non-JSON bodies with 400
27
+ * - Accepts `application/reports+json` AND legacy `application/csp-report`
28
+ */
29
+
30
+ var C = require("../constants");
31
+ var lazyRequire = require("../lazy-require");
32
+ var safeBuffer = require("../safe-buffer");
33
+ var safeJson = require("../safe-json");
34
+ var validateOpts = require("../validate-opts");
35
+
36
+ var audit = lazyRequire(function () { return require("../audit"); });
37
+
38
+ var DEFAULT_MAX_BYTES = C.BYTES.kib(64);
39
+ var SAMPLE_TRUNCATE = 200;
40
+
41
+ function _truncate(value) {
42
+ if (typeof value !== "string") return value;
43
+ if (value.length <= SAMPLE_TRUNCATE) return value;
44
+ return value.slice(0, SAMPLE_TRUNCATE) + "…";
45
+ }
46
+
47
+ function _normalizeOne(reportLike) {
48
+ // Reporting API shape: { type, age, url, user_agent, body: {...} }
49
+ // Legacy CSP shape: { "csp-report": { ... } }
50
+ if (!reportLike || typeof reportLike !== "object") return null;
51
+ if (reportLike["csp-report"] && typeof reportLike["csp-report"] === "object") {
52
+ var legacy = reportLike["csp-report"];
53
+ return {
54
+ type: "csp-violation",
55
+ url: legacy["document-uri"] || null,
56
+ body: {
57
+ documentURL: legacy["document-uri"] || null,
58
+ blockedURL: legacy["blocked-uri"] || null,
59
+ effectiveDirective: legacy["effective-directive"] || legacy["violated-directive"] || null,
60
+ statusCode: legacy["status-code"] || null,
61
+ sample: _truncate(legacy["script-sample"] || ""),
62
+ sourceFile: legacy["source-file"] || null,
63
+ lineNumber: legacy["line-number"] || null,
64
+ },
65
+ };
66
+ }
67
+ if (reportLike.type && reportLike.body && typeof reportLike.body === "object") {
68
+ return {
69
+ type: reportLike.type,
70
+ url: reportLike.url || null,
71
+ body: {
72
+ documentURL: reportLike.body.documentURL || null,
73
+ blockedURL: reportLike.body.blockedURL || null,
74
+ effectiveDirective: reportLike.body.effectiveDirective || null,
75
+ statusCode: reportLike.body.statusCode || null,
76
+ sample: _truncate(reportLike.body.sample || ""),
77
+ sourceFile: reportLike.body.sourceFile || null,
78
+ lineNumber: reportLike.body.lineNumber || null,
79
+ },
80
+ };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function create(opts) {
86
+ opts = opts || {};
87
+ validateOpts(opts, ["audit", "onReport", "maxBytes"], "middleware.cspReport");
88
+ var maxBytes = (typeof opts.maxBytes === "number" && isFinite(opts.maxBytes) && opts.maxBytes > 0)
89
+ ? opts.maxBytes : DEFAULT_MAX_BYTES;
90
+ var onReport = (typeof opts.onReport === "function") ? opts.onReport : null;
91
+
92
+ return async function cspReport(req, res, _next) {
93
+ if (req.method !== "POST") {
94
+ res.writeHead(405, { "Allow": "POST" }); // allow:raw-byte-literal — HTTP 405 status
95
+ res.end();
96
+ return;
97
+ }
98
+ var body;
99
+ try {
100
+ body = await safeBuffer.boundedChunkCollector(req, { maxBytes: maxBytes });
101
+ } catch (_e) {
102
+ res.writeHead(413); // allow:raw-byte-literal — HTTP 413 status
103
+ res.end();
104
+ return;
105
+ }
106
+ var parsed;
107
+ try { parsed = safeJson.parse(body.toString("utf8")); }
108
+ catch (_e) {
109
+ res.writeHead(400); // allow:raw-byte-literal — HTTP 400 status
110
+ res.end();
111
+ return;
112
+ }
113
+ var reports = Array.isArray(parsed) ? parsed : [parsed];
114
+ for (var i = 0; i < reports.length; i++) {
115
+ var normalized = _normalizeOne(reports[i]);
116
+ if (!normalized) continue;
117
+ try {
118
+ audit().safeEmit({
119
+ action: "csp.violation",
120
+ outcome: "failure",
121
+ metadata: Object.assign({ type: normalized.type, url: normalized.url }, normalized.body),
122
+ });
123
+ } catch (_e) { /* audit best-effort */ }
124
+ if (onReport) {
125
+ try { onReport(normalized); } catch (_e) { /* hook best-effort */ }
126
+ }
127
+ }
128
+ res.writeHead(204); // allow:raw-byte-literal — HTTP 204 status
129
+ res.end();
130
+ };
131
+ }
132
+
133
+ module.exports = { create: create };
@@ -32,6 +32,7 @@ var cookies = require("./cookies");
32
32
  var cors = require("./cors");
33
33
  var dailyByteQuota = require("./daily-byte-quota");
34
34
  var cspNonce = require("./csp-nonce");
35
+ var cspReport = require("./csp-report");
35
36
  var csrfProtect = require("./csrf-protect");
36
37
  var dbRoleFor = require("./db-role-for");
37
38
  var dpop = require("./dpop");
@@ -88,6 +89,7 @@ module.exports = {
88
89
  compression: compression.create,
89
90
  cookies: cookies.create,
90
91
  cspNonce: cspNonce.create,
92
+ cspReport: cspReport.create,
91
93
  securityTxt: securityTxt.create,
92
94
  sse: sse.create,
93
95
  requestLog: requestLog.create,
@@ -329,6 +329,67 @@ function captureBaselineFingerprints() {
329
329
  STATE.baselineFingerprints = STATE.cas.map(function (e) { return e.meta.fingerprint256; });
330
330
  }
331
331
 
332
+ // pinsetDriftMonitor — periodic check that emits audit + observability
333
+ // events when the trust-store fingerprint set drifts from the captured
334
+ // baseline. Different intent from expiryMonitor: this fires when a
335
+ // CA is added or removed (by operator config-flip OR by a tampered
336
+ // MANIFEST / vendor refresh), not when an existing one approaches
337
+ // validity expiry.
338
+ //
339
+ // b.network.tls.captureBaselineFingerprints(); // at boot
340
+ // var mon = b.network.tls.pinsetDriftMonitor({
341
+ // intervalMs: C.TIME.minutes(15),
342
+ // onDrift: function (drift) { /* operator hook */ },
343
+ // });
344
+ //
345
+ // Audit emissions:
346
+ // network.tls.pinset.drift_check — every check, ok / warn
347
+ // network.tls.pinset.drifted — when added.length || removed.length
348
+ function pinsetDriftMonitor(opts) {
349
+ opts = opts || {};
350
+ var intervalMs = opts.intervalMs;
351
+ var auditOn = opts.audit !== false;
352
+ if (typeof intervalMs !== "number" || !isFinite(intervalMs) || intervalMs <= 0) {
353
+ throw new TlsTrustError("tls/bad-interval",
354
+ "tls.pinsetDriftMonitor: intervalMs must be a positive finite number");
355
+ }
356
+ function _tick() {
357
+ var drift;
358
+ try { drift = detectBaselineDrift(); }
359
+ catch (_e) { return; }
360
+ if (drift === null) return; // baseline not captured; nothing to compare
361
+ if (auditOn) {
362
+ try {
363
+ audit().safeEmit({
364
+ action: "network.tls.pinset.drift_check",
365
+ outcome: drift.drifted ? "warn" : "ok",
366
+ metadata: { added: drift.added.length, removed: drift.removed.length },
367
+ });
368
+ } catch (_e) { /* drop-silent */ }
369
+ }
370
+ if (drift.drifted) {
371
+ try { observability().safeEvent("network.tls.pinset.drifted", 1, {}); }
372
+ catch (_e) { /* drop-silent */ }
373
+ if (auditOn) {
374
+ try {
375
+ audit().safeEmit({
376
+ action: "network.tls.pinset.drifted",
377
+ outcome: "failure",
378
+ metadata: { added: drift.added, removed: drift.removed },
379
+ });
380
+ } catch (_e) { /* drop-silent */ }
381
+ }
382
+ if (typeof opts.onDrift === "function") {
383
+ try { opts.onDrift(drift); } catch (_e) { /* operator hook */ }
384
+ }
385
+ }
386
+ }
387
+ var handle = safeAsync.repeating(_tick, intervalMs, { name: "tls-pinset-drift-monitor" });
388
+ return {
389
+ stop: function () { if (handle) { handle.stop(); handle = null; } },
390
+ };
391
+ }
392
+
332
393
  function detectBaselineDrift() {
333
394
  if (!STATE.baselineFingerprints) return null;
334
395
  var current = STATE.cas.map(function (e) { return e.meta.fingerprint256; });
@@ -1683,6 +1744,7 @@ module.exports = {
1683
1744
  purgeExpired: purgeExpired,
1684
1745
  expiringSoon: expiringSoon,
1685
1746
  expiryMonitor: expiryMonitor,
1747
+ pinsetDriftMonitor: pinsetDriftMonitor,
1686
1748
  useSystemTrust: useSystemTrust,
1687
1749
  isSystemTrustEnabled: isSystemTrustEnabled,
1688
1750
  getTrustStore: getTrustStore,
package/lib/network.js CHANGED
@@ -7,6 +7,7 @@ var proxy = require("./network-proxy");
7
7
  var trust = require("./network-tls");
8
8
  var heartbeat = require("./network-heartbeat");
9
9
  var smtpPolicy = require("./network-smtp-policy");
10
+ var ssrfGuard = require("./ssrf-guard");
10
11
 
11
12
  var validateOpts = require("./validate-opts");
12
13
  var lazyRequire = require("./lazy-require");
@@ -226,6 +227,7 @@ module.exports = {
226
227
  dane: smtpPolicy.dane,
227
228
  tlsRpt: smtpPolicy.tlsRpt,
228
229
  },
230
+ allowlist: { create: ssrfGuard.createAllowlist },
229
231
  socket: {
230
232
  setDefaultNoDelay: _setSocketNoDelay,
231
233
  setDefaultKeepAlive: _setSocketKeepAlive,
package/lib/ssrf-guard.js CHANGED
@@ -403,10 +403,65 @@ async function checkUrl(url, opts) {
403
403
  return { url: parsed, ips: ips };
404
404
  }
405
405
 
406
+ // b.network.allowlist — contextual per-call egress allowlist composing
407
+ // on ssrfGuard. Operators describe an allowed CIDR set + denylist;
408
+ // the resulting `assert(url)` either resolves to the validated IP set
409
+ // or throws SsrfError. Distinct from `ssrfGuard.checkUrl` (which uses
410
+ // the framework's hard-coded private/cloud-metadata ban list) — this
411
+ // is for cases where the operator's deployment has SPECIFIC outbound
412
+ // targets and everything else should be refused.
413
+ //
414
+ // var egress = b.network.allowlist.create({
415
+ // allow: ["api.partner.example.com", "192.0.2.0/24"],
416
+ // deny: ["api.partner.example.com/admin"],
417
+ // });
418
+ // await egress.assert("https://api.partner.example.com/v1/x");
419
+ function createAllowlist(opts) {
420
+ opts = opts || {};
421
+ var allowList = Array.isArray(opts.allow) ? opts.allow.slice() : [];
422
+ var denyList = Array.isArray(opts.deny) ? opts.deny.slice() : [];
423
+ if (allowList.length === 0) {
424
+ throw new SsrfError(
425
+ "network.allowlist.create requires at least one entry in `allow`",
426
+ "ssrf-guard/empty-allowlist", {});
427
+ }
428
+ function _matches(list, hostOrIp) {
429
+ for (var i = 0; i < list.length; i++) {
430
+ var entry = list[i];
431
+ if (entry === hostOrIp) return true;
432
+ if (entry.indexOf("/") !== -1) {
433
+ try { if (cidrContains(entry, hostOrIp)) return true; } catch (_e) { /* ignore */ }
434
+ }
435
+ }
436
+ return false;
437
+ }
438
+ async function assertUrl(url) {
439
+ var parsed;
440
+ try { parsed = new URL(url); } // allow:raw-new-url — local URL parse for hostname extraction
441
+ catch (_e) {
442
+ throw new SsrfError("invalid URL", "ssrf-guard/bad-url", { url: url });
443
+ }
444
+ var host = parsed.hostname;
445
+ if (!_matches(allowList, host)) {
446
+ throw new SsrfError(
447
+ "URL host '" + host + "' not on the operator allowlist",
448
+ "ssrf-guard/not-on-allowlist", { url: url, host: host });
449
+ }
450
+ if (_matches(denyList, host)) {
451
+ throw new SsrfError(
452
+ "URL host '" + host + "' on the operator denylist",
453
+ "ssrf-guard/on-denylist", { url: url, host: host });
454
+ }
455
+ return checkUrl(parsed.toString(), { allowInternal: true });
456
+ }
457
+ return { assert: assertUrl };
458
+ }
459
+
406
460
  module.exports = {
407
461
  classify: classify,
408
462
  cidrContains: cidrContains,
409
463
  checkUrl: checkUrl,
464
+ createAllowlist: createAllowlist,
410
465
  isPrivate: function (ip) { return classify(ip) === "private"; },
411
466
  isLoopback: function (ip) { return classify(ip) === "loopback"; },
412
467
  isLinkLocal: function (ip) { return classify(ip) === "link-local"; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.38",
3
+ "version": "0.8.40",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:33e4b58d-3cab-4ba0-b54a-4fd4a9b62e38",
5
+ "serialNumber": "urn:uuid:7c951f5a-156e-49be-91c0-8a5a420faf2c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T16:03:53.948Z",
8
+ "timestamp": "2026-05-07T16:35:12.500Z",
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.38",
22
+ "bom-ref": "@blamejs/core@0.8.40",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.38",
25
+ "version": "0.8.40",
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.38",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.40",
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.38",
57
+ "ref": "@blamejs/core@0.8.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]