dsh-standards 1.4.0 → 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.
@@ -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 is available; the
16
- * probe reads `/proc/self/limits` so only limits that actually took
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. Evidence is only allowed to call itself
24
- * `sandbox` when every item above is effective; any missing item switches the
25
- * producer to `sandbox-degraded`, which `level-audit` never reports as runtime.
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 the child's own
155
- * `/proc/self/limits`, so `applied` lists effects, not intentions.
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 { available: false, degraded: SANDBOX_DEGRADED.prlimit, reason: 'prlimit is a util-linux tool; win32 has no equivalent here', applied: [] }
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 command = [prlimit, `--nproc=${requestedNproc}`, `--as=${requestedAs}`, '--', node, '-e', readLimitsProbe()]
171
- const result = spawnSync(prlimit, command.slice(1), {
172
- encoding: 'utf8', timeout: timeoutMs, env: sanitizedEnv(),
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
- const exitCode = Number.isInteger(result.status) ? result.status : null
175
- let observed = null
176
- try {
177
- const line = String(result.stdout ?? '').trim().split(/\r?\n/).filter(Boolean).pop()
178
- observed = line ? JSON.parse(line) : null
179
- } catch { observed = null }
180
- const applied = []
181
- if (observed?.nproc === requestedNproc) applied.push('nproc')
182
- if (observed?.as === requestedAs) applied.push('as')
183
- const available = !result.error && exitCode === 0 && applied.length === 2
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
- observed,
192
- probe: {
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
- ...(available ? {} : { reason: 'prlimit did not apply and verify nproc+as' }),
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
- const unique = [...new Set(degraded)]
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
- /** Wrap `command args...` in the effective prlimit + unshare prefixes. */
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
- argv = [plan.limits.bin, `--nproc=${plan.limits.nproc}`, `--as=${plan.limits.asBytes}`, '--', ...argv]
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
- if (plan?.network?.isolated) {
275
- argv = [plan.network.bin, ...plan.network.args, ...argv]
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
  }