Coverage for src/fileaudit/zip_check.py: 35%
155 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 - ZIP File Security Checker
5"""
6import inspect
7import stat
8import zipfile
9from functools import wraps
10from pathlib import Path
13# ---------------------------------------------------------------------------
14# Defaults
15# ---------------------------------------------------------------------------
17DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MiB
18DEFAULT_MAX_UNCOMPRESSED_RATIO = 100 # 100:1
19DEFAULT_MAX_ZIP_MEMBERS = 10_000
20DEFAULT_MAX_TOTAL_EXTRACTED_SIZE = 1 * 1024 * 1024**3 # 1 GiB
21DEFAULT_MAX_INDIVIDUAL_FILE_SIZE = 100 * 1024 * 1024 # 100 MiB
22DEFAULT_MAX_FILENAME_LENGTH = 255
23DEFAULT_MAX_DIRECTORY_DEPTH = 20
26# ---------------------------------------------------------------------------
27# Exception
28# ---------------------------------------------------------------------------
30class ZipValidationError(Exception):
31 """Raised when a ZIP archive fails security validation."""
34# ---------------------------------------------------------------------------
35# ZIP validation implementation
36# ---------------------------------------------------------------------------
38def _validate_zip_file(
39 path,
40 max_file_size,
41 max_uncompressed_ratio,
42 max_zip_members,
43 max_total_extracted_size,
44 max_individual_file_size,
45 max_filename_length,
46 max_directory_depth,
47):
48 """
49 Validate a ZIP archive without extracting it.
51 Raises:
52 ZipValidationError: If the archive fails any security check.
53 """
55 path = Path(path)
57 if not path.exists():
58 raise ZipValidationError(f"ZIP file does not exist: {path}")
60 if not path.is_file():
61 raise ZipValidationError(f"ZIP path is not a regular file: {path}")
63 # -----------------------------------------------------------------------
64 # Compressed archive size
65 # -----------------------------------------------------------------------
67 try:
68 file_size = path.stat().st_size
69 except OSError as e:
70 raise ZipValidationError(
71 f"Unable to stat ZIP file '{path}': {e}"
72 ) from e
74 if file_size > max_file_size:
75 raise ZipValidationError(
76 f"ZIP file is too large: {file_size} bytes "
77 f"(maximum {max_file_size})"
78 )
80 # -----------------------------------------------------------------------
81 # Open archive
82 # -----------------------------------------------------------------------
84 try:
85 with zipfile.ZipFile(path, "r") as zf: #NOSEC --This is a security check(er)!!
87 # A ZIP central directory can contain many entries even when the
88 # archive itself is relatively small.
89 infos = zf.infolist()
91 if len(infos) > max_zip_members:
92 raise ZipValidationError(
93 f"ZIP contains too many members: {len(infos)} "
94 f"(maximum {max_zip_members})"
95 )
97 total_uncompressed_size = 0
98 seen_names = set()
100 for info in infos:
101 filename = info.filename
103 # -----------------------------------------------------------
104 # Basic filename validation
105 # -----------------------------------------------------------
107 if not filename:
108 raise ZipValidationError(
109 "ZIP contains an entry with an empty filename"
110 )
112 if "\x00" in filename:
113 raise ZipValidationError(
114 f"ZIP entry contains a NUL byte: {filename!r}"
115 )
117 # ZIP uses '/' internally, but '\' can become a path
118 # separator when the archive is later processed on Windows.
119 if "\\" in filename:
120 raise ZipValidationError(
121 f"ZIP entry contains a backslash in its path: "
122 f"{filename!r}"
123 )
125 # Measure encoded filename length rather than Python's
126 # character count. This is closer to filesystem limits.
127 filename_length = len(filename.encode("utf-8"))
129 if filename_length > max_filename_length:
130 raise ZipValidationError(
131 f"ZIP entry filename is too long: "
132 f"{filename_length} bytes "
133 f"(maximum {max_filename_length}): {filename!r}"
134 )
136 # -----------------------------------------------------------
137 # Duplicate names
138 # -----------------------------------------------------------
140 # Duplicate names can cause different extraction libraries
141 # to behave differently (first wins / last wins / overwrite).
142 if filename in seen_names:
143 raise ZipValidationError(
144 f"ZIP contains duplicate filename: {filename!r}"
145 )
147 seen_names.add(filename)
149 # -----------------------------------------------------------
150 # Path traversal protection
151 # -----------------------------------------------------------
153 # Reject POSIX absolute paths.
154 if filename.startswith("/"):
155 raise ZipValidationError(
156 f"ZIP contains an absolute path: {filename!r}"
157 )
159 # Reject Windows drive-letter paths such as C:/foo.
160 if (
161 len(filename) >= 2
162 and filename[1] == ":"
163 and filename[0].isalpha()
164 ):
165 raise ZipValidationError(
166 f"ZIP contains a Windows drive path: {filename!r}"
167 )
169 # Reject UNC-like paths.
170 if filename.startswith("//"):
171 raise ZipValidationError(
172 f"ZIP contains a UNC-style path: {filename!r}"
173 )
175 parts = filename.split("/")
177 # Reject '.', '..', and empty path components where they
178 # could result in ambiguous filesystem paths.
179 for part in parts:
180 if part == "..":
181 raise ZipValidationError(
182 f"ZIP contains a path traversal component: "
183 f"{filename!r}"
184 )
186 # -----------------------------------------------------------
187 # Directory depth
188 # -----------------------------------------------------------
190 # Ignore the final component when it is the filename.
191 is_directory = filename.endswith("/")
193 depth_parts = [
194 p for p in parts
195 if p not in ("", ".")
196 ]
198 depth = len(depth_parts)
200 if not is_directory and depth > 0:
201 depth -= 1
203 if depth > max_directory_depth:
204 raise ZipValidationError(
205 f"ZIP entry exceeds maximum directory depth: "
206 f"{filename!r} "
207 f"(depth {depth}, maximum {max_directory_depth})"
208 )
210 # -----------------------------------------------------------
211 # ZIP member type / symlink protection
212 # -----------------------------------------------------------
214 # Unix external attributes contain the file mode in the
215 # upper 16 bits. If present, reject symbolic links and
216 # special files.
217 unix_mode = (info.external_attr >> 16) & 0xFFFF
219 if unix_mode:
220 file_type = stat.S_IFMT(unix_mode)
222 if file_type == stat.S_IFLNK:
223 raise ZipValidationError(
224 f"ZIP contains a symbolic link: {filename!r}"
225 )
227 if file_type not in (0, stat.S_IFREG, stat.S_IFDIR):
228 raise ZipValidationError(
229 f"ZIP contains an unsupported special file: "
230 f"{filename!r}"
231 )
233 # ZIP's DOS directory attribute.
234 dos_attributes = info.external_attr & 0xFFFF
236 if is_directory:
237 is_directory = True
238 elif unix_mode and stat.S_ISDIR(unix_mode):
239 is_directory = True
240 elif dos_attributes & 0x10:
241 is_directory = True
243 # -----------------------------------------------------------
244 # Encryption
245 # -----------------------------------------------------------
247 # We cannot safely validate the contents of an encrypted
248 # entry without a password. Reject encrypted entries.
249 if info.flag_bits & 0x1:
250 raise ZipValidationError(
251 f"ZIP contains an encrypted entry: {filename!r}"
252 )
254 # -----------------------------------------------------------
255 # Compression method
256 # -----------------------------------------------------------
258 supported_methods = {
259 zipfile.ZIP_STORED,
260 zipfile.ZIP_DEFLATED,
261 zipfile.ZIP_BZIP2,
262 zipfile.ZIP_LZMA,
263 }
265 if info.compress_type not in supported_methods:
266 raise ZipValidationError(
267 f"ZIP entry uses an unsupported compression method: "
268 f"{filename!r}"
269 )
271 # -----------------------------------------------------------
272 # Size validation
273 # -----------------------------------------------------------
275 uncompressed_size = info.file_size
276 compressed_size = info.compress_size
278 # Directory entries should not contain meaningful payload.
279 if is_directory:
280 if uncompressed_size > 0:
281 raise ZipValidationError(
282 f"Directory entry contains data: {filename!r}"
283 )
284 continue
286 if uncompressed_size > max_individual_file_size:
287 raise ZipValidationError(
288 f"ZIP entry is too large: {filename!r} "
289 f"({uncompressed_size} bytes, "
290 f"maximum {max_individual_file_size})"
291 )
293 # Protect against ZIP bombs. A zero-byte compressed stream
294 # cannot meaningfully have a finite compression ratio.
295 if uncompressed_size > 0:
296 if compressed_size == 0:
297 raise ZipValidationError(
298 f"ZIP entry has uncompressed data but zero "
299 f"compressed size: {filename!r}"
300 )
302 ratio = uncompressed_size / compressed_size
304 if ratio > max_uncompressed_ratio:
305 raise ZipValidationError(
306 f"ZIP entry has an excessive compression ratio: "
307 f"{filename!r} "
308 f"({ratio:.2f}:1, maximum "
309 f"{max_uncompressed_ratio}:1)"
310 )
312 # Check cumulative extracted size.
313 total_uncompressed_size += uncompressed_size
315 if total_uncompressed_size > max_total_extracted_size:
316 raise ZipValidationError(
317 f"ZIP total uncompressed size is too large: "
318 f"{total_uncompressed_size} bytes "
319 f"(maximum {max_total_extracted_size})"
320 )
322 except ZipValidationError:
323 raise
325 except zipfile.BadZipFile as e:
326 raise ZipValidationError(
327 f"Invalid or corrupt ZIP file: {e}"
328 ) from e
330 except (OSError, EOFError, RuntimeError, ValueError) as e:
331 raise ZipValidationError(
332 f"Unable to validate ZIP file '{path}': {e}"
333 ) from e
336# ---------------------------------------------------------------------------
337# Public API
338# ---------------------------------------------------------------------------
340def validate_zip(
341 func_or_path=None,
342 max_file_size=None,
343 max_uncompressed_ratio=None,
344 max_zip_members=None,
345 max_total_extracted_size=None,
346 max_individual_file_size=None,
347 max_filename_length=None,
348 max_directory_depth=None,
349):
350 """
351 Validate ZIP files via decorator or direct invocation.
353 Supports:
355 1. Bare decorator::
357 @validate_zip
358 def process(path):
359 ...
361 2. Decorator factory::
363 @validate_zip()
364 def process(path):
365 ...
367 3. Named function argument::
369 @validate_zip("zip_path")
370 def process(zip_path):
371 ...
373 4. Named argument with limits::
375 @validate_zip(
376 "zip_path",
377 max_file_size=500 * 1024 * 1024,
378 max_zip_members=5000,
379 )
380 def process(zip_path):
381 ...
383 5. Direct / CLI invocation::
385 validate_zip("archive.zip")
387 Returns:
388 In decorator mode:
389 The decorated function.
391 In direct mode:
392 True if validation succeeds, False otherwise.
394 Raises:
395 ZipValidationError:
396 If validation fails in decorator mode.
397 """
399 # Resolve optional limits to defaults.
400 resolved_file_size = (
401 DEFAULT_MAX_FILE_SIZE
402 if max_file_size is None
403 else max_file_size
404 )
406 resolved_ratio = (
407 DEFAULT_MAX_UNCOMPRESSED_RATIO
408 if max_uncompressed_ratio is None
409 else max_uncompressed_ratio
410 )
412 resolved_members = (
413 DEFAULT_MAX_ZIP_MEMBERS
414 if max_zip_members is None
415 else max_zip_members
416 )
418 resolved_total_size = (
419 DEFAULT_MAX_TOTAL_EXTRACTED_SIZE
420 if max_total_extracted_size is None
421 else max_total_extracted_size
422 )
424 resolved_individual_size = (
425 DEFAULT_MAX_INDIVIDUAL_FILE_SIZE
426 if max_individual_file_size is None
427 else max_individual_file_size
428 )
430 resolved_filename_len = (
431 DEFAULT_MAX_FILENAME_LENGTH
432 if max_filename_length is None
433 else max_filename_length
434 )
436 resolved_depth = (
437 DEFAULT_MAX_DIRECTORY_DEPTH
438 if max_directory_depth is None
439 else max_directory_depth
440 )
442 # ---------------------------------------------------------------
443 # Determine whether this is decorator or direct-call mode.
444 # ---------------------------------------------------------------
446 is_decorator_mode = False
448 if func_or_path is None:
449 is_decorator_mode = True
451 elif callable(func_or_path):
452 # @validate_zip
453 is_decorator_mode = True
455 elif isinstance(func_or_path, Path):
456 # validate_zip(Path("archive.zip"))
457 is_decorator_mode = False
459 elif isinstance(func_or_path, str): 459 ↛ 477line 459 didn't jump to line 477 because the condition on line 459 was always true
460 # A valid identifier is interpreted as a function argument name.
461 #
462 # Therefore:
463 # @validate_zip("zip_path")
464 #
465 # means "validate the zip_path argument".
466 #
467 # While:
468 # validate_zip("archive.zip")
469 #
470 # is direct-call mode.
471 is_decorator_mode = func_or_path.isidentifier()
473 # ---------------------------------------------------------------
474 # Direct call / CLI mode.
475 # ---------------------------------------------------------------
477 if not is_decorator_mode and isinstance(func_or_path, (str, Path)):
478 try:
479 _validate_zip_file(
480 func_or_path,
481 resolved_file_size,
482 resolved_ratio,
483 resolved_members,
484 resolved_total_size,
485 resolved_individual_size,
486 resolved_filename_len,
487 resolved_depth,
488 )
489 return True
491 except Exception as e:
492 print(f"Exception: {e}")
493 return False
495 # ---------------------------------------------------------------
496 # Decorator mode.
497 # ---------------------------------------------------------------
499 def decorator(f):
500 sig = inspect.signature(f)
501 params = list(sig.parameters.keys())
503 if not params:
504 raise ZipValidationError(
505 f"Decorator applied to '{f.__name__}', "
506 f"but it has no arguments."
507 )
509 # If the user supplied a valid argument name, use it.
510 # Otherwise validate the first argument.
511 if (
512 isinstance(func_or_path, str)
513 and func_or_path in params
514 ):
515 target_arg = func_or_path
516 else:
517 target_arg = params[0]
519 @wraps(f)
520 def wrapper(*args, **kwargs):
521 try:
522 bound_args = sig.bind(*args, **kwargs)
523 bound_args.apply_defaults()
525 except TypeError as e:
526 raise ZipValidationError(
527 f"Invalid function call signature: {e}"
528 ) from e
530 p = bound_args.arguments.get(target_arg)
532 if p is None:
533 raise ZipValidationError(
534 f"Missing required argument: {target_arg}"
535 )
537 if not isinstance(p, (str, Path)):
538 raise ZipValidationError(
539 f"Expected Path or str for {target_arg}, "
540 f"got {type(p).__name__}"
541 )
543 _validate_zip_file(
544 p,
545 resolved_file_size,
546 resolved_ratio,
547 resolved_members,
548 resolved_total_size,
549 resolved_individual_size,
550 resolved_filename_len,
551 resolved_depth,
552 )
554 return f(*args, **kwargs)
556 return wrapper
558 if callable(func_or_path):
559 return decorator(func_or_path)
561 return decorator