@blamejs/core 0.8.38 → 0.8.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 CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - 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
12
  - 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
13
 
13
14
  - **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");
package/lib/audit.js CHANGED
@@ -247,6 +247,7 @@ 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)
250
251
  ];
251
252
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
252
253
 
@@ -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
  };
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.39",
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:319bfdfd-c9b3-4f86-a42e-c3441466295e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T16:03:53.948Z",
8
+ "timestamp": "2026-05-07T16:17:54.954Z",
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.39",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.38",
25
+ "version": "0.8.39",
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.39",
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.39",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]