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
« 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"""
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
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
24class PythonValidationError(Exception):
25 """Custom exception for Python/AST validation failures in FileAudit."""
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)
33 def __str__(self):
34 return self.args[0]
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.
49 A Python file validator that can operate in two modes:
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.
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
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
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.
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).
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
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
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
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
167 # ------------------------------------------------------------------ #
168 # 2. DECORATOR MODE
169 # ------------------------------------------------------------------ #
170 def decorator(f):
171 sig = inspect.signature(f)
172 params = list(sig.parameters.keys())
174 if not params:
175 raise PythonValidationError(
176 f"Decorator applied to '{f.__name__}', but it has no arguments."
177 )
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]
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}")
194 p = bound_args.arguments.get(target_arg)
195 if p is None:
196 raise PythonValidationError(f"Missing required argument: {target_arg}")
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 )
214 return f(*args, **kwargs)
216 return wrapper
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)
222 return decorator
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.
237 Internal function!
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.
244 Supports both local file paths and remote HTTPS URLs.
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).
256 Returns:
257 None: Completes silently on success.
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)
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 )
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 )
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}")
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 )
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
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
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
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")
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 )
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
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 )
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)
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 )
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 )
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