@blamejs/core 0.17.12 → 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 +2 -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 +2 -7
- package/lib/vendor/public-suffix-list.data.js +1315 -1317
- 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/daemon.js
CHANGED
|
@@ -31,8 +31,17 @@
|
|
|
31
31
|
* `b.appShutdown.pidLock`, which layers O_EXCL atomic-create +
|
|
32
32
|
* signal-0 liveness probe + reap-on-stale.
|
|
33
33
|
*
|
|
34
|
+
* On Windows a received signal can never reach a JS handler
|
|
35
|
+
* (process.kill maps it to TerminateProcess), so `stop` drives a
|
|
36
|
+
* cooperative stop-request sentinel (`<pidFile>.stop`) that `start`
|
|
37
|
+
* watches and routes into the same orchestrator, escalating to a hard
|
|
38
|
+
* TerminateProcess only after the stop timeout. `status` is a read-only
|
|
39
|
+
* liveness probe that never mutates the pidfile.
|
|
40
|
+
*
|
|
34
41
|
* Audit events: `daemon.started` (pidFile + logFile + commandKind +
|
|
35
|
-
* pid), `daemon.stopped` (pidFile + signal + waitMs + escalated
|
|
42
|
+
* pid), `daemon.stopped` (pidFile + signal + waitMs + escalated +
|
|
43
|
+
* mechanism: signal|cooperative|terminate), `daemon.spawn_failed`
|
|
44
|
+
* (pidFile + command) when a detached child fails to launch, and
|
|
36
45
|
* `daemon.stale_pid_cleaned` (pidFile + stalePid).
|
|
37
46
|
*
|
|
38
47
|
* @card
|
|
@@ -43,6 +52,7 @@ var nodeFs = require("node:fs");
|
|
|
43
52
|
var nodePath = require("node:path");
|
|
44
53
|
var numericBounds = require("./numeric-bounds");
|
|
45
54
|
var appShutdown = require("./app-shutdown");
|
|
55
|
+
var pidProbe = require("./pid-probe");
|
|
46
56
|
var processSpawn = require("./process-spawn");
|
|
47
57
|
var safeAsync = require("./safe-async");
|
|
48
58
|
var atomicFile = require("./atomic-file");
|
|
@@ -62,27 +72,23 @@ var DEFAULT_STOP_TIMEOUT_MS = C.TIME.seconds(30);
|
|
|
62
72
|
var DEFAULT_STOP_SIGNAL = "SIGTERM";
|
|
63
73
|
var DEFAULT_POLL_MS = 100;
|
|
64
74
|
var DEFAULT_LOG_FILE_MODE = 0o600;
|
|
75
|
+
// Poll cadence for the Windows cooperative-stop sentinel (a synchronous
|
|
76
|
+
// existsSync on this interval; see _installStopSentinelWatcher for why it is a
|
|
77
|
+
// poll and not a filesystem watch). Runs for a foreground daemon's whole
|
|
78
|
+
// lifetime, so it is coarser than DEFAULT_POLL_MS (which only polls during the
|
|
79
|
+
// brief stop window); 250ms keeps graceful-stop detection sub-second at
|
|
80
|
+
// negligible idle cost.
|
|
81
|
+
var STOP_SENTINEL_POLL_MS = 250;
|
|
65
82
|
|
|
66
83
|
function _safeAuditEmit(action, outcome, metadata) {
|
|
67
84
|
auditEmit.emit(action, metadata, outcome);
|
|
68
85
|
}
|
|
69
86
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
function _readPidFile(pidFile) {
|
|
77
|
-
try {
|
|
78
|
-
// Same fd-safe + capped + symlink-refusing read as app-shutdown's lockfile
|
|
79
|
-
// reader (one shape): a PID file is never a legit symlink mount, so
|
|
80
|
-
// refuseSymlink is safe; any throw → null ("nothing live there").
|
|
81
|
-
var raw = atomicFile.fdSafeReadSync(pidFile, { maxBytes: C.BYTES.kib(1), refuseSymlink: true, encoding: "utf8" });
|
|
82
|
-
var pid = parseInt(String(raw).trim(), 10);
|
|
83
|
-
return isFinite(pid) && pid > 0 ? pid : null;
|
|
84
|
-
} catch (_e) { return null; }
|
|
85
|
-
}
|
|
87
|
+
// Signal-0 liveness probe + fd-safe pidfile reader live in lib/pid-probe.js so
|
|
88
|
+
// b.daemon and b.appShutdown.pidLock share ONE implementation (they carried
|
|
89
|
+
// byte-identical copies). Local aliases keep the call sites terse.
|
|
90
|
+
var _isLivePid = pidProbe.isLivePid;
|
|
91
|
+
var _readPidFile = pidProbe.readPidFile;
|
|
86
92
|
|
|
87
93
|
function _validateStartOpts(opts) {
|
|
88
94
|
validateOpts.shape(opts, {
|
|
@@ -132,6 +138,13 @@ function _validateStopOpts(opts) {
|
|
|
132
138
|
}, "daemon.stop", DaemonError, "daemon/bad-opts");
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
function _validateStatusOpts(opts) {
|
|
142
|
+
validateOpts.shape(opts, {
|
|
143
|
+
pidFile: { rule: "required-string", code: "daemon/bad-pid-file",
|
|
144
|
+
label: "daemon.status: opts.pidFile" },
|
|
145
|
+
}, "daemon.status", DaemonError, "daemon/bad-opts");
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
function _maybeReapStale(pidFile) {
|
|
136
149
|
var existing = _readPidFile(pidFile);
|
|
137
150
|
if (existing === null) return false;
|
|
@@ -187,6 +200,85 @@ function _redirectStdio(fd) {
|
|
|
187
200
|
// start() in the same process don't double-install signals.
|
|
188
201
|
var _foregroundOrchestrators = Object.create(null);
|
|
189
202
|
|
|
203
|
+
// Sibling-sentinel path a cooperative stop request is written to. Kept beside
|
|
204
|
+
// the pidFile so it inherits the same operator-owned directory + permissions.
|
|
205
|
+
function _stopSentinelPath(pidFile) {
|
|
206
|
+
return pidFile + ".stop";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Remove a cooperative stop sentinel (best-effort — it may already be gone, or
|
|
210
|
+
// never have existed on the POSIX path). unlink removes the LINK, not a target.
|
|
211
|
+
function _cleanupSentinel(sentinelPath) {
|
|
212
|
+
try { nodeFs.unlinkSync(sentinelPath); } catch (_e) { /* best-effort — may not exist */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Install the Windows cooperative-stop watcher for a foreground daemon. Fires
|
|
216
|
+
// the orchestrator's graceful shutdown the first time the sibling <pidFile>.stop
|
|
217
|
+
// sentinel appears — the same orchestrator.shutdown() the POSIX signal path
|
|
218
|
+
// drives, so the exit code is set from the phase result and the event loop
|
|
219
|
+
// drains once the phases release the daemon's resources.
|
|
220
|
+
//
|
|
221
|
+
// Detection is a synchronous existsSync on a plain unref'd interval, NOT a
|
|
222
|
+
// filesystem watch, for two Windows-specific reasons:
|
|
223
|
+
// - fs.watch (libuv ReadDirectoryChangesW) aborts the whole process — an
|
|
224
|
+
// uncatchable src/win/fs-event.c assertion, "!_wcsnicmp(filename, dir,
|
|
225
|
+
// dirlen)" — when the watched directory is reached through an 8.3
|
|
226
|
+
// short-name path, exactly the shape of a CI runner's temp dir
|
|
227
|
+
// (C:\Users\RUNNER~1\AppData\Local\Temp\...): GetFinalPathNameByHandle
|
|
228
|
+
// returns the long form and the prefix check fails. A try/catch cannot
|
|
229
|
+
// recover from an abort().
|
|
230
|
+
// - fs.watchFile's StatWatcher stats through the libuv threadpool, which
|
|
231
|
+
// starves under heavy concurrent filesystem load and delays detection by
|
|
232
|
+
// seconds; a synchronous existsSync runs inline on the main thread and is
|
|
233
|
+
// immune.
|
|
234
|
+
// The interval is unref'd so it never itself keeps the process alive; a clean
|
|
235
|
+
// daemon exits on its own once shutdown completes. If the sentinel is never
|
|
236
|
+
// written the poll is a no-op the release phase clears at shutdown. Returns a
|
|
237
|
+
// handle exposing close().
|
|
238
|
+
function _installStopSentinelWatcher(pidFile, orchestrator) {
|
|
239
|
+
var dir = nodePath.dirname(pidFile);
|
|
240
|
+
var sentinelName = nodePath.basename(pidFile) + ".stop";
|
|
241
|
+
var sentinelPath = nodePath.join(dir, sentinelName);
|
|
242
|
+
var fired = false;
|
|
243
|
+
var timer = null;
|
|
244
|
+
function _stopPolling() {
|
|
245
|
+
if (!timer) return;
|
|
246
|
+
try { clearInterval(timer); } catch (_e) { /* best-effort */ }
|
|
247
|
+
timer = null;
|
|
248
|
+
}
|
|
249
|
+
function _maybeFire() {
|
|
250
|
+
if (fired) return;
|
|
251
|
+
// Synchronous existsSync on the main thread — deliberately not an async
|
|
252
|
+
// stat, so detection never queues behind a saturated libuv threadpool.
|
|
253
|
+
if (!nodeFs.existsSync(sentinelPath)) return;
|
|
254
|
+
fired = true;
|
|
255
|
+
_stopPolling();
|
|
256
|
+
log("cooperative stop-request observed (" + sentinelPath + ") — initiating graceful shutdown");
|
|
257
|
+
// Mirror the POSIX signal path: run the orchestrator's phases, derive the
|
|
258
|
+
// exit code from the result, then let the loop drain (a foreground daemon's
|
|
259
|
+
// phases release its server/db so the process exits on its own).
|
|
260
|
+
Promise.resolve(orchestrator.shutdown()).then(function (result) {
|
|
261
|
+
if (process.exitCode === undefined || process.exitCode === 0) {
|
|
262
|
+
process.exitCode = (result && result.ok) ? 0 : 1;
|
|
263
|
+
}
|
|
264
|
+
}).catch(function () { process.exitCode = 1; });
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
timer = setInterval(_maybeFire, STOP_SENTINEL_POLL_MS);
|
|
268
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
269
|
+
} catch (_e) {
|
|
270
|
+
// Timer scheduling unavailable — no cooperative channel; daemon.stop()
|
|
271
|
+
// still hard-stops via TerminateProcess after its timeout.
|
|
272
|
+
timer = null;
|
|
273
|
+
}
|
|
274
|
+
// The sentinel may already exist (stop() raced ahead of this install).
|
|
275
|
+
_maybeFire();
|
|
276
|
+
return {
|
|
277
|
+
close: function () { _stopPolling(); },
|
|
278
|
+
sentinelPath: sentinelPath,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
190
282
|
/**
|
|
191
283
|
* @primitive b.daemon.start
|
|
192
284
|
* @signature b.daemon.start(opts)
|
|
@@ -286,6 +378,33 @@ function start(opts) {
|
|
|
286
378
|
throw new DaemonError("daemon/spawn-failed",
|
|
287
379
|
"daemon.start: spawn failed: " + ((e && e.message) || String(e)));
|
|
288
380
|
}
|
|
381
|
+
// A bad command does NOT throw synchronously from spawn — child_process
|
|
382
|
+
// reports it ASYNC via a 'error' event, with child.pid left undefined. The
|
|
383
|
+
// sync try/catch above only covers spawn() itself, so without this the old
|
|
384
|
+
// path wrote "undefined\n" to the pidFile and returned success. Subscribe a
|
|
385
|
+
// one-shot 'error' handler that reaps the sidecar + audits the failure, then
|
|
386
|
+
// refuse to proceed for a child that never got a pid.
|
|
387
|
+
child.on("error", function (err) {
|
|
388
|
+
try { nodeFs.unlinkSync(pidFile); } catch (_e) { /* best-effort — may not exist */ }
|
|
389
|
+
_safeAuditEmit("daemon.spawn_failed", "failure", {
|
|
390
|
+
pidFile: pidFile,
|
|
391
|
+
command: opts.command,
|
|
392
|
+
error: (err && err.message) || String(err),
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
// child.pid must be a real, positive PID. A command that failed to launch
|
|
396
|
+
// leaves it undefined; the same positive-integer front-guard the shared
|
|
397
|
+
// liveness probe (pidProbe.isLivePid) applies before it will signal a pid —
|
|
398
|
+
// a non-number / non-finite / non-positive value is never a launched child.
|
|
399
|
+
// Fail closed BEFORE any pidfile write so no "undefined" sidecar survives
|
|
400
|
+
// for daemon.stop to misread.
|
|
401
|
+
if (typeof child.pid !== "number" || !isFinite(child.pid) || child.pid <= 0) {
|
|
402
|
+
try { if (typeof logFd === "number") nodeFs.closeSync(logFd); }
|
|
403
|
+
catch (_c) { /* best-effort */ }
|
|
404
|
+
throw new DaemonError("daemon/spawn-failed",
|
|
405
|
+
"daemon.start: spawn of '" + opts.command + "' produced no pid (the command " +
|
|
406
|
+
"failed to launch)");
|
|
407
|
+
}
|
|
289
408
|
// Write the child's PID via atomic temp+rename so a concurrent
|
|
290
409
|
// observer never sees a half-written pidFile.
|
|
291
410
|
atomicFile.ensureDir(nodePath.dirname(pidFile));
|
|
@@ -334,6 +453,15 @@ function start(opts) {
|
|
|
334
453
|
}
|
|
335
454
|
}
|
|
336
455
|
|
|
456
|
+
// Cooperative stop channel (Windows). Node maps process.kill(pid, "SIGTERM")
|
|
457
|
+
// to TerminateProcess on win32, so a "signal" never reaches a JS handler and
|
|
458
|
+
// the graceful orchestrator would be unreachable. daemon.stop() writes a
|
|
459
|
+
// sibling <pidFile>.stop sentinel; a watcher installed below routes it into
|
|
460
|
+
// the SAME orchestrator.shutdown() the POSIX signal path uses. Assigned after
|
|
461
|
+
// the orchestrator exists; the release phase (which runs at shutdown) closes
|
|
462
|
+
// it + removes the sentinel.
|
|
463
|
+
var stopWatcher = null;
|
|
464
|
+
|
|
337
465
|
var orchestrator = appShutdown.create({
|
|
338
466
|
signals: signals,
|
|
339
467
|
installSignalHandlers: true,
|
|
@@ -341,16 +469,21 @@ function start(opts) {
|
|
|
341
469
|
{
|
|
342
470
|
name: "pidLock-release",
|
|
343
471
|
run: function () {
|
|
472
|
+
if (stopWatcher) { try { stopWatcher.close(); } catch (_w) { /* best-effort */ } }
|
|
344
473
|
try { lock.release(); } catch (_e) { /* best-effort */ }
|
|
345
474
|
if (logFdForeground !== null) {
|
|
346
475
|
try { nodeFs.closeSync(logFdForeground); } catch (_c) { /* best-effort */ }
|
|
347
476
|
}
|
|
477
|
+
_cleanupSentinel(_stopSentinelPath(pidFile));
|
|
348
478
|
},
|
|
349
479
|
timeoutMs: C.TIME.seconds(2),
|
|
350
480
|
},
|
|
351
481
|
],
|
|
352
482
|
});
|
|
353
483
|
_foregroundOrchestrators[pidFile] = orchestrator;
|
|
484
|
+
if (process.platform === "win32") {
|
|
485
|
+
stopWatcher = _installStopSentinelWatcher(pidFile, orchestrator);
|
|
486
|
+
}
|
|
354
487
|
|
|
355
488
|
_safeAuditEmit("daemon.started", "success", {
|
|
356
489
|
pidFile: pidFile,
|
|
@@ -423,16 +556,26 @@ async function stop(opts) {
|
|
|
423
556
|
}
|
|
424
557
|
|
|
425
558
|
var t0 = Date.now();
|
|
426
|
-
|
|
559
|
+
|
|
560
|
+
// Windows has no cooperative signal: process.kill(pid, "SIGTERM") maps to
|
|
561
|
+
// TerminateProcess (a hard kill), so the graceful appShutdown orchestration is
|
|
562
|
+
// only reachable via the cooperative stop-request sentinel start() watches.
|
|
563
|
+
// Drive that channel first and escalate to the hard kill only on timeout.
|
|
564
|
+
if (process.platform === "win32") {
|
|
565
|
+
return await _stopWin32Cooperative(pidFile, pid, signal, timeoutMs, pollMs, t0, opts);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// POSIX signal path — first signal (typically SIGTERM), wait up to timeoutMs
|
|
569
|
+
// for exit, then escalate to SIGKILL. mechanism is always "signal" here.
|
|
427
570
|
try { process.kill(pid, signal); }
|
|
428
571
|
catch (e) {
|
|
429
572
|
if (e && e.code === "ESRCH") {
|
|
430
573
|
// Died between read and kill — cleanup + report.
|
|
431
574
|
try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
|
|
432
575
|
_safeAuditEmit("daemon.stopped", "success", {
|
|
433
|
-
pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false,
|
|
576
|
+
pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false, mechanism: "signal",
|
|
434
577
|
});
|
|
435
|
-
return { stopped: true, pid: pid, signal: signal };
|
|
578
|
+
return { stopped: true, pid: pid, signal: signal, mechanism: "signal" };
|
|
436
579
|
}
|
|
437
580
|
throw new DaemonError("daemon/kill-failed",
|
|
438
581
|
"daemon.stop: kill(" + pid + ", " + signal + ") failed: " + e.message);
|
|
@@ -443,9 +586,9 @@ async function stop(opts) {
|
|
|
443
586
|
if (!_isLivePid(pid)) {
|
|
444
587
|
try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
|
|
445
588
|
_safeAuditEmit("daemon.stopped", "success", {
|
|
446
|
-
pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false,
|
|
589
|
+
pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false, mechanism: "signal",
|
|
447
590
|
});
|
|
448
|
-
return { stopped: true, pid: pid, signal: signal };
|
|
591
|
+
return { stopped: true, pid: pid, signal: signal, mechanism: "signal" };
|
|
449
592
|
}
|
|
450
593
|
await safeAsync.sleep(pollMs, { signal: opts.abortSignal });
|
|
451
594
|
}
|
|
@@ -466,9 +609,109 @@ async function stop(opts) {
|
|
|
466
609
|
}
|
|
467
610
|
try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
|
|
468
611
|
_safeAuditEmit("daemon.stopped", "success", {
|
|
469
|
-
pidFile: pidFile, signal: "SIGKILL", waitMs: Date.now() - t0, escalated: true,
|
|
612
|
+
pidFile: pidFile, signal: "SIGKILL", waitMs: Date.now() - t0, escalated: true, mechanism: "signal",
|
|
613
|
+
});
|
|
614
|
+
return { stopped: true, pid: pid, signal: "SIGKILL", escalated: true, mechanism: "signal" };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Windows cooperative stop. Writes the sibling <pidFile>.stop sentinel the
|
|
618
|
+
// foreground start() watcher routes into the graceful orchestrator, polls for a
|
|
619
|
+
// clean exit up to timeoutMs, and escalates to a hard TerminateProcess (Windows
|
|
620
|
+
// maps any real signal to it) ONLY on timeout — preserving the POSIX
|
|
621
|
+
// graceful-first / forced-on-timeout shape and the same brief post-kill reap
|
|
622
|
+
// wait. mechanism distinguishes the cooperative exit from the forced one; the
|
|
623
|
+
// sentinel is removed on both paths.
|
|
624
|
+
async function _stopWin32Cooperative(pidFile, pid, signal, timeoutMs, pollMs, t0, opts) {
|
|
625
|
+
var sentinel = _stopSentinelPath(pidFile);
|
|
626
|
+
// O_NOFOLLOW-staged write (atomicFile.writeSync → _openExclTemp with
|
|
627
|
+
// O_EXCL | O_NOFOLLOW) so a symlink planted at <pidFile>.stop can't redirect
|
|
628
|
+
// the request to an attacker-chosen file (CWE-59).
|
|
629
|
+
try {
|
|
630
|
+
atomicFile.writeSync(sentinel, String(pid) + "\n", { fileMode: 0o600 });
|
|
631
|
+
} catch (e) {
|
|
632
|
+
throw new DaemonError("daemon/stop-request-failed",
|
|
633
|
+
"daemon.stop: failed to write cooperative stop-request '" + sentinel + "': " +
|
|
634
|
+
((e && e.message) || String(e)));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Poll for cooperative exit up to timeoutMs.
|
|
638
|
+
var deadline = t0 + timeoutMs;
|
|
639
|
+
while (Date.now() < deadline) {
|
|
640
|
+
if (!_isLivePid(pid)) {
|
|
641
|
+
_cleanupSentinel(sentinel);
|
|
642
|
+
try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
|
|
643
|
+
_safeAuditEmit("daemon.stopped", "success", {
|
|
644
|
+
pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false, mechanism: "cooperative",
|
|
645
|
+
});
|
|
646
|
+
return { stopped: true, pid: pid, signal: signal, mechanism: "cooperative" };
|
|
647
|
+
}
|
|
648
|
+
await safeAsync.sleep(pollMs, { signal: opts.abortSignal });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Timed out — the daemon ignored the cooperative request. Hard-stop it.
|
|
652
|
+
try { process.kill(pid, "SIGKILL"); }
|
|
653
|
+
catch (e) {
|
|
654
|
+
if (!(e && e.code === "ESRCH")) {
|
|
655
|
+
_cleanupSentinel(sentinel);
|
|
656
|
+
throw new DaemonError("daemon/kill-failed",
|
|
657
|
+
"daemon.stop: TerminateProcess escalation failed for pid " + pid + ": " + e.message);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
var killDeadline = Date.now() + C.TIME.seconds(2);
|
|
661
|
+
while (Date.now() < killDeadline) {
|
|
662
|
+
if (!_isLivePid(pid)) break;
|
|
663
|
+
await safeAsync.sleep(pollMs, { signal: opts.abortSignal });
|
|
664
|
+
}
|
|
665
|
+
_cleanupSentinel(sentinel);
|
|
666
|
+
try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
|
|
667
|
+
_safeAuditEmit("daemon.stopped", "success", {
|
|
668
|
+
pidFile: pidFile, signal: "SIGKILL", waitMs: Date.now() - t0, escalated: true, mechanism: "terminate",
|
|
470
669
|
});
|
|
471
|
-
return { stopped: true, pid: pid, signal: "SIGKILL", escalated: true };
|
|
670
|
+
return { stopped: true, pid: pid, signal: "SIGKILL", escalated: true, mechanism: "terminate" };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* @primitive b.daemon.status
|
|
675
|
+
* @signature b.daemon.status(opts)
|
|
676
|
+
* @since 0.17.13
|
|
677
|
+
* @status stable
|
|
678
|
+
* @related b.daemon.start, b.daemon.stop
|
|
679
|
+
*
|
|
680
|
+
* Read-only PID-liveness probe. Reads `pidFile` and reports whether the
|
|
681
|
+
* recorded process is alive, WITHOUT mutating anything — unlike `stop()`,
|
|
682
|
+
* a stale pidfile is reported but never unlinked, so a health check can't
|
|
683
|
+
* disturb the daemon's lifecycle state. A missing / malformed / symlinked /
|
|
684
|
+
* oversized pidfile reports `running: false` with `reason: "no-pidfile"`
|
|
685
|
+
* rather than throwing (the same fd-safe, symlink-refusing, 1 KiB-capped
|
|
686
|
+
* read that `start` and `stop` use). Bad opts throw `daemon/bad-pid-file`.
|
|
687
|
+
*
|
|
688
|
+
* Returns `{ running, pid, reason? }`. `reason` is `"no-pidfile"` when no
|
|
689
|
+
* live sidecar was found and `"stale"` when the pidfile pointed at a dead
|
|
690
|
+
* PID — the file is left in place for the operator to inspect or for `stop`
|
|
691
|
+
* to reap.
|
|
692
|
+
*
|
|
693
|
+
* @opts
|
|
694
|
+
* pidFile: string, // absolute path of the PID sidecar (required)
|
|
695
|
+
*
|
|
696
|
+
* @example
|
|
697
|
+
* var s = b.daemon.status({ pidFile: "/tmp/blamejs-daemon-demo.pid" });
|
|
698
|
+
* s.running; // → false
|
|
699
|
+
* s.reason; // → "no-pidfile"
|
|
700
|
+
*/
|
|
701
|
+
function status(opts) {
|
|
702
|
+
_validateStatusOpts(opts);
|
|
703
|
+
var pidFile = opts.pidFile;
|
|
704
|
+
var pid = _readPidFile(pidFile);
|
|
705
|
+
if (pid === null) {
|
|
706
|
+
// Missing / malformed / symlinked / oversized — nothing live to report.
|
|
707
|
+
// READ-ONLY: never unlink (stop() reaps; status() must not).
|
|
708
|
+
return { running: false, pid: null, reason: "no-pidfile" };
|
|
709
|
+
}
|
|
710
|
+
if (!_isLivePid(pid)) {
|
|
711
|
+
// Recorded PID is dead. Report it but leave the sidecar in place.
|
|
712
|
+
return { running: false, pid: pid, reason: "stale" };
|
|
713
|
+
}
|
|
714
|
+
return { running: true, pid: pid };
|
|
472
715
|
}
|
|
473
716
|
|
|
474
717
|
// Test-only — drop process-wide foreground orchestrator state so smoke
|
|
@@ -485,6 +728,7 @@ function _resetForTest() {
|
|
|
485
728
|
module.exports = {
|
|
486
729
|
start: start,
|
|
487
730
|
stop: stop,
|
|
731
|
+
status: status,
|
|
488
732
|
DaemonError: DaemonError,
|
|
489
733
|
DEFAULT_STOP_SIGNAL: DEFAULT_STOP_SIGNAL,
|
|
490
734
|
DEFAULT_STOP_TIMEOUT_MS: DEFAULT_STOP_TIMEOUT_MS,
|
package/lib/db-declare-view.js
CHANGED
|
@@ -359,7 +359,7 @@ function declareView(opts) {
|
|
|
359
359
|
// Build CREATE VIEW. Each column is independently quoted so a
|
|
360
360
|
// reserved-word column name (e.g. "user", "order") resolves correctly.
|
|
361
361
|
var quotedCols = selectedColumns.map(function (c) {
|
|
362
|
-
return safeSql.quoteIdentifier(c, "postgres");
|
|
362
|
+
return safeSql.quoteIdentifier(c, "postgres", { allowReserved: true }); // reserved-word column names (e.g. "user"/"order") — parity with b.db.from()
|
|
363
363
|
}).join(", ");
|
|
364
364
|
var createSql = "CREATE VIEW " + qView + " AS SELECT " + quotedCols +
|
|
365
365
|
" FROM " + qSource;
|
|
@@ -370,7 +370,7 @@ function declareView(opts) {
|
|
|
370
370
|
// GRANT SELECT — one statement covers all roles.
|
|
371
371
|
if (spec.grantTo.length > 0) {
|
|
372
372
|
var quotedRoles = spec.grantTo.map(function (r) {
|
|
373
|
-
return safeSql.quoteIdentifier(r, "postgres");
|
|
373
|
+
return safeSql.quoteIdentifier(r, "postgres", { allowReserved: true }); // parity with b.db.from()
|
|
374
374
|
}).join(", ");
|
|
375
375
|
await xdb.query(
|
|
376
376
|
"GRANT SELECT ON " + qView + " TO " + quotedRoles,
|
package/lib/db.js
CHANGED
|
@@ -2211,7 +2211,10 @@ function exportCsv(opts) {
|
|
|
2211
2211
|
validateOpts.requireNonEmptyString(opts.table, "exportCsv: opts.table", DbError, "db/bad-export-table");
|
|
2212
2212
|
// Quote-validate the table identifier — refuses anything with embedded
|
|
2213
2213
|
// quotes, schema-qualified names valid via dot-separated parts.
|
|
2214
|
-
|
|
2214
|
+
// allowReserved: parity with b.db.from() — a schema-valid operator table
|
|
2215
|
+
// whose name is a SQL keyword must validate here too (still fails closed on
|
|
2216
|
+
// shape/length/null-byte/sqlite_ prefix; the keyword is safe once quoted).
|
|
2217
|
+
safeSql.quoteIdentifier(opts.table, undefined, { allowReserved: true });
|
|
2215
2218
|
var meta = tableMetadata[opts.table];
|
|
2216
2219
|
if (!meta) {
|
|
2217
2220
|
throw new DbError("db/unknown-table",
|
package/lib/dsr.js
CHANGED
|
@@ -1053,7 +1053,7 @@ function dbTicketStore(opts) {
|
|
|
1053
1053
|
var SQL_OPTS = { dialect: "sqlite", quoteName: true };
|
|
1054
1054
|
var qTable, qEmailIdx, qStatusIdx;
|
|
1055
1055
|
try {
|
|
1056
|
-
qTable = safeSql.quoteIdentifier(tableRaw, "sqlite");
|
|
1056
|
+
qTable = safeSql.quoteIdentifier(tableRaw, "sqlite", { allowReserved: true }); // parity with b.db.from() (the _idx names below are suffix-derived — can't be a bare keyword)
|
|
1057
1057
|
qEmailIdx = safeSql.quoteIdentifier(tableRaw + "_email_idx", "sqlite");
|
|
1058
1058
|
qStatusIdx = safeSql.quoteIdentifier(tableRaw + "_status_idx", "sqlite");
|
|
1059
1059
|
} catch (sqlErr) {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
*/
|
|
49
49
|
var lazyRequire = require("./lazy-require");
|
|
50
50
|
var boundedMap = require("./bounded-map");
|
|
51
|
+
var safeObject = require("./safe-object");
|
|
51
52
|
var { defineClass } = require("./framework-error");
|
|
52
53
|
|
|
53
54
|
var I18nMessageFormatError = defineClass("I18nMessageFormatError",
|
|
@@ -370,7 +371,7 @@ function _renderSequence(nodes, vars, locale, hashContext, depth) {
|
|
|
370
371
|
// a non-array (a request DoS). Every case-map lookup goes through this so no key
|
|
371
372
|
// can reach the prototype chain.
|
|
372
373
|
function _ownCase(cases, key) {
|
|
373
|
-
return
|
|
374
|
+
return safeObject.ownProp(cases, key);
|
|
374
375
|
}
|
|
375
376
|
|
|
376
377
|
// Own-property variable lookup. A template argument NAME is parse-derived from
|
|
@@ -383,7 +384,7 @@ function _ownCase(cases, key) {
|
|
|
383
384
|
// which is already own-property-only. Every argument / plural / select value
|
|
384
385
|
// lookup goes through this so no template name can reach the prototype chain.
|
|
385
386
|
function _ownVar(vars, name) {
|
|
386
|
-
return
|
|
387
|
+
return safeObject.ownProp(vars, name);
|
|
387
388
|
}
|
|
388
389
|
|
|
389
390
|
function _renderNode(node, vars, locale, hashContext, depth) {
|
|
@@ -40,6 +40,10 @@ var lazyRequire = require("./lazy-require");
|
|
|
40
40
|
// scrub attribute values through the telemetry redactor before they cross the
|
|
41
41
|
// OTLP egress boundary (CWE-532).
|
|
42
42
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
43
|
+
// Lazy to match the observability cycle-break above. Scrubs secrets embedded in
|
|
44
|
+
// the free-text log body for a directly-wired sink (defense in depth — emit()
|
|
45
|
+
// already redacts, but a sink can be driven without it, same as meta/redactAttrs).
|
|
46
|
+
var redact = lazyRequire(function () { return require("./redact"); });
|
|
43
47
|
// Lazy — network-tls is widely required; audit an insecure (cert-validation-
|
|
44
48
|
// disabled) outbound TLS session at honor time, same surface as connectWithEch.
|
|
45
49
|
var networkTls = lazyRequire(function () { return require("./network-tls"); });
|
|
@@ -148,7 +152,7 @@ function _encodeLogRecord(record) {
|
|
|
148
152
|
var attrPieces = _encodeAttributes(observability().redactAttrs(record.meta)).map(function (kvBody) {
|
|
149
153
|
return pb.embeddedMessage(6, kvBody);
|
|
150
154
|
});
|
|
151
|
-
var msg = (record.message != null ? String(record.message) : "");
|
|
155
|
+
var msg = (record.message != null ? redact().redactText(String(record.message)) : "");
|
|
152
156
|
return Buffer.concat([
|
|
153
157
|
pb.fixed64(1, tsNs),
|
|
154
158
|
pb.uint32(2, sev.number),
|
package/lib/log-stream-otlp.js
CHANGED
|
@@ -65,6 +65,10 @@ var lazyRequire = require("./lazy-require");
|
|
|
65
65
|
// log path can reach a log-stream sink). Used only to scrub attribute values
|
|
66
66
|
// through the telemetry redactor before they cross the OTLP egress boundary.
|
|
67
67
|
var observability = lazyRequire(function () { return require("./observability"); });
|
|
68
|
+
// Lazy to match the observability cycle-break above. Scrubs secrets embedded in
|
|
69
|
+
// the free-text log body for a directly-wired sink (defense in depth — emit()
|
|
70
|
+
// already redacts, but a sink can be driven without it, same as meta/redactAttrs).
|
|
71
|
+
var redact = lazyRequire(function () { return require("./redact"); });
|
|
68
72
|
|
|
69
73
|
var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
|
|
70
74
|
var FRAMEWORK_VERSION = (pkg && pkg.version) || "unknown";
|
|
@@ -157,7 +161,7 @@ function _toLogRecord(record) {
|
|
|
157
161
|
observedTimeUnixNano: nanos,
|
|
158
162
|
severityNumber: sev.number,
|
|
159
163
|
severityText: sev.text,
|
|
160
|
-
body: { stringValue: record.message == null ? "" : String(record.message) },
|
|
164
|
+
body: { stringValue: record.message == null ? "" : redact().redactText(String(record.message)) },
|
|
161
165
|
attributes: attrs,
|
|
162
166
|
};
|
|
163
167
|
}
|
package/lib/log-stream.js
CHANGED
|
@@ -210,11 +210,14 @@ function emit(level, message, meta) {
|
|
|
210
210
|
if (LEVELS.indexOf(level) === -1) {
|
|
211
211
|
throw _err("INVALID_LEVEL", "log level must be one of " + LEVELS.join(", "), true);
|
|
212
212
|
}
|
|
213
|
-
// Build the record. Redact
|
|
213
|
+
// Build the record. Redact BOTH the structured metadata AND the free-text
|
|
214
|
+
// message BEFORE distribution to any sink — a secret interpolated into the
|
|
215
|
+
// message string (a JWT, an AWS key, a URL password) must not reach a file /
|
|
216
|
+
// remote sink verbatim. redactText scrubs embedded fragments in place.
|
|
214
217
|
var record = {
|
|
215
218
|
ts: Date.now(),
|
|
216
219
|
level: level,
|
|
217
|
-
message: message == null ? null : String(message),
|
|
220
|
+
message: message == null ? null : redact.redactText(String(message)),
|
|
218
221
|
};
|
|
219
222
|
if (meta) {
|
|
220
223
|
record.meta = redact.redact(meta);
|
|
@@ -52,6 +52,7 @@ var DEFAULT_BLOCKED_AGENTS = [
|
|
|
52
52
|
|
|
53
53
|
var lazyRequire = require("../lazy-require");
|
|
54
54
|
var requestHelpers = require("../request-helpers");
|
|
55
|
+
var ssrfGuard = require("../ssrf-guard");
|
|
55
56
|
var validateOpts = require("../validate-opts");
|
|
56
57
|
var denyResponse = require("./deny-response").denyResponse;
|
|
57
58
|
var { defineClass } = require("../framework-error");
|
|
@@ -185,15 +186,10 @@ function create(opts) {
|
|
|
185
186
|
if (_proto.resolve(req) === "https") return true;
|
|
186
187
|
var host = (req.headers && req.headers.host) || "";
|
|
187
188
|
host = String(host).toLowerCase().replace(/:\d+$/, ""); // strip :port
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
host = host.replace(/\.$/, ""); // strip trailing root-zone dot (RFC 1034 §3.1) so "localhost." matches
|
|
193
|
-
if (host === "localhost" || /\.localhost$/.test(host)) return true;
|
|
194
|
-
if (host === "::1") return true;
|
|
195
|
-
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; // allow:regex-no-length-cap — bounded dotted-quad loopback
|
|
196
|
-
return false;
|
|
189
|
+
// Loopback HTTP is a secure context (can't be reached off-box). Compose the
|
|
190
|
+
// canonical hostname-aware classifier — 127/8, ::1 / [::1], localhost, and
|
|
191
|
+
// *.localhost (RFC 6761 §6.3) — instead of re-rolling the triad here.
|
|
192
|
+
return ssrfGuard.isLoopbackHost(host);
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
function _checkHeuristics(req) {
|
package/lib/outbox.js
CHANGED
|
@@ -99,7 +99,7 @@ var KEY_MAX_LEN = C.BYTES.bytes(255);
|
|
|
99
99
|
function _validateTableName(name) {
|
|
100
100
|
// SQL identifier — quoteIdentifier rejects anything with embedded
|
|
101
101
|
// quotes, schema-qualified names valid via dot-separated parts.
|
|
102
|
-
return safeSql.quoteIdentifier(name);
|
|
102
|
+
return safeSql.quoteIdentifier(name, undefined, { allowReserved: true }); // parity with b.db.from()
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
// Map the operator backend's dialect tag to the b.sql dialect vocabulary.
|
package/lib/pid-probe.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// pid-probe — the signal-0 liveness probe + fd-safe pidfile reader shared by
|
|
6
|
+
// b.daemon (lib/daemon.js) and b.appShutdown.pidLock (lib/app-shutdown.js).
|
|
7
|
+
// Both files carried a byte-for-byte copy of the liveness check and the capped,
|
|
8
|
+
// symlink-refusing pidfile reader; a single owner keeps the liveness semantics
|
|
9
|
+
// (process.kill(pid, 0) → alive; EPERM → alive-but-unowned; ESRCH → dead) and
|
|
10
|
+
// the read hardening (1 KiB cap + O_NOFOLLOW refusal so a planted symlink or an
|
|
11
|
+
// oversized file can neither redirect nor OOM the read) identical across every
|
|
12
|
+
// caller — daemon.start / stop / status and pidLock.acquire / release.
|
|
13
|
+
//
|
|
14
|
+
// This is not request-reachable: the two callers are process-lifecycle
|
|
15
|
+
// primitives, so a throw here would only ever surface at daemon start/stop.
|
|
16
|
+
// Both entry points are defensive readers (return-default, never throw): a
|
|
17
|
+
// missing / malformed / hostile pidfile yields null ("nothing live there")
|
|
18
|
+
// rather than propagating an error into the shutdown path.
|
|
19
|
+
|
|
20
|
+
var atomicFile = require("./atomic-file");
|
|
21
|
+
var C = require("./constants");
|
|
22
|
+
|
|
23
|
+
// isLivePid(pid) — signal-0 existence probe. process.kill(pid, 0) sends no
|
|
24
|
+
// signal but performs the permission + existence check the kernel would do for
|
|
25
|
+
// a real signal: it succeeds when the process is alive and signalable, throws
|
|
26
|
+
// EPERM when the process is alive but owned by another user (still "live"), and
|
|
27
|
+
// throws ESRCH when no such process exists (dead). A non-numeric / non-finite /
|
|
28
|
+
// non-positive pid is never live.
|
|
29
|
+
function isLivePid(pid) {
|
|
30
|
+
if (typeof pid !== "number" || !isFinite(pid) || pid <= 0) return false;
|
|
31
|
+
try { process.kill(pid, 0); return true; }
|
|
32
|
+
catch (e) { return !!(e && e.code === "EPERM"); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// readPidFile(pidFile) — fd-safe + capped + symlink-refusing read of a PID
|
|
36
|
+
// sidecar. A PID file is never a legitimate symlink mount (unlike a k8s/certbot
|
|
37
|
+
// secret mount), so refuseSymlink is safe here and stops a planted symlink from
|
|
38
|
+
// redirecting the read; the 1 KiB cap stops an oversized planted file from
|
|
39
|
+
// OOM-ing it. Returns the parsed positive PID, or null for any failure
|
|
40
|
+
// (missing / symlink / too-large / non-numeric) — the uniform "nothing live
|
|
41
|
+
// there" sentinel both callers already relied on.
|
|
42
|
+
function readPidFile(pidFile) {
|
|
43
|
+
try {
|
|
44
|
+
var raw = atomicFile.fdSafeReadSync(pidFile, {
|
|
45
|
+
maxBytes: C.BYTES.kib(1), refuseSymlink: true, encoding: "utf8",
|
|
46
|
+
});
|
|
47
|
+
var pid = parseInt(String(raw).trim(), 10);
|
|
48
|
+
return isFinite(pid) && pid > 0 ? pid : null;
|
|
49
|
+
} catch (_e) { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
isLivePid: isLivePid,
|
|
54
|
+
readPidFile: readPidFile,
|
|
55
|
+
};
|
package/lib/pqc-agent.js
CHANGED
|
@@ -176,6 +176,13 @@ function _buildAgentOpts(opts) {
|
|
|
176
176
|
} else {
|
|
177
177
|
merged.ecdhCurve = C.TLS_GROUP_CURVE_STR;
|
|
178
178
|
}
|
|
179
|
+
// Mirror the resolved ecdhCurve into `groups` from one string (same
|
|
180
|
+
// shape as network-tls.buildOptions). `applyToContext` only fills
|
|
181
|
+
// `groups` when it is undefined, so setting it here preserves the
|
|
182
|
+
// caller's narrowed/reordered selection instead of letting the
|
|
183
|
+
// context filler re-derive `groups` from STATE.tlsKeyShares — a
|
|
184
|
+
// different ordering that would ignore the caller's ecdhCurve.
|
|
185
|
+
merged.groups = merged.ecdhCurve;
|
|
179
186
|
merged.minVersion = "TLSv1.3";
|
|
180
187
|
if (networkTls && typeof networkTls.applyToContext === "function") {
|
|
181
188
|
merged = networkTls.applyToContext({ base: merged });
|
|
@@ -203,7 +210,7 @@ function _buildAgentOpts(opts) {
|
|
|
203
210
|
* maxSockets?: number,
|
|
204
211
|
* maxFreeSockets?: number,
|
|
205
212
|
* scheduling?: string,
|
|
206
|
-
* ecdhCurve?: string, // colon-separated group names; must subset C.TLS_GROUP_PREFERENCE
|
|
213
|
+
* ecdhCurve?: string, // colon-separated group names; must subset C.TLS_GROUP_PREFERENCE. The TLS `groups` list tracks this value exactly (mirrored from one resolved string), so a narrowed/reordered ecdhCurve is the negotiated key-share order.
|
|
207
214
|
* allowOperatorGroups?: boolean, // default false; opt in to operator-supplied groups outside the framework PQC preference
|
|
208
215
|
*
|
|
209
216
|
* @example
|