@blamejs/core 0.17.11 → 0.17.13
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/NOTICE +1 -1
- package/index.js +2 -0
- package/lib/app-shutdown.js +23 -20
- package/lib/audit.js +7 -5
- package/lib/content-digest.js +2 -1
- package/lib/crypto.js +252 -8
- package/lib/daemon.js +268 -24
- package/lib/db-declare-view.js +2 -2
- package/lib/db.js +4 -1
- package/lib/dsr.js +1 -1
- package/lib/i18n-messageformat.js +3 -2
- package/lib/log-stream-otlp-grpc.js +5 -1
- package/lib/log-stream-otlp.js +5 -1
- package/lib/log-stream.js +5 -2
- package/lib/middleware/bot-guard.js +5 -9
- package/lib/outbox.js +1 -1
- package/lib/pid-probe.js +55 -0
- package/lib/pqc-agent.js +8 -1
- package/lib/redact.js +54 -0
- package/lib/safe-object.js +80 -0
- package/lib/self-update-standalone-verifier.js +74 -27
- package/lib/self-update.js +497 -87
- package/lib/ssrf-guard.js +52 -0
- package/lib/vendor/MANIFEST.json +11 -11
- package/lib/vendor/public-suffix-list.dat +7 -41
- package/lib/vendor/public-suffix-list.data.js +5269 -5285
- package/lib/watcher.js +89 -17
- package/lib/webhook-dispatcher.js +1 -1
- package/lib/ws-client.js +17 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/self-update.js
CHANGED
|
@@ -57,7 +57,6 @@ var nodeCrypto = require("node:crypto");
|
|
|
57
57
|
var numericBounds = require("./numeric-bounds");
|
|
58
58
|
var atomicFile = require("./atomic-file");
|
|
59
59
|
var validateOpts = require("./validate-opts");
|
|
60
|
-
var bCrypto = require("./crypto");
|
|
61
60
|
var guardRegex = require("./guard-regex");
|
|
62
61
|
var httpClient = require("./http-client");
|
|
63
62
|
var safeJson = require("./safe-json");
|
|
@@ -301,6 +300,60 @@ function _matchAsset(name, pattern, fallback) {
|
|
|
301
300
|
return fallback ? fallback.test(name) : false;
|
|
302
301
|
}
|
|
303
302
|
|
|
303
|
+
// Detached-signature suffixes. A release's detached signature is conventionally
|
|
304
|
+
// the asset name plus one of these (asset.tar.gz.sig / .asc / .sig.bin).
|
|
305
|
+
var _SIG_SUFFIXES = [".sig", ".asc", ".sig.bin"];
|
|
306
|
+
var _SIG_SHAPE = /\.sig$|\.asc$|\.sig\.bin$/i;
|
|
307
|
+
|
|
308
|
+
function _assetObj(a) {
|
|
309
|
+
return {
|
|
310
|
+
name: a.name,
|
|
311
|
+
url: a.browser_download_url,
|
|
312
|
+
size: a.size || null,
|
|
313
|
+
digest: typeof a.digest === "string" ? a.digest : null,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _findEntryByName(entries, name) {
|
|
318
|
+
for (var i = 0; i < entries.length; i++) {
|
|
319
|
+
if (entries[i].name === name) return entries[i];
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// _selectSignatureFor — pick the detached signature OF `assetName`, not a
|
|
325
|
+
// first-match-wins signature that may sign a DIFFERENT sidecar. Selecting the
|
|
326
|
+
// asset first and DERIVING the expected signature name from it is the pairing
|
|
327
|
+
// contract: a returned { asset, signature } is guaranteed to be an asset and
|
|
328
|
+
// the signature over exactly that asset. Falls back to a lone signature-shaped
|
|
329
|
+
// asset only when the release ships exactly one (the common one-asset-one-sig
|
|
330
|
+
// case); anything ambiguous fails closed (null) rather than pairing a signature
|
|
331
|
+
// that may not sign the returned asset.
|
|
332
|
+
function _selectSignatureFor(assetName, entries, signaturePattern) {
|
|
333
|
+
// (a) Strong pairing: the asset name plus a signature suffix.
|
|
334
|
+
for (var s = 0; s < _SIG_SUFFIXES.length; s++) {
|
|
335
|
+
var hit = _findEntryByName(entries, assetName + _SIG_SUFFIXES[s]);
|
|
336
|
+
// When the operator constrained signaturePattern, the derived name must
|
|
337
|
+
// also satisfy it; otherwise the derived name is authoritative.
|
|
338
|
+
if (hit && (signaturePattern === undefined || _matchAsset(hit.name, signaturePattern, null))) {
|
|
339
|
+
return _assetObj(hit);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// (b) Operator signaturePattern, no derived hit: accept only a pattern match
|
|
343
|
+
// that ALSO references the asset stem, and only when unambiguous — else fail
|
|
344
|
+
// closed (never pair a pattern hit that may sign a different asset).
|
|
345
|
+
if (signaturePattern !== undefined) {
|
|
346
|
+
var stemMatches = entries.filter(function (e) {
|
|
347
|
+
return e.name.indexOf(assetName) === 0 && _matchAsset(e.name, signaturePattern, null);
|
|
348
|
+
});
|
|
349
|
+
return stemMatches.length === 1 ? _assetObj(stemMatches[0]) : null;
|
|
350
|
+
}
|
|
351
|
+
// (c) No operator pattern, no derived hit: accept a lone signature-shaped
|
|
352
|
+
// asset (single-sig release), else null.
|
|
353
|
+
var sigShaped = entries.filter(function (e) { return _SIG_SHAPE.test(e.name); });
|
|
354
|
+
return sigShaped.length === 1 ? _assetObj(sigShaped[0]) : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
304
357
|
/**
|
|
305
358
|
* @primitive b.selfUpdate.poll
|
|
306
359
|
* @signature b.selfUpdate.poll(opts)
|
|
@@ -474,21 +527,27 @@ async function poll(opts) {
|
|
|
474
527
|
}
|
|
475
528
|
|
|
476
529
|
var assets = Array.isArray(latest.assets) ? latest.assets : [];
|
|
477
|
-
|
|
478
|
-
var
|
|
530
|
+
// Collect the well-formed asset entries once, preserving feed order.
|
|
531
|
+
var entries = [];
|
|
479
532
|
for (var i = 0; i < assets.length; i++) {
|
|
480
533
|
var a = assets[i] || {};
|
|
481
534
|
if (typeof a.name !== "string" || typeof a.browser_download_url !== "string") continue;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
535
|
+
entries.push(a);
|
|
536
|
+
}
|
|
537
|
+
// Select the runtime asset FIRST, then derive its detached signature — so the
|
|
538
|
+
// returned signature is the sig OF the returned asset, never a first-match-wins
|
|
539
|
+
// sig that may belong to a different sidecar (#497).
|
|
540
|
+
var assetMatch = null;
|
|
541
|
+
var signatureMatch = null;
|
|
542
|
+
for (var j = 0; j < entries.length; j++) {
|
|
543
|
+
if (_matchAsset(entries[j].name, opts.assetPattern, /\.(tar\.gz|tgz|zip|node|exe|bin)$/i)) {
|
|
544
|
+
assetMatch = _assetObj(entries[j]);
|
|
545
|
+
break;
|
|
490
546
|
}
|
|
491
547
|
}
|
|
548
|
+
if (assetMatch) {
|
|
549
|
+
signatureMatch = _selectSignatureFor(assetMatch.name, entries, opts.signaturePattern);
|
|
550
|
+
}
|
|
492
551
|
|
|
493
552
|
_safeAuditEmit("selfupdate.poll.checked", "success", {
|
|
494
553
|
releasesUrl: opts.releasesUrl,
|
|
@@ -539,13 +598,20 @@ function _validateVerifyOpts(opts) {
|
|
|
539
598
|
* @since 0.6.0
|
|
540
599
|
* @related b.selfUpdate.poll, b.selfUpdate.swap, b.crypto.verify
|
|
541
600
|
*
|
|
542
|
-
* Verify a detached signature over the asset bytes. The
|
|
543
|
-
* auto-detected from `opts.pubkeyPem` (ML-DSA-87 / Ed25519
|
|
544
|
-
* P-384)
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
601
|
+
* Verify a detached signature over the asset bytes. The signature
|
|
602
|
+
* algorithm is auto-detected from `opts.pubkeyPem` (ML-DSA-87 / Ed25519
|
|
603
|
+
* / ECDSA P-384). Verification routes through the framework's own
|
|
604
|
+
* `standaloneVerifier`, which streams the asset (no whole-file buffer),
|
|
605
|
+
* commits to a SHA3-512 digest, and dispatches the ECDSA signature
|
|
606
|
+
* encoding by structure (DER SEQUENCE vs raw IEEE-P1363) — so a release
|
|
607
|
+
* sidecar signed SHA3-512-then-sign with either encoding verifies, and
|
|
608
|
+
* the accept set is identical to `b.selfUpdate.standaloneVerifier.verify`
|
|
609
|
+
* (no verifier divergence between the install-pipeline and installed
|
|
610
|
+
* paths). Reports the asset's hash alongside the verified flag for SBOM /
|
|
611
|
+
* audit correlation; the supported digest algorithms are sha3-512
|
|
612
|
+
* (default), sha-256, sha-512, and shake256. Throws SelfUpdateError on a
|
|
613
|
+
* missing file, a verify-time exception, or a signature that does not
|
|
614
|
+
* verify.
|
|
549
615
|
*
|
|
550
616
|
* @opts
|
|
551
617
|
* assetPath: string, // required — path to the downloaded asset
|
|
@@ -565,56 +631,67 @@ function _validateVerifyOpts(opts) {
|
|
|
565
631
|
* e.code; // → "selfupdate/read-failed"
|
|
566
632
|
* }
|
|
567
633
|
*/
|
|
634
|
+
// _mapStandaloneKind — translate a standaloneVerifier `.kind` into this
|
|
635
|
+
// module's typed selfupdate/* code. File-availability / size-cap failures are
|
|
636
|
+
// read-failed; a cryptographic non-verification is signature-mismatch; a
|
|
637
|
+
// structural / key / encoding problem is verify-failed. Keeping the mapping
|
|
638
|
+
// structural (off `.kind`, not English message text) means a message reword in
|
|
639
|
+
// the zero-dep verifier never silently reclassifies a failure here.
|
|
640
|
+
function _mapStandaloneKind(kind) {
|
|
641
|
+
switch (kind) {
|
|
642
|
+
case "asset-not-found":
|
|
643
|
+
case "sig-not-found":
|
|
644
|
+
case "sig-too-large":
|
|
645
|
+
case "asset-too-large":
|
|
646
|
+
case "size-race":
|
|
647
|
+
return "selfupdate/read-failed";
|
|
648
|
+
case "verify-failed":
|
|
649
|
+
return "selfupdate/signature-mismatch";
|
|
650
|
+
default: // bad-input / bad-pubkey / unsupported-key / sig-empty / bad-sig-encoding
|
|
651
|
+
return "selfupdate/verify-failed";
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
568
655
|
async function verify(opts) {
|
|
569
656
|
_validateVerifyOpts(opts);
|
|
570
657
|
var alg = opts.hashAlgo || DEFAULT_HASH_ALG;
|
|
658
|
+
var maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : C.BYTES.gib(1);
|
|
659
|
+
// The default sha3-512 / sha-256 reported digests are already produced by the
|
|
660
|
+
// standalone verifier's single pass; only a non-default reported alg needs an
|
|
661
|
+
// extra digest folded into that same stream (no second read of the asset).
|
|
662
|
+
var extraDigests = (alg === "sha3-512" || alg === "sha-256") ? [] : [alg];
|
|
571
663
|
|
|
572
|
-
var
|
|
573
|
-
var sigBytes;
|
|
664
|
+
var result;
|
|
574
665
|
try {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
666
|
+
// Route the signature verification through the framework's own
|
|
667
|
+
// standaloneVerifier so the installed path and the copy-into-install-pipeline
|
|
668
|
+
// path share ONE verifier — SHA3-512 digest, DER/IEEE-P1363 structural
|
|
669
|
+
// dispatch, streamed (no whole-asset in-memory buffer). It fails closed on a
|
|
670
|
+
// wrong key, truncated / empty signature, or size-cap breach.
|
|
671
|
+
result = standaloneVerifier.verify(opts.assetPath, opts.signaturePath, opts.pubkeyPem, {
|
|
672
|
+
maxAssetBytes: maxBytes,
|
|
673
|
+
extraDigests: extraDigests,
|
|
580
674
|
});
|
|
581
675
|
} catch (e) {
|
|
676
|
+
var code = _mapStandaloneKind(e && e.kind);
|
|
582
677
|
_safeAuditEmit("selfupdate.verify.failed", "denied", {
|
|
583
678
|
assetPath: opts.assetPath, signaturePath: opts.signaturePath,
|
|
584
|
-
reason: "
|
|
585
|
-
});
|
|
586
|
-
throw new SelfUpdateError("selfupdate/read-failed",
|
|
587
|
-
"selfUpdate.verify: read failed: " + ((e && e.message) || String(e)));
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
var ok = false;
|
|
591
|
-
try { ok = bCrypto.verify(assetBytes, sigBytes, opts.pubkeyPem); }
|
|
592
|
-
catch (e) {
|
|
593
|
-
_safeAuditEmit("selfupdate.verify.failed", "denied", {
|
|
594
|
-
assetPath: opts.assetPath, signaturePath: opts.signaturePath,
|
|
595
|
-
reason: "verify-threw", message: (e && e.message) || String(e),
|
|
679
|
+
reason: (e && e.kind) || "verify-error", message: (e && e.message) || String(e),
|
|
596
680
|
});
|
|
597
|
-
throw new SelfUpdateError(
|
|
598
|
-
"selfUpdate.verify:
|
|
681
|
+
throw new SelfUpdateError(code,
|
|
682
|
+
"selfUpdate.verify: " + ((e && e.message) || String(e)));
|
|
599
683
|
}
|
|
600
684
|
|
|
601
|
-
var hashHex =
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
_safeAuditEmit("selfupdate.verify.failed", "denied", {
|
|
605
|
-
assetPath: opts.assetPath, signaturePath: opts.signaturePath,
|
|
606
|
-
alg: alg, hash: hashHex, reason: "signature-mismatch",
|
|
607
|
-
});
|
|
608
|
-
throw new SelfUpdateError("selfupdate/signature-mismatch",
|
|
609
|
-
"selfUpdate.verify: signature did not verify against the supplied public key");
|
|
610
|
-
}
|
|
685
|
+
var hashHex = alg === "sha3-512" ? result.sha3_512
|
|
686
|
+
: alg === "sha-256" ? result.sha256
|
|
687
|
+
: result.digests[alg];
|
|
611
688
|
|
|
612
689
|
_safeAuditEmit("selfupdate.verify.passed", "success", {
|
|
613
690
|
assetPath: opts.assetPath, signaturePath: opts.signaturePath,
|
|
614
|
-
alg: alg, hash: hashHex, bytes:
|
|
691
|
+
alg: alg, hash: hashHex, bytes: result.bytes,
|
|
615
692
|
});
|
|
616
693
|
log("selfUpdate.verify passed asset=" + opts.assetPath + " alg=" + alg);
|
|
617
|
-
return { verified: true, hash: hashHex, alg: alg, bytes:
|
|
694
|
+
return { verified: true, hash: hashHex, alg: alg, bytes: result.bytes };
|
|
618
695
|
}
|
|
619
696
|
|
|
620
697
|
// ---- swap ----
|
|
@@ -656,25 +733,47 @@ function _validateSwapOpts(opts, label) {
|
|
|
656
733
|
validateOpts.shape(opts, schema, "selfUpdate." + label, SelfUpdateError, "selfupdate/bad-opts");
|
|
657
734
|
}
|
|
658
735
|
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
// `
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
|
|
667
|
-
|
|
736
|
+
// _relocateFile — move `src` -> `dst`, preferring an atomic rename. A rename
|
|
737
|
+
// moves even a locked, RUNNING image on Windows (which refuses an in-place
|
|
738
|
+
// replace of a mapped executable but allows a rename/move) and needs no second
|
|
739
|
+
// copy; on EXDEV (cross-volume) it falls back to copy + unlink. This one
|
|
740
|
+
// primitive backs BOTH "move the outgoing `to` aside to its backup before an
|
|
741
|
+
// install" and "restore the backup over `to` on rollback", so the locked-image
|
|
742
|
+
// path is handled identically in the install and rollback directions.
|
|
743
|
+
async function _relocateFile(src, dst, fileMode) {
|
|
744
|
+
try {
|
|
745
|
+
atomicFile.renameWithRetry(src, dst);
|
|
746
|
+
return;
|
|
747
|
+
} catch (e) {
|
|
748
|
+
if (!e || e.code !== "EXDEV") throw e;
|
|
749
|
+
// Cross-volume: a rename can't cross the device boundary. Preserve the
|
|
750
|
+
// bytes by copy, then remove the source (best-effort — a locked cross-volume
|
|
751
|
+
// source is the documented limitation of this rare fallback).
|
|
752
|
+
await atomicFile.copy(src, dst, { fileMode: fileMode });
|
|
753
|
+
try { nodeFs.unlinkSync(src); } catch (_u) { /* cross-vol source cleanup — operator-cleanable */ }
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// _safeRollback — best-effort restore of `to` from `backupTo` during the swap
|
|
758
|
+
// failure paths. Routes through _relocateFile so the moved-aside backup is
|
|
759
|
+
// renamed back over the (now-absent) `to` — a rename that succeeds even where an
|
|
760
|
+
// in-place copy-replace would be blocked by a lock. Returns null on success (or
|
|
761
|
+
// when no backup existed); returns the rollback Error otherwise so the caller
|
|
762
|
+
// can throw a distinct `selfupdate/swap-rollback-failed`. Emits the
|
|
763
|
+
// `selfupdate.swap.rollback_failed` audit event when rollback fails (the prior
|
|
764
|
+
// best-effort catch dropped this signal silently — operators with no audit row
|
|
765
|
+
// for `rollback_failed` couldn't tell a successful swap-with-rollback from a
|
|
766
|
+
// failed both-binaries-lost scenario). SSDF RV.1.
|
|
668
767
|
async function _safeRollback(backupTo, to, hadOriginal) {
|
|
669
768
|
if (!hadOriginal) return null;
|
|
670
769
|
try {
|
|
671
|
-
await
|
|
770
|
+
await _relocateFile(backupTo, to, 0o600);
|
|
672
771
|
return null;
|
|
673
772
|
} catch (re) {
|
|
674
773
|
var err = re instanceof Error ? re : new Error(String(re));
|
|
675
774
|
_safeAuditEmit("selfupdate.swap.rollback_failed", "denied", {
|
|
676
775
|
to: to, backupTo: backupTo,
|
|
677
|
-
reason: "rollback-
|
|
776
|
+
reason: "rollback-restore-failed",
|
|
678
777
|
message: err.message,
|
|
679
778
|
});
|
|
680
779
|
return err;
|
|
@@ -684,29 +783,33 @@ async function _safeRollback(backupTo, to, hadOriginal) {
|
|
|
684
783
|
// Atomic swap of `from` -> `to` with rollback on failure. Steps:
|
|
685
784
|
//
|
|
686
785
|
// 1. ensure `to` and `backupTo` parents exist
|
|
687
|
-
// 2. if `to` exists —
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
786
|
+
// 2. if `to` exists — MOVE it aside to `backupTo` via a rename (this both
|
|
787
|
+
// frees `to` and IS the backup). A rename moves even a locked, running
|
|
788
|
+
// image on Windows, where an in-place replace of a mapped exe is refused;
|
|
789
|
+
// cross-volume (EXDEV) falls back to copy + unlink.
|
|
790
|
+
// 3. write the verified in-memory bytes to the now-free `to` (a create, not
|
|
791
|
+
// a replace of a locked file)
|
|
691
792
|
// 4. fsync both directories (best-effort across platforms)
|
|
692
793
|
//
|
|
693
|
-
// If step
|
|
694
|
-
//
|
|
794
|
+
// If step 2 fails the original `to` is intact (surfaced as backup-failed); if
|
|
795
|
+
// step 3 fails the moved-aside backup is renamed back over `to` (rollback); if
|
|
796
|
+
// step 4 fails the swap is considered complete (operator can audit).
|
|
695
797
|
/**
|
|
696
798
|
* @primitive b.selfUpdate.swap
|
|
697
799
|
* @signature b.selfUpdate.swap(opts)
|
|
698
800
|
* @since 0.6.0
|
|
699
|
-
* @related b.selfUpdate.verify, b.selfUpdate.rollback, b.atomicFile.
|
|
801
|
+
* @related b.selfUpdate.verify, b.selfUpdate.rollback, b.atomicFile.write
|
|
700
802
|
*
|
|
701
803
|
* Atomic install: re-hash `from` and refuse unless it matches `expectedHash`
|
|
702
804
|
* (the hash selfUpdate.verify returned — this binds the installed bytes to the
|
|
703
|
-
* signature-verified bytes and closes the verify→swap window),
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
805
|
+
* signature-verified bytes and closes the verify→swap window), MOVE the existing
|
|
806
|
+
* `to` aside to `backupTo` with a rename (which succeeds on a locked, running
|
|
807
|
+
* image where an in-place replace is refused, and IS the backup), write the
|
|
808
|
+
* verified bytes to the now-free `to`, then fsync both directories. `backupTo`
|
|
809
|
+
* must be on the same volume as `to`; a cross-volume backup (EXDEV) falls back to
|
|
810
|
+
* copy + replace. On an install-write failure after the move-aside, the backup is
|
|
811
|
+
* restored over `to`. Throws SelfUpdateError on a missing `from`, an expectedHash
|
|
812
|
+
* mismatch, a move-aside/backup failure, or an install-write failure.
|
|
710
813
|
*
|
|
711
814
|
* @opts
|
|
712
815
|
* from: string, // required — newly-installed asset path
|
|
@@ -777,26 +880,31 @@ async function swap(opts) {
|
|
|
777
880
|
atomicFile.ensureDir(toDir);
|
|
778
881
|
atomicFile.ensureDir(backupDir);
|
|
779
882
|
|
|
780
|
-
// Step 2 —
|
|
781
|
-
//
|
|
883
|
+
// Step 2 — move the outgoing `to` ASIDE to `backupTo` via a rename. A rename
|
|
884
|
+
// frees the path even when `to` is a locked, running image (Windows refuses an
|
|
885
|
+
// in-place replace of a mapped executable but allows the move), and the moved
|
|
886
|
+
// file IS the backup — so no separate copy of the old bytes is needed. The
|
|
887
|
+
// move-aside failing leaves the original `to` intact (surfaced as backup-failed).
|
|
782
888
|
var hadOriginal = nodeFs.existsSync(to);
|
|
783
889
|
if (hadOriginal) {
|
|
784
890
|
try {
|
|
785
|
-
await
|
|
891
|
+
await _relocateFile(to, backupTo, 0o600);
|
|
786
892
|
} catch (e) {
|
|
787
893
|
throw new SelfUpdateError("selfupdate/backup-failed",
|
|
788
|
-
"selfUpdate.swap: failed to
|
|
894
|
+
"selfUpdate.swap: failed to move " + to + " aside to " + backupTo + ": " +
|
|
789
895
|
((e && e.message) || String(e)));
|
|
790
896
|
}
|
|
791
897
|
}
|
|
792
898
|
|
|
793
|
-
// Step 3 — install the verified in-memory bytes via an
|
|
794
|
-
// rename
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
899
|
+
// Step 3 — install the verified in-memory bytes at the now-free `to` via an
|
|
900
|
+
// atomic temp+fsync+rename (atomicFile.write). With `to` moved aside (or never
|
|
901
|
+
// present), this rename is a CREATE at a free path, not a replace of a locked
|
|
902
|
+
// file — so it succeeds on a running Windows image. The installed object is
|
|
903
|
+
// exactly the bytes just hashed (installed from memory), so there is no by-path
|
|
904
|
+
// re-read to race and no symlinked source to move into place. On failure the
|
|
905
|
+
// moved-aside backup is renamed back over `to`; a rollback failure surfaces as
|
|
906
|
+
// a DISTINCT error class + audit event so operators don't silently lose both
|
|
907
|
+
// binaries (SSDF RV.1).
|
|
800
908
|
try {
|
|
801
909
|
await atomicFile.write(to, fromBytes, { fileMode: fromMode, overwrite: true });
|
|
802
910
|
} catch (e) {
|
|
@@ -882,11 +990,313 @@ async function rollback(opts) {
|
|
|
882
990
|
return { ok: true, restoredAt: Date.now(), to: to, backupTo: backupTo };
|
|
883
991
|
}
|
|
884
992
|
|
|
993
|
+
// ---- probation / auto-rollback orchestration ----
|
|
994
|
+
//
|
|
995
|
+
// A swap installs the new binary; probation gives it a bounded window to prove
|
|
996
|
+
// itself before the install is considered final. The new binary (or the
|
|
997
|
+
// operator's shutdown hook) calls confirmHealthy() once it is up + healthy,
|
|
998
|
+
// which clears the marker — the "clean / healthy" signal. On the next boot
|
|
999
|
+
// evaluateOnBoot() reads the marker: still inside the window means a clean stop /
|
|
1000
|
+
// restart (NOT a crash) so it keeps; past the window with no confirmHealthy means
|
|
1001
|
+
// the binary never became healthy so it rolls the known-good backup back over the
|
|
1002
|
+
// target. Rollback re-verifies first (the installed bytes must still hash to the
|
|
1003
|
+
// probationary expectedHash and the backup must exist) so a marker left behind by
|
|
1004
|
+
// a swap that FAILED — where the new binary was never installed — never triggers
|
|
1005
|
+
// a phantom rollback.
|
|
1006
|
+
|
|
1007
|
+
var PROBATION_MARKER_SUFFIX = ".blamejs-probation.json";
|
|
1008
|
+
var PROBATION_MARKER_MAX = C.BYTES.kib(64); // marker is a small JSON record
|
|
1009
|
+
|
|
1010
|
+
function _resolveMarkerPath(opts) {
|
|
1011
|
+
if (typeof opts.markerPath === "string" && opts.markerPath.length > 0) return opts.markerPath;
|
|
1012
|
+
return opts.to + PROBATION_MARKER_SUFFIX;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function _probationHashAlgo(value, method) {
|
|
1016
|
+
if (value !== undefined && (typeof value !== "string" || ALLOWED_HASH_ALGS.indexOf(value) === -1)) {
|
|
1017
|
+
throw new SelfUpdateError("selfupdate/bad-hash-algo",
|
|
1018
|
+
"selfUpdate." + method + ": opts.hashAlgo must be one of " + ALLOWED_HASH_ALGS.join(", "));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Field-descriptor builders for the probation validators — the required/optional
|
|
1023
|
+
// string field shape composes from one definition instead of repeating an inline
|
|
1024
|
+
// `{ rule, code, label }` object per field across the three validators.
|
|
1025
|
+
function _reqStr(code, label) { return { rule: "required-string", code: code, label: label }; }
|
|
1026
|
+
function _optStr(code, label) { return { rule: "optional-string", code: code, label: label }; }
|
|
1027
|
+
function _probationLabel(method, field) { return "selfUpdate." + method + ": opts." + field; }
|
|
1028
|
+
|
|
1029
|
+
function _validateProbationBeginOpts(opts) {
|
|
1030
|
+
validateOpts.shape(opts, {
|
|
1031
|
+
to: _reqStr("selfupdate/bad-to", _probationLabel("beginProbation", "to")),
|
|
1032
|
+
backupTo: _reqStr("selfupdate/bad-backup", _probationLabel("beginProbation", "backupTo")),
|
|
1033
|
+
expectedHash: _reqStr("selfupdate/bad-expected-hash", _probationLabel("beginProbation", "expectedHash")),
|
|
1034
|
+
windowMs: function (value) {
|
|
1035
|
+
numericBounds.requirePositiveFiniteIntIfPresent(value,
|
|
1036
|
+
_probationLabel("beginProbation", "windowMs"), SelfUpdateError, "selfupdate/bad-window");
|
|
1037
|
+
},
|
|
1038
|
+
hashAlgo: function (value) { _probationHashAlgo(value, "beginProbation"); },
|
|
1039
|
+
markerPath: _optStr("selfupdate/bad-marker-path", _probationLabel("beginProbation", "markerPath")),
|
|
1040
|
+
}, "selfUpdate.beginProbation", SelfUpdateError, "selfupdate/bad-opts");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function _validateConfirmOpts(opts) {
|
|
1044
|
+
validateOpts.shape(opts, {
|
|
1045
|
+
to: _reqStr("selfupdate/bad-to", _probationLabel("confirmHealthy", "to")),
|
|
1046
|
+
markerPath: _optStr("selfupdate/bad-marker-path", _probationLabel("confirmHealthy", "markerPath")),
|
|
1047
|
+
}, "selfUpdate.confirmHealthy", SelfUpdateError, "selfupdate/bad-opts");
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function _validateEvaluateOpts(opts) {
|
|
1051
|
+
validateOpts.shape(opts, {
|
|
1052
|
+
to: _reqStr("selfupdate/bad-to", _probationLabel("evaluateOnBoot", "to")),
|
|
1053
|
+
backupTo: _optStr("selfupdate/bad-backup", _probationLabel("evaluateOnBoot", "backupTo")),
|
|
1054
|
+
markerPath: _optStr("selfupdate/bad-marker-path", _probationLabel("evaluateOnBoot", "markerPath")),
|
|
1055
|
+
now: function (value) {
|
|
1056
|
+
numericBounds.requirePositiveFiniteIntIfPresent(value,
|
|
1057
|
+
_probationLabel("evaluateOnBoot", "now"), SelfUpdateError, "selfupdate/bad-now");
|
|
1058
|
+
},
|
|
1059
|
+
}, "selfUpdate.evaluateOnBoot", SelfUpdateError, "selfupdate/bad-opts");
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function _probationKeep(reason, to, markerPath) {
|
|
1063
|
+
// A boot with no probation marker is the steady-state no-op — don't audit it
|
|
1064
|
+
// every boot. Every other keep reason is a real probation transition.
|
|
1065
|
+
if (reason !== "no-probation-active") {
|
|
1066
|
+
_safeAuditEmit("selfupdate.probation.kept", "success", {
|
|
1067
|
+
to: to, markerPath: markerPath, reason: reason,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
return { action: "keep", reason: reason };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* @primitive b.selfUpdate.beginProbation
|
|
1075
|
+
* @signature b.selfUpdate.beginProbation(opts)
|
|
1076
|
+
* @since 0.17.13
|
|
1077
|
+
* @status stable
|
|
1078
|
+
* @related b.selfUpdate.confirmHealthy, b.selfUpdate.evaluateOnBoot, b.selfUpdate.swap
|
|
1079
|
+
*
|
|
1080
|
+
* Arm a bounded post-install probation for a freshly-swapped binary. Writes an
|
|
1081
|
+
* atomic marker (`to` + `.blamejs-probation.json`, or `opts.markerPath`)
|
|
1082
|
+
* recording the target, the known-good backup, the installed bytes' hash, and an
|
|
1083
|
+
* `expiresAt` = now + `windowMs`. The new binary calls `confirmHealthy` once it
|
|
1084
|
+
* is up and healthy (clearing the marker); if the window elapses with no such
|
|
1085
|
+
* confirmation, the next `evaluateOnBoot` rolls the backup back over the target.
|
|
1086
|
+
*
|
|
1087
|
+
* The marker is written via `b.atomicFile.writeJson` (temp + fsync + rename), so
|
|
1088
|
+
* a process that dies mid-write leaves either the previous complete marker or
|
|
1089
|
+
* none — never a half-written record a boot could misread.
|
|
1090
|
+
*
|
|
1091
|
+
* @opts
|
|
1092
|
+
* to: string, // required — installed binary path (the probationary target)
|
|
1093
|
+
* backupTo: string, // required — known-good backup restored on a failed probation
|
|
1094
|
+
* expectedHash: string, // required — hash of the installed bytes (selfUpdate.verify/swap's hash)
|
|
1095
|
+
* windowMs: number, // probation window in ms; default 10 minutes
|
|
1096
|
+
* hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
|
|
1097
|
+
* markerPath: string, // override marker path (default: `to` + ".blamejs-probation.json")
|
|
1098
|
+
*
|
|
1099
|
+
* @example
|
|
1100
|
+
* var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
|
|
1101
|
+
* await b.selfUpdate.swap({ from, to, backupTo, expectedHash: v.hash });
|
|
1102
|
+
* var p = await b.selfUpdate.beginProbation({ to, backupTo, expectedHash: v.hash });
|
|
1103
|
+
* p.expiresAt; // → epoch ms the probation window closes
|
|
1104
|
+
*/
|
|
1105
|
+
async function beginProbation(opts) {
|
|
1106
|
+
_validateProbationBeginOpts(opts);
|
|
1107
|
+
var markerPath = _resolveMarkerPath(opts);
|
|
1108
|
+
var windowMs = typeof opts.windowMs === "number" ? opts.windowMs : C.TIME.minutes(10);
|
|
1109
|
+
var hashAlgo = opts.hashAlgo || DEFAULT_HASH_ALG;
|
|
1110
|
+
var installedAt = Date.now();
|
|
1111
|
+
var expiresAt = installedAt + windowMs;
|
|
1112
|
+
|
|
1113
|
+
// Carry a monotonically increasing generation across successive probations of
|
|
1114
|
+
// the same target — each install supersedes the prior probation record.
|
|
1115
|
+
var generation = 1;
|
|
1116
|
+
try {
|
|
1117
|
+
var prior = await atomicFile.readJson(markerPath, { maxBytes: PROBATION_MARKER_MAX });
|
|
1118
|
+
if (prior && typeof prior.generation === "number" && isFinite(prior.generation)) {
|
|
1119
|
+
generation = prior.generation + 1;
|
|
1120
|
+
}
|
|
1121
|
+
} catch (_p) { /* no prior marker (or unreadable) — first generation */ }
|
|
1122
|
+
|
|
1123
|
+
var marker = {
|
|
1124
|
+
schema: 1,
|
|
1125
|
+
installedAt: installedAt,
|
|
1126
|
+
expiresAt: expiresAt,
|
|
1127
|
+
windowMs: windowMs,
|
|
1128
|
+
to: opts.to,
|
|
1129
|
+
backupTo: opts.backupTo,
|
|
1130
|
+
expectedHash: opts.expectedHash,
|
|
1131
|
+
hashAlgo: hashAlgo,
|
|
1132
|
+
generation: generation,
|
|
1133
|
+
};
|
|
1134
|
+
var written = await atomicFile.writeJson(markerPath, marker, { computeHash: true, fileMode: 0o600 });
|
|
1135
|
+
|
|
1136
|
+
_safeAuditEmit("selfupdate.probation.begin", "success", {
|
|
1137
|
+
to: opts.to, backupTo: opts.backupTo, markerPath: markerPath,
|
|
1138
|
+
expiresAt: expiresAt, generation: generation, markerHash: written.hash,
|
|
1139
|
+
});
|
|
1140
|
+
log("selfUpdate.beginProbation to=" + opts.to + " expiresAt=" + expiresAt + " gen=" + generation);
|
|
1141
|
+
return { markerPath: markerPath, installedAt: installedAt, expiresAt: expiresAt, generation: generation };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* @primitive b.selfUpdate.confirmHealthy
|
|
1146
|
+
* @signature b.selfUpdate.confirmHealthy(opts)
|
|
1147
|
+
* @since 0.17.13
|
|
1148
|
+
* @status stable
|
|
1149
|
+
* @related b.selfUpdate.beginProbation, b.selfUpdate.evaluateOnBoot
|
|
1150
|
+
*
|
|
1151
|
+
* Clear the probation marker — the explicit clean / healthy signal. The new
|
|
1152
|
+
* binary calls this once its own startup health checks pass (and an operator's
|
|
1153
|
+
* graceful-shutdown hook may call it too, marking a clean stop). With the marker
|
|
1154
|
+
* gone, a later `evaluateOnBoot` finds no probation and keeps the binary. Absence
|
|
1155
|
+
* of this signal at the next boot past the window is what `evaluateOnBoot` reads
|
|
1156
|
+
* as a failed probation. Idempotent: a missing marker returns `cleared: false`.
|
|
1157
|
+
*
|
|
1158
|
+
* @opts
|
|
1159
|
+
* to: string, // required — the probationary target (locates the marker)
|
|
1160
|
+
* markerPath: string, // override marker path (must match beginProbation)
|
|
1161
|
+
*
|
|
1162
|
+
* @example
|
|
1163
|
+
* // in the new binary, after startup health checks pass:
|
|
1164
|
+
* var r = await b.selfUpdate.confirmHealthy({ to: "/opt/app/app.bin" });
|
|
1165
|
+
* r.cleared; // → true (marker removed)
|
|
1166
|
+
*/
|
|
1167
|
+
async function confirmHealthy(opts) {
|
|
1168
|
+
_validateConfirmOpts(opts);
|
|
1169
|
+
var markerPath = _resolveMarkerPath(opts);
|
|
1170
|
+
var cleared = false;
|
|
1171
|
+
if (nodeFs.existsSync(markerPath)) {
|
|
1172
|
+
try {
|
|
1173
|
+
nodeFs.unlinkSync(markerPath);
|
|
1174
|
+
cleared = true;
|
|
1175
|
+
} catch (e) {
|
|
1176
|
+
throw new SelfUpdateError("selfupdate/probation-confirm-failed",
|
|
1177
|
+
"selfUpdate.confirmHealthy: failed to clear probation marker " + markerPath + ": " +
|
|
1178
|
+
((e && e.message) || String(e)));
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
_safeAuditEmit("selfupdate.probation.confirmed", "success", {
|
|
1182
|
+
to: opts.to, markerPath: markerPath, cleared: cleared,
|
|
1183
|
+
});
|
|
1184
|
+
log("selfUpdate.confirmHealthy to=" + opts.to + " cleared=" + cleared);
|
|
1185
|
+
return { ok: true, cleared: cleared, markerPath: markerPath };
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* @primitive b.selfUpdate.evaluateOnBoot
|
|
1190
|
+
* @signature b.selfUpdate.evaluateOnBoot(opts)
|
|
1191
|
+
* @since 0.17.13
|
|
1192
|
+
* @status stable
|
|
1193
|
+
* @related b.selfUpdate.beginProbation, b.selfUpdate.confirmHealthy, b.selfUpdate.rollback
|
|
1194
|
+
*
|
|
1195
|
+
* Decide, at process start, whether a probationary install should be kept or
|
|
1196
|
+
* rolled back. Returns `{ action: "keep" | "rollback", reason }`. No marker, or a
|
|
1197
|
+
* marker still inside its window, keeps (a clean stop / restart within the window
|
|
1198
|
+
* is not a crash). A marker past its window with no `confirmHealthy` means the
|
|
1199
|
+
* binary never became healthy → the known-good backup is restored over the
|
|
1200
|
+
* target and the marker cleared.
|
|
1201
|
+
*
|
|
1202
|
+
* Before restoring, it RE-VERIFIES: the bytes currently at `to` must still hash
|
|
1203
|
+
* to the marker's `expectedHash` (so a marker left by a swap that FAILED — where
|
|
1204
|
+
* the probationary binary was never installed — never triggers a phantom
|
|
1205
|
+
* rollback), and the backup must exist (otherwise it keeps and defers to the
|
|
1206
|
+
* operator rather than destroying the only present binary). A corrupt / malformed
|
|
1207
|
+
* marker keeps, never rolls back.
|
|
1208
|
+
*
|
|
1209
|
+
* @opts
|
|
1210
|
+
* to: string, // required — the probationary target
|
|
1211
|
+
* backupTo: string, // override the marker's backup path
|
|
1212
|
+
* markerPath: string, // override marker path (must match beginProbation)
|
|
1213
|
+
* now: number, // override the wall clock (epoch ms) for deterministic evaluation
|
|
1214
|
+
*
|
|
1215
|
+
* @example
|
|
1216
|
+
* // at process start, before serving traffic:
|
|
1217
|
+
* var d = await b.selfUpdate.evaluateOnBoot({ to: "/opt/app/app.bin" });
|
|
1218
|
+
* if (d.action === "rollback") process.exit(1); // restart onto the restored binary
|
|
1219
|
+
*/
|
|
1220
|
+
async function evaluateOnBoot(opts) {
|
|
1221
|
+
_validateEvaluateOpts(opts);
|
|
1222
|
+
var markerPath = _resolveMarkerPath(opts);
|
|
1223
|
+
var to = opts.to;
|
|
1224
|
+
var now = typeof opts.now === "number" ? opts.now : Date.now();
|
|
1225
|
+
|
|
1226
|
+
if (!nodeFs.existsSync(markerPath)) {
|
|
1227
|
+
return _probationKeep("no-probation-active", to, markerPath);
|
|
1228
|
+
}
|
|
1229
|
+
var marker;
|
|
1230
|
+
try {
|
|
1231
|
+
marker = await atomicFile.readJson(markerPath, { maxBytes: PROBATION_MARKER_MAX });
|
|
1232
|
+
} catch (_e) {
|
|
1233
|
+
// A corrupt / unreadable marker must not phantom-rollback.
|
|
1234
|
+
return _probationKeep("marker-unreadable", to, markerPath);
|
|
1235
|
+
}
|
|
1236
|
+
if (!marker || typeof marker.expiresAt !== "number" || typeof marker.expectedHash !== "string") {
|
|
1237
|
+
return _probationKeep("marker-malformed", to, markerPath);
|
|
1238
|
+
}
|
|
1239
|
+
// Inside the window — a clean stop / restart is not a crash.
|
|
1240
|
+
if (now < marker.expiresAt) {
|
|
1241
|
+
return _probationKeep("within-probation-window", to, markerPath);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Expired with no confirmHealthy. Re-verify before restoring.
|
|
1245
|
+
var backupTo = typeof opts.backupTo === "string" ? opts.backupTo : marker.backupTo;
|
|
1246
|
+
var alg = ALLOWED_HASH_ALGS.indexOf(marker.hashAlgo) !== -1 ? marker.hashAlgo : DEFAULT_HASH_ALG;
|
|
1247
|
+
|
|
1248
|
+
// The probationary binary must actually be the one installed at `to`; if `to`
|
|
1249
|
+
// is absent or holds different bytes (a swap that failed and left the old
|
|
1250
|
+
// binary), rolling back would be a phantom.
|
|
1251
|
+
var currentHash = null;
|
|
1252
|
+
try {
|
|
1253
|
+
var curBytes = atomicFile.fdSafeReadSync(to, { maxBytes: C.BYTES.gib(1) });
|
|
1254
|
+
currentHash = nodeCrypto.createHash(alg).update(curBytes).digest("hex");
|
|
1255
|
+
} catch (_r) { currentHash = null; }
|
|
1256
|
+
if (currentHash !== marker.expectedHash) {
|
|
1257
|
+
return _probationKeep("installed-binary-not-probationary", to, markerPath);
|
|
1258
|
+
}
|
|
1259
|
+
if (typeof backupTo !== "string" || !nodeFs.existsSync(backupTo)) {
|
|
1260
|
+
// No backup to restore — keep the current binary and defer to the operator
|
|
1261
|
+
// rather than leaving the target with nothing.
|
|
1262
|
+
return _probationKeep("backup-unavailable", to, markerPath);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Restore the known-good backup over the failed probationary binary. On boot
|
|
1266
|
+
// the target is not yet running, so an atomic write-replace is safe.
|
|
1267
|
+
try {
|
|
1268
|
+
var backupBytes = atomicFile.fdSafeReadSync(backupTo, { maxBytes: C.BYTES.gib(1) });
|
|
1269
|
+
var restoreMode;
|
|
1270
|
+
try { restoreMode = (nodeFs.statSync(to).mode & 0o777); } catch (_sm) { restoreMode = 0o600; }
|
|
1271
|
+
await atomicFile.write(to, backupBytes, { fileMode: restoreMode, overwrite: true });
|
|
1272
|
+
} catch (e) {
|
|
1273
|
+
_safeAuditEmit("selfupdate.probation.rollback_failed", "denied", {
|
|
1274
|
+
to: to, backupTo: backupTo, markerPath: markerPath,
|
|
1275
|
+
reason: "restore-failed", message: (e && e.message) || String(e),
|
|
1276
|
+
});
|
|
1277
|
+
throw new SelfUpdateError("selfupdate/probation-rollback-failed",
|
|
1278
|
+
"selfUpdate.evaluateOnBoot: probation rollback restore of " + to + " failed: " +
|
|
1279
|
+
((e && e.message) || String(e)));
|
|
1280
|
+
}
|
|
1281
|
+
atomicFile.fsyncDir(nodePath.dirname(to));
|
|
1282
|
+
try { nodeFs.unlinkSync(markerPath); } catch (_u) { /* marker cleanup best-effort */ }
|
|
1283
|
+
|
|
1284
|
+
_safeAuditEmit("selfupdate.probation.rolled_back", "success", {
|
|
1285
|
+
to: to, backupTo: backupTo, markerPath: markerPath, generation: marker.generation,
|
|
1286
|
+
});
|
|
1287
|
+
log("selfUpdate.evaluateOnBoot rolled back to=" + to + " from=" + backupTo);
|
|
1288
|
+
return { action: "rollback", reason: "probation-window-elapsed-without-confirmation",
|
|
1289
|
+
to: to, backupTo: backupTo, generation: marker.generation };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
885
1292
|
module.exports = {
|
|
886
1293
|
poll: poll,
|
|
887
1294
|
verify: verify,
|
|
888
1295
|
swap: swap,
|
|
889
1296
|
rollback: rollback,
|
|
1297
|
+
beginProbation: beginProbation,
|
|
1298
|
+
confirmHealthy: confirmHealthy,
|
|
1299
|
+
evaluateOnBoot: evaluateOnBoot,
|
|
890
1300
|
// Standalone verifier — zero-dep companion for install-pipeline
|
|
891
1301
|
// contexts that run BEFORE the framework is installed (Dockerfile
|
|
892
1302
|
// build stages, install.sh, update.sh). See the module's intro for
|