Coverage for daklib/sandbox.py: 41%

81 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2026-05-10 21:38 +0000

1# SPDX-License-Identifier: GPL-2.0-or-later 

2# © 2026, Ansgar 🙀 <ansgar@debian.org> 

3 

4"""Sandbox external commands via `systemd-run --user`""" 

5 

6import logging 

7import os 

8import subprocess 

9from collections.abc import Sequence 

10from dataclasses import dataclass 

11from functools import cache 

12 

13from daklib.config import Config 

14 

15_logger = logging.getLogger(__name__) 

16 

17 

18@dataclass 

19class Sandbox: 

20 private_devices = True 

21 private_ipc = True 

22 private_network = True 

23 

24 inaccessible_paths: Sequence[str] | None = None 

25 extra_inaccessible_paths: Sequence[str] = () 

26 temporary_file_systems: Sequence[str] | None = None 

27 extra_temporary_file_systems: Sequence[str] = () 

28 read_only_paths: Sequence[str] | None = None 

29 extra_read_only_paths: Sequence[str] = () 

30 read_write_paths: Sequence[str] | None = None 

31 extra_read_write_paths: Sequence[str] = () 

32 bind_read_only_paths: Sequence[str] | None = None 

33 extra_bind_read_only_paths: Sequence[str] = () 

34 bind_read_write_paths: Sequence[str] | None = None 

35 extra_bind_read_write_paths: Sequence[str] = () 

36 

37 restrict_address_families: Sequence[str] | None = () 

38 system_call_filter: Sequence[str] | None = ("@system-service",) 

39 

40 

41@cache 

42def _default_sandbox() -> Sandbox: 

43 return Sandbox( 

44 read_only_paths=("/",), 

45 ) 

46 

47 

48def _effective( 

49 paths: Sequence[str] | None, 

50 extra_paths: Sequence[str], 

51 default_paths: Sequence[str] | None, 

52) -> Sequence[str]: 

53 if paths is None: 

54 paths = default_paths or () 

55 return (*paths, *extra_paths) 

56 

57 

58def build_sandbox_command(sandbox: Sandbox, original_cmd: Sequence[str]) -> list[str]: 

59 default = _default_sandbox() 

60 

61 cmd: list[str] = [ 

62 "systemd-run", 

63 "--user", 

64 "--pipe", 

65 "--wait", 

66 "--quiet", 

67 "--collect", 

68 "--service-type=exec", 

69 "--expand-environment=no", 

70 "--same-dir", 

71 # Capabilities 

72 "-pCapabilityBoundingSet=", 

73 # Security 

74 "-pNoNewPrivileges=yes", 

75 "-pLockPersonality=yes", 

76 # System call filtering 

77 "-pSystemCallArchitectures=native", 

78 ] 

79 

80 if "TMPDIR" in os.environ: 

81 cmd.append("-ETMPDIR") 

82 

83 if sandbox.private_devices: 

84 cmd.append("-pPrivateDevices=yes") 

85 

86 if sandbox.private_ipc: 

87 cmd.append("-pPrivateIPC=yes") 

88 

89 if sandbox.private_network: 

90 cmd.append("-pPrivateNetwork=yes") 

91 

92 if effective_inaccessible_paths := _effective( 

93 sandbox.inaccessible_paths, 

94 sandbox.extra_inaccessible_paths, 

95 default.inaccessible_paths, 

96 ): 

97 cmd.append(f"-pInaccessiblePaths={" ".join(effective_inaccessible_paths)}") 

98 

99 if temporary_file_systems := _effective( 

100 sandbox.temporary_file_systems, 

101 sandbox.extra_temporary_file_systems, 

102 default.temporary_file_systems, 

103 ): 

104 cmd.append(f"-pTemporaryFileSystem={" ".join(temporary_file_systems)}") 

105 

106 if read_only_paths := _effective( 

107 sandbox.read_only_paths, sandbox.extra_read_only_paths, default.read_only_paths 

108 ): 

109 cmd.append(f"-pReadOnlyPaths={" ".join(read_only_paths)}") 

110 

111 if read_write_paths := _effective( 

112 sandbox.read_write_paths, 

113 sandbox.extra_read_write_paths, 

114 default.read_write_paths, 

115 ): 

116 cmd.append(f"-pReadWritePaths={" ".join(read_write_paths)}") 

117 

118 if bind_read_only_paths := _effective( 

119 sandbox.bind_read_only_paths, 

120 sandbox.extra_bind_read_only_paths, 

121 default.bind_read_only_paths, 

122 ): 

123 cmd.append(f"-pBindReadOnlyPaths={" ".join(bind_read_only_paths)}") 

124 

125 if bind_read_write_paths := _effective( 

126 sandbox.bind_read_write_paths, 

127 sandbox.extra_bind_read_write_paths, 

128 default.bind_read_write_paths, 

129 ): 

130 cmd.append(f"-pBindReadWritePaths={" ".join(bind_read_write_paths)}") 

131 

132 if (restrict_address_families := sandbox.restrict_address_families) is not None: 

133 cmd.append(f"-pRestrictAddressFamilies={" ".join(restrict_address_families)}") 

134 

135 if (system_call_filter := sandbox.system_call_filter) is not None: 

136 cmd.append(f"-pSystemCallFilter={" ".join(system_call_filter)}") 

137 

138 cmd.append("--") 

139 cmd.extend(original_cmd) 

140 return cmd 

141 

142 

143def _run_sandboxed( 

144 sandbox: Sandbox, cmd: Sequence[str], **kwargs 

145) -> subprocess.CompletedProcess: 

146 sandboxed_cmd = build_sandbox_command(sandbox, cmd) 

147 return subprocess.run(sandboxed_cmd, **kwargs) 

148 

149 

150@cache 

151def _sandbox_enabled() -> bool: 

152 config = Config() 

153 if not config.find_b("Dinstall::Sandbox::Enable", True): 153 ↛ 157line 153 didn't jump to line 157 because the condition on line 153 was always true

154 return False 

155 

156 # Check for working sandbox. 

157 try: 

158 _run_sandboxed(_default_sandbox(), ["/usr/bin/true"], check=True) 

159 except Exception as e: 

160 raise Exception("Sandbox is not available.") from e 

161 

162 return True 

163 

164 

165def run( 

166 cmd: Sequence[str], *, sandbox: Sandbox, **kwargs 

167) -> subprocess.CompletedProcess: 

168 if not _sandbox_enabled(): 168 ↛ 170line 168 didn't jump to line 170 because the condition on line 168 was always true

169 return subprocess.run(cmd, **kwargs) 

170 return _run_sandboxed(sandbox, cmd, **kwargs)