aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Johannes Christ <[email protected]>2025-03-17 19:32:14 +0100
committerGravatar Johannes Christ <[email protected]>2025-03-17 19:50:53 +0100
commit6ecb7fa17e3320d6eb0b507709c3c0683b94f400 (patch)
tree101bc1ddf05d100ac11bbb239338d1b7240d4bbc
parentAdd `ruff` as a development tool (diff)
Add unshare support
-rw-r--r--poetry_restrict_plugin/plugin.py117
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