import ctypes import os import os.path import pathlib import sys import traceback import poetry as poetry_package from cleo.io.io import IO from landlock import FSAccess, Ruleset from poetry.plugins.plugin import Plugin from poetry.poetry import Poetry def existing_paths(paths): assert isinstance(paths, (list, tuple)) for path in paths: if os.path.exists(path): yield path def ensure_paths(paths): assert isinstance(paths, (list, tuple)) for path in paths: if not os.path.exists(path): os.makedirs(path) 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 # Needed, otherwise raises: # Fatal Python error: init_import_site: Failed to import the site module # /home/user/.local/pipx/venvs/poetry/pyvenv.cfg poetry_pyvenv_cfg = poetry_libs_path.parent.parent.parent / "pyvenv.cfg" ruleset = Ruleset() # Rules for Poetry's virtual environment management ruleset.allow( *ensure_paths( ( # Storing the virtual environment poetry.config.virtualenvs_path, # Cached dependencies poetry.config.artifacts_cache_directory, poetry.config.repository_cache_directory, ), ), rules=FSAccess.all(), ) # Temporary storage ruleset.allow("/tmp", rules=FSAccess.all() & ~FSAccess.EXECUTE) # Poetry may also want to late-import some of its dependencies, or built-in modules ruleset.allow( *existing_paths(sys.path), rules=FSAccess.READ_FILE | FSAccess.READ_DIR ) # Finally, the Python executable may need to import some of its shared libraries ruleset.allow( *existing_paths(("/lib", "/lib64")), rules=FSAccess.READ_FILE | FSAccess.READ_DIR | FSAccess.EXECUTE, ) # and in poetry shell, we might want to run some system executables, too ruleset.allow( "/usr/bin", rules=FSAccess.READ_FILE | FSAccess.READ_DIR | FSAccess.EXECUTE ) # 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, 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 ) # Python's `zoneinfo` module ruleset.allow( "/usr/share/zoneinfo/", rules=FSAccess.READ_FILE | FSAccess.READ_DIR ) ruleset.allow( # We need to know which DNS resolver to use, and any custom hosts *existing_paths(("/etc/resolv.conf", "/etc/hosts")), # pip reads this file in _vendor/distro/distro.py *existing_paths(("/etc/debian_version",)), # I'm not opposed to including things like this because I don't want to annoy people # when their tooling doesn't work. But we have to be conservative. I think shells # are fine, but if there was some further tooling (e.g. shell tools run at startup) # I don't think those should be included. *existing_paths(("/etc/bash.bashrc", os.path.expanduser("~/.bashrc"))), rules=FSAccess.READ_FILE, ) ruleset.allow( "/etc/ssl/certs", "/usr/local/share/ca-certificates", rules=FSAccess.READ_FILE | FSAccess.READ_DIR, ) # Allow determining mime types. Used for ruamel.yaml installation. ruleset.allow("/etc/mime.types", rules=FSAccess.READ_FILE) # Allow working with shared memory ruleset.allow("/dev/shm") # Black cache access ruleset.allow( *existing_paths((os.path.expanduser("~/.cache/black"),)), rules=FSAccess.READ_FILE | FSAccess.WRITE_FILE | FSAccess.READ_DIR, ) pre_commit_cache = os.path.expanduser("~/.cache/pre-commit") if os.path.exists(pre_commit_cache): ruleset.allow(pre_commit_cache) # pre-commit runs git to figure out the diff to lint, which will # be pretty noisy if we do not whitelist the gitconfig. ruleset.allow( *existing_paths( ( os.path.expanduser("~/.gitconfig"), os.path.expanduser("~/.config/git/config"), ) ), rules=FSAccess.READ_FILE, ) # # Usage of Ansible with DEFAULT_LOCAL_TMP # ruleset.allow(*existing_paths((os.path.expanduser("~/.ansible/tmp"),))) # ruleset.allow("/etc/passwd", rules=FSAccess.READ_FILE) # 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.~~ this should # not happen, since landlock enforces nonewprivs. ruleset.allow(os.path.dirname(poetry.pyproject_path)) # => Rules for poetry-in-poetry # # This is suboptimal. It is needed for nested invocations of poetry, which # sometimes happen through a combination of tooling (e.g. script calling # command through poetry being run in poetry shell). However, the # poetry configuration directory contains a file named `auth.toml`, which # sounds it makes sense to restrict. The cleaner solution here would be # to mount a tmpfs over here so it appears empty. ruleset.allow( *existing_paths((os.path.expanduser("~/.config/pypoetry"),)), rules=FSAccess.READ_FILE | FSAccess.READ_DIR, ) # 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): if os.getenv("POETRY_NO_RESTRICT") == "1": io.write_line( "poetry-restrict-plugin: " "Disabled via POETRY_NO_RESTRICT environment variable!" ) return try: self.unshare(poetry) self.landlock(poetry) io.write_line("poetry-restrict-plugin: Landlocked & unshared.") except Exception as err: io.write_line( "Fatal error trying to enforce Landlock rules or unshare:" ) traceback.print_exception(err) io.write_line( "This is an issue of the Poetry restrict plugin, not of Poetry itself." ) raise