@blamejs/core 0.13.18 → 0.13.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/lib/agent-tenant.js +30 -0
- package/lib/archive-wrap.js +100 -32
- package/lib/atomic-file.js +11 -7
- package/lib/audit-tools.js +129 -28
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.20 (2026-05-27) — **`b.archive.wrap` can seal an archive for a tenant with no key-pair to manage — `recipient: "tenant"`.** b.archive.wrap previously sealed only to an explicit hybrid-PQC key-pair or a peer certificate; the documented recipient: "tenant" strategy threw. It now works: pass { recipient: "tenant", tenantId } and the archive is sealed under a deterministic per-tenant key derived from the vault root (SHAKE256 KDF) with XChaCha20-Poly1305, the tenant id mixed into the AEAD additional-authenticated-data so one tenant's envelope cannot be opened under another tenant's key. There is no recipient key-pair for the operator to generate, store, or rotate — b.archive.unwrap re-derives the key from the same tenantId. Rotating the vault re-keys every tenant (rotation intent is re-seal). The derivation is exposed directly as b.agent.tenant.derivedKey(tenantId, purpose) for operators who need the raw per-tenant key for their own AEAD. Requires an initialized vault. **Added:** *`b.archive.wrap` / `b.archive.unwrap` `recipient: "tenant"` — per-tenant archive sealing, no key-pair* — `b.archive.wrap(bytes, { recipient: "tenant", tenantId })` seals under a deterministic per-tenant key derived from the vault root with XChaCha20-Poly1305 (draft-irtf-cfrg-xchacha-03) and a SHAKE256 KDF (FIPS 202); the tenant id is bound into the AEAD AAD so a tenant-A envelope cannot decrypt under tenant-B's key even if an attacker swaps envelope headers. `b.archive.unwrap(sealed, { recipient: "tenant", tenantId })` (or just `{ tenantId }`) re-derives the key and recovers the bytes — no recipient key-pair to manage. The tenant envelope carries a distinct version byte so it is never fed to the hybrid-KEM decrypt path. The static-key and peer-cert recipient strategies are unchanged. · *`b.agent.tenant.derivedKey(tenantId, purpose)` — direct per-tenant key derivation* — The deterministic, domain-separated per-tenant key derivation (vault root + tenantId + purpose, SHAKE256, NUL-separated) is now exported at the module level, returning a 64-char hex key. Previously reachable only as a method on a created tenant manager; operators who need the raw key for their own AEAD can now call it directly. Throws if the vault is not initialized.
|
|
12
|
+
|
|
13
|
+
- v0.13.19 (2026-05-27) — **`auditTools` export / archive / forensic-snapshot can return the bundle as bytes — no output directory for serverless / read-only filesystems.** b.auditTools.exportSlice, b.auditTools.archive, and b.auditTools.forensicSnapshot required an `out` directory to write the encrypted bundle (rows.enc + optional checkpoint.enc + manifest.json), which is unusable on a read-only or ephemeral serverless filesystem. Each now accepts `returnBytes: true` instead of `out` and returns the bundle as an in-memory `{ filename: Buffer }` map — ready to stream to object storage or over the wire with no filesystem access. `out` and `returnBytes` are mutually exclusive. The on-disk path is unchanged. The bundle's encryption (XChaCha20-Poly1305 + Argon2id), chain-proof material, and manifest checksums are identical to the written bundle, so an in-memory bundle written to disk verifies exactly as one produced by the `out` path. **Added:** *`returnBytes` on `auditTools.exportSlice` / `archive` / `forensicSnapshot` — in-memory bundles* — Pass `returnBytes: true` (and omit `out`) to get the encrypted audit bundle as an in-memory `{ filename: Buffer }` map instead of a directory write — the read-only / serverless path. `exportSlice` / `archive` return `{ manifest, files, rowCount, range }`; `forensicSnapshot` returns `{ ...manifest, files }` where `files` carries the slice's `rows.enc` + `manifest.json` plus the `forensic-snapshot.json` incident wrapper. The encryption, chain proof, and manifest checksums match the on-disk bundle byte-for-byte, so the bytes verify with `verifyBundle` once written out. `out` and `returnBytes` are mutually exclusive (passing both throws). **Fixed:** *`auditTools.forensicSnapshot` now honors the `since` window instead of capturing the entire audit history* — `forensicSnapshot` passed its `since` bound to the slice exporter under the wrong option name, so the time filter was silently dropped and the snapshot bundled every audit row regardless of `since`. The window is now applied — a snapshot scoped to an incident window contains only that window's rows. The snapshot manifest's `auditSliceFile` field, previously always undefined, now records the slice location.
|
|
14
|
+
|
|
11
15
|
- v0.13.18 (2026-05-27) — **`bodyParser` multipart can buffer uploads in memory — no tmp directory for serverless / read-only filesystems.** The multipart/form-data sub-parser previously streamed every file part to a tmp directory on disk (os.tmpdir() by default), which fails on a read-only or ephemeral serverless filesystem. A new multipart.storage option selects where file parts land: "disk" (default, unchanged — req.files[].path points at a tmp file cleaned up on response end) or "memory" (req.files[].buffer holds the assembled bytes, with no filesystem access at all). Both modes enforce the same per-file (fileSize), per-field, and total-request (totalSize) caps, so memory mode adds no new memory-exhaustion surface. The file object shape is stable across both modes — disk sets path with buffer null, memory sets buffer with path null — so a handler branches on whichever is non-null. An invalid storage value is rejected when the middleware is constructed. **Added:** *`bodyParser` multipart `storage: "memory"` — buffer uploads in RAM instead of a tmp directory* — `b.middleware.bodyParser({ multipart: { storage: "memory" } })` buffers each uploaded file part in memory and exposes it as `req.files[].buffer` (a Buffer), with no `os.tmpdir()` write and no tmp-file cleanup — the read-only / serverless path. The default `storage: "disk"` is unchanged: file parts stream to a tmp file, `req.files[].path` points at it, and it is removed when the response finishes. Both modes apply the existing `fileSize` / per-field `maxBytes` / `totalSize` caps and SHA3-512 hash each part during streaming, so memory mode is bounded by the same limits and adds no new DoS surface. The `req.files[]` shape is stable across modes (disk: `path` set, `buffer` null; memory: `buffer` set, `path` null). A `storage` value other than `"disk"` or `"memory"` throws a `TypeError` at construction.
|
|
12
16
|
|
|
13
17
|
- v0.13.17 (2026-05-27) — **Template engine can render from a string with no views directory — for serverless / read-only filesystems.** b.template.create previously required a viewsDir that exists on disk, and rendering always read the template (and its layout/partials) from that directory — unusable on a read-only or ephemeral serverless filesystem where the templates aren't on disk. The engine now accepts a source string directly: viewsDir is optional, and the returned engine exposes renderString(source, data?, opts?) and compileString(source, opts?) that compile and render from a string with no disk read. {% extends %} and {{> partial}} in a string source resolve through an operator-supplied opts.resolve(name) -> string callback (without it, an extends throws a clear error and a missing partial inlines empty, matching the file path). The same HTML-escaping, expression grammar, and extends/partial-depth caps apply. The file-backed render / compile / precompileAll still work exactly as before when a viewsDir is configured, and now refuse with a clear error when one isn't. **Added:** *`engine.renderString` / `engine.compileString` — render templates from a string, no viewsDir* — `b.template.create({})` (no `viewsDir`) returns a string-only engine; `renderString(source, data?, { resolve })` and `compileString(source, { resolve })` compile and render from a source string with zero filesystem access — the read-only / serverless path. `{% extends %}` and `{{> partial}}` resolve through `opts.resolve(name) -> string`. The HTML escaping, grammar, and depth caps are identical to the file path. When a `viewsDir` IS configured, `render`/`compile`/`precompileAll` behave exactly as before; without one they refuse with `viewsDir not configured`. `renderString(source, { resolve })` may omit the data argument — an opts object carrying a function `resolve` is recognized as opts, not data. **Security:** *Vendored `@simplewebauthn/server` refreshed 13.3.0 → 13.3.1* — The vendored WebAuthn server bundle (`b.auth.passkey`'s registration/authentication verification) is refreshed to the latest upstream patch, with the MANIFEST version, CPE, and SHA-256 integrity hashes updated and the bundle re-verified.
|
package/README.md
CHANGED
|
@@ -221,14 +221,14 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
221
221
|
- **PII redaction** — `b.redact`
|
|
222
222
|
- **Decoy detection** — canary-credential / decoy-record framework auditing every positive lookup as `honeytoken.tripped` (`b.honeytoken`)
|
|
223
223
|
- **Boot assertions** — operator-callable security policy assertions (`b.security.assertProduction`); tamper-evident config-baseline drift detection signed with audit-signing key + at-boot vendor-bundle SHA-256 integrity verification across `lib/vendor/*` (`b.configDrift`, `b.configDrift.verifyVendorIntegrity`)
|
|
224
|
-
- **CSP reports + forensic export** — `b.middleware.cspReport`; post-incident audit-bundle composer (`b.auditTools.forensicSnapshot`)
|
|
224
|
+
- **CSP reports + forensic export** — `b.middleware.cspReport`; post-incident audit-bundle composer (`b.auditTools.forensicSnapshot`); audit export / archive / forensic snapshot write to disk or return the encrypted bundle in memory (`returnBytes`) for read-only / serverless filesystems
|
|
225
225
|
|
|
226
226
|
### i18n + format helpers
|
|
227
227
|
|
|
228
228
|
- **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`)
|
|
229
229
|
- **CSV** — RFC 4180 with Excel formula-injection prevention (`b.csv`)
|
|
230
230
|
- **IDs + slugs** — RFC 9562 UUID v4 + v7 (`b.uuid`); URL-safe slugs (`b.slug`)
|
|
231
|
-
- **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`)
|
|
231
|
+
- **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`); recipient-sealed envelopes — hybrid-PQC key-pair, peer certificate, or per-tenant key with no key-pair to manage (`b.archive.wrap({ recipient: "tenant", tenantId })`)
|
|
232
232
|
- **Pagination + forms** — HMAC-signed cursor pagination (`b.pagination`); HTML form rendering + validation + CSRF (`b.forms`)
|
|
233
233
|
|
|
234
234
|
### Production
|
package/lib/agent-tenant.js
CHANGED
|
@@ -386,6 +386,35 @@ function _deriveTenantKeyBytes(tenantId, purpose) {
|
|
|
386
386
|
return bCrypto.kdf(input, TENANT_KEY_BYTES);
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
+
/**
|
|
390
|
+
* @primitive b.agent.tenant.derivedKey
|
|
391
|
+
* @signature b.agent.tenant.derivedKey(tenantId, purpose)
|
|
392
|
+
* @since 0.9.25
|
|
393
|
+
* @status stable
|
|
394
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
395
|
+
* @related b.agent.tenant.create, b.archive.wrap, b.vault
|
|
396
|
+
*
|
|
397
|
+
* Derive a deterministic, domain-separated 32-byte key for a tenant
|
|
398
|
+
* and a named purpose, returned as a 64-char hex string. The key is a
|
|
399
|
+
* SHAKE256 KDF over the vault root (the master keypair PEM hashed),
|
|
400
|
+
* the `tenantId`, and the `purpose`, with NUL separators so distinct
|
|
401
|
+
* `(tenantId, purpose)` pairs cannot collide. The same inputs always
|
|
402
|
+
* produce the same key, so a value sealed under
|
|
403
|
+
* `derivedKey(t, "archive-wrap")` is recoverable later from the same
|
|
404
|
+
* tenant + purpose with no key escrow. Rotating the vault
|
|
405
|
+
* (`b.vaultRotate.rotate`) changes the root and therefore every
|
|
406
|
+
* derived key — by design, rotation intent is re-seal.
|
|
407
|
+
*
|
|
408
|
+
* Throws if the vault has not been initialized (keys cannot be derived
|
|
409
|
+
* before bootstrap) or if `purpose` is empty. This is the same
|
|
410
|
+
* derivation the per-tenant `sealField` / archive `recipient: "tenant"`
|
|
411
|
+
* paths use internally; call it directly when you need the raw key for
|
|
412
|
+
* your own AEAD.
|
|
413
|
+
*
|
|
414
|
+
* @example
|
|
415
|
+
* var key = b.agent.tenant.derivedKey("acme-corp", "archive-wrap");
|
|
416
|
+
* // → "9f3c…" (64 hex chars; deterministic per tenant + purpose)
|
|
417
|
+
*/
|
|
389
418
|
function _derivedKey(tenantId, purpose) {
|
|
390
419
|
// Public API — returns hex so the existing wire shape (operators
|
|
391
420
|
// storing the derived key string in their DB) is unchanged. Internal
|
|
@@ -624,6 +653,7 @@ function _inMemoryBackend() {
|
|
|
624
653
|
|
|
625
654
|
module.exports = {
|
|
626
655
|
create: create,
|
|
656
|
+
derivedKey: _derivedKey,
|
|
627
657
|
CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
|
|
628
658
|
AgentTenantError: AgentTenantError,
|
|
629
659
|
guards: {
|
package/lib/archive-wrap.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* var bytes = b.archive.unwrap(sealed, { recipient: privKeys });
|
|
16
16
|
* var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Three recipient strategies: a static hybrid-PQC key-pair, a peer
|
|
19
|
+
* certificate, and `"tenant"` — a deterministic per-tenant symmetric
|
|
20
|
+
* seal keyed by the vault root (no key-pair to manage; unwrap
|
|
21
|
+
* re-derives from the tenant id). b.backup's `cryptoStrategy:
|
|
22
|
+
* "recipient"` consumes the same substrate.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
var C = require("./constants");
|
|
@@ -29,13 +30,23 @@ var ArchiveWrapError = defineClass("ArchiveWrapError", { alwaysPermanent: true }
|
|
|
29
30
|
|
|
30
31
|
var bCrypto = lazyRequire(function () { return require("./crypto"); });
|
|
31
32
|
var backupCrypto = lazyRequire(function () { return require("./backup/crypto"); });
|
|
33
|
+
var agentTenant = lazyRequire(function () { return require("./agent-tenant"); });
|
|
32
34
|
|
|
33
35
|
// Envelope magic — 5-byte ASCII prefix the safe-archive sniffer
|
|
34
36
|
// recognises. Distinct from b.crypto.encrypt's base64 envelope so
|
|
35
37
|
// archive-wrap output can carry an unambiguous "this is an archive
|
|
36
38
|
// wrap envelope" magic before the operator-controlled payload.
|
|
37
39
|
var ARCH_WRAP_MAGIC = "BAWRP"; // allow:raw-byte-literal — 5-byte ASCII archive-wrap recipient envelope magic
|
|
38
|
-
var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — recipient version byte
|
|
40
|
+
var ARCH_WRAP_VERSION = 0x01; // allow:raw-byte-literal — recipient version byte (hybrid-KEM envelope)
|
|
41
|
+
// Tenant strategy uses the same BAWRP magic with a distinct version
|
|
42
|
+
// byte: the body is a symmetric XChaCha20-Poly1305 packed ciphertext
|
|
43
|
+
// (b.crypto.encryptPacked) keyed by the tenant's vault-derived key,
|
|
44
|
+
// not a hybrid-KEM envelope. unwrap dispatches on the version byte so
|
|
45
|
+
// a tenant envelope is never fed to the KEM decrypt path.
|
|
46
|
+
var ARCH_WRAP_VERSION_TENANT = 0x02; // allow:raw-byte-literal — tenant symmetric-seal version byte
|
|
47
|
+
// Purpose label for the per-tenant key derivation (domain-separates
|
|
48
|
+
// the archive-wrap key from a tenant's seal / audit / session keys).
|
|
49
|
+
var TENANT_KEY_PURPOSE = "archive-wrap";
|
|
39
50
|
var ARCH_WRAP_HEADER_BYTES = C.BYTES.bytes(6); // magic(5) + version(1)
|
|
40
51
|
// Passphrase variant — wire format: magic(5) + version(1) + saltLen(1)
|
|
41
52
|
// + salt(saltLen bytes) + encrypted bytes (24-byte nonce + ciphertext+tag
|
|
@@ -66,10 +77,14 @@ var ARCH_PASSPHRASE_HEADER_BYTES = C.BYTES.bytes(7);
|
|
|
66
77
|
* - peer cert — `{ recipient: { peerCertDer, peerKemPubkey } }` composes
|
|
67
78
|
* `b.crypto.encryptEnvelopeAsCertPeer` (extracts the
|
|
68
79
|
* P-384 half from the cert).
|
|
69
|
-
* - tenant — `{ recipient: "tenant", tenantId: "alpha" }`
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
80
|
+
* - tenant — `{ recipient: "tenant", tenantId: "alpha" }` seals
|
|
81
|
+
* under a deterministic per-tenant key derived from the
|
|
82
|
+
* vault root (`b.agent.tenant.derivedKey`) with
|
|
83
|
+
* XChaCha20-Poly1305, the tenant id mixed into the AEAD
|
|
84
|
+
* AAD so one tenant's envelope cannot open under
|
|
85
|
+
* another's key. No recipient key-pair to manage;
|
|
86
|
+
* `unwrap` re-derives from the same `tenantId`. Requires
|
|
87
|
+
* an initialized vault.
|
|
73
88
|
*
|
|
74
89
|
* @opts
|
|
75
90
|
* recipient: object | string, // see strategies above; required
|
|
@@ -96,15 +111,16 @@ function wrap(bytes, opts) {
|
|
|
96
111
|
throw new ArchiveWrapError("archive-wrap/no-recipient",
|
|
97
112
|
"wrap: opts.recipient is required (static key object | \"tenant\" string | peer-cert object)");
|
|
98
113
|
}
|
|
99
|
-
var
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
114
|
+
var enc = _encryptForRecipient(bytes, opts);
|
|
115
|
+
// enc.body is the envelope bytes (base64 KEM envelope for static /
|
|
116
|
+
// peer-cert recipients, symmetric packed ciphertext for tenant).
|
|
117
|
+
// Prepend the 6-byte archive-wrap header stamped with the strategy's
|
|
118
|
+
// version byte so safeArchive's sniffer + unwrap can identify the
|
|
119
|
+
// envelope (and pick the right decrypt path) without trial decryption.
|
|
104
120
|
var header = Buffer.alloc(ARCH_WRAP_HEADER_BYTES);
|
|
105
121
|
header.write(ARCH_WRAP_MAGIC, 0, 5, "ascii");
|
|
106
|
-
header[5] =
|
|
107
|
-
return Buffer.concat([header,
|
|
122
|
+
header[5] = enc.version;
|
|
123
|
+
return Buffer.concat([header, enc.body]);
|
|
108
124
|
}
|
|
109
125
|
|
|
110
126
|
/**
|
|
@@ -121,11 +137,14 @@ function wrap(bytes, opts) {
|
|
|
121
137
|
* than a crypto-level error.
|
|
122
138
|
*
|
|
123
139
|
* @opts
|
|
124
|
-
* recipient: object,
|
|
140
|
+
* recipient: object | "tenant", // { privateKey, ecPrivateKey } | { certPrivateKey, kemSecret } | "tenant"
|
|
141
|
+
* tenantId: string, // required when the envelope was sealed with recipient: "tenant"
|
|
125
142
|
*
|
|
126
143
|
* @example
|
|
127
144
|
* var bytes = b.archive.unwrap(sealed, { recipient: privPair });
|
|
128
145
|
* var reader = b.archive.read.tar(b.archive.adapters.buffer(bytes));
|
|
146
|
+
* // tenant envelope:
|
|
147
|
+
* var t = b.archive.unwrap(sealedForTenant, { recipient: "tenant", tenantId: "alpha" });
|
|
129
148
|
*/
|
|
130
149
|
function unwrap(sealed, opts) {
|
|
131
150
|
opts = opts || {};
|
|
@@ -145,6 +164,27 @@ function unwrap(sealed, opts) {
|
|
|
145
164
|
JSON.stringify(ARCH_WRAP_MAGIC) + "; got " + JSON.stringify(magic));
|
|
146
165
|
}
|
|
147
166
|
var version = buf[5];
|
|
167
|
+
// Tenant strategy: symmetric packed ciphertext keyed by the
|
|
168
|
+
// vault-derived per-tenant key. Re-derive from opts.tenantId and
|
|
169
|
+
// decrypt under the same tenant-bound AAD that wrap sealed with.
|
|
170
|
+
if (version === ARCH_WRAP_VERSION_TENANT) {
|
|
171
|
+
if (opts.recipient !== undefined && opts.recipient !== "tenant") {
|
|
172
|
+
throw new ArchiveWrapError("archive-wrap/recipient-mismatch",
|
|
173
|
+
"unwrap: this envelope was sealed with recipient: \"tenant\" — pass opts.tenantId " +
|
|
174
|
+
"(and either omit opts.recipient or set it to \"tenant\"), not a key-pair recipient");
|
|
175
|
+
}
|
|
176
|
+
var tenantKey = _tenantKey(opts.tenantId);
|
|
177
|
+
var packedBody = buf.slice(ARCH_WRAP_HEADER_BYTES);
|
|
178
|
+
try {
|
|
179
|
+
return bCrypto().decryptPacked(packedBody, tenantKey, _tenantAad(opts.tenantId));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
var terr = new ArchiveWrapError("archive-wrap/decrypt-failed",
|
|
182
|
+
"unwrap: tenant envelope decryption refused (wrong tenantId or rotated vault?): " +
|
|
183
|
+
((e && e.message) || String(e)));
|
|
184
|
+
terr.cause = e;
|
|
185
|
+
throw terr;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
148
188
|
if (version !== ARCH_WRAP_VERSION) {
|
|
149
189
|
throw new ArchiveWrapError("archive-wrap/bad-version",
|
|
150
190
|
"unwrap: archive-wrap version " + version + " not supported by this build");
|
|
@@ -152,7 +192,8 @@ function unwrap(sealed, opts) {
|
|
|
152
192
|
if (!opts.recipient || typeof opts.recipient !== "object") {
|
|
153
193
|
throw new ArchiveWrapError("archive-wrap/no-recipient",
|
|
154
194
|
"unwrap: opts.recipient is required ({ privateKey, ecPrivateKey } " +
|
|
155
|
-
"for the static-key path, { certPrivateKey, kemSecret } for the peer-cert path
|
|
195
|
+
"for the static-key path, { certPrivateKey, kemSecret } for the peer-cert path, " +
|
|
196
|
+
"or \"tenant\" + opts.tenantId for the tenant path)");
|
|
156
197
|
}
|
|
157
198
|
var envelope = buf.slice(ARCH_WRAP_HEADER_BYTES).toString("utf-8");
|
|
158
199
|
var plaintext;
|
|
@@ -183,28 +224,52 @@ function unwrap(sealed, opts) {
|
|
|
183
224
|
return Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext);
|
|
184
225
|
}
|
|
185
226
|
|
|
227
|
+
// Resolve a tenant's deterministic 32-byte archive-wrap key from the
|
|
228
|
+
// vault root. Throws a clear archive-wrap error (rather than a deep
|
|
229
|
+
// agent-tenant one) when tenantId is missing; the vault-not-initialized
|
|
230
|
+
// case surfaces from agentTenant.derivedKey unchanged.
|
|
231
|
+
function _tenantKey(tenantId) {
|
|
232
|
+
if (typeof tenantId !== "string" || tenantId.length === 0) {
|
|
233
|
+
throw new ArchiveWrapError("archive-wrap/no-tenant-id",
|
|
234
|
+
"recipient: \"tenant\" requires opts.tenantId (a non-empty string)");
|
|
235
|
+
}
|
|
236
|
+
return Buffer.from(agentTenant().derivedKey(tenantId, TENANT_KEY_PURPOSE), "hex");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// AAD context-binds the symmetric envelope to the tenant: the Poly1305
|
|
240
|
+
// tag covers this, so a tenant-A envelope cannot be decrypted under
|
|
241
|
+
// tenant-B's key even if an attacker swaps headers between envelopes.
|
|
242
|
+
function _tenantAad(tenantId) {
|
|
243
|
+
return Buffer.from("archive-wrap|tenant|" + tenantId, "utf8");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Returns { version, body } so wrap() can stamp the right version byte:
|
|
247
|
+
// hybrid-KEM recipients use ARCH_WRAP_VERSION with a base64 envelope
|
|
248
|
+
// body; the tenant strategy uses ARCH_WRAP_VERSION_TENANT with a
|
|
249
|
+
// symmetric packed-ciphertext body.
|
|
186
250
|
function _encryptForRecipient(bytes, opts) {
|
|
187
251
|
var r = opts.recipient;
|
|
188
252
|
if (typeof r === "string") {
|
|
189
253
|
if (r === "tenant") {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
throw new ArchiveWrapError("archive-wrap/tenant-strategy-deferred",
|
|
194
|
-
"wrap: recipient: \"tenant\" lands in v0.12.11 alongside b.backup cryptoStrategy: \"recipient\" + per-tenant key resolution. For v0.12.10, pass an explicit { publicKey, ecPublicKey } recipient");
|
|
254
|
+
var tenantKey = _tenantKey(opts.tenantId);
|
|
255
|
+
var packed = bCrypto().encryptPacked(Buffer.from(bytes), tenantKey, _tenantAad(opts.tenantId));
|
|
256
|
+
return { version: ARCH_WRAP_VERSION_TENANT, body: packed };
|
|
195
257
|
}
|
|
196
258
|
throw new ArchiveWrapError("archive-wrap/bad-recipient",
|
|
197
|
-
"wrap: recipient string " + JSON.stringify(r) + " not recognised; \"tenant\"
|
|
259
|
+
"wrap: recipient string " + JSON.stringify(r) + " not recognised; the only string recipient is \"tenant\" (with opts.tenantId)");
|
|
198
260
|
}
|
|
199
261
|
if (r.peerCertDer || r.peerKemPubkey) {
|
|
200
262
|
if (!r.peerCertDer || !r.peerKemPubkey) {
|
|
201
263
|
throw new ArchiveWrapError("archive-wrap/bad-recipient",
|
|
202
264
|
"wrap: peer-cert strategy requires BOTH peerCertDer + peerKemPubkey");
|
|
203
265
|
}
|
|
204
|
-
return
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
266
|
+
return {
|
|
267
|
+
version: ARCH_WRAP_VERSION,
|
|
268
|
+
body: Buffer.from(bCrypto().encryptEnvelopeAsCertPeer(bytes, {
|
|
269
|
+
peerCertDer: r.peerCertDer,
|
|
270
|
+
peerKemPubkey: r.peerKemPubkey,
|
|
271
|
+
}), "utf-8"),
|
|
272
|
+
};
|
|
208
273
|
}
|
|
209
274
|
if (r.publicKey) {
|
|
210
275
|
// Codex P2 on v0.12.10 PR #161 — b.crypto.encrypt falls back to
|
|
@@ -221,10 +286,13 @@ function _encryptForRecipient(bytes, opts) {
|
|
|
221
286
|
"and ecPublicKey (P-384 ECDH PEM). Partial recipients trip b.crypto.encrypt's " +
|
|
222
287
|
"ML-KEM-only fallback which silently degrades the hybrid contract this primitive promises.");
|
|
223
288
|
}
|
|
224
|
-
return
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
289
|
+
return {
|
|
290
|
+
version: ARCH_WRAP_VERSION,
|
|
291
|
+
body: Buffer.from(bCrypto().encrypt(bytes, {
|
|
292
|
+
publicKey: r.publicKey,
|
|
293
|
+
ecPublicKey: r.ecPublicKey,
|
|
294
|
+
}), "utf-8"),
|
|
295
|
+
};
|
|
228
296
|
}
|
|
229
297
|
throw new ArchiveWrapError("archive-wrap/bad-recipient",
|
|
230
298
|
"wrap: recipient must be { publicKey, ecPublicKey } | { peerCertDer, peerKemPubkey } | \"tenant\"");
|
package/lib/atomic-file.js
CHANGED
|
@@ -146,14 +146,18 @@ function fsync(fd) {
|
|
|
146
146
|
* b.atomicFile.fsyncDir("/var/lib/blamejs/data");
|
|
147
147
|
*/
|
|
148
148
|
function fsyncDir(dirPath) {
|
|
149
|
-
// CodeQL js/insecure-temporary-file:
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
149
|
+
// CodeQL js/insecure-temporary-file: this is a read-only open of an
|
|
150
|
+
// EXISTING directory to fsync its inode — no file is created, so the
|
|
151
|
+
// predictable-temp-name / symlink-race the query targets does not
|
|
152
|
+
// apply. The fd is opened "r", fsynced, and closed immediately; no
|
|
153
|
+
// write goes through it. The directory itself is created 0o700 by
|
|
154
|
+
// ensureDir. dirPath is normally an operator data dir (e.g.
|
|
155
|
+
// /var/lib/blamejs/data); when a caller fsyncs a dir under os.tmpdir
|
|
156
|
+
// (test fixtures via fs.mkdtempSync, or an audit bundle written to a
|
|
157
|
+
// tmp `out`), mkdtempSync already guarantees a unique 0o700 dir, so
|
|
158
|
+
// there is still no race surface.
|
|
155
159
|
try {
|
|
156
|
-
var fd = nodeFs.openSync(dirPath, "r");
|
|
160
|
+
var fd = nodeFs.openSync(dirPath, "r"); // lgtm[js/insecure-temporary-file] — read-only fsync of an existing dir; no temp file created
|
|
157
161
|
try { nodeFs.fsyncSync(fd); } catch (_e) { /* Windows rejects directory fsync */ }
|
|
158
162
|
finally { nodeFs.closeSync(fd); }
|
|
159
163
|
} catch (_e) { /* dir fsync is best-effort across filesystems */ }
|
package/lib/audit-tools.js
CHANGED
|
@@ -291,36 +291,43 @@ async function _defaultReadPredecessorRowHash(firstCounter) {
|
|
|
291
291
|
|
|
292
292
|
// ---- Bundle writer ----
|
|
293
293
|
|
|
294
|
-
|
|
295
|
-
|
|
294
|
+
// Assemble the encrypted bundle entirely in memory: returns the
|
|
295
|
+
// manifest plus an ordered { filename: Buffer } map. Pure — no
|
|
296
|
+
// filesystem touch — so it backs both the on-disk writer and the
|
|
297
|
+
// returnBytes / serverless path. The bundle is always the same 2-3
|
|
298
|
+
// files (rows.enc, optional checkpoint.enc, manifest.json) whether it
|
|
299
|
+
// lands on disk or ships as bytes.
|
|
300
|
+
async function _buildBundle(args) {
|
|
296
301
|
var kind = args.kind;
|
|
297
302
|
var rows = args.rows;
|
|
298
303
|
var checkpoint = args.checkpoint || null;
|
|
299
304
|
var passphrase = args.passphrase;
|
|
300
305
|
var predecessorRowHash = args.predecessorRowHash;
|
|
301
306
|
|
|
302
|
-
atomicFile.ensureDir(outDir);
|
|
303
|
-
|
|
304
307
|
var firstRow = rows[0];
|
|
305
308
|
var lastRow = rows[rows.length - 1];
|
|
309
|
+
var files = {};
|
|
306
310
|
|
|
307
311
|
// 1. Encrypt the rows JSONL
|
|
308
312
|
var jsonl = rows.map(function (r) {
|
|
309
313
|
return JSON.stringify(_rowToWireForm(r));
|
|
310
314
|
}).join("\n") + "\n";
|
|
311
315
|
var rowsEnc = await backupCrypto.encryptWithFreshSalt(jsonl, passphrase);
|
|
312
|
-
|
|
316
|
+
files["rows.enc"] = rowsEnc.encrypted;
|
|
313
317
|
|
|
314
318
|
// 2. (archive) Encrypt the checkpoint JSON
|
|
315
319
|
var checkpointSalt = null;
|
|
320
|
+
var checkpointEncrypted = null;
|
|
316
321
|
if (checkpoint) {
|
|
317
322
|
var ckptJson = _canonicalize(_rowToWireForm(checkpoint));
|
|
318
323
|
var ckptEnc = await backupCrypto.encryptWithFreshSalt(ckptJson, passphrase);
|
|
319
|
-
|
|
324
|
+
files["checkpoint.enc"] = ckptEnc.encrypted;
|
|
320
325
|
checkpointSalt = ckptEnc.salt;
|
|
326
|
+
checkpointEncrypted = ckptEnc.encrypted;
|
|
321
327
|
}
|
|
322
328
|
|
|
323
|
-
// 3. Build manifest
|
|
329
|
+
// 3. Build manifest — checksums computed from the in-memory buffers
|
|
330
|
+
// (no read-back of what we just wrote).
|
|
324
331
|
var manifest = {
|
|
325
332
|
format: BUNDLE_FORMAT,
|
|
326
333
|
kind: kind,
|
|
@@ -342,8 +349,8 @@ async function _writeBundle(args) {
|
|
|
342
349
|
},
|
|
343
350
|
checksum: {
|
|
344
351
|
rowsSha3_512: backupCrypto.checksum(rowsEnc.encrypted),
|
|
345
|
-
checkpointSha3_512:
|
|
346
|
-
? backupCrypto.checksum(
|
|
352
|
+
checkpointSha3_512: checkpointEncrypted
|
|
353
|
+
? backupCrypto.checksum(checkpointEncrypted)
|
|
347
354
|
: null,
|
|
348
355
|
},
|
|
349
356
|
};
|
|
@@ -355,9 +362,22 @@ async function _writeBundle(args) {
|
|
|
355
362
|
checkpointId: String(checkpoint._id),
|
|
356
363
|
};
|
|
357
364
|
}
|
|
365
|
+
files["manifest.json"] = Buffer.from(_canonicalize(manifest), "utf8");
|
|
366
|
+
return { manifest: manifest, files: files };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function _writeBundle(args) {
|
|
370
|
+
var outDir = args.outDir;
|
|
371
|
+
var built = await _buildBundle(args);
|
|
372
|
+
|
|
373
|
+
atomicFile.ensureDir(outDir);
|
|
374
|
+
atomicFile.writeSync(nodePath.join(outDir, "rows.enc"), built.files["rows.enc"], { fileMode: 0o600 });
|
|
375
|
+
if (built.files["checkpoint.enc"]) {
|
|
376
|
+
atomicFile.writeSync(nodePath.join(outDir, "checkpoint.enc"), built.files["checkpoint.enc"], { fileMode: 0o600 });
|
|
377
|
+
}
|
|
358
378
|
var manifestPath = nodePath.join(outDir, "manifest.json");
|
|
359
|
-
atomicFile.writeSync(manifestPath,
|
|
360
|
-
return { manifest: manifest, manifestPath: manifestPath };
|
|
379
|
+
atomicFile.writeSync(manifestPath, built.files["manifest.json"], { fileMode: 0o600 });
|
|
380
|
+
return { manifest: built.manifest, manifestPath: manifestPath };
|
|
361
381
|
}
|
|
362
382
|
|
|
363
383
|
// ---- Bundle reader ----
|
|
@@ -437,8 +457,14 @@ async function _readBundle(inDir, passphrase) {
|
|
|
437
457
|
* Refuses if `opts.out` exists, no rows match, or no signed
|
|
438
458
|
* checkpoint covers the slice (run `b.audit.checkpoint()` first).
|
|
439
459
|
*
|
|
460
|
+
* Pass `returnBytes: true` instead of `out` for the bundle as an
|
|
461
|
+
* in-memory `{ filename: Buffer }` map (`rows.enc` + `checkpoint.enc`
|
|
462
|
+
* + `manifest.json`) — the read-only / serverless path. `out` and
|
|
463
|
+
* `returnBytes` are mutually exclusive.
|
|
464
|
+
*
|
|
440
465
|
* @opts
|
|
441
|
-
* out: string, // fresh directory path
|
|
466
|
+
* out: string, // fresh directory path (omit when returnBytes)
|
|
467
|
+
* returnBytes:boolean, // true → return { manifest, files } in memory, no disk
|
|
442
468
|
* before: number|Date|string, // archive rows recordedAt < this
|
|
443
469
|
* passphrase: Buffer|string, // bundle-encryption passphrase
|
|
444
470
|
*
|
|
@@ -455,7 +481,12 @@ async function _readBundle(inDir, passphrase) {
|
|
|
455
481
|
async function archive(opts) {
|
|
456
482
|
opts = opts || {};
|
|
457
483
|
_requirePassphrase(opts.passphrase);
|
|
458
|
-
|
|
484
|
+
var returnBytes = opts.returnBytes === true;
|
|
485
|
+
if (returnBytes && opts.out !== undefined) {
|
|
486
|
+
throw new AuditToolsError("audit-tools/out-and-return-bytes",
|
|
487
|
+
"archive: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
|
|
488
|
+
}
|
|
489
|
+
if (!returnBytes) _requireOutDir(opts.out, "archive");
|
|
459
490
|
var beforeMs = _toMs(opts.before);
|
|
460
491
|
if (beforeMs == null) {
|
|
461
492
|
throw new AuditToolsError("audit-tools/no-before",
|
|
@@ -482,6 +513,22 @@ async function archive(opts) {
|
|
|
482
513
|
|
|
483
514
|
var predecessorRowHash = await readPredecessorHash(firstCounter);
|
|
484
515
|
|
|
516
|
+
if (returnBytes) {
|
|
517
|
+
var built = await _buildBundle({
|
|
518
|
+
kind: KIND_ARCHIVE,
|
|
519
|
+
rows: rows,
|
|
520
|
+
checkpoint: checkpoint,
|
|
521
|
+
passphrase: opts.passphrase,
|
|
522
|
+
predecessorRowHash: predecessorRowHash,
|
|
523
|
+
});
|
|
524
|
+
return {
|
|
525
|
+
manifest: built.manifest,
|
|
526
|
+
files: built.files,
|
|
527
|
+
rowCount: rows.length,
|
|
528
|
+
range: built.manifest.range,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
485
532
|
var written = await _writeBundle({
|
|
486
533
|
outDir: opts.out,
|
|
487
534
|
kind: KIND_ARCHIVE,
|
|
@@ -517,8 +564,15 @@ async function archive(opts) {
|
|
|
517
564
|
* action filter that drops intermediate counters is rejected with
|
|
518
565
|
* `audit-tools/non-contiguous`.
|
|
519
566
|
*
|
|
567
|
+
* Pass `returnBytes: true` instead of `out` to get the bundle as an
|
|
568
|
+
* in-memory `{ filename: Buffer }` map (`rows.enc` + `manifest.json`)
|
|
569
|
+
* with no filesystem touch — the read-only / serverless path; ship it
|
|
570
|
+
* to object storage or over the wire. `out` and `returnBytes` are
|
|
571
|
+
* mutually exclusive.
|
|
572
|
+
*
|
|
520
573
|
* @opts
|
|
521
|
-
* out: string, // fresh directory path
|
|
574
|
+
* out: string, // fresh directory path (omit when returnBytes)
|
|
575
|
+
* returnBytes:boolean, // true → return { manifest, files } in memory, no disk
|
|
522
576
|
* from: number|Date|string, // recordedAt >= this (inclusive)
|
|
523
577
|
* to: number|Date|string, // recordedAt <= this (inclusive)
|
|
524
578
|
* action: string, // exact action match (optional)
|
|
@@ -536,7 +590,12 @@ async function archive(opts) {
|
|
|
536
590
|
async function exportSlice(opts) {
|
|
537
591
|
opts = opts || {};
|
|
538
592
|
_requirePassphrase(opts.passphrase);
|
|
539
|
-
|
|
593
|
+
var returnBytes = opts.returnBytes === true;
|
|
594
|
+
if (returnBytes && opts.out !== undefined) {
|
|
595
|
+
throw new AuditToolsError("audit-tools/out-and-return-bytes",
|
|
596
|
+
"export: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
|
|
597
|
+
}
|
|
598
|
+
if (!returnBytes) _requireOutDir(opts.out, "export");
|
|
540
599
|
var fromMs = _toMs(opts.from);
|
|
541
600
|
var toMs = _toMs(opts.to);
|
|
542
601
|
var readRows = opts.readRows || _defaultReadRows;
|
|
@@ -567,6 +626,22 @@ async function exportSlice(opts) {
|
|
|
567
626
|
var firstCounter = Number(rows[0].monotonicCounter);
|
|
568
627
|
var predecessorRowHash = await readPredecessorHash(firstCounter);
|
|
569
628
|
|
|
629
|
+
if (returnBytes) {
|
|
630
|
+
var built = await _buildBundle({
|
|
631
|
+
kind: KIND_EXPORT,
|
|
632
|
+
rows: rows,
|
|
633
|
+
checkpoint: null,
|
|
634
|
+
passphrase: opts.passphrase,
|
|
635
|
+
predecessorRowHash: predecessorRowHash,
|
|
636
|
+
});
|
|
637
|
+
return {
|
|
638
|
+
manifest: built.manifest,
|
|
639
|
+
files: built.files,
|
|
640
|
+
rowCount: rows.length,
|
|
641
|
+
range: built.manifest.range,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
570
645
|
var written = await _writeBundle({
|
|
571
646
|
outDir: opts.out,
|
|
572
647
|
kind: KIND_EXPORT,
|
|
@@ -852,9 +927,15 @@ async function _defaultApplyPurge(args) {
|
|
|
852
927
|
* `audit.forensic_snapshot.composed` audit event so the act of
|
|
853
928
|
* composing the snapshot is itself on-chain.
|
|
854
929
|
*
|
|
930
|
+
* Pass `returnBytes: true` instead of `out` for the snapshot as an
|
|
931
|
+
* in-memory `{ filename: Buffer }` map (the slice's `rows.enc` +
|
|
932
|
+
* `manifest.json` plus `forensic-snapshot.json`) — the read-only /
|
|
933
|
+
* serverless path. `out` and `returnBytes` are mutually exclusive.
|
|
934
|
+
*
|
|
855
935
|
* @opts
|
|
856
|
-
* out: string, // fresh directory path
|
|
857
|
-
*
|
|
936
|
+
* out: string, // fresh directory path (omit when returnBytes)
|
|
937
|
+
* returnBytes:boolean, // true → return { ...manifest, files } in memory, no disk
|
|
938
|
+
* since: number|Date|string, // include rows recordedAt >= this (windowed since → now)
|
|
858
939
|
* passphrase: Buffer|string, // bundle-encryption passphrase
|
|
859
940
|
* reason: string, // required incident-context reason
|
|
860
941
|
* incidentId: string, // optional ticket / incident id
|
|
@@ -874,29 +955,39 @@ async function _defaultApplyPurge(args) {
|
|
|
874
955
|
async function forensicSnapshot(opts) {
|
|
875
956
|
opts = opts || {};
|
|
876
957
|
_requirePassphrase(opts.passphrase);
|
|
877
|
-
|
|
958
|
+
var returnBytes = opts.returnBytes === true;
|
|
959
|
+
if (returnBytes && opts.out !== undefined) {
|
|
960
|
+
throw new AuditToolsError("audit-tools/out-and-return-bytes",
|
|
961
|
+
"forensicSnapshot: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
|
|
962
|
+
}
|
|
963
|
+
if (!returnBytes) _requireOutDir(opts.out, "forensicSnapshot");
|
|
878
964
|
var sinceMs = _toMs(opts.since);
|
|
879
965
|
if (sinceMs == null) {
|
|
880
966
|
throw new AuditToolsError("audit-tools/no-since",
|
|
881
967
|
"forensicSnapshot: opts.since is required");
|
|
882
968
|
}
|
|
883
969
|
validateOpts.requireNonEmptyString(opts.reason, "reason", AuditToolsError, "audit-tools/no-reason");
|
|
970
|
+
// exportSlice windows by from/to — pass the requested `since` as `from`
|
|
971
|
+
// and now as `to` so the snapshot captures only the incident window
|
|
972
|
+
// rather than the entire audit history.
|
|
884
973
|
var sliceResult = await exportSlice({
|
|
885
|
-
out:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
974
|
+
out: returnBytes ? undefined : opts.out,
|
|
975
|
+
returnBytes: returnBytes,
|
|
976
|
+
from: sinceMs,
|
|
977
|
+
to: Date.now(),
|
|
978
|
+
passphrase: opts.passphrase,
|
|
979
|
+
readRows: opts.readRows,
|
|
890
980
|
readCoveringCheckpoint: opts.readCoveringCheckpoint,
|
|
891
981
|
});
|
|
892
|
-
// Compose snapshot manifest with operator-supplied IR context.
|
|
982
|
+
// Compose snapshot manifest with operator-supplied IR context. The
|
|
983
|
+
// audit slice lands as rows.enc inside the bundle either way.
|
|
893
984
|
var manifest = {
|
|
894
985
|
snapshotKind: "forensic",
|
|
895
986
|
incidentId: opts.incidentId || null,
|
|
896
987
|
reason: opts.reason,
|
|
897
988
|
actor: opts.actor || null,
|
|
898
989
|
composedAt: new Date().toISOString(),
|
|
899
|
-
auditSliceFile: sliceResult && sliceResult.
|
|
990
|
+
auditSliceFile: returnBytes ? "rows.enc" : (sliceResult && sliceResult.manifestPath),
|
|
900
991
|
auditSliceCount: sliceResult && sliceResult.rowCount,
|
|
901
992
|
runtime: {
|
|
902
993
|
nodeVersion: process.version,
|
|
@@ -906,14 +997,18 @@ async function forensicSnapshot(opts) {
|
|
|
906
997
|
uptimeSec: Math.round(process.uptime()),
|
|
907
998
|
},
|
|
908
999
|
};
|
|
909
|
-
var
|
|
910
|
-
|
|
1000
|
+
var manifestBytes = Buffer.from(_canonicalize(manifest), "utf8");
|
|
1001
|
+
var manifestPath = null;
|
|
1002
|
+
if (!returnBytes) {
|
|
1003
|
+
manifestPath = nodePath.join(opts.out, "forensic-snapshot.json");
|
|
1004
|
+
atomicFile.writeSync(manifestPath, manifestBytes, { fileMode: 0o600 });
|
|
1005
|
+
}
|
|
911
1006
|
try {
|
|
912
1007
|
require("./audit").safeEmit({
|
|
913
1008
|
action: "audit.forensic_snapshot.composed",
|
|
914
1009
|
outcome: "success",
|
|
915
1010
|
metadata: {
|
|
916
|
-
out: opts.out,
|
|
1011
|
+
out: returnBytes ? null : opts.out,
|
|
917
1012
|
incidentId: manifest.incidentId,
|
|
918
1013
|
reason: opts.reason,
|
|
919
1014
|
actor: opts.actor || null,
|
|
@@ -921,6 +1016,12 @@ async function forensicSnapshot(opts) {
|
|
|
921
1016
|
},
|
|
922
1017
|
});
|
|
923
1018
|
} catch (_e) { /* audit best-effort */ }
|
|
1019
|
+
if (returnBytes) {
|
|
1020
|
+
// Mirror the on-disk layout: the slice's files plus the IR wrapper.
|
|
1021
|
+
var files = Object.assign({}, sliceResult.files);
|
|
1022
|
+
files["forensic-snapshot.json"] = manifestBytes;
|
|
1023
|
+
return Object.assign({}, manifest, { files: files });
|
|
1024
|
+
}
|
|
924
1025
|
return Object.assign({}, manifest, { manifestPath: manifestPath });
|
|
925
1026
|
}
|
|
926
1027
|
|
package/package.json
CHANGED
package/sbom.cdx.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:aca0caec-3bdf-4b4b-bfd8-436286cc5bbf",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-27T22:48:58.698Z",
|
|
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.13.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.13.20",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.13.
|
|
25
|
+
"version": "0.13.20",
|
|
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.13.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.13.20",
|
|
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.13.
|
|
57
|
+
"ref": "@blamejs/core@0.13.20",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|