1import errno 2import os 3 4from enum import IntEnum 5from pathlib import Path 6 7 8class Accessibility(IntEnum): 9 """ 10 The level of accessibility we have on a file or directory. 11 12 This is needed to assess the attack surface on the file system namespace we 13 have within a confined service. Higher levels mean more permissions for the 14 user and thus a bigger attack surface. 15 """ 16 NONE = 0 17 18 # Directories can be listed or files can be read. 19 READABLE = 1 20 21 # This is for special file systems such as procfs and for stuff such as 22 # FIFOs or character special files. The reason why this has a lower value 23 # than WRITABLE is because those files are more restricted on what and how 24 # they can be written to. 25 SPECIAL = 2 26 27 # Another special case are sticky directories, which do allow write access 28 # but restrict deletion. This does *not* apply to sticky directories that 29 # are read-only. 30 STICKY = 3 31 32 # Essentially full permissions, the kind of accessibility we want to avoid 33 # in most cases. 34 WRITABLE = 4 35 36 def assert_on(self, path: Path) -> None: 37 """ 38 Raise an AssertionError if the given 'path' allows for more 39 accessibility than 'self'. 40 """ 41 actual = self.NONE 42 43 if path.is_symlink(): 44 actual = self.READABLE 45 elif path.is_dir(): 46 writable = True 47 48 dummy_file = path / 'can_i_write' 49 try: 50 dummy_file.touch() 51 except OSError as e: 52 if e.errno in [errno.EROFS, errno.EACCES]: 53 writable = False 54 else: 55 raise 56 else: 57 dummy_file.unlink() 58 59 if writable: 60 # The reason why we test this *after* we made sure it's 61 # writable is because we could have a sticky directory where 62 # the current user doesn't have write access. 63 if path.stat().st_mode & 0o1000 == 0o1000: 64 actual = self.STICKY 65 else: 66 actual = self.WRITABLE 67 else: 68 actual = self.READABLE 69 elif path.is_file(): 70 try: 71 with path.open('rb') as fp: 72 fp.read(1) 73 actual = self.READABLE 74 except PermissionError: 75 pass 76 77 writable = True 78 try: 79 with path.open('ab') as fp: 80 fp.write('x') 81 size = fp.tell() 82 fp.truncate(size) 83 except PermissionError: 84 writable = False 85 except OSError as e: 86 if e.errno == errno.ETXTBSY: 87 writable = os.access(path, os.W_OK) 88 elif e.errno == errno.EROFS: 89 writable = False 90 else: 91 raise 92 93 # Let's always try to fail towards being writable, so if *either* 94 # access(2) or a real write is successful it's writable. This is to 95 # make sure we don't accidentally introduce no-ops if we have bugs 96 # in the more complicated real write code above. 97 if writable or os.access(path, os.W_OK): 98 actual = self.WRITABLE 99 else: 100 # We need to be very careful when writing to or reading from 101 # special files (eg. FIFOs), since they can possibly block. So if 102 # it's not a file, just trust that access(2) won't lie. 103 if os.access(path, os.R_OK): 104 actual = self.READABLE 105 106 if os.access(path, os.W_OK): 107 actual = self.SPECIAL 108 109 if actual > self: 110 stat = path.stat() 111 details = ', '.join([ 112 f'permissions: {stat.st_mode & 0o7777:o}', 113 f'uid: {stat.st_uid}', 114 f'group: {stat.st_gid}', 115 ]) 116 117 raise AssertionError( 118 f'Expected at most {self!r} but got {actual!r} for path' 119 f' {path} ({details}).' 120 ) 121 122 123def is_special_fs(path: Path) -> bool: 124 """ 125 Check whether the given path truly is a special file system such as procfs 126 or sysfs. 127 """ 128 try: 129 if path == Path('/proc'): 130 return (path / 'version').read_text().startswith('Linux') 131 elif path == Path('/sys'): 132 return b'Linux' in (path / 'kernel' / 'notes').read_bytes() 133 except FileNotFoundError: 134 pass 135 return False 136 137 138def is_empty_dir(path: Path) -> bool: 139 try: 140 next(path.iterdir()) 141 return False 142 except (StopIteration, PermissionError): 143 return True 144 145 146def _assert_permissions_in_directory( 147 directory: Path, 148 accessibility: Accessibility, 149 subdirs: dict[Path, Accessibility], 150) -> None: 151 accessibility.assert_on(directory) 152 153 for file in directory.iterdir(): 154 if is_special_fs(file): 155 msg = f'Got unexpected special filesystem at {file}.' 156 assert subdirs.pop(file) == Accessibility.SPECIAL, msg 157 elif not file.is_symlink() and file.is_dir(): 158 subdir_access = subdirs.pop(file, accessibility) 159 if is_empty_dir(file): 160 # Whenever we got an empty directory, we check the permission 161 # constraints on the current directory (except if specified 162 # explicitly in subdirs) because for example if we're non-root 163 # (the constraints of the current directory are thus 164 # Accessibility.READABLE), we really have to make sure that 165 # empty directories are *never* writable. 166 subdir_access.assert_on(file) 167 else: 168 _assert_permissions_in_directory(file, subdir_access, subdirs) 169 else: 170 subdirs.pop(file, accessibility).assert_on(file) 171 172 173def assert_permissions(subdirs: dict[str, Accessibility]) -> None: 174 """ 175 Recursively check whether the file system conforms to the accessibility 176 specification we specified via 'subdirs'. 177 """ 178 root = Path('/') 179 absolute_subdirs = {root / p: a for p, a in subdirs.items()} 180 _assert_permissions_in_directory( 181 root, 182 Accessibility.WRITABLE if os.getuid() == 0 else Accessibility.READABLE, 183 absolute_subdirs, 184 ) 185 for file in absolute_subdirs.keys(): 186 msg = f'Expected {file} to exist, but it was nowwhere to be found.' 187 raise AssertionError(msg)