diff --git a/CHANGELOG.md b/CHANGELOG.md index 2398ea5c..11b21f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MinerU subprocess argv 생성 시 `-`로 시작하는 option-like 인자를 거부하여 argument injection 위험을 낮춤 - API 에러 응답 생성 시 내부 예외 체인을 억제하여 의존성 오류나 내부 경로가 노출될 가능성을 줄임 - API 응답 미들웨어에 `Cache-Control: no-store, max-age=0` 헤더를 추가하여 민감한 파싱 데이터의 브라우저 및 중간 캐싱을 방지 -- `uv.lock`의 의존성을 재잠금하여 실제 `pip-audit`/`trivy-fs` CVE를 제거: 런타임 경로의 `pillow` 12.2.0→12.3.0 (PYSEC-2026-3451/3452/3453/3454/3493/3494/3495/3496, 이미지 파서 취약점 8건), `pypdf>=6.15.0,<7.0` (lock 6.15.0; CVE-2026-59935/59936/59937/59938/71852/71870, PDF 파싱 경로), `click` 8.3.2→8.4.2 (PYSEC-2026-2132) — 모두 스캔 PDF/이미지 파싱 런타임에 직접 관련되며 선언 범위와 lock을 함께 고정함. 빌드 도구 `setuptools` 81.0.0→83.0.0 (CVE-2026-59890). 문서 툴체인의 `pymdown-extensions` 10.21.3→11.0.1 (CVE-2026-61632, MEDIUM)은 `mkdocs-material` 9.6.x의 `pymdown-extensions~=10.2`(`<11`) 상한 때문에 막혀 있었으므로, docs extra 핀을 `mkdocs-material>=9.7,<9.8`로 올려(9.7.x는 상한을 `>=10.2`로 완화) 해소함. `uv run mkdocs build --strict` 통과 확인. 조치 후 전체 잠금(런타임+extras) `pip-audit`: 취약점 0건. +- `uv.lock`의 의존성을 재잠금하여 실제 `pip-audit`/`trivy-fs` CVE를 제거: 런타임 경로의 `pillow` 12.2.0→12.3.0 (PYSEC-2026-3451/3452/3453/3454/3493/3494/3495/3496, 이미지 파서 취약점 8건), `pypdf>=6.17.0,<7.0` (lock 6.17.0; CVE-2026-59935/59936/59937/59938/71852/71870, PDF 파싱 경로), `click` 8.3.2→8.4.2 (PYSEC-2026-2132) — 모두 스캔 PDF/이미지 파싱 런타임에 직접 관련되며 선언 범위와 lock을 함께 고정함. 빌드 도구 `setuptools` 81.0.0→83.0.0 (CVE-2026-59890). 문서 툴체인의 `pymdown-extensions` 10.21.3→11.0.1 (CVE-2026-61632, MEDIUM)은 `mkdocs-material` 9.6.x의 `pymdown-extensions~=10.2`(`<11`) 상한 때문에 막혀 있었으므로, docs extra 핀을 `mkdocs-material>=9.7,<9.8`로 올려(9.7.x는 상한을 `>=10.2`로 완화) 해소함. `uv run mkdocs build --strict` 통과 확인. 조치 후 전체 잠금(런타임+extras) `pip-audit`: 취약점 0건. ### Performance - `newsdom_api.dom_builder._html_safe_text` 함수에 early return과 타입 체크를 도입하여 불필요한 `str()` 캐스팅을 제거함으로써 처리 속도를 개선했습니다. diff --git a/docs/doctoring/dependency-security-baseline.md b/docs/doctoring/dependency-security-baseline.md index 2dc515c9..cef70deb 100644 --- a/docs/doctoring/dependency-security-baseline.md +++ b/docs/doctoring/dependency-security-baseline.md @@ -14,12 +14,12 @@ The adopted floors are: - `setuptools>=83` for the build backend; - `Pillow>=12.3,<13.0` for image parsing on the untrusted document-ingestion path; -- `pypdf>=6.15.0,<7.0` for PDF parsing; +- `pypdf>=6.17.0,<7.0` for PDF parsing; - `mkdocs-material>=9.7,<9.8`, allowing `pymdown-extensions>=11` while the MkDocs core remains on the supported 1.x line. The generated lock additionally resolves Click 8.4.2, setuptools 83.0.0, -Pillow 12.3.0, pypdf 6.15.0, mkdocs-material 9.7.7, and +Pillow 12.3.0, pypdf 6.17.0, mkdocs-material 9.7.7, and pymdown-extensions 11.0.1. Direct floors prevent a later lock refresh from silently selecting known-vulnerable ranges again. @@ -30,7 +30,7 @@ runtime availability risk rather than an abstract transitive-dependency finding. The earlier baseline raised pypdf to 6.14.2 for CVE-2026-59935. On August 8, 2026, the repository's current Trivy filesystem gate began reporting two additional MEDIUM findings, CVE-2026-71852 and CVE-2026-71870, against the locked -6.14.2 artifact. The same repository had already produced a hash-locked 6.15.0 +6.14.2 artifact. The same repository had already produced a hash-locked 6.17.0 resolution on an isolated branch; that exact head completed the Security Scan successfully without suppressing either finding. The shared direct floor and lock therefore move together to 6.15.0 rather than hiding the findings in @@ -46,7 +46,7 @@ Pillow 12.3.0 and pypdf release artifacts are distributed through PyPI with published cryptographic file digests. Those artifacts and digests provide provenance inputs; they do not by themselves establish that a package is safe. Repository scans, hash-locked resolution, current-head tests, and independent -review remain mandatory. PyPI's official JSON metadata confirms the 6.15.0 +review remain mandatory. PyPI's official JSON metadata confirms the 6.17.0 release and the artifact hashes recorded in this repository's generated lock. ## Secure-development and provenance controls @@ -135,8 +135,8 @@ Python Packaging Authority. (2026a). *Digital attestations*. PyPI Docs. Python Packaging Authority. (2026b). *Pillow 12.3.0*. Python Package Index. Retrieved August 4, 2026, from https://pypi.org/project/pillow/12.3.0/ -Python Packaging Authority. (2026c). *pypdf 6.15.0*. Python Package Index. - Retrieved August 9, 2026, from https://pypi.org/project/pypdf/6.15.0/ +Python Packaging Authority. (2026c). *pypdf 6.17.0*. Python Package Index. + Retrieved August 9, 2026, from https://pypi.org/project/pypdf/6.17.0/ Python Packaging Authority. (2026d). *setuptools 83.0.0*. Python Package Index. Retrieved August 4, 2026, from https://pypi.org/project/setuptools/83.0.0/ diff --git a/pyproject.toml b/pyproject.toml index 7a29144e..9d01cd0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "python-multipart>=0.0.31,<1.0", "reportlab>=4.2,<6.0", "Pillow>=12.3,<13.0", - "pypdf>=6.15.0,<7.0", + "pypdf>=6.17.0,<7.0", ] [project.optional-dependencies] diff --git a/src/newsdom_api/body_limit.py b/src/newsdom_api/body_limit.py new file mode 100644 index 00000000..f7e742dd --- /dev/null +++ b/src/newsdom_api/body_limit.py @@ -0,0 +1,115 @@ +"""ASGI request-body admission limits for parser uploads.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from starlette.responses import JSONResponse +from starlette.types import Message, Receive, Scope, Send + +ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] +PAYLOAD_TOO_LARGE_DETAIL = "Payload Too Large" + + +class RequestBodyTooLarge(Exception): + """Signal that actual ASGI request bytes crossed the configured admission cap.""" + + +def _declared_content_length(scope: Scope) -> int | None: + """Return one valid non-negative Content-Length value, otherwise no hint.""" + + values = [ + value + for name, value in scope.get("headers", []) + if name.lower() == b"content-length" + ] + if len(values) != 1: + return None + try: + declared = int(values[0]) + except (TypeError, ValueError): + return None + return declared if declared >= 0 else None + + +class RequestBodyLimitMiddleware: + """Bound raw request bytes for one HTTP method/path before body parsing. + + `Content-Length` is only an early-rejection hint. Enforcement always wraps the + ASGI receive channel and counts actual bytes, so omitted or understated headers + cannot bypass the limit. The middleware is intended to sit inside the existing + authentication boundary and outside FastAPI's multipart parsing for `/parse`. + + Remove this compatibility middleware after the repository adopts Starlette + 1.6+ and its native `max_body_size` / `RequestBodyLimitMiddleware` contract. + """ + + def __init__( + self, + app: ASGIApp, + *, + max_body_size: int, + path: str, + method: str = "POST", + ) -> None: + """Bind an ASGI app to a non-negative byte limit and exact route selector.""" + + if max_body_size < 0: + raise ValueError("max_body_size must be non-negative") + self.app = app + self.max_body_size = max_body_size + self.path = path + self.method = method.upper() + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Reject an oversized selected request before downstream body parsing.""" + + if ( + scope["type"] != "http" + or scope.get("method", "").upper() != self.method + or scope.get("path") != self.path + ): + await self.app(scope, receive, send) + return + + declared_length = _declared_content_length(scope) + if declared_length is not None and declared_length > self.max_body_size: + await self._send_too_large(scope, receive, send) + return + + received_bytes = 0 + response_started = False + + async def limited_receive() -> Message: + """Wrap receive to enforce the maximum body size limit.""" + nonlocal received_bytes + message = await receive() + if message["type"] == "http.request": + received_bytes += len(message.get("body", b"")) + if received_bytes > self.max_body_size: + raise RequestBodyTooLarge + return message + + async def tracking_send(message: Message) -> None: + """Wrap send to track if a response has already started.""" + nonlocal response_started + if message["type"] == "http.response.start": + response_started = True + await send(message) + + try: + await self.app(scope, limited_receive, tracking_send) + except RequestBodyTooLarge: + if response_started: + raise + await self._send_too_large(scope, receive, send) + + @staticmethod + async def _send_too_large(scope: Scope, receive: Receive, send: Send) -> None: + """Emit the service's sanitized 413 response through the ASGI interface.""" + + response = JSONResponse( + status_code=413, + content={"detail": PAYLOAD_TOO_LARGE_DETAIL}, + ) + await response(scope, receive, send) diff --git a/src/newsdom_api/main.py b/src/newsdom_api/main.py index f61aafc2..5d8d4eac 100644 --- a/src/newsdom_api/main.py +++ b/src/newsdom_api/main.py @@ -24,6 +24,7 @@ from pypdf import PdfReader from pypdf.errors import PdfReadError +from .body_limit import RequestBodyLimitMiddleware from .config import ( AuthenticationMode, MAX_BEARER_HEADER_BYTES, @@ -42,6 +43,7 @@ from .service import parse_pdf MAX_PARSE_UPLOAD_BYTES = 20 * 1024 * 1024 +MAX_PARSE_REQUEST_BYTES = MAX_PARSE_UPLOAD_BYTES + (1024 * 1024) MAX_AUTHORIZATION_HEADER_BYTES = MAX_BEARER_HEADER_BYTES UNSUPPORTED_MEDIA_DETAIL = "Unsupported Media Type" PAYLOAD_TOO_LARGE_DETAIL = "Payload Too Large" @@ -205,6 +207,7 @@ async def parse( language: Annotated[ str, Form( + max_length=50, description=( "MinerU language family or compatibility alias (e.g. `ch`, " "`en`, `japan`, `korean`, `arabic`, `devanagari`)." @@ -214,6 +217,7 @@ async def parse( mode: Annotated[ str, Form( + max_length=50, description=( "MinerU parsing mode: `auto` (born-digital text PDFs skip forced " "OCR), `ocr` (force OCR), or `txt` (embedded text layer only)." @@ -327,6 +331,14 @@ def create_app( application.state.runtime_readiness_probe = ( runtime_readiness_probe or mineru_runtime_available ) + # Register the body limiter first so the subsequently registered authentication + # middleware remains the outer boundary and rejects unauthorized uploads before + # either the limiter or FastAPI's multipart parser consumes request bytes. + application.add_middleware( + RequestBodyLimitMiddleware, + max_body_size=MAX_PARSE_REQUEST_BYTES, + path="/parse", + ) application.middleware("http")(security_boundary_middleware) application.add_exception_handler(Exception, global_exception_handler) application.add_api_route( diff --git a/tests/test_body_limit.py b/tests/test_body_limit.py new file mode 100644 index 00000000..8d7b3562 --- /dev/null +++ b/tests/test_body_limit.py @@ -0,0 +1,320 @@ +"""Deterministic ASGI tests for request-body admission before multipart parsing.""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable + +import pytest +from fastapi.testclient import TestClient +from starlette.types import Message, Receive, Scope, Send + +from newsdom_api.body_limit import ( + RequestBodyLimitMiddleware, + RequestBodyTooLarge, + _declared_content_length, +) +from newsdom_api.config import AuthenticationMode, RuntimeProfile, RuntimeSettings +from newsdom_api.main import MAX_PARSE_REQUEST_BYTES, create_app + +ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] + + +def _http_scope( + *, + path: str = "/parse", + method: str = "POST", + headers: list[tuple[bytes, bytes]] | None = None, +) -> Scope: + """Build the minimal HTTP scope required by the admission middleware.""" + + return { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": method, + "scheme": "http", + "path": path, + "raw_path": path.encode("ascii"), + "query_string": b"", + "root_path": "", + "headers": headers or [], + "client": ("testclient", 1234), + "server": ("testserver", 80), + } + + +async def _run_asgi( + app: ASGIApp, + scope: Scope, + request_messages: list[Message], +) -> list[Message]: + """Run one ASGI exchange and return every response event.""" + + pending = list(request_messages) + sent: list[Message] = [] + + async def receive() -> Message: + if pending: + return pending.pop(0) + return {"type": "http.disconnect"} + + async def send(message: Message) -> None: + sent.append(message) + + await app(scope, receive, send) + return sent + + +class _BodyConsumer: + """Test ASGI app that consumes the complete request body before responding.""" + + def __init__(self) -> None: + self.calls = 0 + self.body = b"" + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """Consume request chunks and emit a minimal successful response.""" + + self.calls += 1 + while True: + message = await receive() + if message["type"] != "http.request": + break + self.body += message.get("body", b"") + if not message.get("more_body", False): + break + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +def _status(messages: list[Message]) -> int: + """Return the HTTP status from one ASGI response event sequence.""" + + return next( + message["status"] + for message in messages + if message["type"] == "http.response.start" + ) + + +def _response_json(messages: list[Message]) -> dict[str, str]: + """Decode the accumulated ASGI response body as JSON.""" + + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + return json.loads(body) + + +def test_declared_content_length_requires_one_valid_non_negative_value() -> None: + """Treat malformed, negative, or duplicated lengths only as unusable hints.""" + + assert _declared_content_length( + _http_scope(headers=[(b"content-length", b"5")]) + ) == 5 + assert ( + _declared_content_length( + _http_scope(headers=[(b"content-length", b"-1")]) + ) + is None + ) + assert ( + _declared_content_length( + _http_scope(headers=[(b"content-length", b"nope")]) + ) + is None + ) + assert ( + _declared_content_length( + _http_scope( + headers=[ + (b"content-length", b"5"), + (b"Content-Length", b"5"), + ] + ) + ) + is None + ) + + +def test_negative_body_limit_is_rejected_at_configuration_time() -> None: + """Reject invalid limits before the middleware can enter the request path.""" + + with pytest.raises(ValueError, match="non-negative"): + RequestBodyLimitMiddleware(_BodyConsumer(), max_body_size=-1, path="/parse") + + +@pytest.mark.asyncio +async def test_declared_oversize_is_rejected_without_calling_downstream() -> None: + """Use Content-Length for safe early rejection before parser allocation.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) + messages = await _run_asgi( + middleware, + _http_scope(headers=[(b"content-length", b"6")]), + [{"type": "http.request", "body": b"123456", "more_body": False}], + ) + + assert downstream.calls == 0 + assert _status(messages) == 413 + assert _response_json(messages) == {"detail": "Payload Too Large"} + + +@pytest.mark.asyncio +async def test_actual_bytes_reject_understated_content_length() -> None: + """Count ASGI bytes so an understated header cannot bypass the admission cap.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) + messages = await _run_asgi( + middleware, + _http_scope(headers=[(b"content-length", b"1")]), + [ + {"type": "http.request", "body": b"1234", "more_body": True}, + {"type": "http.request", "body": b"56", "more_body": False}, + ], + ) + + assert downstream.calls == 1 + assert downstream.body == b"1234" + assert _status(messages) == 413 + assert _response_json(messages) == {"detail": "Payload Too Large"} + + +@pytest.mark.asyncio +async def test_exact_limit_passes_and_preserves_request_bytes() -> None: + """Admit an exact-limit body without changing downstream receive semantics.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) + messages = await _run_asgi( + middleware, + _http_scope(headers=[(b"content-length", b"invalid")]), + [ + {"type": "http.request", "body": b"12", "more_body": True}, + {"type": "http.request", "body": b"345", "more_body": False}, + ], + ) + + assert downstream.calls == 1 + assert downstream.body == b"12345" + assert _status(messages) == 204 + + +@pytest.mark.asyncio +async def test_disconnect_before_body_is_forwarded() -> None: + """Preserve non-body receive events on a selected request.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=5, + path="/parse", + ) + messages = await _run_asgi(middleware, _http_scope(), []) + + assert downstream.calls == 1 + assert downstream.body == b"" + assert _status(messages) == 204 + + +@pytest.mark.asyncio +async def test_unselected_route_bypasses_body_admission() -> None: + """Keep the compatibility boundary scoped to the parser upload route only.""" + + downstream = _BodyConsumer() + middleware = RequestBodyLimitMiddleware( + downstream, + max_body_size=1, + path="/parse", + ) + messages = await _run_asgi( + middleware, + _http_scope( + path="/health", + method="GET", + headers=[(b"content-length", b"6")], + ), + [{"type": "http.request", "body": b"123456", "more_body": False}], + ) + + assert downstream.calls == 1 + assert downstream.body == b"123456" + assert _status(messages) == 204 + + +@pytest.mark.asyncio +async def test_oversize_after_response_start_is_not_rewritten() -> None: + """Never emit a second status if a nonconforming downstream app already started.""" + + async def starts_before_reading( + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + await send({"type": "http.response.start", "status": 200, "headers": []}) + await receive() + + middleware = RequestBodyLimitMiddleware( + starts_before_reading, + max_body_size=1, + path="/parse", + ) + + with pytest.raises(RequestBodyTooLarge): + await _run_asgi( + middleware, + _http_scope(), + [{"type": "http.request", "body": b"12", "more_body": False}], + ) + + +def test_authentication_precedes_parse_body_admission() -> None: + """Reject unauthenticated parser traffic before body-limit admission runs.""" + + application = create_app( + RuntimeSettings( + authentication_mode=AuthenticationMode.REQUIRED, + runtime_profile=RuntimeProfile.PRODUCTION, + api_token="unit-test-token", + ) + ) + client = TestClient(application) + over_limit = str(MAX_PARSE_REQUEST_BYTES + 1) + + unauthorized = client.post( + "/parse", + headers={"Content-Length": over_limit}, + content=b"", + ) + assert unauthorized.status_code == 401 + assert unauthorized.headers["X-Content-Type-Options"] == "nosniff" + + authorized = client.post( + "/parse", + headers={ + "Authorization": "Bearer unit-test-token", + "Content-Length": over_limit, + }, + content=b"", + ) + assert authorized.status_code == 413 + assert authorized.json() == {"detail": "Payload Too Large"} + assert authorized.headers["X-Content-Type-Options"] == "nosniff" + assert authorized.headers["Cache-Control"] == "no-store, no-cache, max-age=0" diff --git a/tests/test_parse_endpoint.py b/tests/test_parse_endpoint.py index 1491ada0..0680ed1d 100644 --- a/tests/test_parse_endpoint.py +++ b/tests/test_parse_endpoint.py @@ -555,3 +555,37 @@ def spy_unlink(self, missing_ok=False): # We should have unlinked exactly one file, which should be in the temp directory assert len(unlinked_paths) == 1 assert "tmp" in unlinked_paths[0].lower() or "temp" in unlinked_paths[0].lower() + +def test_parse_endpoint_rejects_overlong_language_field(monkeypatch): + monkeypatch.setattr("newsdom_api.main._validate_pdf_structure", lambda _: None) + monkeypatch.setattr( + "newsdom_api.main.parse_pdf", + lambda *a, **k: {"document_id": "x", "pages": []}, + ) + + client = TestClient(app) + response = client.post( + "/parse", + files={"file": ("fixture.pdf", b"%PDF-1.4\n%synthetic\n", "application/pdf")}, + data={"language": "x" * 51}, + ) + + assert response.status_code == 422 + assert "detail" in response.json() + +def test_parse_endpoint_rejects_overlong_mode_field(monkeypatch): + monkeypatch.setattr("newsdom_api.main._validate_pdf_structure", lambda _: None) + monkeypatch.setattr( + "newsdom_api.main.parse_pdf", + lambda *a, **k: {"document_id": "x", "pages": []}, + ) + + client = TestClient(app) + response = client.post( + "/parse", + files={"file": ("fixture.pdf", b"%PDF-1.4\n%synthetic\n", "application/pdf")}, + data={"mode": "x" * 51}, + ) + + assert response.status_code == 422 + assert "detail" in response.json() diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 324cb086..6abb63b3 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -96,7 +96,7 @@ def test_security_dependency_floors_exclude_known_vulnerable_ranges(): dependencies_section = _dependencies_section(text) assert '"Pillow>=12.3,<13.0"' in dependencies_section - assert '"pypdf>=6.15.0,<7.0"' in dependencies_section + assert '"pypdf>=6.17.0,<7.0"' in dependencies_section assert 'requires = ["setuptools>=83", "wheel"]' in text @@ -202,4 +202,4 @@ def test_uv_lock_does_not_track_external_mineru_pipeline_runtime_stack(): def test_uv_lock_pins_pypdf_at_patched_release(): - assert _locked_package_version("pypdf") >= (6, 15, 0) + assert _locked_package_version("pypdf") >= (6, 17, 0) diff --git a/tests/test_pypdf_security_floor.py b/tests/test_pypdf_security_floor.py index 6a641e83..fa2d539a 100644 --- a/tests/test_pypdf_security_floor.py +++ b/tests/test_pypdf_security_floor.py @@ -6,9 +6,9 @@ import yaml -_REQUIRED_PYPDF_VERSION = (6, 15, 0) +_REQUIRED_PYPDF_VERSION = (6, 17, 0) _CURRENT_PYPDF_CVES = ("CVE-2026-71852", "CVE-2026-71870") -_LOCKED_PYPDF_REQUIREMENT = '{ name = "pypdf", specifier = ">=6.15.0,<7.0" },' +_LOCKED_PYPDF_REQUIREMENT = '{ name = "pypdf", specifier = ">=6.17.0,<7.0" },' def _locked_pypdf_version() -> tuple[int, ...]: @@ -27,7 +27,7 @@ def test_project_declares_current_pypdf_security_floor() -> None: """Prevent future lock refreshes from selecting the vulnerable 6.14.x line.""" project_text = Path("pyproject.toml").read_text(encoding="utf-8") - assert '"pypdf>=6.15.0,<7.0"' in project_text + assert '"pypdf>=6.17.0,<7.0"' in project_text def test_lock_uses_current_pypdf_security_release() -> None: @@ -61,7 +61,7 @@ def test_current_pypdf_advisories_and_floor_are_documented() -> None: for cve_id in _CURRENT_PYPDF_CVES: assert f"https://osv.dev/vulnerability/{cve_id}" in baseline - assert "`pypdf>=6.15.0,<7.0`" in changelog + assert "`pypdf>=6.17.0,<7.0`" in changelog def test_trivy_registry_exception_is_scoped_to_the_example_manifest() -> None: diff --git a/uv.lock b/uv.lock index a0d133b8..5b7b6ad0 100644 --- a/uv.lock +++ b/uv.lock @@ -303,7 +303,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -643,7 +643,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.9,<3.0" }, { name = "pyinstaller", marker = "extra == 'fuzz'", specifier = "==6.21.0" }, { name = "pymdown-extensions", marker = "extra == 'docs'", specifier = ">=11,<12" }, - { name = "pypdf", specifier = ">=6.15.0,<7.0" }, + { name = "pypdf", specifier = ">=6.17.0,<7.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3,<10.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0,<8.0" }, @@ -929,14 +929,14 @@ wheels = [ [[package]] name = "pypdf" -version = "6.15.0" +version = "6.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/dc/34857a5e31cf708c163929f61a9ba4bd357a8850e49fc4e846ced527b51f/pypdf-6.17.0.tar.gz", hash = "sha256:097ad0d829778ec5b615aeaa5c6da4b6cac4992f8fd80b56f98a1a8c006573bb", size = 7018352, upload-time = "2026-09-04T11:30:44.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/c1/08/1e9731038124a9127e1d27848952b86fb32b2f45f8f1b94adc7f0817a6ac/pypdf-6.17.0-py3-none-any.whl", hash = "sha256:5bd827266a21553b74d910e350131a6227b72f2ab4209bf372814b8195fa11c5", size = 388051, upload-time = "2026-09-04T11:30:42.681Z" }, ] [[package]]