Coverage for src/fileaudit/csv_check.py: 79%
203 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 - CSV Security Checker
6Direct / CLI usage:
7 validate_csv("file.csv")
8"""
10import csv
11import inspect
12import io
13import os
14import urllib.error
15import urllib.request
16from functools import wraps
17from pathlib import Path
18from urllib.parse import urlparse
21# ---------------------------------------------------------------------------
22# Defaults
23# ---------------------------------------------------------------------------
25DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
26DEFAULT_MAX_ROWS = 100_000
27DEFAULT_MAX_COLUMNS = 1_000
28DEFAULT_MAX_FIELD_SIZE = 1 * 1024 * 1024 # 1 MB
29DEFAULT_MAX_TOTAL_FIELDS = 10_000_000
30DEFAULT_MAX_ROW_SIZE = 5 * 1024 * 1024 # 5 MB
31DEFAULT_MAX_FILENAME_LENGTH = 255
33# Spreadsheet formula injection prefixes.
34DEFAULT_DANGEROUS_FORMULA_PREFIXES = (
35 "=",
36 "+",
37 "-",
38 "@",
39)
42# ---------------------------------------------------------------------------
43# Exceptions
44# ---------------------------------------------------------------------------
46class CsvValidationError(Exception):
47 """Custom exception for CSV validation failures in FileAudit."""
49 def __init__(self, message):
50 self.prefix = "FileAudit Security Validation Failed -"
51 self.original_message = str(message)
52 full_message = f"{self.prefix} {self.original_message}"
53 super().__init__(full_message)
55 def __str__(self):
56 return self.args[0]
59# ---------------------------------------------------------------------------
60# Individual CSV checks
61# ---------------------------------------------------------------------------
63def _check_csv_field(
64 value,
65 row_number,
66 column_number,
67 max_field_size,
68 reject_formula_injection,
69 reject_control_characters,
70):
71 """Validate one CSV field for common security issues."""
73 if not isinstance(value, str): 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 raise CsvValidationError(
75 f"Invalid field type at row {row_number}, "
76 f"column {column_number}"
77 )
79 # NUL bytes and other problematic control characters can cause problems
80 # when CSV data is passed to downstream applications.
81 if reject_control_characters:
82 for char in value:
83 codepoint = ord(char)
85 # Permit normal tab/newline/carriage return semantics inside
86 # quoted CSV fields, but reject other C0 control characters.
87 if codepoint < 32 and codepoint not in (9, 10, 13):
88 raise CsvValidationError(
89 f"Rejected field at row {row_number}, "
90 f"column {column_number}: "
91 f"contains control character U+{codepoint:04X}"
92 )
94 # NUL deserves an explicit security error.
95 if codepoint == 0: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 raise CsvValidationError(
97 f"Rejected field at row {row_number}, "
98 f"column {column_number}: contains NUL byte"
99 )
101 if len(value.encode("utf-8")) > max_field_size: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 raise CsvValidationError(
103 f"Rejected field at row {row_number}, "
104 f"column {column_number}: field size exceeds "
105 f"maximum of {max_field_size} bytes"
106 )
108 # CSV itself does not execute formulas. The danger occurs when the CSV
109 # is subsequently opened in spreadsheet software.
110 if reject_formula_injection:
111 stripped = value.lstrip()
113 if stripped.startswith(DEFAULT_DANGEROUS_FORMULA_PREFIXES):
114 raise CsvValidationError(
115 f"Rejected field at row {row_number}, "
116 f"column {column_number}: possible spreadsheet "
117 f"formula injection"
118 )
121def _check_csv_row(
122 row,
123 row_number,
124 max_columns,
125 max_field_size,
126 max_row_size,
127 reject_formula_injection,
128 reject_control_characters,
129):
130 """Validate a single parsed CSV row."""
132 if len(row) > max_columns:
133 raise CsvValidationError(
134 f"Rejected row {row_number}: contains {len(row)} columns, "
135 f"exceeding maximum of {max_columns}"
136 )
138 # Calculate the approximate serialized row size.
139 row_size = sum(
140 len(str(value).encode("utf-8"))
141 for value in row
142 )
144 if row_size > max_row_size:
145 raise CsvValidationError(
146 f"Rejected row {row_number}: row size ({row_size} bytes) "
147 f"exceeds maximum of {max_row_size} bytes"
148 )
150 for column_number, value in enumerate(row, start=1):
151 _check_csv_field(
152 value,
153 row_number,
154 column_number,
155 max_field_size,
156 reject_formula_injection,
157 reject_control_characters,
158 )
161# ---------------------------------------------------------------------------
162# Internal file validation
163# ---------------------------------------------------------------------------
165def _validate_csv_file(
166 path,
167 max_file_size,
168 max_rows,
169 max_columns,
170 max_field_size,
171 max_total_fields,
172 max_row_size,
173 max_filename_length,
174 reject_formula_injection,
175 reject_control_characters,
176 encoding,
177 dialect,
178):
179 """
180 Internal CSV validation function.
182 Validates local files and HTTPS URLs without executing or importing
183 anything contained in the CSV.
185 Security measures:
186 - Only HTTPS is accepted for remote URLs.
187 - File size is enforced both via Content-Length (when present) and
188 via a hard streaming limit on the actual bytes received.
189 - Negative / non-numeric Content-Length values are rejected.
190 - Local files are size-checked before being fully read.
191 - Filename length and .csv extension are validated (local paths).
192 - Decoding is strict; unexpected UTF-8 BOMs are rejected when
193 encoding is plain 'utf-8'.
194 - CSV parser limits (rows, columns, field size, total fields, row size)
195 are enforced.
196 - Optional formula-injection and control-character rejection.
197 """
198 path_str = str(path)
199 parsed = urlparse(path_str)
200 is_remote = bool(parsed.scheme)
202 # ---------------------------------------------------------------
203 # Remote URL validation
204 # ---------------------------------------------------------------
205 if is_remote and parsed.scheme != "https": 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true
206 raise CsvValidationError(
207 f"Unsupported URL scheme '{parsed.scheme}': "
208 "only 'https' is allowed."
209 )
211 # Optional: also require a .csv suffix on remote URLs
212 if is_remote:
213 url_path = parsed.path or ""
214 if not url_path.lower().endswith(".csv"): 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 raise CsvValidationError(
216 f"Remote URL does not point to a .csv file: {path_str}"
217 )
219 # ---------------------------------------------------------------
220 # Obtain CSV data
221 # ---------------------------------------------------------------
222 try:
223 if is_remote:
224 request = urllib.request.Request(
225 path_str,
226 headers={"User-Agent": "FileAudit-CSVValidator/1.0"},
227 method="GET",
228 )
229 with urllib.request.urlopen(request, timeout=30) as response:
230 content_length = response.headers.get("Content-Length")
232 if content_length is not None:
233 try:
234 declared_size = int(content_length)
235 if declared_size < 0:
236 raise CsvValidationError(
237 "Negative Content-Length is invalid"
238 )
239 if declared_size > max_file_size:
240 raise CsvValidationError(
241 f"Remote file size ({declared_size} bytes) "
242 f"exceeds maximum of {max_file_size} bytes"
243 )
244 except ValueError:
245 raise CsvValidationError(
246 f"Remote server returned invalid Content-Length: "
247 f"{content_length!r}"
248 )
250 # Never trust Content-Length alone – stream with a hard ceiling
251 chunks = []
252 total_size = 0
253 while True:
254 remaining = max_file_size - total_size + 1
255 if remaining <= 0:
256 raise CsvValidationError(
257 f"Remote file exceeds maximum size of "
258 f"{max_file_size} bytes"
259 )
260 chunk = response.read(min(64 * 1024, remaining))
261 if not chunk:
262 break
263 total_size += len(chunk)
264 if total_size > max_file_size:
265 raise CsvValidationError(
266 f"Remote file exceeds maximum size of "
267 f"{max_file_size} bytes"
268 )
269 chunks.append(chunk)
271 csv_data = b"".join(chunks)
273 else:
274 local_path = Path(path)
276 if not local_path.exists():
277 raise CsvValidationError(f"File not found: {local_path}")
278 if not local_path.is_file(): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 raise CsvValidationError(f"Path is not a file: {local_path}")
281 # Filename/path sanity check
282 if len(local_path.name) > max_filename_length:
283 raise CsvValidationError(
284 f"Filename length ({len(local_path.name)}) exceeds "
285 f"maximum of {max_filename_length} characters"
286 )
288 file_size = local_path.stat().st_size
289 if file_size > max_file_size:
290 raise CsvValidationError(
291 f"File size ({file_size} bytes) exceeds "
292 f"maximum of {max_file_size} bytes"
293 )
295 with local_path.open("rb") as file:
296 csv_data = file.read(max_file_size + 1)
297 if len(csv_data) > max_file_size: 297 ↛ 298line 297 didn't jump to line 298 because the condition on line 297 was never true
298 raise CsvValidationError(
299 f"File size exceeds maximum of {max_file_size} bytes"
300 )
302 except urllib.error.HTTPError as exc:
303 raise CsvValidationError(
304 f"Remote file unreachable (HTTP {exc.code})"
305 ) from exc
306 except urllib.error.URLError as exc:
307 raise CsvValidationError(
308 f"Could not reach remote file: {exc.reason}"
309 ) from exc
310 except CsvValidationError:
311 raise
312 except Exception as exc:
313 raise CsvValidationError(f"Failed to read file: {exc}") from exc
315 # ---------------------------------------------------------------
316 # Filename / extension check (local only – remote already checked)
317 # ---------------------------------------------------------------
318 if not is_remote: 318 ↛ 328line 318 didn't jump to line 328 because the condition on line 318 was always true
319 suffix = Path(path).suffix.lower()
320 if suffix != ".csv": 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true
321 raise CsvValidationError(
322 f"Expected a .csv file, got '{suffix or '[no extension]'}'"
323 )
325 # ---------------------------------------------------------------
326 # Decode content
327 # ---------------------------------------------------------------
328 try:
329 text = csv_data.decode(encoding)
330 except UnicodeDecodeError as exc:
331 raise CsvValidationError(
332 f"CSV is not valid {encoding} text: {exc}"
333 ) from exc
335 # Reject unexpected UTF-8 BOM when using plain utf-8
336 if encoding.lower().replace("_", "-") == "utf-8":
337 if text.startswith("\ufeff"):
338 raise CsvValidationError(
339 "CSV contains a UTF-8 BOM; use encoding='utf-8-sig' "
340 "if BOMs are intentionally supported"
341 )
343 # Optional early rejection of null bytes (cheap extra safety)
344 if "\0" in text:
345 raise CsvValidationError("Null byte (\\0) found in CSV content – rejected")
347 # ---------------------------------------------------------------
348 # CSV parser configuration
349 # ---------------------------------------------------------------
350 # Note: csv.field_size_limit is process-global.
351 # Save and restore the previous value to avoid side-effects.
352 previous_limit = csv.field_size_limit()
353 try:
354 csv.field_size_limit(max_field_size)
356 reader = csv.reader(
357 io.StringIO(text, newline=""),
358 dialect=dialect,
359 )
361 row_count = 0
362 total_fields = 0
364 for row_number, row in enumerate(reader, start=1):
365 row_count += 1
366 if row_count > max_rows:
367 raise CsvValidationError(
368 f"CSV contains more than {max_rows} rows"
369 )
371 total_fields += len(row)
372 if total_fields > max_total_fields:
373 raise CsvValidationError(
374 f"CSV contains more than {max_total_fields} fields"
375 )
377 _check_csv_row(
378 row=row,
379 row_number=row_number,
380 max_columns=max_columns,
381 max_field_size=max_field_size,
382 max_row_size=max_row_size,
383 reject_formula_injection=reject_formula_injection,
384 reject_control_characters=reject_control_characters,
385 )
387 except csv.Error as exc:
388 raise CsvValidationError(f"Invalid CSV format: {exc}") from exc
389 except CsvValidationError:
390 raise
391 except Exception as exc:
392 raise CsvValidationError(f"CSV validation failed: {exc}") from exc
393 finally:
394 # Restore previous global limit
395 csv.field_size_limit(previous_limit)
398# ---------------------------------------------------------------------------
399# Public API
400# ---------------------------------------------------------------------------
402def validate_csv(
403 func_or_path=None,
404 max_file_size=None,
405 max_rows=None,
406 max_columns=None,
407 max_field_size=None,
408 max_total_fields=None,
409 max_row_size=None,
410 max_filename_length=None,
411 reject_formula_injection=True,
412 reject_control_characters=True,
413 encoding="utf-8",
414 dialect="excel",
415):
416 """
417 Validate CSV files via decorator or direct invocation.
419 Two modes are supported:
421 1. Decorator mode:
423 @validate_csv
424 def process_csv(csv_path):
425 ...
427 @validate_csv()
428 def process_csv(csv_path):
429 ...
431 @validate_csv("input_file")
432 def process_csv(input_file):
433 ...
435 2. Direct / CLI mode:
437 validate_csv("data.csv")
439 Direct invocation returns True when validation succeeds and False
440 when validation fails. Validation failures are printed to stdout.
442 Security checks performed:
444 - Maximum file size
445 - HTTPS-only remote files
446 - Maximum number of rows
447 - Maximum number of columns
448 - Maximum individual field size
449 - Maximum total number of fields
450 - Maximum row size
451 - Filename length
452 - CSV syntax validation
453 - Encoding validation
454 - NUL/control-character rejection
455 - Spreadsheet formula-injection protection
457 Args:
458 func_or_path:
459 Callable for bare decorator usage, str/Path for direct
460 validation, str for the decorated argument name, or None.
462 max_file_size:
463 Maximum CSV file size in bytes.
465 max_rows:
466 Maximum number of CSV rows.
468 max_columns:
469 Maximum number of columns in a row.
471 max_field_size:
472 Maximum size of an individual field in bytes.
474 max_total_fields:
475 Maximum total number of fields in the CSV.
477 max_row_size:
478 Maximum approximate serialized row size in bytes.
480 max_filename_length:
481 Maximum filename length.
483 reject_formula_injection:
484 Reject fields beginning with =, +, -, or @ after whitespace.
486 reject_control_characters:
487 Reject dangerous control characters.
489 encoding:
490 Text encoding used to decode the CSV.
492 dialect:
493 CSV dialect passed to csv.reader.
495 Returns:
496 In decorator mode:
497 The wrapped function.
499 In direct mode:
500 True if validation succeeds, False otherwise.
502 Raises:
503 CsvValidationError:
504 If validation fails in decorator mode.
505 """
507 # ---------------------------------------------------------------
508 # Resolve defaults
509 # ---------------------------------------------------------------
511 resolved_file_size = (
512 DEFAULT_MAX_FILE_SIZE
513 if max_file_size is None
514 else max_file_size
515 )
517 resolved_rows = (
518 DEFAULT_MAX_ROWS
519 if max_rows is None
520 else max_rows
521 )
523 resolved_columns = (
524 DEFAULT_MAX_COLUMNS
525 if max_columns is None
526 else max_columns
527 )
529 resolved_field_size = (
530 DEFAULT_MAX_FIELD_SIZE
531 if max_field_size is None
532 else max_field_size
533 )
535 resolved_total_fields = (
536 DEFAULT_MAX_TOTAL_FIELDS
537 if max_total_fields is None
538 else max_total_fields
539 )
541 resolved_row_size = (
542 DEFAULT_MAX_ROW_SIZE
543 if max_row_size is None
544 else max_row_size
545 )
547 resolved_filename_length = (
548 DEFAULT_MAX_FILENAME_LENGTH
549 if max_filename_length is None
550 else max_filename_length
551 )
553 # ---------------------------------------------------------------
554 # Determine decorator vs direct invocation
555 # ---------------------------------------------------------------
557 def _looks_like_file_path(value):
558 """Heuristic used to distinguish a path from an argument name."""
560 if value.startswith(
561 ("http://", "https://", "ftp://", "file://")
562 ):
563 return True
565 if value.startswith(("/", "\\")):
566 return True
568 if "/" in value or "\\" in value: 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true
569 return True
571 if "." in value and not value.startswith("."):
572 return True
574 return False
576 is_decorator_mode = False
578 if func_or_path is None:
579 is_decorator_mode = True
581 elif callable(func_or_path):
582 is_decorator_mode = True
584 elif isinstance(func_or_path, str):
585 is_decorator_mode = not _looks_like_file_path(func_or_path)
587 elif isinstance(func_or_path, Path):
588 is_decorator_mode = False
590 # ---------------------------------------------------------------
591 # Shared validator invocation
592 # ---------------------------------------------------------------
594 def _validate(path):
595 _validate_csv_file(
596 path=path,
597 max_file_size=resolved_file_size,
598 max_rows=resolved_rows,
599 max_columns=resolved_columns,
600 max_field_size=resolved_field_size,
601 max_total_fields=resolved_total_fields,
602 max_row_size=resolved_row_size,
603 max_filename_length=resolved_filename_length,
604 reject_formula_injection=reject_formula_injection,
605 reject_control_characters=reject_control_characters,
606 encoding=encoding,
607 dialect=dialect,
608 )
610 # ---------------------------------------------------------------
611 # Direct / CLI mode
612 # ---------------------------------------------------------------
614 if (
615 not is_decorator_mode
616 and isinstance(func_or_path, (str, Path))
617 ):
618 try:
619 _validate(func_or_path)
620 return True
622 except Exception as exc:
623 print(f"Exception: {exc}")
624 return False
626 # ---------------------------------------------------------------
627 # Decorator mode
628 # ---------------------------------------------------------------
630 def decorator(function):
631 signature = inspect.signature(function)
632 params = list(signature.parameters.keys())
634 if not params:
635 raise CsvValidationError(
636 f"Decorator applied to '{function.__name__}', "
637 "but it has no arguments."
638 )
640 # Explicit argument name:
641 #
642 # @validate_csv("csv_path")
643 #
644 # Otherwise use the first argument.
645 target_arg = (
646 func_or_path
647 if (
648 isinstance(func_or_path, str)
649 and func_or_path in params
650 )
651 else params[0]
652 )
654 @wraps(function)
655 def wrapper(*args, **kwargs):
656 try:
657 bound_args = signature.bind(*args, **kwargs)
658 bound_args.apply_defaults()
660 except TypeError as exc:
661 raise CsvValidationError(
662 f"Invalid function call signature: {exc}"
663 ) from exc
665 csv_path = bound_args.arguments.get(target_arg)
667 if csv_path is None: 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true
668 raise CsvValidationError(
669 f"Missing required argument: {target_arg}"
670 )
672 if not isinstance(csv_path, (str, Path)):
673 raise CsvValidationError(
674 f"Expected Path or str for {target_arg}, "
675 f"got {type(csv_path).__name__}"
676 )
678 _validate(csv_path)
680 return function(*args, **kwargs)
682 return wrapper
684 if callable(func_or_path):
685 return decorator(func_or_path)
687 return decorator