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

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 

10 

11import urllib.request 

12import urllib.error 

13from urllib.parse import urlparse 

14 

15 

16# Global default fallbacks 

17DEFAULT_MAX_DEPTH = 50 

18DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB 

19 

20 

21class FileValidationError(Exception): 

22 """Custom exception for JSON validation failures in FileAudit.""" 

23 

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) 

29 

30 def __str__(self): 

31 return self.args[0] 

32 

33 

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) 

44 

45def validate_json(func_or_path=None, max_depth=None, max_file_size=None): 

46 """Validate JSON files via decorator or direct invocation. 

47 

48 A JSON file validator that can operate in two modes: 

49 

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. 

54 

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 

61 

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. 

77 

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). 

83 

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``. 

88 

89 Examples: 

90 Bare decorator (validates the first argument):: 

91 

92 @validate_json 

93 def process_data(file_path): 

94 ... 

95 

96 Decorator with custom limits:: 

97 

98 @validate_json(max_depth=50, max_file_size=5000) 

99 def process_data(file_path): 

100 ... 

101 

102 Decorator targeting a specific argument by name:: 

103 

104 @validate_json("config_path", max_depth=10) 

105 def process_data(config_path, other_arg): 

106 ... 

107 

108 Direct call / CLI usage:: 

109 

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 

116 

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 # ------------------------------------------------------------------ # 

131 

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 

145 

146 is_decorator_mode = False 

147 

148 if func_or_path is None: 

149 # @validate_json() — decorator factory 

150 is_decorator_mode = True 

151 

152 elif callable(func_or_path): 

153 # @validate_json — bare decorator, func_or_path is the function 

154 is_decorator_mode = True 

155 

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) 

161 

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 

165 

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 

179 

180 # ------------------------------------------------------------------ # 

181 # 2. DECORATOR MODE 

182 # ------------------------------------------------------------------ # 

183 def decorator(f): 

184 sig = inspect.signature(f) 

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

186 

187 if not params: 

188 raise FileValidationError( 

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

190 ) 

191 

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] 

199 

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}") 

207 

208 p = bound_args.arguments.get(target_arg) 

209 

210 if p is None: 

211 raise FileValidationError(f"Missing required argument: {target_arg}") 

212 

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 ) 

221 

222 return f(*args, **kwargs) 

223 return wrapper 

224 

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) 

228 

229 return decorator 

230 

231 

232 

233 

234def _validate_json_file(path, max_depth, max_file_size): 

235 """Secure JSON validation with size, existence, and depth protection. 

236 

237 Internal function! 

238 

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. 

243 

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. 

255 

256 Returns: 

257 None: This function does not return a value. Successful validation 

258 completes silently. 

259 

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. 

270 

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) 

284 

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 ) 

290 

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}") 

300 

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 

342 

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) 

363 

364 limited_parse(data, max_depth) 

365 

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 

387