Coverage for src/fileaudit/json_check.py: 94%
137 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 12:11 +0200
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-11 12:11 +0200
1"""
2License GPL3
3(C) 2026 Created by Maikel Mardjan - https://nocomplexity.com/
4FileAudit - File Security Checker
5"""
6import json
7import inspect
8from pathlib import Path
9from functools import wraps
11import urllib.request
12import urllib.error
13from urllib.parse import urlparse
16# Global default fallbacks
17DEFAULT_MAX_DEPTH = 50
18DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
21class FileValidationError(Exception):
22 """Custom exception for JSON validation failures in FileAudit."""
24 def __init__(self, message):
25 self.prefix = "FileAudit Security Validation Failed -"
26 self.original_message = str(message)
27 full_message = f"{self.prefix} {self.original_message}"
28 super().__init__(full_message)
30 def __str__(self):
31 return self.args[0]
34def limited_parse(obj, max_depth, depth=0):
35 """Recursively validates nesting depth limits."""
36 if depth > max_depth:
37 raise FileValidationError("JSON nesting depth exceeded")
38 if isinstance(obj, dict):
39 for v in obj.values():
40 limited_parse(v, max_depth, depth + 1)
41 elif isinstance(obj, list):
42 for item in obj:
43 limited_parse(item, max_depth, depth + 1)
45def validate_json(func_or_path=None, max_depth=None, max_file_size=None):
46 """Validate JSON files via decorator or direct invocation.
48 A JSON file validator that can operate in two modes:
50 1. **Decorator mode** — wraps a function to validate a JSON file path
51 passed as an argument before the function body runs.
52 2. **Direct call / CLI mode** — validates a file immediately and returns
53 a boolean result.
55 Usage:
56 @validate_json
57 @validate_json()
58 @validate_json("custom_arg_name", max_depth=50)
59 @validate_json(max_file_size=5000)
60 validate_json("path/to/file.json", max_depth=10) # CLI / direct call usage
62 Args:
63 func_or_path (callable, str, pathlib.Path, or None):
64 * If a **callable**: the function to decorate (bare decorator
65 usage: ``@validate_json``).
66 * If a **str** or **Path** that looks like a file path or URL:
67 the file path to validate (direct call usage).
68 * If a **str** that is a valid Python identifier (not a path):
69 treated as the target argument name to inspect in decorator
70 mode (e.g., ``@validate_json("config_path")``).
71 * If **None**: returns a decorator factory
72 (``@validate_json()`` or ``@validate_json(max_depth=50)``).
73 max_depth (int or None): Maximum allowed JSON nesting depth.
74 Falls back to ``DEFAULT_MAX_DEPTH`` if omitted.
75 max_file_size (int or None): Maximum allowed file size in bytes.
76 Falls back to ``DEFAULT_MAX_FILE_SIZE`` if omitted.
78 Returns:
79 Union[callable, bool, function]:
80 * In decorator mode: the wrapped function.
81 * In direct call mode: ``True`` if validation passes,
82 ``False`` if it fails (errors are printed to stdout).
84 Raises:
85 FileValidationError: If validation fails in decorator mode, or if
86 the decorated function has no arguments, the target argument is
87 missing, or the argument type is not ``str`` or ``Path``.
89 Examples:
90 Bare decorator (validates the first argument)::
92 @validate_json
93 def process_data(file_path):
94 ...
96 Decorator with custom limits::
98 @validate_json(max_depth=50, max_file_size=5000)
99 def process_data(file_path):
100 ...
102 Decorator targeting a specific argument by name::
104 @validate_json("config_path", max_depth=10)
105 def process_data(config_path, other_arg):
106 ...
108 Direct call / CLI usage::
110 result = validate_json("path/to/file.json", max_depth=10)
111 # Returns True on success, False on failure.
112 """
113 # Resolve optional limits to their defaults if they are not provided
114 resolved_depth = DEFAULT_MAX_DEPTH if max_depth is None else max_depth
115 resolved_size = DEFAULT_MAX_FILE_SIZE if max_file_size is None else max_file_size
117 # ------------------------------------------------------------------ #
118 # MODE DETECTION HEURISTIC
119 # ------------------------------------------------------------------ #
120 # We need to distinguish between:
121 # - Direct call: validate_json("path/to/file.json")
122 # - Target arg: @validate_json("config_path")
123 #
124 # A string is treated as a target argument name (decorator mode) if:
125 # 1. It is a valid Python identifier (no slashes, no dots, no colons)
126 # 2. AND it does not look like a URL (no "://" scheme prefix)
127 # 3. AND it does not look like an absolute path (no leading / or \)
128 #
129 # Otherwise, it is treated as a file path (direct call mode).
130 # ------------------------------------------------------------------ #
132 def _looks_like_file_path(s):
133 """Heuristic: does this string look like a file path or URL?"""
134 if s.startswith(("http://", "https://", "ftp://", "file://")):
135 return True
136 if s.startswith(("/", "\\")):
137 return True # absolute path
138 if "/" in s or "\\" in s: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return True # contains path separators
140 if "." in s and not s.startswith("."): 140 ↛ 143line 140 didn't jump to line 143 because the condition on line 140 was never true
141 # Contains a dot that looks like a file extension (e.g., "file.json")
142 # But exclude hidden files like ".config"
143 return True
144 return False
146 is_decorator_mode = False
148 if func_or_path is None:
149 # @validate_json() — decorator factory
150 is_decorator_mode = True
152 elif callable(func_or_path):
153 # @validate_json — bare decorator, func_or_path is the function
154 is_decorator_mode = True
156 elif isinstance(func_or_path, str):
157 # Could be direct call or target arg name
158 # If it looks like a path/URL → direct call
159 # If it looks like a valid identifier → decorator target arg
160 is_decorator_mode = not _looks_like_file_path(func_or_path)
162 elif isinstance(func_or_path, Path): 162 ↛ 169line 162 didn't jump to line 169 because the condition on line 162 was always true
163 # Path objects are ALWAYS direct call (you can't name a param with Path)
164 is_decorator_mode = False
166 # ------------------------------------------------------------------ #
167 # 1. DIRECT CALL / CLI MODE
168 # ------------------------------------------------------------------ #
169 if not is_decorator_mode and isinstance(func_or_path, (str, Path)):
170 try:
171 # FIX: Pass raw string/Path directly — do NOT wrap in Path() here,
172 # because Path("https://...") mangles URLs to "https:/..." which
173 # breaks urlparse and causes "no host given" errors.
174 _validate_json_file(func_or_path, resolved_depth, resolved_size)
175 return True
176 except Exception as e:
177 print(f"Exception: {e}")
178 return False
180 # ------------------------------------------------------------------ #
181 # 2. DECORATOR MODE
182 # ------------------------------------------------------------------ #
183 def decorator(f):
184 sig = inspect.signature(f)
185 params = list(sig.parameters.keys())
187 if not params:
188 raise FileValidationError(
189 f"Decorator applied to '{f.__name__}', but it has no arguments."
190 )
192 # Determine target argument name
193 if isinstance(func_or_path, str) and func_or_path in params:
194 # Explicit target arg name provided (and it exists in signature)
195 target_arg = func_or_path
196 else:
197 # Default to the very first parameter
198 target_arg = params[0]
200 @wraps(f)
201 def wrapper(*args, **kwargs):
202 try:
203 bound_args = sig.bind(*args, **kwargs)
204 bound_args.apply_defaults()
205 except TypeError as e:
206 raise FileValidationError(f"Invalid function call signature: {e}")
208 p = bound_args.arguments.get(target_arg)
210 if p is None:
211 raise FileValidationError(f"Missing required argument: {target_arg}")
213 if isinstance(p, (str, Path)):
214 # FIX: Pass raw string/Path directly to _validate_json_file.
215 # Do NOT wrap in Path() here — it breaks HTTPS URLs.
216 _validate_json_file(p, resolved_depth, resolved_size)
217 else:
218 raise FileValidationError(
219 f"Expected Path or str for {target_arg}, got {type(p).__name__}"
220 )
222 return f(*args, **kwargs)
223 return wrapper
225 # If used as bare `@validate_json`, func_or_path is the function itself
226 if callable(func_or_path):
227 return decorator(func_or_path)
229 return decorator
234def _validate_json_file(path, max_depth, max_file_size):
235 """Secure JSON validation with size, existence, and depth protection.
237 Internal function!
239 Validates a JSON file by checking its existence, type, and file size before
240 attempting to parse it into memory. Also enforces a maximum nesting depth
241 to prevent stack exhaustion attacks. Supports both local file paths and
242 remote HTTPS URLs.
244 Args:
245 path (pathlib.Path or str): The path to the JSON file to validate, or an
246 HTTPS URL pointing to a remote JSON file. Local paths must support
247 ``exists()``, ``is_file()``, ``stat()``, and ``open()`` operations.
248 Only ``https://`` URLs are permitted; plain ``http://`` is rejected.
249 max_depth (int): The maximum allowed nesting depth for the JSON
250 structure. Must be a non-negative integer. Deeper nesting will
251 trigger a validation error.
252 max_file_size (int): The maximum allowed file size in bytes. Files
253 exceeding this limit will be rejected before being read into memory
254 to mitigate denial-of-service (DoS) attacks.
256 Returns:
257 None: This function does not return a value. Successful validation
258 completes silently.
260 Raises:
261 FileValidationError: If any validation check fails, including:
262 - The file does not exist (local) or is unreachable (remote).
263 - The path is not a regular file (local only).
264 - The file size exceeds ``max_file_size``.
265 - File metadata cannot be read.
266 - The file contains invalid JSON syntax.
267 - JSON nesting exceeds ``max_depth`` (or triggers a RecursionError).
268 - The URL scheme is not ``https``.
269 - Any network or unexpected error during fetching or parsing.
271 Note:
272 For remote URLs, this function sends a HEAD request first to check the
273 ``Content-Length`` header before downloading. If the header is missing,
274 it streams the response with a hard size cap to prevent memory
275 exhaustion. For local files, it checks ``stat().st_size`` before reading.
276 Only ``https://`` URLs are accepted to ensure encrypted transport.
277 """
278 # ------------------------------------------------------------------ #
279 # 0. Determine if path is local or remote
280 # ------------------------------------------------------------------ #
281 path_str = str(path)
282 parsed = urlparse(path_str)
283 is_remote = bool(parsed.scheme)
285 if is_remote and parsed.scheme != "https":
286 raise FileValidationError(
287 f"Unsupported URL scheme '{parsed.scheme}': only 'https' is allowed. "
288 f"Use 'https://' for remote files, or omit the scheme for local paths."
289 )
291 # ------------------------------------------------------------------ #
292 # 1. LOCAL PATH: Existence and Type Checks
293 # ------------------------------------------------------------------ #
294 if not is_remote:
295 local_path = Path(path)
296 if not local_path.exists():
297 raise FileValidationError(f"File not found: {local_path}")
298 if not local_path.is_file():
299 raise FileValidationError(f"Path is not a file: {local_path}")
301 # ------------------------------------------------------------------ #
302 # 2. DoS Mitigation: Check file size BEFORE reading into memory
303 # ------------------------------------------------------------------ #
304 if is_remote:
305 # Remote: Use HEAD request to check Content-Length before downloading
306 try:
307 req = urllib.request.Request(path_str, method="HEAD")
308 # Set a timeout to prevent hanging on slow/unresponsive servers
309 with urllib.request.urlopen(req, timeout=10) as response:
310 content_length = response.headers.get("Content-Length")
311 if content_length is not None: 311 ↛ 346line 311 didn't jump to line 346
312 file_size = int(content_length)
313 if file_size > max_file_size:
314 raise FileValidationError(
315 f"Remote file size ({file_size} bytes) exceeds "
316 f"maximum limit of {max_file_size} bytes"
317 )
318 # If Content-Length is missing, we stream with a hard cap later
319 except urllib.error.HTTPError as e:
320 raise FileValidationError(
321 f"Remote file unreachable (HTTP {e.code}): {path_str}"
322 ) from e
323 except urllib.error.URLError as e:
324 raise FileValidationError(
325 f"Could not reach remote file: {path_str} — {e.reason}"
326 ) from e
327 except ValueError:
328 raise FileValidationError(
329 f"Remote server returned invalid Content-Length for: {path_str}"
330 )
331 else:
332 # Local: Check file size via stat()
333 try:
334 file_size = local_path.stat().st_size
335 if file_size > max_file_size:
336 raise FileValidationError(
337 f"File size ({file_size} bytes) exceeds maximum limit of "
338 f"{max_file_size} bytes"
339 )
340 except OSError as e:
341 raise FileValidationError(f"Could not read file metadata: {e}") from e
343 # ------------------------------------------------------------------ #
344 # 3. Safe Parsing and Depth Validation
345 # ------------------------------------------------------------------ #
346 try:
347 if is_remote:
348 # Stream remote file with a hard byte cap to prevent memory DoS
349 # if Content-Length was missing or lied
350 req = urllib.request.Request(path_str)
351 with urllib.request.urlopen(req, timeout=30) as response:
352 # Stream read with a hard ceiling
353 raw_bytes = response.read(max_file_size + 1)
354 if len(raw_bytes) > max_file_size: 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 raise FileValidationError(
356 f"Remote file exceeded maximum limit of {max_file_size} "
357 f"bytes during download"
358 )
359 data = json.loads(raw_bytes.decode("utf-8"))
360 else:
361 with local_path.open("r", encoding="utf-8") as f:
362 data = json.load(f)
364 limited_parse(data, max_depth)
366 except json.JSONDecodeError as e:
367 raise FileValidationError(f"Invalid JSON format: {e}") from e
368 except RecursionError:
369 raise FileValidationError(
370 "JSON nesting limit triggered Python call stack exhaustion"
371 )
372 except urllib.error.HTTPError as e:
373 raise FileValidationError(
374 f"Remote file download failed (HTTP {e.code}): {path_str}"
375 ) from e
376 except urllib.error.URLError as e:
377 raise FileValidationError(
378 f"Network error downloading file: {path_str} — {e.reason}"
379 ) from e
380 except UnicodeDecodeError as e:
381 raise FileValidationError(
382 f"File is not valid UTF-8: {e}"
383 ) from e
384 except Exception as e:
385 # Catches rare OS/permission issues, TOCTOU deletes, or other unexpected errors
386 raise FileValidationError(f"JSON validation failed: {e}") from e