From 376af81b9c556b1ac75801a9b00278cebf9c7039 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:58:28 -0700 Subject: [PATCH 1/9] Fix: Resolve another TC assuming an object has been created somewhere (#12503) --- src/documents/tests/test_views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/documents/tests/test_views.py b/src/documents/tests/test_views.py index 14f0425b9..314636045 100644 --- a/src/documents/tests/test_views.py +++ b/src/documents/tests/test_views.py @@ -31,6 +31,11 @@ from paperless.models import ApplicationConfiguration class TestViews(DirectoriesMixin, TestCase): + @classmethod + def setUpTestData(cls) -> None: + super().setUpTestData() + ApplicationConfiguration.objects.get_or_create() + def setUp(self) -> None: self.user = User.objects.create_user("testuser") super().setUp() From dda05a7c00eb91e76e044aa4007a548b4acb4446 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:30:26 -0700 Subject: [PATCH 2/9] Security: Improve overall security in a few ways (#12501) - Make sure we're always using regex with timeouts for user controlled data - Adds rate limiting to the token endpoint (configurable) - Signs the classifier pickle file with the SECRET_KEY and refuse to load one which doesn't verify. - Require the user to set a secret key, instead of falling back to our old hard coded one --- Dockerfile | 4 +- docker/compose/docker-compose.env | 6 +- docs/configuration.md | 30 +++- paperless.conf.example | 3 +- pyproject.toml | 3 + src/documents/barcodes.py | 20 ++- src/documents/classifier.py | 126 ++++++++++------- .../plugins/date_parsing/regex_parser.py | 12 +- src/documents/regex.py | 70 ++++++++++ src/documents/tests/data/v1.17.4.model.pickle | Bin 714 -> 0 bytes src/documents/tests/test_classifier.py | 130 +++++++++++++----- src/documents/tests/test_regex.py | 128 +++++++++++++++++ src/paperless/settings/__init__.py | 18 ++- src/paperless/views.py | 3 + 14 files changed, 443 insertions(+), 110 deletions(-) delete mode 100644 src/documents/tests/data/v1.17.4.model.pickle create mode 100644 src/documents/tests/test_regex.py diff --git a/Dockerfile b/Dockerfile index ac6143162..0b8886c61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -237,8 +237,8 @@ RUN set -eux \ && echo "Adjusting all permissions" \ && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \ && echo "Collecting static files" \ - && s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \ - && s6-setuidgid paperless python3 manage.py compilemessages \ + && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \ + && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \ && /usr/local/bin/deduplicate.py --verbose /usr/src/paperless/static/ VOLUME ["/usr/src/paperless/data", \ diff --git a/docker/compose/docker-compose.env b/docker/compose/docker-compose.env index 75eeeed09..af6a6e8fe 100644 --- a/docker/compose/docker-compose.env +++ b/docker/compose/docker-compose.env @@ -17,9 +17,9 @@ # (if doing so please consider security measures such as reverse proxy) #PAPERLESS_URL=https://paperless.example.com -# Adjust this key if you plan to make paperless available publicly. It should -# be a very long sequence of random characters. You don't need to remember it. -#PAPERLESS_SECRET_KEY=change-me +# Required. A unique secret key for session tokens and signing. +# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(64))" +PAPERLESS_SECRET_KEY=change-me # Use this variable to set a timezone for the Paperless Docker containers. Defaults to UTC. #PAPERLESS_TIME_ZONE=America/Los_Angeles diff --git a/docs/configuration.md b/docs/configuration.md index a22171ce9..fa0d32c51 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -402,6 +402,12 @@ Defaults to `/usr/share/nltk_data` : This is where paperless will store the classification model. + !!! warning + + The classification model uses Python's pickle serialization format. + Ensure this file is only writable by the paperless user, as a + maliciously crafted model file could execute arbitrary code when loaded. + Defaults to `PAPERLESS_DATA_DIR/classification_model.pickle`. ## Logging @@ -422,14 +428,20 @@ Defaults to `/usr/share/nltk_data` #### [`PAPERLESS_SECRET_KEY=`](#PAPERLESS_SECRET_KEY) {#PAPERLESS_SECRET_KEY} -: Paperless uses this to make session tokens. If you expose paperless -on the internet, you need to change this, since the default secret -is well known. +: **Required.** Paperless uses this to make session tokens and sign +sensitive data. Paperless will refuse to start if this is not set. Use any sequence of characters. The more, the better. You don't - need to remember this. Just face-roll your keyboard. + need to remember this. You can generate a suitable key with: - Default is listed in the file `src/paperless/settings.py`. + python3 -c "import secrets; print(secrets.token_urlsafe(64))" + + !!! warning + + This setting has no default value. You **must** set it before + starting Paperless. Existing installations that relied on the + previous default value should set `PAPERLESS_SECRET_KEY` to + that value to avoid invalidating existing sessions and tokens. #### [`PAPERLESS_URL=`](#PAPERLESS_URL) {#PAPERLESS_URL} @@ -770,6 +782,14 @@ If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS Defaults to 1209600 (2 weeks) +#### [`PAPERLESS_TOKEN_THROTTLE_RATE=`](#PAPERLESS_TOKEN_THROTTLE_RATE) {#PAPERLESS_TOKEN_THROTTLE_RATE} + +: Rate limit for the API token authentication endpoint (`/api/token/`), used to mitigate brute-force login attempts. +Uses Django REST Framework's [throttle rate format](https://www.django-rest-framework.org/api-guide/throttling/#setting-the-throttling-policy), +e.g. `5/min`, `100/hour`, `1000/day`. + + Defaults to `5/min` + ## OCR settings {#ocr} Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/) diff --git a/paperless.conf.example b/paperless.conf.example index 9974aeab6..a0c406f82 100644 --- a/paperless.conf.example +++ b/paperless.conf.example @@ -23,7 +23,8 @@ # Security and hosting -#PAPERLESS_SECRET_KEY=change-me +# Required. Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(64))" +PAPERLESS_SECRET_KEY=change-me #PAPERLESS_URL=https://example.com #PAPERLESS_CSRF_TRUSTED_ORIGINS=https://example.com # can be set using PAPERLESS_URL #PAPERLESS_ALLOWED_HOSTS=example.com,www.example.com # can be set using PAPERLESS_URL diff --git a/pyproject.toml b/pyproject.toml index 5af886f0c..7bb160956 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -315,9 +315,12 @@ markers = [ ] [tool.pytest_env] +PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production" PAPERLESS_DISABLE_DBHANDLER = "true" PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache" PAPERLESS_CHANNELS_BACKEND = "channels.layers.InMemoryChannelLayer" +# I don't think anything hits this, but just in case, basically infinite +PAPERLESS_TOKEN_THROTTLE_RATE = "1000/min" [tool.coverage.report] exclude_also = [ diff --git a/src/documents/barcodes.py b/src/documents/barcodes.py index 31ef052c4..38a28081a 100644 --- a/src/documents/barcodes.py +++ b/src/documents/barcodes.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING +import regex as regex_mod from django.conf import settings from pdf2image import convert_from_path from pikepdf import Page @@ -22,6 +23,8 @@ from documents.plugins.base import ConsumeTaskPlugin from documents.plugins.base import StopConsumeTaskError from documents.plugins.helpers import ProgressManager from documents.plugins.helpers import ProgressStatusOptions +from documents.regex import safe_regex_match +from documents.regex import safe_regex_sub from documents.utils import copy_basic_file_stats from documents.utils import copy_file_with_basic_stats from documents.utils import maybe_override_pixel_limit @@ -68,8 +71,8 @@ class Barcode: Note: This does NOT exclude ASN or separator barcodes - they can also be used as tags if they match a tag mapping pattern (e.g., {"ASN12.*": "JOHN"}). """ - for regex in self.settings.barcode_tag_mapping: - if re.match(regex, self.value, flags=re.IGNORECASE): + for pattern in self.settings.barcode_tag_mapping: + if safe_regex_match(pattern, self.value, flags=regex_mod.IGNORECASE): return True return False @@ -392,11 +395,16 @@ class BarcodePlugin(ConsumeTaskPlugin): for raw in tag_texts.split(","): try: tag_str: str | None = None - for regex in self.settings.barcode_tag_mapping: - if re.match(regex, raw, flags=re.IGNORECASE): - sub = self.settings.barcode_tag_mapping[regex] + for pattern in self.settings.barcode_tag_mapping: + if safe_regex_match(pattern, raw, flags=regex_mod.IGNORECASE): + sub = self.settings.barcode_tag_mapping[pattern] tag_str = ( - re.sub(regex, sub, raw, flags=re.IGNORECASE) + safe_regex_sub( + pattern, + sub, + raw, + flags=regex_mod.IGNORECASE, + ) if sub else raw ) diff --git a/src/documents/classifier.py b/src/documents/classifier.py index 87934ab52..519e1eac5 100644 --- a/src/documents/classifier.py +++ b/src/documents/classifier.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hmac import logging import pickle import re @@ -75,7 +76,7 @@ def load_classifier(*, raise_exception: bool = False) -> DocumentClassifier | No "Unrecoverable error while loading document " "classification model, deleting model file.", ) - Path(settings.MODEL_FILE).unlink + Path(settings.MODEL_FILE).unlink() classifier = None if raise_exception: raise e @@ -97,7 +98,10 @@ class DocumentClassifier: # v7 - Updated scikit-learn package version # v8 - Added storage path classifier # v9 - Changed from hashing to time/ids for re-train check - FORMAT_VERSION = 9 + # v10 - HMAC-signed model file + FORMAT_VERSION = 10 + + HMAC_SIZE = 32 # SHA-256 digest length def __init__(self) -> None: # last time a document changed and therefore training might be required @@ -128,67 +132,89 @@ class DocumentClassifier: pickle.dumps(self.data_vectorizer), ).hexdigest() + @staticmethod + def _compute_hmac(data: bytes) -> bytes: + return hmac.new( + settings.SECRET_KEY.encode(), + data, + sha256, + ).digest() + def load(self) -> None: from sklearn.exceptions import InconsistentVersionWarning + raw = Path(settings.MODEL_FILE).read_bytes() + + if len(raw) <= self.HMAC_SIZE: + raise ClassifierModelCorruptError + + signature = raw[: self.HMAC_SIZE] + data = raw[self.HMAC_SIZE :] + + if not hmac.compare_digest(signature, self._compute_hmac(data)): + raise ClassifierModelCorruptError + # Catch warnings for processing with warnings.catch_warnings(record=True) as w: - with Path(settings.MODEL_FILE).open("rb") as f: - schema_version = pickle.load(f) + try: + ( + schema_version, + self.last_doc_change_time, + self.last_auto_type_hash, + self.data_vectorizer, + self.tags_binarizer, + self.tags_classifier, + self.correspondent_classifier, + self.document_type_classifier, + self.storage_path_classifier, + ) = pickle.loads(data) + except Exception as err: + raise ClassifierModelCorruptError from err - if schema_version != self.FORMAT_VERSION: - raise IncompatibleClassifierVersionError( - "Cannot load classifier, incompatible versions.", - ) - else: - try: - self.last_doc_change_time = pickle.load(f) - self.last_auto_type_hash = pickle.load(f) - - self.data_vectorizer = pickle.load(f) - self._update_data_vectorizer_hash() - self.tags_binarizer = pickle.load(f) - - self.tags_classifier = pickle.load(f) - self.correspondent_classifier = pickle.load(f) - self.document_type_classifier = pickle.load(f) - self.storage_path_classifier = pickle.load(f) - except Exception as err: - raise ClassifierModelCorruptError from err - - # Check for the warning about unpickling from differing versions - # and consider it incompatible - sk_learn_warning_url = ( - "https://scikit-learn.org/stable/" - "model_persistence.html" - "#security-maintainability-limitations" + if schema_version != self.FORMAT_VERSION: + raise IncompatibleClassifierVersionError( + "Cannot load classifier, incompatible versions.", ) - for warning in w: - # The warning is inconsistent, the MLPClassifier is a specific warning, others have not updated yet - if issubclass(warning.category, InconsistentVersionWarning) or ( - issubclass(warning.category, UserWarning) - and sk_learn_warning_url in str(warning.message) - ): - raise IncompatibleClassifierVersionError("sklearn version update") + + self._update_data_vectorizer_hash() + + # Check for the warning about unpickling from differing versions + # and consider it incompatible + sk_learn_warning_url = ( + "https://scikit-learn.org/stable/" + "model_persistence.html" + "#security-maintainability-limitations" + ) + for warning in w: + # The warning is inconsistent, the MLPClassifier is a specific warning, others have not updated yet + if issubclass(warning.category, InconsistentVersionWarning) or ( + issubclass(warning.category, UserWarning) + and sk_learn_warning_url in str(warning.message) + ): + raise IncompatibleClassifierVersionError("sklearn version update") def save(self) -> None: target_file: Path = settings.MODEL_FILE target_file_temp: Path = target_file.with_suffix(".pickle.part") + data = pickle.dumps( + ( + self.FORMAT_VERSION, + self.last_doc_change_time, + self.last_auto_type_hash, + self.data_vectorizer, + self.tags_binarizer, + self.tags_classifier, + self.correspondent_classifier, + self.document_type_classifier, + self.storage_path_classifier, + ), + ) + + signature = self._compute_hmac(data) + with target_file_temp.open("wb") as f: - pickle.dump(self.FORMAT_VERSION, f) - - pickle.dump(self.last_doc_change_time, f) - pickle.dump(self.last_auto_type_hash, f) - - pickle.dump(self.data_vectorizer, f) - - pickle.dump(self.tags_binarizer, f) - pickle.dump(self.tags_classifier, f) - - pickle.dump(self.correspondent_classifier, f) - pickle.dump(self.document_type_classifier, f) - pickle.dump(self.storage_path_classifier, f) + f.write(signature + data) target_file_temp.rename(target_file) diff --git a/src/documents/plugins/date_parsing/regex_parser.py b/src/documents/plugins/date_parsing/regex_parser.py index 2df8f9295..07a9e24f0 100644 --- a/src/documents/plugins/date_parsing/regex_parser.py +++ b/src/documents/plugins/date_parsing/regex_parser.py @@ -1,9 +1,11 @@ import datetime -import re from collections.abc import Iterator -from re import Match + +import regex +from regex import Match from documents.plugins.date_parsing.base import DateParserPluginBase +from documents.regex import safe_regex_finditer class RegexDateParserPlugin(DateParserPluginBase): @@ -14,7 +16,7 @@ class RegexDateParserPlugin(DateParserPluginBase): passed to its constructor. """ - DATE_REGEX = re.compile( + DATE_REGEX = regex.compile( r"(\b|(?!=([_-])))(\d{1,2})[\.\/-](\d{1,2})[\.\/-](\d{4}|\d{2})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))(\d{4}|\d{2})[\.\/-](\d{1,2})[\.\/-](\d{1,2})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))(\d{1,2}[\. ]+[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{4}|[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{1,2}, \d{4})(\b|(?=([_-])))|" @@ -22,7 +24,7 @@ class RegexDateParserPlugin(DateParserPluginBase): r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{4})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))(\d{1,2}[^ 0-9]{2}[\. ]+[^ ]{3,9}[ \.\/-]\d{4})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))(\b\d{1,2}[ \.\/-][a-zéûäëčžúřěáíóńźçŞğü]{3}[ \.\/-]\d{4})(\b|(?=([_-])))", - re.IGNORECASE, + regex.IGNORECASE, ) def _process_match( @@ -45,7 +47,7 @@ class RegexDateParserPlugin(DateParserPluginBase): """ Finds all regex matches in content and yields valid dates. """ - for m in re.finditer(self.DATE_REGEX, content): + for m in safe_regex_finditer(self.DATE_REGEX, content): date = self._process_match(m, date_order) if date is not None: yield date diff --git a/src/documents/regex.py b/src/documents/regex.py index 35acc5af0..849d417d8 100644 --- a/src/documents/regex.py +++ b/src/documents/regex.py @@ -48,3 +48,73 @@ def safe_regex_search(pattern: str, text: str, *, flags: int = 0): textwrap.shorten(pattern, width=80, placeholder="…"), ) return None + + +def safe_regex_match(pattern: str, text: str, *, flags: int = 0): + """ + Run a regex match with a timeout. Returns a match object or None. + Validation errors and timeouts are logged and treated as no match. + """ + + try: + validate_regex_pattern(pattern) + compiled = regex.compile(pattern, flags=flags) + except (regex.error, ValueError) as exc: + logger.error( + "Error while processing regular expression %s: %s", + textwrap.shorten(pattern, width=80, placeholder="…"), + exc, + ) + return None + + try: + return compiled.match(text, timeout=REGEX_TIMEOUT_SECONDS) + except TimeoutError: + logger.warning( + "Regular expression matching timed out for pattern %s", + textwrap.shorten(pattern, width=80, placeholder="…"), + ) + return None + + +def safe_regex_sub(pattern: str, repl: str, text: str, *, flags: int = 0) -> str | None: + """ + Run a regex substitution with a timeout. Returns the substituted string, + or None on error/timeout. + """ + + try: + validate_regex_pattern(pattern) + compiled = regex.compile(pattern, flags=flags) + except (regex.error, ValueError) as exc: + logger.error( + "Error while processing regular expression %s: %s", + textwrap.shorten(pattern, width=80, placeholder="…"), + exc, + ) + return None + + try: + return compiled.sub(repl, text, timeout=REGEX_TIMEOUT_SECONDS) + except TimeoutError: + logger.warning( + "Regular expression substitution timed out for pattern %s", + textwrap.shorten(pattern, width=80, placeholder="…"), + ) + return None + + +def safe_regex_finditer(compiled_pattern: regex.Pattern, text: str): + """ + Run regex finditer with a timeout. Yields match objects. + Stops iteration on timeout. + """ + + try: + yield from compiled_pattern.finditer(text, timeout=REGEX_TIMEOUT_SECONDS) + except TimeoutError: + logger.warning( + "Regular expression finditer timed out for pattern %s", + textwrap.shorten(compiled_pattern.pattern, width=80, placeholder="…"), + ) + return diff --git a/src/documents/tests/data/v1.17.4.model.pickle b/src/documents/tests/data/v1.17.4.model.pickle deleted file mode 100644 index 4b2734607f8453ad7ecf7d972eea7eafb4deea4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 714 zcmZWn&ubGw6i(A5O`5b;(X$aeG!$dS(t7J5NRU7dJt#}>Nxq!lz) zc_%vxLiOa?lL!9<4<5vOp2s&32&ycX`bB-hA)9Z{EyqrctQwW}e+j9eQ=6UnIyJv=rcyXsGMg#PTgpfZF#FamSl0z#bMhN0`#&-p<{{ab%yzh# zbf}fbd!KH6`&fVb`|`VkquYO8y??s#_ru}ymtVgBymPqo>q1OFmrewo54U{aN>n?* zb)I%&bPj|clxa=g45q+MWsAHaMp5)4}4_;i~!Qw>RN|;QG z=f4TQFG&q%>1e)d8q+wvw!BJD1dH)rIf None: + def test_load_corrupt_file(self) -> None: """ GIVEN: - Corrupted classifier pickle file @@ -378,36 +377,116 @@ class TestClassifier(DirectoriesMixin, TestCase): """ self.generate_train_and_save() - # First load is the schema version,allow it - patched_pickle_load.side_effect = [DocumentClassifier.FORMAT_VERSION, OSError()] + # Write garbage data (valid HMAC length but invalid content) + Path(settings.MODEL_FILE).write_bytes(b"\x00" * 64) with self.assertRaises(ClassifierModelCorruptError): self.classifier.load() - patched_pickle_load.assert_called() - - patched_pickle_load.reset_mock() - patched_pickle_load.side_effect = [ - DocumentClassifier.FORMAT_VERSION, - ClassifierModelCorruptError(), - ] self.assertIsNone(load_classifier()) - patched_pickle_load.assert_called() + + def test_load_corrupt_pickle_valid_hmac(self) -> None: + """ + GIVEN: + - A classifier file with valid HMAC but unparsable pickle data + WHEN: + - An attempt is made to load the classifier + THEN: + - The ClassifierModelCorruptError is raised + """ + garbage_data = b"this is not valid pickle data" + signature = DocumentClassifier._compute_hmac(garbage_data) + Path(settings.MODEL_FILE).write_bytes(signature + garbage_data) + + with self.assertRaises(ClassifierModelCorruptError): + self.classifier.load() + + def test_load_tampered_file(self) -> None: + """ + GIVEN: + - A classifier model file whose data has been modified + WHEN: + - An attempt is made to load the classifier + THEN: + - The ClassifierModelCorruptError is raised due to HMAC mismatch + """ + self.generate_train_and_save() + + raw = Path(settings.MODEL_FILE).read_bytes() + # Flip a byte in the data portion (after the 32-byte HMAC) + tampered = raw[:32] + bytes([raw[32] ^ 0xFF]) + raw[33:] + Path(settings.MODEL_FILE).write_bytes(tampered) + + with self.assertRaises(ClassifierModelCorruptError): + self.classifier.load() + + def test_load_wrong_secret_key(self) -> None: + """ + GIVEN: + - A classifier model file signed with a different SECRET_KEY + WHEN: + - An attempt is made to load the classifier + THEN: + - The ClassifierModelCorruptError is raised due to HMAC mismatch + """ + self.generate_train_and_save() + + with override_settings(SECRET_KEY="different-secret-key"): + with self.assertRaises(ClassifierModelCorruptError): + self.classifier.load() + + def test_load_truncated_file(self) -> None: + """ + GIVEN: + - A classifier model file that is too short to contain an HMAC + WHEN: + - An attempt is made to load the classifier + THEN: + - The ClassifierModelCorruptError is raised + """ + Path(settings.MODEL_FILE).write_bytes(b"\x00" * 16) + + with self.assertRaises(ClassifierModelCorruptError): + self.classifier.load() def test_load_new_scikit_learn_version(self) -> None: """ GIVEN: - - classifier pickle file created with a different scikit-learn version + - classifier pickle file triggers an InconsistentVersionWarning WHEN: - An attempt is made to load the classifier THEN: - - The classifier reports the warning was captured and processed + - IncompatibleClassifierVersionError is raised """ - # TODO: This wasn't testing the warning anymore, as the schema changed - # but as it was implemented, it would require installing an old version - # rebuilding the file and committing that. Not developer friendly - # Need to rethink how to pass the load through to a file with a single - # old model? + from sklearn.exceptions import InconsistentVersionWarning + + self.generate_train_and_save() + + fake_warning = warnings.WarningMessage( + message=InconsistentVersionWarning( + estimator_name="MLPClassifier", + current_sklearn_version="1.0", + original_sklearn_version="0.9", + ), + category=InconsistentVersionWarning, + filename="", + lineno=0, + ) + + real_catch_warnings = warnings.catch_warnings + + class PatchedCatchWarnings(real_catch_warnings): + def __enter__(self): + w = super().__enter__() + w.append(fake_warning) + return w + + with mock.patch( + "documents.classifier.warnings.catch_warnings", + PatchedCatchWarnings, + ): + with self.assertRaises(IncompatibleClassifierVersionError): + self.classifier.load() def test_one_correspondent_predict(self) -> None: c1 = Correspondent.objects.create( @@ -685,17 +764,6 @@ class TestClassifier(DirectoriesMixin, TestCase): self.assertIsNone(load_classifier()) self.assertTrue(Path(settings.MODEL_FILE).exists()) - def test_load_old_classifier_version(self) -> None: - shutil.copy( - Path(__file__).parent / "data" / "v1.17.4.model.pickle", - self.dirs.scratch_dir, - ) - with override_settings( - MODEL_FILE=self.dirs.scratch_dir / "v1.17.4.model.pickle", - ): - classifier = load_classifier() - self.assertIsNone(classifier) - @mock.patch("documents.classifier.DocumentClassifier.load") def test_load_classifier_raise_exception(self, mock_load) -> None: Path(settings.MODEL_FILE).touch() diff --git a/src/documents/tests/test_regex.py b/src/documents/tests/test_regex.py new file mode 100644 index 000000000..a55f29c3c --- /dev/null +++ b/src/documents/tests/test_regex.py @@ -0,0 +1,128 @@ +import pytest +import regex +from pytest_mock import MockerFixture + +from documents.regex import safe_regex_finditer +from documents.regex import safe_regex_match +from documents.regex import safe_regex_search +from documents.regex import safe_regex_sub +from documents.regex import validate_regex_pattern + + +class TestValidateRegexPattern: + def test_valid_pattern(self): + validate_regex_pattern(r"\d+") + + def test_invalid_pattern_raises(self): + with pytest.raises(ValueError): + validate_regex_pattern(r"[invalid") + + +class TestSafeRegexSearchAndMatch: + """Tests for safe_regex_search and safe_regex_match (same contract).""" + + @pytest.mark.parametrize( + ("func", "pattern", "text", "expected_group"), + [ + pytest.param( + safe_regex_search, + r"\d+", + "abc123def", + "123", + id="search-match-found", + ), + pytest.param( + safe_regex_match, + r"\d+", + "123abc", + "123", + id="match-match-found", + ), + ], + ) + def test_match_found(self, func, pattern, text, expected_group): + result = func(pattern, text) + assert result is not None + assert result.group() == expected_group + + @pytest.mark.parametrize( + ("func", "pattern", "text"), + [ + pytest.param(safe_regex_search, r"\d+", "abcdef", id="search-no-match"), + pytest.param(safe_regex_match, r"\d+", "abc123", id="match-no-match"), + ], + ) + def test_no_match(self, func, pattern, text): + assert func(pattern, text) is None + + @pytest.mark.parametrize( + "func", + [ + pytest.param(safe_regex_search, id="search"), + pytest.param(safe_regex_match, id="match"), + ], + ) + def test_invalid_pattern_returns_none(self, func): + assert func(r"[invalid", "test") is None + + @pytest.mark.parametrize( + "func", + [ + pytest.param(safe_regex_search, id="search"), + pytest.param(safe_regex_match, id="match"), + ], + ) + def test_flags_respected(self, func): + assert func(r"abc", "ABC", flags=regex.IGNORECASE) is not None + + @pytest.mark.parametrize( + ("func", "method_name"), + [ + pytest.param(safe_regex_search, "search", id="search"), + pytest.param(safe_regex_match, "match", id="match"), + ], + ) + def test_timeout_returns_none(self, func, method_name, mocker: MockerFixture): + mock_compile = mocker.patch("documents.regex.regex.compile") + getattr(mock_compile.return_value, method_name).side_effect = TimeoutError + assert func(r"\d+", "test") is None + + +class TestSafeRegexSub: + @pytest.mark.parametrize( + ("pattern", "repl", "text", "expected"), + [ + pytest.param(r"\d+", "NUM", "abc123def456", "abcNUMdefNUM", id="basic-sub"), + pytest.param(r"\d+", "NUM", "abcdef", "abcdef", id="no-match"), + pytest.param(r"abc", "X", "ABC", "X", id="flags"), + ], + ) + def test_substitution(self, pattern, repl, text, expected): + flags = regex.IGNORECASE if pattern == r"abc" else 0 + result = safe_regex_sub(pattern, repl, text, flags=flags) + assert result == expected + + def test_invalid_pattern_returns_none(self): + assert safe_regex_sub(r"[invalid", "x", "test") is None + + def test_timeout_returns_none(self, mocker: MockerFixture): + mock_compile = mocker.patch("documents.regex.regex.compile") + mock_compile.return_value.sub.side_effect = TimeoutError + assert safe_regex_sub(r"\d+", "X", "test") is None + + +class TestSafeRegexFinditer: + def test_yields_matches(self): + pattern = regex.compile(r"\d+") + matches = list(safe_regex_finditer(pattern, "a1b22c333")) + assert [m.group() for m in matches] == ["1", "22", "333"] + + def test_no_matches(self): + pattern = regex.compile(r"\d+") + assert list(safe_regex_finditer(pattern, "abcdef")) == [] + + def test_timeout_stops_iteration(self, mocker: MockerFixture): + mock_pattern = mocker.MagicMock() + mock_pattern.finditer.side_effect = TimeoutError + mock_pattern.pattern = r"\d+" + assert list(safe_regex_finditer(mock_pattern, "test")) == [] diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 3522b3187..a76c6ce75 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -11,6 +11,7 @@ from typing import Final from urllib.parse import urlparse from compression_middleware.middleware import CompressionMiddleware +from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext_lazy as _ from dotenv import load_dotenv @@ -161,6 +162,9 @@ REST_FRAMEWORK = { "ALLOWED_VERSIONS": ["9", "10"], # DRF Spectacular default schema "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_THROTTLE_RATES": { + "login": os.getenv("PAPERLESS_TOKEN_THROTTLE_RATE", "5/min"), + }, } if DEBUG: @@ -460,13 +464,13 @@ SECURE_PROXY_SSL_HEADER = ( else None ) -# The secret key has a default that should be fine so long as you're hosting -# Paperless on a closed network. However, if you're putting this anywhere -# public, you should change the key to something unique and verbose. -SECRET_KEY = os.getenv( - "PAPERLESS_SECRET_KEY", - "e11fl1oa-*ytql8p)(06fbj4ukrlo+n7k&q5+$1md7i+mge=ee", -) +SECRET_KEY = os.getenv("PAPERLESS_SECRET_KEY", "") +if not SECRET_KEY: # pragma: no cover + raise ImproperlyConfigured( + "PAPERLESS_SECRET_KEY is not set. " + "A unique, secret key is required for secure operation. " + 'Generate one with: python3 -c "import secrets; print(secrets.token_urlsafe(64))"', + ) AUTH_PASSWORD_VALIDATORS = [ { diff --git a/src/paperless/views.py b/src/paperless/views.py index a3b965f3f..e4db40bb4 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -34,6 +34,7 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import DjangoModelPermissions from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle from rest_framework.viewsets import ModelViewSet from documents.permissions import PaperlessObjectPermissions @@ -51,6 +52,8 @@ from paperless_ai.indexing import vector_store_file_exists class PaperlessObtainAuthTokenView(ObtainAuthToken): serializer_class = PaperlessAuthTokenSerializer + throttle_classes = [ScopedRateThrottle] + throttle_scope = "login" class StandardPagination(PageNumberPagination): From 83501757dfe21112b45716fd5691807da6f74285 Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:36:32 +0000 Subject: [PATCH 3/9] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index cd32e10bd..eba121b5b 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-02 19:39+0000\n" +"POT-Creation-Date: 2026-04-02 22:35+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1866,151 +1866,151 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings/__init__.py:524 +#: paperless/settings/__init__.py:528 msgid "English (US)" msgstr "" -#: paperless/settings/__init__.py:525 +#: paperless/settings/__init__.py:529 msgid "Arabic" msgstr "" -#: paperless/settings/__init__.py:526 +#: paperless/settings/__init__.py:530 msgid "Afrikaans" msgstr "" -#: paperless/settings/__init__.py:527 +#: paperless/settings/__init__.py:531 msgid "Belarusian" msgstr "" -#: paperless/settings/__init__.py:528 +#: paperless/settings/__init__.py:532 msgid "Bulgarian" msgstr "" -#: paperless/settings/__init__.py:529 +#: paperless/settings/__init__.py:533 msgid "Catalan" msgstr "" -#: paperless/settings/__init__.py:530 +#: paperless/settings/__init__.py:534 msgid "Czech" msgstr "" -#: paperless/settings/__init__.py:531 +#: paperless/settings/__init__.py:535 msgid "Danish" msgstr "" -#: paperless/settings/__init__.py:532 +#: paperless/settings/__init__.py:536 msgid "German" msgstr "" -#: paperless/settings/__init__.py:533 +#: paperless/settings/__init__.py:537 msgid "Greek" msgstr "" -#: paperless/settings/__init__.py:534 +#: paperless/settings/__init__.py:538 msgid "English (GB)" msgstr "" -#: paperless/settings/__init__.py:535 +#: paperless/settings/__init__.py:539 msgid "Spanish" msgstr "" -#: paperless/settings/__init__.py:536 +#: paperless/settings/__init__.py:540 msgid "Persian" msgstr "" -#: paperless/settings/__init__.py:537 +#: paperless/settings/__init__.py:541 msgid "Finnish" msgstr "" -#: paperless/settings/__init__.py:538 +#: paperless/settings/__init__.py:542 msgid "French" msgstr "" -#: paperless/settings/__init__.py:539 +#: paperless/settings/__init__.py:543 msgid "Hungarian" msgstr "" -#: paperless/settings/__init__.py:540 +#: paperless/settings/__init__.py:544 msgid "Indonesian" msgstr "" -#: paperless/settings/__init__.py:541 +#: paperless/settings/__init__.py:545 msgid "Italian" msgstr "" -#: paperless/settings/__init__.py:542 +#: paperless/settings/__init__.py:546 msgid "Japanese" msgstr "" -#: paperless/settings/__init__.py:543 +#: paperless/settings/__init__.py:547 msgid "Korean" msgstr "" -#: paperless/settings/__init__.py:544 +#: paperless/settings/__init__.py:548 msgid "Luxembourgish" msgstr "" -#: paperless/settings/__init__.py:545 +#: paperless/settings/__init__.py:549 msgid "Norwegian" msgstr "" -#: paperless/settings/__init__.py:546 +#: paperless/settings/__init__.py:550 msgid "Dutch" msgstr "" -#: paperless/settings/__init__.py:547 +#: paperless/settings/__init__.py:551 msgid "Polish" msgstr "" -#: paperless/settings/__init__.py:548 +#: paperless/settings/__init__.py:552 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings/__init__.py:549 +#: paperless/settings/__init__.py:553 msgid "Portuguese" msgstr "" -#: paperless/settings/__init__.py:550 +#: paperless/settings/__init__.py:554 msgid "Romanian" msgstr "" -#: paperless/settings/__init__.py:551 +#: paperless/settings/__init__.py:555 msgid "Russian" msgstr "" -#: paperless/settings/__init__.py:552 +#: paperless/settings/__init__.py:556 msgid "Slovak" msgstr "" -#: paperless/settings/__init__.py:553 +#: paperless/settings/__init__.py:557 msgid "Slovenian" msgstr "" -#: paperless/settings/__init__.py:554 +#: paperless/settings/__init__.py:558 msgid "Serbian" msgstr "" -#: paperless/settings/__init__.py:555 +#: paperless/settings/__init__.py:559 msgid "Swedish" msgstr "" -#: paperless/settings/__init__.py:556 +#: paperless/settings/__init__.py:560 msgid "Turkish" msgstr "" -#: paperless/settings/__init__.py:557 +#: paperless/settings/__init__.py:561 msgid "Ukrainian" msgstr "" -#: paperless/settings/__init__.py:558 +#: paperless/settings/__init__.py:562 msgid "Vietnamese" msgstr "" -#: paperless/settings/__init__.py:559 +#: paperless/settings/__init__.py:563 msgid "Chinese Simplified" msgstr "" -#: paperless/settings/__init__.py:560 +#: paperless/settings/__init__.py:564 msgid "Chinese Traditional" msgstr "" From e7c7978d6738e469673adc753056568ceaa70056 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:24:28 -0700 Subject: [PATCH 4/9] Enhancement: allow opt-in blocking internal mail hosts (#12502) --- docs/configuration.md | 8 ++++++++ src/paperless/settings/__init__.py | 4 ++++ src/paperless_mail/mail.py | 9 +++++++++ src/paperless_mail/tests/test_mail.py | 20 ++++++++++++++++++++ src/paperless_mail/views.py | 22 +++++++++++----------- 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index fa0d32c51..7156b3553 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1440,6 +1440,14 @@ ports. ## Incoming Mail {#incoming_mail} +#### [`PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS=`](#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS) {#PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS} + +: If set to false, incoming mail account connections are blocked when the +configured IMAP hostname resolves to a non-public address (for example, +localhost, link-local, or RFC1918 private ranges). + + Defaults to true, which allows internal hosts. + ### Email OAuth {#email_oauth} #### [`PAPERLESS_OAUTH_CALLBACK_BASE_URL=`](#PAPERLESS_OAUTH_CALLBACK_BASE_URL) {#PAPERLESS_OAUTH_CALLBACK_BASE_URL} diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index a76c6ce75..b8a99e9fb 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -501,6 +501,10 @@ SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid" LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language" EMAIL_CERTIFICATE_FILE = get_path_from_env("PAPERLESS_EMAIL_CERTIFICATE_LOCATION") +EMAIL_ALLOW_INTERNAL_HOSTS = get_bool_from_env( + "PAPERLESS_EMAIL_ALLOW_INTERNAL_HOSTS", + "true", +) ############################################################################### diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index 56eaefaad..ecc785b1a 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -39,6 +39,8 @@ from documents.loggers import LoggingMixin from documents.models import Correspondent from documents.parsers import is_mime_type_supported from documents.tasks import consume_file +from paperless.network import is_public_ip +from paperless.network import resolve_hostname_ips from paperless_mail.models import MailAccount from paperless_mail.models import MailRule from paperless_mail.models import ProcessedMail @@ -412,6 +414,13 @@ def get_mailbox(server, port, security) -> MailBox: """ Returns the correct MailBox instance for the given configuration. """ + if not settings.EMAIL_ALLOW_INTERNAL_HOSTS: + for ip_str in resolve_hostname_ips(server): + if not is_public_ip(ip_str): + raise MailError( + f"Connection blocked: {server} resolves to a non-public address", + ) + ssl_context = ssl.create_default_context() if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: no cover ssl_context.load_verify_locations(cafile=settings.EMAIL_CERTIFICATE_FILE) diff --git a/src/paperless_mail/tests/test_mail.py b/src/paperless_mail/tests/test_mail.py index 72ee5331a..80a718a46 100644 --- a/src/paperless_mail/tests/test_mail.py +++ b/src/paperless_mail/tests/test_mail.py @@ -13,6 +13,7 @@ from django.contrib.auth.models import User from django.core.management import call_command from django.db import DatabaseError from django.test import TestCase +from django.test import override_settings from django.utils import timezone from imap_tools import NOT from imap_tools import EmailAddress @@ -1846,6 +1847,25 @@ class TestMailAccountTestView(APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.content.decode(), "Unable to connect to server") + @override_settings(EMAIL_ALLOW_INTERNAL_HOSTS=False) + @mock.patch("paperless_mail.mail.resolve_hostname_ips", return_value=["127.0.0.1"]) + def test_mail_account_test_view_blocks_internal_host_when_disabled( + self, + _mock_resolve_hostname_ips, + ) -> None: + data = { + "imap_server": "internal.example", + "imap_port": 993, + "imap_security": MailAccount.ImapSecurity.SSL, + "username": "admin", + "password": "secret", + "account_type": MailAccount.MailAccountType.IMAP, + "is_token": False, + } + response = self.client.post(self.url, data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.content.decode(), "Unable to connect to server") + @mock.patch( "paperless_mail.oauth.PaperlessMailOAuth2Manager.refresh_account_oauth_token", ) diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index 2593797f3..9e3850cfc 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -120,12 +120,12 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): serializer.validated_data["expiration"] = existing_account.expiration account = MailAccount(**serializer.validated_data) - with get_mailbox( - account.imap_server, - account.imap_port, - account.imap_security, - ) as M: - try: + try: + with get_mailbox( + account.imap_server, + account.imap_port, + account.imap_security, + ) as M: if ( existing_account is not None and account.is_token @@ -145,11 +145,11 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): mailbox_login(M, account) return Response({"success": True}) - except MailError: - logger.error( - "Mail account connectivity test failed", - ) - return HttpResponseBadRequest("Unable to connect to server") + except MailError: + logger.error( + "Mail account connectivity test failed", + ) + return HttpResponseBadRequest("Unable to connect to server") @action(methods=["post"], detail=True) def process(self, request, pk=None): From 2703c12f1add4f50da196ca2478ed857b6b61dce Mon Sep 17 00:00:00 2001 From: GitHub Actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:25:57 +0000 Subject: [PATCH 5/9] Auto translate strings --- src/locale/en_US/LC_MESSAGES/django.po | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index eba121b5b..57ade319a 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-02 22:35+0000\n" +"POT-Creation-Date: 2026-04-03 03:25+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1866,151 +1866,151 @@ msgstr "" msgid "paperless application settings" msgstr "" -#: paperless/settings/__init__.py:528 +#: paperless/settings/__init__.py:532 msgid "English (US)" msgstr "" -#: paperless/settings/__init__.py:529 +#: paperless/settings/__init__.py:533 msgid "Arabic" msgstr "" -#: paperless/settings/__init__.py:530 +#: paperless/settings/__init__.py:534 msgid "Afrikaans" msgstr "" -#: paperless/settings/__init__.py:531 +#: paperless/settings/__init__.py:535 msgid "Belarusian" msgstr "" -#: paperless/settings/__init__.py:532 +#: paperless/settings/__init__.py:536 msgid "Bulgarian" msgstr "" -#: paperless/settings/__init__.py:533 +#: paperless/settings/__init__.py:537 msgid "Catalan" msgstr "" -#: paperless/settings/__init__.py:534 +#: paperless/settings/__init__.py:538 msgid "Czech" msgstr "" -#: paperless/settings/__init__.py:535 +#: paperless/settings/__init__.py:539 msgid "Danish" msgstr "" -#: paperless/settings/__init__.py:536 +#: paperless/settings/__init__.py:540 msgid "German" msgstr "" -#: paperless/settings/__init__.py:537 +#: paperless/settings/__init__.py:541 msgid "Greek" msgstr "" -#: paperless/settings/__init__.py:538 +#: paperless/settings/__init__.py:542 msgid "English (GB)" msgstr "" -#: paperless/settings/__init__.py:539 +#: paperless/settings/__init__.py:543 msgid "Spanish" msgstr "" -#: paperless/settings/__init__.py:540 +#: paperless/settings/__init__.py:544 msgid "Persian" msgstr "" -#: paperless/settings/__init__.py:541 +#: paperless/settings/__init__.py:545 msgid "Finnish" msgstr "" -#: paperless/settings/__init__.py:542 +#: paperless/settings/__init__.py:546 msgid "French" msgstr "" -#: paperless/settings/__init__.py:543 +#: paperless/settings/__init__.py:547 msgid "Hungarian" msgstr "" -#: paperless/settings/__init__.py:544 +#: paperless/settings/__init__.py:548 msgid "Indonesian" msgstr "" -#: paperless/settings/__init__.py:545 +#: paperless/settings/__init__.py:549 msgid "Italian" msgstr "" -#: paperless/settings/__init__.py:546 +#: paperless/settings/__init__.py:550 msgid "Japanese" msgstr "" -#: paperless/settings/__init__.py:547 +#: paperless/settings/__init__.py:551 msgid "Korean" msgstr "" -#: paperless/settings/__init__.py:548 +#: paperless/settings/__init__.py:552 msgid "Luxembourgish" msgstr "" -#: paperless/settings/__init__.py:549 +#: paperless/settings/__init__.py:553 msgid "Norwegian" msgstr "" -#: paperless/settings/__init__.py:550 +#: paperless/settings/__init__.py:554 msgid "Dutch" msgstr "" -#: paperless/settings/__init__.py:551 +#: paperless/settings/__init__.py:555 msgid "Polish" msgstr "" -#: paperless/settings/__init__.py:552 +#: paperless/settings/__init__.py:556 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings/__init__.py:553 +#: paperless/settings/__init__.py:557 msgid "Portuguese" msgstr "" -#: paperless/settings/__init__.py:554 +#: paperless/settings/__init__.py:558 msgid "Romanian" msgstr "" -#: paperless/settings/__init__.py:555 +#: paperless/settings/__init__.py:559 msgid "Russian" msgstr "" -#: paperless/settings/__init__.py:556 +#: paperless/settings/__init__.py:560 msgid "Slovak" msgstr "" -#: paperless/settings/__init__.py:557 +#: paperless/settings/__init__.py:561 msgid "Slovenian" msgstr "" -#: paperless/settings/__init__.py:558 +#: paperless/settings/__init__.py:562 msgid "Serbian" msgstr "" -#: paperless/settings/__init__.py:559 +#: paperless/settings/__init__.py:563 msgid "Swedish" msgstr "" -#: paperless/settings/__init__.py:560 +#: paperless/settings/__init__.py:564 msgid "Turkish" msgstr "" -#: paperless/settings/__init__.py:561 +#: paperless/settings/__init__.py:565 msgid "Ukrainian" msgstr "" -#: paperless/settings/__init__.py:562 +#: paperless/settings/__init__.py:566 msgid "Vietnamese" msgstr "" -#: paperless/settings/__init__.py:563 +#: paperless/settings/__init__.py:567 msgid "Chinese Simplified" msgstr "" -#: paperless/settings/__init__.py:564 +#: paperless/settings/__init__.py:568 msgid "Chinese Traditional" msgstr "" From d365f199627108a3d227e1585298121e8f6a8915 Mon Sep 17 00:00:00 2001 From: Trenton H <797416+stumpylog@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:49:54 -0700 Subject: [PATCH 6/9] Security: Registers a custom serializer which signs the task payload (#12504) --- src/paperless/celery.py | 48 +++++++++++++++++++++ src/paperless/settings/__init__.py | 6 ++- src/paperless/tests/test_celery.py | 69 ++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/paperless/tests/test_celery.py diff --git a/src/paperless/celery.py b/src/paperless/celery.py index d937b3ada..3797c840c 100644 --- a/src/paperless/celery.py +++ b/src/paperless/celery.py @@ -1,11 +1,59 @@ +import hmac import os +import pickle +from hashlib import sha256 from celery import Celery from celery.signals import worker_process_init +from kombu.serialization import register # Set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings") +# --------------------------------------------------------------------------- +# Signed-pickle serializer: pickle with HMAC-SHA256 integrity verification. +# +# Protects against malicious pickle injection via an exposed Redis broker. +# Messages are signed on the producer side and verified before deserialization +# on the worker side using Django's SECRET_KEY. +# --------------------------------------------------------------------------- + +HMAC_SIZE = 32 # SHA-256 digest length + + +def _get_signing_key() -> bytes: + from django.conf import settings + + return settings.SECRET_KEY.encode() + + +def signed_pickle_dumps(obj: object) -> bytes: + data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL) + signature = hmac.new(_get_signing_key(), data, sha256).digest() + return signature + data + + +def signed_pickle_loads(payload: bytes) -> object: + if len(payload) < HMAC_SIZE: + msg = "Signed-pickle payload too short" + raise ValueError(msg) + signature = payload[:HMAC_SIZE] + data = payload[HMAC_SIZE:] + expected = hmac.new(_get_signing_key(), data, sha256).digest() + if not hmac.compare_digest(signature, expected): + msg = "Signed-pickle HMAC verification failed — message may have been tampered with" + raise ValueError(msg) + return pickle.loads(data) + + +register( + "signed-pickle", + signed_pickle_dumps, + signed_pickle_loads, + content_type="application/x-signed-pickle", + content_encoding="binary", +) + app = Celery("paperless") # Using a string here means the worker doesn't have to serialize diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index b8a99e9fb..964295020 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -675,9 +675,11 @@ CELERY_RESULT_BACKEND = "django-db" CELERY_CACHE_BACKEND = "default" # https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-serializer -CELERY_TASK_SERIALIZER = "pickle" +# Uses HMAC-signed pickle to prevent RCE via malicious messages on an exposed Redis broker. +# The signed-pickle serializer is registered in paperless/celery.py. +CELERY_TASK_SERIALIZER = "signed-pickle" # https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-accept_content -CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"] +CELERY_ACCEPT_CONTENT = ["application/json", "application/x-signed-pickle"] # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule CELERY_BEAT_SCHEDULE = parse_beat_schedule() diff --git a/src/paperless/tests/test_celery.py b/src/paperless/tests/test_celery.py new file mode 100644 index 000000000..0c0e51272 --- /dev/null +++ b/src/paperless/tests/test_celery.py @@ -0,0 +1,69 @@ +import hmac +import pickle +from hashlib import sha256 + +import pytest +from django.test import override_settings + +from paperless.celery import HMAC_SIZE +from paperless.celery import signed_pickle_dumps +from paperless.celery import signed_pickle_loads + + +class TestSignedPickleSerializer: + def test_roundtrip_simple_types(self): + """Signed pickle can round-trip basic JSON-like types.""" + for obj in [42, "hello", [1, 2, 3], {"key": "value"}, None, True]: + assert signed_pickle_loads(signed_pickle_dumps(obj)) == obj + + def test_roundtrip_complex_types(self): + """Signed pickle can round-trip types that JSON cannot.""" + from pathlib import Path + + obj = {"path": Path("/tmp/test"), "data": {1, 2, 3}} + result = signed_pickle_loads(signed_pickle_dumps(obj)) + assert result["path"] == Path("/tmp/test") + assert result["data"] == {1, 2, 3} + + def test_tampered_data_rejected(self): + """Flipping a byte in the data portion causes HMAC failure.""" + payload = signed_pickle_dumps({"task": "test"}) + tampered = bytearray(payload) + tampered[-1] ^= 0xFF + with pytest.raises(ValueError, match="HMAC verification failed"): + signed_pickle_loads(bytes(tampered)) + + def test_tampered_signature_rejected(self): + """Flipping a byte in the signature portion causes HMAC failure.""" + payload = signed_pickle_dumps({"task": "test"}) + tampered = bytearray(payload) + tampered[0] ^= 0xFF + with pytest.raises(ValueError, match="HMAC verification failed"): + signed_pickle_loads(bytes(tampered)) + + def test_truncated_payload_rejected(self): + """A payload shorter than HMAC_SIZE is rejected.""" + with pytest.raises(ValueError, match="too short"): + signed_pickle_loads(b"\x00" * (HMAC_SIZE - 1)) + + def test_empty_payload_rejected(self): + with pytest.raises(ValueError, match="too short"): + signed_pickle_loads(b"") + + @override_settings(SECRET_KEY="different-secret-key") + def test_wrong_secret_key_rejected(self): + """A message signed with one key cannot be loaded with another.""" + original_key = b"test-secret-key-do-not-use-in-production" + obj = {"task": "test"} + data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL) + signature = hmac.new(original_key, data, sha256).digest() + payload = signature + data + with pytest.raises(ValueError, match="HMAC verification failed"): + signed_pickle_loads(payload) + + def test_forged_pickle_rejected(self): + """A raw pickle payload (no signature) is rejected.""" + raw_pickle = pickle.dumps({"task": "test"}) + # Raw pickle won't have a valid HMAC prefix + with pytest.raises(ValueError, match="HMAC verification failed"): + signed_pickle_loads(b"\x00" * HMAC_SIZE + raw_pickle) From 8c539bd862da3a13089c36e0bb91f9d950532e5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:25:17 +0000 Subject: [PATCH 7/9] Chore(deps): Bump the utilities-patch group across 1 directory with 5 updates (#12499) Bumps the utilities-patch group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [llama-index-core](https://github.com/run-llama/llama_index) | `0.14.16` | `0.14.19` | | [nltk](https://github.com/nltk/nltk) | `3.9.3` | `3.9.4` | | [zensical](https://github.com/zensical/zensical) | `0.0.26` | `0.0.29` | | [prek](https://github.com/j178/prek) | `0.3.5` | `0.3.8` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.5` | `0.15.7` | Updates `llama-index-core` from 0.14.16 to 0.14.19 - [Release notes](https://github.com/run-llama/llama_index/releases) - [Changelog](https://github.com/run-llama/llama_index/blob/main/CHANGELOG.md) - [Commits](https://github.com/run-llama/llama_index/compare/v0.14.16...v0.14.19) Updates `nltk` from 3.9.3 to 3.9.4 - [Changelog](https://github.com/nltk/nltk/blob/develop/ChangeLog) - [Commits](https://github.com/nltk/nltk/compare/3.9.3...3.9.4) Updates `zensical` from 0.0.26 to 0.0.29 - [Release notes](https://github.com/zensical/zensical/releases) - [Commits](https://github.com/zensical/zensical/compare/v0.0.26...v0.0.29) Updates `prek` from 0.3.5 to 0.3.8 - [Release notes](https://github.com/j178/prek/releases) - [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md) - [Commits](https://github.com/j178/prek/compare/v0.3.5...v0.3.8) Updates `ruff` from 0.15.5 to 0.15.7 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.5...0.15.7) --- updated-dependencies: - dependency-name: llama-index-core dependency-version: 0.14.19 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: nltk dependency-version: 3.9.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: zensical dependency-version: 0.0.29 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: prek dependency-version: 0.3.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: utilities-patch - dependency-name: ruff dependency-version: 0.15.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: utilities-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 98 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/uv.lock b/uv.lock index feffefce5..4cfd329f4 100644 --- a/uv.lock +++ b/uv.lock @@ -2139,7 +2139,7 @@ wheels = [ [[package]] name = "llama-index-core" -version = "0.14.16" +version = "0.14.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2171,9 +2171,9 @@ dependencies = [ { name = "typing-inspect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/cb/1d7383f9f4520bb1d921c34f18c147b4b270007135212cedfa240edcd4c3/llama_index_core-0.14.16.tar.gz", hash = "sha256:cf2b7e4b798cb5ebad19c935174c200595c7ecff84a83793540cc27b03636a52", size = 11599715, upload-time = "2026-03-10T19:19:52.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/eb/a661cc2f70177f59cfe7bfcdb7a4e9352fb073ab46927068151bf2905fbb/llama_index_core-0.14.19.tar.gz", hash = "sha256:7b17f321f0d965495402890991b2bfde49d4197bc46ca5970300cc7b9c2df6a2", size = 11599592, upload-time = "2026-03-25T20:58:25.751Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/f5/a33839bae0bd07e4030969bdba1ac90665e359ae88c56c296991ae16b8a8/llama_index_core-0.14.16-py3-none-any.whl", hash = "sha256:0cc273ebc44d51ad636217661a25f9cd02fb2d0440641430f105da3ae9f43a6b", size = 11944927, upload-time = "2026-03-10T19:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/6c2678b8597903503b804fe831a203d299bcbcc07bdf35789a484e67f7c0/llama_index_core-0.14.19-py3-none-any.whl", hash = "sha256:807352f16a300f9980d0110cfdaa81d07e201384965e9f7d940c8ead80d463ed", size = 11945679, upload-time = "2026-03-25T20:58:28.265Z" }, ] [[package]] @@ -2691,7 +2691,7 @@ wheels = [ [[package]] name = "nltk" -version = "3.9.3" +version = "3.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2699,9 +2699,9 @@ dependencies = [ { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/8f/915e1c12df07c70ed779d18ab83d065718a926e70d3ea33eb0cd66ffb7c0/nltk-3.9.3.tar.gz", hash = "sha256:cb5945d6424a98d694c2b9a0264519fab4363711065a46aa0ae7a2195b92e71f", size = 2923673, upload-time = "2026-02-24T12:05:53.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl", hash = "sha256:60b3db6e9995b3dd976b1f0fa7dec22069b2677e759c28eb69b62ddd44870522", size = 1525385, upload-time = "2026-02-24T12:05:46.54Z" }, + { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, ] [[package]] @@ -3346,23 +3346,23 @@ wheels = [ [[package]] name = "prek" -version = "0.3.5" +version = "0.3.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/d6/277e002e56eeab3a9d48f1ca4cc067d249d6326fc1783b770d70ad5ae2be/prek-0.3.5.tar.gz", hash = "sha256:ca40b6685a4192256bc807f32237af94bf9b8799c0d708b98735738250685642", size = 374806, upload-time = "2026-03-09T10:35:18.842Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/a9/16dd8d3a50362ebccffe58518af1f1f571c96f0695d7fcd8bbd386585f58/prek-0.3.5-py3-none-linux_armv6l.whl", hash = "sha256:44b3e12791805804f286d103682b42a84e0f98a2687faa37045e9d3375d3d73d", size = 5105604, upload-time = "2026-03-09T10:35:00.332Z" }, - { url = "https://files.pythonhosted.org/packages/e4/74/bc6036f5bf03860cda66ab040b32737e54802b71a81ec381839deb25df9e/prek-0.3.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3cb451cc51ac068974557491beb4c7d2d41dfde29ed559c1694c8ce23bf53e8", size = 5506155, upload-time = "2026-03-09T10:35:17.64Z" }, - { url = "https://files.pythonhosted.org/packages/02/d9/a3745c2a10509c63b6a118ada766614dd705efefd08f275804d5c807aa4a/prek-0.3.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ad8f5f0d8da53dc94d00b76979af312b3dacccc9dcbc6417756c5dca3633c052", size = 5100383, upload-time = "2026-03-09T10:35:13.302Z" }, - { url = "https://files.pythonhosted.org/packages/43/8e/de965fc515d39309a332789cd3778161f7bc80cde15070bedf17f9f8cb93/prek-0.3.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4511e15d34072851ac88e4b2006868fbe13655059ad941d7a0ff9ee17138fd9f", size = 5334913, upload-time = "2026-03-09T10:35:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/44f07e8940256059cfd82520e3cbe0764ab06ddb4aa43148465db00b39ad/prek-0.3.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc0b63b8337e2046f51267facaac63ba755bc14aad53991840a5eccba3e5c28", size = 5033825, upload-time = "2026-03-09T10:35:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/94/85/3ff0f96881ff2360c212d310ff23c3cf5a15b223d34fcfa8cdcef203be69/prek-0.3.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5fc0d78c3896a674aeb8247a83bbda7efec85274dbdfbc978ceff8d37e4ed20", size = 5438586, upload-time = "2026-03-09T10:34:58.779Z" }, - { url = "https://files.pythonhosted.org/packages/79/a5/c6d08d31293400fcb5d427f8e7e6bacfc959988e868ad3a9d97b4d87c4b7/prek-0.3.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64cad21cb9072d985179495b77b312f6b81e7b45357d0c68dc1de66e0408eabc", size = 6359714, upload-time = "2026-03-09T10:34:57.454Z" }, - { url = "https://files.pythonhosted.org/packages/ba/18/321dcff9ece8065d42c8c1c7a53a23b45d2b4330aa70993be75dc5f2822f/prek-0.3.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45ee84199bb48e013bdfde0c84352c17a44cc42d5792681b86d94e9474aab6f8", size = 5717632, upload-time = "2026-03-09T10:35:08.634Z" }, - { url = "https://files.pythonhosted.org/packages/a3/7f/1288226aa381d0cea403157f4e6b64b356e1a745f2441c31dd9d8a1d63da/prek-0.3.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f43275e5d564e18e52133129ebeb5cb071af7ce4a547766c7f025aa0955dfbb6", size = 5339040, upload-time = "2026-03-09T10:35:03.665Z" }, - { url = "https://files.pythonhosted.org/packages/22/94/cfec83df9c2b8e7ed1608087bcf9538a6a77b4c2e7365123e9e0a3162cd1/prek-0.3.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:abcee520d31522bcbad9311f21326b447694cd5edba33618c25fd023fc9865ec", size = 5162586, upload-time = "2026-03-09T10:35:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/13/b7/741d62132f37a5f7cc0fad1168bd31f20dea9628f482f077f569547e0436/prek-0.3.5-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:499c56a94a155790c75a973d351a33f8065579d9094c93f6d451ada5d1e469be", size = 5002933, upload-time = "2026-03-09T10:35:16.347Z" }, - { url = "https://files.pythonhosted.org/packages/6f/83/630a5671df6550fcfa67c54955e8a8174eb9b4d97ac38fb05a362029245b/prek-0.3.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de1065b59f194624adc9dea269d4ff6b50e98a1b5bb662374a9adaa496b3c1eb", size = 5304934, upload-time = "2026-03-09T10:35:09.975Z" }, - { url = "https://files.pythonhosted.org/packages/de/79/67a7afd0c0b6c436630b7dba6e586a42d21d5d6e5778fbd9eba7bbd3dd26/prek-0.3.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a1c4869e45ee341735d07179da3a79fa2afb5959cef8b3c8a71906eb52dc6933", size = 5829914, upload-time = "2026-03-09T10:35:05.39Z" }, + { url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" }, + { url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" }, + { url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" }, + { url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" }, + { url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" }, + { url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" }, ] [[package]] @@ -4326,24 +4326,24 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.5" +version = "0.15.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, - { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, - { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, - { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, - { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, - { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, ] [[package]] @@ -5710,7 +5710,7 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.26" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -5720,18 +5720,18 @@ dependencies = [ { name = "pymdown-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/1f/0a0b1ce8e0553a9dabaedc736d0f34b11fc33d71ff46bce44d674996d41f/zensical-0.0.26.tar.gz", hash = "sha256:f4d9c8403df25fbb3d6dd9577122dc2f23c73a2d16ab778bb7d40370dd71e987", size = 3841473, upload-time = "2026-03-11T09:51:38.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/bd/5786ab618a60bd7469ab243a7fd2c9eecb0790c85c784abb8b97edb77a54/zensical-0.0.29.tar.gz", hash = "sha256:0d6282be7cb551e12d5806badf5e94c54a5e2f2cf07057a3e36d1eaf97c33ada", size = 3842641, upload-time = "2026-03-24T13:37:27.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/58/fa3d9538ff1ea8cf4a193edbf47254f374fa7983fcfa876bb4336d72c53a/zensical-0.0.26-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7823b25afe7d36099253aa59d643abaac940f80fd015d4a37954210c87d3da56", size = 12263607, upload-time = "2026-03-11T09:50:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6e/44a3b21bd3569b9cad203364d73a956768d28a879e4c2be91bd889f74d2c/zensical-0.0.26-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c0254814382cdd3769bc7689180d09bf41de8879871dd736dc52d5f141e8ada7", size = 12144562, upload-time = "2026-03-11T09:50:53.685Z" }, - { url = "https://files.pythonhosted.org/packages/07/ae/31b9885745b3e7ef23a3ae7f175b879807288d11b3fb7e2d3c119c916258/zensical-0.0.26-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c8e601b2bbd239e564b04cf235eefb9777e7dfc7e1857b8871d6cdcfb577aa0", size = 12506728, upload-time = "2026-03-11T09:50:57.775Z" }, - { url = "https://files.pythonhosted.org/packages/bd/93/f5291e2c47076474f181f6eef35ef0428117d3f192da4358c0511e2ce09e/zensical-0.0.26-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2dc43c7e6c25d9724fc0450f0273ca4e5e2506eeb7f89f52f1405a592896ca3b", size = 12454975, upload-time = "2026-03-11T09:51:01.514Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/61cac4f2ebad31dab768eb02753ffde9e56d4d34b8f876b949bf516fbd50/zensical-0.0.26-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24ed236d1254cc474c19227eaa3670a1ccf921af53134ec5542b05853bdcd59c", size = 12791930, upload-time = "2026-03-11T09:51:05.162Z" }, - { url = "https://files.pythonhosted.org/packages/02/86/51995d1ed2dd6ad8a1a70bcdf3c5eb16b50e62ea70e638d454a6b9061c4d/zensical-0.0.26-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1110147710d1dd025d932c4a7eada836bdf079c91b70fb0ae5b202e14b094617", size = 12548166, upload-time = "2026-03-11T09:51:09.218Z" }, - { url = "https://files.pythonhosted.org/packages/3d/93/decbafdbfc77170cbc3851464632390846e9aaf45e743c8dd5a24d5673e9/zensical-0.0.26-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7d21596a785428cdebc20859bd94a05334abe14ad24f1bb9cd80d19219e3c220", size = 12682103, upload-time = "2026-03-11T09:51:12.68Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e2/391d2d08dde621177da069a796a886b549fefb15734aeeb6e696af99b662/zensical-0.0.26-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:680a3c7bb71499b4da784d6072e44b3d7b8c0df3ce9bbd9974e24bd8058c2736", size = 12724219, upload-time = "2026-03-11T09:51:17.32Z" }, - { url = "https://files.pythonhosted.org/packages/80/2a/21b40c5c40a67da8a841f278d61dbd8d5e035e489de6fe1cef5f4e211b4f/zensical-0.0.26-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:e3294a79f98218b6fc2219232e166aa0932ae4dad58f6c8dbc0dbe0ecbff9c25", size = 12862117, upload-time = "2026-03-11T09:51:22.161Z" }, - { url = "https://files.pythonhosted.org/packages/51/76/e1910d6d75d207654c867b8efbda6822dedda9fed3601bf4a864a1f4fe26/zensical-0.0.26-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:630229587df1fb47be184a4a69d0772ce59a44cd2c481ae9f7e8852fffaff11e", size = 12815714, upload-time = "2026-03-11T09:51:26.24Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9c/8b681daa024abca9763017bec09ecee8008e110cae1254217c8dd22cc339/zensical-0.0.29-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:20ae0709ea14fce25ab33d0a82acdaf454a7a2e232a9ee20c019942205174476", size = 12311399, upload-time = "2026-03-24T13:36:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/81/ae/4ebb4d8bb2ef0164d473698b92f11caf431fc436e1625524acd5641102ca/zensical-0.0.29-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:599af3ba66fcd0146d7019f3493ed3c316051fae6c4d5599bc59f3a8f4b8a6f0", size = 12191845, upload-time = "2026-03-24T13:36:56.909Z" }, + { url = "https://files.pythonhosted.org/packages/d5/35/67f89db06571a52283b3ecbe3bcf32fd3115ca50436b3ae177a948b83ea7/zensical-0.0.29-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eea7e48a00a71c0586e875079b5f83a070c33a147e52ad4383e4b63ab524332b", size = 12554105, upload-time = "2026-03-24T13:36:59.945Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/ac79e5d9c18b28557c9ff1c7c23d695fbdd82645d69bfe02292f46d935e7/zensical-0.0.29-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59a57db35542e98d2896b833de07d199320f8ada3b4e7ddccb7fe892292d8b74", size = 12498643, upload-time = "2026-03-24T13:37:02.376Z" }, + { url = "https://files.pythonhosted.org/packages/b1/70/5c22a96a69e0e91e569c26236918bb9bab1170f59b29ad04105ead64f199/zensical-0.0.29-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d42c2b2a96a80cf64c98ba7242f59ef95109914bd4c9499d7ebc12544663852c", size = 12854531, upload-time = "2026-03-24T13:37:04.962Z" }, + { url = "https://files.pythonhosted.org/packages/79/25/e32237a8fcb0ceae1ef8e192e7f8db53b38f1e48f1c7cdbacd0a7b713892/zensical-0.0.29-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b2fca39c5f6b1782c77cf6591cf346357cabee85ebdb956c5ddc0fd5169f3d9", size = 12596828, upload-time = "2026-03-24T13:37:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/ff/74/89ac909cbb258903ea53802c184e4986c17ce0ba79b1c7f77b7e78a2dce3/zensical-0.0.29-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfc23a74ef672aa51088c080286319da1dc0b989cd5051e9e5e6d7d4abbc2fc1", size = 12732059, upload-time = "2026-03-24T13:37:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/8c/31/2429de6a9328eed4acc7e9a3789f160294a15115be15f9870a0d02649302/zensical-0.0.29-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c9336d4e4b232e3c9a70e30258e916dd7e60c0a2a08c8690065e60350c302028", size = 12768542, upload-time = "2026-03-24T13:37:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/10/8a/55588b2a1dcbe86dad0404506c9ba367a06c663b1ff47147c84d26f7510e/zensical-0.0.29-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:30661148f0681199f3b598cbeb1d54f5cba773e54ae840bac639250d85907b84", size = 12917991, upload-time = "2026-03-24T13:37:16.795Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5d/653901f0d3a3ca72daebc62746a148797f4e422cc3a2b66a4e6718e4398f/zensical-0.0.29-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6a566ac1fd4bfac5d711a7bd1ae06666712127c2718daa5083c7bf3f107e8578", size = 12868392, upload-time = "2026-03-24T13:37:19.42Z" }, ] [[package]] From eb758862c94ea6344da4b940e23ee30d21aff829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 07:22:04 -0700 Subject: [PATCH 8/9] Chore(deps): Bump the document-processing group with 3 updates (#12489) Bumps the document-processing group with 3 updates: [gotenberg-client](https://github.com/stumpylog/gotenberg-client), [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF) and [tika-client](https://github.com/stumpylog/tika-client). Updates `gotenberg-client` from 0.13.1 to 0.14.0 - [Release notes](https://github.com/stumpylog/gotenberg-client/releases) - [Changelog](https://github.com/stumpylog/gotenberg-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/stumpylog/gotenberg-client/compare/0.13.1...0.14.0) Updates `ocrmypdf` from 17.3.0 to 17.4.0 - [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases) - [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v17.3.0...v17.4.0) Updates `tika-client` from 0.10.0 to 0.11.0 - [Release notes](https://github.com/stumpylog/tika-client/releases) - [Changelog](https://github.com/stumpylog/tika-client/blob/main/CHANGELOG.md) - [Commits](https://github.com/stumpylog/tika-client/compare/0.10.0...0.11.0) --- updated-dependencies: - dependency-name: gotenberg-client dependency-version: 0.14.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: document-processing - dependency-name: ocrmypdf dependency-version: 17.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: document-processing - dependency-name: tika-client dependency-version: 0.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: document-processing ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 6 +++--- uv.lock | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7bb160956..30be1ae11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "faiss-cpu>=1.10", "filelock~=3.25.2", "flower~=2.0.1", - "gotenberg-client~=0.13.1", + "gotenberg-client~=0.14.0", "httpx-oauth~=0.16", "ijson>=3.2", "imap-tools~=1.11.0", @@ -59,7 +59,7 @@ dependencies = [ "llama-index-llms-openai>=0.6.13", "llama-index-vector-stores-faiss>=0.5.2", "nltk~=3.9.1", - "ocrmypdf~=17.3.0", + "ocrmypdf~=17.4.0", "openai>=1.76", "pathvalidate~=3.3.1", "pdf2image~=1.17.0", @@ -75,7 +75,7 @@ dependencies = [ "sentence-transformers>=4.1", "setproctitle~=1.3.4", "tantivy>=0.25.1", - "tika-client~=0.10.0", + "tika-client~=0.11.0", "torch~=2.10.0", "watchfiles>=1.1.1", "whitenoise~=6.11", diff --git a/uv.lock b/uv.lock index 4cfd329f4..8b72cbcc3 100644 --- a/uv.lock +++ b/uv.lock @@ -1434,14 +1434,14 @@ wheels = [ [[package]] name = "gotenberg-client" -version = "0.13.1" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/6c/aaadd6657ca42fbd148b1c00604b98c1ead5a22552f4e5365ce5f0632430/gotenberg_client-0.13.1.tar.gz", hash = "sha256:cdd6bbb535cd739b87446cd1b4f6347ed7f9af6a0d4b19baf7c064b75528ee54", size = 1211143, upload-time = "2025-12-04T20:45:24.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/34/8e3be3a6a1b654d2a3bfa3e5d201183aeff6d50c42199ac0b8ed912c01c5/gotenberg_client-0.14.0.tar.gz", hash = "sha256:a853700c6b01c3372871264c4eb9ae3375addafbcbbfd3341e411f4217a8088c", size = 1214438, upload-time = "2026-03-11T17:23:11.122Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/f6/7a6e6785295332d2538f729ae19516cef712273a5ab8b90d015f08e37a45/gotenberg_client-0.13.1-py3-none-any.whl", hash = "sha256:613f7083a5e8a81699dd8d715c97e5806a424ac48920aad25d7c11b600cdfaf3", size = 51058, upload-time = "2025-12-04T20:45:22.603Z" }, + { url = "https://files.pythonhosted.org/packages/55/1a/67ff4cca162ae4195bd6f1a107779898b6f2977cc33ae7e05a5178a395fa/gotenberg_client-0.14.0-py3-none-any.whl", hash = "sha256:868f1be46d1ed0f327ca3efeb1888b4fe35641c35bfa39684d23a59365703156", size = 50977, upload-time = "2026-03-11T17:23:09.397Z" }, ] [[package]] @@ -2775,7 +2775,7 @@ wheels = [ [[package]] name = "ocrmypdf" -version = "17.3.0" +version = "17.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2792,9 +2792,9 @@ dependencies = [ { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "uharfbuzz", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/fe/60bdc79529be1ad8b151d426ed2020d5ac90328c54e9ba92bd808e1535c1/ocrmypdf-17.3.0.tar.gz", hash = "sha256:4022f13aad3f405e330056a07aa8bd63714b48b414693831b56e2cf2c325f52d", size = 7378015, upload-time = "2026-02-21T09:30:07.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/b9/01f5cbd062f680af8a3f8f883f8e71de8be7979c3256f509661c1e2e2065/ocrmypdf-17.4.0.tar.gz", hash = "sha256:4bbc53249f3981599565f670c5de774d6440832eede87c515e6608880fa02a34", size = 7378592, upload-time = "2026-03-21T19:06:50.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/b1/b7ae057a1bcb1495067ee3c4d48c1ce5fc66addd9492307c5a0ff799a7f2/ocrmypdf-17.3.0-py3-none-any.whl", hash = "sha256:c8882e7864954d3db6bcee49cc9f261b65bff66b7e5925eb68a1c281f41cad23", size = 488130, upload-time = "2026-02-21T09:30:05.236Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/71ae51a11669e63f1fea76153db4475079ea8d7b60802faf080f25b4a262/ocrmypdf-17.4.0-py3-none-any.whl", hash = "sha256:b93fd4d736a71a241e44d1b48e1305b9bf4581cd2ae9a96fcd89f4db1051dd87", size = 488312, upload-time = "2026-03-21T19:06:48.456Z" }, ] [[package]] @@ -3022,7 +3022,7 @@ requires-dist = [ { name = "faiss-cpu", specifier = ">=1.10" }, { name = "filelock", specifier = "~=3.25.2" }, { name = "flower", specifier = "~=2.0.1" }, - { name = "gotenberg-client", specifier = "~=0.13.1" }, + { name = "gotenberg-client", specifier = "~=0.14.0" }, { name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.7.0" }, { name = "httpx-oauth", specifier = "~=0.16" }, { name = "ijson", specifier = ">=3.2" }, @@ -3037,7 +3037,7 @@ requires-dist = [ { name = "llama-index-vector-stores-faiss", specifier = ">=0.5.2" }, { name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" }, { name = "nltk", specifier = "~=3.9.1" }, - { name = "ocrmypdf", specifier = "~=17.3.0" }, + { name = "ocrmypdf", specifier = "~=17.4.0" }, { name = "openai", specifier = ">=1.76" }, { name = "pathvalidate", specifier = "~=3.3.1" }, { name = "pdf2image", specifier = "~=1.17.0" }, @@ -3058,7 +3058,7 @@ requires-dist = [ { name = "sentence-transformers", specifier = ">=4.1" }, { name = "setproctitle", specifier = "~=1.3.4" }, { name = "tantivy", specifier = ">=0.25.1" }, - { name = "tika-client", specifier = "~=0.10.0" }, + { name = "tika-client", specifier = "~=0.11.0" }, { name = "torch", specifier = "~=2.10.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "watchfiles", specifier = ">=1.1.1" }, { name = "whitenoise", specifier = "~=6.11" }, @@ -4716,15 +4716,15 @@ wheels = [ [[package]] name = "tika-client" -version = "0.10.0" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/be/65bfc47e4689ecd5ead20cf47dc0084fd767b7e71e8cfabf5fddc42aae3c/tika_client-0.10.0.tar.gz", hash = "sha256:3101e8b2482ae4cb7f87be13ada970ff691bdc3404d94cd52f5e57a09c99370c", size = 2178257, upload-time = "2025-08-04T17:47:30.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/d9/01f2049240dacf67c9be61d9c59e72b6827a862e8fd87e77e458e0a3b797/tika_client-0.11.0.tar.gz", hash = "sha256:c741caaca08bbd715a8db3fe6f0430a54d075fef3d59a441e8b8d810f58de4f0", size = 2178828, upload-time = "2026-03-11T16:50:25.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/31/002e0fa5bca67d6a19da8c294273486f6c46cbcc83d6879719a38a181461/tika_client-0.10.0-py3-none-any.whl", hash = "sha256:f5486cc884e4522575662aa295bda761bf9f101ac8d92840155b58ab8b96f6e2", size = 18237, upload-time = "2025-08-04T17:47:28.966Z" }, + { url = "https://files.pythonhosted.org/packages/53/04/5a433d621ec559d1d216d200eea43b0ac63435beb5dd52bbc75f4aaef465/tika_client-0.11.0-py3-none-any.whl", hash = "sha256:461903ccbe705d84dd3e4a1ca83e04174776d4b06dc57b902f9281633a3836e6", size = 18470, upload-time = "2026-03-11T16:50:24.672Z" }, ] [[package]] From 64debc87a594ae339a5ac725550de3d4c9bcfaeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 08:16:36 -0700 Subject: [PATCH 9/9] Chore(deps): Bump djangorestframework in the django-ecosystem group (#12488) Bumps the django-ecosystem group with 1 update: [djangorestframework](https://github.com/encode/django-rest-framework). Updates `djangorestframework` from 3.16.1 to 3.17.1 - [Release notes](https://github.com/encode/django-rest-framework/releases) - [Commits](https://github.com/encode/django-rest-framework/compare/3.16.1...3.17.1) --- updated-dependencies: - dependency-name: djangorestframework dependency-version: 3.17.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: django-ecosystem ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 8b72cbcc3..180448efe 100644 --- a/uv.lock +++ b/uv.lock @@ -1108,14 +1108,14 @@ wheels = [ [[package]] name = "djangorestframework" -version = "3.16.1" +version = "3.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, ] [[package]]