Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 6 additions & 38 deletions box/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@
_TOOLS_DIRS = ("usr", "opt")
_TOOLS_LINKS = ("bin", "sbin", "lib", "lib32", "lib64")
_HOST_SKIP = frozenset({"proc", "nix", "etc", *_TOOLS_DIRS, *_TOOLS_LINKS})
_HOST_ETC = ("machine-id",)
# 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")

# Deterministic environment replacing the sandbox's inherited host environment.
_BASE_ENV = {
Expand Down Expand Up @@ -157,43 +162,6 @@ 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


Expand Down
10 changes: 6 additions & 4 deletions box/sandbox_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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").write_text("layered:x:1:1::/:/bin/sh\n")
(layer / "etc/passwd").touch()
(layer / "srv").mkdir()
return layer

Expand All @@ -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 files over /etc/passwd, /etc/group and /etc/resolv.conf for every command,
# Relaxed mode binds the host's /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"):
Expand Down Expand Up @@ -448,8 +448,10 @@ 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))
passwd = next(bind for bind in _binds(relaxed) if bind.target == "/etc/passwd")
self.assertTrue(Path(passwd.source).read_text().startswith("layered:"))
# 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))

def test_main_mounts_the_merged_tree_only_while_it_plans(self) -> None:
for layers in (["/top"], ["/base", "/top"]):
Expand Down
Loading