diff --git a/osbuild/buildroot.py b/osbuild/buildroot.py index 10b5a327..73a95368 100644 --- a/osbuild/buildroot.py +++ b/osbuild/buildroot.py @@ -16,6 +16,10 @@ import subprocess import tempfile import time +from typing import Optional + +from osbuild.util import linux + __all__ = [ "BuildRoot", @@ -79,6 +83,10 @@ class BuildRoot(contextlib.AbstractContextManager): The `run()` method allows running applications in this environment. Some state is persistent across runs, including data in `/var`. It is deleted only when exiting the context manager. + + If `BuildRoot.caps` is not `None`, only the capabilities listed in this + set will be retained (all others will be dropped), otherwise all caps + are retained. """ def __init__(self, root, runner, libdir, var, *, rundir="/run/osbuild"): @@ -94,6 +102,7 @@ class BuildRoot(contextlib.AbstractContextManager): self.proc = None self.tmp = None self.mount_boot = True + self.caps: Optional[set] = None @staticmethod def _mknod(path, name, mode, major, minor): @@ -273,6 +282,8 @@ class BuildRoot(contextlib.AbstractContextManager): "--unshare-net" ] + cmd += self.build_capabilities_args() + cmd += mounts cmd += ["--", f"/run/osbuild/lib/runners/{self._runner}"] cmd += argv @@ -321,6 +332,32 @@ class BuildRoot(contextlib.AbstractContextManager): return CompletedBuild(proc, output) + def build_capabilities_args(self): + """Build the capabilities arguments for bubblewrap""" + args = [] + + # If no capabilities are explicitly requested we retain all of them + if self.caps is None: + return args + + # Under the assumption that we are running as root, the capabilities + # for the child process (bubblewrap) are calculated as follows: + # P'(effective) = P'(permitted) + # P'(permitted) = P(inheritable) | P(bounding) + # Thus bubblewrap will effectively run with all capabilities that + # are present in the bounding set. If run as root, bubblewrap will + # preserve all capabilities in the effective set when running the + # container, which corresponds to our bounding set. + # Therefore: drop all capabilities present in the bounding set minus + # the ones explicitly requested. + have = linux.cap_bound_set() + drop = have - self.caps + + for cap in sorted(drop): + args += ["--cap-drop", cap] + + return args + @classmethod def read_with_timeout(cls, proc, poller, start, timeout): fd = proc.stdout.fileno() diff --git a/test/mod/test_buildroot.py b/test/mod/test_buildroot.py index 849c24be..fd0b28c6 100644 --- a/test/mod/test_buildroot.py +++ b/test/mod/test_buildroot.py @@ -13,6 +13,7 @@ import pytest from osbuild.buildroot import BuildRoot from osbuild.monitor import LogMonitor, NullMonitor from osbuild.pipeline import detect_host_runner +from osbuild.util import linux from ..test import TestBase @@ -226,3 +227,62 @@ def test_env_isolation(tempdir): for k in have: assert k in allowed + + +@pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only") +def test_caps(tempdir): + runner = detect_host_runner() + libdir = os.path.abspath(os.curdir) + var = pathlib.Path(tempdir, "var") + var.mkdir() + + ipc = pathlib.Path(tempdir, "ipc") + ipc.mkdir() + + monitor = NullMonitor(sys.stderr.fileno()) + with BuildRoot("/", runner, libdir, var) as root: + + def run_and_get_caps(): + cmd = ["/bin/sh", "-c", "cat /proc/self/status > /ipc/status"] + r = root.run(cmd, monitor, binds=[f"{ipc}:/ipc"]) + + assert r.returncode == 0 + with open(os.path.join(ipc, "status"), encoding="utf-8") as f: + data = f.readlines() + assert data + + print(data) + perm = list(filter(lambda x: x.startswith("CapEff"), data)) + + assert perm and len(perm) == 1 + perm = perm[0] + + perm = perm[7:].strip() # strip "CapEff" + print(perm) + + caps = linux.cap_mask_to_set(int(perm, base=16)) + return caps + + # check case of `BuildRoot.caps` is `None`, i.e. don't drop capabilities, + # thus the effective capabilities should be the bounding set + assert root.caps is None + + bound_set = linux.cap_bound_set() + + caps = run_and_get_caps() + assert caps == bound_set + + # drop everything but `CAP_SYS_ADMIN` + assert "CAP_SYS_ADMIN" in bound_set + + enable = set(["CAP_SYS_ADMIN"]) + disable = bound_set - enable + + root.caps = enable + + caps = run_and_get_caps() + + for e in enable: + assert e in caps + for d in disable: + assert d not in caps