Coverage for src/fileaudit/python_check.py: 81%

190 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-11 17:39 +0200

1""" 

2License GPL3 

3(C) 2026 Created by Maikel Mardjan - https://nocomplexity.com/ 

4FileAudit Security Checker - Checks if a Python file is valid Python  

5""" 

6 

7import ast 

8import inspect 

9import signal 

10from pathlib import Path 

11from functools import wraps 

12import urllib.request 

13import urllib.error 

14from urllib.parse import urlparse 

15 

16# Global default fallbacks 

17DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB 

18DEFAULT_MAX_LINES = 100_000 

19DEFAULT_MAX_LINE_LENGTH = 10_000 

20DEFAULT_MAX_AST_NODES = 500_000 

21DEFAULT_PARSE_TIMEOUT = 10 # seconds 

22 

23 

24class PythonValidationError(Exception): 

25 """Custom exception for Python/AST validation failures in FileAudit.""" 

26 

27 def __init__(self, message): 

28 self.prefix = "FileAudit Security Validation Failed -" 

29 self.original_message = str(message) 

30 full_message = f"{self.prefix} {self.original_message}" 

31 super().__init__(full_message) 

32 

33 def __str__(self): 

34 return self.args[0] 

35 

36 

37def validate_python( 

38 func_or_path=None, 

39 max_file_size=None, 

40 max_lines=DEFAULT_MAX_LINES, 

41 max_line_length=DEFAULT_MAX_LINE_LENGTH, 

42 max_ast_nodes=DEFAULT_MAX_AST_NODES, 

43 allowed_base_dir=None, 

44 allow_symlinks=False, 

45 parse_timeout=DEFAULT_PARSE_TIMEOUT, 

46): 

47 """Validate Python source files via decorator or direct invocation. 

48 

49 A Python file validator that can operate in two modes: 

50 

51 1. **Decorator mode** — wraps a function to validate a Python file path 

52 passed as an argument before the function body runs. 

53 2. **Direct call / CLI mode** — validates a file immediately and returns 

54 a boolean result. 

55 

56 Security checks performed before (and during) AST parsing: 

57 - File existence and regular-file type (local paths) 

58 - Symlink rejection (unless ``allow_symlinks=True``) 

59 - Directory containment / path-traversal guard (via ``allowed_base_dir``) 

60 - File size limit (DoS mitigation) — checked *before* reading into memory 

61 - Only ``.py`` extension is accepted 

62 - UTF-8 BOM detection and stripping 

63 - Strict UTF-8 decoding 

64 - Rejection of null bytes (``\\0``) 

65 - Line count and per-line length limits (tokenizer DoS protection) 

66 - Safe ``ast.parse`` with explicit catching of ``SyntaxError``, 

67 ``ValueError``, ``MemoryError`` and ``RecursionError`` 

68 - Optional parse timeout via ``SIGALRM`` (Unix only) 

69 - Post-parse AST node count limit (protects downstream SAST walkers) 

70 - Remote files restricted to ``https://`` only; streamed with a hard 

71 size ceiling 

72 

73 Usage: 

74 @validate_python 

75 @validate_python() 

76 @validate_python("custom_arg_name") 

77 @validate_python(max_file_size=5000) 

78 validate_python("path/to/file.py") # CLI / direct call usage 

79 validate_python("https://example.com/a.py") # remote HTTPS 

80 

81 Args: 

82 func_or_path (callable, str, pathlib.Path, or None): 

83 * If a **callable**: the function to decorate (bare decorator 

84 usage: ``@validate_python``). 

85 * If a **str** or **Path** that looks like a file path or URL: 

86 the file path to validate (direct call usage). 

87 * If a **str** that is a valid Python identifier (not a path): 

88 treated as the target argument name to inspect in decorator 

89 mode (e.g., ``@validate_python("source_path")``). 

90 * If **None**: returns a decorator factory 

91 (``@validate_python()`` or ``@validate_python(max_file_size=…)``). 

92 max_file_size (int or None): Maximum allowed file size in bytes. 

93 Falls back to ``DEFAULT_MAX_FILE_SIZE`` if omitted. 

94 max_lines (int): Maximum number of lines allowed. 

95 max_line_length (int): Maximum characters per line allowed. 

96 max_ast_nodes (int): Maximum AST nodes allowed after parsing. 

97 allowed_base_dir (str or Path or None): If set, the resolved local 

98 path must lie inside this directory (path-traversal protection). 

99 allow_symlinks (bool): If False (default), symlinks are rejected. 

100 parse_timeout (int): Seconds to allow for ``ast.parse`` before 

101 aborting. Uses ``SIGALRM``; only effective on Unix-like systems. 

102 

103 Returns: 

104 Union[callable, bool, function]: 

105 * In decorator mode: the wrapped function. 

106 * In direct call mode: ``True`` if validation passes, 

107 ``False`` if it fails (errors are printed to stdout). 

108 

109 Raises: 

110 PythonValidationError: If validation fails in decorator mode, or if 

111 the decorated function has no arguments, the target argument is 

112 missing, or the argument type is not ``str`` or ``Path``. 

113 """ 

114 # Resolve optional limit to its default if not provided 

115 resolved_size = DEFAULT_MAX_FILE_SIZE if max_file_size is None else max_file_size 

116 

117 # ------------------------------------------------------------------ # 

118 # MODE DETECTION HEURISTIC 

119 # ------------------------------------------------------------------ # 

120 def _looks_like_file_path(s): 

121 """Heuristic: does this string look like a file path or URL?""" 

122 if s.startswith(("http://", "https://", "ftp://", "file://")): 

123 return True 

124 if s.startswith(("/", "\\")): 

125 return True # absolute path 

126 if "/" in s or "\\" in s: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 return True # contains path separators 

128 if "." in s and not s.startswith("."): 

129 # Contains a dot that looks like a file extension (e.g., "file.py") 

130 return True 

131 return False 

132 

133 is_decorator_mode = False 

134 if func_or_path is None: 

135 # @validate_python() — decorator factory 

136 is_decorator_mode = True 

137 elif callable(func_or_path): 

138 # @validate_python — bare decorator, func_or_path is the function 

139 is_decorator_mode = True 

140 elif isinstance(func_or_path, str): 

141 # Could be direct call or target arg name 

142 is_decorator_mode = not _looks_like_file_path(func_or_path) 

143 elif isinstance(func_or_path, Path): 143 ↛ 150line 143 didn't jump to line 150 because the condition on line 143 was always true

144 # Path objects are ALWAYS direct call 

145 is_decorator_mode = False 

146 

147 # ------------------------------------------------------------------ # 

148 # 1. DIRECT CALL / CLI MODE 

149 # ------------------------------------------------------------------ # 

150 if not is_decorator_mode and isinstance(func_or_path, (str, Path)): 

151 try: 

152 _validate_python_file( 

153 func_or_path, 

154 resolved_size, 

155 max_lines=max_lines, 

156 max_line_length=max_line_length, 

157 max_ast_nodes=max_ast_nodes, 

158 allowed_base_dir=allowed_base_dir, 

159 allow_symlinks=allow_symlinks, 

160 parse_timeout=parse_timeout, 

161 ) 

162 return True 

163 except Exception as e: 

164 print(f"Exception: {e}") 

165 return False 

166 

167 # ------------------------------------------------------------------ # 

168 # 2. DECORATOR MODE 

169 # ------------------------------------------------------------------ # 

170 def decorator(f): 

171 sig = inspect.signature(f) 

172 params = list(sig.parameters.keys()) 

173 

174 if not params: 

175 raise PythonValidationError( 

176 f"Decorator applied to '{f.__name__}', but it has no arguments." 

177 ) 

178 

179 # Determine target argument name 

180 if isinstance(func_or_path, str) and func_or_path in params: 

181 target_arg = func_or_path 

182 else: 

183 # Default to the very first parameter 

184 target_arg = params[0] 

185 

186 @wraps(f) 

187 def wrapper(*args, **kwargs): 

188 try: 

189 bound_args = sig.bind(*args, **kwargs) 

190 bound_args.apply_defaults() 

191 except TypeError as e: 

192 raise PythonValidationError(f"Invalid function call signature: {e}") 

193 

194 p = bound_args.arguments.get(target_arg) 

195 if p is None: 

196 raise PythonValidationError(f"Missing required argument: {target_arg}") 

197 

198 if isinstance(p, (str, Path)): 

199 _validate_python_file( 

200 p, 

201 resolved_size, 

202 max_lines=max_lines, 

203 max_line_length=max_line_length, 

204 max_ast_nodes=max_ast_nodes, 

205 allowed_base_dir=allowed_base_dir, 

206 allow_symlinks=allow_symlinks, 

207 parse_timeout=parse_timeout, 

208 ) 

209 else: 

210 raise PythonValidationError( 

211 f"Expected Path or str for {target_arg}, got {type(p).__name__}" 

212 ) 

213 

214 return f(*args, **kwargs) 

215 

216 return wrapper 

217 

218 # If used as bare `@validate_python`, func_or_path is the function itself 

219 if callable(func_or_path): 

220 return decorator(func_or_path) 

221 

222 return decorator 

223 

224 

225def _validate_python_file( 

226 path, 

227 max_file_size, 

228 max_lines=DEFAULT_MAX_LINES, 

229 max_line_length=DEFAULT_MAX_LINE_LENGTH, 

230 max_ast_nodes=DEFAULT_MAX_AST_NODES, 

231 allowed_base_dir=None, 

232 allow_symlinks=False, 

233 parse_timeout=DEFAULT_PARSE_TIMEOUT, 

234): 

235 """Secure Python source validation with size, existence, encoding and AST protection. 

236 

237 Internal function! 

238 

239 Validates a Python source file by checking its existence, type, extension and 

240 file size *before* reading it into memory. Then enforces UTF-8 decoding, 

241 rejects null bytes, and finally runs ``ast.parse`` while catching resource- 

242 exhaustion errors that can crash the interpreter. 

243 

244 Supports both local file paths and remote HTTPS URLs. 

245 

246 Args: 

247 path (pathlib.Path or str): Local path or HTTPS URL to a ``.py`` file. 

248 max_file_size (int): Maximum allowed size in bytes. 

249 max_lines (int): Maximum allowed line count. 

250 max_line_length (int): Maximum allowed characters per line. 

251 max_ast_nodes (int): Maximum allowed AST nodes after parsing. 

252 allowed_base_dir (str or Path or None): Directory the file must reside in. 

253 allow_symlinks (bool): Whether to permit symbolic links. 

254 parse_timeout (int): Seconds to allow for parsing (Unix ``SIGALRM`` only). 

255 

256 Returns: 

257 None: Completes silently on success. 

258 

259 Raises: 

260 PythonValidationError: On any validation failure (missing file, wrong 

261 type/extension, size exceeded, encoding error, null byte, syntax 

262 error, or resource exhaustion during parsing). 

263 """ 

264 # ------------------------------------------------------------------ # 

265 # 0. Determine if path is local or remote 

266 # ------------------------------------------------------------------ # 

267 path_str = str(path) 

268 parsed = urlparse(path_str) 

269 is_remote = bool(parsed.scheme) 

270 

271 if is_remote and parsed.scheme != "https": 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true

272 raise PythonValidationError( 

273 f"Unsupported URL scheme '{parsed.scheme}': only 'https' is allowed. " 

274 f"Use 'https://' for remote files, or omit the scheme for local paths." 

275 ) 

276 

277 # ------------------------------------------------------------------ # 

278 # 1. Extension check (.py) 

279 # ------------------------------------------------------------------ # 

280 if is_remote: 280 ↛ 282line 280 didn't jump to line 282 because the condition on line 280 was never true

281 # Use the path component of the URL 

282 url_path = parsed.path or "" 

283 if not url_path.lower().endswith(".py"): 

284 raise PythonValidationError( 

285 f"Remote URL does not point to a .py file: {path_str}" 

286 ) 

287 else: 

288 local_path = Path(path) 

289 if local_path.suffix.lower() != ".py": 

290 raise PythonValidationError( 

291 f"Only .py files are accepted (got extension '{local_path.suffix}'): {local_path}" 

292 ) 

293 

294 # ------------------------------------------------------------------ # 

295 # 2. LOCAL PATH: Existence, Type, Symlink and Traversal Checks 

296 # ------------------------------------------------------------------ # 

297 if not is_remote: 297 ↛ 331line 297 didn't jump to line 331 because the condition on line 297 was always true

298 local_path = Path(path) 

299 if not local_path.exists(): 

300 raise PythonValidationError(f"File not found: {local_path}") 

301 if not local_path.is_file(): 

302 raise PythonValidationError(f"Path is not a file: {local_path}") 

303 

304 # 2a. Symlink guard 

305 if local_path.is_symlink() and not allow_symlinks: 

306 raise PythonValidationError( 

307 f"Symlinks are not allowed (set allow_symlinks=True to permit): {local_path}" 

308 ) 

309 

310 # 2b. Directory containment / path-traversal guard 

311 if allowed_base_dir is not None: 

312 try: 

313 resolved_file = local_path.resolve(strict=True) 

314 resolved_base = Path(allowed_base_dir).resolve() 

315 try: 

316 resolved_file.relative_to(resolved_base) 

317 except ValueError: 

318 raise PythonValidationError( 

319 f"Path traversal detected: {local_path} resolves to " 

320 f"{resolved_file}, which is outside allowed base " 

321 f"directory {allowed_base_dir}" 

322 ) 

323 except OSError as e: 

324 raise PythonValidationError( 

325 f"Could not resolve path for traversal check: {e}" 

326 ) from e 

327 

328 # ------------------------------------------------------------------ # 

329 # 3. DoS Mitigation: Check file size BEFORE reading into memory 

330 # ------------------------------------------------------------------ # 

331 if is_remote: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true

332 try: 

333 req = urllib.request.Request(path_str, method="HEAD") 

334 with urllib.request.urlopen(req, timeout=10) as response: 

335 content_length = response.headers.get("Content-Length") 

336 if content_length is not None: 

337 file_size = int(content_length) 

338 if file_size > max_file_size: 

339 raise PythonValidationError( 

340 f"Remote file size ({file_size} bytes) exceeds " 

341 f"maximum limit of {max_file_size} bytes" 

342 ) 

343 except urllib.error.HTTPError as e: 

344 raise PythonValidationError( 

345 f"Remote file unreachable (HTTP {e.code}): {path_str}" 

346 ) from e 

347 except urllib.error.URLError as e: 

348 raise PythonValidationError( 

349 f"Could not reach remote file: {path_str}{e.reason}" 

350 ) from e 

351 except ValueError: 

352 raise PythonValidationError( 

353 f"Remote server returned invalid Content-Length for: {path_str}" 

354 ) 

355 else: 

356 try: 

357 file_size = local_path.stat().st_size 

358 if file_size > max_file_size: 

359 raise PythonValidationError( 

360 f"File size ({file_size} bytes) exceeds maximum limit of " 

361 f"{max_file_size} bytes" 

362 ) 

363 except OSError as e: 

364 raise PythonValidationError(f"Could not read file metadata: {e}") from e 

365 

366 # ------------------------------------------------------------------ # 

367 # 4. Safe reading, null-byte check, encoding and AST parsing 

368 # ------------------------------------------------------------------ # 

369 try: 

370 if is_remote: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 req = urllib.request.Request(path_str) 

372 with urllib.request.urlopen(req, timeout=30) as response: 

373 raw_bytes = response.read(max_file_size + 1) 

374 if len(raw_bytes) > max_file_size: 

375 raise PythonValidationError( 

376 f"Remote file exceeded maximum limit of {max_file_size} " 

377 f"bytes during download" 

378 ) 

379 # Strip UTF-8 BOM if present before strict decode 

380 if raw_bytes.startswith(b"\xef\xbb\xbf"): 

381 raw_bytes = raw_bytes[3:] 

382 try: 

383 source = raw_bytes.decode("utf-8") 

384 except UnicodeDecodeError as e: 

385 raise PythonValidationError(f"File is not valid UTF-8: {e}") from e 

386 else: 

387 # Read as bytes first so we can strip a BOM before strict decode 

388 raw_bytes = local_path.read_bytes() 

389 if raw_bytes.startswith(b"\xef\xbb\xbf"): 

390 raw_bytes = raw_bytes[3:] 

391 try: 

392 source = raw_bytes.decode("utf-8", errors="strict") 

393 except UnicodeDecodeError as e: 

394 raise PythonValidationError(f"File is not valid UTF-8: {e}") from e 

395 

396 # Null-byte rejection (ast.parse also raises, but we fail early) 

397 if "\0" in source: 

398 raise PythonValidationError("Null byte (\\0) found in source — rejected") 

399 

400 # Line count and line length check (tokenizer DoS protection) 

401 line_count = 0 

402 for line in source.splitlines(): 

403 line_count += 1 

404 if line_count > max_lines: 

405 raise PythonValidationError( 

406 f"File contains {line_count} lines, exceeding limit of {max_lines}" 

407 ) 

408 if len(line) > max_line_length: 

409 raise PythonValidationError( 

410 f"Line {line_count} length ({len(line)}) exceeds maximum " 

411 f"limit of {max_line_length} characters" 

412 ) 

413 

414 # AST parse — the critical security-relevant step 

415 # Official docs warn that sufficiently large/complex input can 

416 # crash the interpreter via stack-depth limits. 

417 tree = None 

418 try: 

419 # Optional Unix-only timeout to catch pathological inputs that 

420 # parse slowly without hitting recursion limits. 

421 if hasattr(signal, "SIGALRM") and parse_timeout > 0: 

422 _old_alarm = 0 

423 _old_handler = None 

424 

425 def _timeout_handler(signum, frame): 

426 raise PythonValidationError( 

427 f"AST parsing timed out after {parse_timeout} seconds " 

428 f"(possible pathological input)" 

429 ) 

430 

431 _old_handler = signal.signal(signal.SIGALRM, _timeout_handler) 

432 _old_alarm = signal.alarm(parse_timeout) 

433 try: 

434 tree = ast.parse(source, filename=path_str) 

435 finally: 

436 signal.alarm(_old_alarm) 

437 if _old_handler is not None: 437 ↛ 460line 437 didn't jump to line 460 because the condition on line 437 was always true

438 signal.signal(signal.SIGALRM, _old_handler) 

439 else: 

440 tree = ast.parse(source, filename=path_str) 

441 

442 except SyntaxError as e: 

443 raise PythonValidationError( 

444 f"Invalid Python syntax at line {e.lineno}: {e.msg}" 

445 ) from e 

446 except ValueError as e: 

447 # e.g. null bytes that somehow slipped through, or other parser errors 

448 raise PythonValidationError(f"AST parse ValueError: {e}") from e 

449 except MemoryError: 

450 raise PythonValidationError( 

451 "Memory exhaustion during AST parsing (possible DoS input)" 

452 ) 

453 except RecursionError: 

454 raise PythonValidationError( 

455 "Recursion limit / stack depth exceeded during AST parsing " 

456 "(possible pathological input)" 

457 ) 

458 

459 # AST node count check — protects downstream SAST walkers from DoS 

460 node_count = sum(1 for _ in ast.walk(tree)) 

461 if node_count > max_ast_nodes: 

462 raise PythonValidationError( 

463 f"AST contains {node_count} nodes, exceeding maximum limit of " 

464 f"{max_ast_nodes} (possible DoS against AST walkers)" 

465 ) 

466 

467 except PythonValidationError: 

468 # Re-raise our own exceptions unchanged 

469 raise 

470 except urllib.error.HTTPError as e: 

471 raise PythonValidationError( 

472 f"Remote file download failed (HTTP {e.code}): {path_str}" 

473 ) from e 

474 except urllib.error.URLError as e: 

475 raise PythonValidationError( 

476 f"Network error downloading file: {path_str}{e.reason}" 

477 ) from e 

478 except Exception as e: 

479 # Catch-all for rare OS/permission/TOCTOU issues 

480 raise PythonValidationError(f"Python validation failed: {e}") from e