From bb13367f769917bd5e8a38596104927a31fbc114 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:26:18 +0100 Subject: [PATCH 01/16] chore: update safeuploads version to 1.1.1 in uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 2606b5f..ef8f08c 100644 --- a/uv.lock +++ b/uv.lock @@ -975,7 +975,7 @@ wheels = [ [[package]] name = "safeuploads" -version = "1.1.0" +version = "1.1.1" source = { editable = "." } dependencies = [ { name = "defusedxml" }, From e98faec19c0dc60a68b1868ccaf23ca22b0e9686 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:40:44 +0100 Subject: [PATCH 02/16] feat: implement resource monitoring for gzip and zip inspectors, enhance image dimension validation, and add tests for resource limits - Introduced ResourceMonitor to enforce time and memory limits during gzip and zip content inspections. - Updated GzipContentInspector and ZipContentInspector to accept an optional ResourceMonitor parameter for real-time checks. - Enhanced validation logic to raise ResourceLimitError when resource limits are exceeded during inspection. - Added tests to ensure that resource limits are enforced correctly in both gzip and zip inspectors. - Implemented image dimension validation to reject images with excessive pixel counts or zero dimensions. - Added tests for image dimension validation, including checks for PNG and JPEG formats. - Updated existing tests to incorporate new validation logic and ensure compatibility with resource monitoring. --- CHANGELOG.md | 37 +++++ README.md | 37 ++++- docs/index.md | 39 ++++- docs/security/threat-model.md | 46 +++++- examples/fastapi_example.py | 43 ++++-- safeuploads/__init__.py | 2 + safeuploads/config.py | 22 +++ safeuploads/exceptions.py | 43 ++++++ safeuploads/file_validator.py | 142 ++++++++++++++++-- safeuploads/inspectors/gzip_inspector.py | 14 ++ safeuploads/inspectors/zip_inspector.py | 83 +++++++++- safeuploads/utils.py | 133 +++++++++++++++- .../validators/compression_validator.py | 47 +++++- tests/conftest.py | 19 ++- tests/fuzz/test_fuzz_images.py | 2 + tests/inspectors/test_content_inspector.py | 15 +- tests/inspectors/test_gzip_inspector.py | 19 +++ tests/inspectors/test_zip_inspector.py | 112 +++++++++++++- tests/test_config_validation.py | 16 ++ tests/test_file_validator.py | 131 +++++++++++++++- tests/test_performance.py | 8 +- tests/test_utils.py | 92 +++++++++++- .../validators/test_compression_validator.py | 47 ++++++ 23 files changed, 1082 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6cf1b..7108857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ The format is based on project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Image decompression bomb detection. PNG `IHDR` and JPEG + start-of-frame headers are parsed and the declared pixel count is + bounded by the new `max_image_pixels` limit (default 89,478,485, + matching Pillow's `MAX_IMAGE_PIXELS`). Breaches raise the new + `ImageSecurityError`. +- ZIP entries are now rejected when their name carries an extension + from `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, or + `SYSTEM_FILES`. Every dot-separated suffix is checked, so a + disguised name such as `invoice.php.txt` is caught. The threat + model documented this mitigation but it was not implemented. +- `ResourceMonitor.check()`, which enforces the wall-clock and memory + budgets together. + +### Changed + +- **Potentially breaking:** the validation time and memory budgets are + now enforced *during* validation instead of only on completion. + `ResourceMonitor` is threaded through the streaming reads, the ZIP + entry loop, recursive nested-archive inspection, strict + decompression verification, and the gzip inflation loop, so a + runaway upload is aborted while it runs. Uploads that previously + completed after exceeding the budget now raise `ResourceLimitError` + earlier. +- `ResourceLimitError` now propagates out of the ZIP and gzip + inspectors instead of being wrapped as an internal + `FileProcessingError`. +- Documentation and the FastAPI example no longer return `str(err)` to + clients. Exception messages embed the client-supplied filename, so + reflecting them hands attacker-controlled bytes back to the browser; + the examples now log the detail and return `err.error_code`. +- The `ResourceMonitor` memory limit is documented as a coarse, + process-wide upper bound rather than a per-validation measurement. + ## [1.1.1] - 2026-08-19 ### Changed diff --git a/README.md b/README.md index e356179..b9c91c4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f - Filename sanitization and Unicode security checks - Extension validation with configurable allow/block lists - ZIP bomb detection, nested archive inspection, and recursive structure protection +- Dangerous ZIP entry rejection (executables, scripts, system files) +- Image decompression bomb detection via declared pixel dimensions - MIME type verification with file signature validation - Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing - Gzip archive validation with decompression bomb detection @@ -51,9 +53,12 @@ validator = FileValidator() async def upload_image(file: UploadFile): try: await validator.validate_image_file(file) - except FileValidationError as e: - raise HTTPException(status_code=400, detail=str(e)) - + except FileValidationError as err: + # Return the machine-readable code, never `str(err)`: exception + # messages embed the client-supplied filename, so reflecting them + # hands attacker-controlled bytes back to the browser. + raise HTTPException(status_code=400, detail=err.error_code) + return {"status": "success", "filename": file.filename} ``` @@ -68,6 +73,7 @@ validator = FileValidator() # Or customize limits config = FileSecurityConfig() config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB +config.limits.max_image_pixels = 50_000_000 # Reject bigger decoded images config.limits.max_compression_ratio = 50 # Opt in to strict ZIP checking: decompress every entry to @@ -87,22 +93,36 @@ pooled_validator = FileValidator( ## Exception Handling +Exception messages are written for your logs, not for your users. They +embed the client-supplied filename and other untrusted values, so never +return `str(err)` to a client. Branch on the exception type and surface +`err.error_code`, which is a stable machine-readable string. + ```python +import logging + from safeuploads.exceptions import ( FileValidationError, # Base exception FileSizeError, # File too large ExtensionSecurityError, # Dangerous extension + ImageSecurityError, # Image decompression bomb ZipBombError, # Compression attack ) +logger = logging.getLogger(__name__) + try: await validator.validate_image_file(file) except FileSizeError as err: return {"error": "File too large", "max_size": err.max_size} except ExtensionSecurityError as err: - return {"error": "File type not allowed", "extension": err.extension} + return {"error": "File type not allowed", "code": err.error_code} +except ImageSecurityError as err: + return {"error": "Image too large to decode", "code": err.error_code} except FileValidationError as err: - return {"error": str(err), "code": err.error_code} + # Full detail goes to the log; the client only sees the code. + logger.warning("Upload rejected: %s", err) + return {"error": "Upload rejected", "code": err.error_code} ``` ## Current Status @@ -112,10 +132,11 @@ except FileValidationError as err: - **Filename Security**: Unicode normalization, directory traversal prevention, Windows reserved names blocking - **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection - **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification -- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits +- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits, plus rejection of entries whose extension is an executable, script, or system file +- **Image Bomb Protection**: PNG and JPEG headers are parsed and the declared pixel count is bounded by `max_image_pixels` - **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip - **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files -- **Resource Monitoring**: CPU time and memory limits enforced via `ResourceMonitor` +- **Resource Monitoring**: Wall-clock and memory limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs - **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing - **Gzip Support**: Gzip archive validation with decompression bomb detection - **Content Analysis**: Optional malware signature, web shell, and polyglot file detection @@ -128,6 +149,8 @@ except FileValidationError as err: - No built-in rate limiting (application-level concern — see documentation) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` +- Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected +- Memory accounting uses the process-wide peak RSS, so it is a coarse upper bound rather than a per-validation measurement - `SpooledTemporaryFile` uses the system default temp directory ## Documentation diff --git a/docs/index.md b/docs/index.md index cf19d05..509c72d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,11 +20,13 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f - Filename sanitization and Unicode security checks - Extension validation with configurable allow/block lists - ZIP bomb detection, nested archive inspection, and recursive structure protection +- Dangerous ZIP entry rejection (executables, scripts, system files) +- Image decompression bomb detection via declared pixel dimensions - MIME type verification with file signature validation - Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing - Gzip archive validation with decompression bomb detection - Streaming validation for memory-efficient large file processing -- Resource monitoring (CPU time and memory limits) +- Resource monitoring (wall-clock and memory limits) enforced inside the validation loops - Content analysis with malware signature and polyglot detection - Structured audit logging with correlation IDs - Rich exception hierarchy with machine-readable error codes @@ -55,9 +57,12 @@ validator = FileValidator() async def upload_image(file: UploadFile): try: await validator.validate_image_file(file) - except FileValidationError as e: - raise HTTPException(status_code=400, detail=str(e)) - + except FileValidationError as err: + # Return the machine-readable code, never `str(err)`: exception + # messages embed the client-supplied filename, so reflecting them + # hands attacker-controlled bytes back to the browser. + raise HTTPException(status_code=400, detail=err.error_code) + return {"status": "success", "filename": file.filename} ``` @@ -72,6 +77,7 @@ validator = FileValidator() # Or customize limits config = FileSecurityConfig() config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB +config.limits.max_image_pixels = 50_000_000 # Reject bigger decoded images config.limits.max_compression_ratio = 50 # Opt in to strict ZIP checking: decompress every entry to @@ -91,22 +97,36 @@ pooled_validator = FileValidator( ## Exception Handling +Exception messages are written for your logs, not for your users. They +embed the client-supplied filename and other untrusted values, so never +return `str(err)` to a client. Branch on the exception type and surface +`err.error_code`, which is a stable machine-readable string. + ```python +import logging + from safeuploads.exceptions import ( FileValidationError, # Base exception FileSizeError, # File too large ExtensionSecurityError, # Dangerous extension + ImageSecurityError, # Image decompression bomb ZipBombError, # Compression attack ) +logger = logging.getLogger(__name__) + try: await validator.validate_image_file(file) except FileSizeError as err: return {"error": "File too large", "max_size": err.max_size} except ExtensionSecurityError as err: - return {"error": "File type not allowed", "extension": err.extension} + return {"error": "File type not allowed", "code": err.error_code} +except ImageSecurityError as err: + return {"error": "Image too large to decode", "code": err.error_code} except FileValidationError as err: - return {"error": str(err), "code": err.error_code} + # Full detail goes to the log; the client only sees the code. + logger.warning("Upload rejected: %s", err) + return {"error": "Upload rejected", "code": err.error_code} ``` ## Current Status @@ -116,10 +136,11 @@ except FileValidationError as err: - **Filename Security**: Unicode normalization, directory traversal prevention, Windows reserved names blocking - **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection - **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification -- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits +- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits, plus rejection of entries whose extension is an executable, script, or system file +- **Image Bomb Protection**: PNG and JPEG headers are parsed and the declared pixel count is bounded by `max_image_pixels` - **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip - **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files -- **Resource Monitoring**: CPU time and memory limits enforced via `ResourceMonitor` +- **Resource Monitoring**: Wall-clock and memory limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs - **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing - **Gzip Support**: Gzip archive validation with decompression bomb detection - **Content Analysis**: Optional malware signature, web shell, and polyglot file detection @@ -132,6 +153,8 @@ except FileValidationError as err: - No built-in rate limiting (application-level concern — see [Rate Limiting](rate-limiting.md) guide) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` +- Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected +- Memory accounting uses the process-wide peak RSS, so it is a coarse upper bound rather than a per-validation measurement - `SpooledTemporaryFile` uses the system default temp directory ## Documentation diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index c4a3f09..8323ab6 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -166,9 +166,13 @@ hidden inside ZIP archives. **Mitigations:** -- Entry extensions are checked against - `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, and - `SYSTEM_FILES`. +- `ZipContentInspector._check_dangerous_extension()` rejects any + entry whose name carries an extension from + `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, or + `SYSTEM_FILES`. Every dot-separated suffix is checked, so a + disguised name such as `invoice.php.txt` is still rejected. + This check is metadata-level and runs even when + `scan_zip_content=False`. - Binary content is scanned for executable magic bytes from `SuspiciousFilePattern.EXECUTABLE_SIGNATURES`. - Text content is scanned for script injection patterns @@ -188,6 +192,29 @@ arbitrary system files when extracted. ## File Content Attacks +### Image Decompression Bombs (CWE-409) + +**Attack:** A small PNG or JPEG that declares enormous pixel +dimensions. A ~10 KB file claiming 30000x30000 passes every +byte-size check but expands to several gigabytes in any +downstream decoder (Pillow, ImageMagick, a browser). + +**Mitigations:** + +- `FileValidator` parses the declared dimensions directly from + the header: the PNG `IHDR` chunk, or the first JPEG + start-of-frame segment. +- `width * height` is bounded by `max_image_pixels` (default + 89,478,485, matching Pillow's `MAX_IMAGE_PIXELS`). Breaches + raise `ImageSecurityError` with + `IMAGE_DIMENSIONS_EXCEEDED`. +- The header is searched across the first 1 MiB, so padding the + EXIF block to push the frame header past the MIME sample does + not bypass the check. +- The check fails closed: an image whose dimensions cannot be + read, or which declares a zero dimension, is rejected with + `IMAGE_DIMENSIONS_UNREADABLE`. + ### MIME Type Mismatch (CWE-434) **Attack:** A file with a `.jpg` extension but containing @@ -277,13 +304,24 @@ paths (e.g., ZIP with many entries, deeply nested structures). **Mitigations:** - `ResourceMonitor` enforces `max_validation_time_seconds` - (default 30 s) using `time.monotonic()`. + (default 30 s) using `time.monotonic()`. The budget is + checked on every chunk of the streaming reads, every ZIP + entry, every recursive nesting step, and every gzip chunk, so + a runaway file is aborted while it runs rather than reported + after the fact. - ZIP analysis has its own `zip_analysis_timeout` (default 5 s), compared against `time.monotonic()` on each entry during iteration. - `max_zip_entries` (default 10,000) caps per-archive entry count. +**Memory accounting caveat:** the memory limit samples the +process-wide peak RSS (`ru_maxrss`), a monotonic high-water +mark. It is a coarse upper bound, not a per-validation +measurement, and under concurrency it may attribute another +request's allocation to this one. Treat it as defence in depth +behind the byte-size limits, not as a precise control. + ### Gzip Decompression Bombs **Attack:** A small gzip file that decompresses to massive diff --git a/examples/fastapi_example.py b/examples/fastapi_example.py index cc51ba9..8fe3b8d 100644 --- a/examples/fastapi_example.py +++ b/examples/fastapi_example.py @@ -10,6 +10,7 @@ pip install slowapi """ +import logging from concurrent.futures import ThreadPoolExecutor import uvicorn @@ -32,6 +33,7 @@ FileProcessingError, FileSizeError, FileValidationError, + ImageSecurityError, MimeTypeError, ResourceLimitError, UnicodeSecurityError, @@ -40,6 +42,8 @@ ZipContentError, ) +logger = logging.getLogger(__name__) + # Initialize FastAPI app app = FastAPI( title="SafeUploads FastAPI Example", @@ -89,7 +93,13 @@ async def file_validation_exception_handler(request, exc: FileValidationError): Converts safeuploads exceptions to HTTP responses with appropriate status codes and detailed error information. + + Exception messages embed the client-supplied filename, so they are + logged rather than returned. Clients receive a static message plus + the machine-readable ``error_code``. """ + logger.warning("Upload rejected: %r", exc) + # Map exception types to HTTP status codes status_code = status.HTTP_400_BAD_REQUEST @@ -97,15 +107,24 @@ async def file_validation_exception_handler(request, exc: FileValidationError): if isinstance(exc, FileSizeError): detail = { "error": "file_too_large", - "message": str(exc), + "message": "File exceeds the configured size limit.", "size": exc.size, "max_size": exc.max_size, "error_code": exc.error_code, } + elif isinstance(exc, ImageSecurityError): + detail = { + "error": "image_too_large", + "message": "Image is too large once decoded.", + "width": exc.width, + "height": exc.height, + "max_pixels": exc.max_pixels, + "error_code": exc.error_code, + } elif isinstance(exc, MimeTypeError): detail = { "error": "invalid_mime_type", - "message": str(exc), + "message": "File content type is not allowed.", "detected_mime": exc.detected_mime, "allowed_mimes": list(exc.allowed_mimes), "error_code": exc.error_code, @@ -113,15 +132,14 @@ async def file_validation_exception_handler(request, exc: FileValidationError): elif isinstance(exc, ZipBombError): detail = { "error": "zip_bomb_detected", - "message": str(exc), + "message": "Archive expands beyond the allowed limits.", "compression_ratio": exc.compression_ratio, "error_code": exc.error_code, } elif isinstance(exc, ZipContentError): detail = { "error": "dangerous_zip_content", - "message": str(exc), - "threats": exc.threats, + "message": "Archive contains disallowed entries.", "error_code": exc.error_code, } elif isinstance( @@ -134,15 +152,14 @@ async def file_validation_exception_handler(request, exc: FileValidationError): ): detail = { "error": "filename_security_violation", - "message": str(exc), - "filename": exc.filename, + "message": "Filename failed security validation.", "error_code": exc.error_code, } else: # Generic file validation error detail = { "error": "validation_failed", - "message": str(exc), + "message": "Upload failed validation.", "error_code": getattr(exc, "error_code", None), } @@ -158,12 +175,14 @@ async def file_processing_exception_handler(request, exc: FileProcessingError): from FileProcessingError rather than FileValidationError, so they need their own handler. """ + logger.warning("Upload processing failed: %r", exc) + if isinstance(exc, ResourceLimitError): return JSONResponse( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content={ "error": "resource_limit_exceeded", - "message": str(exc), + "message": "Validation exceeded its resource budget.", "error_code": exc.error_code, }, ) @@ -172,7 +191,7 @@ async def file_processing_exception_handler(request, exc: FileProcessingError): status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ "error": "processing_error", - "message": str(exc), + "message": "File could not be processed.", "error_code": exc.error_code, }, ) @@ -215,7 +234,7 @@ async def upload_image_strict(file: UploadFile): ) max_size_mb = e.max_size / 1024 / 1024 else: - message = str(e) + message = "Image exceeds the configured size limit." max_size_mb = None raise HTTPException( @@ -344,11 +363,11 @@ async def upload_multiple(files: list[UploadFile]): ) except FileValidationError as e: # Continue processing other files even if one fails + logger.warning("Upload rejected in batch: %r", e) results.append( { "filename": file.filename, "status": "failed", - "error": str(e), "error_code": getattr(e, "error_code", None), } ) diff --git a/safeuploads/__init__.py b/safeuploads/__init__.py index 788c420..f638d52 100644 --- a/safeuploads/__init__.py +++ b/safeuploads/__init__.py @@ -36,6 +36,7 @@ FileSignatureError, FileSizeError, FileValidationError, + ImageSecurityError, MimeTypeError, ResourceLimitError, UnicodeSecurityError, @@ -92,6 +93,7 @@ "FileSizeError", "MimeTypeError", "FileSignatureError", + "ImageSecurityError", "CompressionSecurityError", "ZipBombError", "ZipContentError", diff --git a/safeuploads/config.py b/safeuploads/config.py index 2491b57..bfea45f 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -55,6 +55,9 @@ class SecurityLimits: Attributes: max_image_size: Maximum size in bytes for image files. + max_image_pixels: Maximum width x height product allowed + for an image, guarding against decompression bombs + that are small on the wire but huge once decoded. max_zip_size: Maximum size in bytes for ZIP archives. max_activity_file_size: Maximum size in bytes for GPX/TCX/FIT activity files. @@ -101,6 +104,11 @@ class SecurityLimits: max_activity_file_size: int = 50 * 1024 * 1024 # 50MB for GPX/TCX/FIT max_gzip_size: int = 500 * 1024 * 1024 # 500MB for gzip files + # Decoded image size limit. Matches Pillow's default + # MAX_IMAGE_PIXELS, the de facto decompression-bomb + # threshold (~0.25GB uncompressed at 3 bytes per pixel). + max_image_pixels: int = 89_478_485 + # Streaming validation settings max_memory_buffer_size: int = ( 10 * 1024 * 1024 # 10MB before spilling to disk @@ -616,6 +624,20 @@ def _validate_file_size_limits( ) ) + # Validate decoded image size limit + if limits.max_image_pixels <= 0: + errors.append( + _config_error( + "invalid_pixel_limit", + "max_image_pixels must be greater than 0", + "file_sizes", + ( + "Set max_image_pixels to a positive" + " value (e.g., 89478485)" + ), + ) + ) + return errors @classmethod diff --git a/safeuploads/exceptions.py b/safeuploads/exceptions.py index 1349e23..b3d4aac 100644 --- a/safeuploads/exceptions.py +++ b/safeuploads/exceptions.py @@ -101,6 +101,10 @@ class ErrorCode(StrEnum): FILE_SIGNATURE_MISSING = "FILE_SIGNATURE_MISSING" FILE_SIGNATURE_MISMATCH = "FILE_SIGNATURE_MISMATCH" + # Image content errors + IMAGE_DIMENSIONS_EXCEEDED = "IMAGE_DIMENSIONS_EXCEEDED" + IMAGE_DIMENSIONS_UNREADABLE = "IMAGE_DIMENSIONS_UNREADABLE" + # Compression and ZIP errors ZIP_BOMB_DETECTED = "ZIP_BOMB_DETECTED" ZIP_CONTENT_THREAT = "ZIP_CONTENT_THREAT" @@ -388,6 +392,45 @@ def __init__( ) +class ImageSecurityError(FileValidationError): + """ + Image header declares unsafe or unreadable dimensions. + + Args: + message: Human-readable error description. + filename: Optional filename that failed the check. + width: Optional declared width in pixels. + height: Optional declared height in pixels. + max_pixels: Optional maximum allowed pixel count. + error_code: Optional error code (defaults to + IMAGE_DIMENSIONS_EXCEEDED). + + Attributes: + width: Declared width in pixels. + height: Declared height in pixels. + max_pixels: Maximum allowed pixel count. + """ + + def __init__( + self, + message: str, + filename: str | None = None, + width: int | None = None, + height: int | None = None, + max_pixels: int | None = None, + error_code: str | None = None, + ): + """Initialize with image dimension details.""" + self.width = width + self.height = height + self.max_pixels = max_pixels + super().__init__( + message, + filename=filename, + error_code=error_code or ErrorCode.IMAGE_DIMENSIONS_EXCEEDED, + ) + + # ============================================================================ # Compression and ZIP Exceptions # ============================================================================ diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index fba665b..43e8df3 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -42,13 +42,14 @@ FileSignatureError, FileSizeError, FileValidationError, + ImageSecurityError, MimeTypeError, ResourceLimitError, ) from .inspectors import ZipContentInspector from .inspectors.content_inspector import ContentSecurityInspector from .inspectors.gzip_inspector import GzipContentInspector -from .utils import ResourceMonitor, bytes_to_mb +from .utils import ResourceMonitor, bytes_to_mb, parse_image_dimensions from .validators import ( CompressionSecurityValidator, ExtensionSecurityValidator, @@ -61,6 +62,11 @@ _T = TypeVar("_T") +# A large EXIF or preview block can push a JPEG frame header +# well past the 8 KB MIME sample. Anything beyond this window +# is treated as malformed rather than scanned indefinitely. +_IMAGE_DIMENSION_SCAN_BYTES = 1024 * 1024 + class FileValidator: """ @@ -346,6 +352,68 @@ def _validate_file_signature( expected_type=expected_type, ) + def _enforce_image_dimensions( + self, dimensions: tuple[int, int] | None, filename: str + ) -> None: + """ + Reject images whose decoded pixel count is unsafe. + + Byte-size limits do not bound decoded size: a small + PNG or JPEG can declare dimensions that expand to + gigabytes in any downstream decoder. + + Args: + dimensions: Parsed ``(width, height)`` pair, or None + if the header could not be read. + filename: Sanitized filename for error context. + + Raises: + ImageSecurityError: If the dimensions are unreadable, + non-positive, or exceed ``max_image_pixels``. + """ + if dimensions is None: + logger.warning("Image dimensions unreadable for '%s'", filename) + raise ImageSecurityError( + "Image dimensions could not be read from the file header", + filename=filename, + error_code=ErrorCode.IMAGE_DIMENSIONS_UNREADABLE, + ) + + width, height = dimensions + if width <= 0 or height <= 0: + raise ImageSecurityError( + f"Image declares invalid dimensions: {width}x{height}", + filename=filename, + width=width, + height=height, + error_code=ErrorCode.IMAGE_DIMENSIONS_UNREADABLE, + ) + + max_pixels = self.config.limits.max_image_pixels + pixels = width * height + if pixels > max_pixels: + logger.warning( + "Image decompression bomb rejected: %dx%d = %d pixels" + " (max %d)", + width, + height, + pixels, + max_pixels, + ) + raise ImageSecurityError( + ( + "Image too large when decoded:" + f" {width}x{height} = {pixels} pixels." + f" Maximum: {max_pixels} pixels" + ), + filename=filename, + width=width, + height=height, + max_pixels=max_pixels, + ) + + logger.debug("Image dimensions accepted: %dx%d", width, height) + def _sanitize_filename(self, filename: str) -> str: """ Sanitize user-provided filename to prevent security risks. @@ -529,7 +597,10 @@ def _validate_file_extension( logger.debug("File extension '%s' accepted", ext) async def _validate_file_size( - self, file: UploadFile, max_file_size: int + self, + file: UploadFile, + max_file_size: int, + monitor: ResourceMonitor | None = None, ) -> tuple[bytes, int]: """ Validate uploaded file size by sampling content. @@ -539,6 +610,8 @@ async def _validate_file_size( Args: file: Uploaded file supporting asynchronous read and seek. max_file_size: Maximum allowed file size in bytes. + monitor: Optional resource monitor checked once per + chunk so a slow upload is aborted mid-read. Returns: Tuple containing first 8 KB of file content and detected file @@ -546,6 +619,8 @@ async def _validate_file_size( Raises: FileSizeError: File size exceeds maximum or file is empty. + ResourceLimitError: If the monitor's time or memory + limit is exceeded while reading. """ # Read first chunk for content analysis file_content = await file.read(8192) # Read first 8KB @@ -574,6 +649,8 @@ async def _validate_file_size( chunk_size = self.config.limits.chunk_size file_size = 0 while True: + if monitor is not None: + monitor.check() chunk = await file.read(chunk_size) if not chunk: break @@ -598,7 +675,10 @@ async def _validate_file_size( return file_content, file_size async def _stream_to_temp_file( - self, file: UploadFile, max_file_size: int + self, + file: UploadFile, + max_file_size: int, + monitor: ResourceMonitor | None = None, ) -> tuple[tempfile.SpooledTemporaryFile[bytes], int]: """ Stream uploaded file to a SpooledTemporaryFile with size validation. @@ -611,6 +691,8 @@ async def _stream_to_temp_file( Args: file: Uploaded file supporting asynchronous read/seek. max_file_size: Maximum allowed file size in bytes. + monitor: Optional resource monitor checked once per + chunk so a slow upload is aborted mid-read. Returns: Tuple of SpooledTemporaryFile positioned at start and @@ -619,6 +701,8 @@ async def _stream_to_temp_file( Raises: FileSizeError: File exceeds maximum or is empty. + ResourceLimitError: If the monitor's time or memory + limit is exceeded while reading. """ temp = tempfile.SpooledTemporaryFile( # noqa: SIM115 max_size=self.config.limits.max_memory_buffer_size @@ -630,6 +714,8 @@ async def _stream_to_temp_file( try: while True: + if monitor is not None: + monitor.check() chunk = await file.read(chunk_size) if not chunk: break @@ -810,6 +896,9 @@ async def validate_image_file(self, file: UploadFile) -> None: MimeTypeError: MIME type is not in allowed image types. FileSignatureError: File signature doesn't match expected image format. + ImageSecurityError: Decoded pixel count exceeds + ``max_image_pixels`` or the header dimensions cannot + be read. FileProcessingError: Unexpected error during validation. """ await self._run_validation(file, "image", self._validate_image_body) @@ -836,11 +925,11 @@ async def _validate_image_body(self, file: UploadFile) -> None: with ResourceMonitor( max_time_seconds=self.config.limits.max_validation_time_seconds, max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + ) as monitor: # Validate file size (raises on failure, # returns content and size on success) file_content, file_size = await self._validate_file_size( - file, self.config.limits.max_image_size + file, self.config.limits.max_image_size, monitor ) # Detect MIME type @@ -856,6 +945,16 @@ async def _validate_image_body(self, file: UploadFile) -> None: # Validate file signature (raises exceptions on failure) self._validate_file_signature(file_content, "image") + # Reject decompression bombs: a small file can declare + # dimensions that expand to gigabytes once decoded. + dimensions = parse_image_dimensions(file_content) + if dimensions is None and file_size > len(file_content): + await file.seek(0) + wider = await file.read(_IMAGE_DIMENSION_SCAN_BYTES) + await file.seek(0) + dimensions = parse_image_dimensions(wider) + self._enforce_image_dimensions(dimensions, filename) + # Optional content analysis (offloaded — scans up to # content_scan_max_size bytes and is CPU-bound) if self.config.limits.enable_content_analysis: @@ -922,17 +1021,21 @@ async def _validate_zip_body(self, file: UploadFile) -> None: with ResourceMonitor( max_time_seconds=self.config.limits.max_validation_time_seconds, max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + ) as monitor: # Stream file to SpooledTemporaryFile with size validation temp_file, file_size = await self._stream_to_temp_file( - file, self.config.limits.max_zip_size + file, self.config.limits.max_zip_size, monitor ) try: # Offload the CPU/IO-bound ZIP inspection off the loop filename = file.filename or "unknown" await self._to_thread( - self._inspect_zip_sync, temp_file, file_size, filename + self._inspect_zip_sync, + temp_file, + file_size, + filename, + monitor, ) finally: temp_file.close() @@ -942,6 +1045,7 @@ def _inspect_zip_sync( temp_file: tempfile.SpooledTemporaryFile[bytes], file_size: int, filename: str, + monitor: ResourceMonitor | None = None, ) -> None: """ Run synchronous ZIP inspection off the event loop. @@ -950,11 +1054,15 @@ def _inspect_zip_sync( temp_file: Spooled temp file holding the ZIP data. file_size: Compressed archive size in bytes. filename: Sanitized filename for context. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: MimeTypeError: If the MIME type is not allowed. FileSignatureError: If the signature mismatches. CompressionSecurityError: If a zip bomb is detected. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. FileProcessingError: If content analysis finds threats. """ # Read header for MIME/signature checks @@ -973,13 +1081,13 @@ def _inspect_zip_sync( # Validate ZIP compression ratio self.compression_validator.validate_zip_compression_ratio( - temp_file, file_size + temp_file, file_size, monitor ) # Perform ZIP content inspection if enabled if self.config.limits.scan_zip_content: temp_file.seek(0) - self.zip_inspector.inspect_zip_content(temp_file) + self.zip_inspector.inspect_zip_content(temp_file, monitor) # Optional content analysis if self.config.limits.enable_content_analysis: @@ -1042,10 +1150,11 @@ async def _validate_activity_body(self, file: UploadFile) -> None: with ResourceMonitor( max_time_seconds=self.config.limits.max_validation_time_seconds, max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + ) as monitor: temp_file, file_size = await self._stream_to_temp_file( file, self.config.limits.max_activity_file_size, + monitor, ) try: @@ -1151,10 +1260,11 @@ async def _validate_gzip_body(self, file: UploadFile) -> None: with ResourceMonitor( max_time_seconds=self.config.limits.max_validation_time_seconds, max_memory_mb=self.config.limits.max_validation_memory_mb, - ): + ) as monitor: temp_file, file_size = await self._stream_to_temp_file( file, self.config.limits.max_gzip_size, + monitor, ) try: @@ -1164,6 +1274,7 @@ async def _validate_gzip_body(self, file: UploadFile) -> None: temp_file, file_size, filename, + monitor, ) finally: temp_file.close() @@ -1173,6 +1284,7 @@ def _inspect_gzip_sync( temp_file: tempfile.SpooledTemporaryFile[bytes], file_size: int, filename: str, + monitor: ResourceMonitor | None = None, ) -> None: """ Run synchronous gzip inspection off the event loop. @@ -1181,11 +1293,15 @@ def _inspect_gzip_sync( temp_file: Spooled temp file holding the gzip data. file_size: Compressed size in bytes. filename: Sanitized filename for context. + monitor: Optional resource monitor checked once per + chunk so a slow stream is aborted mid-inflation. Raises: MimeTypeError: If the MIME type is not allowed. FileSignatureError: If the signature mismatches. ZipBombError: If a decompression bomb is detected. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. CompressionSecurityError: If the gzip is invalid. """ _, detected_mime = self._read_header_and_detect( @@ -1202,7 +1318,7 @@ def _inspect_gzip_sync( ) # Decompression bomb check - self.gzip_inspector.inspect_gzip_content(temp_file, file_size) + self.gzip_inspector.inspect_gzip_content(temp_file, file_size, monitor) logger.debug( "Gzip file validation passed: %s (%s, %s bytes)", diff --git a/safeuploads/inspectors/gzip_inspector.py b/safeuploads/inspectors/gzip_inspector.py index 85bee36..7000cc6 100644 --- a/safeuploads/inspectors/gzip_inspector.py +++ b/safeuploads/inspectors/gzip_inspector.py @@ -11,6 +11,7 @@ CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) from ..utils import bytes_to_mb @@ -18,6 +19,7 @@ if TYPE_CHECKING: from ..protocols import SeekableFile + from ..utils import ResourceMonitor logger = logging.getLogger(__name__) @@ -39,6 +41,7 @@ def inspect_gzip_content( self, file_obj: SeekableFile, compressed_size: int, + monitor: ResourceMonitor | None = None, ) -> None: """ Inspect gzip archive for decompression bombs. @@ -46,12 +49,16 @@ def inspect_gzip_content( Args: file_obj: Seekable file containing gzip data. compressed_size: Size of the compressed file in bytes. + monitor: Optional resource monitor checked once per + chunk so a slow stream is aborted mid-inflation. Raises: ZipBombError: If compression ratio or uncompressed size exceeds configured limits. CompressionSecurityError: If the gzip structure is invalid or corrupted. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. FileProcessingError: If an unexpected error occurs. """ file_obj.seek(0) @@ -67,6 +74,9 @@ def inspect_gzip_content( try: with gzip.open(file_obj, "rb") as gz: while True: + if monitor is not None: + monitor.check() + chunk = gz.read(chunk_size) if not chunk: break @@ -136,6 +146,10 @@ def inspect_gzip_content( except ZipBombError: raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be reported as an internal processing failure. + raise except gzip.BadGzipFile as err: logger.error( "Invalid or corrupted gzip file", diff --git a/safeuploads/inspectors/zip_inspector.py b/safeuploads/inspectors/zip_inspector.py index 363d4d0..ecadca6 100644 --- a/safeuploads/inspectors/zip_inspector.py +++ b/safeuploads/inspectors/zip_inspector.py @@ -16,17 +16,32 @@ SuspiciousFilePattern, ZipThreatCategory, ) -from ..exceptions import ErrorCode, FileProcessingError, ZipContentError +from ..exceptions import ( + ErrorCode, + FileProcessingError, + ResourceLimitError, + ZipContentError, +) from ..utils import find_text_pattern, matches_signature_prefix from .base import BaseInspector if TYPE_CHECKING: from ..config import FileSecurityConfig from ..protocols import SeekableFile + from ..utils import ResourceMonitor logger = logging.getLogger(__name__) +# Entry extensions that must never appear inside an accepted +# archive, keyed by the threat category they belong to so the +# rejection message names the category. +_DANGEROUS_ENTRY_CATEGORIES: tuple[ZipThreatCategory, ...] = ( + ZipThreatCategory.EXECUTABLE_FILES, + ZipThreatCategory.SCRIPT_FILES, + ZipThreatCategory.SYSTEM_FILES, +) + class ZipContentInspector(BaseInspector): """ @@ -70,18 +85,31 @@ def __init__(self, config: FileSecurityConfig): self._recursable_exts: frozenset[str] = frozenset( ZipThreatCategory.RECURSABLE_ARCHIVES.value ) + self._dangerous_entry_exts: dict[str, str] = { + ext.lower(): category.name + for category in _DANGEROUS_ENTRY_CATEGORIES + for ext in category.value + } - def inspect_zip_content(self, file_obj: SeekableFile) -> None: + def inspect_zip_content( + self, + file_obj: SeekableFile, + monitor: ResourceMonitor | None = None, + ) -> None: """ Inspect ZIP archive for potential security threats. Args: file_obj: Seekable file-like object containing ZIP data. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: ZipContentError: If security threats are detected in ZIP content such as directory traversal, symlinks, nested archives, or suspicious patterns. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. FileProcessingError: If ZIP structure is invalid or unexpected error occurs during inspection. """ @@ -98,6 +126,9 @@ def inspect_zip_content(self, file_obj: SeekableFile) -> None: # Analyze each entry in the ZIP for entry in zip_entries: + if monitor is not None: + monitor.check() + # Check for timeout if ( time.monotonic() - start_time @@ -169,11 +200,15 @@ def inspect_zip_content(self, file_obj: SeekableFile) -> None: # when nested archives are allowed if self.config.limits.allow_nested_archives: file_obj.seek(0) - self.inspect_nested_archives(file_obj) + self.inspect_nested_archives(file_obj, monitor=monitor) except ZipContentError: # Re-raise our own exceptions raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be reported as an internal processing failure. + raise except zipfile.BadZipFile as err: logger.error( "Invalid or corrupted ZIP file structure", exc_info=True @@ -256,7 +291,10 @@ def _inspect_zip_entry( ): threats.append(f"Nested archive detected: '{filename}'") - # 9. Check file content if enabled + # 9. Check for dangerous entry extensions + threats.extend(self._check_dangerous_extension(filename)) + + # 10. Check file content if enabled # Only first 512 bytes are read, so no size gate needed if self.config.limits.scan_zip_content and not entry.is_dir(): content_threats = self._inspect_entry_content(entry, zip_file) @@ -264,6 +302,30 @@ def _inspect_zip_entry( return threats + def _check_dangerous_extension(self, filename: str) -> list[str]: + """ + Check an entry name for dangerous file extensions. + + Every dot-separated suffix is checked, so a disguised + name such as ``shell.php.txt`` is still rejected. + + Args: + filename: ZIP entry name to check. + + Returns: + List of threat descriptions. + """ + basename = os.path.basename(filename).lower() + parts = basename.split(".") + for part in parts[1:]: + category = self._dangerous_entry_exts.get(f".{part}") + if category is not None: + return [ + f"Dangerous entry extension '.{part}'" + f" ({category}) in '{filename}'" + ] + return [] + def _inspect_zip_structure( self, entries: list[zipfile.ZipInfo] ) -> list[str]: @@ -499,6 +561,7 @@ def inspect_nested_archives( seen_hashes: set[str] | None = None, entry_counter: list[int] | None = None, start_time: float | None = None, + monitor: ResourceMonitor | None = None, ) -> None: """ Recursively inspect nested archives. @@ -516,10 +579,14 @@ def inspect_nested_archives( cumulative entry count shared across all recursion branches. start_time: Monotonic timestamp of initial call. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: ZipContentError: If recursive structure, quine, or complexity attack is detected. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during inspection. """ if seen_hashes is None: seen_hashes = set() @@ -578,6 +645,9 @@ def inspect_nested_archives( ) for entry in entries: + if monitor is not None: + monitor.check() + # Timeout elapsed = time.monotonic() - start_time if elapsed > timeout: @@ -625,10 +695,15 @@ def inspect_nested_archives( seen_hashes=seen_hashes, entry_counter=entry_counter, start_time=start_time, + monitor=monitor, ) except ZipContentError: raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be downgraded to a skipped branch. + raise except zipfile.BadZipFile: # A corrupt archive at this nesting level is not itself a # threat signal; log for traceability and stop descending diff --git a/safeuploads/utils.py b/safeuploads/utils.py index 1b37547..0c69452 100644 --- a/safeuploads/utils.py +++ b/safeuploads/utils.py @@ -106,6 +106,121 @@ def find_text_pattern(content: bytes, patterns: Iterable[str]) -> str | None: return None +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +# Start-of-frame markers that carry the frame dimensions. 0xC4 +# (DHT), 0xC8 (JPG) and 0xCC (DAC) share the range but are not +# start-of-frame markers, so they are excluded. +_JPEG_SOF_MARKERS: frozenset[int] = frozenset( + { + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC5, + 0xC6, + 0xC7, + 0xC9, + 0xCA, + 0xCB, + 0xCD, + 0xCE, + 0xCF, + } +) + + +def _parse_png_dimensions(content: bytes) -> tuple[int, int] | None: + """ + Read width and height from a PNG IHDR chunk. + + Args: + content: Raw bytes starting at the PNG signature. + + Returns: + ``(width, height)`` tuple, or None if the IHDR chunk is + missing or truncated. + """ + # IHDR is required to be the first chunk: 8-byte signature, + # 4-byte length, 4-byte type, then two big-endian uint32. + if len(content) < 24 or content[12:16] != b"IHDR": + return None + width = int.from_bytes(content[16:20], "big") + height = int.from_bytes(content[20:24], "big") + return width, height + + +def _parse_jpeg_dimensions(content: bytes) -> tuple[int, int] | None: + """ + Walk JPEG segments to the first start-of-frame marker. + + Args: + content: Raw bytes starting at the SOI marker. + + Returns: + ``(width, height)`` tuple, or None if no start-of-frame + segment is present before the entropy-coded data. + """ + pos = 2 # Skip the SOI marker. + total = len(content) + + while pos + 3 < total: + # Segments are contiguous; anything else is malformed. + if content[pos] != 0xFF: + return None + + marker = content[pos + 1] + + # 0xFF fill bytes may pad the gap before a marker. + if marker == 0xFF: + pos += 1 + continue + + # Standalone markers carry no length field. + if marker == 0x01 or 0xD0 <= marker <= 0xD8: + pos += 2 + continue + + # EOI or the start of scan data: no frame header found. + if marker in (0xD9, 0xDA): + return None + + segment_len = int.from_bytes(content[pos + 2 : pos + 4], "big") + if segment_len < 2: + return None + + if marker in _JPEG_SOF_MARKERS: + # SOF payload: precision, height, width. + sof = content[pos + 4 : pos + 9] + if len(sof) < 5: + return None + height = int.from_bytes(sof[1:3], "big") + width = int.from_bytes(sof[3:5], "big") + return width, height + + pos += 2 + segment_len + + return None + + +def parse_image_dimensions(content: bytes) -> tuple[int, int] | None: + """ + Extract pixel dimensions from a PNG or JPEG header. + + Args: + content: Leading bytes of the image file. + + Returns: + ``(width, height)`` tuple, or None if the format is + unsupported or the header is malformed or truncated. + """ + if content.startswith(_PNG_SIGNATURE): + return _parse_png_dimensions(content) + if content.startswith(b"\xff\xd8"): + return _parse_jpeg_dimensions(content) + return None + + class ResourceMonitor: """ Context manager that enforces wall-clock and memory limits. @@ -115,9 +230,10 @@ class ResourceMonitor: process peak RSS (``ru_maxrss``), a monotonic high-water mark for the whole process, so the reported delta is a coarse, best-effort upper bound rather than the exact - memory used by this validation. Call ``check_time`` and - ``check_memory`` inside long loops for early enforcement; - otherwise limits are checked on context exit. + memory used by this validation. Call ``check`` (or the + individual ``check_time`` / ``check_memory``) inside long + loops so a runaway operation is aborted while it runs; + otherwise limits are only checked on context exit. Attributes: max_time_seconds: Maximum allowed wall-clock seconds. @@ -211,6 +327,17 @@ def check_memory(self) -> None: delta = max(0, self._get_peak_rss_bytes() - self.start_memory) self._raise_if_memory_exceeded(delta) + def check(self) -> None: + """ + Check both time and memory limits mid-operation. + + Raises: + ResourceLimitError: If the wall-clock or memory limit + has been exceeded since context entry. + """ + self.check_time() + self.check_memory() + def _raise_if_time_exceeded(self, elapsed: float) -> None: """ Raise if elapsed wall-clock time exceeds the limit. diff --git a/safeuploads/validators/compression_validator.py b/safeuploads/validators/compression_validator.py index 7d66f28..b2dc627 100644 --- a/safeuploads/validators/compression_validator.py +++ b/safeuploads/validators/compression_validator.py @@ -13,6 +13,7 @@ CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) from ..utils import bytes_to_mb @@ -21,6 +22,7 @@ if TYPE_CHECKING: from ..config import FileSecurityConfig from ..protocols import SeekableFile + from ..utils import ResourceMonitor logger = logging.getLogger(__name__) @@ -47,7 +49,10 @@ def __init__(self, config: FileSecurityConfig): ) def validate_zip_compression_ratio( - self, file_obj: SeekableFile, compressed_size: int + self, + file_obj: SeekableFile, + compressed_size: int, + monitor: ResourceMonitor | None = None, ) -> None: """ Validate ZIP archive against security limits. @@ -62,6 +67,8 @@ def validate_zip_compression_ratio( Args: file_obj: Seekable file-like object containing ZIP data. compressed_size: Size of the compressed archive in bytes. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: ZipBombError: If compression ratio exceeds maximum allowed @@ -69,6 +76,8 @@ def validate_zip_compression_ratio( CompressionSecurityError: If ZIP structure is invalid, too many entries, nested archives detected, or individual file too large. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during validation. FileProcessingError: If unexpected error occurs during validation such as memory errors or I/O errors. """ @@ -123,6 +132,9 @@ def validate_zip_compression_ratio( # Analyze each entry in the ZIP for entry in zip_entries: + if monitor is not None: + monitor.check() + # Check for timeout if ( time.monotonic() - start_time @@ -380,7 +392,9 @@ def validate_zip_compression_ratio( # Optional: read every entry through zipfile to # confirm the declared metadata is not forged. if self.config.limits.verify_zip_decompression: - self._verify_entries_decompress(zip_file, zip_entries) + self._verify_entries_decompress( + zip_file, zip_entries, monitor + ) # Log analysis results logger.debug( @@ -418,6 +432,10 @@ def validate_zip_compression_ratio( except (ZipBombError, CompressionSecurityError): # Re-raise our own exceptions raise + except ResourceLimitError: + # A breached time/memory budget must abort the request, + # not be reported as an internal processing failure. + raise except Exception as err: logger.error( "Unexpected error during ZIP compression validation", @@ -431,6 +449,7 @@ def _verify_entries_decompress( self, zip_file: zipfile.ZipFile, zip_entries: list[zipfile.ZipInfo], + monitor: ResourceMonitor | None = None, ) -> None: """ Decompress every entry to detect forged metadata. @@ -446,6 +465,12 @@ def _verify_entries_decompress( Args: zip_file: Open ZIP archive to verify. zip_entries: Entries listed in the archive. + monitor: Optional resource monitor checked once per + chunk so a slow archive is aborted mid-read. + + Raises: + ResourceLimitError: If the monitor's time or memory + limit is exceeded during verification. """ chunk_size = self.config.limits.chunk_size for entry in zip_entries: @@ -453,9 +478,15 @@ def _verify_entries_decompress( continue with zip_file.open(entry, "r") as stream: while stream.read(chunk_size): - pass + if monitor is not None: + monitor.check() - def validate(self, file_obj: SeekableFile, compressed_size: int) -> None: + def validate( + self, + file_obj: SeekableFile, + compressed_size: int, + monitor: ResourceMonitor | None = None, + ) -> None: """ Validate the compression ratio of a ZIP file. @@ -463,10 +494,16 @@ def validate(self, file_obj: SeekableFile, compressed_size: int) -> None: file_obj: Seekable file-like object of the ZIP. compressed_size: Size of the file after compression in bytes. + monitor: Optional resource monitor checked once per + entry so a runaway archive is aborted mid-scan. Raises: ZipBombError: If compression ratio exceeds maximum. CompressionSecurityError: If ZIP structure is invalid. + ResourceLimitError: If the monitor's time or memory + limit is exceeded during validation. FileProcessingError: If unexpected error occurs. """ - return self.validate_zip_compression_ratio(file_obj, compressed_size) + return self.validate_zip_compression_ratio( + file_obj, compressed_size, monitor + ) diff --git a/tests/conftest.py b/tests/conftest.py index 995bf82..04ec8e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,19 @@ from safeuploads.config import FileSecurityConfig, SecurityLimits +# Baseline SOF0 frame header declaring a 16x16 image. Real JPEGs +# always carry a start-of-frame segment; without one the decoded +# dimensions are unknown and validation rejects the file. +JPEG_SOF0 = ( + b"\xff\xc0" # SOF0 marker + b"\x00\x11" # Segment length (17) + b"\x08" # Sample precision + b"\x00\x10" # Height + b"\x00\x10" # Width + b"\x03" # Component count + b"\x01\x11\x00\x02\x11\x01\x03\x11\x01" # Component specs +) + @pytest.fixture def default_config() -> FileSecurityConfig: @@ -101,7 +114,8 @@ def valid_jpeg_bytes() -> bytes: Returns: Minimal valid JPEG file content. """ - # Minimal valid JPEG: SOI marker + APP0 segment + EOI marker + # Minimal valid JPEG: SOI marker + APP0 segment + SOF0 frame + # header + EOI marker return ( b"\xff\xd8\xff\xe0" # JPEG SOI + APP0 b"\x00\x10" # APP0 length @@ -110,7 +124,8 @@ def valid_jpeg_bytes() -> bytes: b"\x00" # Density units b"\x00\x01\x00\x01" # X and Y density b"\x00\x00" # Thumbnail dimensions - b"\xff\xd9" # JPEG EOI + + JPEG_SOF0 + + b"\xff\xd9" # JPEG EOI ) diff --git a/tests/fuzz/test_fuzz_images.py b/tests/fuzz/test_fuzz_images.py index c479162..0afda9c 100644 --- a/tests/fuzz/test_fuzz_images.py +++ b/tests/fuzz/test_fuzz_images.py @@ -11,6 +11,7 @@ FileValidationError, ) from safeuploads.file_validator import FileValidator +from tests.conftest import JPEG_SOF0 class _MockFile: @@ -39,6 +40,7 @@ async def seek(self, offset): # JPEG header prefix _JPEG_HDR = ( b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + + JPEG_SOF0 ) # PNG header prefix diff --git a/tests/inspectors/test_content_inspector.py b/tests/inspectors/test_content_inspector.py index 5e916c8..5b45532 100644 --- a/tests/inspectors/test_content_inspector.py +++ b/tests/inspectors/test_content_inspector.py @@ -8,6 +8,7 @@ from safeuploads.inspectors.content_inspector import ( ContentSecurityInspector, ) +from tests.conftest import JPEG_SOF0 def _sig(category: MalwareSignatureCategory) -> bytes: @@ -149,7 +150,12 @@ async def test_image_with_embedded_sig_rejected(self, mock_upload_file): b"JFIF\x00" b"\x01\x01\x00" b"\x00\x01\x00\x01" - b"\x00\x00" + b"\x00" * 50 + sig + b"\x00" * 50 + b"\xff\xd9" + b"\x00\x00" + + JPEG_SOF0 + + b"\x00" * 50 + + sig + + b"\x00" * 50 + + b"\xff\xd9" ) file = mock_upload_file(filename="bad.jpg", content=content) with pytest.raises(FileProcessingError) as exc: @@ -188,7 +194,12 @@ async def test_disabled_skips_scan(self, mock_upload_file): b"JFIF\x00" b"\x01\x01\x00" b"\x00\x01\x00\x01" - b"\x00\x00" + b"\x00" * 50 + sig + b"\x00" * 50 + b"\xff\xd9" + b"\x00\x00" + + JPEG_SOF0 + + b"\x00" * 50 + + sig + + b"\x00" * 50 + + b"\xff\xd9" ) file = mock_upload_file(filename="img.jpg", content=content) # Should pass — analysis disabled diff --git a/tests/inspectors/test_gzip_inspector.py b/tests/inspectors/test_gzip_inspector.py index b8051ab..9d358b2 100644 --- a/tests/inspectors/test_gzip_inspector.py +++ b/tests/inspectors/test_gzip_inspector.py @@ -10,9 +10,28 @@ CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) from safeuploads.inspectors.gzip_inspector import GzipContentInspector +from safeuploads.utils import ResourceMonitor + + +class TestGzipInspectionResourceLimits: + """A spent time budget aborts inflation mid-stream.""" + + def test_chunk_loop_aborts_on_time_limit(self, default_config): + """Test the per-chunk check surfaces ResourceLimitError.""" + inspector = GzipContentInspector(default_config) + payload = gzip.compress(b"x" * 1024) + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + inspector.inspect_gzip_content( + io.BytesIO(payload), len(payload), monitor + ) class TestGzipContentInspector: diff --git a/tests/inspectors/test_zip_inspector.py b/tests/inspectors/test_zip_inspector.py index 3d0f87a..109547a 100644 --- a/tests/inspectors/test_zip_inspector.py +++ b/tests/inspectors/test_zip_inspector.py @@ -9,9 +9,117 @@ from safeuploads.exceptions import ( ErrorCode, FileProcessingError, + ResourceLimitError, ZipContentError, ) from safeuploads.inspectors.zip_inspector import ZipContentInspector +from safeuploads.utils import ResourceMonitor + + +class TestDangerousEntryExtensions: + """Entry names are checked against the dangerous categories.""" + + @pytest.mark.parametrize( + "entry_name", + [ + "payload.exe", + "shell.php", + "hook.ps1", + "inject.dll", + "settings.ini", + ], + ) + def test_dangerous_entry_rejected(self, default_config, entry_name): + """Test executable, script and system entries are rejected.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr(entry_name, b"harmless looking bytes") + + with pytest.raises(ZipContentError) as exc_info: + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + assert "Dangerous entry extension" in str(exc_info.value) + + def test_disguised_double_extension_rejected(self, default_config): + """Test a dangerous extension hidden mid-name is caught.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("invoice.php.txt", b"just some text") + + with pytest.raises(ZipContentError) as exc_info: + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + assert ".php" in str(exc_info.value) + + def test_dangerous_entry_rejected_without_content_scan(self): + """Test the check is metadata-level, not content-gated.""" + config = FileSecurityConfig() + config.limits = SecurityLimits(scan_zip_content=False) + inspector = ZipContentInspector(config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("payload.exe", b"harmless looking bytes") + + with pytest.raises(ZipContentError): + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + def test_benign_entry_accepted(self, default_config): + """Test ordinary document entries still pass.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("notes.txt", b"hello") + zf.writestr("track.gpx", b"") + + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + +class TestZipInspectionResourceLimits: + """A spent time budget aborts inspection mid-scan.""" + + def test_entry_loop_aborts_on_time_limit(self, default_config): + """Test the per-entry check surfaces ResourceLimitError.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("notes.txt", b"hello") + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + inspector.inspect_zip_content( + io.BytesIO(zip_buffer.getvalue()), monitor + ) + + def test_recursive_scan_aborts_on_time_limit(self): + """Test nested inspection surfaces ResourceLimitError.""" + config = FileSecurityConfig() + config.limits = SecurityLimits(allow_nested_archives=True) + inspector = ZipContentInspector(config) + + inner = io.BytesIO() + with zipfile.ZipFile(inner, "w") as zf: + zf.writestr("notes.txt", b"hello") + + outer = io.BytesIO() + with zipfile.ZipFile(outer, "w") as zf: + zf.writestr("nested.zip", inner.getvalue()) + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + inspector.inspect_nested_archives( + io.BytesIO(outer.getvalue()), monitor=monitor + ) class TestZipContentInspector: @@ -431,7 +539,7 @@ def test_skip_content_scan_when_disabled(self): zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w") as zf: - zf.writestr("file.bin", pe_content) + zf.writestr("file.dat", pe_content) # Should not raise when content scanning disabled inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) @@ -678,7 +786,7 @@ def test_script_pattern_decode_error(self, default_config): with zipfile.ZipFile(zip_buffer, "w") as zf: # Pure binary content with no ASCII patterns # Use invalid UTF-8 sequences - zf.writestr("data.bin", b"\xff\xfe\xfd\xfc\xfb\xfa\xf9\xf8") + zf.writestr("data.dat", b"\xff\xfe\xfd\xfc\xfb\xfa\xf9\xf8") # Should handle decode errors gracefully without raising # The decode error is caught and logged but doesn't cause failure diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index cc277fe..cf9940e 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -135,6 +135,22 @@ def test_nonpositive_sanitized_name_length_generates_error( error_types = [e.error_type for e in errors if e.severity == "error"] assert "invalid_name_length" in error_types + def test_nonpositive_image_pixels_generates_error(self, monkeypatch): + """ + Test that a non-positive image pixel limit errors. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + monkeypatch.setattr( + FileSecurityConfig, + "limits", + SecurityLimits(max_image_pixels=0), + ) + errors = FileSecurityConfig.validate_configuration() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_pixel_limit" in error_types + class TestMimeConfigurationValidation: """Tests for _validate_mime_configurations validation branches.""" diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index 6e967b6..861e3bc 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -14,6 +14,7 @@ FileProcessingError, FileSignatureError, FileSizeError, + ImageSecurityError, MimeTypeError, ResourceLimitError, UnicodeSecurityError, @@ -22,6 +23,7 @@ ZipContentError, ) from safeuploads.file_validator import FileValidator +from tests.conftest import JPEG_SOF0 class TestFileValidatorInitialization: @@ -1445,7 +1447,11 @@ async def test_polyglot_after_header_is_detected(self, mock_upload_file): # Valid JPEG header, 9 KB filler, then a ZIP (GIFAR) # polyglot signature well past the 8 KB read window. jpeg = ( - b"\xff\xd8\xff\xe0" + b"\x00" * 9000 + b"PK\x03\x04" + b"\xff\xd9" + b"\xff\xd8" + + JPEG_SOF0 + + b"\x00" * 9000 + + b"PK\x03\x04" + + b"\xff\xd9" ) file = mock_upload_file(filename="poly.jpg", content=jpeg) with pytest.raises(FileProcessingError, match="Content analysis"): @@ -1455,11 +1461,132 @@ async def test_polyglot_after_header_is_detected(self, mock_upload_file): async def test_clean_large_image_passes(self, mock_upload_file): """A clean image larger than 8 KB passes analysis.""" validator = self._content_analysis_validator() - jpeg = b"\xff\xd8\xff\xe0" + b"\x00" * 9000 + b"\xff\xd9" + jpeg = b"\xff\xd8" + JPEG_SOF0 + b"\x00" * 9000 + b"\xff\xd9" file = mock_upload_file(filename="clean.jpg", content=jpeg) await validator.validate_image_file(file) +def _png_with_dimensions(width: int, height: int) -> bytes: + """Build a PNG whose IHDR declares the given dimensions.""" + return ( + b"\x89PNG\r\n\x1a\n" + + b"\x00\x00\x00\x0d" + + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + + b"\x08\x02\x00\x00\x00" + + b"\x00\x00\x00\x00" + ) + + +def _jpeg_with_dimensions(width: int, height: int) -> bytes: + """Build a JPEG whose SOF0 declares the given dimensions.""" + return ( + b"\xff\xd8" + b"\xff\xc0\x00\x11\x08" + + height.to_bytes(2, "big") + + width.to_bytes(2, "big") + + b"\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01" + + b"\xff\xd9" + ) + + +class TestImageDimensionValidation: + """Decoded pixel count is bounded independently of byte size.""" + + @pytest.mark.asyncio + async def test_png_pixel_bomb_rejected(self, mock_upload_file): + """A tiny PNG declaring huge dimensions is rejected.""" + validator = FileValidator() + # ~40 bytes on the wire, ~3.6 GB once decoded. + content = _png_with_dimensions(30000, 30000) + file = mock_upload_file(filename="bomb.png", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_EXCEEDED + assert exc_info.value.width == 30000 + assert exc_info.value.height == 30000 + + @pytest.mark.asyncio + async def test_jpeg_pixel_bomb_rejected(self, mock_upload_file): + """A tiny JPEG declaring huge dimensions is rejected.""" + validator = FileValidator() + content = _jpeg_with_dimensions(20000, 20000) + file = mock_upload_file(filename="bomb.jpg", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_EXCEEDED + + @pytest.mark.asyncio + async def test_within_pixel_limit_passes(self, mock_upload_file): + """A normally sized image is accepted.""" + validator = FileValidator() + content = _png_with_dimensions(1920, 1080) + file = mock_upload_file(filename="photo.png", content=content) + + await validator.validate_image_file(file) + + @pytest.mark.asyncio + async def test_custom_pixel_limit_enforced(self, mock_upload_file): + """A tightened max_image_pixels is honoured.""" + config = FileSecurityConfig() + config.limits = SecurityLimits(max_image_pixels=1000) + validator = FileValidator(config=config) + content = _png_with_dimensions(100, 100) + file = mock_upload_file(filename="photo.png", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert exc_info.value.max_pixels == 1000 + + @pytest.mark.asyncio + async def test_zero_dimensions_rejected(self, mock_upload_file): + """An image declaring a zero dimension is rejected.""" + validator = FileValidator() + content = _png_with_dimensions(0, 100) + file = mock_upload_file(filename="empty.png", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert ( + exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_UNREADABLE + ) + + @pytest.mark.asyncio + async def test_missing_frame_header_rejected(self, mock_upload_file): + """A JPEG with no frame header fails closed.""" + validator = FileValidator() + content = ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00" + b"\x00\x01\x00\x01\x00\x00\xff\xd9" + ) + file = mock_upload_file(filename="headerless.jpg", content=content) + + with pytest.raises(ImageSecurityError) as exc_info: + await validator.validate_image_file(file) + + assert ( + exc_info.value.error_code == ErrorCode.IMAGE_DIMENSIONS_UNREADABLE + ) + + @pytest.mark.asyncio + async def test_frame_header_past_sample_window(self, mock_upload_file): + """A frame header behind a large EXIF block is still found.""" + validator = FileValidator() + # APP0 segment large enough to push SOF0 past the 8 KB sample. + app0 = b"\xff\xe0" + (9000).to_bytes(2, "big") + b"\x00" * 8998 + content = b"\xff\xd8" + app0 + JPEG_SOF0 + b"\xff\xd9" + file = mock_upload_file(filename="bigexif.jpg", content=content) + + await validator.validate_image_file(file) + + class TestConcurrentValidation: """Validation is offloaded to threads and safe under load.""" diff --git a/tests/test_performance.py b/tests/test_performance.py index a6666e7..c74710f 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -15,6 +15,7 @@ from safeuploads import FileValidator from safeuploads.config import FileSecurityConfig, SecurityLimits +from tests.conftest import JPEG_SOF0 def create_test_image(size_kb: int) -> bytes: @@ -56,10 +57,13 @@ def create_test_image(size_kb: int) -> bytes: # Add padding to reach desired size target_size = size_kb * 1024 - padding_size = max(0, target_size - len(jpeg_header) - len(jpeg_footer)) + padding_size = max( + 0, + target_size - len(jpeg_header) - len(JPEG_SOF0) - len(jpeg_footer), + ) padding = b"\x00" * padding_size - return jpeg_header + padding + jpeg_footer + return jpeg_header + JPEG_SOF0 + padding + jpeg_footer def create_test_zip(num_files: int, file_size_kb: int = 1) -> bytes: diff --git a/tests/test_utils.py b/tests/test_utils.py index 9b5b9c0..b56563b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,7 +8,83 @@ ErrorCode, ResourceLimitError, ) -from safeuploads.utils import ResourceMonitor +from safeuploads.utils import ResourceMonitor, parse_image_dimensions +from tests.conftest import JPEG_SOF0 + + +def _png(width: int, height: int) -> bytes: + """Build a PNG header declaring the given dimensions.""" + return ( + b"\x89PNG\r\n\x1a\n" + + b"\x00\x00\x00\x0d" + + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + ) + + +class TestParseImageDimensions: + """Tests for PNG/JPEG dimension extraction.""" + + def test_png_dimensions(self): + """Test PNG IHDR dimensions are read.""" + assert parse_image_dimensions(_png(1920, 1080)) == (1920, 1080) + + def test_png_truncated_returns_none(self): + """Test truncated PNG header yields no dimensions.""" + assert parse_image_dimensions(_png(10, 10)[:20]) is None + + def test_png_without_ihdr_returns_none(self): + """Test PNG whose first chunk is not IHDR is rejected.""" + content = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IDAT" + b"\x00" * 8 + ) + assert parse_image_dimensions(content) is None + + def test_jpeg_dimensions(self): + """Test JPEG SOF0 dimensions are read.""" + assert parse_image_dimensions(b"\xff\xd8" + JPEG_SOF0) == (16, 16) + + def test_jpeg_skips_app_segment(self): + """Test segments before the frame header are skipped.""" + app0 = b"\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + content = b"\xff\xd8" + app0 + JPEG_SOF0 + assert parse_image_dimensions(content) == (16, 16) + + def test_jpeg_tolerates_fill_bytes(self): + """Test 0xFF padding before a marker is skipped.""" + content = b"\xff\xd8" + b"\xff" * 4 + JPEG_SOF0 + assert parse_image_dimensions(content) == (16, 16) + + def test_jpeg_skips_standalone_marker(self): + """Test standalone markers carry no length field.""" + content = b"\xff\xd8" + b"\xff\xd0" + JPEG_SOF0 + assert parse_image_dimensions(content) == (16, 16) + + def test_jpeg_desynchronised_returns_none(self): + """Test a non-marker byte where a segment must start.""" + content = b"\xff\xd8" + b"\x00\x11\x22\x33" + JPEG_SOF0 + assert parse_image_dimensions(content) is None + + @pytest.mark.parametrize("marker", [b"\xff\xd9", b"\xff\xda"]) + def test_jpeg_scan_end_before_frame(self, marker): + """Test EOI or SOS before any frame header.""" + content = b"\xff\xd8" + marker + JPEG_SOF0 + assert parse_image_dimensions(content) is None + + def test_jpeg_invalid_segment_length(self): + """Test a segment length below the minimum is rejected.""" + content = b"\xff\xd8" + b"\xff\xe0\x00\x01" + JPEG_SOF0 + assert parse_image_dimensions(content) is None + + def test_jpeg_truncated_frame_header(self): + """Test a frame header cut short yields no dimensions.""" + content = b"\xff\xd8" + JPEG_SOF0[:6] + b"\x00\x00" + assert parse_image_dimensions(content) is None + + def test_unsupported_format_returns_none(self): + """Test a non-PNG, non-JPEG payload yields no dimensions.""" + assert parse_image_dimensions(b"GIF89a" + b"\x00" * 32) is None class TestResourceMonitorInit: @@ -26,6 +102,20 @@ def test_custom_values(self): assert monitor.max_time_seconds == 5.0 assert monitor.max_memory_bytes == 128 * 1024 * 1024 + def test_check_enforces_time_budget(self): + """Test check() raises once the time budget is spent.""" + with ( + pytest.raises(ResourceLimitError) as exc_info, + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + monitor.check() + assert exc_info.value.error_code == ErrorCode.RESOURCE_TIME_EXCEEDED + + def test_check_passes_within_budget(self): + """Test check() is a no-op while within limits.""" + with ResourceMonitor(max_time_seconds=30.0) as monitor: + monitor.check() + class TestResourceMonitorTime: """Test wall-clock time monitoring.""" diff --git a/tests/validators/test_compression_validator.py b/tests/validators/test_compression_validator.py index 6730cdd..fd5074c 100644 --- a/tests/validators/test_compression_validator.py +++ b/tests/validators/test_compression_validator.py @@ -11,13 +11,60 @@ CompressionSecurityError, ErrorCode, FileProcessingError, + ResourceLimitError, ZipBombError, ) +from safeuploads.utils import ResourceMonitor from safeuploads.validators.compression_validator import ( CompressionSecurityValidator, ) +class TestCompressionResourceLimits: + """A spent time budget aborts analysis mid-scan.""" + + @staticmethod + def _archive() -> bytes: + """Build a small multi-entry archive.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + zf.writestr("a.txt", b"a" * 512) + zf.writestr("b.txt", b"b" * 512) + return buffer.getvalue() + + def test_entry_loop_aborts_on_time_limit(self, default_config): + """Test the per-entry check surfaces ResourceLimitError.""" + validator = CompressionSecurityValidator(default_config) + payload = self._archive() + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + ): + validator.validate_zip_compression_ratio( + io.BytesIO(payload), len(payload), monitor + ) + + def test_decompression_check_aborts_on_time_limit(self): + """Test strict verification surfaces ResourceLimitError.""" + config = FileSecurityConfig() + config.limits = SecurityLimits( + verify_zip_decompression=True, + chunk_size=16, + ) + validator = CompressionSecurityValidator(config) + payload = self._archive() + + with ( + pytest.raises(ResourceLimitError), + ResourceMonitor(max_time_seconds=0.0) as monitor, + zipfile.ZipFile(io.BytesIO(payload), "r") as zip_file, + ): + validator._verify_entries_decompress( + zip_file, zip_file.infolist(), monitor + ) + + class TestCompressionSecurityValidator: """Test suite for CompressionSecurityValidator.""" From b19ee7af7d0403ca725083aa0b2214b2706034fd Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:23:45 +0100 Subject: [PATCH 03/16] feat: Enhance security and performance of compression and XML validators - Implement timeout for Gzip inflation to prevent decompression bombs. - Introduce safe_label utility to escape untrusted text for logging. - Improve ZipContentInspector to use safe_label for filenames in threat messages. - Add XML root element validation to ensure compliance with expected formats. - Enforce limits on XML element counts to prevent excessive resource usage. - Update tests to cover new features and ensure robustness against edge cases. - Validate configuration limits for gzip timeout and XML element cap. --- CHANGELOG.md | 58 +++++-- README.md | 14 +- docs/index.md | 14 +- docs/security/integration-checklist.md | 45 ++++- docs/security/threat-model.md | 92 +++++++++-- safeuploads/audit.py | 21 ++- safeuploads/config.py | 63 ++++++- safeuploads/exceptions.py | 19 ++- safeuploads/file_validator.py | 61 ++++--- safeuploads/inspectors/content_inspector.py | 17 +- safeuploads/inspectors/gzip_inspector.py | 28 +++- safeuploads/inspectors/zip_inspector.py | 52 +++--- safeuploads/utils.py | 150 ++++++++++++----- .../validators/compression_validator.py | 16 +- safeuploads/validators/extension_validator.py | 5 +- safeuploads/validators/unicode_validator.py | 21 ++- safeuploads/validators/windows_validator.py | 5 +- safeuploads/validators/xml_validator.py | 154 +++++++++++++++--- tests/inspectors/test_gzip_inspector.py | 21 +++ tests/inspectors/test_zip_inspector.py | 23 ++- tests/test_config_validation.py | 32 ++++ tests/test_utils.py | 153 ++++++++++++++++- tests/validators/test_xml_validator.py | 105 ++++++++++-- 23 files changed, 961 insertions(+), 208 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7108857..5c6032b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,17 +23,51 @@ project adheres to model documented this mitigation but it was not implemented. - `ResourceMonitor.check()`, which enforces the wall-clock and memory budgets together. +- Activity XML files must now declare the root element matching their + extension: `.gpx` requires a `gpx` root, `.tcx` requires a + `TrainingCenterDatabase` root. Namespaces are stripped before + matching. Arbitrary XML (including an HTML or SVG payload) wearing a + `.gpx` name is rejected with the new `XML_INVALID_ROOT` code. +- `max_xml_elements` limit (default 1,000,000). XML is now parsed + incrementally and completed elements are discarded as they close, so + a flat document with millions of elements can no longer amplify a + bounded upload into an unbounded object graph. +- `gzip_analysis_timeout` limit (default 5 s) bounding gzip inflation + independently of any caller-supplied `ResourceMonitor`. +- `safe_label()` utility, applied to every untrusted filename and ZIP + entry name before it reaches a log record, audit event, or exception + message. ### Changed -- **Potentially breaking:** the validation time and memory budgets are - now enforced *during* validation instead of only on completion. - `ResourceMonitor` is threaded through the streaming reads, the ZIP - entry loop, recursive nested-archive inspection, strict - decompression verification, and the gzip inflation loop, so a - runaway upload is aborted while it runs. Uploads that previously - completed after exceeding the budget now raise `ResourceLimitError` - earlier. +- **Breaking:** `max_validation_memory_mb` is no longer enforced by + default. It samples the process-wide peak RSS, which never decreases + and misattributes concurrent work, so exceeding it is now logged as + a warning instead of failing the validation. Set the new + `enforce_memory_limit=True` (or + `ResourceMonitor(enforce_memory=True)`) to restore the previous + behaviour, and only in a process that validates one upload at a + time. The real memory bounds are the byte limits in + `SecurityLimits`. +- **Fixed (log injection, CWE-117):** a filename containing a newline + could forge an audit log line, and directional or zero-width + characters could hide the real name from an analyst. Untrusted text + is now escaped at every logging site and again at the audit + emission point. Unicode validation errors report the offending code + point and its Unicode name instead of echoing the character. +- `find_text_pattern()` scans raw bytes with a cached compiled pattern + instead of decoding and lower-casing the whole buffer, removing two + full-size copies of the content-analysis window (up to 50 MB each). +- `FileProcessingError` accepts an optional `error_code`, and XML + failures now carry `XML_MALFORMED`, `XML_FORBIDDEN_CONSTRUCT`, + `XML_INVALID_ROOT`, or `XML_TOO_MANY_ELEMENTS`. +- **Potentially breaking:** the validation time budget is now enforced + *during* validation instead of only on completion. `ResourceMonitor` + is threaded through the streaming reads, the ZIP entry loop, + recursive nested-archive inspection, strict decompression + verification, and the gzip inflation loop, so a runaway upload is + aborted while it runs. Uploads that previously completed after + exceeding the budget now raise `ResourceLimitError` earlier. - `ResourceLimitError` now propagates out of the ZIP and gzip inspectors instead of being wrapped as an internal `FileProcessingError`. @@ -41,8 +75,12 @@ project adheres to clients. Exception messages embed the client-supplied filename, so reflecting them hands attacker-controlled bytes back to the browser; the examples now log the detail and return `err.error_code`. -- The `ResourceMonitor` memory limit is documented as a coarse, - process-wide upper bound rather than a per-validation measurement. +- `verify_zip_decompression` was reviewed and its default retained. + Enabling it by default would inflate every archive on every upload; + the integration checklist now spells out exactly when to turn it on + (any consumer that does not extract with Python's `zipfile`). +- `ZipContentInspector._contains_script_patterns()` no longer takes a + `filename` argument, which it never used. ## [1.1.1] - 2026-08-19 diff --git a/README.md b/README.md index b9c91c4..a9a56aa 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f - Dangerous ZIP entry rejection (executables, scripts, system files) - Image decompression bomb detection via declared pixel dimensions - MIME type verification with file signature validation -- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing +- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing and root-element enforcement - Gzip archive validation with decompression bomb detection - Streaming validation for memory-efficient large file processing -- Resource monitoring (CPU time and memory limits) +- Wall-clock limits enforced inside the validation loops +- Log-injection-safe logging of untrusted filenames - Content analysis with malware signature and polyglot detection - Structured audit logging with correlation IDs - Rich exception hierarchy with machine-readable error codes @@ -136,9 +137,9 @@ except FileValidationError as err: - **Image Bomb Protection**: PNG and JPEG headers are parsed and the declared pixel count is bounded by `max_image_pixels` - **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip - **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files -- **Resource Monitoring**: Wall-clock and memory limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs -- **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing -- **Gzip Support**: Gzip archive validation with decompression bomb detection +- **Resource Monitoring**: Wall-clock limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs. Memory is best-effort telemetry (see Known Limitations) +- **Activity File Support**: GPX, TCX, and FIT validation with XXE-safe XML parsing, a required root element per extension, and a cap on parsed element count +- **Gzip Support**: Gzip archive validation with decompression bomb detection and an inflation timeout - **Content Analysis**: Optional malware signature, web shell, and polyglot file detection - **Audit Logging**: Structured security event logging with correlation IDs via `contextvars` - **Performance Optimizations**: Pre-compiled pattern sets, `frozenset` lookups, LRU-cached MIME guessing @@ -150,7 +151,8 @@ except FileValidationError as err: - No built-in rate limiting (application-level concern — see documentation) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` - Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected -- Memory accounting uses the process-wide peak RSS, so it is a coarse upper bound rather than a per-validation measurement +- `max_validation_memory_mb` is best-effort telemetry, not a limit: it samples the process-wide peak RSS, so it cannot be attributed to a single validation. Exceeding it is logged; set `enforce_memory_limit=True` to enforce, and only in a process that validates one upload at a time +- `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives - `SpooledTemporaryFile` uses the system default temp directory ## Documentation diff --git a/docs/index.md b/docs/index.md index 509c72d..1d1d24e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,10 +23,11 @@ Secure file upload validation for Python 3.13+ applications. Catches dangerous f - Dangerous ZIP entry rejection (executables, scripts, system files) - Image decompression bomb detection via declared pixel dimensions - MIME type verification with file signature validation -- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing +- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing and root-element enforcement - Gzip archive validation with decompression bomb detection - Streaming validation for memory-efficient large file processing -- Resource monitoring (wall-clock and memory limits) enforced inside the validation loops +- Wall-clock limits enforced inside the validation loops +- Log-injection-safe logging of untrusted filenames - Content analysis with malware signature and polyglot detection - Structured audit logging with correlation IDs - Rich exception hierarchy with machine-readable error codes @@ -140,9 +141,9 @@ except FileValidationError as err: - **Image Bomb Protection**: PNG and JPEG headers are parsed and the declared pixel count is bounded by `max_image_pixels` - **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip - **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files -- **Resource Monitoring**: Wall-clock and memory limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs -- **Activity File Support**: GPX, TCX, and FIT file validation with XXE-safe XML parsing -- **Gzip Support**: Gzip archive validation with decompression bomb detection +- **Resource Monitoring**: Wall-clock limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs. Memory is best-effort telemetry (see Known Limitations) +- **Activity File Support**: GPX, TCX, and FIT validation with XXE-safe XML parsing, a required root element per extension, and a cap on parsed element count +- **Gzip Support**: Gzip archive validation with decompression bomb detection and an inflation timeout - **Content Analysis**: Optional malware signature, web shell, and polyglot file detection - **Audit Logging**: Structured security event logging with correlation IDs via `contextvars` - **Performance Optimizations**: Pre-compiled pattern sets, `frozenset` lookups, LRU-cached MIME guessing @@ -154,7 +155,8 @@ except FileValidationError as err: - No built-in rate limiting (application-level concern — see [Rate Limiting](rate-limiting.md) guide) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` - Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected -- Memory accounting uses the process-wide peak RSS, so it is a coarse upper bound rather than a per-validation measurement +- `max_validation_memory_mb` is best-effort telemetry, not a limit: it samples the process-wide peak RSS, so it cannot be attributed to a single validation. Exceeding it is logged; set `enforce_memory_limit=True` to enforce, and only in a process that validates one upload at a time +- `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives (see [Integration Checklist](security/integration-checklist.md)) - `SpooledTemporaryFile` uses the system default temp directory ## Documentation diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index 2369127..77dbf0a 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -35,13 +35,51 @@ that addresses it. - `max_gzip_size` — set if accepting gzip files. - `max_compression_ratio` — default 100:1 is reasonable for most workloads; lower for stricter environments. + - `max_xml_elements` — default 1,000,000; lower if you only + accept small GPX/TCX files. + - `gzip_analysis_timeout` — default 5 s for gzip inflation. - `max_validation_time_seconds` — default 30 s; lower in latency-sensitive services. - - `max_validation_memory_mb` — default 512 MB; adjust based - on container memory limits. + - `max_validation_memory_mb` — default 512 MB. This is + **telemetry, not a limit** (see below). - [ ] Allowed extensions and MIME types reviewed and narrowed to only what your application accepts. +## ZIP Metadata Verification + +safeuploads reads the declared entry sizes from the ZIP central +directory, which an attacker controls. `verify_zip_decompression` +is **off by default** because enabling it decompresses every +entry, costing up to `max_uncompressed_size` of inflation per +upload. That default is safe only because of who consumes the +archive afterwards: + +- [ ] Determine how your application extracts the archive. + - Python's `zipfile` caps reads at the declared size and + raises `BadZipFile` on the resulting CRC mismatch, so + forged metadata cannot bomb it. The default is fine. + - Anything that inflates the raw DEFLATE stream directly + (`zlib`), or an external tool that trusts local headers + (`unzip`, `7z`), is **not** protected by the declared + sizes. Set `verify_zip_decompression=True`. +- [ ] If enabling it, confirm `max_validation_time_seconds` is + large enough for the archive sizes you accept, since the + whole archive is now inflated during validation. + +## Memory Enforcement + +- [ ] Leave `enforce_memory_limit` at its default (`False`) + unless the process validates one upload at a time. The + underlying metric is the process-wide peak RSS, so under + concurrency it attributes other requests' allocations to this + one and will reject legitimate uploads. +- [ ] Rely on the byte limits (`max_image_size`, `max_zip_size`, + `max_uncompressed_size`, `max_memory_buffer_size`, + `content_scan_max_size`, `max_xml_elements`) as the real + memory bound, plus a container memory limit. +- [ ] Alert on the "memory budget exceeded (not enforced)" + warning rather than treating it as a control. + ## Content Analysis - [ ] `enable_content_analysis` set to `True` if accepting @@ -100,8 +138,7 @@ that addresses it. - [ ] Container or process memory limits set — safeuploads `max_validation_memory_mb` should be below the container - limit. -- [ ] Request timeout configured at the reverse proxy and + limit.- [ ] Request timeout configured at the reverse proxy and application level — should be above `max_validation_time_seconds`. - [ ] Disk space monitored for temporary file spill diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 8323ab6..d1ad30d 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -264,8 +264,7 @@ Windows shortcuts) embedded within uploaded files. ### XML External Entity Injection (CWE-611) **Attack:** GPX and TCX files are XML-based; malicious DTD -declarations can trigger external entity resolution, leading -to server-side file reads or SSRF. +declarations can trigger external entity resolution, leadingto server-side file reads or SSRF. **Mitigations:** @@ -276,6 +275,41 @@ to server-side file reads or SSRF. `ExternalReferenceForbidden` are caught and reported as validation failures. +### Arbitrary XML Behind an Activity Extension + +**Attack:** Well-formed XML is not a GPX file. An attacker +uploads `` named `track.gpx`; +it passes the `` is roughly twelve +million elements, and a full DOM of those costs an order of +magnitude more memory than the file itself. + +**Mitigations:** + +- Parsing is incremental (`iterparse`); completed elements and + the accumulated root children are discarded as they close, so + peak memory stays flat regardless of document length. +- The element count is capped by `max_xml_elements` + (default 1,000,000), raising `XML_TOO_MANY_ELEMENTS`. + --- ## Resource Exhaustion @@ -290,11 +324,15 @@ significantly during validation consumes all available memory. - Streaming validation via `SpooledTemporaryFile` keeps memory usage under `max_memory_buffer_size` (default 10 MB) by spilling to disk for larger files. -- `ResourceMonitor` tracks memory delta via - `resource.getrusage()` and enforces - `max_validation_memory_mb` (default 512 MB). +- Every buffer the library allocates is bounded by an explicit + byte limit: `chunk_size`, `content_scan_max_size`, + `max_uncompressed_size`, and `max_xml_elements`. - File size is enforced progressively during chunked reads, not after loading the entire file. +- `ResourceMonitor` additionally reports peak-RSS growth against + `max_validation_memory_mb`. See the caveat under CPU + Exhaustion: this is telemetry, not a limit, unless + `enforce_memory_limit` is set. ### CPU Exhaustion (CWE-400) @@ -315,12 +353,18 @@ paths (e.g., ZIP with many entries, deeply nested structures). - `max_zip_entries` (default 10,000) caps per-archive entry count. -**Memory accounting caveat:** the memory limit samples the +**Memory accounting caveat:** the memory budget samples the process-wide peak RSS (`ru_maxrss`), a monotonic high-water -mark. It is a coarse upper bound, not a per-validation -measurement, and under concurrency it may attribute another -request's allocation to this one. Treat it as defence in depth -behind the byte-size limits, not as a precise control. +mark. It cannot be attributed to a single validation: after the +first peak the measured delta is near zero, and under +concurrency it picks up other requests' allocations. It is +therefore **best-effort telemetry, not a limit** — exceeding +`max_validation_memory_mb` is logged, not enforced. Set +`enforce_memory_limit=True` to make it fail the validation, and +only do so in a process that validates one upload at a time. +The real memory bounds are structural: `max_memory_buffer_size`, +`chunk_size`, `content_scan_max_size`, `max_uncompressed_size` +and `max_xml_elements` cap every buffer the library allocates. ### Gzip Decompression Bombs @@ -328,17 +372,43 @@ behind the byte-size limits, not as a precise control. size, similar to ZIP bombs. **Mitigations:** - - `GzipContentInspector` reads gzip streams in chunks, checking the compression ratio and uncompressed size against `SecurityLimits` progressively. - Exceeding either limit raises a validation error immediately, without reading the rest of the stream. +- Inflation is additionally bounded by `gzip_analysis_timeout` + (default 5 s), so a stream that stays inside the ratio and + size limits still cannot burn unbounded CPU. The bound does + not depend on the caller supplying a `ResourceMonitor`. --- ## Audit & Observability +### Log Injection (CWE-117) + +**Attack:** A filename or ZIP entry name containing a newline +(`upload.jpg\nWARNING forged entry`) forges an extra log line, +or uses directional and zero-width characters to hide the real +name from an analyst reading the log. + +**Mitigations:** + +- `safe_label()` escapes control, format, surrogate and + line-separator characters to `\uXXXX` and bounds the length + before any untrusted text reaches a log record, an audit + event, or an exception message. +- The raw client filename is escaped in `FileValidator` before + the first audit event is emitted, which happens before any + sanitization has run. +- `SecurityAuditLogger.log_event()` escapes the filename, + result and details fields again at the emission point, so + every caller is covered regardless of how the event was + built. +- Unicode validation errors report the offending code point and + its Unicode name rather than echoing the character itself. + ### Undetected Security Events (CWE-778) **Attack:** Security-relevant events (validation failures, diff --git a/safeuploads/audit.py b/safeuploads/audit.py index 2480ad8..eef7a6d 100644 --- a/safeuploads/audit.py +++ b/safeuploads/audit.py @@ -22,6 +22,8 @@ from enum import Enum from typing import Any +from .utils import safe_label + # ---------------------------------------------------------------- # Context variable for correlation ID # ---------------------------------------------------------------- @@ -164,20 +166,27 @@ def log_event(self, event: AuditEvent) -> None: """ Emit an audit event as a structured log record. + Untrusted fields are escaped here so a crafted filename + cannot forge or hide inside a log line, regardless of + which caller built the event. + Args: event: The audit event to record. """ if not self.enabled: return + filename = safe_label(event.filename) + result = safe_label(event.result, max_length=512) + extra = { "audit_event_type": event.event_type.value, "audit_correlation_id": event.correlation_id, - "audit_filename": event.filename, - "audit_result": event.result, - "audit_details": event.details, + "audit_filename": filename, + "audit_result": result, + "audit_details": safe_label(event.details, max_length=1024), "audit_duration_ms": event.duration_ms, - "audit_source_ip": event.source_ip or "", + "audit_source_ip": safe_label(event.source_ip or ""), } level = logging.INFO @@ -193,8 +202,8 @@ def log_event(self, event: AuditEvent) -> None: "[%s] %s file=%s result=%s", event.correlation_id[:12], event.event_type.value, - event.filename, - event.result, + filename, + result, extra=extra, ) diff --git a/safeuploads/config.py b/safeuploads/config.py index bfea45f..172691d 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -65,8 +65,14 @@ class SecurityLimits: max_memory_buffer_size: Bytes kept in memory before a streamed upload spills to a temporary file on disk. chunk_size: Chunk size in bytes for streaming reads. - max_validation_memory_mb: Maximum memory in MB allowed - during a single validation. + max_validation_memory_mb: Peak-RSS growth budget in MB + for a single validation. Best-effort telemetry only + unless ``enforce_memory_limit`` is set. + enforce_memory_limit: Whether exceeding + ``max_validation_memory_mb`` fails the validation + instead of logging a warning. Off by default because + peak RSS is process-wide and misattributes + concurrent work. max_validation_time_seconds: Overall validation timeout in seconds. max_compression_ratio: Maximum expansion ratio for ZIP files. @@ -74,6 +80,10 @@ class SecurityLimits: max_individual_file_size: Maximum size of single file in ZIP. max_zip_entries: Maximum number of file entries in ZIP. zip_analysis_timeout: Maximum seconds for ZIP analysis. + gzip_analysis_timeout: Maximum seconds spent inflating a + gzip stream during inspection. + max_xml_elements: Maximum number of elements parsed from + an XML activity file before it is rejected. max_zip_depth: Maximum directory nesting depth in ZIP. max_filename_length: Maximum length for filenames in ZIP. max_path_length: Maximum length for full paths in ZIP. @@ -116,7 +126,12 @@ class SecurityLimits: chunk_size: int = 65536 # 64KB chunks for streaming reads # Resource monitoring limits - max_validation_memory_mb: int = 512 # Max MB during validation + max_validation_memory_mb: int = 512 # Peak-RSS growth budget + # Peak RSS is a process-wide high-water mark, so it cannot be + # attributed to one validation under concurrency. Enforcement + # is opt-in and only sound when the process validates one + # upload at a time. + enforce_memory_limit: bool = False max_validation_time_seconds: float = ( 30.0 # Overall validation timeout in seconds ) @@ -133,6 +148,14 @@ class SecurityLimits: zip_analysis_timeout: float = ( 5.0 # Maximum seconds to spend analyzing ZIP structure ) + gzip_analysis_timeout: float = ( + 5.0 # Maximum seconds to spend inflating a gzip stream + ) + + # XML activity file limits. Entity expansion is blocked by + # defusedxml, but a flat document with millions of elements + # still costs CPU, so cap the element count. + max_xml_elements: int = 1_000_000 # ZIP content inspection settings max_zip_depth: int = 10 # Maximum nesting depth for directories in ZIP @@ -185,6 +208,8 @@ class FileSecurityConfig: ALLOWED_ACTIVITY_EXTENSIONS: Permitted activity file extensions. ALLOWED_GZIP_EXTENSIONS: Permitted gzip file extensions. + ACTIVITY_XML_ROOTS: Required XML root element per + activity extension. BLOCKED_EXTENSIONS: Dangerous file extensions to block. COMPOUND_BLOCKED_EXTENSIONS: Multi-part extensions to block. DANGEROUS_UNICODE_CHARS: Unicode characters for filename attacks. @@ -248,6 +273,14 @@ class FileSecurityConfig: ) ALLOWED_GZIP_EXTENSIONS: ClassVar[frozenset[str]] = frozenset({".gz"}) + # Required root element per XML activity format, lower-cased + # and namespace-stripped. Guards against an arbitrary XML + # document (or an HTML/SVG payload) wearing a .gpx name. + ACTIVITY_XML_ROOTS: ClassVar[dict[str, str]] = { + ".gpx": "gpx", + ".tcx": "trainingcenterdatabase", + } + # Generate dangerous file extensions from categorized enums @staticmethod def _generate_blocked_extensions() -> frozenset[str]: @@ -638,6 +671,20 @@ def _validate_file_size_limits( ) ) + # Validate XML element cap + if limits.max_xml_elements <= 0: + errors.append( + _config_error( + "invalid_xml_element_limit", + "max_xml_elements must be greater than 0", + "file_sizes", + ( + "Set max_xml_elements to a positive" + " value (e.g., 1000000)" + ), + ) + ) + return errors @classmethod @@ -987,6 +1034,16 @@ def _validate_compression_settings( ) ) + if limits.gzip_analysis_timeout <= 0: + errors.append( + _config_error( + "invalid_timeout", + "gzip_analysis_timeout must be greater than 0", + "compression", + "Set a reasonable timeout for gzip inflation", + ) + ) + return errors @classmethod diff --git a/safeuploads/exceptions.py b/safeuploads/exceptions.py index b3d4aac..d5154cd 100644 --- a/safeuploads/exceptions.py +++ b/safeuploads/exceptions.py @@ -105,6 +105,12 @@ class ErrorCode(StrEnum): IMAGE_DIMENSIONS_EXCEEDED = "IMAGE_DIMENSIONS_EXCEEDED" IMAGE_DIMENSIONS_UNREADABLE = "IMAGE_DIMENSIONS_UNREADABLE" + # XML content errors + XML_MALFORMED = "XML_MALFORMED" + XML_FORBIDDEN_CONSTRUCT = "XML_FORBIDDEN_CONSTRUCT" + XML_INVALID_ROOT = "XML_INVALID_ROOT" + XML_TOO_MANY_ELEMENTS = "XML_TOO_MANY_ELEMENTS" + # Compression and ZIP errors ZIP_BOMB_DETECTED = "ZIP_BOMB_DETECTED" ZIP_CONTENT_THREAT = "ZIP_CONTENT_THREAT" @@ -531,15 +537,24 @@ class FileProcessingError(FileSecurityError): Args: message: Human-readable error description. original_error: Optional original exception that was caught. + error_code: Optional error code (defaults to + PROCESSING_ERROR). Attributes: original_error: The original exception that was caught. """ - def __init__(self, message: str, original_error: Exception | None = None): + def __init__( + self, + message: str, + original_error: Exception | None = None, + error_code: str | None = None, + ): """Initialize with original error.""" self.original_error = original_error - super().__init__(message, error_code=ErrorCode.PROCESSING_ERROR) + super().__init__( + message, error_code=error_code or ErrorCode.PROCESSING_ERROR + ) # ============================================================================ diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index 43e8df3..7c0de36 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -49,7 +49,12 @@ from .inspectors import ZipContentInspector from .inspectors.content_inspector import ContentSecurityInspector from .inspectors.gzip_inspector import GzipContentInspector -from .utils import ResourceMonitor, bytes_to_mb, parse_image_dimensions +from .utils import ( + ResourceMonitor, + bytes_to_mb, + parse_image_dimensions, + safe_label, +) from .validators import ( CompressionSecurityValidator, ExtensionSecurityValidator, @@ -155,6 +160,20 @@ def __init__( err, ) + def _monitor(self) -> ResourceMonitor: + """ + Build a resource monitor from the active configuration. + + Returns: + Monitor carrying the configured time budget and the + opt-in memory enforcement flag. + """ + return ResourceMonitor( + max_time_seconds=self.config.limits.max_validation_time_seconds, + max_memory_mb=self.config.limits.max_validation_memory_mb, + enforce_memory=self.config.limits.enforce_memory_limit, + ) + async def _to_thread(self, func: Callable[..., _T], *args: object) -> _T: """ Run a blocking callable in a worker thread. @@ -839,14 +858,16 @@ async def _run_validation( unexpected internal error. """ cid = set_correlation_id() - filename = file.filename or "unknown" + # The raw client filename reaches the log before any + # sanitization has run, so escape it here. + filename = safe_label(file.filename or "unknown") self._audit.start(filename, cid) logger.debug("Starting %s file validation: %s", file_type, filename) t0 = time.monotonic() try: await body(file) ms = (time.monotonic() - t0) * 1000 - self._audit.success(file.filename or filename, cid, ms) + self._audit.success(safe_label(file.filename or filename), cid, ms) except ( FileValidationError, ResourceLimitError, @@ -854,16 +875,16 @@ async def _run_validation( ) as exc: ms = (time.monotonic() - t0) * 1000 self._audit.failure( - file.filename or filename, + safe_label(file.filename or filename), cid, ms, - str(exc), + safe_label(str(exc), max_length=512), ) raise except Exception as err: ms = (time.monotonic() - t0) * 1000 self._audit.failure( - file.filename or filename, + safe_label(file.filename or filename), cid, ms, "internal_error", @@ -922,10 +943,7 @@ async def _validate_image_body(self, file: UploadFile) -> None: file, self.config.ALLOWED_IMAGE_EXTENSIONS ) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ) as monitor: + with self._monitor() as monitor: # Validate file size (raises on failure, # returns content and size on success) file_content, file_size = await self._validate_file_size( @@ -1018,10 +1036,7 @@ async def _validate_zip_body(self, file: UploadFile) -> None: # Validate file extension (raises exceptions on failure) self._validate_file_extension(file, self.config.ALLOWED_ZIP_EXTENSIONS) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ) as monitor: + with self._monitor() as monitor: # Stream file to SpooledTemporaryFile with size validation temp_file, file_size = await self._stream_to_temp_file( file, self.config.limits.max_zip_size, monitor @@ -1147,10 +1162,7 @@ async def _validate_activity_body(self, file: UploadFile) -> None: self.config.ALLOWED_ACTIVITY_EXTENSIONS, ) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ) as monitor: + with self._monitor() as monitor: temp_file, file_size = await self._stream_to_temp_file( file, self.config.limits.max_activity_file_size, @@ -1207,9 +1219,13 @@ def _inspect_activity_sync( error_code=ErrorCode.MIME_TYPE_MISMATCH, ) - # XXE-safe XML validation for GPX/TCX + # XXE-safe XML validation for GPX/TCX. The root element + # must match the extension, so an arbitrary XML document + # cannot be accepted under a .gpx name. if not is_fit: - self.xml_validator.validate_xml_safety(temp_file) + self.xml_validator.validate_xml_safety( + temp_file, self.config.ACTIVITY_XML_ROOTS.get(ext) + ) logger.debug( "Activity file validation passed: %s (%s, %s bytes)", @@ -1257,10 +1273,7 @@ async def _validate_gzip_body(self, file: UploadFile) -> None: self.config.ALLOWED_GZIP_EXTENSIONS, ) - with ResourceMonitor( - max_time_seconds=self.config.limits.max_validation_time_seconds, - max_memory_mb=self.config.limits.max_validation_memory_mb, - ) as monitor: + with self._monitor() as monitor: temp_file, file_size = await self._stream_to_temp_file( file, self.config.limits.max_gzip_size, diff --git a/safeuploads/inspectors/content_inspector.py b/safeuploads/inspectors/content_inspector.py index a14c280..c59b4a5 100644 --- a/safeuploads/inspectors/content_inspector.py +++ b/safeuploads/inspectors/content_inspector.py @@ -13,7 +13,7 @@ from ..audit import get_correlation_id, log_extra from ..enums import MalwareSignatureCategory, SuspiciousFilePattern -from ..utils import find_embedded_signature, find_text_pattern +from ..utils import find_embedded_signature, find_text_pattern, safe_label from .base import BaseInspector if TYPE_CHECKING: @@ -95,34 +95,35 @@ def scan_content( means content is clean. """ threats: list[str] = [] + label = safe_label(filename) - logger.debug("Scanning content of '%s' for embedded threats", filename) + logger.debug("Scanning content of '%s' for embedded threats", label) # 1. Executable signature scan - threats.extend(self._check_executable_signatures(content, filename)) + threats.extend(self._check_executable_signatures(content, label)) # 2. Script injection scan - threats.extend(self._check_script_patterns(content, filename)) + threats.extend(self._check_script_patterns(content, label)) # 3. Polyglot detection - threats.extend(self._check_polyglot(content, filename, expected_type)) + threats.extend(self._check_polyglot(content, label, expected_type)) if threats: logger.warning( "Content analysis threats detected in '%s': %s", - filename, + label, "; ".join(threats), extra=log_extra(), ) cid = get_correlation_id() if cid: self._audit.threat( - filename, + label, cid, "; ".join(threats), ) else: - logger.debug("Content scan clean for '%s'", filename) + logger.debug("Content scan clean for '%s'", label) return threats diff --git a/safeuploads/inspectors/gzip_inspector.py b/safeuploads/inspectors/gzip_inspector.py index 7000cc6..5d0646a 100644 --- a/safeuploads/inspectors/gzip_inspector.py +++ b/safeuploads/inspectors/gzip_inspector.py @@ -4,6 +4,7 @@ import gzip import logging +import time from typing import TYPE_CHECKING from ..audit import get_correlation_id, log_extra @@ -54,7 +55,8 @@ def inspect_gzip_content( Raises: ZipBombError: If compression ratio or uncompressed - size exceeds configured limits. + size exceeds configured limits, or inflation + exceeds ``gzip_analysis_timeout``. CompressionSecurityError: If the gzip structure is invalid or corrupted. ResourceLimitError: If the monitor's time or memory @@ -66,6 +68,8 @@ def inspect_gzip_content( chunk_size = self.config.limits.chunk_size max_ratio = self.config.limits.max_compression_ratio max_uncompressed = self.config.limits.max_uncompressed_size + timeout = self.config.limits.gzip_analysis_timeout + start_time = time.monotonic() logger.debug( "Inspecting gzip stream (compressed size %d bytes)", compressed_size, @@ -77,6 +81,28 @@ def inspect_gzip_content( if monitor is not None: monitor.check() + if time.monotonic() - start_time > timeout: + logger.error( + "Gzip inflation timeout after %.1fs", + timeout, + extra=log_extra(), + ) + cid = get_correlation_id() + if cid: + self._audit.threat( + "", + cid, + "Gzip inflation timeout", + ) + raise ZipBombError( + message=( + "Gzip inflation timeout after" + f" {timeout}s" + " - potential decompression bomb" + ), + compression_ratio=0, + ) + chunk = gz.read(chunk_size) if not chunk: break diff --git a/safeuploads/inspectors/zip_inspector.py b/safeuploads/inspectors/zip_inspector.py index ecadca6..767823b 100644 --- a/safeuploads/inspectors/zip_inspector.py +++ b/safeuploads/inspectors/zip_inspector.py @@ -22,7 +22,11 @@ ResourceLimitError, ZipContentError, ) -from ..utils import find_text_pattern, matches_signature_prefix +from ..utils import ( + find_text_pattern, + matches_signature_prefix, + safe_label, +) from .base import BaseInspector if TYPE_CHECKING: @@ -243,25 +247,28 @@ def _inspect_zip_entry( """ threats = [] filename = entry.filename + # Entry names are attacker-controlled and end up in log + # records and error messages, so report an escaped copy. + label = safe_label(filename) # 1. Check for null bytes (truncation attacks) if "\x00" in filename: - threats.append(f"Null byte in filename: '{filename}'") + threats.append(f"Null byte in filename: '{label}'") # 2. Check for directory traversal attacks if self._has_directory_traversal(filename): - threats.append(f"Directory traversal attack in '{filename}'") + threats.append(f"Directory traversal attack in '{label}'") # 3. Check for absolute paths if ( not self.config.limits.allow_absolute_paths and self._has_absolute_path(filename) ): - threats.append(f"Absolute path detected in '{filename}'") + threats.append(f"Absolute path detected in '{label}'") # 4. Check for symbolic links if not self.config.limits.allow_symlinks and self._is_symlink(entry): - threats.append(f"Symbolic link detected: '{filename}'") + threats.append(f"Symbolic link detected: '{label}'") # 5. Check filename length limits if ( @@ -269,16 +276,14 @@ def _inspect_zip_entry( > self.config.limits.max_filename_length ): threats.append( - f"Filename too long: '{filename}'" + f"Filename too long: '{label}'" f" ({len(os.path.basename(filename))}" " chars)" ) # 6. Check path length limits if len(filename) > self.config.limits.max_path_length: - threats.append( - f"Path too long: '{filename}' ({len(filename)} chars)" - ) + threats.append(f"Path too long: '{label}' ({len(filename)} chars)") # 7. Check for suspicious filename patterns suspicious_patterns = self._check_suspicious_patterns(filename) @@ -289,7 +294,7 @@ def _inspect_zip_entry( not self.config.limits.allow_nested_archives and self._is_nested_archive(filename) ): - threats.append(f"Nested archive detected: '{filename}'") + threats.append(f"Nested archive detected: '{label}'") # 9. Check for dangerous entry extensions threats.extend(self._check_dangerous_extension(filename)) @@ -322,7 +327,7 @@ def _check_dangerous_extension(self, filename: str) -> list[str]: if category is not None: return [ f"Dangerous entry extension '.{part}'" - f" ({category}) in '{filename}'" + f" ({category}) in '{safe_label(filename)}'" ] return [] @@ -434,11 +439,12 @@ def _check_suspicious_patterns(self, filename: str) -> list[str]: threats = [] filename_lower = filename.lower() basename = os.path.basename(filename_lower) + label = safe_label(filename) # Check suspicious names for pattern in self._suspicious_names: if basename == pattern: - threats.append(f"Suspicious filename pattern: '{filename}'") + threats.append(f"Suspicious filename pattern: '{label}'") break # Check suspicious path components @@ -446,7 +452,7 @@ def _check_suspicious_patterns(self, filename: str) -> list[str]: if pattern in filename_lower: threats.append( "Suspicious path component:" - f" '{filename}' contains" + f" '{label}' contains" f" '{pattern}'" ) break @@ -480,6 +486,7 @@ def _inspect_entry_content( List of content threat descriptions. """ threats = [] + label = safe_label(entry.filename) try: # Read first few bytes to check for executable signatures @@ -492,37 +499,30 @@ def _inspect_entry_content( if matches_signature_prefix( content_sample, self._exec_signatures ): - threats.append( - f"Executable content detected in '{entry.filename}'" - ) + threats.append(f"Executable content detected in '{label}'") ext = os.path.splitext(entry.filename)[1].lower() if ( ext not in self._binary_exts - and self._contains_script_patterns( - content_sample, entry.filename - ) + and self._contains_script_patterns(content_sample) ): - threats.append( - f"Script content detected in '{entry.filename}'" - ) + threats.append(f"Script content detected in '{label}'") except Exception as err: logger.warning( "Could not inspect content of '%s': %s", - entry.filename, + label, err, ) return threats - def _contains_script_patterns(self, content: bytes, filename: str) -> bool: + def _contains_script_patterns(self, content: bytes) -> bool: """ Check content for malicious script patterns. Args: content: Raw bytes to inspect. - filename: Filename for context. Returns: True if script patterns found. @@ -680,7 +680,7 @@ def inspect_nested_archives( except Exception: logger.warning( "Could not read nested archive '%s'", - entry.filename, + safe_label(entry.filename), ) continue diff --git a/safeuploads/utils.py b/safeuploads/utils.py index 0c69452..0c31ac8 100644 --- a/safeuploads/utils.py +++ b/safeuploads/utils.py @@ -1,8 +1,11 @@ """Utility helpers for resource monitoring and content scanning.""" +import functools import logging +import re import sys import time +import unicodedata from collections.abc import Iterable from types import TracebackType @@ -37,6 +40,37 @@ def bytes_to_mb(num_bytes: int) -> int: return num_bytes // (1024 * 1024) +# Control, format, surrogate and line/paragraph separator +# characters. These are what let an attacker-supplied name forge +# or hide inside a log line. +_UNSAFE_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"}) + + +def safe_label(value: str, max_length: int = 256) -> str: + """ + Escape untrusted text for inclusion in a log record. + + Args: + value: Untrusted text such as a filename or ZIP entry name. + max_length: Characters kept before truncation. + + Returns: + Escaped, length-bounded text safe to log. + """ + if not value: + return "" + + escaped = "".join( + f"\\u{ord(char):04x}" + if unicodedata.category(char) in _UNSAFE_CATEGORIES + else char + for char in value[:max_length] + ) + if len(value) > max_length: + escaped += "..." + return escaped + + def matches_signature_prefix( content: bytes, signatures: Iterable[bytes] ) -> bytes | None: @@ -81,29 +115,46 @@ def find_embedded_signature( return None +@functools.lru_cache(maxsize=8) +def _compile_text_patterns(patterns: tuple[str, ...]) -> re.Pattern[bytes]: + """ + Build a cached case-insensitive alternation over patterns. + + Args: + patterns: Lower-case ASCII substrings to search for. + + Returns: + Compiled byte-level pattern matching any of the inputs. + """ + return re.compile( + b"|".join(re.escape(p.encode("utf-8")) for p in patterns), + re.IGNORECASE, + ) + + def find_text_pattern(content: bytes, patterns: Iterable[str]) -> str | None: """ - Return the first text pattern present in decoded content. + Return the first text pattern present in the content. - Content is decoded as UTF-8 with errors ignored and lower- - cased so binary data degrades gracefully. Any decoding - failure is treated as "no match". + Matching runs directly over the raw bytes in a single pass so + a large scan window is never copied or decoded. Args: content: Raw bytes to scan. - patterns: Lower-case substrings to search for. + patterns: Lower-case ASCII substrings to search for. Returns: - The first matching pattern, or None if none present. + The matching pattern in its canonical lower-case form, or + None if none are present. """ - try: - text = content.decode("utf-8", errors="ignore").lower() - except Exception: + candidates = tuple(patterns) + if not candidates: return None - for pattern in patterns: - if pattern in text: - return pattern - return None + + match = _compile_text_patterns(candidates).search(content) + if match is None: + return None + return match.group().lower().decode("utf-8", errors="replace") _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" @@ -223,21 +274,27 @@ def parse_image_dimensions(content: bytes) -> tuple[int, int] | None: class ResourceMonitor: """ - Context manager that enforces wall-clock and memory limits. - - Tracks elapsed time continuously and samples memory usage - via ``resource.getrusage``. Memory accounting uses the - process peak RSS (``ru_maxrss``), a monotonic high-water - mark for the whole process, so the reported delta is a - coarse, best-effort upper bound rather than the exact - memory used by this validation. Call ``check`` (or the - individual ``check_time`` / ``check_memory``) inside long + Context manager that enforces a wall-clock limit. + + Elapsed time is a hard limit: call ``check`` inside long loops so a runaway operation is aborted while it runs; - otherwise limits are only checked on context exit. + otherwise it is only checked on context exit. + + Memory is **best-effort telemetry, not a limit**. Accounting + uses the process peak RSS (``ru_maxrss``), a monotonic + high-water mark for the whole process, so it never decreases + and may attribute concurrent work to this validation. + Exceeding ``max_memory_mb`` is logged as a warning; set + ``enforce_memory`` to raise instead, and only do so when the + process handles one validation at a time. Real memory bounds + come from the byte limits in ``SecurityLimits``, which cap + every buffer the library allocates. Attributes: max_time_seconds: Maximum allowed wall-clock seconds. - max_memory_bytes: Maximum allowed peak-RSS growth in bytes. + max_memory_bytes: Peak-RSS growth budget in bytes. + enforce_memory: Whether exceeding the memory budget + raises instead of logging a warning. start_time: Timestamp when the context was entered. start_memory: Peak process RSS in bytes at context entry. """ @@ -246,16 +303,20 @@ def __init__( self, max_time_seconds: float = 30.0, max_memory_mb: int = 512, + enforce_memory: bool = False, ): """ Initialize the resource monitor. Args: max_time_seconds: Wall-clock timeout in seconds. - max_memory_mb: Maximum memory delta in megabytes. + max_memory_mb: Peak-RSS growth budget in megabytes. + enforce_memory: Raise when the memory budget is + exceeded instead of logging a warning. """ self.max_time_seconds = max_time_seconds self.max_memory_bytes = max_memory_mb * 1024 * 1024 + self.enforce_memory = enforce_memory self.start_time: float = 0.0 self.start_memory: int = 0 self._elapsed: float = 0.0 @@ -287,8 +348,9 @@ def __exit__( exc_tb: Exception traceback if raised inside block. Raises: - ResourceLimitError: If time or memory limits were - exceeded during the monitored block. + ResourceLimitError: If the wall-clock limit was + exceeded, or the memory budget was exceeded and + enforcement is on. """ if exc_type is not None: return @@ -316,27 +378,32 @@ def check_memory(self) -> None: """ Check peak memory growth mid-operation. - Enables early enforcement inside long-running loops - instead of waiting for context exit. Uses the process - peak RSS high-water mark, so it is a coarse upper bound. + Uses the process peak RSS high-water mark, so it is a + coarse upper bound. Only raises when ``enforce_memory`` + is set; otherwise an over-budget reading is logged. Raises: ResourceLimitError: If peak-RSS growth since context - entry exceeds the configured memory limit. + entry exceeds the budget and enforcement is on. """ delta = max(0, self._get_peak_rss_bytes() - self.start_memory) self._raise_if_memory_exceeded(delta) def check(self) -> None: """ - Check both time and memory limits mid-operation. + Check the resource budgets mid-operation. + + Memory is only sampled when ``enforce_memory`` is set, so + the common path costs a single clock read per call. Raises: - ResourceLimitError: If the wall-clock or memory limit - has been exceeded since context entry. + ResourceLimitError: If the wall-clock limit has been + exceeded, or the memory budget has been exceeded + and enforcement is on. """ self.check_time() - self.check_memory() + if self.enforce_memory: + self.check_memory() def _raise_if_time_exceeded(self, elapsed: float) -> None: """ @@ -367,18 +434,27 @@ def _raise_if_time_exceeded(self, elapsed: float) -> None: def _raise_if_memory_exceeded(self, delta: int) -> None: """ - Raise if peak-RSS growth exceeds the limit. + Report peak-RSS growth beyond the budget. Args: delta: Peak-RSS growth in bytes since context entry. Raises: - ResourceLimitError: If the memory limit is exceeded. + ResourceLimitError: If the budget is exceeded and + ``enforce_memory`` is set. """ if delta <= self.max_memory_bytes: return delta_mb = bytes_to_mb(delta) max_mb = bytes_to_mb(self.max_memory_bytes) + if not self.enforce_memory: + logger.warning( + "Validation memory budget exceeded: %dMB > %dMB" + " (not enforced; peak RSS is process-wide)", + delta_mb, + max_mb, + ) + return logger.error( "Validation memory limit exceeded: %dMB > %dMB", delta_mb, diff --git a/safeuploads/validators/compression_validator.py b/safeuploads/validators/compression_validator.py index b2dc627..e3da2b7 100644 --- a/safeuploads/validators/compression_validator.py +++ b/safeuploads/validators/compression_validator.py @@ -16,7 +16,7 @@ ResourceLimitError, ZipBombError, ) -from ..utils import bytes_to_mb +from ..utils import bytes_to_mb, safe_label from .base import BaseValidator if TYPE_CHECKING: @@ -183,6 +183,7 @@ def validate_zip_compression_ratio( compression_ratio > self.config.limits.max_compression_ratio ): + entry_label = safe_label(entry.filename) logger.error( "Excessive compression ratio", extra=log_extra( @@ -190,7 +191,7 @@ def validate_zip_compression_ratio( "error_type": ( "compression_ratio_exceeded" ), - "file_name": entry.filename, + "file_name": entry_label, "compression_ratio": ( compression_ratio ), @@ -203,7 +204,7 @@ def validate_zip_compression_ratio( cid = get_correlation_id() if cid: self._audit.threat( - entry.filename, + entry_label, cid, "Zip bomb — excessive compression ratio", ) @@ -215,7 +216,7 @@ def validate_zip_compression_ratio( "Excessive compression" " ratio detected:" f" {compression_ratio:.1f}:1" - f" for '{entry.filename}'." + f" for '{entry_label}'." " Maximum allowed:" f" {max_ratio}:1" ), @@ -231,7 +232,7 @@ def validate_zip_compression_ratio( filename_lower.endswith(ext) for ext in self._nested_archive_exts ): - nested_archives.append(entry.filename) + nested_archives.append(safe_label(entry.filename)) # Check for excessively large individual files # Use the configurable max_individual_file_size limit @@ -239,12 +240,13 @@ def validate_zip_compression_ratio( uncompressed_size > self.config.limits.max_individual_file_size ): + entry_label = safe_label(entry.filename) logger.warning( "Individual file too large", extra=log_extra( { "error_type": "file_too_large", - "file_name": entry.filename, + "file_name": entry_label, "size_mb": bytes_to_mb(uncompressed_size), "max_size_mb": bytes_to_mb( self.config.limits.max_individual_file_size @@ -258,7 +260,7 @@ def validate_zip_compression_ratio( raise CompressionSecurityError( message=( "Individual file too" - f" large: '{entry.filename}'" + f" large: '{entry_label}'" " would expand to" f" {bytes_to_mb(uncompressed_size)}MB." " Maximum allowed:" diff --git a/safeuploads/validators/extension_validator.py b/safeuploads/validators/extension_validator.py index d45af71..f9405e7 100644 --- a/safeuploads/validators/extension_validator.py +++ b/safeuploads/validators/extension_validator.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from ..exceptions import ErrorCode, ExtensionSecurityError +from ..utils import safe_label from .base import BaseValidator if TYPE_CHECKING: @@ -58,7 +59,7 @@ def validate_extensions(self, filename: str) -> None: "Dangerous compound extension detected", extra={ "error_type": "compound_extension_blocked", - "file_name": filename, + "file_name": safe_label(filename), "extension": compound_ext, }, ) @@ -84,7 +85,7 @@ def validate_extensions(self, filename: str) -> None: "Dangerous extension detected", extra={ "error_type": "extension_blocked", - "file_name": filename, + "file_name": safe_label(filename), "extension": ext, }, ) diff --git a/safeuploads/validators/unicode_validator.py b/safeuploads/validators/unicode_validator.py index 7ab2f39..77a281a 100644 --- a/safeuploads/validators/unicode_validator.py +++ b/safeuploads/validators/unicode_validator.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from ..exceptions import ErrorCode, UnicodeSecurityError +from ..utils import safe_label from .base import BaseValidator if TYPE_CHECKING: @@ -69,15 +70,18 @@ def validate_unicode_security(self, filename: str) -> str: char_details = [] for char, code, pos in dangerous_chars_found: char_name = unicodedata.name(char, f"U+{code:04X}") + # The character itself is deliberately not echoed: + # these are exactly the invisible and directional + # code points used to spoof text. char_details.append( - f"'{char}' (U+{code:04X}: {char_name}) at position {pos}" + f"U+{code:04X} ({char_name}) at position {pos}" ) logger.warning( "Dangerous Unicode characters detected", extra={ "error_type": "unicode_security", - "file_name": filename, + "file_name": safe_label(filename), "char_codes": [ code for _, code, _ in dangerous_chars_found ], @@ -109,8 +113,8 @@ def validate_unicode_security(self, filename: str) -> str: if normalized_filename != filename: logger.info( "Unicode normalization applied: '%s' -> '%s'", - filename, - normalized_filename, + safe_label(filename), + safe_label(normalized_filename), ) # Additional check: ensure normalized filename @@ -125,8 +129,8 @@ def validate_unicode_security(self, filename: str) -> str: "Unicode normalization resulted in dangerous character", extra={ "error_type": "unicode_normalization_error", - "file_name": filename, - "normalized_filename": normalized_filename, + "file_name": safe_label(filename), + "normalized_filename": safe_label(normalized_filename), "char_code": char_code, }, ) @@ -134,9 +138,8 @@ def validate_unicode_security(self, filename: str) -> str: message=( "Unicode normalization resulted" " in dangerous character:" - f" '{char}'" - f" (U+{char_code:04X}:" - f" {char_name})" + f" U+{char_code:04X}" + f" ({char_name})" ), filename=filename, dangerous_chars=[(char, char_code, 0)], diff --git a/safeuploads/validators/windows_validator.py b/safeuploads/validators/windows_validator.py index ab20ee2..0129e5d 100644 --- a/safeuploads/validators/windows_validator.py +++ b/safeuploads/validators/windows_validator.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from ..exceptions import WindowsReservedNameError +from ..utils import safe_label from .base import BaseValidator if TYPE_CHECKING: @@ -67,13 +68,13 @@ def validate_windows_reserved_names(self, filename: str) -> None: "Windows reserved name detected", extra={ "error_type": "windows_reserved_name", - "file_name": filename, + "file_name": safe_label(filename), "reserved_name": name_to_check.upper(), }, ) raise WindowsReservedNameError( message=( - f"Filename '{filename}' uses" + f"Filename '{safe_label(filename)}' uses" f" Windows reserved name" f" '{name_to_check.upper()}'." f" Reserved names:" diff --git a/safeuploads/validators/xml_validator.py b/safeuploads/validators/xml_validator.py index 1f38787..1ac1e24 100644 --- a/safeuploads/validators/xml_validator.py +++ b/safeuploads/validators/xml_validator.py @@ -13,11 +13,11 @@ ExternalReferenceForbidden, ) -from ..exceptions import FileProcessingError +from ..exceptions import ErrorCode, FileProcessingError +from ..utils import safe_label from .base import BaseValidator if TYPE_CHECKING: - from ..config import FileSecurityConfig from ..protocols import SeekableFile @@ -30,73 +30,179 @@ class XmlSecurityValidator(BaseValidator): Uses ``defusedxml`` to parse XML safely. Rejects files containing DTD declarations, external entities, or - excessive entity expansion. + excessive entity expansion. Parsing is incremental and the + element count is capped, so a flat document with millions of + elements cannot amplify a bounded upload into an unbounded + object graph. Attributes: config: Security configuration for validation limits. """ - def __init__(self, config: FileSecurityConfig): - """ - Initialize the XML security validator. - - Args: - config: Security configuration with file limits. - """ - super().__init__(config) - - def validate_xml_safety(self, file_obj: SeekableFile) -> None: + def validate_xml_safety( + self, + file_obj: SeekableFile, + expected_root: str | None = None, + ) -> None: """ Parse XML with XXE protections and validate structure. Args: file_obj: Seekable file containing XML data. + expected_root: Required root element name, lower-cased + and namespace-free. Any root configured in + ``ACTIVITY_XML_ROOTS`` is accepted when omitted. Raises: FileProcessingError: If the XML is malformed, contains - XXE attacks, or fails safety checks. + XXE attacks, declares an unexpected root element, + or exceeds the element cap. """ file_obj.seek(0) logger.debug("Parsing activity XML with XXE protections") try: - # defusedxml blocks external entities and - # entity expansion by default. - # forbid_dtd=True rejects ALL DTD declarations. - DefusedET.parse(file_obj, forbid_dtd=True) + root_tag = self._parse_bounded(file_obj) + except FileProcessingError: + raise except DTDForbidden as err: logger.warning("XML contains forbidden DTD declaration") raise FileProcessingError( - "XML contains forbidden DTD declaration" + "XML contains forbidden DTD declaration", + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, ) from err except EntitiesForbidden as err: logger.warning("XML contains forbidden entity reference") raise FileProcessingError( - "XML contains forbidden external entity" + "XML contains forbidden external entity", + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, ) from err except ExternalReferenceForbidden as err: logger.warning("XML contains forbidden external reference") raise FileProcessingError( - "XML contains forbidden external reference" + "XML contains forbidden external reference", + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, ) from err except ParseError as err: logger.warning("Malformed XML: %s", err) - raise FileProcessingError("Malformed XML content") from err + raise FileProcessingError( + "Malformed XML content", + error_code=ErrorCode.XML_MALFORMED, + ) from err except Exception as err: logger.warning("XML validation failed: %s", err) raise FileProcessingError("XML validation failed") from err + self._enforce_root(root_tag, expected_root) + logger.debug("XML safety validation passed") file_obj.seek(0) - def validate(self, file_obj: SeekableFile) -> None: + def _parse_bounded(self, file_obj: SeekableFile) -> str | None: + """ + Parse incrementally, capping the element count. + + Completed elements are discarded as they close so peak + memory stays flat regardless of document length. + + Args: + file_obj: Seekable file containing XML data. + + Returns: + The root element tag, or None for an empty document. + + Raises: + FileProcessingError: If the element cap is exceeded. + """ + max_elements = self.config.limits.max_xml_elements + root = None + count = 0 + + # forbid_dtd=True rejects ALL DTD declarations; external + # entities and entity expansion are blocked by default. + events = DefusedET.iterparse( + file_obj, events=("start", "end"), forbid_dtd=True + ) + for event, element in events: + if event == "start": + count += 1 + if root is None: + root = element + if count > max_elements: + logger.warning( + "XML element count exceeded: %d > %d", + count, + max_elements, + ) + raise FileProcessingError( + ( + "XML contains too many elements." + f" Maximum allowed: {max_elements}" + ), + error_code=ErrorCode.XML_TOO_MANY_ELEMENTS, + ) + elif element is not root and root is not None: + element.clear() + root.clear() + + return None if root is None else str(root.tag) + + def _enforce_root( + self, root_tag: str | None, expected_root: str | None + ) -> None: + """ + Check the document root against the allowed names. + + Args: + root_tag: Root element tag, possibly namespace-qualified. + expected_root: Required root name, or None to accept any + root configured in ``ACTIVITY_XML_ROOTS``. + + Raises: + FileProcessingError: If the root element is missing or + not permitted. + """ + allowed = ( + {expected_root} + if expected_root is not None + else set(self.config.ACTIVITY_XML_ROOTS.values()) + ) + + if root_tag is None: + raise FileProcessingError( + "XML document has no root element", + error_code=ErrorCode.XML_INVALID_ROOT, + ) + + # ElementTree reports namespaced tags as ``{uri}local``. + local_name = root_tag.rpartition("}")[2].lower() + if local_name not in allowed: + logger.warning( + "Unexpected XML root element: %s", + safe_label(local_name, max_length=64), + ) + raise FileProcessingError( + ( + "Unexpected XML root element." + f" Expected: {', '.join(sorted(allowed))}" + ), + error_code=ErrorCode.XML_INVALID_ROOT, + ) + + def validate( + self, + file_obj: SeekableFile, + expected_root: str | None = None, + ) -> None: """ Validate XML file for security threats. Args: file_obj: Seekable file containing XML data. + expected_root: Required root element name, lower-cased + and namespace-free. Raises: FileProcessingError: If the XML fails safety checks. """ - return self.validate_xml_safety(file_obj) + return self.validate_xml_safety(file_obj, expected_root) diff --git a/tests/inspectors/test_gzip_inspector.py b/tests/inspectors/test_gzip_inspector.py index 9d358b2..b33d8e7 100644 --- a/tests/inspectors/test_gzip_inspector.py +++ b/tests/inspectors/test_gzip_inspector.py @@ -5,6 +5,7 @@ import pytest +from safeuploads.audit import reset_correlation_id, set_correlation_id from safeuploads.config import FileSecurityConfig, SecurityLimits from safeuploads.exceptions import ( CompressionSecurityError, @@ -33,6 +34,26 @@ def test_chunk_loop_aborts_on_time_limit(self, default_config): io.BytesIO(payload), len(payload), monitor ) + def test_inflation_timeout_without_monitor(self): + """Test the inspector bounds inflation on its own.""" + config = FileSecurityConfig() + config.limits = SecurityLimits( + gzip_analysis_timeout=0.0, + chunk_size=1, + enable_audit_logging=True, + ) + inspector = GzipContentInspector(config) + payload = gzip.compress(b"x" * 4096) + + set_correlation_id("test-correlation-id") + try: + with pytest.raises(ZipBombError, match="timeout"): + inspector.inspect_gzip_content( + io.BytesIO(payload), len(payload) + ) + finally: + reset_correlation_id() + class TestGzipContentInspector: """Test suite for GzipContentInspector.""" diff --git a/tests/inspectors/test_zip_inspector.py b/tests/inspectors/test_zip_inspector.py index 109547a..4823d9b 100644 --- a/tests/inspectors/test_zip_inspector.py +++ b/tests/inspectors/test_zip_inspector.py @@ -1079,21 +1079,28 @@ def test_null_byte_in_entry_filename_detected( threats = inspector._inspect_zip_entry(bad_info, None) assert any("Null byte" in t for t in threats) - def test_script_pattern_decode_exception_silenced( + def test_script_pattern_scan_handles_undecodable_bytes( self, ): config = FileSecurityConfig() inspector = ZipContentInspector(config) - class _BadBytes: - def decode(self, *args, **kwargs): - raise RuntimeError("decode failed") - - # Passes a non-bytes object whose .decode() raises; - # covers the except Exception branch (lines 488-490) - result = inspector._contains_script_patterns(_BadBytes(), "file.txt") + # Invalid UTF-8 with no script markers must not match; + # the scan runs over raw bytes and never decodes. + result = inspector._contains_script_patterns( + b"\xff\xfe\xfd\xfc\xfb\xfa" + ) assert result is False + def test_script_pattern_scan_matches_in_binary_noise(self): + config = FileSecurityConfig() + inspector = ZipContentInspector(config) + + result = inspector._contains_script_patterns( + b"\xff\xfe bytes: """Build a PNG header declaring the given dimensions.""" return ( @@ -295,6 +360,7 @@ def _fake_rss() -> int: ResourceMonitor( max_time_seconds=30.0, max_memory_mb=512, + enforce_memory=True, ), ): pass # Immediate exit @@ -303,6 +369,87 @@ def _fake_rss() -> int: assert exc_info.value.memory_bytes is not None assert "memory limit" in str(exc_info.value).lower() + def test_memory_overrun_warns_when_not_enforced(self, monkeypatch, caplog): + """ + Test the default posture reports but does not fail. + + Args: + monkeypatch: pytest monkeypatch fixture. + caplog: pytest log capture fixture. + """ + _calls = {"n": 0} + + def _fake_rss() -> int: + _calls["n"] += 1 + if _calls["n"] == 1: + return 100 * 1024 * 1024 + return 700 * 1024 * 1024 + + monkeypatch.setattr( + ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) + ) + + with ( + caplog.at_level("WARNING", logger="safeuploads.utils"), + ResourceMonitor(max_time_seconds=30.0, max_memory_mb=512), + ): + pass + + assert "not enforced" in caplog.text + + def test_check_skips_memory_when_not_enforced(self, monkeypatch): + """ + Test check() does not sample memory unless enforcing. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + _calls = {"n": 0} + + def _fake_rss() -> int: + _calls["n"] += 1 + return 100 * 1024 * 1024 + + monkeypatch.setattr( + ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) + ) + + with ResourceMonitor(max_time_seconds=30.0) as monitor: + baseline = _calls["n"] + monitor.check() + assert _calls["n"] == baseline + + def test_check_samples_memory_when_enforced(self, monkeypatch): + """ + Test check() enforces the memory budget when enabled. + + Args: + monkeypatch: pytest monkeypatch fixture. + """ + _calls = {"n": 0} + + def _fake_rss() -> int: + _calls["n"] += 1 + if _calls["n"] == 1: + return 100 * 1024 * 1024 + return 700 * 1024 * 1024 + + monkeypatch.setattr( + ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) + ) + + with ( + pytest.raises(ResourceLimitError) as exc_info, + ResourceMonitor( + max_time_seconds=30.0, + max_memory_mb=512, + enforce_memory=True, + ) as monitor, + ): + monitor.check() + + assert exc_info.value.error_code == ErrorCode.RESOURCE_MEMORY_EXCEEDED + def test_check_memory_passes_within_limit(self): """Test that check_memory does not raise within the limit.""" with ResourceMonitor( @@ -329,7 +476,9 @@ def _fake_rss() -> int: ResourceMonitor, "_get_peak_rss_bytes", staticmethod(_fake_rss) ) - monitor = ResourceMonitor(max_time_seconds=30.0, max_memory_mb=512) + monitor = ResourceMonitor( + max_time_seconds=30.0, max_memory_mb=512, enforce_memory=True + ) monitor.__enter__() with pytest.raises(ResourceLimitError) as exc_info: monitor.check_memory() diff --git a/tests/validators/test_xml_validator.py b/tests/validators/test_xml_validator.py index 54b365b..08d5f57 100644 --- a/tests/validators/test_xml_validator.py +++ b/tests/validators/test_xml_validator.py @@ -4,7 +4,8 @@ import pytest -from safeuploads.exceptions import FileProcessingError +from safeuploads.config import FileSecurityConfig, SecurityLimits +from safeuploads.exceptions import ErrorCode, FileProcessingError from safeuploads.validators.xml_validator import XmlSecurityValidator @@ -103,14 +104,21 @@ def test_reject_non_xml_content(self, default_config): def test_validate_delegates_to_validate_xml_safety(self, default_config): """Test validate() delegates correctly.""" validator = XmlSecurityValidator(default_config) - valid_xml = b"" + valid_xml = b"" file_obj = io.BytesIO(valid_xml) validator.validate(file_obj) + def test_validate_forwards_expected_root(self, default_config): + """Test validate() passes the expected root through.""" + validator = XmlSecurityValidator(default_config) + file_obj = io.BytesIO(b"") + with pytest.raises(FileProcessingError, match="root element"): + validator.validate(file_obj, "trainingcenterdatabase") + def test_file_position_reset_after_validation(self, default_config): """Test file position is reset after validation.""" validator = XmlSecurityValidator(default_config) - valid_xml = b"" + valid_xml = b"" file_obj = io.BytesIO(valid_xml) file_obj.seek(5) validator.validate_xml_safety(file_obj) @@ -119,7 +127,7 @@ def test_file_position_reset_after_validation(self, default_config): def test_xml_with_bom(self, default_config): """Test XML with byte order mark passes.""" validator = XmlSecurityValidator(default_config) - bom_xml = b'\xef\xbb\xbf' + bom_xml = b'\xef\xbb\xbf' file_obj = io.BytesIO(bom_xml) validator.validate_xml_safety(file_obj) @@ -144,9 +152,9 @@ def _raise_entities(*a, **kw): "entity", None, None, None, None, None ) - monkeypatch.setattr(_mod.DefusedET, "parse", _raise_entities) + monkeypatch.setattr(_mod.DefusedET, "iterparse", _raise_entities) with pytest.raises(FileProcessingError, match="entity"): - validator.validate_xml_safety(io.BytesIO(b"")) + validator.validate_xml_safety(io.BytesIO(b"")) def test_external_ref_forbidden_raises(self, default_config, monkeypatch): from defusedxml import ElementTree as DefusedET @@ -158,9 +166,9 @@ def test_external_ref_forbidden_raises(self, default_config, monkeypatch): def _raise_ext(*a, **kw): raise DefusedET.ExternalReferenceForbidden("ref", None, None, None) - monkeypatch.setattr(_mod.DefusedET, "parse", _raise_ext) + monkeypatch.setattr(_mod.DefusedET, "iterparse", _raise_ext) with pytest.raises(FileProcessingError, match="external"): - validator.validate_xml_safety(io.BytesIO(b"")) + validator.validate_xml_safety(io.BytesIO(b"")) def test_unexpected_exception_raises(self, default_config, monkeypatch): import safeuploads.validators.xml_validator as _mod @@ -170,6 +178,83 @@ def test_unexpected_exception_raises(self, default_config, monkeypatch): def _raise_generic(*a, **kw): raise OSError("disk failure") - monkeypatch.setattr(_mod.DefusedET, "parse", _raise_generic) + monkeypatch.setattr(_mod.DefusedET, "iterparse", _raise_generic) with pytest.raises(FileProcessingError, match="failed"): - validator.validate_xml_safety(io.BytesIO(b"")) + validator.validate_xml_safety(io.BytesIO(b"")) + + +class TestXmlRootElementValidation: + """The document root must match the activity format.""" + + def test_arbitrary_xml_root_rejected(self, default_config): + """Test a non-activity document is rejected.""" + validator = XmlSecurityValidator(default_config) + payload = b"" + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(payload)) + + assert exc_info.value.error_code == ErrorCode.XML_INVALID_ROOT + + def test_namespaced_gpx_root_accepted(self, default_config): + """Test the namespace prefix is stripped before matching.""" + validator = XmlSecurityValidator(default_config) + payload = ( + b'' + ) + validator.validate_xml_safety(io.BytesIO(payload), "gpx") + + def test_tcx_content_rejected_under_gpx_extension(self, default_config): + """Test the root must match the specific extension.""" + validator = XmlSecurityValidator(default_config) + payload = b"" + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(payload), "gpx") + + assert exc_info.value.error_code == ErrorCode.XML_INVALID_ROOT + + def test_tcx_root_is_case_insensitive(self, default_config): + """Test root matching ignores case.""" + validator = XmlSecurityValidator(default_config) + payload = b"" + validator.validate_xml_safety(io.BytesIO(payload)) + + def test_document_without_root_rejected(self, default_config, monkeypatch): + """Test a parse yielding no elements is rejected.""" + import safeuploads.validators.xml_validator as _mod + + validator = XmlSecurityValidator(default_config) + monkeypatch.setattr( + _mod.DefusedET, "iterparse", lambda *a, **kw: iter(()) + ) + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(b"")) + + assert exc_info.value.error_code == ErrorCode.XML_INVALID_ROOT + + +class TestXmlElementCap: + """A flat document cannot amplify a bounded upload.""" + + def test_element_cap_enforced(self): + """Test exceeding max_xml_elements is rejected.""" + config = FileSecurityConfig() + config.limits = SecurityLimits(max_xml_elements=10) + validator = XmlSecurityValidator(config) + payload = b"" + b"" * 50 + b"" + + with pytest.raises(FileProcessingError) as exc_info: + validator.validate_xml_safety(io.BytesIO(payload)) + + assert exc_info.value.error_code == ErrorCode.XML_TOO_MANY_ELEMENTS + + def test_document_within_cap_passes(self): + """Test a document under the cap is accepted.""" + config = FileSecurityConfig() + config.limits = SecurityLimits(max_xml_elements=100) + validator = XmlSecurityValidator(config) + payload = b"" + b"" * 50 + b"" + + validator.validate_xml_safety(io.BytesIO(payload)) From ae2ae1dc5af18727ad83134e62aec35b50d7859d Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:32:30 +0100 Subject: [PATCH 04/16] refactor: code structure for improved readability and maintainability --- .github/workflows/test-matrix-safeuploads.yml | 4 +- CHANGELOG.md | 17 ++ README.md | 32 +- docs/index.md | 30 +- docs/security/integration-checklist.md | 8 +- examples/README.md | 2 +- examples/fastapi_example.py | 8 +- pyproject.toml | 6 +- safeuploads/__init__.py | 5 +- safeuploads/config.py | 46 ++- safeuploads/file_validator.py | 3 +- tests/test_config.py | 17 ++ tests/test_config_validation.py | 21 ++ tests/test_file_validator.py | 65 +++++ tests/test_public_api.py | 45 +++ uv.lock | 274 +++++++++++++++++- 16 files changed, 535 insertions(+), 48 deletions(-) create mode 100644 tests/test_public_api.py diff --git a/.github/workflows/test-matrix-safeuploads.yml b/.github/workflows/test-matrix-safeuploads.yml index 906785a..a1ace45 100644 --- a/.github/workflows/test-matrix-safeuploads.yml +++ b/.github/workflows/test-matrix-safeuploads.yml @@ -1,7 +1,7 @@ name: Test matrix safeuploads # Portability matrix on top of the fast default `Lint & Test` workflow: -# * every supported Python version (3.13, 3.14) with the full +# * every supported Python version (3.11 - 3.14) with the full # dependency set (dev group + fastapi extra), proving the suite # passes on every Python the package declares support for; and # * a bare `import safeuploads` with only the base dependencies @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - python: ["3.13", "3.14"] + python: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6032b..ca968a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,9 +37,26 @@ project adheres to - `safe_label()` utility, applied to every untrusted filename and ZIP entry name before it reaches a log record, audit event, or exception message. +- `temp_dir` limit controlling where uploads larger than + `max_memory_buffer_size` spill to disk. Configuration validation + reports `invalid_temp_dir` when the directory does not exist, rather + than failing later at rollover time. +- `FileSecurityConfig` now accepts `limits` directly + (`FileSecurityConfig(SecurityLimits(...))`). The object is copied, so + it is never aliased or shared, and configuring an instance no longer + requires mutating class state. +- `UploadFileProtocol` and `reset_correlation_id` are now exported from + the top-level package. `UploadFileProtocol` is the interface a + non-FastAPI framework adapter implements, so it belonged in the + public API alongside `SeekableFile`. ### Changed +- **Lowered the minimum supported Python from 3.13 to 3.11.** No source + changes were required; `enum.StrEnum` was the only 3.11+ dependency. + The full test suite passes on 3.11, 3.12, 3.13 and 3.14, and the CI + matrix now covers all four. + - **Breaking:** `max_validation_memory_mb` is no longer enforced by default. It samples the process-wide peak RSS, which never decreases and misattributes concurrent work, so exceeding it is now logged as diff --git a/README.md b/README.md index a9a56aa..834ef42 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ [![Release](https://img.shields.io/github/v/release/endurain-project/safeuploads?label=release&color=blue)](https://github.com/endurain-project/safeuploads/releases) [![PyPI version](https://img.shields.io/pypi/v/safeuploads)](https://pypi.org/project/safeuploads/) [![PyPI downloads](https://img.shields.io/pypi/dm/safeuploads)](https://pypi.org/project/safeuploads/) -[![Python](https://img.shields.io/badge/python-3.13%2B-blue)](https://pypi.org/project/safeuploads/) +[![Python](https://img.shields.io/badge/python-3.11%2B-blue)](https://pypi.org/project/safeuploads/) [![Docs](https://img.shields.io/badge/docs-safeuploads.endurain.com-blue)](https://safeuploads.endurain.com/) [![Stars](https://img.shields.io/github/stars/endurain-project/safeuploads?label=stars&logo=github)](https://github.com/endurain-project/safeuploads) -Secure file upload validation for Python 3.13+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload. +Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload. ## Features @@ -66,20 +66,26 @@ async def upload_image(file: UploadFile): ## Configuration ```python -from safeuploads import FileValidator, FileSecurityConfig +from safeuploads import FileValidator, FileSecurityConfig, SecurityLimits # Use default secure configuration validator = FileValidator() -# Or customize limits -config = FileSecurityConfig() -config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB -config.limits.max_image_pixels = 50_000_000 # Reject bigger decoded images -config.limits.max_compression_ratio = 50 - -# Opt in to strict ZIP checking: decompress every entry to -# reject archives with forged central-directory metadata -config.limits.verify_zip_decompression = True +# Or pass explicit limits. Anything you leave out keeps its +# secure default, and the limits object is copied, so nothing +# is shared between configs. +config = FileSecurityConfig( + SecurityLimits( + max_image_size=10 * 1024 * 1024, # 10 MiB + max_image_pixels=50_000_000, # Reject bigger decoded images + max_compression_ratio=50, + # Decompress every ZIP entry to reject archives with + # forged central-directory metadata + verify_zip_decompression=True, + # Keep spilled uploads off the system temp directory + temp_dir="/var/lib/myapp/uploads-tmp", + ) +) validator = FileValidator(config=config) @@ -153,7 +159,7 @@ except FileValidationError as err: - Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected - `max_validation_memory_mb` is best-effort telemetry, not a limit: it samples the process-wide peak RSS, so it cannot be attributed to a single validation. Exceeding it is logged; set `enforce_memory_limit=True` to enforce, and only in a process that validates one upload at a time - `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives -- `SpooledTemporaryFile` uses the system default temp directory +- Uploads larger than `max_memory_buffer_size` spill to disk; set `temp_dir` to control where, otherwise the system default temporary directory is used ## Documentation diff --git a/docs/index.md b/docs/index.md index 1d1d24e..53e5203 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,7 @@ -Secure file upload validation for Python 3.13+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload. +Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload. ## Features @@ -70,20 +70,26 @@ async def upload_image(file: UploadFile): ## Configuration ```python -from safeuploads import FileValidator, FileSecurityConfig +from safeuploads import FileValidator, FileSecurityConfig, SecurityLimits # Use default secure configuration validator = FileValidator() -# Or customize limits -config = FileSecurityConfig() -config.limits.max_image_size = 10 * 1024 * 1024 # 10 MiB -config.limits.max_image_pixels = 50_000_000 # Reject bigger decoded images -config.limits.max_compression_ratio = 50 - -# Opt in to strict ZIP checking: decompress every entry to -# reject archives with forged central-directory metadata -config.limits.verify_zip_decompression = True +# Or pass explicit limits. Anything you leave out keeps its +# secure default, and the limits object is copied, so nothing +# is shared between configs. +config = FileSecurityConfig( + SecurityLimits( + max_image_size=10 * 1024 * 1024, # 10 MiB + max_image_pixels=50_000_000, # Reject bigger decoded images + max_compression_ratio=50, + # Decompress every ZIP entry to reject archives with + # forged central-directory metadata + verify_zip_decompression=True, + # Keep spilled uploads off the system temp directory + temp_dir="/var/lib/myapp/uploads-tmp", + ) +) validator = FileValidator(config=config) @@ -157,7 +163,7 @@ except FileValidationError as err: - Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected - `max_validation_memory_mb` is best-effort telemetry, not a limit: it samples the process-wide peak RSS, so it cannot be attributed to a single validation. Exceeding it is logged; set `enforce_memory_limit=True` to enforce, and only in a process that validates one upload at a time - `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives (see [Integration Checklist](security/integration-checklist.md)) -- `SpooledTemporaryFile` uses the system default temp directory +- Uploads larger than `max_memory_buffer_size` spill to disk; set `temp_dir` to control where, otherwise the system default temporary directory is used ## Documentation diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index 77dbf0a..1f6cd4d 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -142,9 +142,11 @@ archive afterwards: application level — should be above `max_validation_time_seconds`. - [ ] Disk space monitored for temporary file spill - (`SpooledTemporaryFile` uses the system temp directory). -- [ ] Consider setting `TMPDIR` environment variable to a - dedicated partition with quota enforcement. + (uploads above `max_memory_buffer_size` are written to disk). +- [ ] `temp_dir` set to a dedicated, quota-enforced partition, + or `TMPDIR` set if you prefer to configure it out of band. + The directory must exist; configuration validation reports + `invalid_temp_dir` when it does not. ## Dependency Management diff --git a/examples/README.md b/examples/README.md index 954df98..f76d9f2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ This directory contains a working example demonstrating how to integrate `safeup ## Prerequisites -The example requires Python 3.13+ and the `safeuploads` library with FastAPI: +The example requires Python 3.11+ and the `safeuploads` library with FastAPI: ```bash pip install safeuploads[fastapi] diff --git a/examples/fastapi_example.py b/examples/fastapi_example.py index 8fe3b8d..a6e16bb 100644 --- a/examples/fastapi_example.py +++ b/examples/fastapi_example.py @@ -67,8 +67,7 @@ ) # Create custom configuration with strict limits -strict_config = FileSecurityConfig() -strict_config.limits = strict_limits +strict_config = FileSecurityConfig(strict_limits) # Initialize validators default_validator = FileValidator() # Uses default config @@ -78,8 +77,9 @@ # forged central-directory metadata, and offload blocking # inspection to a bounded thread pool so large uploads never # starve the event loop. -hardened_config = FileSecurityConfig() -hardened_config.limits = SecurityLimits(verify_zip_decompression=True) +hardened_config = FileSecurityConfig( + SecurityLimits(verify_zip_decompression=True) +) hardened_validator = FileValidator( config=hardened_config, executor=ThreadPoolExecutor(max_workers=4), diff --git a/pyproject.toml b/pyproject.toml index dca5cad..3ecd74d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = {text = "MIT"} readme = "README.md" -requires-python = ">=3.13" +requires-python = ">=3.11" dependencies = [ "python-magic>=0.4.27,<0.5.0", "defusedxml>=0.7.1,<1.0.0", @@ -45,7 +45,7 @@ exclude-newer = "30 days" [tool.ruff] line-length = 79 -target-version = "py313" +target-version = "py311" [tool.ruff.lint] select = [ @@ -150,7 +150,7 @@ exclude_lines = [ ] [tool.mypy] -python_version = "3.13" +python_version = "3.11" incremental = false # strict = true enables the full strict bundle, including # disallow_any_generics, warn_return_any, disallow_untyped_calls, diff --git a/safeuploads/__init__.py b/safeuploads/__init__.py index f638d52..87614b8 100644 --- a/safeuploads/__init__.py +++ b/safeuploads/__init__.py @@ -10,6 +10,7 @@ AuditEventType, SecurityAuditLogger, get_correlation_id, + reset_correlation_id, set_correlation_id, ) @@ -55,7 +56,7 @@ GzipContentInspector, ZipContentInspector, ) -from .protocols import SeekableFile +from .protocols import SeekableFile, UploadFileProtocol from .utils import ResourceMonitor # Specialized validators @@ -80,6 +81,7 @@ "FileSecurityConfig", # Protocols "SeekableFile", + "UploadFileProtocol", # Exceptions "ConfigValidationError", "FileSecurityConfigurationError", @@ -129,4 +131,5 @@ "AuditEventType", "get_correlation_id", "set_correlation_id", + "reset_correlation_id", ] diff --git a/safeuploads/config.py b/safeuploads/config.py index 172691d..4d38779 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -2,6 +2,7 @@ import itertools import logging +import os from dataclasses import dataclass, replace from typing import Any, ClassVar @@ -64,6 +65,8 @@ class SecurityLimits: max_gzip_size: Maximum size in bytes for gzip files. max_memory_buffer_size: Bytes kept in memory before a streamed upload spills to a temporary file on disk. + temp_dir: Directory used for spilled uploads. Uses the + system default temporary directory when unset. chunk_size: Chunk size in bytes for streaming reads. max_validation_memory_mb: Peak-RSS growth budget in MB for a single validation. Best-effort telemetry only @@ -123,6 +126,10 @@ class SecurityLimits: max_memory_buffer_size: int = ( 10 * 1024 * 1024 # 10MB before spilling to disk ) + # Where spilled uploads land. Point this at a dedicated, + # quota-enforced partition to keep large uploads off the + # system temp directory. + temp_dir: str | None = None chunk_size: int = 65536 # 64KB chunks for streaming reads # Resource monitoring limits @@ -196,8 +203,16 @@ class FileSecurityConfig: """ Centralizes file upload security settings and validation. + The class-level ``limits`` is the template copied into each + new instance, not the live configuration. Pass a + ``SecurityLimits`` to the constructor to configure an + instance; assigning to ``FileSecurityConfig.limits`` or + mutating it in place changes the default for every config + created afterwards. + Attributes: - limits: Security limits configuration instance. + limits: Security limits for this instance. At class + level, the template new instances are built from. ALLOWED_IMAGE_MIMES: Permitted MIME types for images. ALLOWED_ZIP_MIMES: Permitted MIME types for ZIP files. ALLOWED_ACTIVITY_MIMES: Permitted MIME types for activity @@ -375,16 +390,20 @@ def _generate_dangerous_unicode_chars() -> frozenset[int]: } ) - def __init__(self) -> None: + def __init__(self, limits: SecurityLimits | None = None) -> None: """ Create a config instance with isolated mutable state. - Copies the class-level ``limits`` so mutating one - instance's limits never affects other instances or - the shared class default. + The supplied or class-level ``limits`` is copied, so + mutating one instance's limits never affects other + instances, the caller's object, or the class default. + + Args: + limits: Security limits to use. Falls back to the + class-level default when omitted. """ # Per-instance copy prevents cross-instance mutation - self.limits = replace(type(self).limits) + self.limits = replace(type(self).limits if limits is None else limits) # Configuration validation trigger @classmethod @@ -685,6 +704,21 @@ def _validate_file_size_limits( ) ) + # A missing temp directory only surfaces when an upload + # spills to disk, so check it up front. + if limits.temp_dir is not None and not os.path.isdir(limits.temp_dir): + errors.append( + _config_error( + "invalid_temp_dir", + f"temp_dir '{limits.temp_dir}' is not a directory", + "file_sizes", + ( + "Create the directory or leave temp_dir" + " unset to use the system default" + ), + ) + ) + return errors @classmethod diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index 7c0de36..8a52a62 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -724,7 +724,8 @@ async def _stream_to_temp_file( limit is exceeded while reading. """ temp = tempfile.SpooledTemporaryFile( # noqa: SIM115 - max_size=self.config.limits.max_memory_buffer_size + max_size=self.config.limits.max_memory_buffer_size, + dir=self.config.limits.temp_dir, ) total_bytes = 0 chunk_size = self.config.limits.chunk_size diff --git a/tests/test_config.py b/tests/test_config.py index 71d0117..6f469cd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -331,6 +331,23 @@ def test_limits_are_not_shared_between_instances(self): assert a.limits is not b.limits + def test_constructor_accepts_limits(self): + """Limits can be supplied without touching class state.""" + limits = SecurityLimits(max_image_size=4096) + config = FileSecurityConfig(limits) + + assert config.limits.max_image_size == 4096 + assert FileSecurityConfig.limits.max_image_size != 4096 + + def test_constructor_copies_supplied_limits(self): + """The caller's limits object must not be aliased.""" + limits = SecurityLimits(max_image_size=4096) + config = FileSecurityConfig(limits) + + config.limits.max_image_size = 1 + + assert limits.max_image_size == 4096 + def test_limit_mutation_does_not_leak_across_instances(self): """Mutating one instance's limits must not affect another.""" a = FileSecurityConfig() diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index b3e1302..dcc3579 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -183,6 +183,27 @@ def test_nonpositive_gzip_timeout_generates_error(self, monkeypatch): error_types = [e.error_type for e in errors if e.severity == "error"] assert "invalid_timeout" in error_types + def test_missing_temp_dir_generates_error(self): + """Test that a temp_dir which is not a directory errors.""" + config = FileSecurityConfig( + SecurityLimits(temp_dir="/nonexistent/safeuploads-temp") + ) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_temp_dir" in error_types + + def test_existing_temp_dir_accepted(self, tmp_path): + """ + Test that an existing temp_dir passes validation. + + Args: + tmp_path: pytest temporary directory fixture. + """ + config = FileSecurityConfig(SecurityLimits(temp_dir=str(tmp_path))) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_temp_dir" not in error_types + class TestMimeConfigurationValidation: """Tests for _validate_mime_configurations validation branches.""" diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index 861e3bc..684cb8e 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -2,6 +2,7 @@ import asyncio import io +import tempfile import pytest @@ -962,6 +963,70 @@ async def test_raises_file_size_error_when_limit_exceeded_during_stream( max_file_size=1 * 1024, # 1 KB limit ) + @pytest.mark.asyncio + async def test_temp_dir_used_for_spilled_uploads( + self, mock_upload_file, monkeypatch, tmp_path + ): + """ + Test the configured temp_dir reaches the spooled file. + + Args: + mock_upload_file: File factory fixture. + monkeypatch: pytest monkeypatch fixture. + tmp_path: pytest temporary directory fixture. + """ + captured = {} + real_spooled = tempfile.SpooledTemporaryFile + + def _spy(*args, **kwargs): + captured.update(kwargs) + return real_spooled(*args, **kwargs) + + monkeypatch.setattr(tempfile, "SpooledTemporaryFile", _spy) + + config = FileSecurityConfig( + SecurityLimits(temp_dir=str(tmp_path), max_memory_buffer_size=8) + ) + validator = FileValidator(config=config) + file = mock_upload_file(filename="a.zip", content=b"x" * 4096) + + temp, _ = await validator._stream_to_temp_file( + file, max_file_size=1024 * 1024 + ) + temp.close() + + assert captured["dir"] == str(tmp_path) + + @pytest.mark.asyncio + async def test_temp_dir_defaults_to_system_location( + self, mock_upload_file, monkeypatch + ): + """ + Test an unset temp_dir leaves the system default in place. + + Args: + mock_upload_file: File factory fixture. + monkeypatch: pytest monkeypatch fixture. + """ + captured = {} + real_spooled = tempfile.SpooledTemporaryFile + + def _spy(*args, **kwargs): + captured.update(kwargs) + return real_spooled(*args, **kwargs) + + monkeypatch.setattr(tempfile, "SpooledTemporaryFile", _spy) + + validator = FileValidator() + file = mock_upload_file(filename="a.zip", content=b"x" * 32) + + temp, _ = await validator._stream_to_temp_file( + file, max_file_size=1024 * 1024 + ) + temp.close() + + assert captured["dir"] is None + class TestResourceMonitorIntegration: """Test resource monitoring in validate methods.""" diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..6b36c1c --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,45 @@ +"""Contract tests for the package's public API surface.""" + +import safeuploads + + +class TestPublicExports: + """Every advertised name must be importable and unique.""" + + def test_all_names_are_resolvable(self): + missing = [ + name + for name in safeuploads.__all__ + if not hasattr(safeuploads, name) + ] + assert missing == [] + + def test_all_has_no_duplicates(self): + assert len(safeuploads.__all__) == len(set(safeuploads.__all__)) + + def test_framework_agnostic_protocols_exported(self): + # A caller integrating a non-FastAPI framework needs both + # protocols from the top-level package. + assert "SeekableFile" in safeuploads.__all__ + assert "UploadFileProtocol" in safeuploads.__all__ + + def test_correlation_id_helpers_exported(self): + for name in ( + "get_correlation_id", + "set_correlation_id", + "reset_correlation_id", + ): + assert name in safeuploads.__all__ + + def test_upload_file_protocol_is_runtime_checkable(self): + class _Upload: + filename = "a.jpg" + size = 3 + + async def read(self, size: int = -1) -> bytes: + return b"" + + async def seek(self, offset: int) -> int: + return offset + + assert isinstance(_Upload(), safeuploads.UploadFileProtocol) diff --git a/uv.lock b/uv.lock index ef8f08c..31ad5e1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version < '3.15'", @@ -34,6 +34,7 @@ version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ @@ -118,6 +119,32 @@ version = "3.4.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, @@ -187,6 +214,36 @@ version = "7.15.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, @@ -235,6 +292,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -336,6 +398,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/c4/d528d58d9c348cf66ff6fd9f83719ab3e23a65cd071bf80d90510d39d79a/hypothesis-6.157.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7da3d279d21938d233a665b2b2fe24c6467de302948d1480491c44fc4f4c66f4", size = 1290441, upload-time = "2026-07-20T14:10:12.004Z" }, { url = "https://files.pythonhosted.org/packages/54/f5/179d6f92a6b2a5d695ce5c90b55f91921ff024d8b9b12aa38bf84d27a576/hypothesis-6.157.1-cp310-abi3-win32.whl", hash = "sha256:7da995675e3c58aa989cd9559340937c5e7a36abcc8fdc5461f63581f4a3cb3b", size = 636948, upload-time = "2026-07-20T14:09:33.961Z" }, { url = "https://files.pythonhosted.org/packages/23/3a/396dbcc9d79e5c2490a7695cfe7d56f2bd58c8db55dc9df15febd084fa6a/hypothesis-6.157.1-cp310-abi3-win_amd64.whl", hash = "sha256:e0f62dc347b97df897f897cf5244fa84f189507c81b8313081447ccc77864200", size = 642732, upload-time = "2026-07-20T14:09:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8e/101297bb001c178d61501e7efc9a7d4df0709e013b1c2fb3d95d77ea0a8c/hypothesis-6.157.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e407d3c6945d5dd63c6594a08311b250a05a8d9687819c04cc29d6f451b88cb1", size = 750342, upload-time = "2026-07-20T14:09:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b4/3a093601e2ef8beb9ab33de4b3057103e597fb8c1d71bd5807f86d472eef/hypothesis-6.157.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:79964a5d38aa0451a3b27b6b58cadd5b5e79407cd77ed5dff6b4c1efa939a091", size = 745090, upload-time = "2026-07-20T14:10:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/32/f6/3b87d53e45ab589f4a9f4fdb2f563a490ce41895d0bc9449fd7cde20537c/hypothesis-6.157.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a91879b8b30789506733c9f4487743b44f4b9ad07b609e6ab7a6240ef057a5", size = 1072620, upload-time = "2026-07-20T14:09:19.076Z" }, + { url = "https://files.pythonhosted.org/packages/24/e7/d29a0a160f43caee6a3b9a5e24e82b5a24e431ef6ac7f8550932dc2e9899/hypothesis-6.157.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7c45f1a955c930fa71df861237bc6db80e7e9c257051d069c8ef59914f088ec", size = 1123814, upload-time = "2026-07-20T14:09:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/d563ec371d5bb9da8ca041a05c7e3108240e5e42f9ba9bf501ff0d8b118d/hypothesis-6.157.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:514b557299947b1c68cb2b841f6c7c0f4893eaab0081df370b2a04db34f5dbb5", size = 1247523, upload-time = "2026-07-20T14:09:09.374Z" }, + { url = "https://files.pythonhosted.org/packages/65/6e/516f57ba3bcfd36303381144fbd6f60be57a4ec61020392af8133bc243fc/hypothesis-6.157.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7518035104ebfdf8f3438937e50c2a3eff6aa21e26590653bec9f9a5b2c7b4ff", size = 1290802, upload-time = "2026-07-20T14:09:20.311Z" }, + { url = "https://files.pythonhosted.org/packages/fa/65/7093ae6ccbf006f75adb98e151c7e4460f5015c11d65d64347c77bb280e5/hypothesis-6.157.1-cp311-cp311-win_amd64.whl", hash = "sha256:8323a0f79793c351dc2cae1e47d0b4a3cdcc93d2b7397219c1afba4191f0cdd6", size = 642555, upload-time = "2026-07-20T14:10:20.663Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2d/750f5073499a61092bbe18bde939647f460cd8a83e22662a5b8dced4a77f/hypothesis-6.157.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:00047e7b366f6844b4d6fe797d610270d095cd7471a537f256b9077ab661e6a8", size = 750681, upload-time = "2026-07-20T14:09:24.124Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/431250ee8978c422e2ef99c24bc48329371fc9056b3800e60db5f20faae7/hypothesis-6.157.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d4d983473c710618ea65860652693c692382eadbacd9a066508e7febf7836ea8", size = 743238, upload-time = "2026-07-20T14:09:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4f/2579839bf22ea7ee18fee95ed03b2687d967c4002098360f25104f3e91b8/hypothesis-6.157.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:073b7361bbe24c3d1865f4bcba1b42d4b402abf7b2cccaa77dcd65cacf99335b", size = 1071567, upload-time = "2026-07-20T14:09:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/41/00/b8594a357af7bdcccc0fc3d6cceaf53ae9661552996fa94528ee5abb6a1a/hypothesis-6.157.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:026b9354ac4f251018c9ca9bc20da92d3ae1afd605682dc655d0dbcaa78ea005", size = 1122778, upload-time = "2026-07-20T14:09:38.206Z" }, + { url = "https://files.pythonhosted.org/packages/28/23/8215ff473a8b986cc138c80ccead07ab0ddeb09e5d3b3fa43885aa51c46e/hypothesis-6.157.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a905e9a208d12d0e9c9538ac10e721c1bc5893d5a643124e8ba73e43121da079", size = 1245818, upload-time = "2026-07-20T14:09:07.237Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/35145430f30d53461a5535c1aa52d0721c2f5e896c8c443fb6a910a267b2/hypothesis-6.157.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9cfb9ca5a0747e759aaa7f8eb6866b9186c4766d76ad761ef545ad502d16cce1", size = 1289401, upload-time = "2026-07-20T14:09:22.908Z" }, + { url = "https://files.pythonhosted.org/packages/77/ee/11d026f3a3f142236e32574a963985770a60e0448dd94025ed641b3c6d82/hypothesis-6.157.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aae63ff2e480209a9b4bc1a87800ba9a902c65597d4bb4e93cad070c1b068f5", size = 640053, upload-time = "2026-07-20T14:09:40.861Z" }, { url = "https://files.pythonhosted.org/packages/62/9c/8173ef621af943d54623c6a4ca082cc0825d4202545c6fad7b86549432c9/hypothesis-6.157.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f39b03615afd67fa89742dead1093a48628cb76469505782b83e3db06617eaa9", size = 751045, upload-time = "2026-07-20T14:09:55.137Z" }, { url = "https://files.pythonhosted.org/packages/cd/72/a8fdef2057fbd30bf160dffc64936414be0721e800fb016bf4fca3ebb12e/hypothesis-6.157.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7822852d34545be3730d9286e0254ea04d8acc0d162b01024574ce1f9b07db4d", size = 743558, upload-time = "2026-07-20T14:09:11.628Z" }, { url = "https://files.pythonhosted.org/packages/be/4b/03945f713197ae933e82ed9c08a2679ecad63fc8b2c3f1efb0f313e1a455/hypothesis-6.157.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa3b13f0ecfceab36d0649b15bd715f404b93b7c630f1a7dc538c08f3006922e", size = 1071773, upload-time = "2026-07-20T14:10:26.215Z" }, @@ -358,6 +434,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/87cda614eebb3c2014d3c1f73f2ad06f971e8424b683eab705af2662ece3/hypothesis-6.157.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:eadae326795e89a0b54b51ad69e63c963397dae872b9a080bec1982be20744c9", size = 1245045, upload-time = "2026-07-20T14:10:02.704Z" }, { url = "https://files.pythonhosted.org/packages/8d/46/ac41389596c91381b764faf58591e1e6227cc9f03c1c651863e8dac1cd92/hypothesis-6.157.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12806f82065632e521cf5719f470c5e81c8c83fb1ad68c1a7b70c4675be2452b", size = 1288799, upload-time = "2026-07-20T14:09:28.711Z" }, { url = "https://files.pythonhosted.org/packages/82/be/e0982c636695fdf5219e60bbd8fe86c3428001f49440569b0f7c479e0088/hypothesis-6.157.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ae7c0a0030953a1b11f6032fdf4c6cda2f6a501050864fcd70a069ab04032c6", size = 640186, upload-time = "2026-07-20T14:09:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/7faedcaceb202cfb634434b14f2d55ce390065426ec5a8054af3ef0755db/hypothesis-6.157.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab9bb8116424a863e2ea5aa07b90214d3a35236444352671d36ec3bafce91588", size = 751009, upload-time = "2026-07-20T14:09:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/15192bd90c03fa55253a64da5041be493f338f67eca9c7e9006ecf8c885b/hypothesis-6.157.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab4f818bd3de0bf520f4c36f5006cc5c9a3e54c42db672359d0e05ed76f5b9d", size = 745811, upload-time = "2026-07-20T14:09:58.063Z" }, + { url = "https://files.pythonhosted.org/packages/00/2c/2d7b3f1340c71b826ea2dbc89a526d3a16c6d96651aa23da13d1946b77e1/hypothesis-6.157.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf6a63b0d293d3f1ae9da9d210e86c163caf9a6b806c4281be3d3444ff788c23", size = 1072891, upload-time = "2026-07-20T14:09:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fc/34a702d85445ed86762570d818af16c5e116095977bf79d75ab155ddb381/hypothesis-6.157.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b340c3bb1ae10858c168f231a319e10f45824ec601948e41fc5835cd134073", size = 1124458, upload-time = "2026-07-20T14:09:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5e/ce4264fc58aa5c885c4a6e0e84498953721120fd41be5e4d8395b88877b4/hypothesis-6.157.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b6eb11b715d52005e8622248242c0f854f771828ef87e7e32438a7ed167f9442", size = 643128, upload-time = "2026-07-20T14:09:42.219Z" }, ] [[package]] @@ -396,6 +477,32 @@ version = "0.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, @@ -454,6 +561,28 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -641,6 +770,20 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, @@ -744,6 +887,36 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, @@ -789,6 +962,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -835,6 +1024,7 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -846,7 +1036,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage" }, + { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] @@ -891,6 +1081,25 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, @@ -1053,12 +1262,67 @@ version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "types-defusedxml" version = "0.7.0.20260504" @@ -1104,6 +1368,12 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, From 83834192db5c5e7f2cdbe525041872360d960d6a Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:57:53 +0100 Subject: [PATCH 05/16] feat: enhance audit logging with client IP tracking and refine validation error handling --- CHANGELOG.md | 37 +++++++ docs/security/integration-checklist.md | 2 + docs/security/threat-model.md | 10 +- safeuploads/__init__.py | 2 + safeuploads/audit.py | 25 ++++- safeuploads/config.py | 6 +- safeuploads/enums.py | 12 --- safeuploads/exceptions.py | 8 -- safeuploads/file_validator.py | 63 ++++++------ safeuploads/validators/base.py | 27 ++---- .../validators/compression_validator.py | 50 ---------- safeuploads/validators/extension_validator.py | 13 --- safeuploads/validators/unicode_validator.py | 12 --- safeuploads/validators/windows_validator.py | 27 ------ safeuploads/validators/xml_validator.py | 18 ---- tests/test_audit.py | 97 +++++++++++++++++++ tests/test_exceptions.py | 5 - .../validators/test_compression_validator.py | 31 ++---- tests/validators/test_extension_validator.py | 11 --- tests/validators/test_unicode_validator.py | 9 -- tests/validators/test_windows_validator.py | 11 --- tests/validators/test_xml_validator.py | 14 --- 22 files changed, 223 insertions(+), 267 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca968a6..610e7fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,9 +49,46 @@ project adheres to the top-level package. `UploadFileProtocol` is the interface a non-FastAPI framework adapter implements, so it belonged in the public API alongside `SeekableFile`. +- `set_source_ip()`, which attaches the client address to every audit + event in the current context. `AuditEvent.source_ip` existed but was + never populated. + +### Removed + +- **Breaking:** the `validate()` alias on every validator, and the + `BaseValidator` abstract method behind it. The abstraction was + false: each validator takes different arguments, so the "uniform" + interface could never be used polymorphically, and its + `*args: Any, **kwargs: Any` signature was the only untyped surface + in the package. Call the purpose-named method instead + (`validate_unicode_security`, `validate_extensions`, + `validate_windows_reserved_names`, `validate_zip_compression_ratio`, + `validate_xml_safety`). `BaseValidator` remains as a plain base + class, matching `BaseInspector`. +- **Breaking:** eight `ErrorCode` members that no code path could ever + produce: `FILE_SIZE_UNKNOWN`, `MIME_DETECTION_FAILED`, + `FILE_SIGNATURE_INVALID`, `ZIP_INVALID_STRUCTURE`, + `ZIP_DIRECTORY_TRAVERSAL`, `ZIP_SYMLINK_DETECTED`, + `ZIP_ABSOLUTE_PATH`, and `MEMORY_ERROR`. +- **Breaking:** three unused `ZipThreatCategory` members that held + marker strings rather than extensions: `RECURSIVE_STRUCTURE`, + `QUINE_ARCHIVE`, and `COMPLEXITY_ATTACK`. The corresponding + `ErrorCode` values are unaffected and still raised. +- A redundant entry-count check in `CompressionSecurityValidator` that + applied `max_total_entries_recursive` to a single flat archive. That + limit counts entries across nesting levels and is enforced in + `ZipContentInspector`; a flat archive is capped by + `max_zip_entries`. ### Changed +- A breached resource budget is now audited as `RESOURCE_LIMIT` + instead of a generic `VALIDATION_FAILURE`. The integration checklist + already told integrators to alert on this event type, but nothing + emitted it. +- The file-signature table in `FileValidator` is a module constant + instead of a dict rebuilt on every validation. + - **Lowered the minimum supported Python from 3.13 to 3.11.** No source changes were required; `enum.StrEnum` was the only 3.11+ dependency. The full test suite passes on 3.11, 3.12, 3.13 and 3.14, and the CI diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index 1f6cd4d..14f9491 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -95,6 +95,8 @@ archive afterwards: (or parent `safeuploads` logger). - [ ] Structured log output configured (JSON formatter recommended for log aggregation). +- [ ] `set_source_ip()` called with the client address before + validating, so audit events can be attributed to a caller. - [ ] Log storage retention policy defined (minimum 90 days recommended for security incident investigation). - [ ] Alerting configured for `THREAT_DETECTED` and diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index d1ad30d..1f8d46c 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -126,7 +126,8 @@ ZIPs that cause infinite recursion during inspection. raises `ZIP_QUINE_DETECTED`. - `max_zip_depth` (default 10) limits nesting level. - `max_total_entries_recursive` (default 50,000) limits the - cumulative entry count across all nesting levels. + cumulative entry count across all nesting levels. A single + flat archive is capped by `max_zip_entries` instead. - `ZIP_RECURSIVE_STRUCTURE` and `ZIP_COMPLEXITY_ATTACK` error codes provide precise feedback. @@ -418,9 +419,14 @@ threat detections) go unlogged, preventing incident response. - `SecurityAuditLogger` emits structured log records under the `safeuploads.audit` logger for every validation start, - success, failure, and threat detection. + success, failure, and threat detection. A breached resource + budget is recorded as `RESOURCE_LIMIT` rather than a generic + failure, so it can be alerted on separately. - Correlation IDs (via `contextvars`) link all log messages from a single validation call. +- `set_source_ip()` attaches the client address to every audit + event in the current context. safeuploads never sees the + request, so the application supplies it. - Audit logging is off by default (`enable_audit_logging= False`) to avoid noise in development, enabled in production. diff --git a/safeuploads/__init__.py b/safeuploads/__init__.py index 87614b8..7f5c667 100644 --- a/safeuploads/__init__.py +++ b/safeuploads/__init__.py @@ -12,6 +12,7 @@ get_correlation_id, reset_correlation_id, set_correlation_id, + set_source_ip, ) # Core classes and configurations @@ -132,4 +133,5 @@ "get_correlation_id", "set_correlation_id", "reset_correlation_id", + "set_source_ip", ] diff --git a/safeuploads/audit.py b/safeuploads/audit.py index eef7a6d..4eac628 100644 --- a/safeuploads/audit.py +++ b/safeuploads/audit.py @@ -64,6 +64,25 @@ def reset_correlation_id() -> None: correlation_id_var.set(None) +source_ip_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "safeuploads_source_ip", default=None +) + + +def set_source_ip(ip: str | None) -> None: + """ + Record the client address for audit events in this context. + + safeuploads never sees the request, so the application sets + this from its own framework before validating. Pass None to + clear it. + + Args: + ip: Client address, or None to clear. + """ + source_ip_var.set(ip) + + def log_extra( extra: dict[str, Any] | None = None, ) -> dict[str, Any]: @@ -129,7 +148,7 @@ class AuditEvent: result: str = "" details: str = "" duration_ms: float = 0.0 - source_ip: str | None = None + source_ip: str | None = field(default_factory=source_ip_var.get) timestamp: float = field(default_factory=time.monotonic) @@ -259,6 +278,7 @@ def failure( duration_ms: float, error: str, details: str = "", + event_type: AuditEventType = AuditEventType.VALIDATION_FAILURE, ) -> None: """ Log a validation failure event. @@ -269,10 +289,11 @@ def failure( duration_ms: Validation duration in milliseconds. error: Short error description. details: Additional failure context. + event_type: Category to record the failure under. """ self.log_event( AuditEvent( - event_type=(AuditEventType.VALIDATION_FAILURE), + event_type=event_type, correlation_id=correlation_id, filename=filename, result=error, diff --git a/safeuploads/config.py b/safeuploads/config.py index 4d38779..77768b7 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -1324,7 +1324,7 @@ def _report_errors( ) # Raise exception if there are errors and strict mode is enabled - if error_list and strict: + if strict and error_list: raise FileSecurityConfigurationError(error_list) - if (error_list or warning_list) and strict: - raise FileSecurityConfigurationError(error_list + warning_list) + if strict and warning_list: + raise FileSecurityConfigurationError(warning_list) diff --git a/safeuploads/enums.py b/safeuploads/enums.py index cd477fb..3a2b7bc 100644 --- a/safeuploads/enums.py +++ b/safeuploads/enums.py @@ -423,9 +423,6 @@ class ZipThreatCategory(Enum): EXECUTABLE_FILES: Executable content threats. SCRIPT_FILES: Script and code threats. SYSTEM_FILES: System and configuration threats. - RECURSIVE_STRUCTURE: Self-referencing ZIP structures. - QUINE_ARCHIVE: ZIP that contains a copy of itself. - COMPLEXITY_ATTACK: Algorithmic complexity exploits. """ # Archive format threats @@ -498,15 +495,6 @@ class ZipThreatCategory(Enum): ".ini", } - # Self-referencing ZIP structures - RECURSIVE_STRUCTURE = {"recursive_zip"} - - # ZIP that contains a copy of itself - QUINE_ARCHIVE = {"quine_zip"} - - # Algorithmic complexity exploits - COMPLEXITY_ATTACK = {"complexity_attack"} - class MalwareSignatureCategory(Enum): """ diff --git a/safeuploads/exceptions.py b/safeuploads/exceptions.py index d5154cd..7f719b5 100644 --- a/safeuploads/exceptions.py +++ b/safeuploads/exceptions.py @@ -89,15 +89,12 @@ class ErrorCode(StrEnum): # File size errors FILE_TOO_LARGE = "FILE_TOO_LARGE" FILE_EMPTY = "FILE_EMPTY" - FILE_SIZE_UNKNOWN = "FILE_SIZE_UNKNOWN" # MIME type errors MIME_TYPE_INVALID = "MIME_TYPE_INVALID" MIME_TYPE_MISMATCH = "MIME_TYPE_MISMATCH" - MIME_DETECTION_FAILED = "MIME_DETECTION_FAILED" # File signature errors - FILE_SIGNATURE_INVALID = "FILE_SIGNATURE_INVALID" FILE_SIGNATURE_MISSING = "FILE_SIGNATURE_MISSING" FILE_SIGNATURE_MISMATCH = "FILE_SIGNATURE_MISMATCH" @@ -116,13 +113,9 @@ class ErrorCode(StrEnum): ZIP_CONTENT_THREAT = "ZIP_CONTENT_THREAT" COMPRESSION_RATIO_EXCEEDED = "COMPRESSION_RATIO_EXCEEDED" ZIP_TOO_MANY_ENTRIES = "ZIP_TOO_MANY_ENTRIES" - ZIP_INVALID_STRUCTURE = "ZIP_INVALID_STRUCTURE" ZIP_CORRUPT = "ZIP_CORRUPT" ZIP_TOO_LARGE = "ZIP_TOO_LARGE" ZIP_NESTED_ARCHIVE = "ZIP_NESTED_ARCHIVE" - ZIP_DIRECTORY_TRAVERSAL = "ZIP_DIRECTORY_TRAVERSAL" - ZIP_SYMLINK_DETECTED = "ZIP_SYMLINK_DETECTED" - ZIP_ABSOLUTE_PATH = "ZIP_ABSOLUTE_PATH" ZIP_ANALYSIS_TIMEOUT = "ZIP_ANALYSIS_TIMEOUT" ZIP_RECURSIVE_STRUCTURE = "ZIP_RECURSIVE_STRUCTURE" ZIP_QUINE_DETECTED = "ZIP_QUINE_DETECTED" @@ -136,7 +129,6 @@ class ErrorCode(StrEnum): # Processing errors PROCESSING_ERROR = "PROCESSING_ERROR" IO_ERROR = "IO_ERROR" - MEMORY_ERROR = "MEMORY_ERROR" # ============================================================================ diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index 8a52a62..f2e6fbb 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -29,6 +29,7 @@ from .protocols import UploadFileProtocol as UploadFile from .audit import ( + AuditEventType, SecurityAuditLogger, reset_correlation_id, set_correlation_id, @@ -52,6 +53,7 @@ from .utils import ( ResourceMonitor, bytes_to_mb, + matches_signature_prefix, parse_image_dimensions, safe_label, ) @@ -72,6 +74,28 @@ # is treated as malformed rather than scanned indefinitely. _IMAGE_DIMENSION_SCAN_BYTES = 1024 * 1024 +# Header bytes that identify each accepted format. Kept separate +# from the threat signatures in ``enums`` on purpose: these +# answer "is this the format we asked for", not "is this a +# threat". +_FILE_SIGNATURES: dict[str, tuple[bytes, ...]] = { + "image": ( + b"\xff\xd8\xff", # JPEG + b"\xff\xd8\xff\xe1", # JPEG EXIF (additional JPEG variant) + b"\x89PNG\r\n\x1a\n", # PNG + ), + "zip": ( + b"PK\x03\x04", # ZIP file + b"PK\x05\x06", # Empty ZIP + b"PK\x07\x08", # ZIP with spanning + ), + "gzip": (b"\x1f\x8b",), # gzip magic number + "activity": ( + b" Any: - """ - Validate data using subclass-specific logic. - - Args: - *args: Positional arguments for concrete validator. - **kwargs: Keyword arguments for concrete validator. - - Returns: - Validated result defined by subclass. - """ diff --git a/safeuploads/validators/compression_validator.py b/safeuploads/validators/compression_validator.py index e3da2b7..98bac9f 100644 --- a/safeuploads/validators/compression_validator.py +++ b/safeuploads/validators/compression_validator.py @@ -368,29 +368,6 @@ def validate_zip_compression_ratio( error_code=ErrorCode.ZIP_NESTED_ARCHIVE, ) - # Cumulative entry count check for - # complexity attack prevention - max_recursive = self.config.limits.max_total_entries_recursive - if file_count > max_recursive: - logger.error( - "ZIP entry count exceeds recursive limit", - extra=log_extra( - { - "file_count": file_count, - "max_recursive": max_recursive, - } - ), - ) - raise CompressionSecurityError( - message=( - "ZIP entry count" - f" ({file_count})" - " exceeds recursive limit" - f" ({max_recursive})" - ), - error_code=(ErrorCode.ZIP_COMPLEXITY_ATTACK), - ) - # Optional: read every entry through zipfile to # confirm the declared metadata is not forged. if self.config.limits.verify_zip_decompression: @@ -482,30 +459,3 @@ def _verify_entries_decompress( while stream.read(chunk_size): if monitor is not None: monitor.check() - - def validate( - self, - file_obj: SeekableFile, - compressed_size: int, - monitor: ResourceMonitor | None = None, - ) -> None: - """ - Validate the compression ratio of a ZIP file. - - Args: - file_obj: Seekable file-like object of the ZIP. - compressed_size: Size of the file after compression - in bytes. - monitor: Optional resource monitor checked once per - entry so a runaway archive is aborted mid-scan. - - Raises: - ZipBombError: If compression ratio exceeds maximum. - CompressionSecurityError: If ZIP structure is invalid. - ResourceLimitError: If the monitor's time or memory - limit is exceeded during validation. - FileProcessingError: If unexpected error occurs. - """ - return self.validate_zip_compression_ratio( - file_obj, compressed_size, monitor - ) diff --git a/safeuploads/validators/extension_validator.py b/safeuploads/validators/extension_validator.py index f9405e7..3c6253d 100644 --- a/safeuploads/validators/extension_validator.py +++ b/safeuploads/validators/extension_validator.py @@ -102,16 +102,3 @@ def validate_extensions(self, filename: str) -> None: ) logger.debug("Extension validation passed") - - def validate(self, filename: str) -> None: - """ - Validate the given filename. - - Args: - filename: Name of the file to validate. - - Raises: - ExtensionSecurityError: If filename extension is not - permitted. - """ - return self.validate_extensions(filename) diff --git a/safeuploads/validators/unicode_validator.py b/safeuploads/validators/unicode_validator.py index 77a281a..6f099d0 100644 --- a/safeuploads/validators/unicode_validator.py +++ b/safeuploads/validators/unicode_validator.py @@ -148,15 +148,3 @@ def validate_unicode_security(self, filename: str) -> str: logger.debug("Unicode validation passed") return normalized_filename - - def validate(self, filename: str) -> str: - """ - Validate a filename for Unicode security issues. - - Args: - filename: The name of the file to assess. - - Returns: - The validated and normalized filename. - """ - return self.validate_unicode_security(filename) diff --git a/safeuploads/validators/windows_validator.py b/safeuploads/validators/windows_validator.py index 0129e5d..3c56963 100644 --- a/safeuploads/validators/windows_validator.py +++ b/safeuploads/validators/windows_validator.py @@ -4,16 +4,11 @@ import logging import os -from typing import TYPE_CHECKING from ..exceptions import WindowsReservedNameError from ..utils import safe_label from .base import BaseValidator -if TYPE_CHECKING: - from ..config import FileSecurityConfig - - logger = logging.getLogger(__name__) @@ -25,15 +20,6 @@ class WindowsSecurityValidator(BaseValidator): config: File security configuration settings. """ - def __init__(self, config: FileSecurityConfig): - """ - Initialize the validator. - - Args: - config: File security configuration settings. - """ - super().__init__(config) - def validate_windows_reserved_names(self, filename: str) -> None: """ Validate filename against Windows reserved device names. @@ -95,16 +81,3 @@ def validate_windows_reserved_names(self, filename: str) -> None: current_name = name_without_ext logger.debug("No Windows reserved name detected") - - def validate(self, filename: str) -> None: - """ - Validate filename against Windows reserved naming rules. - - Args: - filename: The filename to validate. - - Raises: - WindowsReservedNameError: If filename matches a Windows - reserved device name. - """ - return self.validate_windows_reserved_names(filename) diff --git a/safeuploads/validators/xml_validator.py b/safeuploads/validators/xml_validator.py index 1ac1e24..1990000 100644 --- a/safeuploads/validators/xml_validator.py +++ b/safeuploads/validators/xml_validator.py @@ -188,21 +188,3 @@ def _enforce_root( ), error_code=ErrorCode.XML_INVALID_ROOT, ) - - def validate( - self, - file_obj: SeekableFile, - expected_root: str | None = None, - ) -> None: - """ - Validate XML file for security threats. - - Args: - file_obj: Seekable file containing XML data. - expected_root: Required root element name, lower-cased - and namespace-free. - - Raises: - FileProcessingError: If the XML fails safety checks. - """ - return self.validate_xml_safety(file_obj, expected_root) diff --git a/tests/test_audit.py b/tests/test_audit.py index 9f7f35b..f143de4 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -11,6 +11,7 @@ get_correlation_id, reset_correlation_id, set_correlation_id, + set_source_ip, ) from safeuploads.exceptions import ( CompressionSecurityError, @@ -188,6 +189,63 @@ def test_extra_fields_on_log_record(self, caplog): assert record.audit_source_ip == "" +class TestAuditSourceIp: + """The client address is carried on the context.""" + + def test_defaults_to_none(self): + """Test no source IP is recorded by default.""" + event = AuditEvent( + event_type=AuditEventType.VALIDATION_START, + correlation_id="cid", + ) + assert event.source_ip is None + + def test_context_value_populates_event(self): + """Test a set source IP reaches new events.""" + set_source_ip("203.0.113.7") + try: + event = AuditEvent( + event_type=AuditEventType.VALIDATION_START, + correlation_id="cid", + ) + finally: + set_source_ip(None) + + assert event.source_ip == "203.0.113.7" + + def test_source_ip_reaches_log_record(self, caplog): + """Test the emitted record carries the source IP. + + Args: + caplog: pytest log capture fixture. + """ + audit = SecurityAuditLogger(enabled=True) + set_source_ip("203.0.113.7") + try: + with caplog.at_level(logging.DEBUG, logger="safeuploads.audit"): + audit.start("photo.jpg", "cid-ip") + finally: + set_source_ip(None) + + assert caplog.records[0].audit_source_ip == "203.0.113.7" + + def test_source_ip_is_escaped(self, caplog): + """Test an untrusted forwarded address cannot inject. + + Args: + caplog: pytest log capture fixture. + """ + audit = SecurityAuditLogger(enabled=True) + set_source_ip("1.2.3.4\nWARNING forged") + try: + with caplog.at_level(logging.DEBUG, logger="safeuploads.audit"): + audit.start("photo.jpg", "cid-ip") + finally: + set_source_ip(None) + + assert "\n" not in caplog.records[0].audit_source_ip + + class TestAuditIntegration: """Test audit logging integration with FileValidator.""" @@ -309,6 +367,45 @@ async def test_correlation_id_reset_after_validation( await validator.validate_image_file(file) assert get_correlation_id() is None + @pytest.mark.asyncio + async def test_resource_limit_emits_dedicated_event( + self, mock_upload_file, valid_jpeg_bytes, caplog + ): + """Test a breached budget records a RESOURCE_LIMIT event. + + Args: + mock_upload_file: File factory fixture. + valid_jpeg_bytes: Valid JPEG bytes fixture. + caplog: pytest log capture fixture. + """ + from safeuploads.config import FileSecurityConfig, SecurityLimits + from safeuploads.exceptions import ResourceLimitError + from safeuploads.file_validator import FileValidator + + validator = FileValidator( + config=FileSecurityConfig( + SecurityLimits( + enable_audit_logging=True, + max_validation_time_seconds=0.0, + ) + ) + ) + file = mock_upload_file(filename="photo.jpg", content=valid_jpeg_bytes) + + with ( + caplog.at_level(logging.DEBUG, logger="safeuploads.audit"), + pytest.raises(ResourceLimitError), + ): + await validator.validate_image_file(file) + + types = [ + r.audit_event_type + for r in caplog.records + if r.name == "safeuploads.audit" + ] + assert "resource_limit" in types + assert "validation_failure" not in types + class TestThreatAuditEvents: """Test threat audit events from inspectors/validators.""" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 8d024ad..26cf928 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -47,15 +47,12 @@ def test_error_code_constants_exist(self): # File size errors assert hasattr(ErrorCode, "FILE_TOO_LARGE") assert hasattr(ErrorCode, "FILE_EMPTY") - assert hasattr(ErrorCode, "FILE_SIZE_UNKNOWN") # MIME type errors assert hasattr(ErrorCode, "MIME_TYPE_INVALID") assert hasattr(ErrorCode, "MIME_TYPE_MISMATCH") - assert hasattr(ErrorCode, "MIME_DETECTION_FAILED") # File signature errors - assert hasattr(ErrorCode, "FILE_SIGNATURE_INVALID") assert hasattr(ErrorCode, "FILE_SIGNATURE_MISSING") assert hasattr(ErrorCode, "FILE_SIGNATURE_MISMATCH") @@ -64,13 +61,11 @@ def test_error_code_constants_exist(self): assert hasattr(ErrorCode, "ZIP_CONTENT_THREAT") assert hasattr(ErrorCode, "COMPRESSION_RATIO_EXCEEDED") assert hasattr(ErrorCode, "ZIP_TOO_MANY_ENTRIES") - assert hasattr(ErrorCode, "ZIP_INVALID_STRUCTURE") assert hasattr(ErrorCode, "ZIP_CORRUPT") # Processing errors assert hasattr(ErrorCode, "PROCESSING_ERROR") assert hasattr(ErrorCode, "IO_ERROR") - assert hasattr(ErrorCode, "MEMORY_ERROR") def test_error_codes_are_strings(self): """Verify error codes are string values.""" diff --git a/tests/validators/test_compression_validator.py b/tests/validators/test_compression_validator.py index fd5074c..adba87e 100644 --- a/tests/validators/test_compression_validator.py +++ b/tests/validators/test_compression_validator.py @@ -91,22 +91,6 @@ def test_validate_normal_zip(self, default_config, create_zip_file): io.BytesIO(zip_bytes), len(zip_bytes) ) - def test_validate_method_delegates_correctly( - self, default_config, create_zip_file - ): - """ - Test that validate() method delegates to - validate_zip_compression_ratio(). - """ - validator = CompressionSecurityValidator(default_config) - zip_bytes = create_zip_file() - - # Both methods should work identically - validator.validate(io.BytesIO(zip_bytes), len(zip_bytes)) - validator.validate_zip_compression_ratio( - io.BytesIO(zip_bytes), len(zip_bytes) - ) - def test_reject_corrupted_zip(self, default_config): """Test rejection of corrupted ZIP files.""" validator = CompressionSecurityValidator(default_config) @@ -769,12 +753,13 @@ def test_overall_compression_ratio_exceeded(self) -> None: error_msg = str(exc_info.value).lower() assert "overall compression ratio" in error_msg - def test_complexity_attack_entry_count(self): - """Test rejection when entries exceed recursive limit.""" - config = FileSecurityConfig() - config.limits = SecurityLimits( - max_zip_entries=100000, - max_total_entries_recursive=5, + def test_flat_entry_count_uses_max_zip_entries(self): + """Test the flat entry cap is the only one that applies.""" + config = FileSecurityConfig( + SecurityLimits( + max_zip_entries=5, + max_total_entries_recursive=100000, + ) ) validator = CompressionSecurityValidator(config) @@ -788,7 +773,7 @@ def test_complexity_attack_entry_count(self): validator.validate_zip_compression_ratio( io.BytesIO(zip_bytes), len(zip_bytes) ) - assert exc_info.value.error_code == ErrorCode.ZIP_COMPLEXITY_ATTACK + assert exc_info.value.error_code == ErrorCode.ZIP_TOO_MANY_ENTRIES class TestCompressionValidatorNestedAllowed: diff --git a/tests/validators/test_extension_validator.py b/tests/validators/test_extension_validator.py index eab6978..78f1f44 100644 --- a/tests/validators/test_extension_validator.py +++ b/tests/validators/test_extension_validator.py @@ -126,17 +126,6 @@ def test_case_insensitive_compound_extension(self, default_config): with pytest.raises(ExtensionSecurityError): validator.validate_extensions("archive.Tar.Gz") - def test_validate_method_delegates_correctly(self, default_config): - """Test that validate() method delegates to validate_extensions().""" - validator = ExtensionSecurityValidator(default_config) - - # Should not raise for safe extension - validator.validate("safe.txt") - - # Should raise for dangerous extension - with pytest.raises(ExtensionSecurityError): - validator.validate("dangerous.exe") - def test_all_parts_checked_for_dangerous_extensions(self, default_config): """Test that all extension parts are checked.""" validator = ExtensionSecurityValidator(default_config) diff --git a/tests/validators/test_unicode_validator.py b/tests/validators/test_unicode_validator.py index 41cc38e..6f0778a 100644 --- a/tests/validators/test_unicode_validator.py +++ b/tests/validators/test_unicode_validator.py @@ -98,15 +98,6 @@ def test_unicode_normalization_nfc(self, default_config): assert "\u0301" not in result # No combining character assert "é" in result # Has composed é - def test_validate_method_delegates_to_validate_unicode_security( - self, default_config - ): - """Test that validate() method delegates correctly.""" - validator = UnicodeSecurityValidator(default_config) - filename = "test.txt" - result = validator.validate(filename) - assert result == filename - def test_dangerous_char_position_tracking(self, default_config): """Test that character positions are correctly tracked.""" validator = UnicodeSecurityValidator(default_config) diff --git a/tests/validators/test_windows_validator.py b/tests/validators/test_windows_validator.py index d9ffdc9..35d3998 100644 --- a/tests/validators/test_windows_validator.py +++ b/tests/validators/test_windows_validator.py @@ -137,17 +137,6 @@ def test_filename_containing_but_not_matching_reserved( validator.validate_windows_reserved_names("context.log") validator.validate_windows_reserved_names("acon.txt") - def test_validate_method_delegates_correctly(self, default_config): - """Test that validate() method delegates correctly.""" - validator = WindowsSecurityValidator(default_config) - - # Should not raise for safe filename - validator.validate("normal.txt") - - # Should raise for reserved name - with pytest.raises(WindowsReservedNameError): - validator.validate("CON.txt") - def test_error_includes_filename(self, default_config): """Test that error includes the problematic filename.""" validator = WindowsSecurityValidator(default_config) diff --git a/tests/validators/test_xml_validator.py b/tests/validators/test_xml_validator.py index 08d5f57..dc7cd76 100644 --- a/tests/validators/test_xml_validator.py +++ b/tests/validators/test_xml_validator.py @@ -101,20 +101,6 @@ def test_reject_non_xml_content(self, default_config): with pytest.raises(FileProcessingError): validator.validate_xml_safety(file_obj) - def test_validate_delegates_to_validate_xml_safety(self, default_config): - """Test validate() delegates correctly.""" - validator = XmlSecurityValidator(default_config) - valid_xml = b"" - file_obj = io.BytesIO(valid_xml) - validator.validate(file_obj) - - def test_validate_forwards_expected_root(self, default_config): - """Test validate() passes the expected root through.""" - validator = XmlSecurityValidator(default_config) - file_obj = io.BytesIO(b"") - with pytest.raises(FileProcessingError, match="root element"): - validator.validate(file_obj, "trainingcenterdatabase") - def test_file_position_reset_after_validation(self, default_config): """Test file position is reset after validation.""" validator = XmlSecurityValidator(default_config) From ed25b344169b900ea28364a3e6d2a5faa078ebbf Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:25:20 +0100 Subject: [PATCH 06/16] refactor: code structure for improved readability and maintainability --- .github/workflows/codeql.yml | 58 ++++ .github/workflows/mutation.yml | 85 ++++++ .github/workflows/scorecard.yml | 65 ++++ .gitignore | 7 +- CHANGELOG.md | 15 + README.md | 14 + docs/index.md | 14 + docs/security/integration-checklist.md | 4 + pyproject.toml | 26 ++ tests/corpus/__init__.py | 1 + tests/corpus/test_attack_corpus.py | 400 +++++++++++++++++++++++++ tests/test_utils.py | 9 + uv.lock | 269 ++++++++++++++++- 13 files changed, 965 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/mutation.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 tests/corpus/__init__.py create mode 100644 tests/corpus/test_attack_corpus.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..8c3d451 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,58 @@ +name: CodeQL + +# `pip-audit` covers known vulnerabilities in our dependencies; ruff's +# flake8-bandit rules cover single-line patterns in our own code. Neither does +# interprocedural taint tracking, which is what actually catches an untrusted +# filename reaching a filesystem or subprocess sink. That is CodeQL's job. + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + # Weekly, so a newly-published query pack is run against unchanged code. + - cron: '15 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze Python + runs-on: ubuntu-latest + permissions: + # Required to upload the SARIF result to the Security tab. + security-events: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + # `build-mode: none` is the correct setting for an interpreted language: + # CodeQL reads the sources directly instead of watching a compiler. + - name: Initialize CodeQL + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + languages: python + build-mode: none + # `security-extended` adds lower-severity and precision queries on top + # of the default pack. Appropriate for a security library, where a + # false positive costs far less than a missed sink. + queries: security-extended + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + category: /language:python diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..af524aa --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,85 @@ +name: Mutation testing + +# 98%+ line coverage says every line runs; it does not say anything asserted on +# the result. Mutation testing perturbs the source and reports which changes the +# suite fails to notice. Scheduled and non-blocking: a surviving mutant is a +# lead to investigate, not a build break. + +on: + schedule: + # Every Sunday at 05:00 UTC, after the fuzz run. + - cron: '0 5 * * 0' + workflow_dispatch: + inputs: + filter: + description: Mutant name glob, e.g. safeuploads.utils.* + required: false + default: '' + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + mutate: + name: Mutate and score + runs-on: ubuntu-latest + # The full run is a few thousand mutants; cap it so a pathological + # mutant that hangs cannot hold a runner all day. + timeout-minutes: 60 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libmagic1 + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version-file: pyproject.toml + + - name: Install dependencies + run: uv sync --frozen --dev --extra fastapi --group mutation + + # A failing suite makes every mutant "killed" and the score meaningless, + # so establish that the baseline is green before mutating anything. + - name: Verify the baseline suite passes + run: uv run pytest -q --no-cov -m "not performance and not fuzz" + + # The filter goes through the environment, never through `${{ }}` + # interpolation into the script body, which would let anyone able to + # dispatch the workflow inject shell. + - name: Run mutation testing + continue-on-error: true + env: + MUTMUT_FILTER: ${{ inputs.filter }} + run: | + set -euo pipefail + if [ -n "${MUTMUT_FILTER}" ]; then + uv run mutmut run --max-children 4 "${MUTMUT_FILTER}" + else + uv run mutmut run --max-children 4 + fi + + - name: Summarise surviving mutants + if: always() + run: | + set -euo pipefail + { + echo '## Surviving mutants' + echo + echo 'Each line is a source change the test suite did not notice.' + echo + echo '```' + uv run mutmut results || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..86e8080 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,65 @@ +name: OpenSSF Scorecard + +# Scores this repository's *supply-chain posture* rather than its code: are +# actions pinned to SHAs, are workflow permissions least-privilege, is branch +# protection on, are releases signed. Those are the properties a consumer of a +# security library cannot verify from the source alone. + +on: + branch_protection_rule: + push: + branches: + - main + schedule: + # Weekly, offset from the CodeQL run so the two do not contend. + - cron: '45 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + # Upload the SARIF result to the Security tab. + security-events: write + # Publish the result to the OpenSSF REST API so the badge resolves. + id-token: write + contents: read + actions: read + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + # Publishing makes the score readable by anyone evaluating the + # package, which is the point of running it. Requires a public repo. + publish_results: true + + # Retained separately from the Security tab so a score regression can be + # diffed against a specific run. + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: 7 + + - name: Upload to code-scanning + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 56d8275..c2e1acf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,9 @@ __pycache__ # Tests .coverage htmlcov/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ + +# Mutation testing +mutants/ +.mutmut-cache +mutants.sqlite diff --git a/CHANGELOG.md b/CHANGELOG.md index 610e7fe..802d400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,21 @@ project adheres to - `set_source_ip()`, which attaches the client address to every audit event in the current context. `AuditEvent.source_ip` existed but was never populated. +- CodeQL workflow (`security-extended` queries) and OpenSSF Scorecard + workflow. `pip-audit` covers vulnerable dependencies and ruff's + flake8-bandit rules cover single-line patterns; neither does + interprocedural taint tracking or scores supply-chain posture. +- Attack corpus under `tests/corpus/`: every threat the threat model + claims to stop is now a named, deterministically constructed sample + asserted to raise the documented error code. Samples are built at + test time rather than checked in, so the repository carries no + payload an antivirus scanner would quarantine. +- Scheduled, non-blocking mutation-testing workflow (`mutmut`) with a + `mutation` dependency group. It immediately found two unasserted + behaviours in `safe_label()` — the default length bound and the + truncation boundary — which are now covered. +- Release-verification instructions for consumers, covering PEP 740 + attestation checks with `pypi-attestations`. ### Removed diff --git a/README.md b/README.md index 834ef42..49c8300 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ For FastAPI integration: pip install safeuploads[fastapi] ``` +### Verifying a release + +Releases are built and published by [this repository's release workflow](.github/workflows/publish-safeuploads.yml) through PyPI Trusted Publishing, with [PEP 740](https://peps.python.org/pep-0740/) attestations. You can confirm a downloaded artifact came from that workflow and was not substituted: + +```bash +uvx pypi-attestations verify pypi \ + --repository https://github.com/endurain-project/safeuploads \ + pypi:safeuploads--py3-none-any.whl +``` + +A successful run prints `OK: `. `Provenance for file ... was not found` means the artifact predates attested publishing rather than that verification failed. + +Each release run also produces a CycloneDX SBOM and `SHA256SUMS`, generated from a clean install of the built wheel. These are retained as workflow artifacts on the release run rather than published to PyPI. + ## Quick Start ```python diff --git a/docs/index.md b/docs/index.md index 53e5203..42d7b7c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,6 +44,20 @@ For FastAPI integration: pip install safeuploads[fastapi] ``` +### Verifying a release + +Releases are built and published by this repository's release workflow through PyPI Trusted Publishing, with [PEP 740](https://peps.python.org/pep-0740/) attestations. You can confirm a downloaded artifact came from that workflow and was not substituted: + +```bash +uvx pypi-attestations verify pypi \ + --repository https://github.com/endurain-project/safeuploads \ + pypi:safeuploads--py3-none-any.whl +``` + +A successful run prints `OK: `. `Provenance for file ... was not found` means the artifact predates attested publishing rather than that verification failed. + +Each release run also produces a CycloneDX SBOM and `SHA256SUMS`, generated from a clean install of the built wheel. These are retained as workflow artifacts on the release run rather than published to PyPI. + ## Quick Start ```python diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index 14f9491..7ce8ead 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -154,6 +154,10 @@ archive afterwards: - [ ] `safeuploads` pinned to a specific version in `requirements.txt` or `pyproject.toml`. +- [ ] Release provenance verified before promoting a new version: + `uvx pypi-attestations verify pypi --repository + https://github.com/endurain-project/safeuploads + pypi:safeuploads--py3-none-any.whl`. - [ ] `pip-audit` or `safety` run in CI to detect known vulnerabilities in dependencies. - [ ] `defusedxml` and `python-magic` dependencies kept diff --git a/pyproject.toml b/pyproject.toml index 3ecd74d..9f11fb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,11 @@ docs = [ "mkdocs-material>=9.6.0", "mkdocstrings[python]>=0.29.0", ] +# Kept out of `dev` because a mutation run is scheduled, not something +# you want resolved on every local sync. +mutation = [ + "mutmut>=3.2,<4.0", +] [build-system] @@ -127,6 +132,7 @@ markers = [ "slow: Slow running tests", "performance: Performance and benchmarking tests", "fuzz: Hypothesis-based fuzzing tests", + "corpus: Regression corpus of known upload attacks", ] [tool.coverage.run] @@ -149,6 +155,26 @@ exclude_lines = [ "@abstractmethod", ] +[tool.mutmut] +source_paths = ["safeuploads/"] +pytest_add_cli_args_test_selection = ["tests/"] +# mutmut's trampoline cannot instrument the static methods that +# config.py calls from its own class body, so importing a mutated +# copy fails outright. Excluded rather than reshaping the module +# to suit the tool. +do_not_mutate = ["safeuploads/config.py"] +# Coverage reporting and the slow suites would be paid once per +# mutant, so drop them: mutation scoring only needs pass/fail. +pytest_add_cli_args = [ + "--no-cov", + "-p", + "no:cacheprovider", + "-m", + "not performance and not fuzz", +] +# Line coverage says a line ran; a surviving mutant says nothing +# asserted on it. Run on a schedule, read as a signal, not a gate. + [tool.mypy] python_version = "3.11" incremental = false diff --git a/tests/corpus/__init__.py b/tests/corpus/__init__.py new file mode 100644 index 0000000..3875fa3 --- /dev/null +++ b/tests/corpus/__init__.py @@ -0,0 +1 @@ +"""Regression corpus of known upload attacks.""" diff --git a/tests/corpus/test_attack_corpus.py b/tests/corpus/test_attack_corpus.py new file mode 100644 index 0000000..9377fda --- /dev/null +++ b/tests/corpus/test_attack_corpus.py @@ -0,0 +1,400 @@ +""" +Traceability corpus: every attack the threat model claims to stop. + +Each sample is a named, deterministically constructed file rather +than a checked-in binary, so the repository carries no payload that +an antivirus scanner would quarantine. Samples are structural — a +traversal entry name, a compression ratio, an XML entity — not +functional malware. + +Adding a threat to ``docs/security/threat-model.md`` without adding +it here should be treated as an incomplete change. +""" + +import gzip +import io +import os +import zipfile +from dataclasses import dataclass + +import pytest + +from safeuploads import ( + CompressionSecurityError, + ErrorCode, + ExtensionSecurityError, + FileProcessingError, + FileSecurityConfig, + FileSizeError, + FileValidator, + ImageSecurityError, + MimeTypeError, + SecurityLimits, + UnicodeSecurityError, + WindowsReservedNameError, + ZipBombError, + ZipContentError, +) +from tests.conftest import JPEG_SOF0 + +# ---------------------------------------------------------------- +# Builders +# ---------------------------------------------------------------- + + +def _png(width: int, height: int) -> bytes: + """Build a PNG header declaring the given dimensions.""" + return ( + b"\x89PNG\r\n\x1a\n" + + b"\x00\x00\x00\x0d" + + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + + b"\x08\x02\x00\x00\x00" + + b"\x00\x00\x00\x00" + ) + + +def _jpeg(extra: bytes = b"") -> bytes: + """Build a structurally valid JPEG with optional trailing bytes.""" + return b"\xff\xd8" + JPEG_SOF0 + extra + b"\xff\xd9" + + +def _zip( + files: dict[str, bytes], + compression: int = zipfile.ZIP_STORED, +) -> bytes: + """Build a ZIP archive from a name-to-content mapping.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=compression) as zf: + for name, data in files.items(): + zf.writestr(name, data) + return buffer.getvalue() + + +def _zip_with_symlink() -> bytes: + """Build a ZIP whose single entry is a symbolic link.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as zf: + info = zipfile.ZipInfo("link") + info.external_attr = 0o120777 << 16 + zf.writestr(info, "/etc/passwd") + return buffer.getvalue() + + +def _gpx(body: bytes = b"") -> bytes: + """Build a minimal GPX document.""" + return b'' + body + b"" + + +def _assert_cannot_escape(name: str) -> None: + """Assert a sanitized name stays inside its upload directory.""" + assert "/" not in name + assert "\\" not in name + assert os.path.basename(name) == name + + resolved = os.path.normpath(os.path.join("/srv/uploads", name)) + assert resolved.startswith("/srv/uploads/") + + +# ---------------------------------------------------------------- +# Corpus +# ---------------------------------------------------------------- + + +@dataclass(frozen=True) +class AttackSample: + """ + A single attack and the rejection it must produce. + + Attributes: + name: Threat name, matching the threat model. + filename: Name the file is uploaded under. + content: Raw bytes of the upload. + method: ``FileValidator`` method under test. + expected: Exception type the upload must raise. + error_code: Machine-readable code the error must carry. + limits: Optional limits needed to exercise the attack. + """ + + name: str + filename: str + content: bytes + method: str + expected: type[Exception] + error_code: str | None = None + limits: SecurityLimits | None = None + + +_CONTENT_ANALYSIS = SecurityLimits(enable_content_analysis=True) + +REJECTED: tuple[AttackSample, ...] = ( + # --- Filename attacks --- + AttackSample( + name="rtl-override-extension-spoof", + filename="photo\u202egpj.exe", + content=_jpeg(), + method="validate_image_file", + expected=UnicodeSecurityError, + error_code=ErrorCode.UNICODE_DANGEROUS_CHARS, + ), + AttackSample( + name="zero-width-joiner-filename", + filename="photo\u200d.jpg", + content=_jpeg(), + method="validate_image_file", + expected=UnicodeSecurityError, + error_code=ErrorCode.UNICODE_DANGEROUS_CHARS, + ), + AttackSample( + name="windows-reserved-device-name", + filename="CON.jpg", + content=_jpeg(), + method="validate_image_file", + expected=WindowsReservedNameError, + error_code=ErrorCode.WINDOWS_RESERVED_NAME, + ), + AttackSample( + name="double-extension-php", + filename="photo.php.jpg", + content=_jpeg(), + method="validate_image_file", + expected=ExtensionSecurityError, + error_code=ErrorCode.EXTENSION_BLOCKED, + ), + AttackSample( + name="disallowed-extension", + filename="photo.txt", + content=_jpeg(), + method="validate_image_file", + expected=ExtensionSecurityError, + error_code=ErrorCode.EXTENSION_NOT_ALLOWED, + ), + # --- Content / type confusion --- + AttackSample( + name="gif-masquerading-as-jpeg", + filename="photo.jpg", + content=b"GIF89a" + b"\x00" * 64, + method="validate_image_file", + expected=MimeTypeError, + ), + AttackSample( + name="empty-upload", + filename="photo.jpg", + content=b"", + method="validate_image_file", + expected=FileSizeError, + error_code=ErrorCode.FILE_EMPTY, + ), + AttackSample( + name="oversized-upload", + filename="photo.jpg", + content=_jpeg(b"\x00" * 4096), + method="validate_image_file", + expected=FileSizeError, + error_code=ErrorCode.FILE_TOO_LARGE, + limits=SecurityLimits(max_image_size=1024), + ), + # --- Image decompression bombs --- + AttackSample( + name="png-pixel-bomb", + filename="bomb.png", + content=_png(30000, 30000), + method="validate_image_file", + expected=ImageSecurityError, + error_code=ErrorCode.IMAGE_DIMENSIONS_EXCEEDED, + ), + AttackSample( + name="jpeg-without-frame-header", + filename="headerless.jpg", + content=b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00" + b"\x00\x01\x00\x01\x00\x00\xff\xd9", + method="validate_image_file", + expected=ImageSecurityError, + error_code=ErrorCode.IMAGE_DIMENSIONS_UNREADABLE, + ), + # --- Polyglot and embedded executables --- + AttackSample( + name="gifar-polyglot", + filename="poly.jpg", + content=_jpeg(b"\x00" * 64 + b"PK\x03\x04"), + method="validate_image_file", + expected=FileProcessingError, + limits=_CONTENT_ANALYSIS, + ), + AttackSample( + name="pe-header-embedded-in-image", + filename="dropper.jpg", + content=_jpeg(b"\x00" * 64 + b"MZ\x90\x00"), + method="validate_image_file", + expected=FileProcessingError, + limits=_CONTENT_ANALYSIS, + ), + # --- Compression attacks --- + AttackSample( + name="zip-bomb-compression-ratio", + filename="bomb.zip", + content=_zip({"big.txt": b"\x00" * 2_000_000}, zipfile.ZIP_DEFLATED), + method="validate_zip_file", + expected=ZipBombError, + error_code=ErrorCode.COMPRESSION_RATIO_EXCEEDED, + ), + AttackSample( + name="zip-slip-traversal-entry", + filename="slip.zip", + content=_zip({"../../etc/passwd": b"root:x:0:0"}), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-absolute-path-entry", + filename="abs.zip", + content=_zip({"/etc/shadow": b"secret"}), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-symlink-entry", + filename="link.zip", + content=_zip_with_symlink(), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-webshell-entry-extension", + filename="shell.zip", + content=_zip({"invoice.php.txt": b"harmless looking text"}), + method="validate_zip_file", + expected=ZipContentError, + error_code=ErrorCode.ZIP_CONTENT_THREAT, + ), + AttackSample( + name="zip-nested-archive", + filename="nested.zip", + content=_zip({"inner.zip": _zip({"a.txt": b"a"})}), + method="validate_zip_file", + expected=CompressionSecurityError, + error_code=ErrorCode.ZIP_NESTED_ARCHIVE, + ), + AttackSample( + name="gzip-decompression-bomb", + filename="bomb.gz", + content=gzip.compress(b"\x00" * 2_000_000), + method="validate_gzip_file", + expected=ZipBombError, + error_code=ErrorCode.COMPRESSION_RATIO_EXCEEDED, + ), + # --- XML attacks --- + AttackSample( + name="xxe-external-entity", + filename="track.gpx", + content=b'' + b']>' + b"&xxe;", + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, + ), + AttackSample( + name="billion-laughs", + filename="track.gpx", + content=b'' + b"' + b'' + b"]>" + b"&lol2;", + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_FORBIDDEN_CONSTRUCT, + ), + AttackSample( + name="html-payload-behind-gpx-extension", + filename="track.gpx", + content=b'' + b"", + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_INVALID_ROOT, + ), + AttackSample( + name="xml-element-flood", + filename="track.gpx", + content=_gpx(b"" * 200), + method="validate_activity_file", + expected=FileProcessingError, + error_code=ErrorCode.XML_TOO_MANY_ELEMENTS, + limits=SecurityLimits(max_xml_elements=10), + ), +) + + +@pytest.mark.corpus +class TestAttackCorpus: + """Every catalogued attack must be rejected.""" + + @pytest.mark.parametrize( + "sample", REJECTED, ids=[s.name for s in REJECTED] + ) + @pytest.mark.asyncio + async def test_attack_is_rejected(self, sample, mock_upload_file): + config = ( + FileSecurityConfig(sample.limits) + if sample.limits is not None + else FileSecurityConfig() + ) + validator = FileValidator(config=config) + file = mock_upload_file( + filename=sample.filename, content=sample.content + ) + + with pytest.raises(sample.expected) as exc_info: + await getattr(validator, sample.method)(file) + + if sample.error_code is not None: + assert exc_info.value.error_code == sample.error_code + + def test_every_sample_is_uniquely_named(self): + names = [s.name for s in REJECTED] + assert len(names) == len(set(names)) + + +@pytest.mark.corpus +class TestNeutralisedAttacks: + """Attacks defused by sanitization rather than rejection.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "filename", + [ + "../../../etc/passwd.jpg", + "..\\..\\windows\\system32\\evil.jpg", + "/absolute/path/photo.jpg", + "....//....//photo.jpg", + ], + ) + async def test_sanitized_name_cannot_escape_a_directory( + self, filename, mock_upload_file + ): + validator = FileValidator() + file = mock_upload_file(filename=filename, content=_jpeg()) + + await validator.validate_image_file(file) + + # Leading dots may survive as literal characters; with no + # separator left they cannot traverse. + _assert_cannot_escape(file.filename) + + @pytest.mark.asyncio + async def test_control_characters_are_stripped(self, mock_upload_file): + validator = FileValidator() + file = mock_upload_file( + filename="photo\x00\x07\x1b.jpg", content=_jpeg() + ) + + await validator.validate_image_file(file) + + assert all(ord(c) >= 32 for c in file.filename) diff --git a/tests/test_utils.py b/tests/test_utils.py index e103788..0037bae 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -56,6 +56,15 @@ def test_truncates_long_values(self): result = safe_label("a" * 400, max_length=16) assert result == "a" * 16 + "..." + def test_exact_length_is_not_marked_truncated(self): + """Test the boundary case keeps the value intact.""" + assert safe_label("a" * 16, max_length=16) == "a" * 16 + + def test_default_length_bound(self): + """Test the documented default bound is applied.""" + assert safe_label("a" * 300) == "a" * 256 + "..." + assert safe_label("a" * 256) == "a" * 256 + class TestFindTextPattern: """Pattern scanning runs over raw bytes.""" diff --git a/uv.lock b/uv.lock index 31ad5e1..2b21bc9 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,9 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15'", - "python_full_version < '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", ] [options] @@ -471,6 +473,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "libcst" +version = "1.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "python_full_version != '3.13.*'" }, + { name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" }, + { url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" }, + { url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" }, + { url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" }, + { url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" }, + { url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" }, + { url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" }, +] + [[package]] name = "librt" version = "0.13.0" @@ -546,6 +608,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -555,6 +629,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -629,6 +720,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -757,6 +869,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, ] +[[package]] +name = "mutmut" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b0/ebcae42b90b07756b7aa10c4176835f436332e6c1cb28bc35bae83462382/mutmut-3.6.0.tar.gz", hash = "sha256:bcbd3e4d0d2d4edf3dfb42955417279a8866a3dbbcb87d619f2f3fd0ac7fafda", size = 51538, upload-time = "2026-06-06T07:44:51.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5a/a0caa3f9db407b5d12c311bd4c87aa67fdd6e3f329377149e303108a1c51/mutmut-3.6.0-py3-none-any.whl", hash = "sha256:a9f5b8dcf6cbf9496769d7cf8bdbba37a0ec709ad98f88d103238b62f10bdf37", size = 47770, upload-time = "2026-06-06T07:44:50.038Z" }, +] + [[package]] name = "mypy" version = "2.3.0" @@ -1142,6 +1271,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" }, + { url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" }, + { url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" }, + { url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1157,6 +1310,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.22" @@ -1212,6 +1378,9 @@ docs = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, ] +mutation = [ + { name = "mutmut" }, +] [package.metadata] requires-dist = [ @@ -1237,6 +1406,78 @@ docs = [ { name = "mkdocs-material", specifier = ">=9.6.0" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=0.29.0" }, ] +mutation = [{ name = "mutmut", specifier = ">=3.2,<4.0" }] + +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, +] [[package]] name = "six" @@ -1269,6 +1510,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1353,6 +1611,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From e6be66449afe3d252973327049fa24863915ddc2 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:39:44 +0100 Subject: [PATCH 07/16] feat: release version 2.0.0 with breaking changes, enhanced memory management, and improved security measures --- .github/copilot-instructions.md | 2 +- CHANGELOG.md | 152 +++++++++++++---------- docs/security/integration-checklist.md | 3 +- docs/security/threat-model.md | 4 +- pyproject.toml | 2 +- safeuploads/config.py | 27 ++++ safeuploads/inspectors/gzip_inspector.py | 1 + tests/inspectors/test_gzip_inspector.py | 3 +- tests/test_config_validation.py | 37 ++++++ uv.lock | 2 +- 10 files changed, 164 insertions(+), 69 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 42c1f6e..5112ec3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,7 +14,7 @@ - Ask for clarification if the scope is unclear rather than assuming additional deliverables are wanted. ## Style Expectations -- Target Python 3.13+. Use modern type hint syntax (`int | None`, `list[str]`, `dict[str, Any]`) instead of `Optional`, `List`, `Dict`, etc. +- Target Python 3.11+. Use modern type hint syntax (`int | None`, `list[str]`, `dict[str, Any]`) instead of `Optional`, `List`, `Dict`, etc. - Preserve async boundaries in validator methods; do not block event loops with synchronous I/O inside `async` functions. - Use module-level `logging.getLogger(__name__)` for security-relevant events; never rely on application-specific loggers. - Enforce PEP 8 line limits: diff --git a/CHANGELOG.md b/CHANGELOG.md index 802d400..4163085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,28 @@ The format is based on project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.0.0] - 2026-08-21 + +Major version because this release removes public symbols and changes +which uploads are accepted. Read the upgrade notes before bumping. + +### Upgrade notes + +1. **`max_validation_memory_mb` no longer fails a validation by + default.** This is the only change that makes safeuploads accept + something it previously rejected. If you relied on it, set + `enforce_memory_limit=True` — and only in a process that validates + one upload at a time. Configuration validation warns when the + budget is customised but enforcement is off. +2. **More uploads are rejected than before.** Images whose dimensions + cannot be read, arbitrary XML behind a `.gpx`/`.tcx` name, and ZIPs + containing executable, script, or system-file entries all now fail. + Re-run your own fixtures before deploying. +3. **The time budget aborts mid-flight.** Uploads that previously ran + past `max_validation_time_seconds` and still completed now raise + `ResourceLimitError`. +4. **Removed symbols raise `AttributeError`.** See *Removed* below; + none of them could produce a meaningful result before. ### Added @@ -16,13 +37,9 @@ project adheres to bounded by the new `max_image_pixels` limit (default 89,478,485, matching Pillow's `MAX_IMAGE_PIXELS`). Breaches raise the new `ImageSecurityError`. -- ZIP entries are now rejected when their name carries an extension - from `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, or - `SYSTEM_FILES`. Every dot-separated suffix is checked, so a - disguised name such as `invoice.php.txt` is caught. The threat - model documented this mitigation but it was not implemented. -- `ResourceMonitor.check()`, which enforces the wall-clock and memory - budgets together. +- `ResourceMonitor.check()`, which enforces the configured budgets + together, and the `enforce_memory_limit` flag that opts memory back + into enforcement. - Activity XML files must now declare the root element matching their extension: `.gpx` requires a `gpx` root, `.tcx` requires a `TrainingCenterDatabase` root. Namespaces are stripped before @@ -33,7 +50,9 @@ project adheres to a flat document with millions of elements can no longer amplify a bounded upload into an unbounded object graph. - `gzip_analysis_timeout` limit (default 5 s) bounding gzip inflation - independently of any caller-supplied `ResourceMonitor`. + independently of any caller-supplied `ResourceMonitor`. A breach + raises `ZipBombError` with `ZIP_ANALYSIS_TIMEOUT`, matching the ZIP + inspector's timeout. - `safe_label()` utility, applied to every untrusted filename and ZIP entry name before it reaches a log record, audit event, or exception message. @@ -68,6 +87,54 @@ project adheres to - Release-verification instructions for consumers, covering PEP 740 attestation checks with `pypi-attestations`. +### Changed + +- **Breaking:** `max_validation_memory_mb` is no longer enforced by + default. It samples the process-wide peak RSS, which never decreases + and misattributes concurrent work, so it is near-inert after a + process's first peak and fires mainly when it is wrong. Exceeding it + is now logged as a warning; set `enforce_memory_limit=True` (or + `ResourceMonitor(enforce_memory=True)`) to restore the previous + behaviour, and only in a process that validates one upload at a + time. The real memory bounds are the byte limits in + `SecurityLimits`, which cap every buffer the library allocates. +- **Breaking:** the validation time budget is now enforced *during* + validation instead of only on completion. `ResourceMonitor` is + threaded through the streaming reads, the ZIP entry loop, recursive + nested-archive inspection, strict decompression verification, and + the gzip inflation loop, so a runaway upload is aborted while it + runs. Uploads that previously completed after exceeding the budget + now raise `ResourceLimitError` earlier. +- **Lowered the minimum supported Python from 3.13 to 3.11.** No source + changes were required; `enum.StrEnum` was the only 3.11+ dependency. + The full test suite passes on 3.11, 3.12, 3.13 and 3.14, and the CI + matrix now covers all four. +- `ResourceLimitError` now propagates out of the ZIP and gzip + inspectors instead of being wrapped as an internal + `FileProcessingError`. +- A breached resource budget is now audited as `RESOURCE_LIMIT` + instead of a generic `VALIDATION_FAILURE`. The integration checklist + already told integrators to alert on this event type, but nothing + emitted it. +- `FileProcessingError` accepts an optional `error_code`, and XML + failures now carry `XML_MALFORMED`, `XML_FORBIDDEN_CONSTRUCT`, + `XML_INVALID_ROOT`, or `XML_TOO_MANY_ELEMENTS`. +- `find_text_pattern()` scans raw bytes with a cached compiled pattern + instead of decoding and lower-casing the whole buffer, removing two + full-size copies of the content-analysis window (up to 50 MB each). +- The file-signature table in `FileValidator` is a module constant + instead of a dict rebuilt on every validation. +- Documentation and the FastAPI example no longer return `str(err)` to + clients. Exception messages embed the client-supplied filename, so + reflecting them hands attacker-controlled bytes back to the browser; + the examples now log the detail and return `err.error_code`. +- `verify_zip_decompression` was reviewed and its default retained. + Enabling it by default would inflate every archive on every upload; + the integration checklist now spells out exactly when to turn it on + (any consumer that does not extract with Python's `zipfile`). +- `ZipContentInspector._contains_script_patterns()` no longer takes a + `filename` argument, which it never used. + ### Removed - **Breaking:** the `validate()` alias on every validator, and the @@ -95,61 +162,19 @@ project adheres to `ZipContentInspector`; a flat archive is capped by `max_zip_entries`. -### Changed - -- A breached resource budget is now audited as `RESOURCE_LIMIT` - instead of a generic `VALIDATION_FAILURE`. The integration checklist - already told integrators to alert on this event type, but nothing - emitted it. -- The file-signature table in `FileValidator` is a module constant - instead of a dict rebuilt on every validation. - -- **Lowered the minimum supported Python from 3.13 to 3.11.** No source - changes were required; `enum.StrEnum` was the only 3.11+ dependency. - The full test suite passes on 3.11, 3.12, 3.13 and 3.14, and the CI - matrix now covers all four. +### Fixed -- **Breaking:** `max_validation_memory_mb` is no longer enforced by - default. It samples the process-wide peak RSS, which never decreases - and misattributes concurrent work, so exceeding it is now logged as - a warning instead of failing the validation. Set the new - `enforce_memory_limit=True` (or - `ResourceMonitor(enforce_memory=True)`) to restore the previous - behaviour, and only in a process that validates one upload at a - time. The real memory bounds are the byte limits in - `SecurityLimits`. -- **Fixed (log injection, CWE-117):** a filename containing a newline - could forge an audit log line, and directional or zero-width - characters could hide the real name from an analyst. Untrusted text - is now escaped at every logging site and again at the audit - emission point. Unicode validation errors report the offending code - point and its Unicode name instead of echoing the character. -- `find_text_pattern()` scans raw bytes with a cached compiled pattern - instead of decoding and lower-casing the whole buffer, removing two - full-size copies of the content-analysis window (up to 50 MB each). -- `FileProcessingError` accepts an optional `error_code`, and XML - failures now carry `XML_MALFORMED`, `XML_FORBIDDEN_CONSTRUCT`, - `XML_INVALID_ROOT`, or `XML_TOO_MANY_ELEMENTS`. -- **Potentially breaking:** the validation time budget is now enforced - *during* validation instead of only on completion. `ResourceMonitor` - is threaded through the streaming reads, the ZIP entry loop, - recursive nested-archive inspection, strict decompression - verification, and the gzip inflation loop, so a runaway upload is - aborted while it runs. Uploads that previously completed after - exceeding the budget now raise `ResourceLimitError` earlier. -- `ResourceLimitError` now propagates out of the ZIP and gzip - inspectors instead of being wrapped as an internal - `FileProcessingError`. -- Documentation and the FastAPI example no longer return `str(err)` to - clients. Exception messages embed the client-supplied filename, so - reflecting them hands attacker-controlled bytes back to the browser; - the examples now log the detail and return `err.error_code`. -- `verify_zip_decompression` was reviewed and its default retained. - Enabling it by default would inflate every archive on every upload; - the integration checklist now spells out exactly when to turn it on - (any consumer that does not extract with Python's `zipfile`). -- `ZipContentInspector._contains_script_patterns()` no longer takes a - `filename` argument, which it never used. +- **Log injection (CWE-117):** a filename containing a newline could + forge an audit log line, and directional or zero-width characters + could hide the real name from an analyst. Untrusted text is now + escaped at every logging site and again at the audit emission point. + Unicode validation errors report the offending code point and its + Unicode name instead of echoing the character. +- ZIP entries are now rejected when their name carries an extension + from `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, or + `SYSTEM_FILES`. Every dot-separated suffix is checked, so a + disguised name such as `invoice.php.txt` is caught. The threat model + documented this mitigation but it was never implemented. ## [1.1.1] - 2026-08-19 @@ -296,6 +321,7 @@ Initial release. - Framework-agnostic async validation, a rich exception hierarchy with machine-readable error codes, secure defaults, and full type hints. +[2.0.0]: https://github.com/endurain-project/safeuploads/compare/v1.1.1...v2.0.0 [1.1.1]: https://github.com/endurain-project/safeuploads/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/endurain-project/safeuploads/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/endurain-project/safeuploads/releases/tag/v1.0.1 diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index 7ce8ead..c3bb05f 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -140,7 +140,8 @@ archive afterwards: - [ ] Container or process memory limits set — safeuploads `max_validation_memory_mb` should be below the container - limit.- [ ] Request timeout configured at the reverse proxy and + limit. +- [ ] Request timeout configured at the reverse proxy and application level — should be above `max_validation_time_seconds`. - [ ] Disk space monitored for temporary file spill diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 1f8d46c..69b0513 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -265,7 +265,8 @@ Windows shortcuts) embedded within uploaded files. ### XML External Entity Injection (CWE-611) **Attack:** GPX and TCX files are XML-based; malicious DTD -declarations can trigger external entity resolution, leadingto server-side file reads or SSRF. +declarations can trigger external entity resolution, leading +to server-side file reads or SSRF. **Mitigations:** @@ -373,6 +374,7 @@ and `max_xml_elements` cap every buffer the library allocates. size, similar to ZIP bombs. **Mitigations:** + - `GzipContentInspector` reads gzip streams in chunks, checking the compression ratio and uncompressed size against `SecurityLimits` progressively. diff --git a/pyproject.toml b/pyproject.toml index 9f11fb1..97bd325 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "safeuploads" -version = "1.1.1" +version = "2.0.0" description = "A comprehensive file security system for validating uploads and preventing attacks" authors = [ {name = "João Vitória Silva",email = "joao@endurain.com"} diff --git a/safeuploads/config.py b/safeuploads/config.py index 77768b7..f08193d 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -719,6 +719,33 @@ def _validate_file_size_limits( ) ) + # Someone who tuned the memory budget but left enforcement + # off believes they have a control they do not have. + if ( + not limits.enforce_memory_limit + and limits.max_validation_memory_mb + != SecurityLimits.max_validation_memory_mb + ): + errors.append( + _config_error( + "memory_limit_not_enforced", + ( + "max_validation_memory_mb is set to" + f" {limits.max_validation_memory_mb}MB but" + " enforce_memory_limit is False, so exceeding" + " it is only logged" + ), + "resource_limits", + ( + "Set enforce_memory_limit=True if this must" + " fail the validation, and only in a process" + " that validates one upload at a time;" + " otherwise rely on the byte limits" + ), + severity="warning", + ) + ) + return errors @classmethod diff --git a/safeuploads/inspectors/gzip_inspector.py b/safeuploads/inspectors/gzip_inspector.py index 5d0646a..0788590 100644 --- a/safeuploads/inspectors/gzip_inspector.py +++ b/safeuploads/inspectors/gzip_inspector.py @@ -101,6 +101,7 @@ def inspect_gzip_content( " - potential decompression bomb" ), compression_ratio=0, + error_code=ErrorCode.ZIP_ANALYSIS_TIMEOUT, ) chunk = gz.read(chunk_size) diff --git a/tests/inspectors/test_gzip_inspector.py b/tests/inspectors/test_gzip_inspector.py index b33d8e7..066dbd1 100644 --- a/tests/inspectors/test_gzip_inspector.py +++ b/tests/inspectors/test_gzip_inspector.py @@ -47,10 +47,11 @@ def test_inflation_timeout_without_monitor(self): set_correlation_id("test-correlation-id") try: - with pytest.raises(ZipBombError, match="timeout"): + with pytest.raises(ZipBombError, match="timeout") as exc_info: inspector.inspect_gzip_content( io.BytesIO(payload), len(payload) ) + assert exc_info.value.error_code == ErrorCode.ZIP_ANALYSIS_TIMEOUT finally: reset_correlation_id() diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index dcc3579..5ea7ee9 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -204,6 +204,43 @@ def test_existing_temp_dir_accepted(self, tmp_path): error_types = [e.error_type for e in errors if e.severity == "error"] assert "invalid_temp_dir" not in error_types + def test_customised_memory_budget_without_enforcement_warns(self): + """Test a tuned but unenforced memory budget is surfaced.""" + config = FileSecurityConfig( + SecurityLimits(max_validation_memory_mb=128) + ) + warnings = [ + e.error_type + for e in config.validate_instance() + if e.severity == "warning" + ] + assert "memory_limit_not_enforced" in warnings + + def test_customised_memory_budget_with_enforcement_is_quiet(self): + """Test opting in to enforcement clears the warning.""" + config = FileSecurityConfig( + SecurityLimits( + max_validation_memory_mb=128, + enforce_memory_limit=True, + ) + ) + warnings = [ + e.error_type + for e in config.validate_instance() + if e.severity == "warning" + ] + assert "memory_limit_not_enforced" not in warnings + + def test_default_memory_budget_does_not_warn(self): + """Test an untouched budget is not flagged.""" + config = FileSecurityConfig() + warnings = [ + e.error_type + for e in config.validate_instance() + if e.severity == "warning" + ] + assert "memory_limit_not_enforced" not in warnings + class TestMimeConfigurationValidation: """Tests for _validate_mime_configurations validation branches.""" diff --git a/uv.lock b/uv.lock index 2b21bc9..4436749 100644 --- a/uv.lock +++ b/uv.lock @@ -1350,7 +1350,7 @@ wheels = [ [[package]] name = "safeuploads" -version = "1.1.1" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "defusedxml" }, From 338ef0ad714160180d18261b6305237ff1d16900 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:48:31 +0100 Subject: [PATCH 08/16] refactor: remove unused ignore rule for abstract methods in linting configuration --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 97bd325..3136cf0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -152,7 +152,6 @@ exclude_lines = [ "raise NotImplementedError", "if __name__ == .__main__.:", "if TYPE_CHECKING:", - "@abstractmethod", ] [tool.mutmut] From 61756be212be2dbf5c6bca9ce5674fd6c3626980 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:26:47 +0100 Subject: [PATCH 09/16] feat: enhance content analysis and validation error handling in file uploads --- safeuploads/audit.py | 7 +++-- safeuploads/config.py | 13 ++++++++ safeuploads/file_validator.py | 27 +++++++++++++--- tests/conftest.py | 2 +- tests/test_audit.py | 24 ++++++++++++++ tests/test_config_validation.py | 14 +++++++++ tests/test_validate_activity_file.py | 47 ++++++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 8 deletions(-) diff --git a/safeuploads/audit.py b/safeuploads/audit.py index 4eac628..c594865 100644 --- a/safeuploads/audit.py +++ b/safeuploads/audit.py @@ -98,7 +98,7 @@ def log_extra( merged: dict[str, Any] = dict(extra) if extra else {} cid = correlation_id_var.get() if cid is not None: - merged["correlation_id"] = cid + merged["correlation_id"] = safe_label(cid) return merged @@ -195,12 +195,13 @@ def log_event(self, event: AuditEvent) -> None: if not self.enabled: return + correlation_id = safe_label(event.correlation_id) filename = safe_label(event.filename) result = safe_label(event.result, max_length=512) extra = { "audit_event_type": event.event_type.value, - "audit_correlation_id": event.correlation_id, + "audit_correlation_id": correlation_id, "audit_filename": filename, "audit_result": result, "audit_details": safe_label(event.details, max_length=1024), @@ -219,7 +220,7 @@ def log_event(self, event: AuditEvent) -> None: _audit_logger.log( level, "[%s] %s file=%s result=%s", - event.correlation_id[:12], + correlation_id[:12], event.event_type.value, filename, result, diff --git a/safeuploads/config.py b/safeuploads/config.py index f08193d..0f4d451 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -704,6 +704,19 @@ def _validate_file_size_limits( ) ) + if limits.content_scan_max_size <= 0: + errors.append( + _config_error( + "invalid_content_scan_size", + "content_scan_max_size must be greater than 0", + "content_analysis", + ( + "Set content_scan_max_size to a positive" + " byte limit (e.g., 50MB)" + ), + ) + ) + # A missing temp directory only surfaces when an upload # spills to disk, so check it up front. if limits.temp_dir is not None and not os.path.isdir(limits.temp_dir): diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index f2e6fbb..d09d570 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -1153,14 +1153,16 @@ async def _validate_activity_body(self, file: UploadFile) -> None: Run activity-file-specific validation steps. Handles XXE-safe XML parsing for GPX/TCX and binary - signature validation for FIT files. + signature validation for FIT files, followed by optional + deep content analysis. Args: file: Uploaded activity file to validate. Raises: FileValidationError: If an activity check fails. - FileProcessingError: If XML parsing fails. + FileProcessingError: If XML parsing or content analysis + fails. """ self._validate_filename(file) self._validate_file_extension( @@ -1182,6 +1184,7 @@ async def _validate_activity_body(self, file: UploadFile) -> None: temp_file, file_size, filename, + monitor, ) finally: temp_file.close() @@ -1191,22 +1194,27 @@ def _inspect_activity_sync( temp_file: tempfile.SpooledTemporaryFile[bytes], file_size: int, filename: str, + monitor: ResourceMonitor | None = None, ) -> None: """ Run synchronous activity-file inspection off the loop. Handles XXE-safe XML parsing for GPX/TCX and binary - signature validation for FIT files. + signature validation for FIT files, followed by optional + deep content analysis. Args: temp_file: Spooled temp file holding the data. file_size: File size in bytes. filename: Sanitized filename for context. + monitor: Optional resource monitor checked around + content analysis. Raises: MimeTypeError: If the MIME type is not allowed. FileSignatureError: If the signature mismatches. - FileProcessingError: If XML parsing fails. + FileProcessingError: If XML parsing or content analysis + fails. """ _, ext = os.path.splitext(filename.lower()) is_fit = ext == ".fit" @@ -1233,6 +1241,17 @@ def _inspect_activity_sync( temp_file, self.config.ACTIVITY_XML_ROOTS.get(ext) ) + if self.config.limits.enable_content_analysis: + if monitor is not None: + monitor.check() + temp_file.seek(0) + scan_size = self.config.limits.content_scan_max_size + sample = temp_file.read(scan_size) + temp_file.seek(0) + self._raise_on_content_threats(sample, filename, "activity") + if monitor is not None: + monitor.check() + logger.debug( "Activity file validation passed: %s (%s, %s bytes)", filename, diff --git a/tests/conftest.py b/tests/conftest.py index 04ec8e3..56d687f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,7 +69,7 @@ def __init__( ): self.filename = filename self.content = content - self.size = size or len(content) + self.size = size if size is not None else len(content) self._position = 0 async def read(self, size: int = -1) -> bytes: diff --git a/tests/test_audit.py b/tests/test_audit.py index f143de4..14097d2 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -188,6 +188,18 @@ def test_extra_fields_on_log_record(self, caplog): assert record.audit_result == "started" assert record.audit_source_ip == "" + def test_correlation_id_is_escaped(self, caplog): + """Test a custom correlation ID cannot inject a log line.""" + audit = SecurityAuditLogger(enabled=True) + correlation_id = "cid\nWARNING forged" + + with caplog.at_level(logging.DEBUG, logger="safeuploads.audit"): + audit.start("photo.jpg", correlation_id) + + record = caplog.records[0] + assert record.audit_correlation_id == "cid\\u000aWARNING forged" + assert "\n" not in record.getMessage() + class TestAuditSourceIp: """The client address is carried on the context.""" @@ -567,3 +579,15 @@ def test_log_extra_with_no_base_dict(self): extra = log_extra() assert extra["correlation_id"] == "cid-abc" reset_correlation_id() + + def test_log_extra_escapes_correlation_id(self): + """Test log extras cannot carry raw line separators.""" + from safeuploads.audit import log_extra + + set_correlation_id("cid\nWARNING forged") + try: + extra = log_extra() + finally: + reset_correlation_id() + + assert extra["correlation_id"] == "cid\\u000aWARNING forged" diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index 5ea7ee9..d3dd45c 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -167,6 +167,20 @@ def test_nonpositive_xml_element_cap_generates_error(self, monkeypatch): error_types = [e.error_type for e in errors if e.severity == "error"] assert "invalid_xml_element_limit" in error_types + @pytest.mark.parametrize("scan_size", [0, -1]) + def test_nonpositive_content_scan_size_generates_error( + self, monkeypatch, scan_size + ): + """Test that a non-positive content scan limit errors.""" + monkeypatch.setattr( + FileSecurityConfig, + "limits", + SecurityLimits(content_scan_max_size=scan_size), + ) + errors = FileSecurityConfig.validate_configuration() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "invalid_content_scan_size" in error_types + def test_nonpositive_gzip_timeout_generates_error(self, monkeypatch): """ Test that a non-positive gzip timeout errors. diff --git a/tests/test_validate_activity_file.py b/tests/test_validate_activity_file.py index f7eb405..4152ee1 100644 --- a/tests/test_validate_activity_file.py +++ b/tests/test_validate_activity_file.py @@ -135,6 +135,12 @@ async def test_validate_activity_file_exe_extension_raises( class TestValidateActivityFileSizeErrors: """Tests for file size validation failures.""" + def test_mock_upload_file_preserves_explicit_zero_size( + self, mock_upload_file + ): + f = mock_upload_file("track.gpx", _GPX_CONTENT, size=0) + assert f.size == 0 + async def test_validate_activity_file_empty_file_raises( self, mock_upload_file ): @@ -213,6 +219,47 @@ async def test_validate_activity_file_malformed_xml_raises( await validator.validate_activity_file(f) +class TestValidateActivityFileContentAnalysis: + """Tests for optional deep content analysis.""" + + async def test_clean_gpx_passes_when_content_analysis_enabled( + self, mock_upload_file + ): + config = FileSecurityConfig( + SecurityLimits(enable_content_analysis=True) + ) + validator = FileValidator(config=config) + f = mock_upload_file("track.gpx", _GPX_CONTENT) + + await validator.validate_activity_file(f) + + async def test_script_in_gpx_is_rejected(self, mock_upload_file): + config = FileSecurityConfig( + SecurityLimits(enable_content_analysis=True) + ) + validator = FileValidator(config=config) + content = ( + b'' + b'' + ) + f = mock_upload_file("track.gpx", content) + + with pytest.raises(FileProcessingError, match="Content analysis"): + await validator.validate_activity_file(f) + + async def test_executable_signature_in_fit_is_rejected( + self, mock_upload_file + ): + config = FileSecurityConfig( + SecurityLimits(enable_content_analysis=True) + ) + validator = FileValidator(config=config) + f = mock_upload_file("activity.fit", _FIT_CONTENT + b"MZpayload") + + with pytest.raises(FileProcessingError, match="Content analysis"): + await validator.validate_activity_file(f) + + class TestValidateActivityFileExceptionWrapping: """Tests that unexpected errors are wrapped in FileProcessingError.""" From becc8bfa4f4d1cd4097438a9cc0411e3513da99f Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:11:42 +0100 Subject: [PATCH 10/16] feat: enhance documentation with security policy details and integration checklist updates --- SECURITY.md | 59 ++++++++-- docs/index.md | 157 ++++--------------------- docs/security/integration-checklist.md | 94 ++++++++++++++- mkdocs.yml | 16 +++ 4 files changed, 177 insertions(+), 149 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index d2d4262..615deec 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,17 +1,52 @@ -# Security Policy | +# Security Policy -## Reporting a Vulnerability +## Supported versions -If you discover a security vulnerability, please follow these steps: +Security fixes are released against the latest published version only; there +are no maintained release branches. Upgrading to the current release is the +supported way to receive a fix. -1. **Do not** open a public issue; -2. Send an email to joao@endurain.com with the details of the vulnerability; -3. Include the following in your report: -- Steps to reproduce the vulnerability; -- Potential impact; -- Any suggested fixes, if available. -4. I will provide an acknowledgment when possible. +| Version | Supported | +|---|---| +| `2.0.x` | Yes | +| `< 2.0` | No | -Please include as much information as possible to help me resolve the issue promptly. +## Scope -Thank you for helping keep this project secure! +In scope: anything in the `safeuploads` package. That includes the filename +and extension validators, archive and image bomb detection, the ZIP and gzip +inspectors, the XML parsing behind activity files, the resource monitor, and +any way a crafted upload could bypass a check, exhaust the host, or corrupt or +expose a host application's data through safeuploads' own code. + +Out of scope: vulnerabilities in an application that *uses* safeuploads but +stem from its own code or configuration — for example allow-listing an +executable extension through `SecurityLimits`, serving an accepted upload back +inline from the document root, or storing a file under the client-supplied +name. Report those to that application's maintainers. + +Vulnerabilities in a dependency should be reported upstream first. If +safeuploads' use of it makes the impact materially worse, report that here too. + +## Reporting a vulnerability + +1. **Do not** open a public issue. +2. Email with the details. +3. Include: + - steps to reproduce; + - the affected version; + - potential impact; + - any suggested fix, if you have one. +4. You will get an acknowledgement when possible. + +Please include as much detail as you can — a sample payload and the +configuration it was validated under are what make a report actionable rather +than a starting point for investigation. + +## What to expect + +This project is maintained by one person in their spare time, so response times +vary. A fix will be released as soon as it is ready, and the advisory will +credit you unless you would rather stay anonymous. + +Thank you for helping keep this project secure. diff --git a/docs/index.md b/docs/index.md index 42d7b7c..d06c185 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,37 +1,10 @@ # safeuploads - - -Secure file upload validation for Python 3.11+ applications. Catches dangerous filenames, malicious extensions, Windows reserved names, and compression-based attacks before you accept an upload. - -## Features - -- **Framework-agnostic** async validation (FastAPI, generic) -- Filename sanitization and Unicode security checks -- Extension validation with configurable allow/block lists -- ZIP bomb detection, nested archive inspection, and recursive structure protection -- Dangerous ZIP entry rejection (executables, scripts, system files) -- Image decompression bomb detection via declared pixel dimensions -- MIME type verification with file signature validation -- Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing and root-element enforcement -- Gzip archive validation with decompression bomb detection -- Streaming validation for memory-efficient large file processing -- Wall-clock limits enforced inside the validation loops -- Log-injection-safe logging of untrusted filenames -- Content analysis with malware signature and polyglot detection -- Structured audit logging with correlation IDs -- Rich exception hierarchy with machine-readable error codes -- Zero configuration required—secure defaults out of the box +Secure file upload validation for Python 3.11+ applications. Hand it an upload before you accept it, and it rejects dangerous filenames, disallowed extensions, Windows reserved names, forged MIME types, compression bombs and XXE payloads — raising a typed exception with a machine-readable error code rather than returning a verdict you have to interpret. + +Validation is `async` and framework-agnostic: anything matching `UploadFileProtocol` works, and FastAPI's `UploadFile` is picked up when FastAPI happens to be installed — there is no hard dependency on it. Uploads are read in chunks and spooled to a `SpooledTemporaryFile` rather than held whole in memory, and every loop that could be made to run long is bounded by a wall-clock budget. + +This site is the reference documentation. For the feature list and project overview, see the [README on GitHub](https://github.com/endurain-project/safeuploads). ## Installation @@ -40,23 +13,12 @@ pip install safeuploads ``` For FastAPI integration: -```bash -pip install safeuploads[fastapi] -``` - -### Verifying a release - -Releases are built and published by this repository's release workflow through PyPI Trusted Publishing, with [PEP 740](https://peps.python.org/pep-0740/) attestations. You can confirm a downloaded artifact came from that workflow and was not substituted: ```bash -uvx pypi-attestations verify pypi \ - --repository https://github.com/endurain-project/safeuploads \ - pypi:safeuploads--py3-none-any.whl +pip install safeuploads[fastapi] ``` -A successful run prints `OK: `. `Provenance for file ... was not found` means the artifact predates attested publishing rather than that verification failed. - -Each release run also produces a CycloneDX SBOM and `SHA256SUMS`, generated from a clean install of the built wheel. These are retained as workflow artifacts on the release run rather than published to PyPI. +`python-magic` needs the `libmagic` system library present on the deployment target. ## Quick Start @@ -81,111 +43,40 @@ async def upload_image(file: UploadFile): return {"status": "success", "filename": file.filename} ``` -## Configuration +`FileValidator()` with no arguments is already a secure configuration. Pass a `FileSecurityConfig` when you want to narrow it further — see [File Validation Configuration](security/integration-checklist.md#file-validation-configuration). -```python -from safeuploads import FileValidator, FileSecurityConfig, SecurityLimits +The sibling methods are `validate_zip_file`, `validate_activity_file` (GPX, TCX, FIT) and `validate_gzip_file`. Each pipeline is described in [Architecture](security/architecture.md#validation-pipelines). -# Use default secure configuration -validator = FileValidator() +## What safeuploads Does Not Do -# Or pass explicit limits. Anything you leave out keeps its -# secure default, and the limits object is copied, so nothing -# is shared between configs. -config = FileSecurityConfig( - SecurityLimits( - max_image_size=10 * 1024 * 1024, # 10 MiB - max_image_pixels=50_000_000, # Reject bigger decoded images - max_compression_ratio=50, - # Decompress every ZIP entry to reject archives with - # forged central-directory metadata - verify_zip_decompression=True, - # Keep spilled uploads off the system temp directory - temp_dir="/var/lib/myapp/uploads-tmp", - ) -) - -validator = FileValidator(config=config) - -# Optionally offload blocking inspection to a bounded pool -from concurrent.futures import ThreadPoolExecutor - -pooled_validator = FileValidator( - config=config, - executor=ThreadPoolExecutor(max_workers=4), -) -``` +Knowing where the boundary sits matters more than the feature list, because everything past it is still your application's job: -## Exception Handling +**It does not rate limit.** A validator that correctly rejects ten thousand ZIP bombs has still burned the CPU rejecting them. Throttling belongs in front of the application — see the [Rate Limiting](rate-limiting.md) guide. -Exception messages are written for your logs, not for your users. They -embed the client-supplied filename and other untrusted values, so never -return `str(err)` to a client. Branch on the exception type and surface -`err.error_code`, which is a stable machine-readable string. - -```python -import logging - -from safeuploads.exceptions import ( - FileValidationError, # Base exception - FileSizeError, # File too large - ExtensionSecurityError, # Dangerous extension - ImageSecurityError, # Image decompression bomb - ZipBombError, # Compression attack -) - -logger = logging.getLogger(__name__) - -try: - await validator.validate_image_file(file) -except FileSizeError as err: - return {"error": "File too large", "max_size": err.max_size} -except ExtensionSecurityError as err: - return {"error": "File type not allowed", "code": err.error_code} -except ImageSecurityError as err: - return {"error": "Image too large to decode", "code": err.error_code} -except FileValidationError as err: - # Full detail goes to the log; the client only sees the code. - logger.warning("Upload rejected: %s", err) - return {"error": "Upload rejected", "code": err.error_code} -``` +**It does not store, rename, or transform files.** Nothing is written anywhere except the temporary spill buffer, which is discarded afterwards. Choosing a storage path, generating a non-guessable name, and setting permissions are yours to get right; the [Integration Checklist](security/integration-checklist.md#file-storage-security) lists what that involves. -## Current Status +**It is not an antivirus.** `enable_content_analysis` scans for known malware signatures, web shells, and polyglot markers — useful, but a fixed pattern set rather than a maintained threat database. High-risk deployments should run a real scanner as well. -### Implemented +**It does not decode media.** Image bombs are caught by reading the declared dimensions out of the PNG or JPEG header, never by decoding pixels — that is what stops a bomb detonating during validation, and it also means safeuploads cannot tell you whether an image is otherwise well-formed. -- **Filename Security**: Unicode normalization, directory traversal prevention, Windows reserved names blocking -- **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection -- **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification -- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits, plus rejection of entries whose extension is an executable, script, or system file -- **Image Bomb Protection**: PNG and JPEG headers are parsed and the declared pixel count is bounded by `max_image_pixels` -- **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip -- **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files -- **Resource Monitoring**: Wall-clock limits enforced by `ResourceMonitor`, checked inside the streaming, ZIP, and gzip loops so a runaway upload is aborted while it runs. Memory is best-effort telemetry (see Known Limitations) -- **Activity File Support**: GPX, TCX, and FIT validation with XXE-safe XML parsing, a required root element per extension, and a cap on parsed element count -- **Gzip Support**: Gzip archive validation with decompression bomb detection and an inflation timeout -- **Content Analysis**: Optional malware signature, web shell, and polyglot file detection -- **Audit Logging**: Structured security event logging with correlation IDs via `contextvars` -- **Performance Optimizations**: Pre-compiled pattern sets, `frozenset` lookups, LRU-cached MIME guessing -- **Rich Exception System**: Machine-readable error codes with detailed context -- **Fuzzing Tests**: Hypothesis-based property testing for filenames, ZIP, images, and config +**It does not judge accepted content.** A validated GPX file is well-formed XML with the expected root element, in which safeuploads found no attack. Whether its contents mean anything to your domain is a separate question. -### Known Limitations +## Known Limitations - No built-in rate limiting (application-level concern — see [Rate Limiting](rate-limiting.md) guide) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` - Image dimensions are read from the declared PNG/IHDR or JPEG/SOF header within the first 1 MiB; images whose dimensions cannot be read are rejected - `max_validation_memory_mb` is best-effort telemetry, not a limit: it samples the process-wide peak RSS, so it cannot be attributed to a single validation. Exceeding it is logged; set `enforce_memory_limit=True` to enforce, and only in a process that validates one upload at a time -- `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives (see [Integration Checklist](security/integration-checklist.md)) +- `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives (see [Integration Checklist](security/integration-checklist.md#zip-metadata-verification)) - Uploads larger than `max_memory_buffer_size` spill to disk; set `temp_dir` to control where, otherwise the system default temporary directory is used -## Documentation +## Where to Go Next -- [API Reference](api.md) — full public API documentation -- [Rate Limiting](rate-limiting.md) — production rate limiting guide -- [Threat Model](security/threat-model.md) — threat categories and mitigations -- [Architecture](security/architecture.md) — validation pipeline and data flow -- [Integration Checklist](security/integration-checklist.md) — production deployment checklist +- [API Reference](api.md) — every public class, method, and exception, generated from the source. +- [Rate Limiting](rate-limiting.md) — the layer safeuploads deliberately leaves to you, with SlowApi, nginx, Caddy, and Traefik recipes. +- [Threat Model](security/threat-model.md) — each attack class, the CWE it maps to, and the check that stops it. +- [Architecture](security/architecture.md) — components, the four validation pipelines, and where file content is actually read. +- [Integration Checklist](security/integration-checklist.md) — the list to work through before running it in production, including how to verify a release's provenance. ## License diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index c3bb05f..76914c0 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -45,6 +45,39 @@ that addresses it. - [ ] Allowed extensions and MIME types reviewed and narrowed to only what your application accepts. +Anything you leave out of `SecurityLimits` keeps its secure +default, and the limits object is copied, so nothing is shared +between configs: + +```python +from concurrent.futures import ThreadPoolExecutor + +from safeuploads import ( + FileSecurityConfig, + FileValidator, + SecurityLimits, +) + +config = FileSecurityConfig( + SecurityLimits( + max_image_size=10 * 1024 * 1024, # 10 MiB + max_image_pixels=50_000_000, # Reject bigger decoded images + max_compression_ratio=50, + # Decompress every ZIP entry to reject archives with + # forged central-directory metadata + verify_zip_decompression=True, + # Keep spilled uploads off the system temp directory + temp_dir="/var/lib/myapp/uploads-tmp", + ) +) + +# Optionally offload blocking inspection to a bounded pool +validator = FileValidator( + config=config, + executor=ThreadPoolExecutor(max_workers=4), +) +``` + ## ZIP Metadata Verification safeuploads reads the declared entry sizes from the ZIP central @@ -114,6 +147,40 @@ archive afterwards: - [ ] Generic 500 errors for unexpected failures — no stack traces in production responses. +!!! warning + Exception messages embed the client-supplied filename and + other untrusted values. Returning `str(err)` to a client + reflects attacker-controlled bytes back to the browser. + Branch on the exception type and surface `err.error_code`, + which is a stable machine-readable string. + +```python +import logging + +from safeuploads.exceptions import ( + FileValidationError, # Base exception + FileSizeError, # File too large + ExtensionSecurityError, # Dangerous extension + ImageSecurityError, # Image decompression bomb + ZipBombError, # Compression attack +) + +logger = logging.getLogger(__name__) + +try: + await validator.validate_image_file(file) +except FileSizeError as err: + return {"error": "File too large", "max_size": err.max_size} +except ExtensionSecurityError as err: + return {"error": "File type not allowed", "code": err.error_code} +except ImageSecurityError as err: + return {"error": "Image too large to decode", "code": err.error_code} +except FileValidationError as err: + # Full detail goes to the log; the client only sees the code. + logger.warning("Upload rejected: %s", err) + return {"error": "Upload rejected", "code": err.error_code} +``` + ## File Storage Security - [ ] Uploaded files stored outside the web-accessible @@ -155,10 +222,8 @@ archive afterwards: - [ ] `safeuploads` pinned to a specific version in `requirements.txt` or `pyproject.toml`. -- [ ] Release provenance verified before promoting a new version: - `uvx pypi-attestations verify pypi --repository - https://github.com/endurain-project/safeuploads - pypi:safeuploads--py3-none-any.whl`. +- [ ] Release provenance verified before promoting a new version + (see below). - [ ] `pip-audit` or `safety` run in CI to detect known vulnerabilities in dependencies. - [ ] `defusedxml` and `python-magic` dependencies kept @@ -166,6 +231,27 @@ archive afterwards: - [ ] `libmagic` system library installed and up to date on the deployment target. +Releases are built and published by the repository's release +workflow through PyPI Trusted Publishing, with +[PEP 740](https://peps.python.org/pep-0740/) attestations, so +you can confirm a downloaded artifact came from that workflow +and was not substituted: + +```bash +uvx pypi-attestations verify pypi \ + --repository https://github.com/endurain-project/safeuploads \ + pypi:safeuploads--py3-none-any.whl +``` + +A successful run prints `OK: `. `Provenance for file +... was not found` means the artifact predates attested +publishing rather than that verification failed. + +Each release run also produces a CycloneDX SBOM and +`SHA256SUMS`, generated from a clean install of the built wheel. +These are retained as workflow artifacts on the release run +rather than published to PyPI. + ## Testing - [ ] Unit tests verify validation rejects known-bad payloads diff --git a/mkdocs.yml b/mkdocs.yml index a55e012..46ebb41 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,6 @@ site_name: safeuploads documentation +site_description: Secure file upload validation for Python applications +site_url: https://safeuploads.endurain.com/ repo_url: https://github.com/endurain-project/safeuploads theme: name: material @@ -16,7 +18,20 @@ theme: toggle: icon: material/brightness-4 name: Switch to light mode + features: + - content.code.copy + - navigation.sections + - navigation.top + - toc.follow +markdown_extensions: + - admonition + - tables + - toc: + permalink: true + - pymdownx.highlight + - pymdownx.superfences plugins: + - search - mkdocstrings: handlers: python: @@ -24,6 +39,7 @@ plugins: docstring_style: google show_source: true separate_signature: true + show_root_heading: true nav: - Home: index.md - API: api.md From 4adf5154ee148d1d9e245fa57bef2a5e5d65b471 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:22:09 +0100 Subject: [PATCH 11/16] feat: standardize heading capitalization in documentation files --- CONTRIBUTING.md | 22 ++++----- README.md | 8 ++-- SECURITY.md | 2 +- docs/api.md | 2 +- docs/index.md | 8 ++-- docs/rate-limiting.md | 18 ++++---- docs/security/architecture.md | 18 ++++---- docs/security/integration-checklist.md | 28 ++++++------ docs/security/threat-model.md | 62 +++++++++++++------------- mkdocs.yml | 6 +-- 10 files changed, 87 insertions(+), 87 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9135cc3..a7e8b93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,18 @@ -# Contributing to Endurain +# Contributing to safeuploads Thank you for considering contributing to Endurain! Before diving in, please read these guidelines carefully. They exist to make the process sustainable for everyone. -## A Note on Maintainership +## A note on maintainership Endurain is maintained by a single person in their spare time. This means review bandwidth is genuinely limited. Following these guidelines isn't bureaucracy, it's what allows contributions to actually get merged rather than sitting in a queue indefinitely. -## Before You Write Any Code +## Before you write any code **Open an issue first.** For anything beyond a small bug fix, typo, or documentation improvement, please open an issue and wait for a response before writing code. This takes minutes and can save you hours of work on something that won't be merged because it conflicts with planned direction, existing work, or project scope. If an issue already exists, comment on it to signal your intent so work isn't duplicated. -## Pull Request Size — The Most Important Rule +## Pull request size — the most important rule **Keep PRs small and focused on a single concern.** @@ -26,9 +26,9 @@ PRs that are too large to review efficiently will be asked to be split before th **Excluded from the line count:** `uv.lock`, migration files, and other generated or vendored files. -## How to Contribute +## How to contribute -### Bug Fixes +### Bug fixes - Check if an issue already exists before opening a new one - Include clear steps to reproduce in the issue @@ -40,19 +40,19 @@ PRs that are too large to review efficiently will be asked to be split before th - Improvements to the docs site, inline code comments, and the README all count - Keep the same tone and structure as existing docs -### New Features +### New features - **Always discuss in an issue first** — this is required, not optional - Features that haven't been discussed and approved in an issue may be closed without review, regardless of quality -### Refactors and Code Quality +### Refactors and code quality - Must be discussed in an issue first - Pure refactor PRs (no behaviour change) are easiest to review. Keep them separate from feature or fix PRs - Include a clear explanation of what improved and why -## Getting Started +## Getting started 1. **Fork the repository** on GitHub 2. **Clone your fork** locally: @@ -73,10 +73,10 @@ PRs that are too large to review efficiently will be asked to be split before th ``` 5. **Push and open a PR** against the `master` branch, filling in the PR template completely -## Response Time Expectations +## Response time expectations Reviews may take days to weeks depending on availability. A PR sitting without a response is not a rejection. Please feel free to leave a polite ping after two weeks if there's been no activity. -## Thank You +## Thank you Even small contributions make a real difference. Thank you for taking the time to improve Endurain for everyone who self-hosts it. \ No newline at end of file diff --git a/README.md b/README.md index 49c8300..e8b25df 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ A successful run prints `OK: `. `Provenance for file ... was not found Each release run also produces a CycloneDX SBOM and `SHA256SUMS`, generated from a clean install of the built wheel. These are retained as workflow artifacts on the release run rather than published to PyPI. -## Quick Start +## Quick start ```python from fastapi import FastAPI, UploadFile, HTTPException @@ -112,7 +112,7 @@ pooled_validator = FileValidator( ) ``` -## Exception Handling +## Exception handling Exception messages are written for your logs, not for your users. They embed the client-supplied filename and other untrusted values, so never @@ -146,7 +146,7 @@ except FileValidationError as err: return {"error": "Upload rejected", "code": err.error_code} ``` -## Current Status +## Current status ### Implemented @@ -166,7 +166,7 @@ except FileValidationError as err: - **Rich Exception System**: Machine-readable error codes with detailed context - **Fuzzing Tests**: Hypothesis-based property testing for filenames, ZIP, images, and config -### Known Limitations +### Known limitations - No built-in rate limiting (application-level concern — see documentation) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` diff --git a/SECURITY.md b/SECURITY.md index 615deec..8466353 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,4 @@ -# Security Policy +# Security policy ## Supported versions diff --git a/docs/api.md b/docs/api.md index 3e98168..ec5903f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,4 @@ -# API Reference +# API reference ::: safeuploads handler: python diff --git a/docs/index.md b/docs/index.md index d06c185..37dd8c9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ pip install safeuploads[fastapi] `python-magic` needs the `libmagic` system library present on the deployment target. -## Quick Start +## Quick start ```python from fastapi import FastAPI, UploadFile, HTTPException @@ -47,7 +47,7 @@ async def upload_image(file: UploadFile): The sibling methods are `validate_zip_file`, `validate_activity_file` (GPX, TCX, FIT) and `validate_gzip_file`. Each pipeline is described in [Architecture](security/architecture.md#validation-pipelines). -## What safeuploads Does Not Do +## What safeuploads does not do Knowing where the boundary sits matters more than the feature list, because everything past it is still your application's job: @@ -61,7 +61,7 @@ Knowing where the boundary sits matters more than the feature list, because ever **It does not judge accepted content.** A validated GPX file is well-formed XML with the expected root element, in which safeuploads found no attack. Whether its contents mean anything to your domain is a separate question. -## Known Limitations +## Known limitations - No built-in rate limiting (application-level concern — see [Rate Limiting](rate-limiting.md) guide) - MIME detection covers first 8 KB; advanced polyglot attacks may require `enable_content_analysis` @@ -70,7 +70,7 @@ Knowing where the boundary sits matters more than the feature list, because ever - `verify_zip_decompression` is off by default; enable it if anything other than Python's `zipfile` extracts your archives (see [Integration Checklist](security/integration-checklist.md#zip-metadata-verification)) - Uploads larger than `max_memory_buffer_size` spill to disk; set `temp_dir` to control where, otherwise the system default temporary directory is used -## Where to Go Next +## Where to go next - [API Reference](api.md) — every public class, method, and exception, generated from the source. - [Rate Limiting](rate-limiting.md) — the layer safeuploads deliberately leaves to you, with SlowApi, nginx, Caddy, and Traefik recipes. diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 6f779ed..1a00370 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -1,4 +1,4 @@ -# Rate Limiting +# Rate limiting File upload endpoints are high-value targets for abuse. Without rate limiting, attackers can exhaust server resources through @@ -6,7 +6,7 @@ rapid-fire uploads, even when each individual file passes validation. **safeuploads validates file content — rate limiting protects the endpoint itself.** -## Why Rate Limiting Matters for Uploads +## Why rate limiting matters for uploads | Threat | Impact | Mitigation | |---|---|---| @@ -15,7 +15,7 @@ protects the endpoint itself.** | Storage exhaustion | Disk full, service outage | Global upload quotas | | Zip bomb floods | CPU exhaustion during analysis | Combined with `ResourceMonitor` | -## Recommended Limits +## Recommended limits | Endpoint type | Suggested rate | Burst | |---|---|---| @@ -28,7 +28,7 @@ Adjust based on your application's expected traffic patterns. --- -## FastAPI with SlowApi +## FastAPI with SlowAPI [SlowApi](https://github.com/laurents/slowapi) wraps [limits](https://limits.readthedocs.io/) for use with Starlette @@ -40,7 +40,7 @@ and FastAPI. pip install slowapi ``` -### Basic Setup +### Basic setup ```python from fastapi import FastAPI, Request, UploadFile @@ -74,7 +74,7 @@ async def upload_zip(request: Request, file: UploadFile): return {"filename": file.filename} ``` -### Per-User Limits (Authenticated) +### Per-user limits (authenticated) ```python from fastapi import Depends @@ -101,7 +101,7 @@ async def upload_image_authed( return {"filename": file.filename} ``` -### Custom Error Response +### Custom error response ```python from fastapi.responses import JSONResponse @@ -129,7 +129,7 @@ app.add_exception_handler( --- -## Custom Middleware (No Dependencies) +## Custom middleware (no dependencies) If you prefer not to add `slowapi`, a simple token-bucket middleware works for basic per-IP limiting: @@ -181,7 +181,7 @@ async def rate_limit_middleware(request: Request, call_next): --- -## Reverse Proxy Rate Limiting +## Reverse proxy rate limiting For production, rate limiting at the reverse proxy layer is more efficient and protects the application before requests diff --git a/docs/security/architecture.md b/docs/security/architecture.md index 23cec12..e9dfd8f 100644 --- a/docs/security/architecture.md +++ b/docs/security/architecture.md @@ -5,7 +5,7 @@ component responsibilities, and data flow for each file type. --- -## Component Overview +## Component overview ``` safeuploads/ @@ -50,9 +50,9 @@ safeuploads/ --- -## Validation Pipelines +## Validation pipelines -### Image Validation (`validate_image_file`) +### Image validation (`validate_image_file`) ``` UploadFile @@ -114,7 +114,7 @@ UploadFile └─────────────────────────────┘ ``` -### ZIP Validation (`validate_zip_file`) +### ZIP validation (`validate_zip_file`) ``` UploadFile @@ -194,7 +194,7 @@ UploadFile └──────────────────────────────┘ ``` -### Activity File Validation (`validate_activity_file`) +### Activity file validation (`validate_activity_file`) ``` UploadFile (.gpx, .tcx, .fit) @@ -249,7 +249,7 @@ UploadFile (.gpx, .tcx, .fit) └──────────────────────────────┘ ``` -### Gzip Validation (`validate_gzip_file`) +### Gzip validation (`validate_gzip_file`) ``` UploadFile (.gz) @@ -302,7 +302,7 @@ UploadFile (.gz) --- -## Data Flow: Where File Content Is Read +## Data flow: where file content is read | Stage | What is read | Buffer size | |---|---|---| @@ -318,7 +318,7 @@ UploadFile (.gz) --- -## Exception Hierarchy +## Exception hierarchy ``` Exception @@ -344,7 +344,7 @@ machine-readable classification. --- -## Audit Event Flow +## Audit event flow ``` FileValidator.validate_*() diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index 76914c0..c88bada 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -1,4 +1,4 @@ -# Integration Checklist +# Integration checklist Production deployment checklist for applications using safeuploads. Each item links to the relevant threat in the @@ -7,14 +7,14 @@ that addresses it. --- -## HTTPS & Transport Security +## HTTPS & transport security - [ ] All upload endpoints served over HTTPS. - [ ] `Strict-Transport-Security` header set with `max-age=31536000; includeSubDomains`. - [ ] HTTP requests redirected to HTTPS at the reverse proxy. -## Rate Limiting +## Rate limiting - [ ] Per-IP rate limits applied to upload endpoints (see [Rate Limiting](../rate-limiting.md) guide). @@ -24,7 +24,7 @@ that addresses it. - [ ] `429 Too Many Requests` responses include a `Retry-After` header. -## File Validation Configuration +## File validation configuration - [ ] `FileValidator` instantiated with explicit `FileSecurityConfig` (not relying solely on defaults). @@ -78,7 +78,7 @@ validator = FileValidator( ) ``` -## ZIP Metadata Verification +## ZIP metadata verification safeuploads reads the declared entry sizes from the ZIP central directory, which an attacker controls. `verify_zip_decompression` @@ -99,7 +99,7 @@ archive afterwards: large enough for the archive sizes you accept, since the whole archive is now inflated during validation. -## Memory Enforcement +## Memory enforcement - [ ] Leave `enforce_memory_limit` at its default (`False`) unless the process validates one upload at a time. The @@ -113,7 +113,7 @@ archive afterwards: - [ ] Alert on the "memory budget exceeded (not enforced)" warning rather than treating it as a control. -## Content Analysis +## Content analysis - [ ] `enable_content_analysis` set to `True` if accepting files from untrusted users. @@ -121,7 +121,7 @@ archive afterwards: - [ ] Consider supplemental antivirus scanning (ClamAV or similar) for high-risk environments. -## Audit Logging +## Audit logging - [ ] `enable_audit_logging` set to `True` in production. - [ ] Log handler attached to `safeuploads.audit` logger @@ -135,7 +135,7 @@ archive afterwards: - [ ] Alerting configured for `THREAT_DETECTED` and `RESOURCE_LIMIT` audit event types. -## Error Handling +## Error handling - [ ] Application catches specific exception types (`FileSizeError`, `ExtensionSecurityError`, etc.) and @@ -181,7 +181,7 @@ except FileValidationError as err: return {"error": "Upload rejected", "code": err.error_code} ``` -## File Storage Security +## File storage security - [ ] Uploaded files stored outside the web-accessible document root. @@ -195,7 +195,7 @@ except FileValidationError as err: the filename. - `X-Content-Type-Options: nosniff` header. -## Security Headers (for serving uploaded content) +## Security headers (for serving uploaded content) - [ ] `Content-Security-Policy` configured to prevent inline script execution if serving HTML/SVG content. @@ -203,7 +203,7 @@ except FileValidationError as err: - [ ] `X-Frame-Options: DENY` or `SAMEORIGIN` as appropriate. - [ ] `Cache-Control: no-store` for sensitive uploaded content. -## Resource Limits +## Resource limits - [ ] Container or process memory limits set — safeuploads `max_validation_memory_mb` should be below the container @@ -218,7 +218,7 @@ except FileValidationError as err: The directory must exist; configuration validation reports `invalid_temp_dir` when it does not. -## Dependency Management +## Dependency management - [ ] `safeuploads` pinned to a specific version in `requirements.txt` or `pyproject.toml`. @@ -263,7 +263,7 @@ rather than published to PyPI. - [ ] Penetration testing includes crafted uploads: ZIP bombs, polyglot files, XXE payloads, traversal filenames. -## Monitoring & Incident Response +## Monitoring & incident response - [ ] Upload validation metrics tracked (success rate, failure rate, latency). diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 69b0513..da6535f 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -1,4 +1,4 @@ -# Threat Model +# Threat model This document describes the threat categories that safeuploads protects against, the attack vectors for each, and the @@ -6,9 +6,9 @@ mitigations implemented in the library. --- -## Filename Attacks +## Filename attacks -### Directory Traversal (CWE-22) +### Directory traversal (CWE-22) **Attack:** Filenames containing `../`, `..\\`, or URL-encoded variants (`%2e%2e%2f`) attempt to write files outside the @@ -23,7 +23,7 @@ intended upload directory. - Null bytes in filenames are rejected to prevent C-string truncation attacks. -### Unicode Obfuscation (CWE-116) +### Unicode obfuscation (CWE-116) **Attack:** Right-to-left override characters (U+202E) and zero-width joiners can disguise file extensions so that @@ -39,7 +39,7 @@ is `.exe`. - Fullwidth period (U+FF0E) and dot leader (U+2024) are flagged to prevent extension spoofing. -### Windows Reserved Names (CWE-20) +### Windows reserved names (CWE-20) **Attack:** Filenames like `CON`, `PRN`, `NUL`, or `COM1` cause undefined behavior on Windows file systems, potentially @@ -53,9 +53,9 @@ leading to denial of service. --- -## Extension Attacks +## Extension attacks -### Dangerous Extensions (CWE-434) +### Dangerous extensions (CWE-434) **Attack:** Uploading executable files (`.exe`, `.bat`, `.ps1`, `.php`, `.jsp`) that could be executed if served or stored @@ -76,9 +76,9 @@ improperly. --- -## Compression Attacks +## Compression attacks -### ZIP Bombs (CWE-400) +### ZIP bombs (CWE-400) **Attack:** A small ZIP archive that decompresses to an enormous size (e.g., 42.zip — 42 KB compressed, 4.5 PB @@ -114,7 +114,7 @@ uncompressed), exhausting disk and memory. declared metadata are then rejected as `ZIP_CORRUPT`. This is off by default because it decompresses the full archive. -### Recursive / Quine ZIP Archives +### Recursive / quine ZIP archives **Attack:** A ZIP containing itself (quine) or deeply nested ZIPs that cause infinite recursion during inspection. @@ -131,7 +131,7 @@ ZIPs that cause infinite recursion during inspection. - `ZIP_RECURSIVE_STRUCTURE` and `ZIP_COMPLEXITY_ATTACK` error codes provide precise feedback. -### Nested Archive Detection +### Nested archive detection **Attack:** Archives hidden inside other archives to bypass single-level content inspection. @@ -146,9 +146,9 @@ single-level content inspection. --- -## Content Threats (ZIP Entries) +## Content threats (ZIP entries) -### Path Traversal in ZIP Entry Names (CWE-22) +### Path traversal in ZIP entry names (CWE-22) **Attack:** ZIP entry filenames like `../../etc/passwd` write outside the extraction directory (Zip Slip). @@ -160,7 +160,7 @@ outside the extraction directory (Zip Slip). - Null bytes in entry filenames are rejected first to prevent C-string truncation bypasses (CWE-158). -### Executable Content in ZIP +### Executable content in ZIP **Attack:** Executables, scripts, system files, or shortcuts hidden inside ZIP archives. @@ -179,7 +179,7 @@ hidden inside ZIP archives. - Text content is scanned for script injection patterns (shebangs, `eval()`, `` named `track.gpx`; @@ -297,7 +297,7 @@ content type, the payload executes (stored XSS). `XML_INVALID_ROOT`. A TCX document uploaded as `.gpx` is rejected. -### XML Element Amplification +### XML element amplification **Attack:** `defusedxml` blocks entity expansion, but a flat document needs no entities: 50 MB of `` is roughly twelve @@ -314,9 +314,9 @@ magnitude more memory than the file itself. --- -## Resource Exhaustion +## Resource exhaustion -### Memory Exhaustion (CWE-400) +### Memory exhaustion (CWE-400) **Attack:** Uploading very large files or files that expand significantly during validation consumes all available memory. @@ -336,7 +336,7 @@ significantly during validation consumes all available memory. Exhaustion: this is telemetry, not a limit, unless `enforce_memory_limit` is set. -### CPU Exhaustion (CWE-400) +### CPU exhaustion (CWE-400) **Attack:** Crafted files that trigger expensive validation paths (e.g., ZIP with many entries, deeply nested structures). @@ -368,7 +368,7 @@ The real memory bounds are structural: `max_memory_buffer_size`, `chunk_size`, `content_scan_max_size`, `max_uncompressed_size` and `max_xml_elements` cap every buffer the library allocates. -### Gzip Decompression Bombs +### Gzip decompression bombs **Attack:** A small gzip file that decompresses to massive size, similar to ZIP bombs. @@ -387,9 +387,9 @@ size, similar to ZIP bombs. --- -## Audit & Observability +## Audit & observability -### Log Injection (CWE-117) +### Log injection (CWE-117) **Attack:** A filename or ZIP entry name containing a newline (`upload.jpg\nWARNING forged entry`) forges an extra log line, @@ -412,7 +412,7 @@ name from an analyst reading the log. - Unicode validation errors report the offending code point and its Unicode name rather than echoing the character itself. -### Undetected Security Events (CWE-778) +### Undetected security events (CWE-778) **Attack:** Security-relevant events (validation failures, threat detections) go unlogged, preventing incident response. @@ -434,7 +434,7 @@ threat detections) go unlogged, preventing incident response. --- -## Error Information Leakage (CWE-209) +## Error information leakage (CWE-209) **Attack:** Detailed internal error messages in API responses help attackers understand the validation pipeline and craft diff --git a/mkdocs.yml b/mkdocs.yml index 46ebb41..a2b94b8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,8 +43,8 @@ plugins: nav: - Home: index.md - API: api.md - - Rate Limiting: rate-limiting.md + - Rate limiting: rate-limiting.md - Security: - - Threat Model: security/threat-model.md + - Threat model: security/threat-model.md - Architecture: security/architecture.md - - Integration Checklist: security/integration-checklist.md \ No newline at end of file + - Integration checklist: security/integration-checklist.md \ No newline at end of file From 8c657adf07d2398745889a06e495118fdb4ea9cc Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:29:16 +0100 Subject: [PATCH 12/16] feat: update changelog to remove outdated security workflow details and clarify Python version support --- CHANGELOG.md | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4163085..e2a3d9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,19 +71,6 @@ which uploads are accepted. Read the upgrade notes before bumping. - `set_source_ip()`, which attaches the client address to every audit event in the current context. `AuditEvent.source_ip` existed but was never populated. -- CodeQL workflow (`security-extended` queries) and OpenSSF Scorecard - workflow. `pip-audit` covers vulnerable dependencies and ruff's - flake8-bandit rules cover single-line patterns; neither does - interprocedural taint tracking or scores supply-chain posture. -- Attack corpus under `tests/corpus/`: every threat the threat model - claims to stop is now a named, deterministically constructed sample - asserted to raise the documented error code. Samples are built at - test time rather than checked in, so the repository carries no - payload an antivirus scanner would quarantine. -- Scheduled, non-blocking mutation-testing workflow (`mutmut`) with a - `mutation` dependency group. It immediately found two unasserted - behaviours in `safe_label()` — the default length bound and the - truncation boundary — which are now covered. - Release-verification instructions for consumers, covering PEP 740 attestation checks with `pypi-attestations`. @@ -105,10 +92,8 @@ which uploads are accepted. Read the upgrade notes before bumping. the gzip inflation loop, so a runaway upload is aborted while it runs. Uploads that previously completed after exceeding the budget now raise `ResourceLimitError` earlier. -- **Lowered the minimum supported Python from 3.13 to 3.11.** No source - changes were required; `enum.StrEnum` was the only 3.11+ dependency. - The full test suite passes on 3.11, 3.12, 3.13 and 3.14, and the CI - matrix now covers all four. +- **Lowered the minimum supported Python from 3.13 to 3.11.** + Supported and tested on 3.11, 3.12, 3.13 and 3.14. - `ResourceLimitError` now propagates out of the ZIP and gzip inspectors instead of being wrapped as an internal `FileProcessingError`. @@ -122,27 +107,18 @@ which uploads are accepted. Read the upgrade notes before bumping. - `find_text_pattern()` scans raw bytes with a cached compiled pattern instead of decoding and lower-casing the whole buffer, removing two full-size copies of the content-analysis window (up to 50 MB each). -- The file-signature table in `FileValidator` is a module constant - instead of a dict rebuilt on every validation. - Documentation and the FastAPI example no longer return `str(err)` to clients. Exception messages embed the client-supplied filename, so reflecting them hands attacker-controlled bytes back to the browser; the examples now log the detail and return `err.error_code`. -- `verify_zip_decompression` was reviewed and its default retained. - Enabling it by default would inflate every archive on every upload; - the integration checklist now spells out exactly when to turn it on - (any consumer that does not extract with Python's `zipfile`). -- `ZipContentInspector._contains_script_patterns()` no longer takes a - `filename` argument, which it never used. ### Removed - **Breaking:** the `validate()` alias on every validator, and the `BaseValidator` abstract method behind it. The abstraction was false: each validator takes different arguments, so the "uniform" - interface could never be used polymorphically, and its - `*args: Any, **kwargs: Any` signature was the only untyped surface - in the package. Call the purpose-named method instead + interface could never be used polymorphically. Call the + purpose-named method instead (`validate_unicode_security`, `validate_extensions`, `validate_windows_reserved_names`, `validate_zip_compression_ratio`, `validate_xml_safety`). `BaseValidator` remains as a plain base From e0467acfe8ad6dbbd9a39114ac1802a7e6415407 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:06:28 +0100 Subject: [PATCH 13/16] feat: enhance filename sanitization and validation error handling --- CHANGELOG.md | 18 +++++++++++++---- safeuploads/config.py | 7 +++++-- safeuploads/file_validator.py | 10 ++++++---- safeuploads/utils.py | 21 ++++++++++++++++++++ tests/test_config_validation.py | 31 +++++++++++++++++++----------- tests/test_file_validator.py | 25 ++++++++++++++++++++++++ tests/test_utils.py | 34 +++++++++++++++++++++++++++++++++ 7 files changed, 125 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a3d9c..7362298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,10 @@ which uploads are accepted. Read the upgrade notes before bumping. default.** This is the only change that makes safeuploads accept something it previously rejected. If you relied on it, set `enforce_memory_limit=True` — and only in a process that validates - one upload at a time. Configuration validation warns when the - budget is customised but enforcement is off. + one upload at a time. Configuration validation reports an + informational notice when the budget is customised but enforcement + is off; it does not fail strict validation, because leaving + enforcement off is the correct choice under concurrency. 2. **More uploads are rejected than before.** Images whose dimensions cannot be read, arbitrary XML behind a `.gpx`/`.tcx` name, and ZIPs containing executable, script, or system-file entries all now fail. @@ -107,6 +109,10 @@ which uploads are accepted. Read the upgrade notes before bumping. - `find_text_pattern()` scans raw bytes with a cached compiled pattern instead of decoding and lower-casing the whole buffer, removing two full-size copies of the content-analysis window (up to 50 MB each). + It now returns the match that appears earliest in the content rather + than the first pattern in the supplied order. Whether a threat is + detected is unchanged; only which pattern name is reported when a + buffer matches several can differ. - Documentation and the FastAPI example no longer return `str(err)` to clients. Exception messages embed the client-supplied filename, so reflecting them hands attacker-controlled bytes back to the browser; @@ -144,8 +150,12 @@ which uploads are accepted. Read the upgrade notes before bumping. forge an audit log line, and directional or zero-width characters could hide the real name from an analyst. Untrusted text is now escaped at every logging site and again at the audit emission point. - Unicode validation errors report the offending code point and its - Unicode name instead of echoing the character. + Filename sanitization now strips every control, format, surrogate + and line-separator code point rather than only the C0 range, so + U+0085, U+009B, U+2028 and U+2029 no longer survive into the name + returned to the caller. Unicode validation errors report the + offending code point and its Unicode name instead of echoing the + character. - ZIP entries are now rejected when their name carries an extension from `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, or `SYSTEM_FILES`. Every dot-separated suffix is checked, so a diff --git a/safeuploads/config.py b/safeuploads/config.py index 0f4d451..818ac2a 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -733,7 +733,10 @@ def _validate_file_size_limits( ) # Someone who tuned the memory budget but left enforcement - # off believes they have a control they do not have. + # off believes they have a control they do not have. Only + # informational: leaving enforcement off is the correct + # choice under concurrency, so this must not fail strict + # validation for an otherwise sound configuration. if ( not limits.enforce_memory_limit and limits.max_validation_memory_mb @@ -755,7 +758,7 @@ def _validate_file_size_limits( " that validates one upload at a time;" " otherwise rely on the byte limits" ), - severity="warning", + severity="info", ) ) diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index d09d570..35586bf 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -56,6 +56,7 @@ matches_signature_prefix, parse_image_dimensions, safe_label, + strip_unsafe_chars, ) from .validators import ( CompressionSecurityValidator, @@ -467,10 +468,11 @@ def _sanitize_filename(self, filename: str) -> str: # Remove path components to prevent directory traversal filename = os.path.basename(filename) - # Remove null bytes and control characters - filename = "".join( - char for char in filename if ord(char) >= 32 and char != "\x7f" - ) + # Drop control, format and separator code points. C0 alone + # is not enough: U+2028, U+2029 and the C1 controls also + # break a log line, and the sanitized name is returned to + # the caller for storage, not just logged. + filename = strip_unsafe_chars(filename) # Remove dangerous characters that could be used # for path traversal or command injection diff --git a/safeuploads/utils.py b/safeuploads/utils.py index 0c31ac8..60b28e4 100644 --- a/safeuploads/utils.py +++ b/safeuploads/utils.py @@ -71,6 +71,27 @@ def safe_label(value: str, max_length: int = 256) -> str: return escaped +def strip_unsafe_chars(value: str) -> str: + """ + Remove characters that can forge or hide inside a log line. + + Covers C0 and C1 controls, format and surrogate code points, + and the line and paragraph separators, so the result is safe + to store and to log without further escaping. + + Args: + value: Untrusted text such as a filename. + + Returns: + Text with every unsafe character removed. + """ + return "".join( + char + for char in value + if unicodedata.category(char) not in _UNSAFE_CATEGORIES + ) + + def matches_signature_prefix( content: bytes, signatures: Iterable[bytes] ) -> bytes | None: diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index d3dd45c..9e3f6bb 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -218,42 +218,51 @@ def test_existing_temp_dir_accepted(self, tmp_path): error_types = [e.error_type for e in errors if e.severity == "error"] assert "invalid_temp_dir" not in error_types - def test_customised_memory_budget_without_enforcement_warns(self): + def test_customised_memory_budget_without_enforcement_is_informational( + self, + ): """Test a tuned but unenforced memory budget is surfaced.""" config = FileSecurityConfig( SecurityLimits(max_validation_memory_mb=128) ) - warnings = [ + notices = [ e.error_type for e in config.validate_instance() - if e.severity == "warning" + if e.severity == "info" ] - assert "memory_limit_not_enforced" in warnings + assert "memory_limit_not_enforced" in notices + + def test_customised_memory_budget_passes_strict_validation(self): + """Test the notice does not fail strict validation.""" + config = FileSecurityConfig( + SecurityLimits(max_validation_memory_mb=128) + ) + config.validate_and_report_instance(strict=True) def test_customised_memory_budget_with_enforcement_is_quiet(self): - """Test opting in to enforcement clears the warning.""" + """Test opting in to enforcement clears the notice.""" config = FileSecurityConfig( SecurityLimits( max_validation_memory_mb=128, enforce_memory_limit=True, ) ) - warnings = [ + notices = [ e.error_type for e in config.validate_instance() - if e.severity == "warning" + if e.severity == "info" ] - assert "memory_limit_not_enforced" not in warnings + assert "memory_limit_not_enforced" not in notices def test_default_memory_budget_does_not_warn(self): """Test an untouched budget is not flagged.""" config = FileSecurityConfig() - warnings = [ + notices = [ e.error_type for e in config.validate_instance() - if e.severity == "warning" + if e.severity == "info" ] - assert "memory_limit_not_enforced" not in warnings + assert "memory_limit_not_enforced" not in notices class TestMimeConfigurationValidation: diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index 684cb8e..39833a6 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -24,6 +24,7 @@ ZipContentError, ) from safeuploads.file_validator import FileValidator +from safeuploads.utils import safe_label from tests.conftest import JPEG_SOF0 @@ -1314,6 +1315,30 @@ def test_sanitize_whitespace_only_name_part(self): assert result.startswith("file_") assert result.endswith(".jpg") + @pytest.mark.parametrize( + "raw", + [ + "a\x85b.jpg", # C1 next-line + "a\x9bb.jpg", # C1 control sequence introducer + "a\u2028b.jpg", # Line separator + "a\u2029b.jpg", # Paragraph separator + ], + ) + def test_sanitize_strips_non_c0_line_breakers(self, raw): + """ + Test that code points beyond C0 cannot break a log line. + + Args: + raw: Filename carrying a log-breaking code point. + + Returns: + None + """ + validator = FileValidator() + result = validator._sanitize_filename(raw) + assert result == "ab.jpg" + assert safe_label(result) == result + class TestValidateActivityFile: """Test activity file validation (GPX, TCX, FIT).""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 0037bae..665666a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,6 +13,7 @@ find_text_pattern, parse_image_dimensions, safe_label, + strip_unsafe_chars, ) from tests.conftest import JPEG_SOF0 @@ -66,6 +67,39 @@ def test_default_length_bound(self): assert safe_label("a" * 256) == "a" * 256 +class TestStripUnsafeChars: + """Log-breaking code points are removed, not escaped.""" + + def test_plain_text_unchanged(self): + """Test ordinary filenames pass through untouched.""" + assert strip_unsafe_chars("holiday-photo.jpg") == "holiday-photo.jpg" + + def test_non_ascii_preserved(self): + """Test legitimate non-ASCII names stay readable.""" + assert strip_unsafe_chars("caf\u00e9.jpg") == "caf\u00e9.jpg" + + def test_empty_value(self): + """Test empty input yields empty output.""" + assert strip_unsafe_chars("") == "" + + @pytest.mark.parametrize( + "raw", + [ + "a\x00b.jpg", # C0 null + "a\nb.jpg", # C0 newline + "a\x7fb.jpg", # Delete + "a\x85b.jpg", # C1 next-line + "a\x9bb.jpg", # C1 control sequence introducer + "a\u2028b.jpg", # Line separator + "a\u2029b.jpg", # Paragraph separator + "a\u200bb.jpg", # Zero-width space (format) + ], + ) + def test_unsafe_code_points_removed(self, raw): + """Test every log-breaking category is stripped.""" + assert strip_unsafe_chars(raw) == "ab.jpg" + + class TestFindTextPattern: """Pattern scanning runs over raw bytes.""" From 4ed094ca50c0c243a7084cfaa3e6531bd8da8dfd Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:16:41 +0100 Subject: [PATCH 14/16] feat: enhance ZIP entry rejection logic with configurable threat categories --- CHANGELOG.md | 22 +++++++--- README.md | 4 +- docs/security/threat-model.md | 14 ++++--- safeuploads/config.py | 30 +++++++++++++ safeuploads/inspectors/zip_inspector.py | 15 +++---- tests/inspectors/test_zip_inspector.py | 56 +++++++++++++++++++++++-- tests/test_config_validation.py | 24 +++++++++++ 7 files changed, 138 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7362298..b04a4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,8 @@ which uploads are accepted. Read the upgrade notes before bumping. enforcement off is the correct choice under concurrency. 2. **More uploads are rejected than before.** Images whose dimensions cannot be read, arbitrary XML behind a `.gpx`/`.tcx` name, and ZIPs - containing executable, script, or system-file entries all now fail. - Re-run your own fixtures before deploying. + containing executable or script entries all now fail. Re-run your + own fixtures before deploying. 3. **The time budget aborts mid-flight.** Uploads that previously ran past `max_validation_time_seconds` and still completed now raise `ResourceLimitError`. @@ -62,6 +62,15 @@ which uploads are accepted. Read the upgrade notes before bumping. `max_memory_buffer_size` spill to disk. Configuration validation reports `invalid_temp_dir` when the directory does not exist, rather than failing later at rollover time. +- `blocked_zip_entry_categories` limit naming the `ZipThreatCategory` + members whose extensions are rejected on a ZIP entry. Defaults to + `EXECUTABLE_FILES` and `SCRIPT_FILES`; add `SYSTEM_FILES` to also + reject `.dll`, `.so`, `.ini` and `.conf` entries, or pass an empty + set to disable the check without also disabling the unrelated + content scanning that `scan_zip_content` gates. Configuration + validation reports `unknown_zip_entry_category` for a name that is + not a `ZipThreatCategory` member, so a typo cannot silently disable + the check. - `FileSecurityConfig` now accepts `limits` directly (`FileSecurityConfig(SecurityLimits(...))`). The object is copied, so it is never aliased or shared, and configuring an instance no longer @@ -157,10 +166,11 @@ which uploads are accepted. Read the upgrade notes before bumping. offending code point and its Unicode name instead of echoing the character. - ZIP entries are now rejected when their name carries an extension - from `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, or - `SYSTEM_FILES`. Every dot-separated suffix is checked, so a - disguised name such as `invoice.php.txt` is caught. The threat model - documented this mitigation but it was never implemented. + from a blocked `ZipThreatCategory`. Every dot-separated suffix is + checked, so a disguised name such as `invoice.php.txt` is caught. + The threat model documented this mitigation but it was never + implemented. Which categories are blocked is controlled by the new + `blocked_zip_entry_categories` limit. ## [1.1.1] - 2026-08-19 diff --git a/README.md b/README.md index e8b25df..0712b80 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Secure file upload validation for Python 3.11+ applications. Catches dangerous f - Filename sanitization and Unicode security checks - Extension validation with configurable allow/block lists - ZIP bomb detection, nested archive inspection, and recursive structure protection -- Dangerous ZIP entry rejection (executables, scripts, system files) +- Dangerous ZIP entry rejection (executables and scripts by default, configurable) - Image decompression bomb detection via declared pixel dimensions - MIME type verification with file signature validation - Activity file support (.gpx, .tcx, .fit) with XXE-safe XML parsing and root-element enforcement @@ -153,7 +153,7 @@ except FileValidationError as err: - **Filename Security**: Unicode normalization, directory traversal prevention, Windows reserved names blocking - **Extension Validation**: Allow/block lists with configurable rules, dangerous extension detection - **Compression Security**: ZIP bomb detection, nested archive inspection, recursive structure and quine detection, size and ratio limits, optional strict decompression verification -- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits, plus rejection of entries whose extension is an executable, script, or system file +- **Content Inspection**: Deep ZIP content analysis with configurable depth and entry limits, plus rejection of entries whose extension falls in a blocked `ZipThreatCategory` (`EXECUTABLE_FILES` and `SCRIPT_FILES` by default, via `blocked_zip_entry_categories`) - **Image Bomb Protection**: PNG and JPEG headers are parsed and the declared pixel count is bounded by `max_image_pixels` - **MIME Type Verification**: Magic number validation for images, ZIP, activity files, and gzip - **Streaming Validation**: Memory-efficient processing via `SpooledTemporaryFile` for large files diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index da6535f..e27cfd5 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -168,12 +168,14 @@ hidden inside ZIP archives. **Mitigations:** - `ZipContentInspector._check_dangerous_extension()` rejects any - entry whose name carries an extension from - `ZipThreatCategory.EXECUTABLE_FILES`, `SCRIPT_FILES`, or - `SYSTEM_FILES`. Every dot-separated suffix is checked, so a - disguised name such as `invoice.php.txt` is still rejected. - This check is metadata-level and runs even when - `scan_zip_content=False`. + entry whose name carries an extension from a `ZipThreatCategory` + listed in `blocked_zip_entry_categories`, which defaults to + `EXECUTABLE_FILES` and `SCRIPT_FILES`. Add `SYSTEM_FILES` to also + reject `.dll`, `.so`, `.ini` and `.conf` entries; that category is + mostly configuration rather than executable content, so it is + opt-in. Every dot-separated suffix is checked, so a disguised name + such as `invoice.php.txt` is still rejected. This check is + metadata-level and runs even when `scan_zip_content=False`. - Binary content is scanned for executable magic bytes from `SuspiciousFilePattern.EXECUTABLE_SIGNATURES`. - Text content is scanned for script injection patterns diff --git a/safeuploads/config.py b/safeuploads/config.py index 818ac2a..ba8896c 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -10,6 +10,7 @@ CompoundExtensionCategory, DangerousExtensionCategory, UnicodeAttackCategory, + ZipThreatCategory, ) from .exceptions import ConfigValidationError, FileSecurityConfigurationError from .utils import bytes_to_mb @@ -98,6 +99,9 @@ class SecurityLimits: allow_nested_archives: Whether nested archives are permitted. allow_symlinks: Whether symbolic links are permitted. allow_absolute_paths: Whether absolute paths are permitted. + blocked_zip_entry_categories: ``ZipThreatCategory`` names + whose extensions are rejected when they appear on a + ZIP entry. scan_zip_content: Whether deep content inspection is enabled. verify_zip_decompression: Whether to decompress every ZIP entry to reject forged central-directory metadata. @@ -179,6 +183,13 @@ class SecurityLimits: allow_symlinks: bool = False # Whether to allow absolute paths in ZIP allow_absolute_paths: bool = False + # Entry extensions rejected inside an accepted archive. + # EXECUTABLE_FILES and SCRIPT_FILES are code; SYSTEM_FILES is + # mostly configuration and is a different threat class, so it + # is available but not on by default. + blocked_zip_entry_categories: frozenset[str] = frozenset( + {"EXECUTABLE_FILES", "SCRIPT_FILES"} + ) scan_zip_content: bool = True # Whether to perform deep content inspection # Decompress every ZIP entry to reject forged central- # directory metadata (extra CPU/IO; off by default) @@ -1121,6 +1132,25 @@ def _validate_compression_settings( ) ) + # A misspelled category would silently disable the check. + known = {category.name for category in ZipThreatCategory} + unknown = sorted(set(limits.blocked_zip_entry_categories) - known) + if unknown: + errors.append( + _config_error( + "unknown_zip_entry_category", + ( + "blocked_zip_entry_categories contains" + f" unknown names: {', '.join(unknown)}" + ), + "compression", + ( + "Use ZipThreatCategory member names" + f" ({', '.join(sorted(known))})" + ), + ) + ) + return errors @classmethod diff --git a/safeuploads/inspectors/zip_inspector.py b/safeuploads/inspectors/zip_inspector.py index 767823b..96fef7b 100644 --- a/safeuploads/inspectors/zip_inspector.py +++ b/safeuploads/inspectors/zip_inspector.py @@ -37,15 +37,6 @@ logger = logging.getLogger(__name__) -# Entry extensions that must never appear inside an accepted -# archive, keyed by the threat category they belong to so the -# rejection message names the category. -_DANGEROUS_ENTRY_CATEGORIES: tuple[ZipThreatCategory, ...] = ( - ZipThreatCategory.EXECUTABLE_FILES, - ZipThreatCategory.SCRIPT_FILES, - ZipThreatCategory.SYSTEM_FILES, -) - class ZipContentInspector(BaseInspector): """ @@ -89,9 +80,13 @@ def __init__(self, config: FileSecurityConfig): self._recursable_exts: frozenset[str] = frozenset( ZipThreatCategory.RECURSABLE_ARCHIVES.value ) + # Extension to category name, so a rejection names the + # category that blocked it. Unknown names are reported by + # configuration validation and ignored here. self._dangerous_entry_exts: dict[str, str] = { ext.lower(): category.name - for category in _DANGEROUS_ENTRY_CATEGORIES + for category in ZipThreatCategory + if category.name in config.limits.blocked_zip_entry_categories for ext in category.value } diff --git a/tests/inspectors/test_zip_inspector.py b/tests/inspectors/test_zip_inspector.py index 4823d9b..a470879 100644 --- a/tests/inspectors/test_zip_inspector.py +++ b/tests/inspectors/test_zip_inspector.py @@ -25,12 +25,10 @@ class TestDangerousEntryExtensions: "payload.exe", "shell.php", "hook.ps1", - "inject.dll", - "settings.ini", ], ) def test_dangerous_entry_rejected(self, default_config, entry_name): - """Test executable, script and system entries are rejected.""" + """Test executable and script entries are rejected.""" inspector = ZipContentInspector(default_config) zip_buffer = io.BytesIO() @@ -42,6 +40,58 @@ def test_dangerous_entry_rejected(self, default_config, entry_name): assert "Dangerous entry extension" in str(exc_info.value) + @pytest.mark.parametrize( + "entry_name", + [ + "inject.dll", + "settings.ini", + ], + ) + def test_system_file_entry_allowed_by_default( + self, default_config, entry_name + ): + """Test SYSTEM_FILES is off by default.""" + inspector = ZipContentInspector(default_config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr(entry_name, b"harmless looking bytes") + + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + def test_system_file_entry_rejected_when_opted_in(self): + """Test adding SYSTEM_FILES restores the stricter check.""" + config = FileSecurityConfig( + SecurityLimits( + blocked_zip_entry_categories=frozenset( + {"EXECUTABLE_FILES", "SCRIPT_FILES", "SYSTEM_FILES"} + ) + ) + ) + inspector = ZipContentInspector(config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("settings.ini", b"harmless looking bytes") + + with pytest.raises(ZipContentError) as exc_info: + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + + assert "SYSTEM_FILES" in str(exc_info.value) + + def test_check_disabled_when_no_categories_blocked(self): + """Test an empty category set skips the check entirely.""" + config = FileSecurityConfig( + SecurityLimits(blocked_zip_entry_categories=frozenset()) + ) + inspector = ZipContentInspector(config) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("payload.exe", b"harmless looking bytes") + + inspector.inspect_zip_content(io.BytesIO(zip_buffer.getvalue())) + def test_disguised_double_extension_rejected(self, default_config): """Test a dangerous extension hidden mid-name is caught.""" inspector = ZipContentInspector(default_config) diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index 9e3f6bb..25bfd9a 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -197,6 +197,30 @@ def test_nonpositive_gzip_timeout_generates_error(self, monkeypatch): error_types = [e.error_type for e in errors if e.severity == "error"] assert "invalid_timeout" in error_types + def test_unknown_zip_entry_category_generates_error(self): + """Test that a misspelled threat category errors.""" + config = FileSecurityConfig( + SecurityLimits( + blocked_zip_entry_categories=frozenset( + {"EXECUTABLE_FILES", "SCRIPT_FILE"} + ) + ) + ) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "unknown_zip_entry_category" in error_types + + def test_known_zip_entry_categories_accepted(self): + """Test that valid category names pass validation.""" + config = FileSecurityConfig( + SecurityLimits( + blocked_zip_entry_categories=frozenset({"SYSTEM_FILES"}) + ) + ) + errors = config.validate_instance() + error_types = [e.error_type for e in errors if e.severity == "error"] + assert "unknown_zip_entry_category" not in error_types + def test_missing_temp_dir_generates_error(self): """Test that a temp_dir which is not a directory errors.""" config = FileSecurityConfig( From a74bd9b5c72c319f3a00afc75e9a4cfa74b03b24 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:46:04 +0100 Subject: [PATCH 15/16] feat: enhance error handling and logging for file uploads, ensuring untrusted values are escaped once --- CHANGELOG.md | 17 ++++- README.md | 12 ++-- docs/index.md | 3 +- docs/security/integration-checklist.md | 6 +- examples/fastapi_example.py | 6 +- safeuploads/config.py | 12 ++-- safeuploads/file_validator.py | 31 +++++---- safeuploads/inspectors/gzip_inspector.py | 8 ++- safeuploads/inspectors/zip_inspector.py | 4 +- tests/test_audit.py | 82 ++++++++++++++++++++++++ tests/test_config.py | 8 +++ 11 files changed, 156 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b04a4c5..996a151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,9 +123,20 @@ which uploads are accepted. Read the upgrade notes before bumping. detected is unchanged; only which pattern name is reported when a buffer matches several can differ. - Documentation and the FastAPI example no longer return `str(err)` to - clients. Exception messages embed the client-supplied filename, so - reflecting them hands attacker-controlled bytes back to the browser; - the examples now log the detail and return `err.error_code`. + clients. Exception messages embed values derived from the upload, + such as the detected MIME type and ZIP entry names, and + `err.filename` carries the client-supplied name; the examples now log + the detail and return `err.error_code`. +- `ZipContentInspector.inspect_zip_content()` and + `GzipContentInspector.inspect_gzip_content()` take an optional + `filename`, so a `THREAT_DETECTED` audit event names the file instead + of leaving the field empty for a correlation-ID join. +- `FileSecurityConfig.ACTIVITY_XML_ROOTS` is a read-only mapping rather + than a plain `dict`, matching the frozen allow-lists beside it. A + consumer can no longer mutate it process-wide. +- Audit fields are escaped once, at the emission point, instead of also + being escaped by the caller. Escaping twice could truncate an + adversarial filename mid-escape-sequence. ### Removed diff --git a/README.md b/README.md index 0712b80..b45a9ee 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ async def upload_image(file: UploadFile): await validator.validate_image_file(file) except FileValidationError as err: # Return the machine-readable code, never `str(err)`: exception - # messages embed the client-supplied filename, so reflecting them - # hands attacker-controlled bytes back to the browser. + # messages embed upload-derived values such as the detected MIME + # type, and `err.filename` carries the client-supplied name. raise HTTPException(status_code=400, detail=err.error_code) return {"status": "success", "filename": file.filename} @@ -115,9 +115,11 @@ pooled_validator = FileValidator( ## Exception handling Exception messages are written for your logs, not for your users. They -embed the client-supplied filename and other untrusted values, so never -return `str(err)` to a client. Branch on the exception type and surface -`err.error_code`, which is a stable machine-readable string. +embed values derived from the upload — the detected MIME type, ZIP entry +names, declared image dimensions — and `err.filename` carries the +client-supplied name. Never return `str(err)` or `err.filename` to a +client. Branch on the exception type and surface `err.error_code`, which +is a stable machine-readable string. ```python import logging diff --git a/docs/index.md b/docs/index.md index 37dd8c9..c6fbc06 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,7 +36,8 @@ async def upload_image(file: UploadFile): await validator.validate_image_file(file) except FileValidationError as err: # Return the machine-readable code, never `str(err)`: exception - # messages embed the client-supplied filename, so reflecting them + # messages embed upload-derived values such as the detected MIME + # type, and `err.filename` carries the client-supplied name. # hands attacker-controlled bytes back to the browser. raise HTTPException(status_code=400, detail=err.error_code) diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index c88bada..a90d943 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -148,8 +148,10 @@ archive afterwards: traces in production responses. !!! warning - Exception messages embed the client-supplied filename and - other untrusted values. Returning `str(err)` to a client + Exception messages embed values derived from the upload — the + detected MIME type, ZIP entry names, declared image + dimensions — and `err.filename` carries the client-supplied + name. Returning `str(err)` or `err.filename` to a client reflects attacker-controlled bytes back to the browser. Branch on the exception type and surface `err.error_code`, which is a stable machine-readable string. diff --git a/examples/fastapi_example.py b/examples/fastapi_example.py index a6e16bb..789b298 100644 --- a/examples/fastapi_example.py +++ b/examples/fastapi_example.py @@ -94,9 +94,9 @@ async def file_validation_exception_handler(request, exc: FileValidationError): Converts safeuploads exceptions to HTTP responses with appropriate status codes and detailed error information. - Exception messages embed the client-supplied filename, so they are - logged rather than returned. Clients receive a static message plus - the machine-readable ``error_code``. + Exception messages embed upload-derived values such as the detected + MIME type, so they are logged rather than returned. Clients receive + a static message plus the machine-readable ``error_code``. """ logger.warning("Upload rejected: %r", exc) diff --git a/safeuploads/config.py b/safeuploads/config.py index ba8896c..963021d 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -3,7 +3,9 @@ import itertools import logging import os +from collections.abc import Mapping from dataclasses import dataclass, replace +from types import MappingProxyType from typing import Any, ClassVar from .enums import ( @@ -302,10 +304,12 @@ class FileSecurityConfig: # Required root element per XML activity format, lower-cased # and namespace-stripped. Guards against an arbitrary XML # document (or an HTML/SVG payload) wearing a .gpx name. - ACTIVITY_XML_ROOTS: ClassVar[dict[str, str]] = { - ".gpx": "gpx", - ".tcx": "trainingcenterdatabase", - } + ACTIVITY_XML_ROOTS: ClassVar[Mapping[str, str]] = MappingProxyType( + { + ".gpx": "gpx", + ".tcx": "trainingcenterdatabase", + } + ) # Generate dangerous file extensions from categorized enums @staticmethod diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index 35586bf..3db6e3a 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -861,16 +861,21 @@ async def _run_validation( unexpected internal error. """ cid = set_correlation_id() - # The raw client filename reaches the log before any - # sanitization has run, so escape it here. - filename = safe_label(file.filename or "unknown") - self._audit.start(filename, cid) - logger.debug("Starting %s file validation: %s", file_type, filename) + # Audit fields are escaped at the emission point, so pass + # the raw name through; escaping twice would truncate an + # adversarial name mid-escape-sequence. + raw_name = file.filename or "unknown" + self._audit.start(raw_name, cid) + logger.debug( + "Starting %s file validation: %s", + file_type, + safe_label(raw_name), + ) t0 = time.monotonic() try: await body(file) ms = (time.monotonic() - t0) * 1000 - self._audit.success(safe_label(file.filename or filename), cid, ms) + self._audit.success(file.filename or raw_name, cid, ms) except ( FileValidationError, ResourceLimitError, @@ -878,10 +883,10 @@ async def _run_validation( ) as exc: ms = (time.monotonic() - t0) * 1000 self._audit.failure( - safe_label(file.filename or filename), + file.filename or raw_name, cid, ms, - safe_label(str(exc), max_length=512), + str(exc), event_type=( AuditEventType.RESOURCE_LIMIT if isinstance(exc, ResourceLimitError) @@ -892,7 +897,7 @@ async def _run_validation( except Exception as err: ms = (time.monotonic() - t0) * 1000 self._audit.failure( - safe_label(file.filename or filename), + file.filename or raw_name, cid, ms, "internal_error", @@ -1110,7 +1115,9 @@ def _inspect_zip_sync( # Perform ZIP content inspection if enabled if self.config.limits.scan_zip_content: temp_file.seek(0) - self.zip_inspector.inspect_zip_content(temp_file, monitor) + self.zip_inspector.inspect_zip_content( + temp_file, monitor, filename + ) # Optional content analysis if self.config.limits.enable_content_analysis: @@ -1358,7 +1365,9 @@ def _inspect_gzip_sync( ) # Decompression bomb check - self.gzip_inspector.inspect_gzip_content(temp_file, file_size, monitor) + self.gzip_inspector.inspect_gzip_content( + temp_file, file_size, monitor, filename + ) logger.debug( "Gzip file validation passed: %s (%s, %s bytes)", diff --git a/safeuploads/inspectors/gzip_inspector.py b/safeuploads/inspectors/gzip_inspector.py index 0788590..324ea7f 100644 --- a/safeuploads/inspectors/gzip_inspector.py +++ b/safeuploads/inspectors/gzip_inspector.py @@ -43,6 +43,7 @@ def inspect_gzip_content( file_obj: SeekableFile, compressed_size: int, monitor: ResourceMonitor | None = None, + filename: str = "", ) -> None: """ Inspect gzip archive for decompression bombs. @@ -52,6 +53,7 @@ def inspect_gzip_content( compressed_size: Size of the compressed file in bytes. monitor: Optional resource monitor checked once per chunk so a slow stream is aborted mid-inflation. + filename: Sanitized filename recorded on audit events. Raises: ZipBombError: If compression ratio or uncompressed @@ -90,7 +92,7 @@ def inspect_gzip_content( cid = get_correlation_id() if cid: self._audit.threat( - "", + filename, cid, "Gzip inflation timeout", ) @@ -120,7 +122,7 @@ def inspect_gzip_content( cid = get_correlation_id() if cid: self._audit.threat( - "", + filename, cid, "Gzip decompression bomb — size exceeded", ) @@ -152,7 +154,7 @@ def inspect_gzip_content( cid = get_correlation_id() if cid: self._audit.threat( - "", + filename, cid, "Gzip decompression bomb — ratio exceeded", ) diff --git a/safeuploads/inspectors/zip_inspector.py b/safeuploads/inspectors/zip_inspector.py index 96fef7b..412e7b6 100644 --- a/safeuploads/inspectors/zip_inspector.py +++ b/safeuploads/inspectors/zip_inspector.py @@ -94,6 +94,7 @@ def inspect_zip_content( self, file_obj: SeekableFile, monitor: ResourceMonitor | None = None, + filename: str = "", ) -> None: """ Inspect ZIP archive for potential security threats. @@ -102,6 +103,7 @@ def inspect_zip_content( file_obj: Seekable file-like object containing ZIP data. monitor: Optional resource monitor checked once per entry so a runaway archive is aborted mid-scan. + filename: Sanitized filename recorded on audit events. Raises: ZipContentError: If security threats are detected in ZIP @@ -177,7 +179,7 @@ def inspect_zip_content( cid = get_correlation_id() if cid: self._audit.threat( - "", + filename, cid, "; ".join(threats_found), ) diff --git a/tests/test_audit.py b/tests/test_audit.py index 14097d2..ee69f55 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -258,6 +258,45 @@ def test_source_ip_is_escaped(self, caplog): assert "\n" not in caplog.records[0].audit_source_ip +class TestAuditEscapingIsSinglePass: + """Untrusted fields are escaped once, at the emission point.""" + + @pytest.mark.asyncio + async def test_hostile_filename_is_not_double_escaped( + self, mock_upload_file, caplog + ): + """Test escaping does not run twice and mangle the name. + + Args: + mock_upload_file: Upload file factory fixture. + caplog: pytest log capture fixture. + """ + from safeuploads.config import FileSecurityConfig, SecurityLimits + from safeuploads.file_validator import FileValidator + from safeuploads.utils import safe_label + from tests.conftest import JPEG_SOF0 + + config = FileSecurityConfig(SecurityLimits(enable_audit_logging=True)) + validator = FileValidator(config=config) + + # Sanitization reduces this to "photo.jpg"; the audit start + # event still records the raw name the client sent. + hostile = "photo" + "\n" * 256 + ".jpg" + file = mock_upload_file( + filename=hostile, + content=b"\xff\xd8" + JPEG_SOF0 + b"\xff\xd9", + ) + + with caplog.at_level(logging.DEBUG, logger="safeuploads.audit"): + await validator.validate_image_file(file) + + recorded = caplog.records[0].audit_filename + assert recorded == safe_label(hostile) + assert "\n" not in recorded + # A second pass would truncate mid-escape-sequence. + assert not recorded.rstrip(".").endswith(("\\u", "\\u0", "\\u00")) + + class TestAuditIntegration: """Test audit logging integration with FileValidator.""" @@ -462,6 +501,49 @@ async def test_zip_threats_emit_audit_event( if r.name == "safeuploads.audit" and "threat_detected" in r.message ] assert len(threat_records) >= 1 + assert threat_records[0].audit_filename == "evil.zip" + + @pytest.mark.asyncio + async def test_gzip_threat_audit_event_names_the_file( + self, mock_upload_file, caplog + ): + """Test a gzip bomb audit event carries the filename.""" + import gzip + import io + + from safeuploads.config import ( + FileSecurityConfig, + SecurityLimits, + ) + from safeuploads.file_validator import FileValidator + + config = FileSecurityConfig( + SecurityLimits( + enable_audit_logging=True, + max_compression_ratio=2, + ) + ) + validator = FileValidator(config=config) + + buf = io.BytesIO() + with gzip.GzipFile(fileobj=buf, mode="wb") as gz: + gz.write(b"a" * 1_000_000) + + file = mock_upload_file(filename="bomb.gz", content=buf.getvalue()) + + with ( + caplog.at_level(logging.DEBUG, logger="safeuploads.audit"), + pytest.raises(ZipBombError), + ): + await validator.validate_gzip_file(file) + + threat_records = [ + r + for r in caplog.records + if r.name == "safeuploads.audit" and "threat_detected" in r.message + ] + assert len(threat_records) >= 1 + assert threat_records[0].audit_filename == "bomb.gz" @pytest.mark.asyncio async def test_zip_bomb_emits_audit_threat(self, mock_upload_file, caplog): diff --git a/tests/test_config.py b/tests/test_config.py index 6f469cd..eaf43a6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -98,6 +98,14 @@ def test_allowed_image_extensions(self): assert ".jpeg" in config.ALLOWED_IMAGE_EXTENSIONS assert ".png" in config.ALLOWED_IMAGE_EXTENSIONS + def test_activity_xml_roots_are_immutable(self): + """Test the XML root mapping cannot be mutated.""" + config = FileSecurityConfig() + + assert config.ACTIVITY_XML_ROOTS[".gpx"] == "gpx" + with pytest.raises(TypeError): + config.ACTIVITY_XML_ROOTS[".svg"] = "svg" # type: ignore[index] + def test_allowed_zip_extensions(self): """Test allowed ZIP extensions.""" config = FileSecurityConfig() From 2a222acb2882038e9a8e2522e08d47e366543dae Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:16:44 +0100 Subject: [PATCH 16/16] feat: update Python version in documentation and enhance gzip analysis timeout handling --- .github/instructions/python.instructions.md | 10 +-- CHANGELOG.md | 39 ++++++---- docs/security/integration-checklist.md | 9 ++- docs/security/threat-model.md | 7 +- examples/README.md | 6 +- safeuploads/__init__.py | 4 ++ safeuploads/audit.py | 18 ++++- safeuploads/config.py | 39 +++++++++- safeuploads/file_validator.py | 5 +- safeuploads/inspectors/content_inspector.py | 7 +- safeuploads/inspectors/gzip_inspector.py | 1 - safeuploads/inspectors/zip_inspector.py | 7 +- safeuploads/utils.py | 72 +++++++++++-------- .../validators/compression_validator.py | 4 +- tests/inspectors/test_gzip_inspector.py | 16 +++-- tests/test_audit.py | 11 +++ tests/test_config_validation.py | 21 ++++++ tests/test_file_validator.py | 3 +- tests/test_utils.py | 37 ++++++++++ tests/validators/test_xml_validator.py | 6 +- 20 files changed, 243 insertions(+), 79 deletions(-) diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md index 5f10be0..0f4ee2d 100644 --- a/.github/instructions/python.instructions.md +++ b/.github/instructions/python.instructions.md @@ -1,9 +1,9 @@ --- -description: 'Python 3.13 + FastAPI + SQLAlchemy + Alembic coding standards, docstring format, testing patterns, and module organization for the Endurain backend' +description: 'Python 3.11 + FastAPI + SQLAlchemy + Alembic coding standards, docstring format, testing patterns, and module organization for the Endurain backend' applyTo: '**/*.py' --- # Project Context -- **Python Version:** 3.13+ (required) +- **Python Version:** 3.11+ (required) - **Framework:** FastAPI with SQLAlchemy ORM and Alembic migrations - **Dependency Management:** uv (see `pyproject.toml`) - **Project Structure:** All backend code in `backend/app/` @@ -12,7 +12,7 @@ applyTo: '**/*.py' # Development Setup - **Install uv:** `pip install uv` - **Install dependencies:** `uv sync --group dev` -- **Use Docker:** If system Python < 3.13, use Docker for +- **Use Docker:** If system Python < 3.11, use Docker for development # SQLAlchemy 2.0 Standards @@ -48,11 +48,11 @@ applyTo: '**/*.py' - **No hardcoded secrets:** Use environment variables - **Async file I/O:** Use `await file.read()`, not sync -# Modern Python Syntax (Python 3.13+) +# Modern Python Syntax (Python 3.11+) - Use modern type hint syntax: `int | None`, `list[str]`, `dict[str, Any]` - Do NOT use `typing.Optional`, `typing.List`, `typing.Dict`, etc. -- Target Python 3.13+ features and syntax +- Target Python 3.11+ features and syntax - Always prioritize readability and clarity # PEP 8 Line Limits diff --git a/CHANGELOG.md b/CHANGELOG.md index 996a151..dadbd15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,10 +51,16 @@ which uploads are accepted. Read the upgrade notes before bumping. incrementally and completed elements are discarded as they close, so a flat document with millions of elements can no longer amplify a bounded upload into an unbounded object graph. -- `gzip_analysis_timeout` limit (default 5 s) bounding gzip inflation +- `gzip_analysis_timeout` limit (default 25 s) bounding gzip inflation independently of any caller-supplied `ResourceMonitor`. A breach - raises `ZipBombError` with `ZIP_ANALYSIS_TIMEOUT`, matching the ZIP - inspector's timeout. + raises `ZipBombError` with `ZIP_ANALYSIS_TIMEOUT`, the same error + code the ZIP inspector uses for its own timeout. The default is + sized to inflate `max_uncompressed_size` at a conservative + 50 MB/s; configuration validation reports + `gzip_timeout_below_size_limit` when the two are set against each + other, because a timeout too short for the permitted size turns + every slow-but-legitimate upload into a reported decompression + bomb. - `safe_label()` utility, applied to every untrusted filename and ZIP entry name before it reaches a log record, audit event, or exception message. @@ -80,8 +86,9 @@ which uploads are accepted. Read the upgrade notes before bumping. non-FastAPI framework adapter implements, so it belonged in the public API alongside `SeekableFile`. - `set_source_ip()`, which attaches the client address to every audit - event in the current context. `AuditEvent.source_ip` existed but was - never populated. + event in the current context, together with `get_source_ip()` and + `reset_source_ip()`. `AuditEvent.source_ip` existed but was never + populated. - Release-verification instructions for consumers, covering PEP 740 attestation checks with `pypi-attestations`. @@ -115,13 +122,18 @@ which uploads are accepted. Read the upgrade notes before bumping. - `FileProcessingError` accepts an optional `error_code`, and XML failures now carry `XML_MALFORMED`, `XML_FORBIDDEN_CONSTRUCT`, `XML_INVALID_ROOT`, or `XML_TOO_MANY_ELEMENTS`. -- `find_text_pattern()` scans raw bytes with a cached compiled pattern - instead of decoding and lower-casing the whole buffer, removing two - full-size copies of the content-analysis window (up to 50 MB each). - It now returns the match that appears earliest in the content rather - than the first pattern in the supplied order. Whether a threat is - detected is unchanged; only which pattern name is reported when a - buffer matches several can differ. +- `find_text_pattern()` lower-cases the scan window once and searches + it as bytes, instead of decoding it to text and lower-casing that, + removing one full-size copy of the content-analysis window (up to + 50 MB). `find_embedded_signature()` takes a start offset, so + skipping an expected header no longer copies the window either. + Both now scan candidates in a canonical longest-first order, so + which pattern is reported when a buffer matches several no longer + depends on set iteration order. Whether a threat is detected is + unchanged, with one exception: matching over bytes does not see + through interleaved invalid bytes the way decoding with + `errors="ignore"` did, so a pattern split by junk bytes is no + longer joined back together. - Documentation and the FastAPI example no longer return `str(err)` to clients. Exception messages embed values derived from the upload, such as the detected MIME type and ZIP entry names, and @@ -136,7 +148,8 @@ which uploads are accepted. Read the upgrade notes before bumping. consumer can no longer mutate it process-wide. - Audit fields are escaped once, at the emission point, instead of also being escaped by the caller. Escaping twice could truncate an - adversarial filename mid-escape-sequence. + adversarial filename mid-escape-sequence, because the escaped form + is longer than the input and is re-truncated on the second pass. ### Removed diff --git a/docs/security/integration-checklist.md b/docs/security/integration-checklist.md index a90d943..e595490 100644 --- a/docs/security/integration-checklist.md +++ b/docs/security/integration-checklist.md @@ -37,7 +37,12 @@ that addresses it. for most workloads; lower for stricter environments. - `max_xml_elements` — default 1,000,000; lower if you only accept small GPX/TCX files. - - `gzip_analysis_timeout` — default 5 s for gzip inflation. + - `gzip_analysis_timeout` — default 25 s for gzip inflation, + sized to cover `max_uncompressed_size` at a conservative + 50 MB/s. Lower it only alongside `max_uncompressed_size`: + a timeout too short for the permitted size rejects slow + but legitimate uploads as decompression bombs, and + configuration validation warns when the two disagree. - `max_validation_time_seconds` — default 30 s; lower in latency-sensitive services. - `max_validation_memory_mb` — default 512 MB. This is @@ -130,6 +135,8 @@ archive afterwards: recommended for log aggregation). - [ ] `set_source_ip()` called with the client address before validating, so audit events can be attributed to a caller. +- [ ] `reset_source_ip()` called when the address must not + outlive the request, if your framework reuses the context. - [ ] Log storage retention policy defined (minimum 90 days recommended for security incident investigation). - [ ] Alerting configured for `THREAT_DETECTED` and diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index e27cfd5..2720d90 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -383,9 +383,12 @@ size, similar to ZIP bombs. - Exceeding either limit raises a validation error immediately, without reading the rest of the stream. - Inflation is additionally bounded by `gzip_analysis_timeout` - (default 5 s), so a stream that stays inside the ratio and + (default 25 s), so a stream that stays inside the ratio and size limits still cannot burn unbounded CPU. The bound does - not depend on the caller supplying a `ResourceMonitor`. + not depend on the caller supplying a `ResourceMonitor`. The + default is sized to inflate `max_uncompressed_size` at a + conservative 50 MB/s, so it fires on pathological CPU cost + rather than on a large but legitimate stream. --- diff --git a/examples/README.md b/examples/README.md index f76d9f2..356a78a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -103,9 +103,9 @@ strict_limits = SecurityLimits( max_compression_ratio=50, # Stricter ratio ) -# Apply to config -config = FileSecurityConfig() -config.limits = strict_limits +# Apply to config. The limits are copied, so the object above is +# never aliased; assigning to `config.limits` instead would share it. +config = FileSecurityConfig(strict_limits) # Use with validator validator = FileValidator(config=config) diff --git a/safeuploads/__init__.py b/safeuploads/__init__.py index 7f5c667..b6f96e6 100644 --- a/safeuploads/__init__.py +++ b/safeuploads/__init__.py @@ -10,7 +10,9 @@ AuditEventType, SecurityAuditLogger, get_correlation_id, + get_source_ip, reset_correlation_id, + reset_source_ip, set_correlation_id, set_source_ip, ) @@ -133,5 +135,7 @@ "get_correlation_id", "set_correlation_id", "reset_correlation_id", + "get_source_ip", "set_source_ip", + "reset_source_ip", ] diff --git a/safeuploads/audit.py b/safeuploads/audit.py index c594865..ad32a4f 100644 --- a/safeuploads/audit.py +++ b/safeuploads/audit.py @@ -69,13 +69,22 @@ def reset_correlation_id() -> None: ) +def get_source_ip() -> str | None: + """ + Return the client address recorded for this context. + + Returns: + Client address string, or None if not set. + """ + return source_ip_var.get() + + def set_source_ip(ip: str | None) -> None: """ Record the client address for audit events in this context. safeuploads never sees the request, so the application sets - this from its own framework before validating. Pass None to - clear it. + this from its own framework before validating. Args: ip: Client address, or None to clear. @@ -83,6 +92,11 @@ def set_source_ip(ip: str | None) -> None: source_ip_var.set(ip) +def reset_source_ip() -> None: + """Clear the client address recorded for this context.""" + source_ip_var.set(None) + + def log_extra( extra: dict[str, Any] | None = None, ) -> dict[str, Any]: diff --git a/safeuploads/config.py b/safeuploads/config.py index 963021d..75cf9f1 100644 --- a/safeuploads/config.py +++ b/safeuploads/config.py @@ -19,6 +19,12 @@ logger = logging.getLogger(__name__) +# Conservative floor for gzip inflation throughput, used only to +# size the analysis timeout against the uncompressed byte limit. +# Well below what a modern host sustains, so the derived timeout +# stays generous rather than borderline. +_MIN_INFLATE_THROUGHPUT_MB_S = 50 + def _config_error( error_type: str, @@ -162,7 +168,7 @@ class SecurityLimits: 5.0 # Maximum seconds to spend analyzing ZIP structure ) gzip_analysis_timeout: float = ( - 5.0 # Maximum seconds to spend inflating a gzip stream + 25.0 # Maximum seconds to spend inflating a gzip stream ) # XML activity file limits. Entity expansion is blocked by @@ -1136,6 +1142,37 @@ def _validate_compression_settings( ) ) + # A timeout too short to inflate a permitted stream turns + # every slow-but-legitimate upload into a ZipBombError and + # a THREAT_DETECTED audit event, so the two limits have to + # be sized against each other. + required = ( + bytes_to_mb(limits.max_uncompressed_size) + / _MIN_INFLATE_THROUGHPUT_MB_S + ) + if 0 < limits.gzip_analysis_timeout < required: + errors.append( + _config_error( + "gzip_timeout_below_size_limit", + ( + "gzip_analysis_timeout" + f" ({limits.gzip_analysis_timeout}s) is too" + " short to inflate max_uncompressed_size" + f" ({bytes_to_mb(limits.max_uncompressed_size)}MB)," + f" which needs about {required:.0f}s at" + f" {_MIN_INFLATE_THROUGHPUT_MB_S}MB/s; legitimate" + " uploads will be rejected as decompression bombs" + ), + "compression", + ( + f"Raise gzip_analysis_timeout to at least" + f" {required:.0f}s or lower" + " max_uncompressed_size" + ), + severity="warning", + ) + ) + # A misspelled category would silently disable the check. known = {category.name for category in ZipThreatCategory} unknown = sorted(set(limits.blocked_zip_entry_categories) - known) diff --git a/safeuploads/file_validator.py b/safeuploads/file_validator.py index 3db6e3a..49f850a 100644 --- a/safeuploads/file_validator.py +++ b/safeuploads/file_validator.py @@ -343,9 +343,10 @@ def _validate_file_signature( error_code=ErrorCode.FILE_SIGNATURE_MISSING, ) - if matches_signature_prefix( + matched = matches_signature_prefix( file_content, _FILE_SIGNATURES.get(expected_type, ()) - ): + ) + if matched is not None: logger.debug("File signature matched for type '%s'", expected_type) return diff --git a/safeuploads/inspectors/content_inspector.py b/safeuploads/inspectors/content_inspector.py index c59b4a5..e895fe4 100644 --- a/safeuploads/inspectors/content_inspector.py +++ b/safeuploads/inspectors/content_inspector.py @@ -117,8 +117,10 @@ def scan_content( ) cid = get_correlation_id() if cid: + # Raw name: the audit logger escapes on emission, + # and escaping twice can truncate mid-sequence. self._audit.threat( - label, + filename, cid, "; ".join(threats), ) @@ -203,8 +205,7 @@ def _check_polyglot( # Skip first 8 bytes (longest common header is # PNG at 8 bytes) and search rest for secondary # signatures to detect polyglot files - tail = content[8:] - sig = find_embedded_signature(tail, self._polyglot_sigs) + sig = find_embedded_signature(content, self._polyglot_sigs, 8) if sig is not None: return [ f"Polyglot file detected" diff --git a/safeuploads/inspectors/gzip_inspector.py b/safeuploads/inspectors/gzip_inspector.py index 324ea7f..2adff79 100644 --- a/safeuploads/inspectors/gzip_inspector.py +++ b/safeuploads/inspectors/gzip_inspector.py @@ -102,7 +102,6 @@ def inspect_gzip_content( f" {timeout}s" " - potential decompression bomb" ), - compression_ratio=0, error_code=ErrorCode.ZIP_ANALYSIS_TIMEOUT, ) diff --git a/safeuploads/inspectors/zip_inspector.py b/safeuploads/inspectors/zip_inspector.py index 412e7b6..7b52f4b 100644 --- a/safeuploads/inspectors/zip_inspector.py +++ b/safeuploads/inspectors/zip_inspector.py @@ -493,8 +493,11 @@ def _inspect_entry_content( # Executable signatures are matched against the # entry header (anchored): the entry either is or # is not an executable. - if matches_signature_prefix( - content_sample, self._exec_signatures + if ( + matches_signature_prefix( + content_sample, self._exec_signatures + ) + is not None ): threats.append(f"Executable content detected in '{label}'") diff --git a/safeuploads/utils.py b/safeuploads/utils.py index 60b28e4..665d615 100644 --- a/safeuploads/utils.py +++ b/safeuploads/utils.py @@ -1,13 +1,12 @@ """Utility helpers for resource monitoring and content scanning.""" -import functools import logging -import re import sys import time import unicodedata from collections.abc import Iterable from types import TracebackType +from typing import TypeVar from .exceptions import ErrorCode, ResourceLimitError @@ -114,68 +113,79 @@ def matches_signature_prefix( return None +_TNeedle = TypeVar("_TNeedle", str, bytes) + + +def _canonical_order(needles: Iterable[_TNeedle]) -> tuple[_TNeedle, ...]: + """ + Deduplicate and order needles so scans are reproducible. + + Callers pass sets, whose iteration order is an implementation + detail; without this, which of several matching patterns gets + reported could vary. Longest-first makes the most specific + candidate win when two of them match. + + Args: + needles: Patterns or signatures to canonicalize. + + Returns: + Deduplicated tuple ordered longest-first, then by value. + """ + return tuple(sorted(set(needles), key=lambda n: (-len(n), n))) + + def find_embedded_signature( - content: bytes, signatures: Iterable[bytes] + content: bytes, signatures: Iterable[bytes], start: int = 0 ) -> bytes | None: """ Return the first signature found anywhere in the content. Substring match; use when detecting a format embedded inside otherwise-valid content (polyglots, appended payloads). + Scanning uses ``bytes.find`` from ``start``: its C search + beats a compiled alternation over the same literals, and the + offset skips an expected header without copying the window. Args: content: Raw bytes to scan. signatures: Candidate byte signatures. + start: Offset to begin scanning at, so a caller can skip + an expected header without slicing the buffer. Returns: The first matching signature, or None if none present. """ - for sig in signatures: - if sig in content: + for sig in _canonical_order(signatures): + if content.find(sig, start) != -1: return sig return None -@functools.lru_cache(maxsize=8) -def _compile_text_patterns(patterns: tuple[str, ...]) -> re.Pattern[bytes]: - """ - Build a cached case-insensitive alternation over patterns. - - Args: - patterns: Lower-case ASCII substrings to search for. - - Returns: - Compiled byte-level pattern matching any of the inputs. - """ - return re.compile( - b"|".join(re.escape(p.encode("utf-8")) for p in patterns), - re.IGNORECASE, - ) - - def find_text_pattern(content: bytes, patterns: Iterable[str]) -> str | None: """ Return the first text pattern present in the content. - Matching runs directly over the raw bytes in a single pass so - a large scan window is never copied or decoded. + The window is lower-cased once and searched as bytes. A + case-insensitive regex alternation over the same literals + measures an order of magnitude slower on a large window, and + decoding to text would cost a second full-size copy. Args: content: Raw bytes to scan. patterns: Lower-case ASCII substrings to search for. Returns: - The matching pattern in its canonical lower-case form, or - None if none are present. + The matching pattern, or None if none are present. """ - candidates = tuple(patterns) + candidates = _canonical_order(patterns) if not candidates: return None - match = _compile_text_patterns(candidates).search(content) - if match is None: - return None - return match.group().lower().decode("utf-8", errors="replace") + lowered = content.lower() + for pattern in candidates: + if lowered.find(pattern.encode("utf-8")) != -1: + return pattern + return None _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" diff --git a/safeuploads/validators/compression_validator.py b/safeuploads/validators/compression_validator.py index 98bac9f..ccdefa5 100644 --- a/safeuploads/validators/compression_validator.py +++ b/safeuploads/validators/compression_validator.py @@ -203,8 +203,10 @@ def validate_zip_compression_ratio( ) cid = get_correlation_id() if cid: + # Raw name: the audit logger escapes + # on emission. self._audit.threat( - entry_label, + entry.filename, cid, "Zip bomb — excessive compression ratio", ) diff --git a/tests/inspectors/test_gzip_inspector.py b/tests/inspectors/test_gzip_inspector.py index 066dbd1..f94ca77 100644 --- a/tests/inspectors/test_gzip_inspector.py +++ b/tests/inspectors/test_gzip_inspector.py @@ -36,14 +36,17 @@ def test_chunk_loop_aborts_on_time_limit(self, default_config): def test_inflation_timeout_without_monitor(self): """Test the inspector bounds inflation on its own.""" - config = FileSecurityConfig() - config.limits = SecurityLimits( - gzip_analysis_timeout=0.0, - chunk_size=1, - enable_audit_logging=True, + # A configuration-valid timeout, kept tiny; the 1-byte + # chunk size guarantees enough loop iterations to pass it. + config = FileSecurityConfig( + SecurityLimits( + gzip_analysis_timeout=0.001, + chunk_size=1, + enable_audit_logging=True, + ) ) inspector = GzipContentInspector(config) - payload = gzip.compress(b"x" * 4096) + payload = gzip.compress(b"x" * 65536) set_correlation_id("test-correlation-id") try: @@ -52,6 +55,7 @@ def test_inflation_timeout_without_monitor(self): io.BytesIO(payload), len(payload) ) assert exc_info.value.error_code == ErrorCode.ZIP_ANALYSIS_TIMEOUT + assert exc_info.value.compression_ratio is None finally: reset_correlation_id() diff --git a/tests/test_audit.py b/tests/test_audit.py index ee69f55..b5f0018 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -9,7 +9,9 @@ AuditEventType, SecurityAuditLogger, get_correlation_id, + get_source_ip, reset_correlation_id, + reset_source_ip, set_correlation_id, set_source_ip, ) @@ -257,6 +259,15 @@ def test_source_ip_is_escaped(self, caplog): assert "\n" not in caplog.records[0].audit_source_ip + def test_reset_clears_the_context_value(self): + """Test reset_source_ip clears a recorded address.""" + set_source_ip("203.0.113.7") + assert get_source_ip() == "203.0.113.7" + + reset_source_ip() + + assert get_source_ip() is None + class TestAuditEscapingIsSinglePass: """Untrusted fields are escaped once, at the emission point.""" diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py index 25bfd9a..e9ae704 100644 --- a/tests/test_config_validation.py +++ b/tests/test_config_validation.py @@ -210,6 +210,27 @@ def test_unknown_zip_entry_category_generates_error(self): error_types = [e.error_type for e in errors if e.severity == "error"] assert "unknown_zip_entry_category" in error_types + def test_gzip_timeout_below_size_limit_warns(self): + """Test a timeout too short for the size limit warns.""" + config = FileSecurityConfig( + SecurityLimits( + gzip_analysis_timeout=1.0, + max_uncompressed_size=1024 * 1024 * 1024, + ) + ) + errors = config.validate_instance() + warnings = [e.error_type for e in errors if e.severity == "warning"] + assert "gzip_timeout_below_size_limit" in warnings + + def test_default_gzip_timeout_covers_size_limit(self): + """Test the shipped defaults do not warn against each other.""" + errors = FileSecurityConfig().validate_instance() + assert not [ + e + for e in errors + if e.error_type == "gzip_timeout_below_size_limit" + ] + def test_known_zip_entry_categories_accepted(self): """Test that valid category names pass validation.""" config = FileSecurityConfig( diff --git a/tests/test_file_validator.py b/tests/test_file_validator.py index 39833a6..4421d4a 100644 --- a/tests/test_file_validator.py +++ b/tests/test_file_validator.py @@ -1623,8 +1623,7 @@ async def test_within_pixel_limit_passes(self, mock_upload_file): @pytest.mark.asyncio async def test_custom_pixel_limit_enforced(self, mock_upload_file): """A tightened max_image_pixels is honoured.""" - config = FileSecurityConfig() - config.limits = SecurityLimits(max_image_pixels=1000) + config = FileSecurityConfig(SecurityLimits(max_image_pixels=1000)) validator = FileValidator(config=config) content = _png_with_dimensions(100, 100) file = mock_upload_file(filename="photo.png", content=content) diff --git a/tests/test_utils.py b/tests/test_utils.py index 665666a..775bbb9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,6 +10,7 @@ ) from safeuploads.utils import ( ResourceMonitor, + find_embedded_signature, find_text_pattern, parse_image_dimensions, safe_label, @@ -119,6 +120,42 @@ def test_undecodable_bytes_do_not_raise(self): """Test invalid UTF-8 is scanned without decoding.""" assert find_text_pattern(b"\xff\xfe\xfd", ("", (" bytes: """Build a PNG header declaring the given dimensions.""" diff --git a/tests/validators/test_xml_validator.py b/tests/validators/test_xml_validator.py index dc7cd76..02c4536 100644 --- a/tests/validators/test_xml_validator.py +++ b/tests/validators/test_xml_validator.py @@ -226,8 +226,7 @@ class TestXmlElementCap: def test_element_cap_enforced(self): """Test exceeding max_xml_elements is rejected.""" - config = FileSecurityConfig() - config.limits = SecurityLimits(max_xml_elements=10) + config = FileSecurityConfig(SecurityLimits(max_xml_elements=10)) validator = XmlSecurityValidator(config) payload = b"" + b"" * 50 + b"" @@ -238,8 +237,7 @@ def test_element_cap_enforced(self): def test_document_within_cap_passes(self): """Test a document under the cap is accepted.""" - config = FileSecurityConfig() - config.limits = SecurityLimits(max_xml_elements=100) + config = FileSecurityConfig(SecurityLimits(max_xml_elements=100)) validator = XmlSecurityValidator(config) payload = b"" + b"" * 50 + b""