dsh-wsl-desktop 0.7.4 → 0.7.5
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/lib/client.js +2 -2
- package/lib/protocol.js +2 -2
- package/lib/wsl/confinement.js +148 -13
- package/lib/wsl/dsh-wsl-confine.sh +26 -4
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -245,7 +245,7 @@ window.__ModuleLoader__.load({
|
|
|
245
245
|
'unmeasured': '未测量',
|
|
246
246
|
'not measured': '未测量',
|
|
247
247
|
'not selected; the runner uses sudo-unshare': '未被选中;运行器使用 sudo-unshare',
|
|
248
|
-
'not installed or not v1.
|
|
248
|
+
'not installed or not v1.3': '未安装或版本不是 v1.3',
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/**
|
|
@@ -256,7 +256,7 @@ window.__ModuleLoader__.load({
|
|
|
256
256
|
function localizeDetail(detail) {
|
|
257
257
|
if (LOCALE !== 'zh' || typeof detail !== 'string') return detail
|
|
258
258
|
if (detail.startsWith('missing: ')) return `${copy('missingPrefix')}${detail.slice('missing: '.length)}`
|
|
259
|
-
// Version strings carry their own identity (Python x.y, dsh-wsl-confine v1.
|
|
259
|
+
// Version strings carry their own identity (Python x.y, dsh-wsl-confine v1.3).
|
|
260
260
|
if (detail.startsWith('Python ') || detail.startsWith('dsh-wsl-confine ')) return detail
|
|
261
261
|
return DETAIL_ZH[detail] ?? detail
|
|
262
262
|
}
|
package/lib/protocol.js
CHANGED
|
@@ -202,8 +202,8 @@ export const CHECK_REMEDIES = deepFreeze({
|
|
|
202
202
|
},
|
|
203
203
|
helper: {
|
|
204
204
|
kind: 'text',
|
|
205
|
-
zh: 'helper 未安装或版本不是 v1.
|
|
206
|
-
en: 'The helper is missing or not v1.
|
|
205
|
+
zh: 'helper 未安装或版本不是 v1.3;安装说明见 docs/CONFINEMENT.md。',
|
|
206
|
+
en: 'The helper is missing or not v1.3; see docs/CONFINEMENT.md for the install command.',
|
|
207
207
|
},
|
|
208
208
|
nnp: {
|
|
209
209
|
kind: 'text',
|
package/lib/wsl/confinement.js
CHANGED
|
@@ -39,8 +39,20 @@ export const HELPER_PATH = '/usr/local/sbin/dsh-wsl-confine'
|
|
|
39
39
|
* bypass. The probe below therefore refuses anything older, and the direct
|
|
40
40
|
* sudo-unshare runner is used instead: its in-process builder always escaped, so
|
|
41
41
|
* a stale install degrades to a correct fence rather than a silent hole.
|
|
42
|
+
*
|
|
43
|
+
* v1.3 is the first helper whose writability post-condition reads the MOUNT FLAG
|
|
44
|
+
* instead of `[ -w ]`. v1.2 asked `[ -w ]` about every swept target, which the
|
|
45
|
+
* kernel answers "writable" for any non-storage inode — a SOCKET, FIFO or device
|
|
46
|
+
* node — even on a mount that was successfully remounted read-only. With a
|
|
47
|
+
* unix-socket bind mount present (measured: `/mnt/wsl/podman-sockets` while
|
|
48
|
+
* `podman machine` runs) that refused EVERY confined command with exit 97,
|
|
49
|
+
* including a bare `true`, and the terminal path surfaced it only as
|
|
50
|
+
* "PTY shell exited during startup" (github issue #2). Refusing the old version
|
|
51
|
+
* is what makes the fix reach an installed helper: the requirement is an exact
|
|
52
|
+
* match, so an un-reinstalled v1.2 now falls back to the direct runner, whose
|
|
53
|
+
* in-process builder carries the corrected post-condition.
|
|
42
54
|
*/
|
|
43
|
-
export const HELPER_VERSION = 'v1.
|
|
55
|
+
export const HELPER_VERSION = 'v1.3'
|
|
44
56
|
|
|
45
57
|
/** stderr text the kernel produces for a write blocked by the read-only root. */
|
|
46
58
|
export const DENIAL_SIGNATURES = ['Read-only file system', 'read-only file system']
|
|
@@ -555,6 +567,40 @@ export const SETUP_FAILURE_MARKER = 'dsh-wsl-sandbox: setup failed'
|
|
|
555
567
|
*/
|
|
556
568
|
export const KERNEL_SURFACES = new Set(['/dev', '/dev/pts', '/dev/mqueue', '/proc', '/sys'])
|
|
557
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Kernel surfaces exempt as SUBTREES, not as single mount points.
|
|
572
|
+
*
|
|
573
|
+
* A fresh procfs brings its own nested mounts with it, and the distribution's
|
|
574
|
+
* real ones are not theoretical: measured on a running WSL2 distribution,
|
|
575
|
+
* `findmnt` lists `/proc/sys/fs/binfmt_misc` AND
|
|
576
|
+
* `/proc/sys/fs/binfmt_misc/status` as separate mounts, plus a `/proc` record
|
|
577
|
+
* that is a re-mount of the root procfs. A nested kernel mount cannot be made
|
|
578
|
+
* read-only — measured: `mount -o remount,ro,bind /proc/sys/fs/binfmt_misc`
|
|
579
|
+
* answers exit **32** ("mount point not mounted or bad option") on this
|
|
580
|
+
* distribution, and `umount` answers EBUSY — so exact-entry matching left those
|
|
581
|
+
* children unexempt and the post-condition found the first one and refused
|
|
582
|
+
* EVERY confined command (measured: exit 97, `/proc/sys/fs/binfmt_misc is still
|
|
583
|
+
* writable`).
|
|
584
|
+
*
|
|
585
|
+
* This set must agree with `KERNEL_SUBTREE` in `dsh-wsl-confine.sh`, which has
|
|
586
|
+
* carried the same rule since the two fences were split. It did NOT before: the
|
|
587
|
+
* helper exempted these subtrees and the in-process builder matched exactly, so
|
|
588
|
+
* on a distribution with nested kernel mounts the TWO FENCES DISAGREED about
|
|
589
|
+
* whether the fence could be established at all — the helper's path worked while
|
|
590
|
+
* the direct `sudo-unshare` runner failed closed on every command. The direct
|
|
591
|
+
* runner is exactly what an operator gets when the helper is not installed or
|
|
592
|
+
* its version does not match, so that divergence was a whole-world outage for
|
|
593
|
+
* this distribution, not an edge case.
|
|
594
|
+
*
|
|
595
|
+
* Deliberately NARROWER than `KERNEL_SURFACES`: `/dev` and `/tmp` stay exact
|
|
596
|
+
* matches, so their writable children keep being swept. `/dev/shm` is shared
|
|
597
|
+
* memory that must end up READONLY (the post-condition asserts it), and a
|
|
598
|
+
* workspace under `/tmp` is refused outright. Only the two pseudo-filesystems
|
|
599
|
+
* whose children are kernel state by construction get the subtree rule, and the
|
|
600
|
+
* workspace is never one of them.
|
|
601
|
+
*/
|
|
602
|
+
export const KERNEL_SUBTREES = ['/proc', '/sys']
|
|
603
|
+
|
|
558
604
|
/**
|
|
559
605
|
* Escape ERE metacharacters so a path can only ever match itself.
|
|
560
606
|
* @param {string} text - a mount target or exempt path.
|
|
@@ -567,11 +613,19 @@ function escapeEre(text) {
|
|
|
567
613
|
/**
|
|
568
614
|
* The assembled grep -E pattern for one sweep/postcondition: regex-escaped
|
|
569
615
|
* entries joined with alternation, anchored both ends.
|
|
616
|
+
*
|
|
617
|
+
* The subtree entries are emitted the way the helper builds them — the escaped
|
|
618
|
+
* path followed by `/.*` — inside the same anchored group, so `/proc` exempts
|
|
619
|
+
* `/proc/sys/fs/binfmt_misc` while the trailing `$` still stops a sibling like
|
|
620
|
+
* `/procxyz` from matching. See {@link KERNEL_SUBTREES} for why the two fences
|
|
621
|
+
* must agree on this.
|
|
570
622
|
* @param {string[]} keep - mount targets that stay writable.
|
|
571
623
|
* @returns {string} the pattern, as data (the caller quotes it for the shell).
|
|
572
624
|
*/
|
|
573
625
|
function exemptPattern(keep) {
|
|
574
|
-
|
|
626
|
+
const exact = [...new Set([...keep, ...KERNEL_SURFACES])].map(escapeEre)
|
|
627
|
+
const subtrees = KERNEL_SUBTREES.map((kernel) => `${escapeEre(kernel)}/.*`)
|
|
628
|
+
return `^(${[...exact, ...subtrees].join('|')})$`
|
|
575
629
|
}
|
|
576
630
|
|
|
577
631
|
/**
|
|
@@ -594,6 +648,12 @@ function sweepHead(pattern) {
|
|
|
594
648
|
* Enumerated at run time from `findmnt` rather than from a fixed list, because a
|
|
595
649
|
* list is only ever as complete as the machine it was written on: a new drvfs
|
|
596
650
|
* drive or a runtime tmpfs would reopen the hole silently.
|
|
651
|
+
*
|
|
652
|
+
* A remount that fails is swallowed here and caught a step later by
|
|
653
|
+
* {@link postConditions}, which is deliberate: one unmountable target must not
|
|
654
|
+
* abort the sweep for the others, and a nested kernel mount legitimately cannot
|
|
655
|
+
* be remounted (measured: `remount,ro,bind /proc/sys/fs/binfmt_misc` answers
|
|
656
|
+
* exit 32) — those are exempted as subtrees instead, in both fences.
|
|
597
657
|
* @param {string[]} keep - mount targets that stay writable.
|
|
598
658
|
* @returns {string} the fragment.
|
|
599
659
|
*/
|
|
@@ -626,6 +686,36 @@ function readOnlySweep(keep) {
|
|
|
626
686
|
* not abort the rest, which means the only trustworthy statement about the
|
|
627
687
|
* boundary is one read back from inside it. A target that is still writable
|
|
628
688
|
* outside the allow-list is a setup failure, not a command failure.
|
|
689
|
+
*
|
|
690
|
+
* The writability verdict pairs two tests, and each is load-bearing where the
|
|
691
|
+
* other is blind.
|
|
692
|
+
*
|
|
693
|
+
* **Storage inodes are judged by `[ -w ]`.** `access(W_OK)` answers about the
|
|
694
|
+
* mount a write would actually land on — including a STACKED target (findmnt
|
|
695
|
+
* lists one record per mount), where it lands on the topmost, visible layer.
|
|
696
|
+
* `remount,ro,bind` by path reaches only that top layer, so a covered record's
|
|
697
|
+
* `rw` flag is not a hole: measured on a running WSL2 distribution, after
|
|
698
|
+
* remounting the visible `/run/user` the covered record still reported
|
|
699
|
+
* `rw,relatime` while a write through the path failed `EROFS`. Judging by that
|
|
700
|
+
* covered flag was a false positive that refused every confined command.
|
|
701
|
+
*
|
|
702
|
+
* **Everything else is judged by the MOUNT FLAG.** On a socket, FIFO or device
|
|
703
|
+
* node `[ -w ]` says nothing about the mount: the kernel's read-only answer is
|
|
704
|
+
* deliberately narrowed to storage. `sb_permission()` returns `-EROFS` only for
|
|
705
|
+
* a regular file, directory or symlink (`fs/namei.c`: `S_ISREG || S_ISDIR ||
|
|
706
|
+
* S_ISLNK`), `do_faccessat()`'s own read-only re-check — the one `[ -w ]`
|
|
707
|
+
* reaches — repeats the exclusion with `special_file(inode->i_mode)`, and
|
|
708
|
+
* between them `generic_permission()` lets the check's uid (root, because this
|
|
709
|
+
* fragment runs before the setpriv drop) through `CAP_DAC_OVERRIDE` for a
|
|
710
|
+
* `MAY_WRITE`-only mask. A SOCKET therefore reports "writable" on a mount that
|
|
711
|
+
* was successfully remounted read-only — measured as fence exit 97 for a
|
|
712
|
+
* bind-mounted unix socket under `/mnt/wsl/podman-sockets` while `podman
|
|
713
|
+
* machine` was running, refusing every confined command including a bare `true`
|
|
714
|
+
* (github issue #2).
|
|
715
|
+
*
|
|
716
|
+
* Both read ONE `findmnt -rno TARGET,OPTIONS` enumeration, and `ro` is a comma
|
|
717
|
+
* member rather than a `^ro` prefix: a mount's VFS options are a comma list and
|
|
718
|
+
* nothing pins `ro` to a position.
|
|
629
719
|
* @param {string[]} keep - mount targets that may stay writable.
|
|
630
720
|
* @returns {string} the fragment.
|
|
631
721
|
*/
|
|
@@ -634,17 +724,62 @@ function postConditions(keep) {
|
|
|
634
724
|
return [
|
|
635
725
|
'mountpoint -q /tmp || fail "/tmp is not a private tmpfs"',
|
|
636
726
|
'findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"',
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
727
|
+
// TARGET,OPTIONS in ONE enumeration, and the exemption test applies to the
|
|
728
|
+
// DECODED TARGET alone. `findmnt -r` escapes a space in a target as \x20, so
|
|
729
|
+
// the escaped target is a single space-free field and the options are the
|
|
730
|
+
// rest of the line; the split therefore happens BEFORE the decode, which is
|
|
731
|
+
// what keeps a spaced target one target (the sweep's own regression) while
|
|
732
|
+
// the options stay intact for the flag test. Test the target, not the whole
|
|
733
|
+
// line: the pattern is anchored, so a line carrying options would never
|
|
734
|
+
// match and every target — the workspace included — would be required to be
|
|
735
|
+
// read-only.
|
|
736
|
+
"findmnt -rno TARGET,OPTIONS | while IFS=' ' read -r raw opts; do",
|
|
737
|
+
// `\${raw}` is ESCAPED: this fragment is a JS template literal, and an
|
|
738
|
+
// unescaped `${raw}` is JS interpolation (a ReferenceError at build time),
|
|
739
|
+
// not the shell variable the fence needs.
|
|
740
|
+
' target=$(printf \'%b\' "${raw}");',
|
|
741
|
+
// A failing grep is not a match: the sweep has already failed closed on a
|
|
742
|
+
// broken pattern (fail "exemption grep failed"), so an unreadable exemption
|
|
743
|
+
// here can only over-ask, never under-ask. `if ...; then continue; fi` and
|
|
744
|
+
// NOT `grep ... && continue`: the failing branch of an if returns 0, while
|
|
745
|
+
// a bare `&&` list would return 1 for every non-exempt target and `set -e`
|
|
746
|
+
// would abort the fence with status 1 instead of 97.
|
|
747
|
+
` if printf '%s\\n' "$target" | grep -Eq ${shellQuote(pattern)}; then continue; fi;`,
|
|
748
|
+
// WHY THE FLAG TEST IS GATED ON REACHABILITY, and the measurement behind it.
|
|
749
|
+
// `findmnt` lists ONE RECORD PER MOUNT, and a target can be stacked: measured
|
|
750
|
+
// on a running WSL2 distribution, `/run/user` appears TWICE (a mode=755 layer
|
|
751
|
+
// and a relatime layer on top of it) and `/proc/sys/fs/binfmt_misc/status`
|
|
752
|
+
// is a second record under a first. `mount -o remount,ro,bind "$target"` by
|
|
753
|
+
// path can only ever reach the TOPMOST mount, so the covered record keeps
|
|
754
|
+
// `rw` no matter what the sweep does — measured: after remounting the visible
|
|
755
|
+
// `/run/user`, the other record still reported `rw,relatime` while a write
|
|
756
|
+
// through the path failed EROFS, i.e. the covered record is NOT a hole (it is
|
|
757
|
+
// unreachable storage). Failing on its flag was therefore a false positive
|
|
758
|
+
// that refused every confined command, which is precisely the defect class
|
|
759
|
+
// this whole change exists to remove.
|
|
760
|
+
//
|
|
761
|
+
// `[ -w ]` on the target answers about the mount a WRITE WOULD ACTUALLY
|
|
762
|
+
// LAND ON — the topmost, reachable one — which is the question the boundary
|
|
763
|
+
// is about. So the two tests are paired rather than redundant: `[ -w ]` is
|
|
764
|
+
// the reachability and writability fact, and the flag test is applied only
|
|
765
|
+
// where the flag is the authority, i.e. on a target that is not storage. For
|
|
766
|
+
// a socket/FIFO/device, `[ -w ]` says nothing about the mount (see
|
|
767
|
+
// {@link KERNEL_SURFACES} and the module note), so the flag is what decides.
|
|
768
|
+
' if [ -d "$target" ] || [ -f "$target" ]; then',
|
|
769
|
+
// Storage inode: `[ -w ]` decides. It already covers a covered record (the
|
|
770
|
+
// write would land on the reachable mount) and needs no flag test.
|
|
771
|
+
' if [ -w "$target" ]; then fail "$target is still writable"; fi;',
|
|
772
|
+
' else',
|
|
773
|
+
// Not storage: the mount flag decides, and it must be `ro` as a comma member
|
|
774
|
+
// (a mount's VFS options are a comma list; `ro` is not pinned to a position).
|
|
775
|
+
' case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac;',
|
|
776
|
+
' fi;',
|
|
777
|
+
// The trailing `true` keeps the pipeline itself green: this while is the
|
|
778
|
+
// LAST element of a pipeline, and anything that ends the body with a
|
|
779
|
+
// non-zero status would fail the whole pipeline under `set -o pipefail` and
|
|
780
|
+
// abort the script before the command runs. A real `fail` still exits 97
|
|
781
|
+
// from inside the loop, which pipefail turns into the script's own setup
|
|
782
|
+
// failure — the marker and exit code still surface.
|
|
648
783
|
' true;',
|
|
649
784
|
'done',
|
|
650
785
|
'true',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Privileged mode (-p): this script runs as root via sudo, and without -p bash would
|
|
3
3
|
# source a caller-supplied BASH_ENV file BEFORE our first line, as root. The
|
|
4
4
|
# startup-defence note below carries the measurement (bash 5.2.37).
|
|
5
|
-
# dsh-wsl-confine v1.
|
|
5
|
+
# dsh-wsl-confine v1.3 — DSH WSL confinement helper.
|
|
6
6
|
#
|
|
7
7
|
# Root-owned fence executor: the ONLY thing this helper does is apply the
|
|
8
8
|
# mount-namespace fence (workspace bind, tmpfs /tmp, read-only /, read-only
|
|
@@ -88,7 +88,7 @@ export PATH
|
|
|
88
88
|
# purpose: it must not depend on the deployment's sudoers either way.
|
|
89
89
|
|
|
90
90
|
set -euo pipefail
|
|
91
|
-
VERSION='dsh-wsl-confine v1.
|
|
91
|
+
VERSION='dsh-wsl-confine v1.3'
|
|
92
92
|
|
|
93
93
|
uid=; gid=; home=; cwd=; workspace=; pidns=1
|
|
94
94
|
ARGS=()
|
|
@@ -234,8 +234,30 @@ findmnt -rno TARGET | while IFS= read -r raw; do printf "%b\n" "$raw"; done | {
|
|
|
234
234
|
done
|
|
235
235
|
mountpoint -q /tmp || fail "/tmp is not a private tmpfs"
|
|
236
236
|
findmnt -rno OPTIONS / | grep -q "^ro" || fail "/ is not read-only"
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
# The postcondition pairs two tests, and each is load-bearing where the other is
|
|
238
|
+
# blind. A target can be STACKED — findmnt lists one record per mount — and
|
|
239
|
+
# `remount,ro,bind` by path only ever reaches the TOPMOST one (measured: after
|
|
240
|
+
# remounting /run/user, a covered record still reported rw while a write through
|
|
241
|
+
# the path failed EROFS — unreachable storage, not a hole). So:
|
|
242
|
+
# * a REGULAR FILE or DIRECTORY is judged by `[ -w ]`: it answers about the
|
|
243
|
+
# mount a write would actually land on, covered record or not;
|
|
244
|
+
# * anything else (socket, FIFO, device) is judged by the MOUNT FLAG, because
|
|
245
|
+
# `[ -w ]` is meaningless there: access(W_OK) narrows the kernel's read-only
|
|
246
|
+
# answer to S_ISREG/S_ISDIR/S_ISLNK (sb_permission), do_faccessat() adds a
|
|
247
|
+
# special_file() exclusion, and this fragment runs as root (CAP_DAC_OVERRIDE)
|
|
248
|
+
# before the setpriv drop. A socket therefore reported "writable" on a mount
|
|
249
|
+
# that WAS remounted read-only: measured as exit 97 for the unix socket under
|
|
250
|
+
# /mnt/wsl/podman-sockets with podman machine running, refusing every
|
|
251
|
+
# confined command including a bare `true` (github issue #2).
|
|
252
|
+
findmnt -rno TARGET,OPTIONS | while IFS=" " read -r raw opts; do
|
|
253
|
+
target=$(printf "%b" "$raw")
|
|
254
|
+
if printf "%s\n" "$target" | grep -Eq "^(${DROP_EXEMPT})\$"; then continue; fi
|
|
255
|
+
if [[ -d "$target" || -f "$target" ]]; then
|
|
256
|
+
if [[ -w "$target" ]]; then fail "$target is still writable"; fi
|
|
257
|
+
else
|
|
258
|
+
# Comma-delimited membership, not a ^ro prefix: ro is not pinned to a position.
|
|
259
|
+
case ",$opts," in *,ro,*) ;; *) fail "$target is not read-only (mount options: $opts)" ;; esac
|
|
260
|
+
fi
|
|
239
261
|
true
|
|
240
262
|
done
|
|
241
263
|
cd "$DROP_CWD"
|