safeuploads 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/references/testing.md +3 -2
- safeuploads-2.2.0/.github/workflows/mirror.yml +41 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/publish-safeuploads.yml +5 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.gitignore +1 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/CHANGELOG.md +16 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/PKG-INFO +1 -1
- {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/security/architecture.md +1 -1
- {safeuploads-2.1.0 → safeuploads-2.2.0}/pyproject.toml +6 -2
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/__init__.py +13 -2
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/config.py +18 -18
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/exceptions.py +51 -4
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/utils.py +17 -10
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/test_gzip_inspector.py +2 -1
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/test_zip_inspector.py +4 -2
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_audit.py +4 -2
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_config.py +8 -8
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_config_validation.py +5 -3
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_exceptions.py +51 -13
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_fastapi_integration.py +2 -2
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_file_validator.py +6 -2
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_performance.py +2 -1
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_sync_validation.py +25 -9
- {safeuploads-2.1.0 → safeuploads-2.2.0}/uv.lock +34 -15
- safeuploads-2.1.0/.github/workflows/mirror.yml +0 -30
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/conventional-commits/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/conventional-commits/references/pull-requests.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/debugging/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/references/code-standards.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/references/security.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/skill-authoring/SKILL.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/CODEOWNERS +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/FUNDING.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/copilot-instructions.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/dependabot.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/scripts/check_conventional_commits.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/scripts/smoke_import.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/audit-safeuploads.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/conventional-commits.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/docs.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/fuzz.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/lint-and-test.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/.github/workflows/test-matrix-safeuploads.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/AGENTS.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/CODE_OF_CONDUCT.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/CONTRIBUTING.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/LICENSE.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/README.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/SECURITY.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/api.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/index.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/rate-limiting.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/security/integration-checklist.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/docs/security/threat-model.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/examples/README.md +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/examples/fastapi_example.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/mkdocs.yml +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/audit.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/enums.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/file_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/base.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/content_inspector.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/gzip_inspector.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/inspectors/zip_inspector.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/protocols.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/py.typed +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/base.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/compression_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/extension_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/filename_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/fit_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/mime_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/signature_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/unicode_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/windows_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/safeuploads/validators/xml_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/conftest.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/helpers.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_config.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_filenames.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_images.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/fuzz/test_fuzz_zip.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/inspectors/test_content_inspector.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_enums.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_utils.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_validate_activity_file.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/test_validate_gzip_file.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/__init__.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_compression_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_extension_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_filename_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_fit_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_mime_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_signature_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_unicode_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_windows_validator.py +0 -0
- {safeuploads-2.1.0 → safeuploads-2.2.0}/tests/validators/test_xml_validator.py +0 -0
{safeuploads-2.1.0 → safeuploads-2.2.0}/.agents/skills/safeuploads-library/references/testing.md
RENAMED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- Markers are declared in `pyproject.toml` (`unit`, `integration`, `slow`, `performance`, `fuzz`) and run with `--strict-markers`; mark a new test accordingly.
|
|
10
10
|
- Skips need `reason=`.
|
|
11
11
|
- Coverage is branch coverage with `fail_under = 90`; it is a ratchet, raise it when coverage rises and never lower it.
|
|
12
|
-
- Tests are deterministic and offline; a performance test asserts a generous bound, never an exact time.
|
|
12
|
+
- Tests are deterministic and offline; a performance test asserts a generous bound, never an exact time, and is deselected by default (`addopts` in `pyproject.toml`), so run it with `-m performance`.
|
|
13
13
|
- Do not mock a validator to test the orchestrator's decision; run the real one on a small payload.
|
|
14
14
|
|
|
15
15
|
## Fuzzing
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
uv run pytest tests/validators -v
|
|
21
|
-
uv run pytest -m "not
|
|
21
|
+
uv run pytest -m "not fuzz"
|
|
22
|
+
uv run pytest -m performance
|
|
22
23
|
uv run pytest --cov=safeuploads --cov-report=term-missing
|
|
23
24
|
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Mirror to GitLab
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
|
|
6
|
+
concurrency:
|
|
7
|
+
group: gitlab-mirror
|
|
8
|
+
cancel-in-progress: false
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
mirror:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- name: Push full mirror to GitLab
|
|
18
|
+
env:
|
|
19
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
20
|
+
GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }}
|
|
21
|
+
# Credentials go in per-host Authorization headers passed through
|
|
22
|
+
# GIT_CONFIG_* environment variables, so they never appear in a
|
|
23
|
+
# command line, a remote URL or the mirror's stored git config.
|
|
24
|
+
run: |
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
github_auth="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)"
|
|
27
|
+
gitlab_auth="$(printf 'oauth2:%s' "$GITLAB_TOKEN" | base64 -w0)"
|
|
28
|
+
echo "::add-mask::${github_auth}"
|
|
29
|
+
echo "::add-mask::${gitlab_auth}"
|
|
30
|
+
export GIT_CONFIG_COUNT=2
|
|
31
|
+
export GIT_CONFIG_KEY_0="http.https://github.com/.extraheader"
|
|
32
|
+
export GIT_CONFIG_VALUE_0="Authorization: Basic ${github_auth}"
|
|
33
|
+
export GIT_CONFIG_KEY_1="http.https://gitlab.com/.extraheader"
|
|
34
|
+
export GIT_CONFIG_VALUE_1="Authorization: Basic ${gitlab_auth}"
|
|
35
|
+
mirror_dir="$RUNNER_TEMP/safeuploads.git"
|
|
36
|
+
git clone --mirror \
|
|
37
|
+
"https://github.com/${GITHUB_REPOSITORY}.git" "$mirror_dir"
|
|
38
|
+
cd "$mirror_dir"
|
|
39
|
+
git remote set-url --push origin \
|
|
40
|
+
"https://gitlab.com/endurain-project/safeuploads.git"
|
|
41
|
+
git push --mirror
|
|
@@ -70,6 +70,11 @@ jobs:
|
|
|
70
70
|
exit 1
|
|
71
71
|
fi
|
|
72
72
|
|
|
73
|
+
# python-magic needs the libmagic shared library at import time; install it
|
|
74
|
+
# explicitly instead of relying on the runner image, as the test workflows do.
|
|
75
|
+
- name: Install system dependencies
|
|
76
|
+
run: sudo apt-get update && sudo apt-get install -y libmagic1
|
|
77
|
+
|
|
73
78
|
# The full gate runs on every PR, but a tag can point anywhere in history,
|
|
74
79
|
# so re-run it here: a release must never ship code that does not pass.
|
|
75
80
|
- name: Sync dependencies
|
|
@@ -4,6 +4,22 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [2.2.0] - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `ConfigValidationIssue`, exported from `safeuploads` and `safeuploads.exceptions`: the record `validate_instance()` and `validate_configuration()` return and `FileSecurityConfigurationError.errors` carries. It is the same dataclass under a name that does not suggest an exception.
|
|
12
|
+
|
|
13
|
+
### Deprecated
|
|
14
|
+
|
|
15
|
+
- `ConfigValidationError` is a deprecated alias of `ConfigValidationIssue` and will be removed in 3.0. Accessing or importing it from `safeuploads` or `safeuploads.exceptions` emits a `DeprecationWarning`; it is no longer in `safeuploads.__all__`. It was never an exception, so `except ConfigValidationError` could not catch anything.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Development: the dev dependency `httpx` is replaced by `httpx2`, which Starlette's `TestClient` now expects.
|
|
20
|
+
- Development: performance tests (`-m performance`) are deselected by default; run them with `pytest -m performance`.
|
|
21
|
+
- CI: the publish workflow installs `libmagic1` explicitly, and the GitLab mirror passes its tokens as HTTP authorization headers through `GIT_CONFIG_*` environment variables instead of embedding them in remote URLs.
|
|
22
|
+
|
|
7
23
|
## [2.1.0] - 2026-11-05
|
|
8
24
|
|
|
9
25
|
### Added
|
|
@@ -373,7 +373,7 @@ Exception
|
|
|
373
373
|
All `FileSecurityError` subclasses carry an `error_code` from
|
|
374
374
|
`ErrorCode` for machine-readable classification.
|
|
375
375
|
`FileSecurityConfigurationError` is separate and exposes its
|
|
376
|
-
`errors` list instead. When no code is passed, the default is:
|
|
376
|
+
`errors` list of `ConfigValidationIssue` records instead. When no code is passed, the default is:
|
|
377
377
|
|
|
378
378
|
| Exception | Default `ErrorCode` |
|
|
379
379
|
|---|---|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "safeuploads"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.2.0"
|
|
4
4
|
description = "A comprehensive file security system for validating uploads and preventing attacks"
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "João Vitória Silva",email = "joao@endurain.com"}
|
|
@@ -27,7 +27,7 @@ dev = [
|
|
|
27
27
|
"ty>=0.0.83",
|
|
28
28
|
"types-defusedxml",
|
|
29
29
|
# HTTP stack for the FastAPI multipart integration test
|
|
30
|
-
"
|
|
30
|
+
"httpx2>=2.16,<3.0",
|
|
31
31
|
"python-multipart>=0.0.9,<1.0",
|
|
32
32
|
]
|
|
33
33
|
docs = [
|
|
@@ -118,6 +118,10 @@ addopts = [
|
|
|
118
118
|
"--strict-markers",
|
|
119
119
|
"--strict-config",
|
|
120
120
|
"-ra",
|
|
121
|
+
# Benchmarks assert wall-clock bounds that depend on the machine;
|
|
122
|
+
# run them explicitly with `pytest -m performance`.
|
|
123
|
+
"-m",
|
|
124
|
+
"not performance",
|
|
121
125
|
"--cov=safeuploads",
|
|
122
126
|
"--cov-report=term-missing",
|
|
123
127
|
"--cov-report=html",
|
|
@@ -6,6 +6,7 @@ validating uploads and preventing attacks.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
9
10
|
|
|
10
11
|
from .audit import (
|
|
11
12
|
AuditEvent,
|
|
@@ -28,7 +29,7 @@ from .enums import (
|
|
|
28
29
|
)
|
|
29
30
|
from .exceptions import (
|
|
30
31
|
CompressionSecurityError,
|
|
31
|
-
|
|
32
|
+
ConfigValidationIssue,
|
|
32
33
|
ContentThreatError,
|
|
33
34
|
ErrorCode,
|
|
34
35
|
ExtensionSecurityError,
|
|
@@ -75,6 +76,16 @@ from .validators import (
|
|
|
75
76
|
XmlSecurityValidator,
|
|
76
77
|
)
|
|
77
78
|
|
|
79
|
+
if TYPE_CHECKING:
|
|
80
|
+
# Deprecated alias, resolved by ``__getattr__`` at runtime
|
|
81
|
+
from .exceptions import ConfigValidationError as ConfigValidationError
|
|
82
|
+
else:
|
|
83
|
+
from .exceptions import _resolve_deprecated
|
|
84
|
+
|
|
85
|
+
def __getattr__(name: str) -> object:
|
|
86
|
+
"""Resolve deprecated package attributes (PEP 562)."""
|
|
87
|
+
return _resolve_deprecated(__name__, name)
|
|
88
|
+
|
|
78
89
|
# Configuration is validated explicitly rather than at import
|
|
79
90
|
# time: FileValidator validates its config on construction, and
|
|
80
91
|
# callers can invoke FileSecurityConfig.validate_and_report() or
|
|
@@ -95,7 +106,7 @@ __all__ = [
|
|
|
95
106
|
"SeekableFile",
|
|
96
107
|
"UploadFileProtocol",
|
|
97
108
|
# Exceptions
|
|
98
|
-
"
|
|
109
|
+
"ConfigValidationIssue",
|
|
99
110
|
"FileSecurityConfigurationError",
|
|
100
111
|
"ErrorCode",
|
|
101
112
|
"FileSecurityError",
|
|
@@ -11,7 +11,7 @@ from .enums import (
|
|
|
11
11
|
DangerousExtensionCategory,
|
|
12
12
|
UnicodeAttackCategory,
|
|
13
13
|
)
|
|
14
|
-
from .exceptions import
|
|
14
|
+
from .exceptions import ConfigValidationIssue, FileSecurityConfigurationError
|
|
15
15
|
from .utils import bytes_to_mb
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
@@ -23,9 +23,9 @@ def _config_error(
|
|
|
23
23
|
component: str,
|
|
24
24
|
recommendation: str,
|
|
25
25
|
severity: str = "error",
|
|
26
|
-
) ->
|
|
26
|
+
) -> ConfigValidationIssue:
|
|
27
27
|
"""
|
|
28
|
-
Build a
|
|
28
|
+
Build a ConfigValidationIssue with a default severity.
|
|
29
29
|
|
|
30
30
|
Centralizes construction so the many validation branches
|
|
31
31
|
share one call shape instead of repeating every field.
|
|
@@ -38,9 +38,9 @@ def _config_error(
|
|
|
38
38
|
severity: Severity level ('error', 'warning', 'info').
|
|
39
39
|
|
|
40
40
|
Returns:
|
|
41
|
-
Populated
|
|
41
|
+
Populated ConfigValidationIssue instance.
|
|
42
42
|
"""
|
|
43
|
-
return
|
|
43
|
+
return ConfigValidationIssue(
|
|
44
44
|
error_type=error_type,
|
|
45
45
|
message=message,
|
|
46
46
|
severity=severity,
|
|
@@ -451,7 +451,7 @@ class FileSecurityConfig:
|
|
|
451
451
|
return None
|
|
452
452
|
|
|
453
453
|
@classmethod
|
|
454
|
-
def validate_configuration(cls) -> list[
|
|
454
|
+
def validate_configuration(cls) -> list[ConfigValidationIssue]:
|
|
455
455
|
"""
|
|
456
456
|
Validate the class-level default configuration.
|
|
457
457
|
|
|
@@ -460,7 +460,7 @@ class FileSecurityConfig:
|
|
|
460
460
|
"""
|
|
461
461
|
return cls._collect_errors(cls, cls.limits)
|
|
462
462
|
|
|
463
|
-
def validate_instance(self) -> list[
|
|
463
|
+
def validate_instance(self) -> list[ConfigValidationIssue]:
|
|
464
464
|
"""
|
|
465
465
|
Validate this instance's configuration.
|
|
466
466
|
|
|
@@ -479,7 +479,7 @@ class FileSecurityConfig:
|
|
|
479
479
|
cls,
|
|
480
480
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
481
481
|
limits: SecurityLimits,
|
|
482
|
-
) -> list[
|
|
482
|
+
) -> list[ConfigValidationIssue]:
|
|
483
483
|
"""
|
|
484
484
|
Run all validation routines for the given configuration.
|
|
485
485
|
|
|
@@ -495,7 +495,7 @@ class FileSecurityConfig:
|
|
|
495
495
|
Returns:
|
|
496
496
|
List of detected validation errors.
|
|
497
497
|
"""
|
|
498
|
-
errors: list[
|
|
498
|
+
errors: list[ConfigValidationIssue] = []
|
|
499
499
|
|
|
500
500
|
# Validate limit types first so the range checks below
|
|
501
501
|
# never compare a wrongly typed value.
|
|
@@ -530,7 +530,7 @@ class FileSecurityConfig:
|
|
|
530
530
|
@staticmethod
|
|
531
531
|
def _validate_limit_types(
|
|
532
532
|
limits: SecurityLimits,
|
|
533
|
-
) -> list[
|
|
533
|
+
) -> list[ConfigValidationIssue]:
|
|
534
534
|
"""
|
|
535
535
|
Validate that every limit holds a value of its declared type.
|
|
536
536
|
|
|
@@ -577,7 +577,7 @@ class FileSecurityConfig:
|
|
|
577
577
|
@staticmethod
|
|
578
578
|
def _validate_file_size_limits(
|
|
579
579
|
limits: SecurityLimits,
|
|
580
|
-
) -> list[
|
|
580
|
+
) -> list[ConfigValidationIssue]:
|
|
581
581
|
"""
|
|
582
582
|
Validate the provided file size limits.
|
|
583
583
|
|
|
@@ -708,7 +708,7 @@ class FileSecurityConfig:
|
|
|
708
708
|
@staticmethod
|
|
709
709
|
def _validate_streaming_limits(
|
|
710
710
|
limits: SecurityLimits,
|
|
711
|
-
) -> list[
|
|
711
|
+
) -> list[ConfigValidationIssue]:
|
|
712
712
|
"""
|
|
713
713
|
Validate streaming, resource and content scan limits.
|
|
714
714
|
|
|
@@ -788,7 +788,7 @@ class FileSecurityConfig:
|
|
|
788
788
|
@staticmethod
|
|
789
789
|
def _validate_mime_configurations(
|
|
790
790
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
791
|
-
) -> list[
|
|
791
|
+
) -> list[ConfigValidationIssue]:
|
|
792
792
|
"""
|
|
793
793
|
Validate MIME type configurations.
|
|
794
794
|
|
|
@@ -876,7 +876,7 @@ class FileSecurityConfig:
|
|
|
876
876
|
@staticmethod
|
|
877
877
|
def _validate_extension_configurations(
|
|
878
878
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
879
|
-
) -> list[
|
|
879
|
+
) -> list[ConfigValidationIssue]:
|
|
880
880
|
"""
|
|
881
881
|
Validate file extension configurations.
|
|
882
882
|
|
|
@@ -1028,7 +1028,7 @@ class FileSecurityConfig:
|
|
|
1028
1028
|
@staticmethod
|
|
1029
1029
|
def _validate_compression_settings(
|
|
1030
1030
|
limits: SecurityLimits,
|
|
1031
|
-
) -> list[
|
|
1031
|
+
) -> list[ConfigValidationIssue]:
|
|
1032
1032
|
"""
|
|
1033
1033
|
Validate the provided compression-related limits.
|
|
1034
1034
|
|
|
@@ -1240,7 +1240,7 @@ class FileSecurityConfig:
|
|
|
1240
1240
|
return errors
|
|
1241
1241
|
|
|
1242
1242
|
@classmethod
|
|
1243
|
-
def _validate_enum_consistency(cls) -> list[
|
|
1243
|
+
def _validate_enum_consistency(cls) -> list[ConfigValidationIssue]:
|
|
1244
1244
|
"""
|
|
1245
1245
|
Validate enum categories for emptiness and overlaps.
|
|
1246
1246
|
|
|
@@ -1345,7 +1345,7 @@ class FileSecurityConfig:
|
|
|
1345
1345
|
@staticmethod
|
|
1346
1346
|
def _validate_cross_dependencies(
|
|
1347
1347
|
config: "FileSecurityConfig | type[FileSecurityConfig]",
|
|
1348
|
-
) -> list[
|
|
1348
|
+
) -> list[ConfigValidationIssue]:
|
|
1349
1349
|
"""
|
|
1350
1350
|
Validate cross-field configuration constraints.
|
|
1351
1351
|
|
|
@@ -1452,7 +1452,7 @@ class FileSecurityConfig:
|
|
|
1452
1452
|
|
|
1453
1453
|
@staticmethod
|
|
1454
1454
|
def _report_errors(
|
|
1455
|
-
errors: list[
|
|
1455
|
+
errors: list[ConfigValidationIssue], strict: bool
|
|
1456
1456
|
) -> None:
|
|
1457
1457
|
"""
|
|
1458
1458
|
Log validation issues and optionally raise.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""File security exception classes and error codes."""
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
5
7
|
|
|
6
8
|
# ============================================================================
|
|
7
9
|
# Configuration Validation
|
|
@@ -9,10 +11,14 @@ from enum import StrEnum
|
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
@dataclass
|
|
12
|
-
class
|
|
14
|
+
class ConfigValidationIssue:
|
|
13
15
|
"""
|
|
14
16
|
Configuration validation issue with severity and recommendation.
|
|
15
17
|
|
|
18
|
+
A plain record, not an exception: ``validate_instance()`` and
|
|
19
|
+
``validate_configuration()`` return a list of them, and
|
|
20
|
+
``FileSecurityConfigurationError.errors`` carries them.
|
|
21
|
+
|
|
16
22
|
Attributes:
|
|
17
23
|
error_type: Type of the validation error.
|
|
18
24
|
message: Human-readable error message.
|
|
@@ -28,18 +34,59 @@ class ConfigValidationError:
|
|
|
28
34
|
recommendation: str = ""
|
|
29
35
|
|
|
30
36
|
|
|
37
|
+
_DEPRECATED_NAMES = {"ConfigValidationError": "ConfigValidationIssue"}
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
# Deprecated alias; at runtime ``__getattr__`` resolves it with a
|
|
41
|
+
# DeprecationWarning. Removed in 3.0.
|
|
42
|
+
ConfigValidationError = ConfigValidationIssue
|
|
43
|
+
else:
|
|
44
|
+
|
|
45
|
+
def _resolve_deprecated(module: str, name: str) -> object:
|
|
46
|
+
"""
|
|
47
|
+
Return the object a deprecated name stands for, with a warning.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
module: Name of the module the attribute was looked up on.
|
|
51
|
+
name: Attribute name being looked up.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The object that replaces the deprecated name.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
AttributeError: ``name`` is not a deprecated name.
|
|
58
|
+
"""
|
|
59
|
+
new_name = _DEPRECATED_NAMES.get(name)
|
|
60
|
+
if new_name is None:
|
|
61
|
+
raise AttributeError(
|
|
62
|
+
f"module {module!r} has no attribute {name!r}"
|
|
63
|
+
)
|
|
64
|
+
warnings.warn(
|
|
65
|
+
f"{name} is deprecated and will be removed in safeuploads"
|
|
66
|
+
f" 3.0; use {new_name}",
|
|
67
|
+
DeprecationWarning,
|
|
68
|
+
# Skip this helper and the module ``__getattr__``
|
|
69
|
+
stacklevel=3,
|
|
70
|
+
)
|
|
71
|
+
return globals()[new_name]
|
|
72
|
+
|
|
73
|
+
def __getattr__(name: str) -> object:
|
|
74
|
+
"""Resolve deprecated module attributes (PEP 562)."""
|
|
75
|
+
return _resolve_deprecated(__name__, name)
|
|
76
|
+
|
|
77
|
+
|
|
31
78
|
class FileSecurityConfigurationError(Exception):
|
|
32
79
|
"""
|
|
33
80
|
Configuration validation failed with aggregated errors.
|
|
34
81
|
|
|
35
82
|
Args:
|
|
36
|
-
errors: List of
|
|
83
|
+
errors: List of ConfigValidationIssue instances.
|
|
37
84
|
|
|
38
85
|
Attributes:
|
|
39
86
|
errors: List of validation errors that caused failure.
|
|
40
87
|
"""
|
|
41
88
|
|
|
42
|
-
def __init__(self, errors: list[
|
|
89
|
+
def __init__(self, errors: list[ConfigValidationIssue]):
|
|
43
90
|
"""Initialize with validation errors."""
|
|
44
91
|
self.errors = errors
|
|
45
92
|
error_messages = [
|
|
@@ -49,7 +96,7 @@ class FileSecurityConfigurationError(Exception):
|
|
|
49
96
|
f"Configuration validation failed: {'; '.join(error_messages)}"
|
|
50
97
|
)
|
|
51
98
|
|
|
52
|
-
def __reduce__(self) -> tuple[type, tuple[list[
|
|
99
|
+
def __reduce__(self) -> tuple[type, tuple[list[ConfigValidationIssue]]]:
|
|
53
100
|
"""Rebuild from ``errors`` so pickling round-trips."""
|
|
54
101
|
return (type(self), (self.errors,))
|
|
55
102
|
|
|
@@ -11,20 +11,30 @@ from types import TracebackType
|
|
|
11
11
|
from .exceptions import ErrorCode, ResourceLimitError
|
|
12
12
|
from .protocols import SeekableFile
|
|
13
13
|
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# ``resource.getrusage`` reports peak RSS (``ru_maxrss``) in bytes on
|
|
17
|
+
# macOS but in kilobytes on Linux/BSD. Normalize to bytes with a
|
|
18
|
+
# platform-specific multiplier resolved once at import time.
|
|
19
|
+
_RSS_UNIT_BYTES: int = 1 if sys.platform == "darwin" else 1024
|
|
20
|
+
|
|
14
21
|
try:
|
|
15
22
|
# ``resource`` is a Unix-only stdlib module and is absent on
|
|
16
23
|
# Windows; memory accounting degrades to a no-op there while
|
|
17
24
|
# the wall-clock timeout keeps working.
|
|
18
25
|
import resource
|
|
19
26
|
except ImportError: # pragma: no cover - platform-specific (Windows)
|
|
20
|
-
resource = None # type: ignore[assignment]
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
def _read_peak_rss_bytes() -> int:
|
|
29
|
+
"""Return 0: this platform has no ``resource`` module."""
|
|
30
|
+
return 0
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
else:
|
|
33
|
+
|
|
34
|
+
def _read_peak_rss_bytes() -> int:
|
|
35
|
+
"""Return the process peak RSS in bytes from ``getrusage``."""
|
|
36
|
+
usage = resource.getrusage(resource.RUSAGE_SELF)
|
|
37
|
+
return usage.ru_maxrss * _RSS_UNIT_BYTES
|
|
28
38
|
|
|
29
39
|
|
|
30
40
|
def bytes_to_mb(num_bytes: int) -> int:
|
|
@@ -316,10 +326,7 @@ class ResourceMonitor:
|
|
|
316
326
|
module (e.g. Windows), which disables memory-limit
|
|
317
327
|
enforcement while leaving the timeout active.
|
|
318
328
|
"""
|
|
319
|
-
|
|
320
|
-
return 0
|
|
321
|
-
usage = resource.getrusage(resource.RUSAGE_SELF)
|
|
322
|
-
return usage.ru_maxrss * _RSS_UNIT_BYTES
|
|
329
|
+
return _read_peak_rss_bytes()
|
|
323
330
|
|
|
324
331
|
|
|
325
332
|
_active_monitor: contextvars.ContextVar[ResourceMonitor | None] = (
|
|
@@ -422,10 +422,11 @@ class TestGzipMemberHandling:
|
|
|
422
422
|
)
|
|
423
423
|
member = gzip.compress(bytes(400 * 1024))
|
|
424
424
|
data = member * 4
|
|
425
|
-
with pytest.raises(ZipBombError):
|
|
425
|
+
with pytest.raises(ZipBombError) as exc_info:
|
|
426
426
|
GzipContentInspector(config).inspect_gzip_content(
|
|
427
427
|
io.BytesIO(data), len(data)
|
|
428
428
|
)
|
|
429
|
+
assert exc_info.value.error_code == ErrorCode.ZIP_BOMB_DETECTED
|
|
429
430
|
|
|
430
431
|
def test_ratio_limit_counts_all_members(self):
|
|
431
432
|
"""The ratio is checked on the output summed across members."""
|
|
@@ -1042,8 +1042,9 @@ class TestZipInspectorStructureGaps:
|
|
|
1042
1042
|
|
|
1043
1043
|
monkeypatch.setattr(zipfile.ZipFile, "open", _mock_open)
|
|
1044
1044
|
caplog.set_level("WARNING", logger=zip_inspector.__name__)
|
|
1045
|
-
with pytest.raises(ZipContentError):
|
|
1045
|
+
with pytest.raises(ZipContentError) as exc_info:
|
|
1046
1046
|
inspector.inspect_zip_content(io.BytesIO(zip_data))
|
|
1047
|
+
assert exc_info.value.error_code == ErrorCode.ZIP_CONTENT_THREAT
|
|
1047
1048
|
|
|
1048
1049
|
messages = [
|
|
1049
1050
|
r.getMessage()
|
|
@@ -1318,8 +1319,9 @@ class TestZipInspectorNestedArchives:
|
|
|
1318
1319
|
|
|
1319
1320
|
monkeypatch.setattr(zipfile.ZipFile, "open", _fail_open)
|
|
1320
1321
|
caplog.set_level("WARNING", logger=zip_inspector.__name__)
|
|
1321
|
-
with pytest.raises(ZipContentError):
|
|
1322
|
+
with pytest.raises(ZipContentError) as exc_info:
|
|
1322
1323
|
inspector.inspect_nested_archives(io.BytesIO(outer_bytes))
|
|
1324
|
+
assert exc_info.value.error_code == ErrorCode.ZIP_CONTENT_THREAT
|
|
1323
1325
|
|
|
1324
1326
|
messages = [
|
|
1325
1327
|
r.getMessage()
|
|
@@ -17,7 +17,6 @@ from safeuploads.audit import (
|
|
|
17
17
|
)
|
|
18
18
|
from safeuploads.config import FileSecurityConfig
|
|
19
19
|
from safeuploads.exceptions import (
|
|
20
|
-
CompressionSecurityError,
|
|
21
20
|
ErrorCode,
|
|
22
21
|
FileSizeError,
|
|
23
22
|
UnicodeSecurityError,
|
|
@@ -444,9 +443,12 @@ class TestThreatAuditEvents:
|
|
|
444
443
|
|
|
445
444
|
with (
|
|
446
445
|
caplog.at_level(logging.DEBUG, logger="safeuploads.audit"),
|
|
447
|
-
pytest.raises(
|
|
446
|
+
pytest.raises(ZipBombError) as exc_info,
|
|
448
447
|
):
|
|
449
448
|
await validator.validate_zip_file(file)
|
|
449
|
+
assert (
|
|
450
|
+
exc_info.value.error_code == ErrorCode.COMPRESSION_RATIO_EXCEEDED
|
|
451
|
+
)
|
|
450
452
|
|
|
451
453
|
threat_records = [
|
|
452
454
|
r
|
|
@@ -6,7 +6,7 @@ import pytest
|
|
|
6
6
|
|
|
7
7
|
from safeuploads.config import FileSecurityConfig, SecurityLimits
|
|
8
8
|
from safeuploads.exceptions import (
|
|
9
|
-
|
|
9
|
+
ConfigValidationIssue,
|
|
10
10
|
FileSecurityConfigurationError,
|
|
11
11
|
)
|
|
12
12
|
|
|
@@ -171,8 +171,8 @@ class TestConfigurationValidation:
|
|
|
171
171
|
pytest.fail("Should not raise for valid configuration")
|
|
172
172
|
|
|
173
173
|
def test_config_validation_error_attributes(self):
|
|
174
|
-
"""Test
|
|
175
|
-
error =
|
|
174
|
+
"""Test ConfigValidationIssue attributes."""
|
|
175
|
+
error = ConfigValidationIssue(
|
|
176
176
|
error_type="test_error",
|
|
177
177
|
message="Test message",
|
|
178
178
|
severity="error",
|
|
@@ -187,8 +187,8 @@ class TestConfigurationValidation:
|
|
|
187
187
|
assert error.recommendation == "Test recommendation"
|
|
188
188
|
|
|
189
189
|
def test_config_validation_error_string_representation(self):
|
|
190
|
-
"""Test
|
|
191
|
-
error =
|
|
190
|
+
"""Test ConfigValidationIssue string representation."""
|
|
191
|
+
error = ConfigValidationIssue(
|
|
192
192
|
error_type="test_error",
|
|
193
193
|
message="Test message",
|
|
194
194
|
severity="error",
|
|
@@ -269,13 +269,13 @@ class TestConfigurationValidation:
|
|
|
269
269
|
|
|
270
270
|
def test_file_security_configuration_error_with_errors(self):
|
|
271
271
|
"""Test FileSecurityConfigurationError with error list."""
|
|
272
|
-
error1 =
|
|
272
|
+
error1 = ConfigValidationIssue(
|
|
273
273
|
error_type="test1",
|
|
274
274
|
message="Error 1",
|
|
275
275
|
severity="error",
|
|
276
276
|
component="test",
|
|
277
277
|
)
|
|
278
|
-
error2 =
|
|
278
|
+
error2 = ConfigValidationIssue(
|
|
279
279
|
error_type="test2",
|
|
280
280
|
message="Error 2",
|
|
281
281
|
severity="warning",
|
|
@@ -368,7 +368,7 @@ class TestConfigInstanceIsolation:
|
|
|
368
368
|
config.ALLOWED_ACTIVITY_EXTENSIONS.add(".evil")
|
|
369
369
|
|
|
370
370
|
|
|
371
|
-
def _errors_for(**overrides: Any) -> list[
|
|
371
|
+
def _errors_for(**overrides: Any) -> list[ConfigValidationIssue]:
|
|
372
372
|
"""
|
|
373
373
|
Validate an instance whose limits carry the given overrides.
|
|
374
374
|
|
|
@@ -15,6 +15,7 @@ from safeuploads.enums import (
|
|
|
15
15
|
UnicodeAttackCategory,
|
|
16
16
|
)
|
|
17
17
|
from safeuploads.exceptions import (
|
|
18
|
+
ErrorCode,
|
|
18
19
|
FileSecurityConfigurationError,
|
|
19
20
|
WindowsReservedNameError,
|
|
20
21
|
)
|
|
@@ -817,7 +818,7 @@ class TestValidateAndReportLogging:
|
|
|
817
818
|
"""
|
|
818
819
|
import logging
|
|
819
820
|
|
|
820
|
-
from safeuploads.exceptions import
|
|
821
|
+
from safeuploads.exceptions import ConfigValidationIssue
|
|
821
822
|
|
|
822
823
|
original = FileSecurityConfig._validate_enum_consistency
|
|
823
824
|
|
|
@@ -825,7 +826,7 @@ class TestValidateAndReportLogging:
|
|
|
825
826
|
def _force_info(cls):
|
|
826
827
|
results = original.__func__(cls)
|
|
827
828
|
results.append(
|
|
828
|
-
|
|
829
|
+
ConfigValidationIssue(
|
|
829
830
|
error_type="test_info",
|
|
830
831
|
message="Test info message",
|
|
831
832
|
severity="info",
|
|
@@ -1086,8 +1087,9 @@ class TestInstanceOverrideValidation:
|
|
|
1086
1087
|
assert config.validate_instance() == []
|
|
1087
1088
|
validator = FileValidator(config=config)
|
|
1088
1089
|
|
|
1089
|
-
with pytest.raises(WindowsReservedNameError):
|
|
1090
|
+
with pytest.raises(WindowsReservedNameError) as exc_info:
|
|
1090
1091
|
validator._sanitize_filename("con.png")
|
|
1092
|
+
assert exc_info.value.error_code == ErrorCode.WINDOWS_RESERVED_NAME
|
|
1091
1093
|
assert validator._sanitize_filename("photo.png") == "photo.png"
|
|
1092
1094
|
|
|
1093
1095
|
def test_class_validation_still_reports_class_problems(self, monkeypatch):
|