@blamejs/core 0.10.7 → 0.10.9
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/index.js +10 -1
- package/lib/ai-content-detect.js +268 -0
- package/lib/ai-input.js +58 -8
- package/lib/ai-model-manifest.js +363 -0
- package/lib/atomic-file.js +83 -0
- package/lib/audit.js +5 -0
- package/lib/boot-gates.js +174 -0
- package/lib/content-credentials.js +140 -0
- package/lib/metrics.js +352 -18
- package/lib/pqc-agent.js +70 -1
- package/lib/promise-pool.js +162 -0
- package/lib/safe-path.js +254 -0
- package/lib/sd-notify.js +269 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.promisePool
|
|
4
|
+
* @nav Async
|
|
5
|
+
* @title Promise Pool
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Bounded-concurrency task runner for promise-returning work — the
|
|
9
|
+
* common gap between `b.workerPool` (worker_threads for CPU-bound
|
|
10
|
+
* work) and `b.queue` (durable cross-process messaging). Wraps the
|
|
11
|
+
* typical "I have N parallel I/O fan-outs and want at most K in
|
|
12
|
+
* flight at any moment" pattern with back-pressure on enqueue
|
|
13
|
+
* (so the caller can't out-run the worker side) and a clean drain
|
|
14
|
+
* path that composes with `b.appShutdown`.
|
|
15
|
+
*
|
|
16
|
+
* Two enqueue paths:
|
|
17
|
+
*
|
|
18
|
+
* - `pool.run(taskFn)` returns a Promise that resolves to the
|
|
19
|
+
* task's return value (or rejects with the task's error). When
|
|
20
|
+
* the pool is at capacity, `run` waits until a slot frees
|
|
21
|
+
* BEFORE the task starts — back-pressure is part of the
|
|
22
|
+
* contract, not an opt.
|
|
23
|
+
*
|
|
24
|
+
* - `pool.fire(taskFn)` is the synchronous-enqueue variant for
|
|
25
|
+
* fan-out from non-async contexts. Returns the same Promise
|
|
26
|
+
* but the call itself can't await — useful inside event
|
|
27
|
+
* handlers that fire-and-forget.
|
|
28
|
+
*
|
|
29
|
+
* Drain semantics: `pool.drain()` resolves when every queued and
|
|
30
|
+
* in-flight task settles. Callers wire this into shutdown via
|
|
31
|
+
* `b.appShutdown.create({ priority: 50, run: () => pool.drain() })`
|
|
32
|
+
* so the process doesn't tear down with work mid-flight.
|
|
33
|
+
*
|
|
34
|
+
* The pool does NOT retry failed tasks; rejection of a task's
|
|
35
|
+
* promise is the caller's signal. Operators that want retry compose
|
|
36
|
+
* `b.retry.withRetry` inside the task body.
|
|
37
|
+
*
|
|
38
|
+
* @card
|
|
39
|
+
* Bounded-concurrency promise pool — back-pressure on enqueue, drain-on-shutdown, no hidden retry. The thing every consumer reaches for p-limit for.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
var validateOpts = require("./validate-opts");
|
|
43
|
+
var numericBounds = require("./numeric-bounds");
|
|
44
|
+
var { defineClass } = require("./framework-error");
|
|
45
|
+
|
|
46
|
+
var PromisePoolError = defineClass("PromisePoolError", { alwaysPermanent: true });
|
|
47
|
+
|
|
48
|
+
var MAX_CONCURRENCY = 65536; // allow:raw-byte-literal — uint16 ceiling on parallel I/O fan-out
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @primitive b.promisePool.create
|
|
52
|
+
* @signature b.promisePool.create(opts)
|
|
53
|
+
* @since 0.10.8
|
|
54
|
+
* @status stable
|
|
55
|
+
* @related b.workerPool.create, b.appShutdown.create, b.retry.withRetry
|
|
56
|
+
*
|
|
57
|
+
* Build a bounded-concurrency pool. Returns
|
|
58
|
+
* `{ run, fire, drain, size, inFlight, queued, closed }`. The pool is
|
|
59
|
+
* closed via `drain({ close: true })`; subsequent enqueues throw.
|
|
60
|
+
*
|
|
61
|
+
* @opts
|
|
62
|
+
* concurrency: number, // required; integer in [1, 65536]
|
|
63
|
+
* queueLimit: number, // default Infinity; once exceeded, enqueue throws
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* var pool = b.promisePool.create({ concurrency: 8 });
|
|
67
|
+
* var results = await Promise.all(items.map(function (item) {
|
|
68
|
+
* return pool.run(function () { return fetchOne(item); });
|
|
69
|
+
* }));
|
|
70
|
+
* await pool.drain({ close: true });
|
|
71
|
+
*/
|
|
72
|
+
function create(opts) {
|
|
73
|
+
validateOpts.requireObject(opts, "b.promisePool.create",
|
|
74
|
+
PromisePoolError, "promise-pool/bad-opts");
|
|
75
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.concurrency,
|
|
76
|
+
"b.promisePool.create: concurrency", PromisePoolError, "promise-pool/bad-concurrency");
|
|
77
|
+
if (opts.concurrency === undefined || opts.concurrency > MAX_CONCURRENCY) {
|
|
78
|
+
throw new PromisePoolError("promise-pool/bad-concurrency",
|
|
79
|
+
"b.promisePool.create: concurrency must be an integer in [1, " +
|
|
80
|
+
MAX_CONCURRENCY + "] (got " + opts.concurrency + ")");
|
|
81
|
+
}
|
|
82
|
+
var queueLimit = opts.queueLimit === undefined ? Infinity : opts.queueLimit;
|
|
83
|
+
if (queueLimit !== Infinity) {
|
|
84
|
+
numericBounds.requirePositiveFiniteIntIfPresent(queueLimit + 1,
|
|
85
|
+
"b.promisePool.create: queueLimit (must be non-negative int)", PromisePoolError,
|
|
86
|
+
"promise-pool/bad-queue-limit");
|
|
87
|
+
}
|
|
88
|
+
var concurrency = opts.concurrency;
|
|
89
|
+
var inFlight = 0;
|
|
90
|
+
var queue = []; // FIFO of pending { taskFn, resolve, reject }
|
|
91
|
+
var drainWaiters = [];
|
|
92
|
+
var closed = false;
|
|
93
|
+
|
|
94
|
+
function _pump() {
|
|
95
|
+
while (inFlight < concurrency && queue.length > 0) {
|
|
96
|
+
var slot = queue.shift();
|
|
97
|
+
inFlight += 1;
|
|
98
|
+
Promise.resolve().then(function () { return slot.taskFn(); })
|
|
99
|
+
.then(function (val) { slot.resolve(val); _settle(); })
|
|
100
|
+
.catch(function (err) { slot.reject(err); _settle(); });
|
|
101
|
+
}
|
|
102
|
+
if (inFlight === 0 && queue.length === 0 && drainWaiters.length > 0) {
|
|
103
|
+
var waiters = drainWaiters.slice();
|
|
104
|
+
drainWaiters.length = 0;
|
|
105
|
+
for (var i = 0; i < waiters.length; i += 1) waiters[i]();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _settle() {
|
|
110
|
+
inFlight -= 1;
|
|
111
|
+
_pump();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _enqueue(taskFn) {
|
|
115
|
+
if (typeof taskFn !== "function") {
|
|
116
|
+
throw new PromisePoolError("promise-pool/bad-task",
|
|
117
|
+
"b.promisePool: task must be a function returning a value or Promise");
|
|
118
|
+
}
|
|
119
|
+
if (closed) {
|
|
120
|
+
throw new PromisePoolError("promise-pool/closed",
|
|
121
|
+
"b.promisePool: pool is closed (drain({close:true}) was called)");
|
|
122
|
+
}
|
|
123
|
+
if (queue.length >= queueLimit) {
|
|
124
|
+
throw new PromisePoolError("promise-pool/queue-full",
|
|
125
|
+
"b.promisePool: queueLimit=" + queueLimit + " reached");
|
|
126
|
+
}
|
|
127
|
+
return new Promise(function (resolve, reject) {
|
|
128
|
+
queue.push({ taskFn: taskFn, resolve: resolve, reject: reject });
|
|
129
|
+
_pump();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function run(taskFn) { return _enqueue(taskFn); }
|
|
134
|
+
function fire(taskFn) { return _enqueue(taskFn); }
|
|
135
|
+
|
|
136
|
+
function drain(drainOpts) {
|
|
137
|
+
drainOpts = drainOpts || {};
|
|
138
|
+
return new Promise(function (resolve) {
|
|
139
|
+
function _done() {
|
|
140
|
+
if (drainOpts.close === true) closed = true;
|
|
141
|
+
resolve();
|
|
142
|
+
}
|
|
143
|
+
if (inFlight === 0 && queue.length === 0) { _done(); return; }
|
|
144
|
+
drainWaiters.push(_done);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
run: run,
|
|
150
|
+
fire: fire,
|
|
151
|
+
drain: drain,
|
|
152
|
+
size: function () { return concurrency; },
|
|
153
|
+
inFlight: function () { return inFlight; },
|
|
154
|
+
queued: function () { return queue.length; },
|
|
155
|
+
closed: function () { return closed; },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
create: create,
|
|
161
|
+
PromisePoolError: PromisePoolError,
|
|
162
|
+
};
|
package/lib/safe-path.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.safePath
|
|
4
|
+
* @nav Filesystem
|
|
5
|
+
* @title Safe Path
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Path-traversal-safe multi-segment resolve. Operators consuming
|
|
9
|
+
* operator-OR-user-supplied path segments (uploaded filenames,
|
|
10
|
+
* tarball entries, archive extraction, dynamic include paths) pass
|
|
11
|
+
* `base + rel` to `b.safePath.resolve` and get back the absolute
|
|
12
|
+
* canonicalized path — guaranteed to lie strictly within `base` —
|
|
13
|
+
* or a typed `SafePathError` with a stable `code` on refusal.
|
|
14
|
+
*
|
|
15
|
+
* Refusal classes (each a documented code, never best-effort):
|
|
16
|
+
*
|
|
17
|
+
* - `safe-path/absolute-rel` — rel is absolute, UNC, or carries a drive letter
|
|
18
|
+
* - `safe-path/escapes-base` — `..` segments escape base after lexical resolve
|
|
19
|
+
* - `safe-path/null-byte` — NUL anywhere (closes Node poison-NUL class)
|
|
20
|
+
* - `safe-path/control-char` — C0 control char other than NUL
|
|
21
|
+
* - `safe-path/bidi` — bidi-override codepoint (CVE-2021-42574 Trojan Source)
|
|
22
|
+
* - `safe-path/win-reserved` — Windows reserved name (CON/PRN/AUX/NUL/COM0-9/LPT0-9)
|
|
23
|
+
* on EVERY platform — closes CVE-2025-27210 cross-mount class
|
|
24
|
+
* - `safe-path/win-trailing` — segment ends with `.` or ` ` under windows-mode resolve
|
|
25
|
+
* - `safe-path/separator-in-segment` — encoded path-separator in a segment (URL / fullwidth /
|
|
26
|
+
* overlong UTF-8 / division-slash)
|
|
27
|
+
* - `safe-path/ads-marker` — NTFS Alternate Data Stream `foo:bar` marker
|
|
28
|
+
* - `safe-path/realpath-escapes-base` — symlink resolution escapes base (opt-in via opts.realpath)
|
|
29
|
+
*
|
|
30
|
+
* Per-segment filename validation composes `b.guardFilename`'s
|
|
31
|
+
* reserved-name + overlong UTF-8 + bidi tables; the multi-segment
|
|
32
|
+
* resolve + base-escape check is the new code.
|
|
33
|
+
*
|
|
34
|
+
* @card
|
|
35
|
+
* Traversal-safe multi-segment path resolve. Every documented failure mode → coded refusal. Composes b.guardFilename.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
var nodePath = require("node:path");
|
|
39
|
+
var nodeFs = require("node:fs");
|
|
40
|
+
var { defineClass } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var SafePathError = defineClass("SafePathError", { alwaysPermanent: true });
|
|
43
|
+
|
|
44
|
+
// Windows reserved device names — CON, PRN, AUX, NUL, COM0–COM9,
|
|
45
|
+
// LPT0–LPT9, CONIN$, CONOUT$. Enforced on EVERY platform to defend
|
|
46
|
+
// the cross-mount case where a POSIX server writes a path that a
|
|
47
|
+
// Windows operator later mounts (closes CVE-2025-27210 class).
|
|
48
|
+
var WIN_RESERVED_RE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)(?:\..*)?$/i;
|
|
49
|
+
// Path separators outside the platform-native set. Each entry MUST
|
|
50
|
+
// be rejected as a segment-internal character. Includes both raw +
|
|
51
|
+
// canonical-encoded forms.
|
|
52
|
+
var ENCODED_SEPARATOR_RE = /(%2[fF]|%5[cC]|%C0%AF|%C1%9C|[/\∕⧸⁄])/;
|
|
53
|
+
// Bidi-override codepoints (RTL/LTR markers + isolate enclosures).
|
|
54
|
+
var BIDI_RE = /[--]/;
|
|
55
|
+
// C0 control byte range (excluding NUL which has its own dedicated
|
|
56
|
+
// refusal so the error code matches the historical poison-NUL class).
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
var C0_RE = /[\x01-\x1F\x7F]/;
|
|
59
|
+
|
|
60
|
+
function _refuse(code, message) {
|
|
61
|
+
throw new SafePathError(code, message);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @primitive b.safePath.resolve
|
|
66
|
+
* @signature b.safePath.resolve(base, rel, opts?)
|
|
67
|
+
* @since 0.10.9
|
|
68
|
+
* @status stable
|
|
69
|
+
* @related b.safePath.validate, b.guardFilename.validate, b.atomicFile.write
|
|
70
|
+
*
|
|
71
|
+
* Resolve `rel` against `base` and return the absolute canonicalized
|
|
72
|
+
* path — guaranteed to lie strictly within `base`. Throws
|
|
73
|
+
* `SafePathError` with a stable refusal code on any rejection.
|
|
74
|
+
*
|
|
75
|
+
* @opts
|
|
76
|
+
* realpath: boolean, // default false; true → fs.realpathSync check (symlink-escape)
|
|
77
|
+
* platform: string, // "windows" forces win-trailing / UNC refusal regardless of host
|
|
78
|
+
* allowAbsoluteRel: boolean, // default false; opt-in for absolute rel that still resolves inside base
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* var p = b.safePath.resolve("/srv/uploads", req.body.path);
|
|
82
|
+
* // → "/srv/uploads/<safe-rel>" OR throws SafePathError on traversal
|
|
83
|
+
*/
|
|
84
|
+
function resolve(base, rel, opts) {
|
|
85
|
+
return _resolveCore(base, rel, opts || {});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @primitive b.safePath.resolveOrNull
|
|
90
|
+
* @signature b.safePath.resolveOrNull(base, rel, opts?)
|
|
91
|
+
* @since 0.10.9
|
|
92
|
+
* @status stable
|
|
93
|
+
* @related b.safePath.resolve, b.safePath.validate
|
|
94
|
+
*
|
|
95
|
+
* Same contract as `resolve` but returns `null` on refusal instead of
|
|
96
|
+
* throwing. Useful for hot-path callers that want a boolean-ish gate
|
|
97
|
+
* without try/catch overhead.
|
|
98
|
+
*
|
|
99
|
+
* @opts
|
|
100
|
+
* realpath: boolean,
|
|
101
|
+
* platform: string,
|
|
102
|
+
* allowAbsoluteRel: boolean,
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* var p = b.safePath.resolveOrNull("/srv/uploads", req.body.path);
|
|
106
|
+
* if (p === null) { res.statusCode = 400; res.end("bad path"); return; }
|
|
107
|
+
*/
|
|
108
|
+
function resolveOrNull(base, rel, opts) {
|
|
109
|
+
try { return _resolveCore(base, rel, opts || {}); }
|
|
110
|
+
catch (_e) { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @primitive b.safePath.validate
|
|
115
|
+
* @signature b.safePath.validate(base, rel, opts?)
|
|
116
|
+
* @since 0.10.9
|
|
117
|
+
* @status stable
|
|
118
|
+
* @related b.safePath.resolve
|
|
119
|
+
*
|
|
120
|
+
* Same gate as `resolve` but returns a verdict object instead of
|
|
121
|
+
* throwing — `{ ok: true, resolved }` on success, `{ ok: false,
|
|
122
|
+
* code, message }` on refusal. Use when the caller wants to log the
|
|
123
|
+
* refusal class without throw/catch.
|
|
124
|
+
*
|
|
125
|
+
* @opts
|
|
126
|
+
* realpath: boolean,
|
|
127
|
+
* platform: string,
|
|
128
|
+
* allowAbsoluteRel: boolean,
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* var v = b.safePath.validate("/srv/uploads", req.body.path);
|
|
132
|
+
* if (!v.ok) { res.end("rejected: " + v.code); return; }
|
|
133
|
+
*/
|
|
134
|
+
function validate(base, rel, opts) {
|
|
135
|
+
try { return { ok: true, resolved: _resolveCore(base, rel, opts || {}) }; }
|
|
136
|
+
catch (e) { return { ok: false, code: e.code || "safe-path/unknown", message: e.message }; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _resolveCore(base, rel, opts) {
|
|
140
|
+
if (typeof base !== "string" || base.length === 0) {
|
|
141
|
+
_refuse("safe-path/bad-input", "b.safePath.resolve: base must be a non-empty string");
|
|
142
|
+
}
|
|
143
|
+
if (typeof rel !== "string") {
|
|
144
|
+
_refuse("safe-path/bad-input", "b.safePath.resolve: rel must be a string");
|
|
145
|
+
}
|
|
146
|
+
var platform = opts.platform || process.platform;
|
|
147
|
+
var isWin = platform === "win32" || platform === "windows";
|
|
148
|
+
|
|
149
|
+
// NUL byte ANYWHERE — its own refusal so the audit code matches
|
|
150
|
+
// the historical Node poison-NUL class.
|
|
151
|
+
if (rel.indexOf("\0") !== -1) {
|
|
152
|
+
_refuse("safe-path/null-byte", "b.safePath.resolve: NUL byte in rel");
|
|
153
|
+
}
|
|
154
|
+
// Other C0 + DEL.
|
|
155
|
+
if (C0_RE.test(rel)) { // allow:regex-no-length-cap — anchored C0/DEL set, length bounded by rel
|
|
156
|
+
_refuse("safe-path/control-char", "b.safePath.resolve: C0 control char in rel");
|
|
157
|
+
}
|
|
158
|
+
// Bidi override (Trojan Source).
|
|
159
|
+
if (BIDI_RE.test(rel)) { // allow:regex-no-length-cap — fixed bidi set, length bounded by rel
|
|
160
|
+
_refuse("safe-path/bidi",
|
|
161
|
+
"b.safePath.resolve: bidi-override codepoint in rel (CVE-2021-42574 class)");
|
|
162
|
+
}
|
|
163
|
+
// Encoded path separators inside what should be a single segment.
|
|
164
|
+
if (ENCODED_SEPARATOR_RE.test(rel)) { // allow:regex-no-length-cap — fixed separator-shape set
|
|
165
|
+
_refuse("safe-path/separator-in-segment",
|
|
166
|
+
"b.safePath.resolve: encoded path-separator codepoint in rel");
|
|
167
|
+
}
|
|
168
|
+
// Absolute rel (POSIX, Windows drive-letter, UNC) — refuse unless
|
|
169
|
+
// operator opted in.
|
|
170
|
+
var isAbsolute = nodePath.isAbsolute(rel) ||
|
|
171
|
+
/^[A-Za-z]:[\\/]/.test(rel) || // allow:regex-no-length-cap — anchored drive-letter shape
|
|
172
|
+
/^\\\\/.test(rel) || // allow:regex-no-length-cap — UNC `\\` prefix
|
|
173
|
+
/^\/\//.test(rel); // allow:regex-no-length-cap — POSIX `//` prefix
|
|
174
|
+
if (isAbsolute && !opts.allowAbsoluteRel) {
|
|
175
|
+
_refuse("safe-path/absolute-rel",
|
|
176
|
+
"b.safePath.resolve: rel is absolute/UNC/drive-letter (set opts.allowAbsoluteRel for opt-in)");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Per-segment walk. Reserved-name + ADS + win-trailing + segment-
|
|
180
|
+
// shape checks happen here.
|
|
181
|
+
var sep = isWin ? /[\\/]/ : /\//;
|
|
182
|
+
var segments = rel.split(sep); // allow:regex-no-length-cap — fixed separator
|
|
183
|
+
for (var si = 0; si < segments.length; si += 1) {
|
|
184
|
+
var seg = segments[si];
|
|
185
|
+
if (seg.length === 0) continue; // empty (leading/trailing/double-sep)
|
|
186
|
+
if (seg === "." || seg === "..") continue; // resolution handled below
|
|
187
|
+
var segLc = seg.toLowerCase();
|
|
188
|
+
var baseName = segLc.indexOf(".") === -1 ? segLc : segLc.slice(0, segLc.indexOf("."));
|
|
189
|
+
if (WIN_RESERVED_RE.test(seg) || WIN_RESERVED_RE.test(baseName)) { // allow:regex-no-length-cap — anchored reserved-name set
|
|
190
|
+
_refuse("safe-path/win-reserved",
|
|
191
|
+
"b.safePath.resolve: segment '" + seg + "' is a Windows reserved name (CVE-2025-27210 class)");
|
|
192
|
+
}
|
|
193
|
+
if (isWin) {
|
|
194
|
+
var last = seg.charAt(seg.length - 1);
|
|
195
|
+
if (last === "." || last === " ") {
|
|
196
|
+
_refuse("safe-path/win-trailing",
|
|
197
|
+
"b.safePath.resolve: segment '" + seg + "' ends with '.' or ' ' (Windows silently strips)");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// NTFS Alternate Data Stream marker — refuse `foo:bar` ANYWHERE
|
|
201
|
+
// except where the colon is part of a Windows drive prefix (the
|
|
202
|
+
// absolute-rel branch above already refused those).
|
|
203
|
+
if (seg.indexOf(":") !== -1) {
|
|
204
|
+
_refuse("safe-path/ads-marker",
|
|
205
|
+
"b.safePath.resolve: segment '" + seg + "' contains ':' (NTFS Alternate Data Stream marker; CVE-2024-12217 class)");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Lexical resolve.
|
|
210
|
+
var baseResolved = nodePath.resolve(base);
|
|
211
|
+
var joined = nodePath.resolve(baseResolved, rel);
|
|
212
|
+
// Cross-check via posix.normalize so a Windows host with mixed
|
|
213
|
+
// separators still surfaces escapes consistently.
|
|
214
|
+
var sepChar = isWin ? "\\" : "/";
|
|
215
|
+
if (joined !== baseResolved && joined.slice(0, baseResolved.length + 1) !== baseResolved + sepChar) {
|
|
216
|
+
_refuse("safe-path/escapes-base",
|
|
217
|
+
"b.safePath.resolve: rel resolves outside base ('" + joined + "' not inside '" + baseResolved + "')");
|
|
218
|
+
}
|
|
219
|
+
if (opts.realpath === true) {
|
|
220
|
+
var baseRealpath;
|
|
221
|
+
try { baseRealpath = nodeFs.realpathSync.native(baseResolved); }
|
|
222
|
+
catch (e) {
|
|
223
|
+
_refuse("safe-path/realpath-base-unresolvable",
|
|
224
|
+
"b.safePath.resolve: opts.realpath set but base realpath failed: " + (e && e.message));
|
|
225
|
+
}
|
|
226
|
+
// Walk up the joined path from the leaf, finding the longest
|
|
227
|
+
// ancestor that exists, and check its realpath. Operators want
|
|
228
|
+
// refusal when ANY ancestor symlink escapes — nodeFs.realpathSync on a
|
|
229
|
+
// non-existent path would throw.
|
|
230
|
+
var ancestor = joined;
|
|
231
|
+
while (ancestor.length > baseResolved.length) {
|
|
232
|
+
try {
|
|
233
|
+
var ancRealpath = nodeFs.realpathSync.native(ancestor);
|
|
234
|
+
if (ancRealpath !== baseRealpath &&
|
|
235
|
+
ancRealpath.slice(0, baseRealpath.length + 1) !== baseRealpath + sepChar) {
|
|
236
|
+
_refuse("safe-path/realpath-escapes-base",
|
|
237
|
+
"b.safePath.resolve: symlink resolution at '" + ancestor +
|
|
238
|
+
"' escapes base realpath '" + baseRealpath + "'");
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
} catch (_ie) {
|
|
242
|
+
ancestor = nodePath.dirname(ancestor);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return joined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
resolve: resolve,
|
|
251
|
+
resolveOrNull: resolveOrNull,
|
|
252
|
+
validate: validate,
|
|
253
|
+
SafePathError: SafePathError,
|
|
254
|
+
};
|
package/lib/sd-notify.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.sdNotify
|
|
4
|
+
* @nav Process
|
|
5
|
+
* @title systemd Notify
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* `sd_notify` protocol surface for daemons running under
|
|
9
|
+
* `Type=notify` systemd units. Composes the standard lifecycle
|
|
10
|
+
* messages — `READY=1` on boot, `WATCHDOG=1` on heartbeat,
|
|
11
|
+
* `STOPPING=1` on shutdown, `RELOADING=1` on hot-reload — against
|
|
12
|
+
* the `$NOTIFY_SOCKET` env var systemd populates for the child
|
|
13
|
+
* process.
|
|
14
|
+
*
|
|
15
|
+
* Transport: Node has no unix-DGRAM socket support in its `dgram`
|
|
16
|
+
* module, so the v1 path shells out to `systemd-notify(1)` via
|
|
17
|
+
* `execFile` (NOT `exec` — no shell-string parsing on the
|
|
18
|
+
* message bytes). Operators running under systemd already have
|
|
19
|
+
* `systemd-tools` installed by definition, so the dependency is
|
|
20
|
+
* no expansion of the trust surface.
|
|
21
|
+
*
|
|
22
|
+
* Compose with `b.appShutdown` for the STOPPING signal: register a
|
|
23
|
+
* priority-0 phase that calls `b.sdNotify.stopping()` so systemd
|
|
24
|
+
* sees the shutdown intent before the framework tears anything
|
|
25
|
+
* down. Compose with a periodic `WATCHDOG=1` against the unit's
|
|
26
|
+
* `WatchdogSec=` interval so systemd auto-restarts the daemon if
|
|
27
|
+
* the event loop wedges.
|
|
28
|
+
*
|
|
29
|
+
* When `$NOTIFY_SOCKET` is unset (process running outside systemd
|
|
30
|
+
* — bare invocation, foreground dev, container without
|
|
31
|
+
* `--notify-ready`), every call is a no-op that surfaces a single
|
|
32
|
+
* boot-time audit entry. Operators get observability of the
|
|
33
|
+
* degraded state without per-call log noise.
|
|
34
|
+
*
|
|
35
|
+
* @card
|
|
36
|
+
* sd_notify protocol for systemd Type=notify daemons — READY / WATCHDOG / STOPPING / RELOADING. Composes b.appShutdown for shutdown signaling.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
var { execFile } = require("node:child_process");
|
|
40
|
+
var C = require("./constants");
|
|
41
|
+
var safeEnv = require("./parsers/safe-env");
|
|
42
|
+
var audit = require("./audit");
|
|
43
|
+
var { defineClass } = require("./framework-error");
|
|
44
|
+
|
|
45
|
+
var SdNotifyError = defineClass("SdNotifyError", { alwaysPermanent: true });
|
|
46
|
+
|
|
47
|
+
// Whitelist of sd_notify state= values we ship as named helpers. The
|
|
48
|
+
// underlying `send({ state })` accepts any string but the helpers are
|
|
49
|
+
// the operator-facing surface — `READY=1` etc. — and the audit log
|
|
50
|
+
// records the named state, not arbitrary payload bytes.
|
|
51
|
+
var KNOWN_STATES = Object.freeze({
|
|
52
|
+
ready: "READY=1",
|
|
53
|
+
stopping: "STOPPING=1",
|
|
54
|
+
reloading: "RELOADING=1",
|
|
55
|
+
watchdog: "WATCHDOG=1",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function _notifySocketPath() {
|
|
59
|
+
var p = safeEnv.readVar("NOTIFY_SOCKET");
|
|
60
|
+
if (typeof p !== "string" || p.length === 0) return null;
|
|
61
|
+
// Abstract namespace socket (Linux-only) prefixed with `@` —
|
|
62
|
+
// systemd-notify(1) accepts the same form, so we don't normalize.
|
|
63
|
+
return p;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _runNotify(payload) {
|
|
67
|
+
return new Promise(function (resolve, reject) {
|
|
68
|
+
var args = [];
|
|
69
|
+
var lines = String(payload).split("\n");
|
|
70
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
71
|
+
if (lines[i].length > 0) args.push(lines[i]);
|
|
72
|
+
}
|
|
73
|
+
if (args.length === 0) { resolve(); return; }
|
|
74
|
+
// execFile (not exec) — no shell evaluation; the message bytes
|
|
75
|
+
// pass through argv exactly. systemd-notify accepts one or more
|
|
76
|
+
// KEY=VALUE arguments. The `--no-block` flag returns immediately
|
|
77
|
+
// without waiting for the notification to be processed.
|
|
78
|
+
execFile("systemd-notify", ["--no-block"].concat(args),
|
|
79
|
+
{ timeout: C.TIME.seconds(5), windowsHide: true },
|
|
80
|
+
function (err) {
|
|
81
|
+
if (err) reject(err);
|
|
82
|
+
else resolve();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @primitive b.sdNotify.send
|
|
89
|
+
* @signature b.sdNotify.send(opts)
|
|
90
|
+
* @since 0.10.8
|
|
91
|
+
* @status stable
|
|
92
|
+
* @related b.sdNotify.ready, b.sdNotify.stopping, b.appShutdown.create
|
|
93
|
+
*
|
|
94
|
+
* Generic sd_notify dispatch. Sends one or more `KEY=VALUE` payload
|
|
95
|
+
* lines to systemd via `systemd-notify(1)`. No-op when
|
|
96
|
+
* `$NOTIFY_SOCKET` is unset (foreground / container without
|
|
97
|
+
* `--notify-ready` / non-systemd init). Returns a Promise resolving
|
|
98
|
+
* on dispatch success.
|
|
99
|
+
*
|
|
100
|
+
* @opts
|
|
101
|
+
* state: string, // e.g. "READY=1" / "STOPPING=1"
|
|
102
|
+
* status: string, // free-form status text → `STATUS=`
|
|
103
|
+
* mainpid: number, // PID override → `MAINPID=`
|
|
104
|
+
* audit: boolean, // default true
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* await b.sdNotify.send({ state: "READY=1", status: "Listening on :8080" });
|
|
108
|
+
*/
|
|
109
|
+
function send(opts) {
|
|
110
|
+
opts = opts || {};
|
|
111
|
+
var lines = [];
|
|
112
|
+
if (typeof opts.state === "string" && opts.state.length > 0) lines.push(opts.state);
|
|
113
|
+
if (typeof opts.status === "string" && opts.status.length > 0) {
|
|
114
|
+
// STATUS= permits arbitrary UTF-8 except newline — refuse newline
|
|
115
|
+
// so a hostile status string can't smuggle a second key.
|
|
116
|
+
if (opts.status.indexOf("\n") !== -1 || opts.status.indexOf("\r") !== -1) {
|
|
117
|
+
throw new SdNotifyError("sd-notify/control-char-in-status",
|
|
118
|
+
"send: status field must not contain CR/LF (sd_notify framing)");
|
|
119
|
+
}
|
|
120
|
+
lines.push("STATUS=" + opts.status);
|
|
121
|
+
}
|
|
122
|
+
if (opts.mainpid !== undefined) {
|
|
123
|
+
if (typeof opts.mainpid !== "number" || !isFinite(opts.mainpid) ||
|
|
124
|
+
Math.floor(opts.mainpid) !== opts.mainpid || opts.mainpid < 1) {
|
|
125
|
+
throw new SdNotifyError("sd-notify/bad-mainpid",
|
|
126
|
+
"send: mainpid must be a positive integer");
|
|
127
|
+
}
|
|
128
|
+
lines.push("MAINPID=" + opts.mainpid);
|
|
129
|
+
}
|
|
130
|
+
if (lines.length === 0) return Promise.resolve();
|
|
131
|
+
|
|
132
|
+
var socketPath = _notifySocketPath();
|
|
133
|
+
if (socketPath === null) {
|
|
134
|
+
if (opts.audit !== false) {
|
|
135
|
+
try {
|
|
136
|
+
audit.safeEmit({
|
|
137
|
+
action: "sdnotify.send.skipped",
|
|
138
|
+
outcome: "denied",
|
|
139
|
+
metadata: { reason: "no-notify-socket", state: opts.state || null },
|
|
140
|
+
});
|
|
141
|
+
} catch (_e) { /* drop-silent */ }
|
|
142
|
+
}
|
|
143
|
+
return Promise.resolve();
|
|
144
|
+
}
|
|
145
|
+
var auditOn = opts.audit !== false;
|
|
146
|
+
return _runNotify(lines.join("\n")).then(function () {
|
|
147
|
+
if (auditOn) {
|
|
148
|
+
try {
|
|
149
|
+
audit.safeEmit({
|
|
150
|
+
action: "sdnotify.send",
|
|
151
|
+
outcome: "success",
|
|
152
|
+
metadata: { state: opts.state || null, status: opts.status || null },
|
|
153
|
+
});
|
|
154
|
+
} catch (_e) { /* drop-silent */ }
|
|
155
|
+
}
|
|
156
|
+
}).catch(function (err) {
|
|
157
|
+
if (auditOn) {
|
|
158
|
+
try {
|
|
159
|
+
audit.safeEmit({
|
|
160
|
+
action: "sdnotify.send",
|
|
161
|
+
outcome: "failure",
|
|
162
|
+
metadata: { state: opts.state || null, error: (err && err.message) || String(err) },
|
|
163
|
+
});
|
|
164
|
+
} catch (_e2) { /* drop-silent */ }
|
|
165
|
+
}
|
|
166
|
+
throw new SdNotifyError("sd-notify/dispatch-failed",
|
|
167
|
+
"send: systemd-notify dispatch failed: " + ((err && err.message) || String(err)));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @primitive b.sdNotify.ready
|
|
173
|
+
* @signature b.sdNotify.ready(opts?)
|
|
174
|
+
* @since 0.10.8
|
|
175
|
+
* @status stable
|
|
176
|
+
* @related b.sdNotify.send, b.sdNotify.stopping
|
|
177
|
+
*
|
|
178
|
+
* Send `READY=1` to systemd, signaling boot complete. Use once the
|
|
179
|
+
* listener is bound and the daemon is accepting work.
|
|
180
|
+
*
|
|
181
|
+
* @opts
|
|
182
|
+
* status: string, // free-form status text → STATUS=
|
|
183
|
+
*
|
|
184
|
+
* @opts
|
|
185
|
+
* status: string, // free-form status text → STATUS=
|
|
186
|
+
* audit: boolean, // default true
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* await b.sdNotify.ready({ status: "Listening on :8080" });
|
|
190
|
+
*/
|
|
191
|
+
function ready(opts) {
|
|
192
|
+
return send(Object.assign({}, opts || {}, { state: KNOWN_STATES.ready }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @primitive b.sdNotify.stopping
|
|
197
|
+
* @signature b.sdNotify.stopping(opts?)
|
|
198
|
+
* @since 0.10.8
|
|
199
|
+
* @status stable
|
|
200
|
+
* @related b.sdNotify.send, b.appShutdown.create
|
|
201
|
+
*
|
|
202
|
+
* Send `STOPPING=1`. Operators wire this into `b.appShutdown` as the
|
|
203
|
+
* earliest shutdown phase (priority 0) so systemd sees the shutdown
|
|
204
|
+
* intent before any teardown begins.
|
|
205
|
+
*
|
|
206
|
+
* @opts
|
|
207
|
+
* status: string, // free-form status text → STATUS=
|
|
208
|
+
* audit: boolean, // default true
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* b.appShutdown.create({ name: "sd-notify-stopping", priority: 0,
|
|
212
|
+
* run: function () { return b.sdNotify.stopping(); } });
|
|
213
|
+
*/
|
|
214
|
+
function stopping(opts) {
|
|
215
|
+
return send(Object.assign({}, opts || {}, { state: KNOWN_STATES.stopping }));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @primitive b.sdNotify.reloading
|
|
220
|
+
* @signature b.sdNotify.reloading(opts?)
|
|
221
|
+
* @since 0.10.8
|
|
222
|
+
* @status stable
|
|
223
|
+
*
|
|
224
|
+
* Send `RELOADING=1` then (after the reload completes) `READY=1`.
|
|
225
|
+
* Use during hot-config-reload paths; systemd treats the unit as
|
|
226
|
+
* "reloading" until the next `READY=1`.
|
|
227
|
+
*
|
|
228
|
+
* @opts
|
|
229
|
+
* status: string, // free-form status text → STATUS=
|
|
230
|
+
* audit: boolean, // default true
|
|
231
|
+
*
|
|
232
|
+
* @example
|
|
233
|
+
* await b.sdNotify.reloading();
|
|
234
|
+
* await reloadConfig();
|
|
235
|
+
* await b.sdNotify.ready();
|
|
236
|
+
*/
|
|
237
|
+
function reloading(opts) {
|
|
238
|
+
return send(Object.assign({}, opts || {}, { state: KNOWN_STATES.reloading }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @primitive b.sdNotify.watchdog
|
|
243
|
+
* @signature b.sdNotify.watchdog(opts?)
|
|
244
|
+
* @since 0.10.8
|
|
245
|
+
* @status stable
|
|
246
|
+
*
|
|
247
|
+
* Send `WATCHDOG=1`. Operators with `WatchdogSec=` configured on
|
|
248
|
+
* their unit call this periodically (e.g. every `WatchdogSec/2`)
|
|
249
|
+
* so systemd auto-restarts the daemon when the event loop wedges.
|
|
250
|
+
*
|
|
251
|
+
* @opts
|
|
252
|
+
* audit: boolean, // default true
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* setInterval(function () { b.sdNotify.watchdog(); }, 15000);
|
|
256
|
+
*/
|
|
257
|
+
function watchdog(opts) {
|
|
258
|
+
return send(Object.assign({}, opts || {}, { state: KNOWN_STATES.watchdog }));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
send: send,
|
|
263
|
+
ready: ready,
|
|
264
|
+
stopping: stopping,
|
|
265
|
+
reloading: reloading,
|
|
266
|
+
watchdog: watchdog,
|
|
267
|
+
isAvailable: function () { return _notifySocketPath() !== null; },
|
|
268
|
+
SdNotifyError: SdNotifyError,
|
|
269
|
+
};
|