Coverage for src/fileaudit/tar_check.py: 93%
154 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 - TAR Security Checker
5"""
6import tarfile
7import urllib.request
8import urllib.error
9from pathlib import Path
10from urllib.parse import urlparse
11from functools import wraps
12import inspect
13import os
14import tempfile
17# Global default fallbacks
18DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
19DEFAULT_MAX_TAR_MEMBERS = 1000
20DEFAULT_MAX_TOTAL_EXTRACTED_SIZE = 100 * 1024 * 1024 # 100 MB
21DEFAULT_MAX_INDIVIDUAL_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
22DEFAULT_MAX_FILENAME_LENGTH = 255
23DEFAULT_MAX_DIRECTORY_DEPTH = 50
26class TarValidationError(Exception):
27 """Custom exception for TAR validation failures in FileAudit."""
29 def __init__(self, message):
30 self.prefix = "FileAudit Security Validation Failed -"
31 self.original_message = str(message)
32 full_message = f"{self.prefix} {self.original_message}"
33 super().__init__(full_message)
35 def __str__(self):
36 return self.args[0]
39def _check_tar_member(member, base_path, max_individual_size, max_filename_length, max_depth):
40 """
41 Validate a single TAR member for security issues.
43 Args:
44 member: TarInfo object
45 base_path: Path object for the extraction base directory
46 max_individual_size: Maximum allowed size for individual files
47 max_filename_length: Maximum allowed filename/path length
48 max_depth: Maximum allowed directory depth
50 Raises:
51 TarValidationError: If any security check fails
52 """
54 # 1. Reject symlinks, hardlinks, devices, and FIFOs
55 if member.issym() or member.islnk():
56 raise TarValidationError(
57 f"Rejected {member.name}: Symlinks and hardlinks are not allowed"
58 )
60 if member.isdev() or member.isfifo():
61 raise TarValidationError(
62 f"Rejected {member.name}: Device files and FIFOs are not allowed"
63 )
65 # 2. Filename/path length limits
66 if len(member.name) > max_filename_length:
67 raise TarValidationError(
68 f"Rejected {member.name}: Path length ({len(member.name)}) exceeds "
69 f"maximum of {max_filename_length} characters"
70 )
72 # 3. Path traversal protection
73 # Resolve the full path and check if it's within the extraction directory
74 member_path = (base_path / member.name).resolve()
75 try:
76 # Check if the resolved path is still within the base directory
77 member_path.relative_to(base_path.resolve())
78 except ValueError:
79 raise TarValidationError(
80 f"Rejected {member.name}: Path traversal attempt detected"
81 )
83 # 4. Directory depth limits
84 # Count path components
85 path_parts = Path(member.name).parts
86 depth = len(path_parts) if member.name else 0
87 if depth > max_depth: 87 ↛ 88line 87 didn't jump to line 88 because the condition on line 87 was never true
88 raise TarValidationError(
89 f"Rejected {member.name}: Directory depth ({depth}) exceeds "
90 f"maximum of {max_depth}"
91 )
93 # 5. Individual file size limits (for regular files only)
94 if member.isfile() and member.size > max_individual_size:
95 raise TarValidationError(
96 f"Rejected {member.name}: File size ({member.size} bytes) exceeds "
97 f"maximum individual file size of {max_individual_size} bytes"
98 )
101def validate_tar(func_or_path=None,
102 max_file_size=None,
103 max_tar_members=None,
104 max_total_extracted_size=None,
105 max_individual_file_size=None,
106 max_filename_length=None,
107 max_directory_depth=None):
108 """
109 Validate TAR files via decorator or direct invocation.
111 A TAR file validator that can operate in two modes:
113 1. **Decorator mode** — wraps a function to validate a TAR file path
114 passed as an argument before the function body runs.
115 2. **Direct call / CLI mode** — validates a file immediately and returns
116 a boolean result.
118 Security checks performed:
119 - File size limits
120 - Tar member count limits
121 - Tar total extracted size limits
122 - Tar individual file size limits
123 - Tar path traversal protection
124 - Reject symlinks/hardlinks/devices/FIFOs
125 - Filename/path length limits
126 - Directory depth limits
128 Usage:
129 @validate_tar
130 @validate_tar()
131 @validate_tar("custom_arg_name", max_file_size=5000)
132 validate_tar("path/to/file.tar", max_tar_members=100) # CLI usage
134 Args:
135 func_or_path (callable, str, pathlib.Path, or None):
136 * If a **callable**: the function to decorate (bare decorator
137 usage: ``@validate_tar``).
138 * If a **str** or **Path**: the file path to validate (direct call).
139 * If a **str** that is a valid Python identifier: treated as the
140 target argument name in decorator mode.
141 * If **None**: returns a decorator factory.
142 max_file_size (int): Maximum allowed file size in bytes.
143 max_tar_members (int): Maximum number of files/directories in TAR.
144 max_total_extracted_size (int): Maximum total extracted size in bytes.
145 max_individual_file_size (int): Maximum size per extracted file.
146 max_filename_length (int): Maximum filename/path length.
147 max_directory_depth (int): Maximum directory nesting depth.
149 Returns:
150 Union[callable, bool, function]:
151 * In decorator mode: the wrapped function.
152 * In direct call mode: ``True`` if validation passes,
153 ``False`` if it fails (errors are printed to stdout).
155 Raises:
156 TarValidationError: If validation fails in decorator mode.
157 """
159 # Resolve optional limits to defaults
160 resolved_file_size = DEFAULT_MAX_FILE_SIZE if max_file_size is None else max_file_size
161 resolved_members = DEFAULT_MAX_TAR_MEMBERS if max_tar_members is None else max_tar_members
162 resolved_total_size = DEFAULT_MAX_TOTAL_EXTRACTED_SIZE if max_total_extracted_size is None else max_total_extracted_size
163 resolved_individual_size = DEFAULT_MAX_INDIVIDUAL_FILE_SIZE if max_individual_file_size is None else max_individual_file_size
164 resolved_filename_len = DEFAULT_MAX_FILENAME_LENGTH if max_filename_length is None else max_filename_length
165 resolved_depth = DEFAULT_MAX_DIRECTORY_DEPTH if max_directory_depth is None else max_directory_depth
167 def _looks_like_file_path(s):
168 """Heuristic: does this string look like a file path or URL?"""
169 if s.startswith(("http://", "https://", "ftp://", "file://")):
170 return True
171 if s.startswith(("/", "\\")):
172 return True
173 if "/" in s or "\\" in s: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true
174 return True
175 if "." in s and not s.startswith("."):
176 return True
177 return False
179 is_decorator_mode = False
181 if func_or_path is None:
182 is_decorator_mode = True
183 elif callable(func_or_path):
184 is_decorator_mode = True
185 elif isinstance(func_or_path, str):
186 is_decorator_mode = not _looks_like_file_path(func_or_path)
187 elif isinstance(func_or_path, Path): 187 ↛ 191line 187 didn't jump to line 191 because the condition on line 187 was always true
188 is_decorator_mode = False
190 # Direct call / CLI mode
191 if not is_decorator_mode and isinstance(func_or_path, (str, Path)):
192 try:
193 _validate_tar_file(
194 func_or_path,
195 resolved_file_size,
196 resolved_members,
197 resolved_total_size,
198 resolved_individual_size,
199 resolved_filename_len,
200 resolved_depth
201 )
202 return True
203 except Exception as e:
204 print(f"Exception: {e}")
205 return False
207 # Decorator mode
208 def decorator(f):
209 sig = inspect.signature(f)
210 params = list(sig.parameters.keys())
212 if not params:
213 raise TarValidationError(
214 f"Decorator applied to '{f.__name__}', but it has no arguments."
215 )
217 target_arg = func_or_path if isinstance(func_or_path, str) and func_or_path in params else params[0]
219 @wraps(f)
220 def wrapper(*args, **kwargs):
221 try:
222 bound_args = sig.bind(*args, **kwargs)
223 bound_args.apply_defaults()
224 except TypeError as e:
225 raise TarValidationError(f"Invalid function call signature: {e}")
227 p = bound_args.arguments.get(target_arg)
229 if p is None:
230 raise TarValidationError(f"Missing required argument: {target_arg}")
232 if isinstance(p, (str, Path)):
233 _validate_tar_file(
234 p,
235 resolved_file_size,
236 resolved_members,
237 resolved_total_size,
238 resolved_individual_size,
239 resolved_filename_len,
240 resolved_depth
241 )
242 else:
243 raise TarValidationError(
244 f"Expected Path or str for {target_arg}, got {type(p).__name__}"
245 )
247 return f(*args, **kwargs)
248 return wrapper
250 if callable(func_or_path):
251 return decorator(func_or_path)
253 return decorator
256def _validate_tar_file(path, max_file_size, max_tar_members,
257 max_total_extracted_size, max_individual_file_size,
258 max_filename_length, max_directory_depth):
259 """
260 Internal validation function for TAR files.
262 Performs all security checks on a TAR file.
263 """
264 path_str = str(path)
265 parsed = urlparse(path_str)
266 is_remote = bool(parsed.scheme)
268 if is_remote and parsed.scheme != "https":
269 raise TarValidationError(
270 f"Unsupported URL scheme '{parsed.scheme}': only 'https' is allowed."
271 )
273 # Get file content as bytes (either local or remote)
274 try:
275 if is_remote:
276 req = urllib.request.Request(path_str)
277 with urllib.request.urlopen(req, timeout=30) as response:
278 tar_data = response.read()
279 if len(tar_data) > max_file_size:
280 raise TarValidationError(
281 f"Remote file size ({len(tar_data)} bytes) exceeds "
282 f"maximum of {max_file_size} bytes"
283 )
284 else:
285 local_path = Path(path)
286 if not local_path.exists():
287 raise TarValidationError(f"File not found: {local_path}")
288 if not local_path.is_file(): 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 raise TarValidationError(f"Path is not a file: {local_path}")
291 if local_path.stat().st_size > max_file_size:
292 raise TarValidationError(
293 f"File size ({local_path.stat().st_size} bytes) exceeds "
294 f"maximum of {max_file_size} bytes"
295 )
297 with local_path.open('rb') as f:
298 tar_data = f.read()
300 except urllib.error.HTTPError as e:
301 raise TarValidationError(f"Remote file unreachable (HTTP {e.code})") from e
302 except urllib.error.URLError as e:
303 raise TarValidationError(f"Could not reach remote file: {e.reason}") from e
304 except Exception as e:
305 raise TarValidationError(f"Failed to read file: {e}") from e
307 # Parse the TAR archive
308 try:
309 # Use a temporary file for large archives to avoid memory issues
310 with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
311 tmp_file.write(tar_data)
312 tmp_path = tmp_file.name
314 try:
315 with tarfile.open(tmp_path, mode='r') as tar:
316 members = tar.getmembers()
318 # Check member count
319 if len(members) > max_tar_members:
320 raise TarValidationError(
321 f"Archive contains {len(members)} members, exceeding "
322 f"maximum of {max_tar_members}"
323 )
325 # Create a temporary base path for path traversal checks
326 temp_base = Path("/tmp/tar_validation_extract")
328 # Check total extracted size
329 total_size = sum(member.size for member in members if member.isfile())
330 if total_size > max_total_extracted_size:
331 raise TarValidationError(
332 f"Total extracted size ({total_size} bytes) exceeds "
333 f"maximum of {max_total_extracted_size} bytes"
334 )
336 # Validate each member
337 for member in members:
338 _check_tar_member(
339 member,
340 temp_base,
341 max_individual_file_size,
342 max_filename_length,
343 max_directory_depth
344 )
346 except tarfile.TarError as e:
347 raise TarValidationError(f"Invalid TAR archive: {e}") from e
348 except Exception as e:
349 raise TarValidationError(f"TAR validation failed: {e}") from e
350 finally:
351 try:
352 os.unlink(tmp_path)
353 except FileNotFoundError:
354 pass # Already removed — no data leakage.
355 except OSError:
356 # Permission denied or similar: the temp file may still exist.
357 # If we're already handling an exception, don't mask it.
358 # Otherwise, raise so the caller knows cleanup failed.
359 if sys.exc_info()[0] is None:
360 raise
363 except Exception as e:
364 raise TarValidationError(f"Failed to process TAR archive: {e}") from e