Coverage for src/taipanstack/utils/subprocess.py: 100%

96 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +0000

1""" 

2Safe subprocess execution with security guards. 

3 

4Provides secure wrappers around subprocess execution with 

5command validation, timeout handling, and retry logic. 

6""" 

7 

8import math 

9import os 

10import shutil 

11import subprocess # nosec B404 

12import time 

13from collections.abc import Sequence 

14from dataclasses import dataclass 

15from pathlib import Path 

16 

17from taipanstack.security.guards import ( 

18 SecurityError, 

19 guard_command_injection, 

20) 

21 

22 

23@dataclass(frozen=True) 

24class SafeCommandResult: 

25 """Result of a safe command execution. 

26 

27 Attributes: 

28 command: The executed command. 

29 returncode: Exit code of the command. 

30 stdout: Standard output. 

31 stderr: Standard error. 

32 success: Whether the command succeeded (returncode == 0). 

33 duration_seconds: How long the command took. 

34 

35 """ 

36 

37 command: list[str] 

38 returncode: int 

39 stdout: str = "" 

40 stderr: str = "" 

41 duration_seconds: float = 0.0 

42 

43 @property 

44 def success(self) -> bool: 

45 """Check if command succeeded.""" 

46 return self.returncode == 0 

47 

48 def raise_on_error(self) -> "SafeCommandResult": 

49 """Raise an exception if command failed. 

50 

51 Returns: 

52 Self if successful. 

53 

54 Raises: 

55 subprocess.CalledProcessError: If command failed. 

56 

57 """ 

58 if not self.success: 

59 raise subprocess.CalledProcessError( 

60 self.returncode, 

61 self.command, 

62 self.stdout, 

63 self.stderr, 

64 ) 

65 return self 

66 

67 

68# Default allowed commands whitelist 

69DEFAULT_ALLOWED_COMMANDS: frozenset[str] = frozenset( 

70 { 

71 # Python/Poetry 

72 "python", 

73 "python3", 

74 "pip", 

75 "pip3", 

76 "poetry", 

77 "pipx", 

78 # Version control 

79 "git", 

80 # Build tools 

81 "make", 

82 # Testing 

83 "pytest", 

84 "mypy", 

85 "ruff", 

86 "bandit", 

87 "safety", 

88 "semgrep", 

89 "pre-commit", 

90 # System 

91 "echo", 

92 "cat", 

93 "ls", 

94 "pwd", 

95 "mkdir", 

96 "rm", 

97 "cp", 

98 "mv", 

99 "touch", 

100 "chmod", 

101 "which", 

102 }, 

103) 

104 

105 

106def _validate_and_resolve_command( 

107 command: Sequence[str], 

108 allowed_commands: Sequence[str] | None, 

109) -> list[str]: 

110 """Validate and resolve a command. 

111 

112 Args: 

113 command: Command and arguments as a sequence. 

114 allowed_commands: Whitelist of allowed commands. 

115 

116 Returns: 

117 The validated command as a list. 

118 

119 Raises: 

120 SecurityError: If validation fails. 

121 

122 """ 

123 cmd_list = list(command) 

124 

125 if not cmd_list: 

126 raise SecurityError( 

127 "Empty command is not allowed", 

128 guard_name="safe_command", 

129 ) 

130 

131 if allowed_commands is not None: 

132 whitelist = list(allowed_commands) 

133 else: 

134 whitelist = list(DEFAULT_ALLOWED_COMMANDS) 

135 

136 validated_cmd = guard_command_injection(cmd_list, allowed_commands=whitelist) 

137 

138 base_command = validated_cmd[0] 

139 if not shutil.which(base_command): 

140 raise SecurityError( 

141 f"Command not found: {base_command}", 

142 guard_name="safe_command", 

143 value=base_command, 

144 ) 

145 

146 return validated_cmd 

147 

148 

149def _get_allowed_keys(allowed_env_vars: Sequence[str] | None) -> set[str]: 

150 """Get the allowed environment variable keys. 

151 

152 Args: 

153 allowed_env_vars: Whitelist of allowed environment variable names. 

154 

155 Returns: 

156 Set of allowed keys in uppercase. 

157 

158 """ 

159 if allowed_env_vars is None: 

160 return {"PATH"} 

161 return {k.upper() for k in allowed_env_vars} 

162 

163 

164def _get_env_to_filter(env: dict[str, str] | None) -> dict[str, str]: 

165 """Get the environment to filter.""" 

166 return env if env is not None else dict(os.environ) 

167 

168 

169def _build_filtered_env( 

170 env_to_filter: dict[str, str], allowed_keys: set[str] 

171) -> dict[str, str]: 

172 """Build the filtered environment dictionary.""" 

173 if not allowed_keys: 

174 return {} 

175 return {k: str(v) for k, v in env_to_filter.items() if k.upper() in allowed_keys} 

176 

177 

178def _filter_environment( 

179 env: dict[str, str] | None, 

180 allowed_env_vars: Sequence[str] | None = None, 

181) -> dict[str, str]: 

182 """Filter environment variables for safe execution using a whitelist approach. 

183 

184 Args: 

185 env: Environment variables to filter. If None, uses os.environ. 

186 allowed_env_vars: Whitelist of allowed environment variable names. 

187 If None, only minimal necessary variables (e.g. PATH) are inherited. 

188 Pass an empty list for a completely empty environment. 

189 

190 Returns: 

191 Filtered environment variables containing only allowed keys. 

192 

193 """ 

194 env_to_filter = _get_env_to_filter(env) 

195 allowed_keys = _get_allowed_keys(allowed_env_vars) 

196 return _build_filtered_env(env_to_filter, allowed_keys) 

197 

198 

199def _extract_timeout_stdout(e: subprocess.TimeoutExpired) -> str: 

200 """Extract stdout from a TimeoutExpired exception safely. 

201 

202 Args: 

203 e: The TimeoutExpired exception. 

204 

205 Returns: 

206 The extracted stdout string. 

207 

208 """ 

209 if not hasattr(e, "stdout") or e.stdout is None: 

210 return "" 

211 if isinstance(e.stdout, str): # type: ignore[unreachable] 

212 return e.stdout # type: ignore[unreachable] 

213 return e.stdout.decode("utf-8", errors="replace") 

214 

215 

216def _execute_command( 

217 validated_cmd: list[str], 

218 cwd: Path | None, 

219 timeout: float, 

220 capture_output: bool, 

221 safe_env: dict[str, str], 

222) -> SafeCommandResult: 

223 """Execute the command and handle TimeoutExpired. 

224 

225 Args: 

226 validated_cmd: Validated command list. 

227 cwd: Resolved working directory. 

228 timeout: Execution timeout. 

229 capture_output: Capture stdout/stderr. 

230 safe_env: Filtered environment variables. 

231 

232 Returns: 

233 The execution result. 

234 

235 """ 

236 start_time = time.time() 

237 

238 try: 

239 result = subprocess.run( # nosec B603 

240 validated_cmd, 

241 cwd=cwd, 

242 timeout=timeout, 

243 capture_output=capture_output, 

244 text=True, 

245 encoding="utf-8", 

246 env=safe_env, 

247 check=False, 

248 ) 

249 except subprocess.TimeoutExpired as e: 

250 duration = time.time() - start_time 

251 return SafeCommandResult( 

252 command=validated_cmd, 

253 returncode=-1, 

254 stdout=_extract_timeout_stdout(e), 

255 stderr=f"Command timed out after {timeout}s", 

256 duration_seconds=duration, 

257 ) 

258 

259 duration = time.time() - start_time 

260 

261 return SafeCommandResult( 

262 command=validated_cmd, 

263 returncode=result.returncode, 

264 stdout=result.stdout or "", 

265 stderr=result.stderr or "", 

266 duration_seconds=duration, 

267 ) 

268 

269 

270def _validate_cwd(cwd: Path | str | None) -> Path | None: 

271 """Resolve and validate the working directory.""" 

272 if cwd is None: 

273 return None 

274 resolved_cwd = Path(cwd).resolve() 

275 if not resolved_cwd.exists(): 

276 raise SecurityError( 

277 f"Working directory does not exist: {cwd}", 

278 guard_name="safe_command", 

279 ) 

280 return resolved_cwd 

281 

282 

283def _handle_dry_run(validated_cmd: list[str]) -> SafeCommandResult: 

284 """Return a SafeCommandResult for a dry run execution.""" 

285 return SafeCommandResult( 

286 command=validated_cmd, 

287 returncode=0, 

288 stdout=f"[DRY-RUN] Would execute: {' '.join(validated_cmd)}", 

289 stderr="", 

290 duration_seconds=0.0, 

291 ) 

292 

293 

294def _check_timeout_type(timeout: float) -> None: 

295 """Check if timeout is a number.""" 

296 if not isinstance(timeout, (int, float)): 

297 raise TypeError("timeout must be a finite non-negative number") 

298 

299 

300def _check_timeout_value(timeout: float) -> None: 

301 """Check if timeout is finite and non-negative.""" 

302 if not math.isfinite(timeout) or timeout < 0: 

303 raise ValueError("timeout must be a finite non-negative number") 

304 

305 

306def _validate_timeout(timeout: float) -> None: 

307 """Validate that the timeout is a finite, non-negative number. 

308 

309 Args: 

310 timeout: The timeout value to validate. 

311 

312 Raises: 

313 ValueError: If timeout is invalid. 

314 

315 """ 

316 if timeout is None: 

317 return # type: ignore[unreachable] 

318 

319 _check_timeout_type(timeout) 

320 _check_timeout_value(timeout) 

321 

322 

323def run_safe_command( 

324 command: Sequence[str], 

325 *, 

326 cwd: Path | str | None = None, 

327 timeout: float = 300.0, 

328 capture_output: bool = True, 

329 check: bool = False, 

330 allowed_commands: Sequence[str] | None = None, 

331 env: dict[str, str] | None = None, 

332 allowed_env_vars: Sequence[str] | None = None, 

333 dry_run: bool = False, 

334) -> SafeCommandResult: 

335 """Execute a command safely with security guards. 

336 

337 This function provides a secure wrapper around subprocess.run 

338 with command injection protection, timeout handling, and 

339 optional command whitelisting. 

340 

341 Args: 

342 command: Command and arguments as a sequence. 

343 cwd: Working directory for the command. 

344 timeout: Maximum execution time in seconds. 

345 capture_output: Whether to capture stdout/stderr. 

346 check: Whether to raise on non-zero exit. 

347 allowed_commands: Whitelist of allowed commands. 

348 env: Environment variables to set. 

349 allowed_env_vars: Whitelist of allowed environment variables. 

350 dry_run: If True, don't actually execute the command. 

351 

352 Returns: 

353 SafeCommandResult with execution details. 

354 

355 Raises: 

356 SecurityError: If command validation fails. 

357 subprocess.TimeoutExpired: If command times out. 

358 subprocess.CalledProcessError: If check=True and command fails. 

359 

360 Example: 

361 >>> result = run_safe_command(["poetry", "install"]) 

362 >>> if result.success: 

363 ... print("Installation complete!") 

364 

365 """ 

366 # Security Enhancement: Prevent DoS via infinite blocking behaviors or 

367 # unhandled exceptions from threading/asyncio primitives by explicitly 

368 # validating that timeout is a finite, non-negative number. 

369 _validate_timeout(timeout) 

370 

371 validated_cmd = _validate_and_resolve_command(command, allowed_commands) 

372 safe_env = _filter_environment(env, allowed_env_vars) 

373 

374 if dry_run: 

375 return _handle_dry_run(validated_cmd) 

376 

377 resolved_cwd = _validate_cwd(cwd) 

378 

379 safe_result = _execute_command( 

380 validated_cmd, 

381 resolved_cwd, 

382 timeout, 

383 capture_output, 

384 safe_env, 

385 ) 

386 

387 if check: 

388 safe_result.raise_on_error() 

389 

390 return safe_result