@blamejs/core 0.8.18 → 0.8.25

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
Binary file
package/index.js CHANGED
@@ -100,6 +100,8 @@ var graphqlFederation = require("./lib/graphql-federation");
100
100
  var aiInput = require("./lib/ai-input");
101
101
  var a2a = require("./lib/a2a");
102
102
  var darkPatterns = require("./lib/dark-patterns");
103
+ var budr = require("./lib/budr");
104
+ var secCyber = require("./lib/sec-cyber");
103
105
  var safeUrl = require("./lib/safe-url");
104
106
  var safeRedirect = require("./lib/safe-redirect");
105
107
  var pick = require("./lib/pick");
@@ -287,6 +289,8 @@ module.exports = {
287
289
  graphqlFederation: graphqlFederation,
288
290
  a2a: a2a,
289
291
  darkPatterns: darkPatterns,
292
+ budr: budr,
293
+ secCyber: secCyber,
290
294
  safeUrl: safeUrl,
291
295
  safeRedirect: safeRedirect,
292
296
  pick: pick,
package/lib/audit.js CHANGED
@@ -238,6 +238,8 @@ var FRAMEWORK_NAMESPACES = [
238
238
  "aiinput", // b.ai.input.classify (aiInput.classify)
239
239
  "a2a", // b.a2a (a2a.card_signed / verified / rejected)
240
240
  "darkpatterns", // b.darkPatterns (darkPatterns.attest / cancel-blocked)
241
+ "budr", // b.budr (budr.declared)
242
+ "seccyber", // b.secCyber (seccyber.eight_k_artifact)
241
243
  ];
242
244
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
243
245
 
package/lib/budr.js ADDED
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+ /**
3
+ * b.budr — backup, disaster-recovery, RTO/RPO declaration primitive.
4
+ *
5
+ * Operators in regulated environments (HIPAA / DORA / ISO 22301:2019 /
6
+ * NIST SP 800-34) must declare their Recovery Time Objective (RTO,
7
+ * how long systems can be down before unacceptable impact) and
8
+ * Recovery Point Objective (RPO, max acceptable data loss). The
9
+ * declaration is auditor-facing — regulators want it on file as part
10
+ * of business-continuity / disaster-recovery documentation.
11
+ *
12
+ * The framework can't enforce RTO/RPO end-to-end (those depend on
13
+ * downstream backup cadence, replication topology, restore testing).
14
+ * What it can do: capture the operator's declared targets in a
15
+ * tamper-evident audit row + expose them to dashboards.
16
+ *
17
+ * Public API:
18
+ *
19
+ * b.budr.declare(opts) -> declaration
20
+ * opts:
21
+ * service: operator-named service identifier (string).
22
+ * rtoMs: Recovery Time Objective in milliseconds.
23
+ * rpoMs: Recovery Point Objective in milliseconds.
24
+ * tier: "platinum" / "gold" / "silver" / "bronze"
25
+ * (BCDR criticality classification — platinum
26
+ * most-critical).
27
+ * criticality: "critical" / "high" / "medium" / "low".
28
+ * owner: operator-named accountable owner (team / role).
29
+ * reviewedAt: timestamp of the most recent operator review.
30
+ * citations: array of regulatory citations (e.g. ["dora-art-11", "iso-22301:2019"]).
31
+ * audit: bool, default true.
32
+ *
33
+ * b.budr.list() -> Array<declaration>
34
+ *
35
+ * b.budr.get(service) -> declaration | null
36
+ */
37
+
38
+ var nb = require("./numeric-bounds");
39
+ var validateOpts = require("./validate-opts");
40
+ var audit = require("./audit");
41
+ var { defineClass } = require("./framework-error");
42
+ var BudrError = defineClass("BudrError", { alwaysPermanent: true });
43
+
44
+ var SERVICE_MAX = 128; // allow:raw-byte-literal — string-length cap, not bytes
45
+ var SERVICE_RE = /^[a-zA-Z0-9._:/-]{1,128}$/; // allow:raw-byte-literal — string-length cap; not bytes
46
+ var TIERS = ["platinum", "gold", "silver", "bronze"];
47
+ var CRITICALITIES = ["critical", "high", "medium", "low"];
48
+
49
+ var declarations = new Map();
50
+
51
+ function declare(opts) {
52
+ if (!opts || typeof opts !== "object") {
53
+ throw BudrError.factory("BAD_OPTS", "budr.declare: opts required");
54
+ }
55
+ if (typeof opts.service !== "string" || opts.service.length === 0 ||
56
+ opts.service.length > SERVICE_MAX || !SERVICE_RE.test(opts.service)) {
57
+ throw BudrError.factory("BAD_SERVICE",
58
+ "budr.declare: service must match " + SERVICE_RE);
59
+ }
60
+ nb.requirePositiveFiniteIntIfPresent(opts.rtoMs, "budr.declare: rtoMs", BudrError, "BAD_RTO");
61
+ nb.requirePositiveFiniteIntIfPresent(opts.rpoMs, "budr.declare: rpoMs", BudrError, "BAD_RPO");
62
+ if (typeof opts.rtoMs !== "number" || typeof opts.rpoMs !== "number") {
63
+ throw BudrError.factory("BAD_TARGETS",
64
+ "budr.declare: rtoMs and rpoMs are required positive integer milliseconds");
65
+ }
66
+ if (opts.tier !== undefined && TIERS.indexOf(opts.tier) === -1) {
67
+ throw BudrError.factory("BAD_TIER",
68
+ "budr.declare: tier must be one of " + TIERS.join(", "));
69
+ }
70
+ if (opts.criticality !== undefined && CRITICALITIES.indexOf(opts.criticality) === -1) {
71
+ throw BudrError.factory("BAD_CRITICALITY",
72
+ "budr.declare: criticality must be one of " + CRITICALITIES.join(", "));
73
+ }
74
+ validateOpts.optionalNonEmptyString(opts.owner,
75
+ "budr.declare: owner", BudrError, "BAD_OWNER");
76
+ if (opts.citations !== undefined && !Array.isArray(opts.citations)) {
77
+ throw BudrError.factory("BAD_CITATIONS",
78
+ "budr.declare: citations must be an array of strings");
79
+ }
80
+
81
+ var declaration = Object.freeze({
82
+ service: opts.service,
83
+ rtoMs: opts.rtoMs,
84
+ rpoMs: opts.rpoMs,
85
+ tier: opts.tier || null,
86
+ criticality: opts.criticality || null,
87
+ owner: opts.owner || null,
88
+ citations: Array.isArray(opts.citations) ? opts.citations.slice() : [],
89
+ declaredAt: Date.now(),
90
+ reviewedAt: typeof opts.reviewedAt === "number" ? opts.reviewedAt : Date.now(),
91
+ });
92
+ declarations.set(opts.service, declaration);
93
+
94
+ if (opts.audit !== false) {
95
+ audit.safeEmit({
96
+ action: "budr.declared",
97
+ outcome: "success",
98
+ metadata: {
99
+ service: declaration.service,
100
+ rtoMs: declaration.rtoMs,
101
+ rpoMs: declaration.rpoMs,
102
+ tier: declaration.tier,
103
+ criticality: declaration.criticality,
104
+ owner: declaration.owner,
105
+ citations: declaration.citations,
106
+ },
107
+ });
108
+ }
109
+ return declaration;
110
+ }
111
+
112
+ function get(service) {
113
+ if (typeof service !== "string") return null;
114
+ var rec = declarations.get(service);
115
+ return rec === undefined ? null : rec;
116
+ }
117
+
118
+ function list() {
119
+ return Array.from(declarations.values());
120
+ }
121
+
122
+ function _resetForTest() { declarations.clear(); }
123
+
124
+ module.exports = {
125
+ declare: declare,
126
+ get: get,
127
+ list: list,
128
+ TIERS: TIERS.slice(),
129
+ CRITICALITIES: CRITICALITIES.slice(),
130
+ BudrError: BudrError,
131
+ _resetForTest: _resetForTest,
132
+ };
package/lib/compliance.js CHANGED
@@ -63,6 +63,28 @@ var KNOWN_POSTURES = Object.freeze([
63
63
  // ---- Canada / UK ----
64
64
  "pipeda-ca", // Canada Personal Information Protection and Electronic Documents Act (added 2026)
65
65
  "uk-gdpr", // UK General Data Protection Regulation (added 2026)
66
+ // ---- Sectoral expansions (added 2026 — v0.8.24) ----
67
+ "fapi-2.0", // Financial-grade API 2.0 Final (composes PAR + DPoP + OAuth 2.1 + mTLS)
68
+ "cfpb-1033", // CFPB §1033 / FDX consumer-financial-data sharing (deadline past for $250B+ banks 2026-04-01)
69
+ "iab-tcf-v2.3", // IAB Transparency & Consent Framework v2.3 with disclosedVendors (deadline past 2026-02-28)
70
+ "iab-mspa", // IAB Multi-State Privacy Agreement / Global Privacy Platform universal opt-out
71
+ "tcpa-10dlc", // TCPA 10DLC carrier-shaped consent + FCC 1:1 disclosure
72
+ "fda-21cfr11", // FDA 21 CFR Part 11 — audit-trail + electronic signatures (general-purpose subset)
73
+ "fda-annex-11", // EU GMP Annex 11 — computerized systems (Part-11 equivalent)
74
+ "sec-1.05", // SEC Cybersecurity Disclosure Item 1.05 — material-incident 8-K filing // allow:raw-byte-literal — regulatory identifier, not bytes
75
+ // ---- US state student-data privacy (F5.1 posture group) ----
76
+ "ny-2-d", // NY Education Law §2-d
77
+ "il-soppa", // Illinois Student Online Personal Protection Act
78
+ "ca-sopipa", // California Student Online Personal Information Protection Act
79
+ "ct-pa-5-2", // Connecticut Public Act 5-2
80
+ "tx-hb-4504", // Texas HB 4504 // allow:raw-byte-literal — statute identifier, not bytes
81
+ "va-sb-1376", // Virginia SB 1376 // allow:raw-byte-literal — statute identifier, not bytes
82
+ // ---- EU government / cloud-region ----
83
+ "staterramp", // StateRAMP / TX-RAMP / AZ-RAMP / GovRAMP family (FedRAMP-Moderate cross-walks)
84
+ "irap", // Australia IRAP / Essential Eight / ISM
85
+ "bsi-c5", // Germany BSI C5
86
+ "ens-es", // Spain Esquema Nacional de Seguridad
87
+ "uk-g-cloud", // UK G-Cloud
66
88
  ]);
67
89
 
68
90
  var STATE = { posture: null, setAt: null };
@@ -106,6 +128,22 @@ function set(posture) {
106
128
  STATE.posture = posture;
107
129
  STATE.setAt = Date.now();
108
130
  _emitAudit("compliance.posture.set", { posture: posture });
131
+ // F-AUD-5 — TZ awareness. Auditors expect timestamps in UTC.
132
+ // process.env.TZ controls Node's local-time conversion for any
133
+ // operator code that uses non-UTC formatters; under regulated
134
+ // postures (hipaa / pci-dss / sox / gdpr / soc2) emit a boot
135
+ // warning if it's set to a non-UTC value or unset (which means
136
+ // host-default which on most cloud images IS UTC but isn't
137
+ // guaranteed). Pure signal — no behavior change.
138
+ var REGULATED = ["hipaa", "pci-dss", "sox", "gdpr", "soc2", "fda-21cfr11"];
139
+ if (REGULATED.indexOf(posture) !== -1) {
140
+ var tz = process.env.TZ; // allow:raw-process-env — bootstrap signal, no operator-supplied default needed
141
+ if (typeof tz === "string" && tz !== "UTC" && tz !== "Etc/UTC") {
142
+ _emitAudit("compliance.posture.tz_warning",
143
+ { posture: posture, tz: tz, recommendation: "Set TZ=UTC under regulated postures so audit timestamps align with regulator expectations." },
144
+ "warning");
145
+ }
146
+ }
109
147
  }
110
148
 
111
149
  function current() {
package/lib/crypto.js CHANGED
@@ -160,6 +160,13 @@ function verify(data, signature, publicKeyPem) {
160
160
  return nodeCrypto.verify(null, Buffer.from(data), publicKeyPem, signature);
161
161
  }
162
162
 
163
+ // Track whether the hybrid-disabled audit has been emitted at least
164
+ // once per process, so a high-volume KEM-only deployment doesn't peg
165
+ // the audit bus with one event per encrypt() call. Operators who want
166
+ // the per-call signal can call encryptMlkemOnly directly (which never
167
+ // emits) or read the metric at b.metrics — the count is preserved.
168
+ var _hybridDisabledAuditEmitted = false;
169
+
163
170
  // ---- Envelope encrypt (ML-KEM-1024 + P-384 ECDH hybrid + SHAKE256 + XChaCha20) ----
164
171
  function encrypt(plaintext, publicKeys) {
165
172
  var mlkemPubPem = typeof publicKeys === "string" ? publicKeys : publicKeys.publicKey;
@@ -168,20 +175,23 @@ function encrypt(plaintext, publicKeys) {
168
175
  // Operator passed only an ML-KEM public key — silently dropping
169
176
  // the P-384 hybrid leg means the operator's defense-in-depth
170
177
  // posture (classical ECDH backstop on top of PQC KEM) is gone
171
- // without any signal. Emit on next tick (crypto must not import
172
- // audit synchronously audit imports crypto for chain hashing).
173
- // Operators who genuinely want KEM-only should call
178
+ // without any signal. Audit ONCE per process (M2 audit-dedup
179
+ // pre-v0.8.22 every plain-KEM call emitted, pegging the audit
180
+ // bus). Operators who genuinely want KEM-only should call
174
181
  // encryptMlkemOnly explicitly so this audit doesn't fire.
175
- setImmediate(function () {
176
- try {
177
- var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports crypto)
178
- auditMod.safeEmit({
179
- action: "system.crypto.hybrid_disabled",
180
- outcome: "success",
181
- metadata: { reason: "no-ec-public-key", note: "encrypt() received only mlkem; ecPublicKey absent — call encryptMlkemOnly explicitly to silence" },
182
- });
183
- } catch (_e) { /* drop-silentbest-effort */ }
184
- });
182
+ if (!_hybridDisabledAuditEmitted) {
183
+ _hybridDisabledAuditEmitted = true;
184
+ setImmediate(function () {
185
+ try {
186
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports crypto)
187
+ auditMod.safeEmit({
188
+ action: "system.crypto.hybrid_disabled",
189
+ outcome: "success",
190
+ metadata: { reason: "no-ec-public-key", note: "encrypt() received only mlkem; ecPublicKey absent call encryptMlkemOnly explicitly to silence (audited once per process)" },
191
+ });
192
+ } catch (_e) { /* drop-silent — best-effort */ }
193
+ });
194
+ }
185
195
  return encryptMlkemOnly(plaintext, mlkemPubPem);
186
196
  }
187
197
 
package/lib/db.js CHANGED
@@ -704,6 +704,20 @@ async function init(opts) {
704
704
  // delete, which the framework's audit-and-DSR-erase path already
705
705
  // dominates with audit-chain emissions and cascade fan-out.
706
706
  runSql(database, "PRAGMA secure_delete=ON");
707
+ // PRAGMA trusted_schema=OFF — refuses to call functions / virtual-
708
+ // table modules referenced from a malicious shadow schema. Defends
709
+ // the CVE-2018-8740 family where an attacker who can write to the
710
+ // database file (backups, logs, restore-from-untrusted) plants
711
+ // schema entries that fire on next access.
712
+ try { runSql(database, "PRAGMA trusted_schema=OFF"); } catch (_e) { /* sqlite < 3.31 */ }
713
+ // PRAGMA cell_size_check=ON — refuses pages with corrupted cell
714
+ // sizes at parse time rather than crashing later. Cheap defense
715
+ // against malformed-page attacks.
716
+ try { runSql(database, "PRAGMA cell_size_check=ON"); } catch (_e) { /* sqlite < 3.26 */ }
717
+ // node:sqlite does not expose loadExtension at all — extensions must
718
+ // be statically linked into the runtime. The framework's surface is
719
+ // therefore implicitly extension-free; no runtime defense is needed
720
+ // beyond the trusted_schema + cell_size_check PRAGMAs above.
707
721
 
708
722
  // Boot-time integrity check — refuse to boot on B-tree corruption.
709
723
  // SQLite normally surfaces corruption only when a query stumbles on
@@ -1002,9 +1016,52 @@ function stream(sql) {
1002
1016
  });
1003
1017
  }
1004
1018
 
1019
+ // DDL_RE — case-insensitive prefix match for the eight statement
1020
+ // shapes that MUTATE schema. Audited individually so a forensic
1021
+ // review can reconstruct schema evolution from the chain alone (D-M1).
1022
+ var DDL_RE = /^\s*(CREATE|DROP|ALTER|TRUNCATE|RENAME|ATTACH|DETACH|REINDEX)\b/i;
1023
+
1005
1024
  function execRaw(sql) {
1006
1025
  _requireInit();
1007
- return runSql(database, sql);
1026
+ var startedAt = Date.now();
1027
+ var auditMod = (function () { try { return require("./audit"); } catch (_e) { return null; } })(); // allow:inline-require — circular-load defense (audit imports db)
1028
+ // DDL_RE only matches the leading keyword — bounded by `/\s*(KEYWORD)\b/`
1029
+ // so the test is constant-time regardless of the rest of the query.
1030
+ var isDdl = typeof sql === "string" && DDL_RE.test(sql); // allow:regex-no-length-cap — leading-keyword anchor; constant-time test
1031
+ try {
1032
+ var result = runSql(database, sql);
1033
+ if (isDdl && auditMod) {
1034
+ auditMod.safeEmit({
1035
+ action: "db.ddl.executed",
1036
+ outcome: "success",
1037
+ metadata: {
1038
+ // OTel db.* semconv (F-RFC-4) — emit framework-conventional
1039
+ // attributes alongside the audit row so dashboards built on
1040
+ // OTel can correlate without an adapter.
1041
+ "db.system": "sqlite",
1042
+ "db.operation": String(sql).match(DDL_RE)[1].toUpperCase(),
1043
+ "db.statement": String(sql).slice(0, 256), // allow:raw-byte-literal — log-truncation length, not bytes
1044
+ durationMs: Date.now() - startedAt,
1045
+ },
1046
+ });
1047
+ }
1048
+ return result;
1049
+ } catch (e) {
1050
+ if (isDdl && auditMod) {
1051
+ auditMod.safeEmit({
1052
+ action: "db.ddl.executed",
1053
+ outcome: "failure",
1054
+ reason: (e && e.message) || String(e),
1055
+ metadata: {
1056
+ "db.system": "sqlite",
1057
+ "db.operation": String(sql).match(DDL_RE)[1].toUpperCase(),
1058
+ "db.statement": String(sql).slice(0, 256), // allow:raw-byte-literal — log-truncation length, not bytes
1059
+ durationMs: Date.now() - startedAt,
1060
+ },
1061
+ });
1062
+ }
1063
+ throw e;
1064
+ }
1008
1065
  }
1009
1066
 
1010
1067
  function transaction(fn) {
@@ -146,7 +146,7 @@ async function _acquireLock(xdb, opts) {
146
146
  "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
147
147
  [nowMs, holder]
148
148
  );
149
- return holder;
149
+ return { holder: holder, takeoverFrom: null, takeoverAgeMs: 0 };
150
150
  } catch (_e) {
151
151
  // PRIMARY KEY conflict → existing lock. Inspect it.
152
152
  var existingRes = await xdb.query(
@@ -160,7 +160,7 @@ async function _acquireLock(xdb, opts) {
160
160
  "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
161
161
  [nowMs, holder]
162
162
  );
163
- return holder;
163
+ return { holder: holder, takeoverFrom: null, takeoverAgeMs: 0 };
164
164
  } catch (e2) {
165
165
  throw _err("externaldb-migrate/lock-busy",
166
166
  "could not acquire migration lock: " + ((e2 && e2.message) || String(e2)));
@@ -168,7 +168,9 @@ async function _acquireLock(xdb, opts) {
168
168
  }
169
169
  var ageMs = nowMs - Number(existing.lockedat || existing.lockedAt);
170
170
  if (staleAfterMs > 0 && ageMs > staleAfterMs) {
171
- // Force-replace the stale lock atomically.
171
+ // Force-replace the stale lock atomically. Stale-takeover is a
172
+ // SOC2 evidence event — caller emits an audit row.
173
+ var prevHolder = existing.lockedby || existing.lockedBy;
172
174
  await xdb.query(
173
175
  "DELETE FROM " + Q_LOCK + " WHERE scope = 'lock' AND lockedAt = $1",
174
176
  [Number(existing.lockedat || existing.lockedAt)]
@@ -177,7 +179,7 @@ async function _acquireLock(xdb, opts) {
177
179
  "INSERT INTO " + Q_LOCK + " (scope, lockedAt, lockedBy) VALUES ('lock', $1, $2)",
178
180
  [nowMs, holder]
179
181
  );
180
- return holder;
182
+ return { holder: holder, takeoverFrom: prevHolder, takeoverAgeMs: ageMs };
181
183
  }
182
184
  throw _err("externaldb-migrate/lock-held",
183
185
  "migration lock is held by " + (existing.lockedby || existing.lockedBy) +
@@ -310,12 +312,21 @@ function create(opts) {
310
312
  // pool acquisition for the lock connection — the migrate runner
311
313
  // serializes apply order, so this single-connection lock is
312
314
  // sufficient.
313
- var lockHolder = await externalDbModule().transaction(async function (xdb) {
315
+ var lockResult = await externalDbModule().transaction(async function (xdb) {
314
316
  return await _acquireLock(xdb, opts);
315
317
  }, { backend: backendName });
318
+ var lockHolder = lockResult.holder;
316
319
 
317
320
  _emit(audit, "externaldb.migrate.lock.acquired", "success",
318
321
  { holder: lockHolder, backend: backendName }, null);
322
+ // SOC2 evidence — record the stale-takeover separately so a
323
+ // forensic review can reconstruct WHICH process orphaned the
324
+ // lock and WHEN. Pre-v0.8.19 the takeover happened silently.
325
+ if (lockResult.takeoverFrom) {
326
+ _emit(audit, "externaldb.migrate.lock.takeover", "success",
327
+ { holder: lockHolder, takeoverFrom: lockResult.takeoverFrom,
328
+ takeoverAgeMs: lockResult.takeoverAgeMs, backend: backendName }, null);
329
+ }
319
330
 
320
331
  try {
321
332
  var appliedRes = await externalDbModule().query(
@@ -379,12 +390,18 @@ function create(opts) {
379
390
  await _ensureLockTable(xdb);
380
391
  }, { backend: backendName });
381
392
 
382
- var lockHolder = await externalDbModule().transaction(async function (xdb) {
393
+ var lockResultDown = await externalDbModule().transaction(async function (xdb) {
383
394
  return await _acquireLock(xdb, opts);
384
395
  }, { backend: backendName });
396
+ var lockHolder = lockResultDown.holder;
385
397
 
386
398
  _emit(audit, "externaldb.migrate.lock.acquired", "success",
387
399
  { holder: lockHolder, backend: backendName }, null);
400
+ if (lockResultDown.takeoverFrom) {
401
+ _emit(audit, "externaldb.migrate.lock.takeover", "success",
402
+ { holder: lockHolder, takeoverFrom: lockResultDown.takeoverFrom,
403
+ takeoverAgeMs: lockResultDown.takeoverAgeMs, backend: backendName }, null);
404
+ }
388
405
 
389
406
  try {
390
407
  var appliedRes = await externalDbModule().query(
@@ -946,9 +946,27 @@ function _connectAs(rawConnect, query, opts) {
946
946
  for (var gn in opts.gucs) {
947
947
  var gv = opts.gucs[gn];
948
948
  if (typeof gv === "number") {
949
+ // Numeric GUCs must be finite — Infinity / NaN serialize as
950
+ // tokens that Postgres would reject at parse time, but only
951
+ // AFTER the connection started using a half-set state. Refuse
952
+ // at config-time instead.
953
+ if (!isFinite(gv)) {
954
+ throw _err("INVALID_CONFIG",
955
+ "connectAs: gucs[" + gn + "] number must be finite (got " + gv + ")",
956
+ true);
957
+ }
949
958
  stmts.push('SET "' + gn + '" TO ' + gv);
950
959
  } else {
951
960
  var gvs = String(gv).replace(/'/g, "''");
961
+ // Refuse embedded NUL / line breaks in GUC string values —
962
+ // they have no legitimate use and would terminate the SET
963
+ // statement early in some drivers.
964
+ // eslint-disable-next-line no-control-regex
965
+ if (/[\r\n\u0000]/.test(gvs)) {
966
+ throw _err("INVALID_CONFIG",
967
+ "connectAs: gucs[" + gn + "] string value must not contain NUL or newline characters",
968
+ true);
969
+ }
952
970
  stmts.push('SET "' + gn + '" TO \'' + gvs + "'");
953
971
  }
954
972
  }
@@ -332,7 +332,7 @@ function checkExtractionPath(entryName, extractionRoot) {
332
332
  return { ok: false, reason: "entry name is an absolute path" };
333
333
  }
334
334
  // Reject entries containing null bytes regardless of extraction root.
335
- if (entryName.indexOf("") !== -1) {
335
+ if (entryName.indexOf("\u0000") !== -1) {
336
336
  return { ok: false, reason: "entry name contains null byte" };
337
337
  }
338
338
  void extractionRoot;
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ /**
3
+ * b.secCyber — SEC Cybersecurity Disclosure Item 1.05 (Form 8-K)
4
+ * artifact generator.
5
+ *
6
+ * Required by 17 CFR §229.106 / Form 8-K Item 1.05 (final rule
7
+ * effective 2023-12-18). When a registrant determines that a
8
+ * cybersecurity incident is material, it MUST file a Form 8-K within
9
+ * 4 business days of the materiality determination, describing:
10
+ *
11
+ * - The material aspects of the nature, scope, and timing
12
+ * - The material impact or reasonably likely material impact on
13
+ * the registrant (financial condition + results of operations)
14
+ *
15
+ * Materiality determination MUST be made "without unreasonable
16
+ * delay." The Attorney General can authorize a delay (when public
17
+ * disclosure would pose substantial risk to national security or
18
+ * public safety) — registrant requests the delay before the 4-day
19
+ * window elapses.
20
+ *
21
+ * The framework can't decide materiality (that's a fact-and-circum-
22
+ * stances judgment). What it CAN do:
23
+ *
24
+ * - Structure the operator's materiality finding into a
25
+ * tamper-evident audit-chain row (the regulator-facing record).
26
+ * - Generate the 8-K Item 1.05 narrative skeleton with the
27
+ * operator's content slotted in.
28
+ * - Compute the 4-business-day deadline so the operator's
29
+ * filing-system gate refuses to slip past it.
30
+ * - Emit an AG-delay-request artifact when the operator asserts
31
+ * national-security / public-safety risk.
32
+ *
33
+ * Public API:
34
+ *
35
+ * b.secCyber.eightKArtifact(opts) -> { artifact, deadline, audit }
36
+ * opts:
37
+ * incidentId: operator-supplied incident reference (string).
38
+ * registrant: { name, cik, filer }
39
+ * detectedAt: Unix-ms when the incident was detected.
40
+ * materialityDeterminedAt: Unix-ms when materiality was determined.
41
+ * materialityFinding: "material" | "not-material" | "pending".
42
+ * materialityReasoning: operator-provided narrative
43
+ * explaining the materiality call.
44
+ * nature: string describing the incident's nature.
45
+ * scope: string describing the scope.
46
+ * timing: string describing the timing.
47
+ * impact: string describing material/likely-material
48
+ * impact on financial condition + operations.
49
+ * agDelayRequested: bool. When true, the artifact includes the
50
+ * AG-delay-request template and the 4-day
51
+ * deadline is suspended pending DOJ response.
52
+ * agDelayJustification: string explaining the national-security
53
+ * / public-safety risk that justifies delay
54
+ * (REQUIRED when agDelayRequested = true).
55
+ * audit: bool, default true.
56
+ *
57
+ * Returns:
58
+ * artifact: structured 8-K Item 1.05 content (markdown
59
+ * + JSON for downstream EDGAR filing).
60
+ * deadline: Unix-ms 4-business-day deadline (null when
61
+ * AG-delay-requested).
62
+ * deadlineBusinessDays: business-day count (4 by default; spec
63
+ * gives no exception).
64
+ *
65
+ * The framework does NOT submit to EDGAR — operators wire the
66
+ * artifact into their existing filer-attorney workflow.
67
+ */
68
+
69
+ var audit = require("./audit");
70
+ var C = require("./constants");
71
+ var validateOpts = require("./validate-opts");
72
+ var nb = require("./numeric-bounds");
73
+ var { defineClass } = require("./framework-error");
74
+ var SecCyberError = defineClass("SecCyberError", { alwaysPermanent: true });
75
+
76
+ var FINDINGS = ["material", "not-material", "pending"];
77
+
78
+ function _addBusinessDays(startMs, days) {
79
+ // Walk forward N business days (Mon-Fri). Doesn't honor US federal
80
+ // holidays — operators with a calendar-aware filing system override
81
+ // by reading deadlineBusinessDays and computing themselves.
82
+ var t = new Date(startMs);
83
+ var added = 0;
84
+ while (added < days) {
85
+ t = new Date(t.getTime() + C.TIME.days(1));
86
+ var dow = t.getUTCDay();
87
+ if (dow !== 0 && dow !== 6) added += 1;
88
+ }
89
+ return t.getTime();
90
+ }
91
+
92
+ function eightKArtifact(opts) {
93
+ if (!opts || typeof opts !== "object") {
94
+ throw SecCyberError.factory("BAD_OPTS",
95
+ "secCyber.eightKArtifact: opts required");
96
+ }
97
+ validateOpts.requireNonEmptyString(opts.incidentId,
98
+ "secCyber.eightKArtifact: incidentId", SecCyberError, "BAD_INCIDENT_ID");
99
+ if (!opts.registrant || typeof opts.registrant !== "object") {
100
+ throw SecCyberError.factory("BAD_REGISTRANT",
101
+ "secCyber.eightKArtifact: registrant object required");
102
+ }
103
+ validateOpts.requireNonEmptyString(opts.registrant.name,
104
+ "secCyber.eightKArtifact: registrant.name", SecCyberError, "BAD_REGISTRANT_NAME");
105
+ validateOpts.requireNonEmptyString(opts.registrant.cik,
106
+ "secCyber.eightKArtifact: registrant.cik", SecCyberError, "BAD_CIK");
107
+ nb.requirePositiveFiniteIntIfPresent(opts.detectedAt,
108
+ "secCyber.eightKArtifact: detectedAt", SecCyberError, "BAD_DETECTED_AT");
109
+ nb.requirePositiveFiniteIntIfPresent(opts.materialityDeterminedAt,
110
+ "secCyber.eightKArtifact: materialityDeterminedAt", SecCyberError, "BAD_MAT_AT");
111
+
112
+ if (FINDINGS.indexOf(opts.materialityFinding) === -1) {
113
+ throw SecCyberError.factory("BAD_FINDING",
114
+ "secCyber.eightKArtifact: materialityFinding must be one of " + FINDINGS.join(", "));
115
+ }
116
+ validateOpts.requireNonEmptyString(opts.materialityReasoning,
117
+ "secCyber.eightKArtifact: materialityReasoning", SecCyberError, "BAD_REASONING");
118
+
119
+ if (opts.materialityFinding === "material") {
120
+ validateOpts.requireNonEmptyString(opts.nature,
121
+ "secCyber.eightKArtifact: nature", SecCyberError, "BAD_NATURE");
122
+ validateOpts.requireNonEmptyString(opts.scope,
123
+ "secCyber.eightKArtifact: scope", SecCyberError, "BAD_SCOPE");
124
+ validateOpts.requireNonEmptyString(opts.timing,
125
+ "secCyber.eightKArtifact: timing", SecCyberError, "BAD_TIMING");
126
+ validateOpts.requireNonEmptyString(opts.impact,
127
+ "secCyber.eightKArtifact: impact", SecCyberError, "BAD_IMPACT");
128
+ }
129
+
130
+ var agDelayRequested = opts.agDelayRequested === true;
131
+ if (agDelayRequested) {
132
+ validateOpts.requireNonEmptyString(opts.agDelayJustification,
133
+ "secCyber.eightKArtifact: agDelayJustification (required when agDelayRequested=true)",
134
+ SecCyberError, "BAD_AG_JUSTIFICATION");
135
+ }
136
+
137
+ var matAt = opts.materialityDeterminedAt || Date.now();
138
+ var deadline = agDelayRequested ? null : _addBusinessDays(matAt, 4);
139
+
140
+ var markdown = "# Form 8-K — Item 1.05 Material Cybersecurity Incident\n\n" +
141
+ "**Registrant:** " + opts.registrant.name + " (CIK: " + opts.registrant.cik + ")\n\n" +
142
+ "**Incident ID:** " + opts.incidentId + "\n\n" +
143
+ "**Materiality determination date:** " + new Date(matAt).toISOString() + "\n\n" +
144
+ "**Materiality finding:** " + opts.materialityFinding + "\n\n" +
145
+ "**Reasoning:**\n\n" + opts.materialityReasoning + "\n\n";
146
+
147
+ if (opts.materialityFinding === "material") {
148
+ markdown +=
149
+ "## Item 1.05(a) — Material aspects\n\n" +
150
+ "**Nature.** " + opts.nature + "\n\n" +
151
+ "**Scope.** " + opts.scope + "\n\n" +
152
+ "**Timing.** " + opts.timing + "\n\n" +
153
+ "## Item 1.05(b) — Material impact\n\n" + opts.impact + "\n\n";
154
+ }
155
+
156
+ if (agDelayRequested) {
157
+ markdown += "## AG-delay request (17 CFR §229.106(c)(1)(ii))\n\n" +
158
+ "Registrant asserts that disclosure of this incident would pose a substantial " +
159
+ "risk to national security or public safety. Pursuant to the rule, registrant " +
160
+ "requests that the Attorney General authorize a delay of disclosure.\n\n" +
161
+ "**Justification:** " + opts.agDelayJustification + "\n\n";
162
+ }
163
+
164
+ markdown += "**Filing deadline:** " +
165
+ (deadline ? new Date(deadline).toISOString() + " (4 business days from materiality determination)" :
166
+ "suspended pending DOJ response to AG-delay request") + "\n";
167
+
168
+ var artifactJson = {
169
+ form: "8-K",
170
+ item: "1.05",
171
+ incidentId: opts.incidentId,
172
+ registrant: { name: opts.registrant.name, cik: opts.registrant.cik },
173
+ detectedAt: opts.detectedAt || null,
174
+ materialityDeterminedAt: matAt,
175
+ materialityFinding: opts.materialityFinding,
176
+ materialityReasoning: opts.materialityReasoning,
177
+ items: opts.materialityFinding === "material" ? {
178
+ "1.05(a)": {
179
+ nature: opts.nature, scope: opts.scope, timing: opts.timing,
180
+ },
181
+ "1.05(b)": { impact: opts.impact },
182
+ } : null,
183
+ agDelayRequested: agDelayRequested,
184
+ agDelayJustification: agDelayRequested ? opts.agDelayJustification : null,
185
+ deadlineMs: deadline,
186
+ };
187
+
188
+ if (opts.audit !== false) {
189
+ audit.safeEmit({
190
+ action: "seccyber.eight_k_artifact",
191
+ outcome: "success",
192
+ metadata: {
193
+ incidentId: opts.incidentId,
194
+ registrant: opts.registrant.name,
195
+ cik: opts.registrant.cik,
196
+ materialityFinding: opts.materialityFinding,
197
+ deadlineMs: deadline,
198
+ agDelayRequested: agDelayRequested,
199
+ },
200
+ });
201
+ }
202
+
203
+ return {
204
+ artifact: { markdown: markdown, json: artifactJson },
205
+ deadline: deadline,
206
+ deadlineBusinessDays: agDelayRequested ? null : 4, // allow:raw-byte-literal — SEC Item 1.05 4-business-day deadline (17 CFR §229.106(c)(1))
207
+ };
208
+ }
209
+
210
+ module.exports = {
211
+ eightKArtifact: eightKArtifact,
212
+ FINDINGS: FINDINGS.slice(),
213
+ SecCyberError: SecCyberError,
214
+ };
@@ -59,6 +59,7 @@ var path = require("path");
59
59
  var atomicFile = require("../atomic-file");
60
60
  var C = require("../constants");
61
61
  var lazyRequire = require("../lazy-require");
62
+ var safeBuffer = require("../safe-buffer");
62
63
  var validateOpts = require("../validate-opts");
63
64
  var { defineClass } = require("../framework-error");
64
65
  var { boot } = require("../log");
@@ -77,11 +78,18 @@ var SealPemFileError = defineClass("SealPemFileError", { alwaysPermanent: true }
77
78
  // override via opts.pollInterval.
78
79
  var DEFAULT_POLL_MS = C.TIME.seconds(2);
79
80
 
81
+ // PEM files are tiny — 4 KiB for an ECDSA key, ~8 KiB for a 4096-bit
82
+ // RSA key, ~64 KiB for a long cert chain. Cap at 1 MiB so an operator
83
+ // with write access to source can't present a 10 GiB file and OOM the
84
+ // host. Operators with genuinely larger inputs override via
85
+ // opts.maxSourceBytes.
86
+ var DEFAULT_MAX_SOURCE_BYTES = C.BYTES.mib(1);
87
+
80
88
  function sealPemFile(opts) {
81
89
  opts = opts || {};
82
90
  validateOpts(opts, [
83
91
  "source", "destination", "audit", "pollInterval",
84
- "onResealed", "onError",
92
+ "onResealed", "onError", "maxSourceBytes",
85
93
  ], "vault.sealPemFile");
86
94
 
87
95
  validateOpts.requireNonEmptyString(opts.source,
@@ -109,6 +117,9 @@ function sealPemFile(opts) {
109
117
  var auditOn = opts.audit !== false;
110
118
  var onResealed = typeof opts.onResealed === "function" ? opts.onResealed : null;
111
119
  var onError = typeof opts.onError === "function" ? opts.onError : null;
120
+ validateOpts.optionalPositiveFinite(opts.maxSourceBytes,
121
+ "vault.sealPemFile: maxSourceBytes", SealPemFileError, "seal-pem-file/bad-max-source-bytes");
122
+ var maxSourceBytes = opts.maxSourceBytes || DEFAULT_MAX_SOURCE_BYTES;
112
123
 
113
124
  var generation = 0;
114
125
  var lastResealedAt = null;
@@ -149,18 +160,64 @@ function sealPemFile(opts) {
149
160
  try { fs.unlinkSync(markerPath); } catch (_e) { /* marker cleanup best-effort */ }
150
161
  }
151
162
 
152
- function _resealNow() {
163
+ function _resealNow(actor) {
153
164
  if (resealing) return;
154
165
  resealing = true;
166
+ var plaintext = null;
155
167
  try {
156
- var plaintext;
157
- try { plaintext = fs.readFileSync(source); }
168
+ // H6 #1 — bounded read. fs.readFileSync without a size cap on a
169
+ // file the operator's renewal process writes is an OOM vector.
170
+ // H6 #3 — symlink TOCTOU defense. Open the file via fs.openSync
171
+ // with O_NOFOLLOW where possible; lstat first to verify the
172
+ // source isn't a symlink we don't expect, then read via fd so
173
+ // a swap-after-stat doesn't change which bytes we read.
174
+ try {
175
+ var lstat = fs.lstatSync(source);
176
+ if (lstat.isSymbolicLink()) {
177
+ throw new SealPemFileError("seal-pem-file/symlink-refused",
178
+ "source is a symlink (refused; follow + re-stat opens TOCTOU)");
179
+ }
180
+ if (lstat.size > maxSourceBytes) {
181
+ throw new SealPemFileError("seal-pem-file/source-too-large",
182
+ "source size " + lstat.size + " exceeds maxSourceBytes " + maxSourceBytes);
183
+ }
184
+ var fd = fs.openSync(source, "r");
185
+ try {
186
+ var fstat = fs.fstatSync(fd);
187
+ // H6 #3 — confirm the fd points at the same inode lstat saw.
188
+ if (fstat.ino !== lstat.ino || fstat.size > maxSourceBytes) {
189
+ throw new SealPemFileError("seal-pem-file/toctou-detected",
190
+ "source mutated between lstat and open (TOCTOU defense)");
191
+ }
192
+ plaintext = Buffer.alloc(fstat.size);
193
+ var read = 0;
194
+ while (read < fstat.size) {
195
+ var n = fs.readSync(fd, plaintext, read, fstat.size - read, null);
196
+ if (n === 0) break;
197
+ read += n;
198
+ }
199
+ if (read !== fstat.size) {
200
+ throw new SealPemFileError("seal-pem-file/short-read",
201
+ "short read: " + read + " of " + fstat.size + " bytes");
202
+ }
203
+ } finally {
204
+ try { fs.closeSync(fd); } catch (_e) { /* close best-effort */ }
205
+ }
206
+ }
158
207
  catch (e) {
159
208
  var err = new SealPemFileError("seal-pem-file/source-read-failed",
160
209
  "vault.sealPemFile: failed to read source '" + source + "': " + e.message);
161
210
  lastError = err;
162
211
  _emitAudit("read_failed", "failure", { source: source, error: e.message });
163
- if (onError) { try { onError(err); } catch (_e) { /* drop-silent */ } }
212
+ if (onError) {
213
+ try { onError(err); }
214
+ catch (cbErr) {
215
+ // H6 #7 — operator callback throw is captured in audit
216
+ // rather than dropped silently.
217
+ _emitAudit("on_error_callback_failed", "failure",
218
+ { error: cbErr && cbErr.message });
219
+ }
220
+ }
164
221
  return;
165
222
  }
166
223
  try {
@@ -172,7 +229,13 @@ function sealPemFile(opts) {
172
229
  _emitAudit("seal_failed", "failure", {
173
230
  source: source, destination: destination, error: e2.message,
174
231
  });
175
- if (onError) { try { onError(err2); } catch (_e) { /* drop-silent */ } }
232
+ if (onError) {
233
+ try { onError(err2); }
234
+ catch (cbErr) {
235
+ _emitAudit("on_error_callback_failed", "failure",
236
+ { error: cbErr && cbErr.message });
237
+ }
238
+ }
176
239
  return;
177
240
  }
178
241
  generation += 1;
@@ -183,6 +246,11 @@ function sealPemFile(opts) {
183
246
  destination: destination,
184
247
  bytes: plaintext.length,
185
248
  generation: generation,
249
+ // H6 #8 — actor is captured when forceReseal({ actor }) is
250
+ // called explicitly. Watcher-driven resealings record actor=null
251
+ // (the kernel's mtime-change notification has no operator).
252
+ actor: (actor && actor.actorId) || null,
253
+ actorReason: (actor && actor.reason) || null,
186
254
  });
187
255
  if (onResealed) {
188
256
  try {
@@ -193,9 +261,18 @@ function sealPemFile(opts) {
193
261
  resealedAt: lastResealedAt,
194
262
  generation: generation,
195
263
  });
196
- } catch (_e) { /* drop-silent */ }
264
+ } catch (cbErr) {
265
+ // H6 #7 — operator callback throw lands in audit.
266
+ _emitAudit("on_resealed_callback_failed", "failure",
267
+ { error: cbErr && cbErr.message });
268
+ }
197
269
  }
198
270
  } finally {
271
+ // H6 #2 — zero plaintext PEM bytes from the heap. V8 may have
272
+ // copied the buffer internally (string interning, GC compaction)
273
+ // but the explicit zero ensures the operator-visible buffer no
274
+ // longer holds the secret.
275
+ if (plaintext) { try { safeBuffer.secureZero(plaintext); } catch (_e) { /* best-effort */ } }
199
276
  resealing = false;
200
277
  if (pendingMtime) {
201
278
  // A change event arrived while we were resealing — reseal again
@@ -271,8 +348,11 @@ function sealPemFile(opts) {
271
348
  get watching() { return watching; },
272
349
  // Force a reseal — useful for tests and operator-triggered rotations
273
350
  // (e.g. after a manual ACME renewal). Idempotent: produces an
274
- // updated destination from the current source bytes.
275
- forceReseal: _resealNow,
351
+ // updated destination from the current source bytes. Accepts
352
+ // { actorId, reason } for forensic audit-trail capture (H6 #8).
353
+ forceReseal: function (actorOpts) {
354
+ _resealNow(actorOpts && typeof actorOpts === "object" ? actorOpts : null);
355
+ },
276
356
  };
277
357
  }
278
358
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.18",
3
+ "version": "0.8.25",
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:1751cf74-fcc9-495e-a159-bac5eb2565b3",
5
+ "serialNumber": "urn:uuid:a6056d34-71d3-41f1-a99f-80454c5b2780",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T07:40:58.458Z",
8
+ "timestamp": "2026-05-07T13:31:44.359Z",
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.18",
22
+ "bom-ref": "@blamejs/core@0.8.25",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.18",
25
+ "version": "0.8.25",
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.18",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.25",
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.18",
57
+ "ref": "@blamejs/core@0.8.25",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]