summaryrefslogtreecommitdiffstats
path: root/poetry_restrict_plugin/plugin.py
blob: 632f5bfa227d7ddbf30cb38a72c57a688ca1dad8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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


class RestrictPlugin(Plugin):
    def landlock(self, poetry: Poetry):
        poetry_libs_path = pathlib.Path(poetry_package.__path__._path[0]).parent

        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, later we might want to restrict the pid namespace though
        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)

        pre_commit_cache = os.path.expanduser("~/.cache/pre-commit")
        if os.path.exists(pre_commit_cache):
            ruleset.allow(pre_commit_cache)
            ruleset.allow(
                *existing_paths((".gitconfig", os.path.expanduser("~/.config/git/config"))),
                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.
        ruleset.allow(os.path.dirname(poetry.pyproject_path))

        ruleset.apply()

    def activate(self, poetry: Poetry, io: IO):
        if os.getenv("POETRY_NO_RESTRICT") == "1":
            io.write_line(
                "<info>poetry-restrict-plugin</info>: "
                "<comment>Disabled via POETRY_NO_RESTRICT environment variable!</comment>"
            )
            return

        try:
            self.landlock(poetry)
            io.write_line("<info>poetry-restrict-plugin</info>: Landlock engaged.")
        except Exception as err:
            io.write_line("<error>Fatal error trying to enforce Landlock rules:</error>")
            traceback.print_exception(err)
            io.write_line("<error>This is an issue of the Poetry restrict plugin, not of Poetry itself.</error>")
            raise