diff options
author | 2025-03-17 19:32:14 +0100 | |
---|---|---|
committer | 2025-03-17 19:50:53 +0100 | |
commit | 6ecb7fa17e3320d6eb0b507709c3c0683b94f400 (patch) | |
tree | 101bc1ddf05d100ac11bbb239338d1b7240d4bbc | |
parent | Add `ruff` as a development tool (diff) |
Add unshare support
-rw-r--r-- | poetry_restrict_plugin/plugin.py | 117 |
1 files changed, 112 insertions, 5 deletions
diff --git a/poetry_restrict_plugin/plugin.py b/poetry_restrict_plugin/plugin.py index 7b80acc..00af92b 100644 --- a/poetry_restrict_plugin/plugin.py +++ b/poetry_restrict_plugin/plugin.py @@ -1,3 +1,5 @@ +import ctypes +import os import os.path import pathlib import sys @@ -25,7 +27,105 @@ def ensure_paths(paths): yield path +def find_libc(**kwargs): + # intentionally doesn't use `ctypes.util.find_library` since that seems + # to run external programs, which seems like a security risk. + libc = ctypes.CDLL("libc.so.6", **kwargs) + # const char *source, const char *target, const char *filesystemtype, + # unsigned long mountflags, const void *_Nullable data + libc.mount.argtypes = (ctypes.c_char_p, ctypes.c_char_p, + ctypes.c_char_p, ctypes.c_ulong, ctypes.c_char_p) + return libc + + +class UnshareFlags: + # HACK: manually entered here from `/usr/include/linux/sched.h`... + CLONE_VM = 0x00000100 + CLONE_FS = 0x00000200 + CLONE_FILES = 0x00000400 + CLONE_SIGHAND = 0x00000800 + CLONE_PIDFD = 0x00001000 + CLONE_PTRACE = 0x00002000 + CLONE_VFORK = 0x00004000 + CLONE_PARENT = 0x00008000 + CLONE_THREAD = 0x00010000 + CLONE_NEWNS = 0x00020000 + CLONE_SYSVSEM = 0x00040000 + CLONE_SETTLS = 0x00080000 + CLONE_PARENT_SETTID = 0x00100000 + CLONE_CHILD_CLEARTID = 0x00200000 + CLONE_DETACHED = 0x00400000 + CLONE_UNTRACED = 0x00800000 + CLONE_CHILD_SETTID = 0x01000000 + CLONE_NEWCGROUP = 0x02000000 + CLONE_NEWUTS = 0x04000000 + CLONE_NEWIPC = 0x08000000 + CLONE_NEWUSER = 0x10000000 + CLONE_NEWPID = 0x20000000 + CLONE_NEWNET = 0x40000000 + CLONE_IO = 0x80000000 + + +def exc_from_errno(syscall: str, detail: str | None = None, hint: str | None = None): + errno = ctypes.get_errno() + err = RuntimeError(f"Failed to {syscall}()...") + err.add_note(f"OS error: {os.strerror(errno)}") + err.add_note(f"Details: {detail}") + if hint: + err.add_note(f"HINT: {hint}") + return err + + class RestrictPlugin(Plugin): + def unshare(self, poetry: Poetry): + libc = find_libc(use_errno=True) + # After CLONE_NEWUSER, every UID will be 65534 (nobody) + uid = os.getuid() + + rc = libc.unshare(UnshareFlags.CLONE_NEWUSER) + if rc != 0: + raise exc_from_errno( + syscall="unshare", + detail="Tried to create a new user namespace", + hint="Ensure user namespacing is enabled (sysctl `kernel.unprivileged_userns_clone`)", + ) + + # Pretend we're root, see `man 7 user_namespaces`, "Defining user and group ID mappings" + with open("/proc/self/uid_map", "w") as f: + f.write(f"0 {uid} 1") + + # Now we are """root""" and can unshare whatever we wish + flags = ( + UnshareFlags.CLONE_FILES + | UnshareFlags.CLONE_FS + | UnshareFlags.CLONE_NEWCGROUP + | UnshareFlags.CLONE_NEWUTS + | UnshareFlags.CLONE_NEWPID + | UnshareFlags.CLONE_NEWNS + ) + rc = libc.unshare(flags) + if rc != 0: + raise exc_from_errno(syscall="unshare") + + # "The first child created by the calling process will have the process + # ID 1 and will assume the role of init(1) in the new namespace." + # Let's create that first child. + pid = os.fork() # Raises on error. + if pid > 0: + (_pid, child_exit_code) = os.waitpid(pid, 0) + exit_code = os.waitstatus_to_exitcode(child_exit_code) + sys.exit(exit_code) + + # Hide process table + mounts = ( + # source, target, fstype, mountflags, data (options?) + (b"proc", b"/proc", b"proc", 0, b""), + ) + for mountargs in mounts: + rc = libc.mount(*mountargs) + if rc != 0: + raise exc_from_errno(syscall="mount", detail=f"Mount options are {mountargs!r}") + def landlock(self, poetry: Poetry): # /home/user/.local/pipx/venvs/poetry/lib/python3.11/site-packages poetry_libs_path = pathlib.Path(poetry_package.__path__._path[0]).parent @@ -65,7 +165,7 @@ class RestrictPlugin(Plugin): # For compilation of C dependencies, we need to be able to find headers ruleset.allow(*existing_paths(("/usr/include",)), rules=FSAccess.READ_FILE | FSAccess.READ_DIR) - # We allow read access here, later we might want to restrict the pid namespace though + # We allow read access here, note the pid namespace is restricted ruleset.allow("/proc", rules=FSAccess.READ_FILE | FSAccess.READ_DIR) # needed for /dev/tty and /dev/pty devices, see /usr/lib/python3.11/pty.py ruleset.allow("/dev", rules=FSAccess.READ_FILE | FSAccess.READ_DIR | FSAccess.WRITE_FILE) @@ -120,8 +220,9 @@ class RestrictPlugin(Plugin): # ruleset.allow(*existing_paths((os.path.expanduser("~/.ssh/known_hosts"),)), rules=FSAccess.READ_FILE) # Allow manipulation of files in our projects, e.g. for linters. - # We might need to check this more thoroughly. For instance, configuring custom - # filter programs in gitattributes might allow a sandbox escape. + # We might need to check this more thoroughly. ~~For instance, configuring custom + # filter programs in gitattributes might allow a sandbox escape.~~ this should + # not happen, since landlock enforces nonewprivs. ruleset.allow(os.path.dirname(poetry.pyproject_path)) # => Rules for poetry-in-poetry @@ -139,6 +240,11 @@ class RestrictPlugin(Plugin): # Python may need to read pyvenv.cfg ruleset.allow(poetry_pyvenv_cfg, rules=FSAccess.READ_FILE) + # [Errno 13] Permission denied: '~/.local/share/virtualenv/py_info/1/$HASH.lock' + # Needs more investigation. Seems to happen on some occasions when + # setting up the virtual env. + # ruleset.allow(*existing_paths((os.path.expanduser("~/.local/share/virtualenv/py_info"),)), rules=FSAccess.READ_FILE | FSAccess.READ_DIR) + ruleset.apply() def activate(self, poetry: Poetry, io: IO): @@ -150,10 +256,11 @@ class RestrictPlugin(Plugin): return try: + self.unshare(poetry) self.landlock(poetry) - io.write_line("<info>poetry-restrict-plugin</info>: Landlock engaged.") + io.write_line("<info>poetry-restrict-plugin</info>: Landlocked & unshared.") except Exception as err: - io.write_line("<error>Fatal error trying to enforce Landlock rules:</error>") + io.write_line("<error>Fatal error trying to enforce Landlock rules or unshare:</error>") traceback.print_exception(err) io.write_line("<error>This is an issue of the Poetry restrict plugin, not of Poetry itself.</error>") raise |