safeuploads 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
safeuploads/config.py ADDED
@@ -0,0 +1,842 @@
1
+ """
2
+ File security configuration module.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import logging
8
+ from .enums import (
9
+ DangerousExtensionCategory,
10
+ CompoundExtensionCategory,
11
+ UnicodeAttackCategory,
12
+ )
13
+ from .exceptions import ConfigValidationError, FileSecurityConfigurationError
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class SecurityLimits:
21
+ """
22
+ Security constraints for file submissions.
23
+
24
+ Attributes:
25
+ max_image_size: Maximum size in bytes for image files.
26
+ max_zip_size: Maximum size in bytes for ZIP archives.
27
+ max_compression_ratio: Maximum expansion ratio for ZIP files.
28
+ max_uncompressed_size: Maximum cumulative size of ZIP contents.
29
+ max_individual_file_size: Maximum size of single file in ZIP.
30
+ max_zip_entries: Maximum number of file entries in ZIP.
31
+ zip_analysis_timeout: Maximum seconds for ZIP analysis.
32
+ max_zip_depth: Maximum directory nesting depth in ZIP.
33
+ max_filename_length: Maximum length for filenames in ZIP.
34
+ max_path_length: Maximum length for full paths in ZIP.
35
+ allow_nested_archives: Whether nested archives are permitted.
36
+ allow_symlinks: Whether symbolic links are permitted.
37
+ allow_absolute_paths: Whether absolute paths are permitted.
38
+ scan_zip_content: Whether deep content inspection is enabled.
39
+ """
40
+
41
+ # File size limits (in bytes)
42
+ max_image_size: int = 20 * 1024 * 1024 # 20MB for images
43
+ max_zip_size: int = 500 * 1024 * 1024 # 500MB for ZIP files
44
+
45
+ # ZIP compression security settings
46
+ max_compression_ratio: int = 100 # Maximum allowed expansion ratio (e.g., 100:1)
47
+ max_uncompressed_size: int = 1024 * 1024 * 1024 # 1GB max uncompressed size
48
+ max_individual_file_size: int = (
49
+ 500 * 1024 * 1024
50
+ ) # 500MB max per individual file in ZIP
51
+ max_zip_entries: int = 10000 # Maximum number of files in ZIP archive
52
+ zip_analysis_timeout: float = (
53
+ 5.0 # Maximum seconds to spend analyzing ZIP structure
54
+ )
55
+
56
+ # ZIP content inspection settings
57
+ max_zip_depth: int = 10 # Maximum nesting depth for directories in ZIP
58
+ max_filename_length: int = 255 # Maximum length for individual file names
59
+ max_path_length: int = 1024 # Maximum length for full file paths
60
+ allow_nested_archives: bool = False # Whether to allow nested archive files
61
+ allow_symlinks: bool = False # Whether to allow symbolic links in ZIP
62
+ allow_absolute_paths: bool = False # Whether to allow absolute paths in ZIP
63
+ scan_zip_content: bool = True # Whether to perform deep content inspection
64
+
65
+
66
+ class FileSecurityConfig:
67
+ """
68
+ Centralizes file upload security settings and validation.
69
+
70
+ Attributes:
71
+ limits: Security limits configuration instance.
72
+ ALLOWED_IMAGE_MIMES: Permitted MIME types for images.
73
+ ALLOWED_ZIP_MIMES: Permitted MIME types for ZIP files.
74
+ ALLOWED_IMAGE_EXTENSIONS: Permitted image file extensions.
75
+ ALLOWED_ZIP_EXTENSIONS: Permitted ZIP file extensions.
76
+ BLOCKED_EXTENSIONS: Dangerous file extensions to block.
77
+ COMPOUND_BLOCKED_EXTENSIONS: Multi-part extensions to block.
78
+ DANGEROUS_UNICODE_CHARS: Unicode characters for filename attacks.
79
+ WINDOWS_RESERVED_NAMES: Platform-specific reserved filenames.
80
+ """
81
+
82
+ # Security limits configuration
83
+ limits = SecurityLimits()
84
+
85
+ # Allowed MIME types for images
86
+ ALLOWED_IMAGE_MIMES: set[str] = {"image/jpeg", "image/jpg", "image/png"}
87
+
88
+ # Allowed MIME types for ZIP files
89
+ ALLOWED_ZIP_MIMES: set[str] = {
90
+ "application/zip",
91
+ "application/x-zip-compressed",
92
+ "multipart/x-zip",
93
+ }
94
+
95
+ # Allowed file extensions
96
+ ALLOWED_IMAGE_EXTENSIONS: set[str] = {".jpg", ".jpeg", ".png"}
97
+ ALLOWED_ZIP_EXTENSIONS: set[str] = {".zip"}
98
+
99
+ # Generate dangerous file extensions from categorized enums
100
+ @staticmethod
101
+ def _generate_blocked_extensions() -> set[str]:
102
+ """
103
+ Aggregate all dangerous extension categories.
104
+
105
+ Returns:
106
+ Combined set of blocked file extensions.
107
+ """
108
+ blocked_extensions = set()
109
+
110
+ # Combine all dangerous extension categories
111
+ for category in DangerousExtensionCategory:
112
+ blocked_extensions.update(category.value)
113
+
114
+ return blocked_extensions
115
+
116
+ # Generate compound dangerous file extensions from categorized enums
117
+ @staticmethod
118
+ def _generate_compound_blocked_extensions() -> set[str]:
119
+ """
120
+ Aggregate all compound extension categories.
121
+
122
+ Returns:
123
+ Combined set of blocked compound file extensions.
124
+ """
125
+ compound_extensions = set()
126
+
127
+ # Combine all compound extension categories
128
+ for category in CompoundExtensionCategory:
129
+ compound_extensions.update(category.value)
130
+
131
+ return compound_extensions
132
+
133
+ # Generate dangerous Unicode characters from categorized enums
134
+ @staticmethod
135
+ def _generate_dangerous_unicode_chars() -> set[int]:
136
+ """
137
+ Aggregate all dangerous Unicode code points.
138
+
139
+ Returns:
140
+ Combined set of dangerous Unicode code points.
141
+ """
142
+ dangerous_chars = set()
143
+
144
+ # Combine all Unicode attack categories
145
+ for category in UnicodeAttackCategory:
146
+ dangerous_chars.update(category.value)
147
+
148
+ return dangerous_chars
149
+
150
+ # Dangerous file extensions to explicitly block (generated from enums)
151
+ BLOCKED_EXTENSIONS: set[str] = _generate_blocked_extensions()
152
+
153
+ # Compound dangerous file extensions (multi-part extensions)
154
+ # These are checked as complete strings, not individual parts
155
+ COMPOUND_BLOCKED_EXTENSIONS: set[str] = _generate_compound_blocked_extensions()
156
+
157
+ # Dangerous Unicode characters that can be used for filename attacks
158
+ # These characters can disguise file extensions or cause rendering issues
159
+ DANGEROUS_UNICODE_CHARS: set[int] = _generate_dangerous_unicode_chars()
160
+
161
+ # Windows reserved names that cannot be used as filenames
162
+ # These names are reserved by Windows regardless of extension
163
+ WINDOWS_RESERVED_NAMES: set[str] = {
164
+ "con",
165
+ "prn",
166
+ "aux",
167
+ "nul",
168
+ "com1",
169
+ "com2",
170
+ "com3",
171
+ "com4",
172
+ "com5",
173
+ "com6",
174
+ "com7",
175
+ "com8",
176
+ "com9",
177
+ "lpt1",
178
+ "lpt2",
179
+ "lpt3",
180
+ "lpt4",
181
+ "lpt5",
182
+ "lpt6",
183
+ "lpt7",
184
+ "lpt8",
185
+ "lpt9",
186
+ }
187
+
188
+ # Configuration validation trigger
189
+ @classmethod
190
+ def __init_subclass__(cls, **kwargs):
191
+ """
192
+ Hook for subclass creation to validate configuration.
193
+
194
+ Args:
195
+ **kwargs: Subclass initialization arguments.
196
+ """
197
+ super().__init_subclass__(**kwargs)
198
+ # Perform validation with warnings allowed (non-strict mode)
199
+ try:
200
+ cls.validate_and_report(strict=False)
201
+ except Exception as err:
202
+ logger.warning("Configuration validation failed: %s", err)
203
+
204
+ @classmethod
205
+ def get_extensions_by_category(
206
+ cls, category: DangerousExtensionCategory
207
+ ) -> set[str]:
208
+ """
209
+ Return extensions for a dangerous extension category.
210
+
211
+ Args:
212
+ category: The dangerous extension category.
213
+
214
+ Returns:
215
+ Copy of extensions in the specified category.
216
+ """
217
+ return category.value.copy()
218
+
219
+ @classmethod
220
+ def get_compound_extensions_by_category(
221
+ cls, category: CompoundExtensionCategory
222
+ ) -> set[str]:
223
+ """
224
+ Return compound extensions for a category.
225
+
226
+ Args:
227
+ category: The compound extension category.
228
+
229
+ Returns:
230
+ Copy of compound extensions in the specified category.
231
+ """
232
+ return category.value.copy()
233
+
234
+ @classmethod
235
+ def get_unicode_chars_by_category(cls, category: UnicodeAttackCategory) -> set[int]:
236
+ """
237
+ Return Unicode code points for an attack category.
238
+
239
+ Args:
240
+ category: The Unicode attack category.
241
+
242
+ Returns:
243
+ Copy of code points in the specified category.
244
+ """
245
+ return category.value.copy()
246
+
247
+ @classmethod
248
+ def is_extension_in_category(
249
+ cls, extension: str, category: DangerousExtensionCategory
250
+ ) -> bool:
251
+ """
252
+ Check if extension belongs to a dangerous category.
253
+
254
+ Args:
255
+ extension: File extension to evaluate.
256
+ category: Category to check against.
257
+
258
+ Returns:
259
+ True if extension is in the category, False otherwise.
260
+ """
261
+ return extension.lower() in category.value
262
+
263
+ @classmethod
264
+ def get_extension_category(
265
+ cls, extension: str
266
+ ) -> DangerousExtensionCategory | None:
267
+ """
268
+ Return the dangerous extension category for an extension.
269
+
270
+ Args:
271
+ extension: The file extension to evaluate.
272
+
273
+ Returns:
274
+ Matching category if dangerous, None otherwise.
275
+ """
276
+ extension_lower = extension.lower()
277
+ for category in DangerousExtensionCategory:
278
+ if extension_lower in category.value:
279
+ return category
280
+ return None
281
+
282
+ @classmethod
283
+ def validate_configuration(cls, strict: bool = True) -> list[ConfigValidationError]:
284
+ """
285
+ Run all configuration validation routines.
286
+
287
+ Args:
288
+ strict: Reserved for future behavior adjustments.
289
+
290
+ Returns:
291
+ List of detected validation errors.
292
+ """
293
+ errors = []
294
+
295
+ # Validate file size limits
296
+ errors.extend(cls._validate_file_size_limits())
297
+
298
+ # Validate MIME type configurations
299
+ errors.extend(cls._validate_mime_configurations())
300
+
301
+ # Validate file extension configurations
302
+ errors.extend(cls._validate_extension_configurations())
303
+
304
+ # Validate ZIP compression settings
305
+ errors.extend(cls._validate_compression_settings())
306
+
307
+ # Validate enum consistency
308
+ errors.extend(cls._validate_enum_consistency())
309
+
310
+ # Validate cross-configuration dependencies
311
+ errors.extend(cls._validate_cross_dependencies())
312
+
313
+ return errors
314
+
315
+ @classmethod
316
+ def _validate_file_size_limits(cls) -> list[ConfigValidationError]:
317
+ """
318
+ Validate configured file size limits.
319
+
320
+ Returns:
321
+ List of detected configuration issues.
322
+ """
323
+ errors = []
324
+
325
+ # Check image size limits
326
+ if cls.limits.max_image_size <= 0:
327
+ errors.append(
328
+ ConfigValidationError(
329
+ error_type="invalid_size_limit",
330
+ message="max_image_size must be greater than 0",
331
+ severity="error",
332
+ component="file_sizes",
333
+ recommendation="Set max_image_size to a positive value (e.g., 20MB)",
334
+ )
335
+ )
336
+
337
+ if cls.limits.max_image_size > 100 * 1024 * 1024: # 100MB
338
+ errors.append(
339
+ ConfigValidationError(
340
+ error_type="excessive_size_limit",
341
+ message=f"max_image_size ({cls.limits.max_image_size // (1024*1024)}MB) is very large",
342
+ severity="warning",
343
+ component="file_sizes",
344
+ recommendation="Consider reducing image size limit to prevent resource exhaustion",
345
+ )
346
+ )
347
+
348
+ # Check ZIP size limits
349
+ if cls.limits.max_zip_size <= 0:
350
+ errors.append(
351
+ ConfigValidationError(
352
+ error_type="invalid_size_limit",
353
+ message="max_zip_size must be greater than 0",
354
+ severity="error",
355
+ component="file_sizes",
356
+ recommendation="Set max_zip_size to a positive value (e.g., 500MB)",
357
+ )
358
+ )
359
+
360
+ if cls.limits.max_zip_size > 2 * 1024 * 1024 * 1024: # 2GB
361
+ errors.append(
362
+ ConfigValidationError(
363
+ error_type="excessive_size_limit",
364
+ message=f"max_zip_size ({cls.limits.max_zip_size // (1024*1024)}MB) is very large",
365
+ severity="warning",
366
+ component="file_sizes",
367
+ recommendation="Consider reducing ZIP size limit to prevent resource exhaustion",
368
+ )
369
+ )
370
+
371
+ # Validate size relationship
372
+ if cls.limits.max_zip_size <= cls.limits.max_image_size:
373
+ errors.append(
374
+ ConfigValidationError(
375
+ error_type="inconsistent_size_limits",
376
+ message="max_zip_size should typically be larger than max_image_size",
377
+ severity="warning",
378
+ component="file_sizes",
379
+ recommendation="ZIP files usually contain multiple files and should have higher limits",
380
+ )
381
+ )
382
+
383
+ return errors
384
+
385
+ @classmethod
386
+ def _validate_mime_configurations(cls) -> list[ConfigValidationError]:
387
+ """
388
+ Validate MIME type configurations.
389
+
390
+ Returns:
391
+ List of detected configuration issues.
392
+ """
393
+ errors = []
394
+
395
+ # Check image MIME types
396
+ if not cls.ALLOWED_IMAGE_MIMES:
397
+ errors.append(
398
+ ConfigValidationError(
399
+ error_type="empty_mime_set",
400
+ message="ALLOWED_IMAGE_MIMES cannot be empty",
401
+ severity="error",
402
+ component="mime_types",
403
+ recommendation="Add at least one allowed image MIME type",
404
+ )
405
+ )
406
+
407
+ # Validate image MIME type format
408
+ for mime_type in cls.ALLOWED_IMAGE_MIMES:
409
+ if not mime_type.startswith("image/"):
410
+ errors.append(
411
+ ConfigValidationError(
412
+ error_type="invalid_image_mime",
413
+ message=f"Image MIME type '{mime_type}' should start with 'image/'",
414
+ severity="warning",
415
+ component="mime_types",
416
+ recommendation="Use standard image MIME types like 'image/jpeg', 'image/png'",
417
+ )
418
+ )
419
+
420
+ # Check ZIP MIME types
421
+ if not cls.ALLOWED_ZIP_MIMES:
422
+ errors.append(
423
+ ConfigValidationError(
424
+ error_type="empty_mime_set",
425
+ message="ALLOWED_ZIP_MIMES cannot be empty",
426
+ severity="error",
427
+ component="mime_types",
428
+ recommendation="Add at least one allowed ZIP MIME type",
429
+ )
430
+ )
431
+
432
+ # Check for duplicate MIME types
433
+ all_mimes = list(cls.ALLOWED_IMAGE_MIMES) + list(cls.ALLOWED_ZIP_MIMES)
434
+ duplicates = set([mime for mime in all_mimes if all_mimes.count(mime) > 1])
435
+ if duplicates:
436
+ errors.append(
437
+ ConfigValidationError(
438
+ error_type="duplicate_mime_types",
439
+ message=f"Duplicate MIME types found: {duplicates}",
440
+ severity="warning",
441
+ component="mime_types",
442
+ recommendation="Remove duplicate MIME types to avoid confusion",
443
+ )
444
+ )
445
+
446
+ return errors
447
+
448
+ @classmethod
449
+ def _validate_extension_configurations(cls) -> list[ConfigValidationError]:
450
+ """
451
+ Validate file extension configurations.
452
+
453
+ Returns:
454
+ List of detected configuration issues.
455
+ """
456
+ errors = []
457
+
458
+ # Check extension format
459
+ for ext_set_name, ext_set in [
460
+ ("ALLOWED_IMAGE_EXTENSIONS", cls.ALLOWED_IMAGE_EXTENSIONS),
461
+ ("ALLOWED_ZIP_EXTENSIONS", cls.ALLOWED_ZIP_EXTENSIONS),
462
+ ]:
463
+ if not ext_set:
464
+ errors.append(
465
+ ConfigValidationError(
466
+ error_type="empty_extension_set",
467
+ message=f"{ext_set_name} cannot be empty",
468
+ severity="error",
469
+ component="extensions",
470
+ recommendation=f"Add at least one extension to {ext_set_name}",
471
+ )
472
+ )
473
+
474
+ for ext in ext_set:
475
+ if not ext.startswith("."):
476
+ errors.append(
477
+ ConfigValidationError(
478
+ error_type="invalid_extension_format",
479
+ message=f"Extension '{ext}' in {ext_set_name} should start with '.'",
480
+ severity="error",
481
+ component="extensions",
482
+ recommendation="Use format '.ext' for file extensions",
483
+ )
484
+ )
485
+
486
+ # Check blocked extensions
487
+ if not cls.BLOCKED_EXTENSIONS:
488
+ errors.append(
489
+ ConfigValidationError(
490
+ error_type="empty_blocked_extensions",
491
+ message="BLOCKED_EXTENSIONS is empty - security risk",
492
+ severity="error",
493
+ component="extensions",
494
+ recommendation="Ensure dangerous extensions are properly blocked",
495
+ )
496
+ )
497
+
498
+ # Check for overlap between allowed and blocked extensions
499
+ image_blocked = cls.ALLOWED_IMAGE_EXTENSIONS.intersection(
500
+ cls.BLOCKED_EXTENSIONS
501
+ )
502
+ if image_blocked:
503
+ errors.append(
504
+ ConfigValidationError(
505
+ error_type="extension_conflict",
506
+ message=f"Image extensions {image_blocked} are both allowed and blocked",
507
+ severity="error",
508
+ component="extensions",
509
+ recommendation="Remove conflicts between allowed and blocked extensions",
510
+ )
511
+ )
512
+
513
+ zip_blocked = cls.ALLOWED_ZIP_EXTENSIONS.intersection(cls.BLOCKED_EXTENSIONS)
514
+ if zip_blocked:
515
+ errors.append(
516
+ ConfigValidationError(
517
+ error_type="extension_conflict",
518
+ message=f"ZIP extensions {zip_blocked} are both allowed and blocked",
519
+ severity="error",
520
+ component="extensions",
521
+ recommendation="Remove conflicts between allowed and blocked extensions",
522
+ )
523
+ )
524
+
525
+ # Check compound extension consistency
526
+ compound_overlap = cls.BLOCKED_EXTENSIONS.intersection(
527
+ cls.COMPOUND_BLOCKED_EXTENSIONS
528
+ )
529
+ if compound_overlap:
530
+ errors.append(
531
+ ConfigValidationError(
532
+ error_type="compound_extension_overlap",
533
+ message=f"Extensions {compound_overlap} appear in both blocked and compound blocked lists",
534
+ severity="warning",
535
+ component="extensions",
536
+ recommendation="Compound extensions should only be in COMPOUND_BLOCKED_EXTENSIONS",
537
+ )
538
+ )
539
+
540
+ return errors
541
+
542
+ @classmethod
543
+ def _validate_compression_settings(cls) -> list[ConfigValidationError]:
544
+ """
545
+ Validate compression-related limits.
546
+
547
+ Returns:
548
+ List of detected configuration issues.
549
+ """
550
+ errors = []
551
+
552
+ # Validate compression ratio
553
+ if cls.limits.max_compression_ratio <= 0:
554
+ errors.append(
555
+ ConfigValidationError(
556
+ error_type="invalid_compression_ratio",
557
+ message="max_compression_ratio must be greater than 0",
558
+ severity="error",
559
+ component="compression",
560
+ recommendation="Set a reasonable compression ratio limit (e.g., 100:1)",
561
+ )
562
+ )
563
+
564
+ if cls.limits.max_compression_ratio < 10:
565
+ errors.append(
566
+ ConfigValidationError(
567
+ error_type="too_strict_compression",
568
+ message=f"max_compression_ratio ({cls.limits.max_compression_ratio}) is very strict",
569
+ severity="warning",
570
+ component="compression",
571
+ recommendation="Consider allowing higher compression ratios for legitimate files",
572
+ )
573
+ )
574
+
575
+ if cls.limits.max_compression_ratio > 1000:
576
+ errors.append(
577
+ ConfigValidationError(
578
+ error_type="too_permissive_compression",
579
+ message=f"max_compression_ratio ({cls.limits.max_compression_ratio}) may allow zip bombs",
580
+ severity="warning",
581
+ component="compression",
582
+ recommendation="Reduce compression ratio limit to prevent zip bomb attacks",
583
+ )
584
+ )
585
+
586
+ # Validate uncompressed size limit
587
+ if cls.limits.max_uncompressed_size <= 0:
588
+ errors.append(
589
+ ConfigValidationError(
590
+ error_type="invalid_uncompressed_size",
591
+ message="max_uncompressed_size must be greater than 0",
592
+ severity="error",
593
+ component="compression",
594
+ recommendation="Set a reasonable uncompressed size limit",
595
+ )
596
+ )
597
+
598
+ # Validate individual file size limit
599
+ if cls.limits.max_individual_file_size <= 0:
600
+ errors.append(
601
+ ConfigValidationError(
602
+ error_type="invalid_individual_file_size",
603
+ message="max_individual_file_size must be greater than 0",
604
+ severity="error",
605
+ component="compression",
606
+ recommendation="Set a reasonable individual file size limit",
607
+ )
608
+ )
609
+
610
+ # Check individual file size doesn't exceed total uncompressed size
611
+ if cls.limits.max_individual_file_size > cls.limits.max_uncompressed_size:
612
+ errors.append(
613
+ ConfigValidationError(
614
+ error_type="inconsistent_size_limits",
615
+ message=f"max_individual_file_size ({cls.limits.max_individual_file_size // (1024*1024)}MB) "
616
+ f"exceeds max_uncompressed_size ({cls.limits.max_uncompressed_size // (1024*1024)}MB)",
617
+ severity="warning",
618
+ component="compression",
619
+ recommendation="Individual file size limit should not exceed total uncompressed size limit",
620
+ )
621
+ )
622
+
623
+ # Validate ZIP entry limits
624
+ if cls.limits.max_zip_entries <= 0:
625
+ errors.append(
626
+ ConfigValidationError(
627
+ error_type="invalid_zip_entries",
628
+ message="max_zip_entries must be greater than 0",
629
+ severity="error",
630
+ component="compression",
631
+ recommendation="Set a reasonable limit for ZIP file entries",
632
+ )
633
+ )
634
+
635
+ if cls.limits.max_zip_entries > 100000:
636
+ errors.append(
637
+ ConfigValidationError(
638
+ error_type="excessive_zip_entries",
639
+ message=f"max_zip_entries ({cls.limits.max_zip_entries}) is very high",
640
+ severity="warning",
641
+ component="compression",
642
+ recommendation="High entry limits may impact performance",
643
+ )
644
+ )
645
+
646
+ # Validate timeout settings
647
+ if cls.limits.zip_analysis_timeout <= 0:
648
+ errors.append(
649
+ ConfigValidationError(
650
+ error_type="invalid_timeout",
651
+ message="zip_analysis_timeout must be greater than 0",
652
+ severity="error",
653
+ component="compression",
654
+ recommendation="Set a reasonable timeout for ZIP analysis",
655
+ )
656
+ )
657
+
658
+ if cls.limits.zip_analysis_timeout > 30:
659
+ errors.append(
660
+ ConfigValidationError(
661
+ error_type="excessive_timeout",
662
+ message=f"zip_analysis_timeout ({cls.limits.zip_analysis_timeout}s) is very long",
663
+ severity="warning",
664
+ component="compression",
665
+ recommendation="Long timeouts may impact user experience",
666
+ )
667
+ )
668
+
669
+ return errors
670
+
671
+ @classmethod
672
+ def _validate_enum_consistency(cls) -> list[ConfigValidationError]:
673
+ """
674
+ Validate enum categories for emptiness and overlaps.
675
+
676
+ Returns:
677
+ List of detected configuration issues.
678
+ """
679
+ errors = []
680
+
681
+ # Check for empty enum categories
682
+ for category in DangerousExtensionCategory:
683
+ if not category.value:
684
+ errors.append(
685
+ ConfigValidationError(
686
+ error_type="empty_enum_category",
687
+ message=f"Extension category {category.name} is empty",
688
+ severity="warning",
689
+ component="enums",
690
+ recommendation=f"Add extensions to {category.name} or remove unused category",
691
+ )
692
+ )
693
+
694
+ for category in CompoundExtensionCategory:
695
+ if not category.value:
696
+ errors.append(
697
+ ConfigValidationError(
698
+ error_type="empty_enum_category",
699
+ message=f"Compound extension category {category.name} is empty",
700
+ severity="warning",
701
+ component="enums",
702
+ recommendation=f"Add extensions to {category.name} or remove unused category",
703
+ )
704
+ )
705
+
706
+ for category in UnicodeAttackCategory:
707
+ if not category.value:
708
+ errors.append(
709
+ ConfigValidationError(
710
+ error_type="empty_enum_category",
711
+ message=f"Unicode attack category {category.name} is empty",
712
+ severity="warning",
713
+ component="enums",
714
+ recommendation=f"Add Unicode characters to {category.name} or remove unused category",
715
+ )
716
+ )
717
+
718
+ # Check for overlapping extensions between categories
719
+ all_extensions_by_category = {}
720
+ for category in DangerousExtensionCategory:
721
+ all_extensions_by_category[category.name] = category.value
722
+
723
+ for cat1_name, cat1_exts in all_extensions_by_category.items():
724
+ for cat2_name, cat2_exts in all_extensions_by_category.items():
725
+ if cat1_name != cat2_name:
726
+ overlap = cat1_exts.intersection(cat2_exts)
727
+ if overlap:
728
+ errors.append(
729
+ ConfigValidationError(
730
+ error_type="category_overlap",
731
+ message=f"Categories {cat1_name} and {cat2_name} share extensions: {overlap}",
732
+ severity="info",
733
+ component="enums",
734
+ recommendation="Consider if extensions should belong to multiple categories",
735
+ )
736
+ )
737
+
738
+ return errors
739
+
740
+ @classmethod
741
+ def _validate_cross_dependencies(cls) -> list[ConfigValidationError]:
742
+ """
743
+ Validate cross-field configuration constraints.
744
+
745
+ Returns:
746
+ List of detected configuration issues.
747
+ """
748
+ errors = []
749
+
750
+ # Check Windows reserved names format
751
+ for name in cls.WINDOWS_RESERVED_NAMES:
752
+ if not name.islower():
753
+ errors.append(
754
+ ConfigValidationError(
755
+ error_type="case_sensitive_reserved_name",
756
+ message=f"Windows reserved name '{name}' should be lowercase",
757
+ severity="warning",
758
+ component="reserved_names",
759
+ recommendation="Use lowercase for consistent case-insensitive matching",
760
+ )
761
+ )
762
+
763
+ # Validate Unicode character ranges
764
+ for char_code in cls.DANGEROUS_UNICODE_CHARS:
765
+ if not isinstance(char_code, int):
766
+ errors.append(
767
+ ConfigValidationError(
768
+ error_type="invalid_unicode_char",
769
+ message=f"Unicode character code {char_code} is not an integer",
770
+ severity="error",
771
+ component="unicode",
772
+ recommendation="Use integer Unicode code points",
773
+ )
774
+ )
775
+ elif char_code < 0 or char_code > 0x10FFFF:
776
+ errors.append(
777
+ ConfigValidationError(
778
+ error_type="invalid_unicode_range",
779
+ message=f"Unicode character code {char_code} is outside valid range",
780
+ severity="error",
781
+ component="unicode",
782
+ recommendation="Use valid Unicode code points (0-0x10FFFF)",
783
+ )
784
+ )
785
+
786
+ return errors
787
+
788
+ @classmethod
789
+ def validate_and_report(cls, strict: bool = True) -> None:
790
+ """
791
+ Validate configuration and log outcomes.
792
+
793
+ Args:
794
+ strict: If True, raise on errors/warnings.
795
+
796
+ Raises:
797
+ FileSecurityConfigurationError: If strict and issues found.
798
+ """
799
+ errors = cls.validate_configuration(strict=strict)
800
+
801
+ if not errors:
802
+ logger.info("File security configuration validation passed")
803
+ return
804
+
805
+ # Separate errors by severity
806
+ error_list = [e for e in errors if e.severity == "error"]
807
+ warning_list = [e for e in errors if e.severity == "warning"]
808
+ info_list = [e for e in errors if e.severity == "info"]
809
+
810
+ # Log validation results
811
+ if error_list:
812
+ for error in error_list:
813
+ logger.error(
814
+ "Configuration error in %s: %s. %s",
815
+ error.component,
816
+ error.message,
817
+ error.recommendation,
818
+ )
819
+
820
+ if warning_list:
821
+ for warning in warning_list:
822
+ logger.warning(
823
+ "Configuration warning in %s: %s. %s",
824
+ warning.component,
825
+ warning.message,
826
+ warning.recommendation,
827
+ )
828
+
829
+ if info_list:
830
+ for info in info_list:
831
+ logger.info(
832
+ "Configuration info in %s: %s. %s",
833
+ info.component,
834
+ info.message,
835
+ info.recommendation,
836
+ )
837
+
838
+ # Raise exception if there are errors and strict mode is enabled
839
+ if error_list and strict:
840
+ raise FileSecurityConfigurationError(error_list)
841
+ elif (error_list or warning_list) and strict:
842
+ raise FileSecurityConfigurationError(error_list + warning_list)