diff --git a/box/sandbox.py b/box/sandbox.py index a89e673b..e1b3df21 100644 --- a/box/sandbox.py +++ b/box/sandbox.py @@ -48,12 +48,7 @@ _TOOLS_DIRS = ("usr", "opt") _TOOLS_LINKS = ("bin", "sbin", "lib", "lib32", "lib64") _HOST_SKIP = frozenset({"proc", "nix", "etc", *_TOOLS_DIRS, *_TOOLS_LINKS}) -# Relaxed mode maps only the caller's uid and gid into the user namespace. No file or process in the sandbox -# can belong to another user from the box's /etc/passwd. The box's /etc/passwd lacks the caller, and -# ssh-keygen aborts when getpwuid() cannot find the caller. The host's nsswitch.conf can name an NSS module -# that the box does not install, such as sss. glibc skips a missing module and queries the next one, such -# as systemd. The systemd module reaches the host's userdb through the bound /run. -_HOST_ETC = ("group", "machine-id", "nsswitch.conf", "passwd") +_HOST_ETC = ("machine-id",) # Deterministic environment replacing the sandbox's inherited host environment. _BASE_ENV = { @@ -162,6 +157,43 @@ def _relaxed(tools: str, stage: Stage) -> list[Filesystem]: host = os.path.join("/etc", f) if os.path.exists(host) and os.path.exists(os.path.join(etc, f)): out.append(Bind(host, host, readonly=True)) + return out + _identity(tools) + + +def _identity(tools: str) -> list[Filesystem]: + """Make the invoking uid/gid resolvable inside the sandbox. + + Relaxed /etc comes from the tools tree, which lists only system users. On a host the caller's uid + is resolved by nss-systemd via the bound /run, but where that is unavailable (e.g. a CI runner + whose uid is served by neither files nor userdb) getpwuid() fails and callers like ssh-keygen + abort. Append an entry for the caller to the passwd/group tables and bind them over /etc; a file + bind stacks over the read-only /etc mount, which a plain write could not. + """ + uid, gid = os.getuid(), os.getgid() + name = os.environ.get("USER") or "" + home = os.environ.get("HOME") or "" + if not name or not name.isascii() or ":" in name or "\n" in name: + name = f"u{uid}" + if not home or ":" in home or "\n" in home: + home = "/root" + tables = { + "passwd": f"{name}:x:{uid}:{gid}::{home}:/bin/sh\n", + "group": f"{name}:x:{gid}:\n", + } + out: list[Filesystem] = [] + for base, entry in tables.items(): + source = os.path.join(tools, "etc", base) + content = "" + if os.path.exists(source): + with open(source, encoding="utf-8") as handle: + content = handle.read() + # Deterministic per-uid path: overwritten each run rather than accumulated, and O_NOFOLLOW so + # a pre-planted symlink can't redirect the write. + path = f"/var/tmp/.tine-sandbox-{base}-{uid}" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o644) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content + entry) + out.append(Bind(path, "/etc/" + base, readonly=True)) return out diff --git a/box/sandbox_test.py b/box/sandbox_test.py index d7c9ef83..8c7dce70 100644 --- a/box/sandbox_test.py +++ b/box/sandbox_test.py @@ -396,7 +396,7 @@ def _layer(tools: Path) -> Path: (layer / "usr/bin").mkdir(parents=True) (layer / "usr/bin/tool").write_text("from the layer\n") (layer / "etc").mkdir() - (layer / "etc/passwd").touch() + (layer / "etc/passwd").write_text("layered:x:1:1::/:/bin/sh\n") (layer / "srv").mkdir() return layer @@ -408,7 +408,7 @@ def _runnable(tools: Path) -> Path: host = tools.parent / "host-usr" for name in ("lib", "lib64"): (tools / name).symlink_to(host / name) - # Relaxed mode binds the host's /etc/passwd, /etc/group and /etc/resolv.conf for every command, + # Relaxed mode binds files over /etc/passwd, /etc/group and /etc/resolv.conf for every command, # even for a command that reads none of the files. The tools tree's /etc is bound read-only, so # the sandbox cannot create a missing mount point in it. enter_sandbox() then fails with EROFS. for name in ("passwd", "group", "resolv.conf"): @@ -448,10 +448,8 @@ def test_launch_reads_the_merged_tree(self) -> None: self.assertIn(("usr/bin", "/bin"), [(link.source, link.target) for link in _symlinks(hermetic)]) self.assertIn((layer / "srv", Path("/srv"), True, False), _bind_specs(hermetic)) self.assertIn((layer / "home", Path("/home"), True, False), _bind_specs(hermetic)) - # The base tools tree lacks etc/passwd, and only the layer contains it. Relaxed mode binds the - # host's /etc/passwd only when the tools tree contains etc/passwd. The bind therefore shows that - # _launch() read the merged tree. - self.assertIn((Path("/etc/passwd"), Path("/etc/passwd"), True, False), _bind_specs(relaxed)) + passwd = next(bind for bind in _binds(relaxed) if bind.target == "/etc/passwd") + self.assertTrue(Path(passwd.source).read_text().startswith("layered:")) def test_main_mounts_the_merged_tree_only_while_it_plans(self) -> None: for layers in (["/top"], ["/base", "/top"]):