@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 +0 -0
- package/index.js +4 -0
- package/lib/audit.js +2 -0
- package/lib/budr.js +132 -0
- package/lib/compliance.js +38 -0
- package/lib/crypto.js +23 -13
- package/lib/db.js +58 -1
- package/lib/external-db-migrate.js +23 -6
- package/lib/external-db.js +18 -0
- package/lib/guard-archive.js +1 -1
- package/lib/sec-cyber.js +214 -0
- package/lib/vault/seal-pem-file.js +89 -9
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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.
|
|
172
|
-
//
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
package/lib/external-db.js
CHANGED
|
@@ -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
|
}
|
package/lib/guard-archive.js
CHANGED
|
@@ -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("
|
|
335
|
+
if (entryName.indexOf("\u0000") !== -1) {
|
|
336
336
|
return { ok: false, reason: "entry name contains null byte" };
|
|
337
337
|
}
|
|
338
338
|
void extractionRoot;
|
package/lib/sec-cyber.js
ADDED
|
@@ -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
|
-
|
|
157
|
-
|
|
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) {
|
|
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) {
|
|
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 (
|
|
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
|
-
|
|
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
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:a6056d34-71d3-41f1-a99f-80454c5b2780",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.25",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.8.25",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|