about summary refs log tree commit diff
path: root/nixos/tests/systemd-confinement/checkperms.py
blob: 3c7ba279a3d20f161c809fc05c731605334245d4 (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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import errno
import os

from enum import IntEnum
from pathlib import Path


class Accessibility(IntEnum):
    """
    The level of accessibility we have on a file or directory.

    This is needed to assess the attack surface on the file system namespace we
    have within a confined service. Higher levels mean more permissions for the
    user and thus a bigger attack surface.
    """
    NONE = 0

    # Directories can be listed or files can be read.
    READABLE = 1

    # This is for special file systems such as procfs and for stuff such as
    # FIFOs or character special files. The reason why this has a lower value
    # than WRITABLE is because those files are more restricted on what and how
    # they can be written to.
    SPECIAL = 2

    # Another special case are sticky directories, which do allow write access
    # but restrict deletion. This does *not* apply to sticky directories that
    # are read-only.
    STICKY = 3

    # Essentially full permissions, the kind of accessibility we want to avoid
    # in most cases.
    WRITABLE = 4

    def assert_on(self, path: Path) -> None:
        """
        Raise an AssertionError if the given 'path' allows for more
        accessibility than 'self'.
        """
        actual = self.NONE

        if path.is_symlink():
            actual = self.READABLE
        elif path.is_dir():
            writable = True

            dummy_file = path / 'can_i_write'
            try:
                dummy_file.touch()
            except OSError as e:
                if e.errno in [errno.EROFS, errno.EACCES]:
                    writable = False
                else:
                    raise
            else:
                dummy_file.unlink()

            if writable:
                # The reason why we test this *after* we made sure it's
                # writable is because we could have a sticky directory where
                # the current user doesn't have write access.
                if path.stat().st_mode & 0o1000 == 0o1000:
                    actual = self.STICKY
                else:
                    actual = self.WRITABLE
            else:
                actual = self.READABLE
        elif path.is_file():
            try:
                with path.open('rb') as fp:
                    fp.read(1)
                actual = self.READABLE
            except PermissionError:
                pass

            writable = True
            try:
                with path.open('ab') as fp:
                    fp.write('x')
                    size = fp.tell()
                    fp.truncate(size)
            except PermissionError:
                writable = False
            except OSError as e:
                if e.errno == errno.ETXTBSY:
                    writable = os.access(path, os.W_OK)
                elif e.errno == errno.EROFS:
                    writable = False
                else:
                    raise

            # Let's always try to fail towards being writable, so if *either*
            # access(2) or a real write is successful it's writable. This is to
            # make sure we don't accidentally introduce no-ops if we have bugs
            # in the more complicated real write code above.
            if writable or os.access(path, os.W_OK):
                actual = self.WRITABLE
        else:
            # We need to be very careful when writing to or reading from
            # special files (eg.  FIFOs), since they can possibly block. So if
            # it's not a file, just trust that access(2) won't lie.
            if os.access(path, os.R_OK):
                actual = self.READABLE

            if os.access(path, os.W_OK):
                actual = self.SPECIAL

        if actual > self:
            stat = path.stat()
            details = ', '.join([
                f'permissions: {stat.st_mode & 0o7777:o}',
                f'uid: {stat.st_uid}',
                f'group: {stat.st_gid}',
            ])

            raise AssertionError(
                f'Expected at most {self!r} but got {actual!r} for path'
                f' {path} ({details}).'
            )


def is_special_fs(path: Path) -> bool:
    """
    Check whether the given path truly is a special file system such as procfs
    or sysfs.
    """
    try:
        if path == Path('/proc'):
            return (path / 'version').read_text().startswith('Linux')
        elif path == Path('/sys'):
            return b'Linux' in (path / 'kernel' / 'notes').read_bytes()
    except FileNotFoundError:
        pass
    return False


def is_empty_dir(path: Path) -> bool:
    try:
        next(path.iterdir())
        return False
    except (StopIteration, PermissionError):
        return True


def _assert_permissions_in_directory(
    directory: Path,
    accessibility: Accessibility,
    subdirs: dict[Path, Accessibility],
) -> None:
    accessibility.assert_on(directory)

    for file in directory.iterdir():
        if is_special_fs(file):
            msg = f'Got unexpected special filesystem at {file}.'
            assert subdirs.pop(file) == Accessibility.SPECIAL, msg
        elif not file.is_symlink() and file.is_dir():
            subdir_access = subdirs.pop(file, accessibility)
            if is_empty_dir(file):
                # Whenever we got an empty directory, we check the permission
                # constraints on the current directory (except if specified
                # explicitly in subdirs) because for example if we're non-root
                # (the constraints of the current directory are thus
                # Accessibility.READABLE), we really have to make sure that
                # empty directories are *never* writable.
                subdir_access.assert_on(file)
            else:
                _assert_permissions_in_directory(file, subdir_access, subdirs)
        else:
            subdirs.pop(file, accessibility).assert_on(file)


def assert_permissions(subdirs: dict[str, Accessibility]) -> None:
    """
    Recursively check whether the file system conforms to the accessibility
    specification we specified via 'subdirs'.
    """
    root = Path('/')
    absolute_subdirs = {root / p: a for p, a in subdirs.items()}
    _assert_permissions_in_directory(
        root,
        Accessibility.WRITABLE if os.getuid() == 0 else Accessibility.READABLE,
        absolute_subdirs,
    )
    for file in absolute_subdirs.keys():
        msg = f'Expected {file} to exist, but it was nowwhere to be found.'
        raise AssertionError(msg)