dsh-standards 1.4.1 → 1.5.0
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/40-checker/scripts/sign-evidence.mjs +169 -0
- package/40-checker/src/cli.mjs +394 -70
- package/40-checker/src/flow-evidence.mjs +21 -0
- package/40-checker/src/report.mjs +49 -3
- package/40-checker/src/runtime-child.mjs +68 -0
- package/40-checker/src/runtime.mjs +3 -1
- package/40-checker/src/sandbox-run.mjs +406 -35
- package/40-checker/src/signature.mjs +249 -0
- package/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/spec/schemas/conformance.schema.json +5 -0
|
@@ -12,19 +12,35 @@
|
|
|
12
12
|
* network `unshare -n` when permitted, falling back to
|
|
13
13
|
* `unshare -Urn` (user+network namespace); if neither works the run
|
|
14
14
|
* continues degraded and records `unshare-missing`;
|
|
15
|
-
* limits `prlimit --nproc --as` when util-linux prlimit
|
|
16
|
-
* probe reads `/proc/self/limits` so only limits
|
|
17
|
-
* effect are recorded;
|
|
15
|
+
* limits `prlimit --nproc --as --nofile --fsize` when util-linux prlimit
|
|
16
|
+
* is available; the probe reads `/proc/self/limits` so only limits
|
|
17
|
+
* that actually took effect are recorded;
|
|
18
|
+
* privileges `setpriv --no-new-privs --bounding-set=-all` inside the user
|
|
19
|
+
* namespace when available (the bounding-set drop needs CAP_SETPCAP,
|
|
20
|
+
* which an unprivileged user only has inside `unshare -Ur`); when
|
|
21
|
+
* the full command fails the probe records the partial or missing
|
|
22
|
+
* state instead of claiming it applied;
|
|
23
|
+
* read-only probe `unshare -Urm` + `mount --bind -o ro` (and a tmpfs
|
|
24
|
+
* read-only mount) in a throwaway mount namespace; the report
|
|
25
|
+
* records the observed exit status/stderr. A failed probe is a
|
|
26
|
+
* degraded reason, never a silent pass. When the probe succeeds,
|
|
27
|
+
* `sandboxArgv` wraps the spawned command so the child's working
|
|
28
|
+
* directory (the inspected package copy) is bind-mounted read-only
|
|
29
|
+
* and `cd`-ed into before exec;
|
|
18
30
|
* timeout the child is spawned as its own process-group leader and the
|
|
19
31
|
* whole group receives SIGKILL, so `dsh` grandchildren die too
|
|
20
32
|
* (Windows: `taskkill /T /F`).
|
|
21
33
|
*
|
|
22
34
|
* This is containment, not a security boundary: no seccomp, no cgroup memory/
|
|
23
|
-
* pids accounting, no OCI image.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
35
|
+
* pids accounting, no OCI image. Even with every hardening item effective the
|
|
36
|
+
* host root filesystem stays readable. Evidence is only allowed to call itself
|
|
37
|
+
* `sandbox` when every required item above is effective; any missing item
|
|
38
|
+
* switches the producer to `sandbox-degraded`, which `level-audit` never reports
|
|
39
|
+
* as runtime.
|
|
26
40
|
*/
|
|
27
41
|
import fs from 'node:fs'
|
|
42
|
+
import os from 'node:os'
|
|
43
|
+
import path from 'node:path'
|
|
28
44
|
import { spawn, spawnSync } from 'node:child_process'
|
|
29
45
|
import { isSecretEnvName, sanitizedEnv } from './sandbox-env.mjs'
|
|
30
46
|
|
|
@@ -36,8 +52,17 @@ export const SANDBOX_DEGRADED = Object.freeze({
|
|
|
36
52
|
home: 'home-not-isolated',
|
|
37
53
|
treeKill: 'process-tree-kill-unavailable',
|
|
38
54
|
execution: 'sandbox-execution-failed',
|
|
55
|
+
setpriv: 'setpriv-missing',
|
|
56
|
+
setprivBounding: 'setpriv-bounding-set-missing',
|
|
57
|
+
rlimitExtra: 'rlimit-extra-missing',
|
|
58
|
+
mountNamespace: 'mount-namespace-missing',
|
|
59
|
+
roBind: 'ro-bind-missing',
|
|
39
60
|
})
|
|
40
61
|
|
|
62
|
+
/** Extra rlimits requested on top of the historical nproc/as pair. */
|
|
63
|
+
export const DEFAULT_NOFILE = 4096
|
|
64
|
+
export const DEFAULT_FSIZE = 1024 ** 3
|
|
65
|
+
|
|
41
66
|
/** Names that must never reach a child that may import plugin code. */
|
|
42
67
|
export const CREDENTIAL_ENV_NAMES = Object.freeze(['GH_TOKEN', 'NPM_TOKEN', 'GITHUB_TOKEN'])
|
|
43
68
|
|
|
@@ -146,57 +171,296 @@ function readLimitsProbe() {
|
|
|
146
171
|
return [
|
|
147
172
|
"const t = require('fs').readFileSync('/proc/self/limits', 'utf8')",
|
|
148
173
|
"const pick = (label) => { const m = new RegExp('^' + label + '\\\\s+(\\\\d+)', 'm').exec(t); return m ? Number(m[1]) : null }",
|
|
149
|
-
"console.log(JSON.stringify({ nproc: pick('Max processes'), as: pick('Max address space') }))",
|
|
174
|
+
"console.log(JSON.stringify({ nproc: pick('Max processes'), as: pick('Max address space'), nofile: pick('Max open files'), fsize: pick('Max file size') }))",
|
|
150
175
|
].join(';')
|
|
151
176
|
}
|
|
152
177
|
|
|
178
|
+
function parseLimitsOutput(result) {
|
|
179
|
+
try {
|
|
180
|
+
const line = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop()
|
|
181
|
+
return line ? JSON.parse(line) : null
|
|
182
|
+
} catch { return null }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function limitAttempt({ prlimit, args, requested, node, timeoutMs }) {
|
|
186
|
+
const command = [prlimit, ...args, '--', node, '-e', readLimitsProbe()]
|
|
187
|
+
const result = spawnSync(prlimit, command.slice(1), {
|
|
188
|
+
encoding: 'utf8', timeout: timeoutMs, env: sanitizedEnv(),
|
|
189
|
+
})
|
|
190
|
+
const exitCode = Number.isInteger(result.status) ? result.status : null
|
|
191
|
+
const observed = parseLimitsOutput(result)
|
|
192
|
+
const applied = []
|
|
193
|
+
for (const [key, value] of Object.entries(requested)) {
|
|
194
|
+
if (observed?.[key] === value) applied.push(key)
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
command,
|
|
198
|
+
result,
|
|
199
|
+
exitCode,
|
|
200
|
+
observed,
|
|
201
|
+
applied,
|
|
202
|
+
probe: {
|
|
203
|
+
command,
|
|
204
|
+
exitCode,
|
|
205
|
+
signal: result.signal ?? null,
|
|
206
|
+
error: probeFailure(result.error),
|
|
207
|
+
stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
153
212
|
/**
|
|
154
|
-
* Apply and verify `prlimit --nproc --as`. The probe prints
|
|
155
|
-
* `/proc/self/limits`, so `applied` lists effects, not
|
|
213
|
+
* Apply and verify `prlimit --nproc --as --nofile --fsize`. The probe prints
|
|
214
|
+
* the child's own `/proc/self/limits`, so `applied` lists effects, not
|
|
215
|
+
* intentions. If the four-limit invocation fails (for example an older
|
|
216
|
+
* prlimit without `--fsize`), the historical nproc/as pair is retried and the
|
|
217
|
+
* extra limits are recorded as missing rather than dropping every limit.
|
|
156
218
|
*/
|
|
157
219
|
export function probeLimits({
|
|
158
220
|
platform = process.platform,
|
|
159
221
|
prlimit = 'prlimit',
|
|
160
222
|
nproc = null,
|
|
161
223
|
asBytes = 4 * 1024 ** 3,
|
|
224
|
+
nofile = DEFAULT_NOFILE,
|
|
225
|
+
fsize = DEFAULT_FSIZE,
|
|
162
226
|
node = process.execPath,
|
|
163
227
|
timeoutMs = 15_000,
|
|
164
228
|
} = {}) {
|
|
165
229
|
if (platform === 'win32') {
|
|
166
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'prlimit is a util-linux tool; win32 has no equivalent here',
|
|
232
|
+
applied: [], extraApplied: [], nofile: null, fsize: null, attempts: [],
|
|
233
|
+
}
|
|
167
234
|
}
|
|
168
235
|
const requestedNproc = Number.isFinite(nproc) && nproc > 0 ? Math.trunc(nproc) : defaultNproc()
|
|
169
236
|
const requestedAs = Number.isFinite(asBytes) && asBytes > 0 ? Math.trunc(asBytes) : 4 * 1024 ** 3
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
237
|
+
const requestedNofile = Number.isFinite(nofile) && nofile > 0 ? Math.trunc(nofile) : DEFAULT_NOFILE
|
|
238
|
+
const requestedFsize = Number.isFinite(fsize) && fsize > 0 ? Math.trunc(fsize) : DEFAULT_FSIZE
|
|
239
|
+
const requested = { nproc: requestedNproc, as: requestedAs, nofile: requestedNofile, fsize: requestedFsize }
|
|
240
|
+
const attempts = []
|
|
241
|
+
let effective = limitAttempt({
|
|
242
|
+
prlimit,
|
|
243
|
+
args: [`--nproc=${requestedNproc}`, `--as=${requestedAs}`, `--nofile=${requestedNofile}`, `--fsize=${requestedFsize}`],
|
|
244
|
+
requested,
|
|
245
|
+
node,
|
|
246
|
+
timeoutMs,
|
|
173
247
|
})
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
248
|
+
attempts.push(effective.probe)
|
|
249
|
+
const baseApplied = effective.applied.includes('nproc') && effective.applied.includes('as')
|
|
250
|
+
if (!baseApplied || effective.exitCode !== 0 || effective.result.error) {
|
|
251
|
+
effective = limitAttempt({
|
|
252
|
+
prlimit,
|
|
253
|
+
args: [`--nproc=${requestedNproc}`, `--as=${requestedAs}`],
|
|
254
|
+
requested,
|
|
255
|
+
node,
|
|
256
|
+
timeoutMs,
|
|
257
|
+
})
|
|
258
|
+
attempts.push(effective.probe)
|
|
259
|
+
}
|
|
260
|
+
const applied = effective.applied
|
|
261
|
+
const available = !effective.result.error && effective.exitCode === 0
|
|
262
|
+
&& applied.includes('nproc') && applied.includes('as')
|
|
263
|
+
const extraApplied = ['nofile', 'fsize'].filter(key => applied.includes(key))
|
|
184
264
|
return {
|
|
185
265
|
available,
|
|
186
266
|
...(available ? {} : { degraded: SANDBOX_DEGRADED.prlimit }),
|
|
267
|
+
...(available && extraApplied.length < 2 ? { extraDegraded: SANDBOX_DEGRADED.rlimitExtra } : {}),
|
|
187
268
|
bin: prlimit,
|
|
188
269
|
nproc: requestedNproc,
|
|
189
270
|
asBytes: requestedAs,
|
|
271
|
+
nofile: requestedNofile,
|
|
272
|
+
fsize: requestedFsize,
|
|
190
273
|
applied,
|
|
191
|
-
|
|
192
|
-
|
|
274
|
+
extraApplied,
|
|
275
|
+
observed: effective.observed,
|
|
276
|
+
attempts,
|
|
277
|
+
probe: effective.probe,
|
|
278
|
+
...(available ? {} : { reason: 'prlimit did not apply and verify nproc+as' }),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Probe `setpriv --no-new-privs --bounding-set=-all`. Dropping the bounding
|
|
284
|
+
* set needs CAP_SETPCAP, so the full command is evaluated inside the same
|
|
285
|
+
* unprivileged user/network namespace prefix used for the run (`unshare -Urn`);
|
|
286
|
+
* outside it, an unprivileged process can only apply `--no-new-privs`.
|
|
287
|
+
*/
|
|
288
|
+
export function probeSetpriv({
|
|
289
|
+
platform = process.platform,
|
|
290
|
+
setpriv = 'setpriv',
|
|
291
|
+
prefix = [],
|
|
292
|
+
timeoutMs = 5_000,
|
|
293
|
+
} = {}) {
|
|
294
|
+
if (platform === 'win32') {
|
|
295
|
+
return {
|
|
296
|
+
available: false, status: 'unavailable', applied: [], degraded: SANDBOX_DEGRADED.setpriv,
|
|
297
|
+
reason: 'setpriv is a util-linux tool; win32 has no equivalent here', attempts: [],
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const attempts = []
|
|
301
|
+
const run = (args) => {
|
|
302
|
+
const command = [...prefix, setpriv, ...args, 'true']
|
|
303
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
304
|
+
encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'], timeout: timeoutMs, env: sanitizedEnv(),
|
|
305
|
+
})
|
|
306
|
+
const exitCode = Number.isInteger(result.status) ? result.status : null
|
|
307
|
+
const attempt = {
|
|
193
308
|
command,
|
|
194
309
|
exitCode,
|
|
195
310
|
signal: result.signal ?? null,
|
|
196
311
|
error: probeFailure(result.error),
|
|
197
312
|
stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
|
|
198
|
-
}
|
|
199
|
-
|
|
313
|
+
}
|
|
314
|
+
attempts.push(attempt)
|
|
315
|
+
return { ...attempt, ok: !result.error && exitCode === 0 }
|
|
316
|
+
}
|
|
317
|
+
const fullArgs = ['--no-new-privs', '--bounding-set=-all']
|
|
318
|
+
const full = run(fullArgs)
|
|
319
|
+
if (full.ok) {
|
|
320
|
+
return {
|
|
321
|
+
available: true, status: 'applied', bin: setpriv, command: [setpriv, ...fullArgs],
|
|
322
|
+
applied: ['no-new-privs', 'bounding-set'], prefix: [...prefix], attempts, probe: full,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const partialArgs = ['--no-new-privs']
|
|
326
|
+
const partial = run(partialArgs)
|
|
327
|
+
if (partial.ok) {
|
|
328
|
+
return {
|
|
329
|
+
available: false, status: 'partial', bin: setpriv, command: [setpriv, ...partialArgs],
|
|
330
|
+
applied: ['no-new-privs'], degraded: SANDBOX_DEGRADED.setprivBounding,
|
|
331
|
+
reason: `setpriv --bounding-set=-all failed (${full.stderr ?? `exit ${full.exitCode ?? 'unknown'}`}); only --no-new-privs applied`,
|
|
332
|
+
prefix: [...prefix], attempts, probe: full,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
available: false, status: 'unavailable', bin: setpriv, command: null, applied: [],
|
|
337
|
+
degraded: SANDBOX_DEGRADED.setpriv,
|
|
338
|
+
reason: `setpriv did not apply no-new-privs (${partial.stderr ?? `exit ${partial.exitCode ?? 'unknown'}`})`,
|
|
339
|
+
prefix: [...prefix], attempts,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Probe whether an unprivileged user+mount namespace can be created. */
|
|
344
|
+
export function probeMountNamespace({
|
|
345
|
+
platform = process.platform,
|
|
346
|
+
unshare = 'unshare',
|
|
347
|
+
timeoutMs = 5_000,
|
|
348
|
+
} = {}) {
|
|
349
|
+
if (platform === 'win32') {
|
|
350
|
+
return {
|
|
351
|
+
available: false, degraded: SANDBOX_DEGRADED.mountNamespace,
|
|
352
|
+
reason: 'unshare is not available on win32', attempts: [],
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const args = ['-Urm', 'true']
|
|
356
|
+
const result = spawnSync(unshare, args, {
|
|
357
|
+
encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'], timeout: timeoutMs, env: sanitizedEnv(),
|
|
358
|
+
})
|
|
359
|
+
const exitCode = Number.isInteger(result.status) ? result.status : null
|
|
360
|
+
const available = !result.error && exitCode === 0
|
|
361
|
+
const probe = {
|
|
362
|
+
command: [unshare, ...args],
|
|
363
|
+
exitCode,
|
|
364
|
+
signal: result.signal ?? null,
|
|
365
|
+
error: probeFailure(result.error),
|
|
366
|
+
stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
|
|
367
|
+
}
|
|
368
|
+
return available
|
|
369
|
+
? { available: true, mechanism: 'unshare -Urm (user+mount namespace)', bin: unshare, args: ['-Urm'], probe }
|
|
370
|
+
: {
|
|
371
|
+
available: false, degraded: SANDBOX_DEGRADED.mountNamespace,
|
|
372
|
+
reason: `unshare -Urm failed: ${probe.stderr ?? probe.error ?? `exit ${exitCode}`}`, attempts: [probe],
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function runMountProbe({ unshare, script, args, timeoutMs }) {
|
|
377
|
+
const command = [unshare, '-Urm', 'sh', '-c', script, 'sh', ...args]
|
|
378
|
+
const result = spawnSync(unshare, command.slice(1), {
|
|
379
|
+
encoding: 'utf8', stdio: ['ignore', 'ignore', 'pipe'], timeout: timeoutMs, env: sanitizedEnv(),
|
|
380
|
+
})
|
|
381
|
+
const exitCode = Number.isInteger(result.status) ? result.status : null
|
|
382
|
+
return {
|
|
383
|
+
command,
|
|
384
|
+
ok: !result.error && exitCode === 0,
|
|
385
|
+
exitCode,
|
|
386
|
+
signal: result.signal ?? null,
|
|
387
|
+
error: probeFailure(result.error),
|
|
388
|
+
stderr: String(result.stderr ?? '').trim().slice(0, 300) || null,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Attempt a throwaway read-only bind mount and a read-only tmpfs mount in a
|
|
394
|
+
* private mount namespace. Nothing here runs against the inspected tree: a
|
|
395
|
+
* temp source/target pair is used, so a failure can only degrade the report.
|
|
396
|
+
* `mount --bind -o ro` is followed by `mount -o remount,bind,ro` because some
|
|
397
|
+
* kernels accept the first call without actually making the bind read-only;
|
|
398
|
+
* then a write is attempted and must fail.
|
|
399
|
+
*/
|
|
400
|
+
export function probeReadOnlyBind({
|
|
401
|
+
platform = process.platform,
|
|
402
|
+
unshare = 'unshare',
|
|
403
|
+
timeoutMs = 10_000,
|
|
404
|
+
} = {}) {
|
|
405
|
+
if (platform === 'win32') {
|
|
406
|
+
return {
|
|
407
|
+
available: false, status: 'unavailable', degraded: SANDBOX_DEGRADED.roBind,
|
|
408
|
+
reason: 'mount namespaces need unshare (Linux); win32 has no equivalent', bind: null, tmpfs: null,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const mountNamespace = probeMountNamespace({ platform, unshare, timeoutMs: 5_000 })
|
|
412
|
+
if (!mountNamespace.available) {
|
|
413
|
+
return {
|
|
414
|
+
available: false, status: 'unavailable',
|
|
415
|
+
degraded: mountNamespace.degraded ?? SANDBOX_DEGRADED.mountNamespace,
|
|
416
|
+
reason: mountNamespace.reason, mountNamespace, bind: null, tmpfs: null,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-ro-bind-'))
|
|
420
|
+
try {
|
|
421
|
+
const source = path.join(dir, 'source')
|
|
422
|
+
const target = path.join(dir, 'target')
|
|
423
|
+
const tmpfs = path.join(dir, 'tmpfs')
|
|
424
|
+
fs.mkdirSync(source)
|
|
425
|
+
fs.mkdirSync(target)
|
|
426
|
+
fs.mkdirSync(tmpfs)
|
|
427
|
+
fs.writeFileSync(path.join(source, 'keep.txt'), 'read-only probe\n')
|
|
428
|
+
const bindScript = [
|
|
429
|
+
'if ! mount --bind -o ro "$1" "$2"; then exit 31; fi',
|
|
430
|
+
'if ! mount -o remount,bind,ro "$2" 2>/dev/null; then exit 32; fi',
|
|
431
|
+
'if printf x > "$2/.dsh-probe" 2>/dev/null; then exit 33; fi',
|
|
432
|
+
'exit 0',
|
|
433
|
+
].join('\n')
|
|
434
|
+
const tmpfsScript = [
|
|
435
|
+
'if ! mount -t tmpfs -o ro tmpfs "$1"; then exit 41; fi',
|
|
436
|
+
'if printf x > "$1/.dsh-probe" 2>/dev/null; then exit 42; fi',
|
|
437
|
+
'exit 0',
|
|
438
|
+
].join('\n')
|
|
439
|
+
const bind = runMountProbe({ unshare, script: bindScript, args: [source, target], timeoutMs })
|
|
440
|
+
const tmpfsProbe = runMountProbe({ unshare, script: tmpfsScript, args: [tmpfs], timeoutMs })
|
|
441
|
+
const available = bind.ok
|
|
442
|
+
const reason = available
|
|
443
|
+
? 'mount --bind -o ro+remount,bind,ro is effective and rejected a write'
|
|
444
|
+
: `mount --bind -o ro probe failed (${bind.stderr ?? bind.error ?? `exit ${bind.exitCode}`})`
|
|
445
|
+
return {
|
|
446
|
+
available,
|
|
447
|
+
status: available ? 'applied' : 'degraded',
|
|
448
|
+
...(available ? {} : { degraded: SANDBOX_DEGRADED.roBind }),
|
|
449
|
+
mechanism: available ? 'unshare -Urm + mount --bind -o ro + remount,bind,ro' : null,
|
|
450
|
+
reason,
|
|
451
|
+
mountNamespace,
|
|
452
|
+
bind,
|
|
453
|
+
tmpfs: {
|
|
454
|
+
attempted: true,
|
|
455
|
+
available: tmpfsProbe.ok,
|
|
456
|
+
reason: tmpfsProbe.ok
|
|
457
|
+
? 'mount -t tmpfs -o ro rejected a write'
|
|
458
|
+
: `mount -t tmpfs -o ro probe failed (${tmpfsProbe.stderr ?? tmpfsProbe.error ?? `exit ${tmpfsProbe.exitCode}`})`,
|
|
459
|
+
probe: tmpfsProbe,
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
} finally {
|
|
463
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
200
464
|
}
|
|
201
465
|
}
|
|
202
466
|
|
|
@@ -224,6 +488,8 @@ export function buildSandboxPlan({
|
|
|
224
488
|
prlimit = 'prlimit',
|
|
225
489
|
nproc = null,
|
|
226
490
|
asBytes = 4 * 1024 ** 3,
|
|
491
|
+
nofile = DEFAULT_NOFILE,
|
|
492
|
+
fsize = DEFAULT_FSIZE,
|
|
227
493
|
homeIsolated = true,
|
|
228
494
|
force = {},
|
|
229
495
|
} = {}) {
|
|
@@ -238,14 +504,73 @@ export function buildSandboxPlan({
|
|
|
238
504
|
if (!network.isolated) degraded.push(network.degraded ?? SANDBOX_DEGRADED.unshare)
|
|
239
505
|
|
|
240
506
|
const limits = force.limits
|
|
241
|
-
? { available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'rlimits disabled by caller', applied: [] }
|
|
242
|
-
: probeLimits({ platform, prlimit, nproc, asBytes })
|
|
507
|
+
? { available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'rlimits disabled by caller', applied: [], extraApplied: [], nofile: null, fsize: null }
|
|
508
|
+
: probeLimits({ platform, prlimit, nproc, asBytes, nofile, fsize })
|
|
243
509
|
if (!limits.available) degraded.push(limits.degraded ?? SANDBOX_DEGRADED.prlimit)
|
|
244
510
|
|
|
245
511
|
const processTreeKill = processTreeKillCapability(platform)
|
|
246
512
|
if (!processTreeKill.available) degraded.push(processTreeKill.degraded ?? SANDBOX_DEGRADED.treeKill)
|
|
247
513
|
|
|
248
|
-
|
|
514
|
+
// Hardening probes are part of a real collection. When the caller forces
|
|
515
|
+
// network/limit isolation off, the run is already degraded by definition;
|
|
516
|
+
// skip the extra probes and say so instead of inventing a second verdict.
|
|
517
|
+
const hardening = []
|
|
518
|
+
const hardeningDegraded = []
|
|
519
|
+
if (force.network || force.limits) {
|
|
520
|
+
for (const item of ['setpriv', 'rlimits-extra', 'read-only-bind']) {
|
|
521
|
+
hardening.push({ item, status: 'skipped', reason: 'core isolation disabled by caller; hardening not evaluated' })
|
|
522
|
+
}
|
|
523
|
+
} else {
|
|
524
|
+
const prefix = network.isolated ? [network.bin, ...network.args] : []
|
|
525
|
+
const setpriv = probeSetpriv({ platform, prefix })
|
|
526
|
+
if (setpriv.status !== 'applied') hardeningDegraded.push(setpriv.degraded ?? SANDBOX_DEGRADED.setpriv)
|
|
527
|
+
hardening.push({
|
|
528
|
+
item: 'setpriv',
|
|
529
|
+
status: setpriv.status,
|
|
530
|
+
command: setpriv.command,
|
|
531
|
+
applied: [...setpriv.applied],
|
|
532
|
+
...(setpriv.probe ? { probe: setpriv.probe } : {}),
|
|
533
|
+
...(setpriv.reason ? { reason: setpriv.reason } : {}),
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
if (limits.available) {
|
|
537
|
+
const extra = Array.isArray(limits.extraApplied) ? limits.extraApplied : []
|
|
538
|
+
if (extra.length < 2) hardeningDegraded.push(SANDBOX_DEGRADED.rlimitExtra)
|
|
539
|
+
hardening.push({
|
|
540
|
+
item: 'rlimits-extra',
|
|
541
|
+
status: extra.length === 2 ? 'applied' : 'degraded',
|
|
542
|
+
applied: extra,
|
|
543
|
+
requested: { nofile: limits.nofile, fsize: limits.fsize },
|
|
544
|
+
...(extra.length === 2 ? {} : { reason: `nofile/fsize not applied (${extra.join(', ') || 'none'})` }),
|
|
545
|
+
})
|
|
546
|
+
} else {
|
|
547
|
+
hardening.push({ item: 'rlimits-extra', status: 'unavailable', reason: 'prlimit did not apply nproc/as' })
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// A successful probe enables the `sandboxArgv` mount wrapper (the child
|
|
551
|
+
// cwd is the package copy). A failed probe is always recorded as a
|
|
552
|
+
// degraded reason; it is never reported as an applied read-only root.
|
|
553
|
+
const readOnlyBind = probeReadOnlyBind({ platform, unshare })
|
|
554
|
+
if (!readOnlyBind.available) hardeningDegraded.push(readOnlyBind.degraded ?? SANDBOX_DEGRADED.roBind)
|
|
555
|
+
hardening.push({
|
|
556
|
+
item: 'read-only-bind',
|
|
557
|
+
status: readOnlyBind.available ? 'applied' : 'degraded',
|
|
558
|
+
available: readOnlyBind.available,
|
|
559
|
+
// `sandboxArgv` wraps the spawned command with this mount when enabled;
|
|
560
|
+
// the recorded execution command shows the actual wrapper.
|
|
561
|
+
enabled: readOnlyBind.available && platform !== 'win32',
|
|
562
|
+
applied: readOnlyBind.available && platform !== 'win32',
|
|
563
|
+
...(readOnlyBind.mechanism ? { mechanism: readOnlyBind.mechanism } : {}),
|
|
564
|
+
reason: readOnlyBind.reason,
|
|
565
|
+
mountNamespace: readOnlyBind.mountNamespace
|
|
566
|
+
? { available: readOnlyBind.mountNamespace.available, mechanism: readOnlyBind.mountNamespace.mechanism ?? null }
|
|
567
|
+
: null,
|
|
568
|
+
bind: readOnlyBind.bind,
|
|
569
|
+
tmpfs: readOnlyBind.tmpfs,
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const unique = [...new Set([...degraded, ...hardeningDegraded])]
|
|
249
574
|
return {
|
|
250
575
|
schema: SANDBOX_REPORT_SCHEMA,
|
|
251
576
|
mode: unique.length === 0 ? 'sandbox' : 'sandbox-degraded',
|
|
@@ -260,19 +585,65 @@ export function buildSandboxPlan({
|
|
|
260
585
|
network,
|
|
261
586
|
limits,
|
|
262
587
|
processTreeKill,
|
|
588
|
+
hardening,
|
|
589
|
+
hardeningDegraded: [...new Set(hardeningDegraded)],
|
|
263
590
|
degraded: unique,
|
|
264
|
-
note: 'containment only: no seccomp, no cgroup memory/pids accounting, no OCI image; read access is not restricted',
|
|
591
|
+
note: 'containment only: no seccomp, no cgroup memory/pids accounting, no OCI image; read access is not restricted; hardening items are probes, not a security certification',
|
|
265
592
|
}
|
|
266
593
|
}
|
|
267
594
|
|
|
268
|
-
/**
|
|
595
|
+
/**
|
|
596
|
+
* The mount wrapper runs inside a private mount namespace and binds the
|
|
597
|
+
* child's working directory (the read-only package copy) onto itself, remounts
|
|
598
|
+
* it read-only, then execs the real command. `pwd -P` is used instead of $PWD
|
|
599
|
+
* so a stale environment variable cannot point the mount at the wrong tree.
|
|
600
|
+
* Exit 31/32 means the mount did not take effect; the wrapper fails closed.
|
|
601
|
+
*/
|
|
602
|
+
const READ_ONLY_WRAPPER = [
|
|
603
|
+
'target=$(pwd -P) || exit 1',
|
|
604
|
+
'if ! mount --bind -o ro "$target" "$target"; then exit 31; fi',
|
|
605
|
+
'if ! mount -o remount,bind,ro "$target" 2>/dev/null; then exit 32; fi',
|
|
606
|
+
// The process cwd still points at the covered mount underneath; cd into the
|
|
607
|
+
// read-only mount or relative writes bypass it.
|
|
608
|
+
'cd "$target" || exit 33',
|
|
609
|
+
'exec "$@"',
|
|
610
|
+
].join('\n')
|
|
611
|
+
|
|
612
|
+
function unshareArgsFor(plan, mountNamespace) {
|
|
613
|
+
const args = plan?.network?.isolated && Array.isArray(plan.network.args) ? [...plan.network.args] : []
|
|
614
|
+
if (!mountNamespace) return args
|
|
615
|
+
// `mount` needs a private mount namespace, and an unprivileged mount needs a
|
|
616
|
+
// user namespace as well. Never assume the network probe already created
|
|
617
|
+
// them: add each flag only when absent.
|
|
618
|
+
const joined = args.join(' ')
|
|
619
|
+
if (!joined.includes('U')) args.push('-U')
|
|
620
|
+
if (!joined.includes('r')) args.push('-r')
|
|
621
|
+
if (!joined.includes('m')) args.push('-m')
|
|
622
|
+
return args
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Wrap `command args...` in the effective unshare + setpriv + prlimit prefixes. */
|
|
269
626
|
export function sandboxArgv(plan, command, args = []) {
|
|
270
627
|
let argv = [command, ...args]
|
|
271
628
|
if (plan?.limits?.available) {
|
|
272
|
-
|
|
629
|
+
const applied = Array.isArray(plan.limits.applied) ? plan.limits.applied : ['nproc', 'as']
|
|
630
|
+
const limitArgs = [`--nproc=${plan.limits.nproc}`, `--as=${plan.limits.asBytes}`]
|
|
631
|
+
if (applied.includes('nofile') && Number.isFinite(plan.limits.nofile)) limitArgs.push(`--nofile=${plan.limits.nofile}`)
|
|
632
|
+
if (applied.includes('fsize') && Number.isFinite(plan.limits.fsize)) limitArgs.push(`--fsize=${plan.limits.fsize}`)
|
|
633
|
+
argv = [plan.limits.bin, ...limitArgs, '--', ...argv]
|
|
273
634
|
}
|
|
274
|
-
|
|
275
|
-
|
|
635
|
+
const setpriv = Array.isArray(plan?.hardening)
|
|
636
|
+
? plan.hardening.find(item => item.item === 'setpriv' && Array.isArray(item.command) && item.command.length > 0)
|
|
637
|
+
: null
|
|
638
|
+
if (setpriv) argv = [...setpriv.command, '--', ...argv]
|
|
639
|
+
const readOnlyBind = Array.isArray(plan?.hardening)
|
|
640
|
+
? plan.hardening.find(item => item.item === 'read-only-bind' && item.enabled === true)
|
|
641
|
+
: null
|
|
642
|
+
const mountNamespace = Boolean(readOnlyBind)
|
|
643
|
+
if (mountNamespace) argv = ['sh', '-c', READ_ONLY_WRAPPER, 'sh', ...argv]
|
|
644
|
+
if (plan?.network?.isolated || mountNamespace) {
|
|
645
|
+
const bin = plan?.network?.bin ?? 'unshare'
|
|
646
|
+
argv = [bin, ...unshareArgsFor(plan, mountNamespace), ...argv]
|
|
276
647
|
}
|
|
277
648
|
return argv
|
|
278
649
|
}
|