diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 927d2ec..616e930 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,7 @@ jobs: - name: Scan Python dependencies for CVEs run: | # No suppressions: every prior CVE is resolved at source on the py3.10+ - # resolution. urllib3>=2.7.0 and pip>=26.1.2 are pinned via + # resolution. urllib3>=2.7.0 and pip>=26.2 are floored via # [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared # by their py3.10+ fix versions. Keep this list IDENTICAL to # security-fast.yml's pip-audit so the two cannot drift. diff --git a/.github/workflows/security-deep.yml b/.github/workflows/security-deep.yml index 46b074b..c6c378d 100644 --- a/.github/workflows/security-deep.yml +++ b/.github/workflows/security-deep.yml @@ -147,31 +147,58 @@ jobs: run: | uv sync --group dev --group fuzz --python 3.11 + # Exit-code contract — no `|| true`, every non-zero exit fails the job. + # (scripts/fuzz-python.sh carries the same contract for local runs; keep + # the budgets and flags in the two files in sync.) + # - Pass is libFuzzer exiting 0 when -max_total_time=600 expires cleanly. + # - A crash, OOM, or uncaught Python exception exits non-zero and fails + # the job. atheris.Setup(sys.argv, ...) forwards flags to libFuzzer, so + # -artifact_prefix drops the reproducer in tests/fuzzing/artifacts/. + # No corpus dir is passed, so that directory only ever holds crash + # artifacts — a file there is always a finding, never corpus growth. + # - A hung input is caught by libFuzzer's own per-input watchdog + # (-timeout=60), which writes a timeout-* reproducer and exits + # non-zero. `timeout -k 30s 15m` is the backstop for hangs in native + # code where that watchdog can't fire: exit 124 is a deliberate + # FAILURE, not budget exhaustion — the fuzz budget is 600 s and budget + # exhaustion exits 0 above, so a target still alive at 15 min is hung + # (and libFuzzer traps SIGTERM, hence the -k hard kill). + # - Zero matched targets fails the job: a glob that stops matching must + # not "pass" having fuzzed nothing (LAB-1136 post-mortem pattern). - name: Run Atheris fuzz targets (10 min each) run: | - for fuzz_target in tests/fuzzing/fuzz_*.py; do - if [ -f "$fuzz_target" ]; then - echo "Fuzzing $fuzz_target..." - timeout 10m uv run --python 3.11 python "$fuzz_target" -max_total_time=600 || true - fi + mkdir -p tests/fuzzing/artifacts + shopt -s nullglob + targets=(tests/fuzzing/fuzz_*.py) + if [ "${#targets[@]}" -eq 0 ]; then + echo "::error::no Atheris targets matched tests/fuzzing/fuzz_*.py — refusing to pass having fuzzed nothing" + exit 1 + fi + for fuzz_target in "${targets[@]}"; do + echo "Fuzzing $fuzz_target..." + timeout -k 30s 15m uv run --python 3.11 python "$fuzz_target" \ + -max_total_time=600 -timeout=60 -artifact_prefix=tests/fuzzing/artifacts/ done - - name: Upload crash corpus - if: always() + # No "Report fuzzing results" step: now that `|| true` is gone, a crash + # fails its own fuzz step, so a trailing check could only ever run in the + # no-crash case and print ✅ — a named green step incapable of failing, + # the manufactured-evidence pattern the Extended Fuzzing job above already + # removed. (The old gate also inspected tests/fuzzing/corpus/, a directory + # nothing ever wrote to.) The fuzz steps' exit codes are the signal; this + # upload preserves the reproducer, which would otherwise die with the + # ephemeral runner. + - name: Upload crash artifacts + if: failure() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: atheris-crashes-${{ github.run_id }} - path: tests/fuzzing/corpus/ - - - name: Report fuzzing results - if: always() - run: | - if [ -d tests/fuzzing/corpus/ ] && find tests/fuzzing/corpus/ -mindepth 1 ! -name '.gitignore' -print -quit | grep -q .; then - echo "⚠️ Crashes discovered during Atheris fuzzing!" - ls -lh tests/fuzzing/corpus/ - exit 1 - fi - echo "✅ No crashes discovered during Atheris fuzzing" + name: atheris-crash-artifacts + path: tests/fuzzing/artifacts/ + # Public repo: a crash reproducer is a ready-made PoC for anyone while + # it is retrievable. 7 days covers triage of a nightly red without + # leaving a month-long public exploit window. + retention-days: 7 + if-no-files-found: warn miri-full: name: Miri Full Suite diff --git a/.github/workflows/security-fast.yml b/.github/workflows/security-fast.yml index eaa91e0..d159176 100644 --- a/.github/workflows/security-fast.yml +++ b/.github/workflows/security-fast.yml @@ -91,7 +91,7 @@ jobs: - name: Run pip-audit run: | # No suppressions: every prior CVE is resolved at source on the py3.10+ - # resolution. urllib3>=2.7.0 and pip>=26.1.2 are pinned via + # resolution. urllib3>=2.7.0 and pip>=26.2 are floored via # [tool.uv] constraint-dependencies; pygments/pyarrow advisories cleared # by their py3.10+ fix versions. Keep this list IDENTICAL to ci.yml's # post-merge pip-audit so the two cannot drift. diff --git a/.gitignore b/.gitignore index 791b7ef..d58c577 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ target/ benchmark_results/ *.benchmark +# Atheris/libFuzzer crash reproducers (see -artifact_prefix in scripts/fuzz-python.sh) +tests/fuzzing/artifacts/ + # Temporary files *.log logs/ diff --git a/pyproject.toml b/pyproject.toml index 3860e79..2a05a27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -247,10 +247,12 @@ constraint-dependencies = [ "urllib3>=2.7.0", "fonttools>=4.60.2", "werkzeug>=3.1.4", - # pip is a dev-only transitive dep (pip-audit -> pip-api -> pip). 26.1.2 fixes + # pip is a dev-only transitive dep (pip-audit -> pip-api -> pip). 26.1.2 fixed # PYSEC-2026-196 (entry-point path traversal), GHSA-58qw-9mgm-455v (tar/zip - # confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering). - "pip>=26.1.2", + # confusion) and GHSA-jp4c-xjxw-mgf9 (self-update import ordering); 26.2 adds + # PYSEC-2026-3721 (doubly-encoded index URLs escaping the target directory — + # arbitrary write on install from a malicious index). + "pip>=26.2", # h2 is a transitive dep (httpx[http2] -> h2). 4.4.1 fixes # GHSA-6hr6-w5qg-qmwg (duplicate Host headers forwarded on HTTP/2 -> # HTTP/1.1 downgrade — request smuggling primitive). diff --git a/scripts/fuzz-python.sh b/scripts/fuzz-python.sh index 7da8f32..4df9aa7 100755 --- a/scripts/fuzz-python.sh +++ b/scripts/fuzz-python.sh @@ -6,15 +6,34 @@ set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" -if command -v python &>/dev/null && python -c "import atheris" 2>/dev/null; then - echo "${BLUE}Running Atheris fuzzing...${RESET}" - for fuzz_target in tests/fuzzing/fuzz_*.py; do - if [ -f "$fuzz_target" ]; then - echo "${YELLOW}Fuzzing $fuzz_target...${RESET}" - timeout 10m uv run python "$fuzz_target" -max_total_time=600 || true - fi - done -else - echo "${YELLOW}⚠️ Atheris not available (macOS limitation - libFuzzer not in Apple Clang)${RESET}" - echo "${YELLOW} Atheris fuzzing will run in CI on Linux${RESET}" +# macOS is the ONLY soft skip (Apple Clang ships no libFuzzer, so atheris +# cannot work there). Everywhere else the targets run unconditionally: if +# atheris is missing, the target's own import fails loudly and reds the run — +# probing for it first and skipping (the old behavior) was a silent green on +# Linux, the exact lie this contract exists to kill (LAB-1140). +if [ "$(uname -s)" = "Darwin" ]; then + echo "${YELLOW}⚠️ Skipping Atheris fuzzing (macOS limitation - libFuzzer not in Apple Clang)${RESET}" + echo "${YELLOW} Atheris fuzzing runs in CI on Linux${RESET}" + exit 0 fi + +echo "${BLUE}Running Atheris fuzzing...${RESET}" +# Same exit-code contract as the atheris-fuzzing job in security-deep.yml +# (LAB-1140) — keep budgets/flags in sync with it: no `|| true`; a crash, +# import error, or hang fails the run (libFuzzer's -timeout=60 per-input +# watchdog writes a timeout-* reproducer; `timeout -k 30s 15m` is the +# backstop for native hangs, since libFuzzer traps SIGTERM; budget exhaustion +# exits 0 well before 15 min). Reproducers land in tests/fuzzing/artifacts/ +# (gitignored), which only ever holds crash artifacts — no corpus dir is +# passed. +mkdir -p tests/fuzzing/artifacts +shopt -s nullglob +targets=(tests/fuzzing/fuzz_*.py) +if [ "${#targets[@]}" -eq 0 ]; then + echo "${YELLOW}no Atheris targets matched tests/fuzzing/fuzz_*.py — refusing to pass having fuzzed nothing${RESET}" >&2 + exit 1 +fi +for fuzz_target in "${targets[@]}"; do + echo "${YELLOW}Fuzzing $fuzz_target...${RESET}" + timeout -k 30s 15m uv run python "$fuzz_target" -max_total_time=600 -timeout=60 -artifact_prefix=tests/fuzzing/artifacts/ +done diff --git a/tests/fuzzing/corpus/.gitignore b/tests/fuzzing/corpus/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fuzzing/corpus/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fuzzing/fuzz_byte_storage.py b/tests/fuzzing/fuzz_byte_storage.py new file mode 100644 index 0000000..47b0133 --- /dev/null +++ b/tests/fuzzing/fuzz_byte_storage.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Atheris fuzz target for ByteStorage (Python → Rust FFI boundary). + +This guards the FFI binding contract: roundtrip fidelity, and that hostile +envelopes surface as clean ValueError — never an interpreter crash or a +pyo3 PanicException. Coverage-guided exploration of the Rust decode surface +itself lives in `cargo fuzz run byte_storage_decompress` (rust/fuzz/); the +native .so is invisible to Atheris' bytecode instrumentation, so this +target's mutation is unguided by design. +""" + +from __future__ import annotations + +import contextlib +import importlib +import sys + +import atheris + +# Pre-import third-party deps so instrument_imports() below skips them +# (already in sys.modules = not instrumented) — we fuzz cachekit's code; +# third-party coverage is not the goal, and atheris-instrumented third-party +# bytecode is a proven startup-crash class: instrumented pydantic segfaults +# CPython 3.11 in _decorators.merge_seqs during pydantic_settings' +# CLI-provider model construction (pulled in transitively via +# cachekit.hiredis_compat). That SIGSEGV killed every nightly target during +# startup, before a single fuzz iteration (LAB-1140/LAB-2528). Optional deps +# use suppress: absent is fine, instrumented is the trap. +for _mod in ( + "pydantic", + "pydantic_settings", + "numpy", + "pandas", + "pyarrow", + "redis", + "msgpack", + "xxhash", + "prometheus_client", +): + with contextlib.suppress(ImportError): + importlib.import_module(_mod) + +with atheris.instrument_imports(): + from cachekit._rust_serializer import ByteStorage + +_STORAGE = ByteStorage("msgpack") + + +def TestOneInput(data: bytes) -> None: + """Fuzz the ByteStorage store/retrieve FFI roundtrip and hostile-envelope decode.""" + fdp = atheris.FuzzedDataProvider(data) + + if fdp.ConsumeBool(): + # Roundtrip: store must retrieve byte-identically. + payload = bytes(fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 4096))) + envelope = _STORAGE.store(payload, "msgpack") + retrieved, fmt = _STORAGE.retrieve(envelope) + # Explicit raise, not assert: -O / PYTHONOPTIMIZE strips assert, which + # would leave this target reporting no crashes while verifying nothing. + if bytes(retrieved) != payload: + raise AssertionError("ByteStorage roundtrip failed") + if fmt != "msgpack": + raise AssertionError(f"format tag corrupted: {fmt}") + else: + # Attacker-controlled envelope (cache content is untrusted): must + # raise cleanly, never crash the interpreter. + try: + _STORAGE.retrieve(bytes(fdp.ConsumeBytes(fdp.remaining_bytes()))) + except ValueError: + pass + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() diff --git a/tests/fuzzing/fuzz_decorator_stack.py b/tests/fuzzing/fuzz_decorator_stack.py index 51ba4b8..637cfe4 100644 --- a/tests/fuzzing/fuzz_decorator_stack.py +++ b/tests/fuzzing/fuzz_decorator_stack.py @@ -1,51 +1,70 @@ #!/usr/bin/env python3 -"""Atheris fuzz target for decorator stack (full integration).""" +"""Atheris fuzz target for the cache decorator stack (L1-only integration).""" from __future__ import annotations +import contextlib +import importlib import sys import atheris +# Pre-import third-party deps so instrument_imports() below skips them +# (already in sys.modules = not instrumented) — we fuzz cachekit's code; +# third-party coverage is not the goal, and atheris-instrumented third-party +# bytecode is a proven startup-crash class: instrumented pydantic segfaults +# CPython 3.11 in _decorators.merge_seqs during pydantic_settings' +# CLI-provider model construction (pulled in transitively via +# cachekit.hiredis_compat). That SIGSEGV killed every nightly target during +# startup, before a single fuzz iteration (LAB-1140/LAB-2528). Optional deps +# use suppress: absent is fine, instrumented is the trap. +for _mod in ( + "pydantic", + "pydantic_settings", + "numpy", + "pandas", + "pyarrow", + "redis", + "msgpack", + "xxhash", + "prometheus_client", +): + with contextlib.suppress(ImportError): + importlib.import_module(_mod) + with atheris.instrument_imports(): - from cachekit.decorators.main import redis_cache - from cachekit.serializers.raw import RawSerializer + from cachekit import cache + from cachekit.config import DecoratorConfig + from cachekit.config.nested import L1CacheConfig + + +# L1-only (backend=None): no network, deterministic — exercises the +# decorator / key-generation / ObjectCache (L1) stack on every call. In this +# mode values are stored as raw Python objects (no serializer runs). +# +# max_size_mb=8: L1's byte accounting counts only getsizeof(value); the +# ~450 B of real per-entry overhead (key string + entry bookkeeping) is +# uncounted, so the default 100 MB budget reaches ~1.5 GB real RSS over a +# 600 s run — inside libFuzzer's default -rss_limit_mb=2048 OOM kill. 8 MB +# accounted keeps real RSS comfortably bounded. +@cache(config=DecoratorConfig(backend=None, l1=L1CacheConfig(max_size_mb=8, swr_enabled=False))) +def _cached_identity(value: bytes) -> bytes: + return value def TestOneInput(data: bytes) -> None: - """Fuzz the complete cache decorator stack.""" + """Fuzz the decorator stack: both calls must roundtrip byte-identically.""" fdp = atheris.FuzzedDataProvider(data) + payload = bytes(fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 1024))) - try: - # Generate test function names to avoid collision - func_id = fdp.ConsumeIntInRange(0, 1000000) - - # Create a simple cached function with RawSerializer - @redis_cache( - redis_url="redis://localhost:6379", - serializer=RawSerializer(), - default_ttl=3600, - ) - def cached_func(value: bytes) -> bytes: - """Simple cached function that returns input.""" - return value - - # Test with random payload - payload = fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 1024)) - - # Attempt to call the function - # May fail if Redis is unavailable, which is expected - try: - result = cached_func(payload) - # If it works, verify roundtrip - assert result == payload, "Decorator roundtrip failed" - except (ConnectionError, TimeoutError, OSError): - # Expected when Redis is unavailable - pass - - except (ValueError, OverflowError, RuntimeError, AttributeError, TypeError): - # Expected exceptions for malformed input or missing Redis - pass + # First call stores (or hits L1); the repeat exercises the hit path when + # the entry survived eviction. Both must return the payload unchanged. + # Explicit raise, not assert: -O / PYTHONOPTIMIZE strips assert, which would + # leave this target reporting no crashes while verifying nothing. + if _cached_identity(payload) != payload: + raise AssertionError("Decorator roundtrip failed (first call)") + if _cached_identity(payload) != payload: + raise AssertionError("Decorator roundtrip failed (repeat call)") if __name__ == "__main__": diff --git a/tests/fuzzing/fuzz_encryption_wrapper.py b/tests/fuzzing/fuzz_encryption_wrapper.py index dc9183b..bd0d650 100644 --- a/tests/fuzzing/fuzz_encryption_wrapper.py +++ b/tests/fuzzing/fuzz_encryption_wrapper.py @@ -3,53 +3,94 @@ from __future__ import annotations -import os +import contextlib +import copy +import importlib import sys import uuid import atheris +# Pre-import third-party deps so instrument_imports() below skips them +# (already in sys.modules = not instrumented) — we fuzz cachekit's code; +# third-party coverage is not the goal, and atheris-instrumented third-party +# bytecode is a proven startup-crash class: instrumented pydantic segfaults +# CPython 3.11 in _decorators.merge_seqs during pydantic_settings' +# CLI-provider model construction (pulled in transitively via +# cachekit.hiredis_compat). That SIGSEGV killed every nightly target during +# startup, before a single fuzz iteration (LAB-1140/LAB-2528). Optional deps +# use suppress: absent is fine, instrumented is the trap. +for _mod in ( + "pydantic", + "pydantic_settings", + "numpy", + "pandas", + "pyarrow", + "redis", + "msgpack", + "xxhash", + "prometheus_client", +): + with contextlib.suppress(ImportError): + importlib.import_module(_mod) + with atheris.instrument_imports(): - from cachekit.serializers.encryption_wrapper import EncryptionWrapper + from cachekit.serializers.encryption_wrapper import ( + DecryptionAuthenticationError, + EncryptionWrapper, + ) + +# Fixed test key for reproducibility (mirrors the doctest fixtures). +_MASTER_KEY = b"0" * 32 def TestOneInput(data: bytes) -> None: - """Fuzz EncryptionWrapper encrypt/decrypt with tenant isolation.""" + """Fuzz encrypt/decrypt roundtrip, AAD cache-key binding, and tenant isolation.""" fdp = atheris.FuzzedDataProvider(data) - # Get master key from environment or use a test key - master_key_hex = os.environ.get("CACHEKIT_MASTER_KEY") - if master_key_hex: - master_key = bytes.fromhex(master_key_hex) - else: - # Use a fixed test key for reproducibility - master_key = b"0" * 32 + payload = bytes(fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 4096))) + tenant_a = str(uuid.UUID(bytes=bytes(fdp.ConsumeBytes(16)).ljust(16, b"\0"))) + tenant_b = str(uuid.UUID(bytes=bytes(fdp.ConsumeBytes(16)).ljust(16, b"\0"))) + # Prefix guarantees the non-empty cache_key serialize() requires. + cache_key = "ns:fuzz:" + fdp.ConsumeUnicodeNoSurrogates(64) + + wrapper_a = EncryptionWrapper(master_key=_MASTER_KEY, tenant_id=tenant_a) + # Roundtrip under the same tenant + cache_key must be lossless. + encrypted, metadata = wrapper_a.serialize(payload, cache_key=cache_key) + decrypted = wrapper_a.deserialize(encrypted, metadata, cache_key=cache_key) + # Explicit raise, not assert: -O / PYTHONOPTIMIZE strips assert, which would + # leave this target reporting no crashes while verifying nothing. Matches + # the AAD-binding and tenant-isolation oracles below, which already raise. + if decrypted != payload: + raise AssertionError("Encryption roundtrip failed: data mismatch") + + # AAD binding: a different cache_key must fail authentication. try: - serializer = EncryptionWrapper(master_key=master_key) - - # Fuzz payload and tenant ID - payload = fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 4096)) - tenant_id_bytes = fdp.ConsumeBytes(16) - tenant_id = str(uuid.UUID(bytes=tenant_id_bytes)) - - # Test encryption roundtrip - encrypted = serializer.serialize(payload, tenant_id=tenant_id) - decrypted = serializer.deserialize(encrypted, tenant_id=tenant_id) - - # Verify roundtrip - assert decrypted == payload, "Roundtrip failed: data mismatch" - - # Test tenant isolation: same data with different tenant_id should produce different ciphertext - other_tenant_bytes = fdp.ConsumeBytes(16) - other_tenant_id = str(uuid.UUID(bytes=other_tenant_bytes)) - if tenant_id != other_tenant_id: - encrypted_other = serializer.serialize(payload, tenant_id=other_tenant_id) - assert encrypted != encrypted_other, "Tenant isolation failed: ciphertexts match" - except (ValueError, OverflowError, RuntimeError, AttributeError): - # Expected exceptions for malformed input + wrapper_a.deserialize(encrypted, metadata, cache_key=cache_key + "x") + raise RuntimeError("AAD binding failed: decrypt succeeded with wrong cache_key") + except DecryptionAuthenticationError: pass + # Tenant isolation. Metadata is cleartext an attacker controls, so the + # honest cross-tenant check FORGES it (tenant_id + key_fingerprint claim + # tenant B) — that gets past the unauthenticated metadata comparisons and + # must still die at the AES-GCM layer, where tenant separation is real + # (HKDF tenant-derived key + tenant-bound AAD). + if tenant_a != tenant_b: + wrapper_b = EncryptionWrapper(master_key=_MASTER_KEY, tenant_id=tenant_b) + encrypted_b, metadata_b = wrapper_b.serialize(payload, cache_key=cache_key) + if encrypted == encrypted_b: + raise AssertionError("Tenant isolation failed: ciphertexts match") + forged = copy.copy(metadata) + forged.tenant_id = metadata_b.tenant_id + forged.key_fingerprint = metadata_b.key_fingerprint + try: + wrapper_b.deserialize(encrypted, forged, cache_key=cache_key) + raise RuntimeError("Tenant isolation failed: cross-tenant decrypt succeeded") + except DecryptionAuthenticationError: + pass + if __name__ == "__main__": atheris.Setup(sys.argv, TestOneInput) diff --git a/tests/fuzzing/fuzz_raw_serializer.py b/tests/fuzzing/fuzz_raw_serializer.py deleted file mode 100644 index 6b0f543..0000000 --- a/tests/fuzzing/fuzz_raw_serializer.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python3 -"""Atheris fuzz target for RawSerializer (Python → ByteStorage boundary).""" - -from __future__ import annotations - -import sys - -import atheris - -with atheris.instrument_imports(): - from cachekit.serializers.raw import RawSerializer - - -def TestOneInput(data: bytes) -> None: - """Fuzz RawSerializer serialize/deserialize with various payloads.""" - fdp = atheris.FuzzedDataProvider(data) - - try: - serializer = RawSerializer() - - # Fuzz ByteStorage compression/decompression - payload = fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 4096)) - - # Test serialize → deserialize roundtrip - compressed = serializer.serialize(payload) - decompressed = serializer.deserialize(compressed) - - # Verify roundtrip - assert decompressed == payload, "Roundtrip failed: data mismatch" - except (ValueError, OverflowError, RuntimeError): - # Expected exceptions for malformed input - pass - - -if __name__ == "__main__": - atheris.Setup(sys.argv, TestOneInput) - atheris.Fuzz() diff --git a/uv.lock b/uv.lock index 4f9df25..0281576 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ resolution-markers = [ constraints = [ { name = "fonttools", specifier = ">=4.60.2" }, { name = "h2", specifier = ">=4.4.1" }, - { name = "pip", specifier = ">=26.1.2" }, + { name = "pip", specifier = ">=26.2" }, { name = "urllib3", specifier = ">=2.7.0" }, { name = "werkzeug", specifier = ">=3.1.4" }, ] @@ -1283,11 +1283,11 @@ wheels = [ [[package]] name = "pip" -version = "26.1.2" +version = "26.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/91/47e7d486260f618783899587af63ccf7980fb60245c3e63dd4571c6b57ad/pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605", size = 1840799, upload-time = "2026-05-31T17:33:58.56Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/95/6b5cb3461ea5673ba0995989746db58eb18b91b54dbf331e72f569540946/pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab", size = 1813144, upload-time = "2026-05-31T17:33:56.772Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, ] [[package]]