From ac0c1508c2beb1d0af6cf968a3cef3ad7a4ec9eb Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 06:26:08 +1000 Subject: [PATCH 1/6] fix(ci): make Atheris fuzz job capable of failing (LAB-1140) Remove the blanket || true that swallowed import errors, libFuzzer crash exits, and hangs alike; route crash reproducers to tests/fuzzing/artifacts/ via -artifact_prefix and upload them on failure (the old gate inspected tests/fuzzing/corpus/, which nothing ever wrote to); fail the job when the fuzz_*.py glob matches nothing. Same shape as the Extended Fuzzing fix merged in #251 (LAB-1136). scripts/fuzz-python.sh gets the identical contract so 'make fuzz-quick' stops lying locally. --- .github/workflows/security-deep.yml | 57 +++++++++++++++++++---------- .gitignore | 3 ++ scripts/fuzz-python.sh | 20 +++++++--- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/.github/workflows/security-deep.yml b/.github/workflows/security-deep.yml index 46b074b..9a4bb06 100644 --- a/.github/workflows/security-deep.yml +++ b/.github/workflows/security-deep.yml @@ -147,31 +147,50 @@ jobs: run: | uv sync --group dev --group fuzz --python 3.11 + # Exit-code contract — no `|| true`, every non-zero exit fails the job: + # - 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. + # - `timeout 15m` 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 (e.g. stuck in + # native code where libFuzzer's own watchdog can't fire). + # - 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 15m uv run --python 3.11 python "$fuzz_target" \ + -max_total_time=600 -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/ + retention-days: 30 + if-no-files-found: warn miri-full: name: Miri Full Suite 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/scripts/fuzz-python.sh b/scripts/fuzz-python.sh index 7da8f32..93d4ed8 100755 --- a/scripts/fuzz-python.sh +++ b/scripts/fuzz-python.sh @@ -8,11 +8,21 @@ 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 + # Same exit-code contract as the atheris-fuzzing job in security-deep.yml + # (LAB-1140): no `|| true` — a crash, import error, or hang (timeout exit + # 124; budget exhaustion exits 0 well before 15 min) fails the run, and + # reproducers land in tests/fuzzing/artifacts/ (gitignored), which only + # ever holds crash artifacts since 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 15m uv run python "$fuzz_target" -max_total_time=600 -artifact_prefix=tests/fuzzing/artifacts/ done else echo "${YELLOW}⚠️ Atheris not available (macOS limitation - libFuzzer not in Apple Clang)${RESET}" From 585f84e73b8b83e4df57234cb3926689e208b2a9 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 06:42:17 +1000 Subject: [PATCH 2/6] fix(fuzz): repair Atheris targets that never survived startup (LAB-1140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the audit's 'zero fuzzing while green' finding (LAB-2528 Finding 1): atheris.instrument_imports() instrumented pydantic, whose instrumented bytecode segfaults CPython 3.11 in _decorators.merge_seqs during pydantic_settings CLI-provider model construction (imported transitively via cachekit.hiredis_compat). SIGSEGV during startup, before one fuzz iteration — swallowed by the workflow's || true every night. Fix: pre-import pydantic/pydantic_settings outside the instrumentation block; we fuzz cachekit's code, not third-party bytecode. The targets had also rotted against APIs deleted while they were dead: cachekit.serializers.raw.RawSerializer and decorators.main.redis_cache no longer exist, and EncryptionWrapper moved tenant_id to the constructor and grew mandatory cache_key AAD binding. Rewritten against the live API, same intent, stronger asserts (AAD wrong-key and cross-tenant decrypt must fail authentication). Verified locally: all three run clean 15 s (2.9M / 278k / 312k execs), and a deliberate crash drops its reproducer in tests/fuzzing/artifacts/ with a non-zero exit. --- tests/fuzzing/fuzz_decorator_stack.py | 59 ++++++++---------- tests/fuzzing/fuzz_encryption_wrapper.py | 78 ++++++++++++++---------- tests/fuzzing/fuzz_raw_serializer.py | 48 +++++++++------ 3 files changed, 101 insertions(+), 84 deletions(-) diff --git a/tests/fuzzing/fuzz_decorator_stack.py b/tests/fuzzing/fuzz_decorator_stack.py index 51ba4b8..3934d90 100644 --- a/tests/fuzzing/fuzz_decorator_stack.py +++ b/tests/fuzzing/fuzz_decorator_stack.py @@ -1,5 +1,5 @@ #!/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 @@ -7,45 +7,36 @@ import atheris +# Pre-import third-party deps so instrument_imports() below skips them +# (already in sys.modules = not instrumented). Atheris-instrumented pydantic +# bytecode segfaults CPython 3.11 in _decorators.merge_seqs during +# pydantic_settings' CLI-provider model construction (pulled in transitively +# via cachekit.hiredis_compat) — the SIGSEGV killed every nightly target +# during startup, before a single fuzz iteration (LAB-1140/LAB-2528). We fuzz +# cachekit's code; third-party coverage is not the goal. +import pydantic # noqa: F401 +import pydantic_settings # noqa: F401 + with atheris.instrument_imports(): - from cachekit.decorators.main import redis_cache - from cachekit.serializers.raw import RawSerializer + from cachekit import cache + + +# L1-only (backend=None): no network, deterministic — fuzzes the full +# decorator/key-generation/serialization/L1 stack on every call. +@cache(backend=None) +def _cached_identity(value: bytes) -> bytes: + return value def TestOneInput(data: bytes) -> None: - """Fuzz the complete cache decorator stack.""" + """Fuzz the decorator stack: miss path, then hit path, must both roundtrip.""" 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 may miss or hit L1; second call for the same args must hit. + # Both must return the payload byte-identically. + assert _cached_identity(payload) == payload, "Decorator roundtrip failed (first call)" + assert _cached_identity(payload) == payload, "Decorator roundtrip failed (cached call)" if __name__ == "__main__": diff --git a/tests/fuzzing/fuzz_encryption_wrapper.py b/tests/fuzzing/fuzz_encryption_wrapper.py index dc9183b..7d91777 100644 --- a/tests/fuzzing/fuzz_encryption_wrapper.py +++ b/tests/fuzzing/fuzz_encryption_wrapper.py @@ -3,53 +3,67 @@ from __future__ import annotations -import os import sys import uuid import atheris +# Pre-import third-party deps so instrument_imports() below skips them +# (already in sys.modules = not instrumented). Atheris-instrumented pydantic +# bytecode segfaults CPython 3.11 in _decorators.merge_seqs during +# pydantic_settings' CLI-provider model construction (pulled in transitively +# via cachekit.hiredis_compat) — the SIGSEGV killed every nightly target +# during startup, before a single fuzz iteration (LAB-1140/LAB-2528). We fuzz +# cachekit's code; third-party coverage is not the goal. +import pydantic # noqa: F401 +import pydantic_settings # noqa: F401 + 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) + assert decrypted == payload, "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: different tenant → different ciphertext, and + # cross-tenant decryption must fail authentication. + if tenant_a != tenant_b: + wrapper_b = EncryptionWrapper(master_key=_MASTER_KEY, tenant_id=tenant_b) + encrypted_b, _ = wrapper_b.serialize(payload, cache_key=cache_key) + assert encrypted != encrypted_b, "Tenant isolation failed: ciphertexts match" + try: + wrapper_b.deserialize(encrypted, metadata, 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 index 6b0f543..bf04571 100644 --- a/tests/fuzzing/fuzz_raw_serializer.py +++ b/tests/fuzzing/fuzz_raw_serializer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Atheris fuzz target for RawSerializer (Python → ByteStorage boundary).""" +"""Atheris fuzz target for ByteStorage (Python → Rust FFI boundary).""" from __future__ import annotations @@ -7,29 +7,41 @@ import atheris -with atheris.instrument_imports(): - from cachekit.serializers.raw import RawSerializer +# Pre-import third-party deps so instrument_imports() below skips them +# (already in sys.modules = not instrumented). Atheris-instrumented pydantic +# bytecode segfaults CPython 3.11 in _decorators.merge_seqs during +# pydantic_settings' CLI-provider model construction (pulled in transitively +# via cachekit.hiredis_compat) — the SIGSEGV killed every nightly target +# during startup, before a single fuzz iteration (LAB-1140/LAB-2528). We fuzz +# cachekit's code; third-party coverage is not the goal. +import pydantic # noqa: F401 +import pydantic_settings # noqa: F401 +with atheris.instrument_imports(): + from cachekit._rust_serializer import ByteStorage -def TestOneInput(data: bytes) -> None: - """Fuzz RawSerializer serialize/deserialize with various payloads.""" - fdp = atheris.FuzzedDataProvider(data) - try: - serializer = RawSerializer() +_STORAGE = ByteStorage("msgpack") - # Fuzz ByteStorage compression/decompression - payload = fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 4096)) - # Test serialize → deserialize roundtrip - compressed = serializer.serialize(payload) - decompressed = serializer.deserialize(compressed) +def TestOneInput(data: bytes) -> None: + """Fuzz the ByteStorage store/retrieve FFI roundtrip and hostile-envelope decode.""" + fdp = atheris.FuzzedDataProvider(data) - # Verify roundtrip - assert decompressed == payload, "Roundtrip failed: data mismatch" - except (ValueError, OverflowError, RuntimeError): - # Expected exceptions for malformed input - pass + 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) + assert bytes(retrieved) == payload, "ByteStorage roundtrip failed" + assert fmt == "msgpack", 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__": From 230f1c56815ab20d0c5a34591803b10961921117 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 31 Aug 2026 07:11:53 +1000 Subject: [PATCH 3/6] fix(fuzz): apply expert-panel findings (LAB-1140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel verdict FIX-FIRST; all accepted findings applied: - decorator target: bound L1 (max_size_mb=8) — default 100MB accounted budget reaches ~1.5GB real RSS over 600s (per-entry overhead uncounted), within 25% of libFuzzer's rss_limit_mb=2048 OOM kill; comments corrected to stop claiming serialization coverage L1-only mode doesn't run - all targets: pre-import shield broadened from pydantic-only to the full third-party set (numpy/pandas/pyarrow/redis/msgpack/xxhash/prometheus) — instrumented third-party bytecode is the proven startup-SIGSEGV class, one dep bump from a permanent-red nightly - encryption target: cross-tenant check now FORGES metadata (tenant_id + key_fingerprint) so it must die at the AES-GCM layer, not the unauthenticated metadata string compare - fuzz-python.sh: atheris probe used PATH python while targets run under uv — on Linux without an active venv it soft-skipped green having fuzzed nothing; now only Darwin soft-skips, everything else runs and fails loudly - both loops: timeout -k 30s (libFuzzer traps SIGTERM) + libFuzzer -timeout=60 per-input watchdog so a hang leaves a timeout-* reproducer - artifact retention 30d -> 7d (public repo: reproducer = ready-made PoC) - rename fuzz_raw_serializer.py -> fuzz_byte_storage.py (RawSerializer no longer exists); delete dead tests/fuzzing/corpus/ (nothing writes or reads it) Rejected (with reasons in PR): consolidating the CI loop into the script (AC pins 3.11 inline; LAB-1136 precedent is inline), trimming the tombstone comment (load-bearing per pragmatism review). --- .github/workflows/security-deep.yml | 24 ++++++--- scripts/fuzz-python.sh | 51 ++++++++++-------- tests/fuzzing/corpus/.gitignore | 2 - ...raw_serializer.py => fuzz_byte_storage.py} | 42 +++++++++++---- tests/fuzzing/fuzz_decorator_stack.py | 54 +++++++++++++------ tests/fuzzing/fuzz_encryption_wrapper.py | 46 +++++++++++----- 6 files changed, 151 insertions(+), 68 deletions(-) delete mode 100644 tests/fuzzing/corpus/.gitignore rename tests/fuzzing/{fuzz_raw_serializer.py => fuzz_byte_storage.py} (50%) diff --git a/.github/workflows/security-deep.yml b/.github/workflows/security-deep.yml index 9a4bb06..c6c378d 100644 --- a/.github/workflows/security-deep.yml +++ b/.github/workflows/security-deep.yml @@ -147,17 +147,22 @@ jobs: run: | uv sync --group dev --group fuzz --python 3.11 - # Exit-code contract — no `|| true`, every non-zero exit fails the job: + # 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. - # - `timeout 15m` 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 (e.g. stuck in - # native code where libFuzzer's own watchdog can't fire). + # - 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) @@ -171,8 +176,8 @@ jobs: fi for fuzz_target in "${targets[@]}"; do echo "Fuzzing $fuzz_target..." - timeout 15m uv run --python 3.11 python "$fuzz_target" \ - -max_total_time=600 -artifact_prefix=tests/fuzzing/artifacts/ + timeout -k 30s 15m uv run --python 3.11 python "$fuzz_target" \ + -max_total_time=600 -timeout=60 -artifact_prefix=tests/fuzzing/artifacts/ done # No "Report fuzzing results" step: now that `|| true` is gone, a crash @@ -189,7 +194,10 @@ jobs: with: name: atheris-crash-artifacts path: tests/fuzzing/artifacts/ - retention-days: 30 + # 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: diff --git a/scripts/fuzz-python.sh b/scripts/fuzz-python.sh index 93d4ed8..4df9aa7 100755 --- a/scripts/fuzz-python.sh +++ b/scripts/fuzz-python.sh @@ -6,25 +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}" - # Same exit-code contract as the atheris-fuzzing job in security-deep.yml - # (LAB-1140): no `|| true` — a crash, import error, or hang (timeout exit - # 124; budget exhaustion exits 0 well before 15 min) fails the run, and - # reproducers land in tests/fuzzing/artifacts/ (gitignored), which only - # ever holds crash artifacts since 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 15m uv run python "$fuzz_target" -max_total_time=600 -artifact_prefix=tests/fuzzing/artifacts/ - 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_raw_serializer.py b/tests/fuzzing/fuzz_byte_storage.py similarity index 50% rename from tests/fuzzing/fuzz_raw_serializer.py rename to tests/fuzzing/fuzz_byte_storage.py index bf04571..7f17bb5 100644 --- a/tests/fuzzing/fuzz_raw_serializer.py +++ b/tests/fuzzing/fuzz_byte_storage.py @@ -1,26 +1,48 @@ #!/usr/bin/env python3 -"""Atheris fuzz target for ByteStorage (Python → Rust FFI boundary).""" +"""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). Atheris-instrumented pydantic -# bytecode segfaults CPython 3.11 in _decorators.merge_seqs during -# pydantic_settings' CLI-provider model construction (pulled in transitively -# via cachekit.hiredis_compat) — the SIGSEGV killed every nightly target -# during startup, before a single fuzz iteration (LAB-1140/LAB-2528). We fuzz -# cachekit's code; third-party coverage is not the goal. -import pydantic # noqa: F401 -import pydantic_settings # noqa: F401 +# (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") diff --git a/tests/fuzzing/fuzz_decorator_stack.py b/tests/fuzzing/fuzz_decorator_stack.py index 3934d90..25b0a48 100644 --- a/tests/fuzzing/fuzz_decorator_stack.py +++ b/tests/fuzzing/fuzz_decorator_stack.py @@ -3,40 +3,64 @@ 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). Atheris-instrumented pydantic -# bytecode segfaults CPython 3.11 in _decorators.merge_seqs during -# pydantic_settings' CLI-provider model construction (pulled in transitively -# via cachekit.hiredis_compat) — the SIGSEGV killed every nightly target -# during startup, before a single fuzz iteration (LAB-1140/LAB-2528). We fuzz -# cachekit's code; third-party coverage is not the goal. -import pydantic # noqa: F401 -import pydantic_settings # noqa: F401 +# (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 import cache + from cachekit.config import DecoratorConfig + from cachekit.config.nested import L1CacheConfig -# L1-only (backend=None): no network, deterministic — fuzzes the full -# decorator/key-generation/serialization/L1 stack on every call. -@cache(backend=None) +# 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 decorator stack: miss path, then hit path, must both roundtrip.""" + """Fuzz the decorator stack: both calls must roundtrip byte-identically.""" fdp = atheris.FuzzedDataProvider(data) payload = bytes(fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 1024))) - # First call may miss or hit L1; second call for the same args must hit. - # Both must return the payload byte-identically. + # First call stores (or hits L1); the repeat exercises the hit path when + # the entry survived eviction. Both must return the payload unchanged. assert _cached_identity(payload) == payload, "Decorator roundtrip failed (first call)" - assert _cached_identity(payload) == payload, "Decorator roundtrip failed (cached call)" + assert _cached_identity(payload) == payload, "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 7d91777..0e1745b 100644 --- a/tests/fuzzing/fuzz_encryption_wrapper.py +++ b/tests/fuzzing/fuzz_encryption_wrapper.py @@ -3,20 +3,36 @@ from __future__ import annotations +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). Atheris-instrumented pydantic -# bytecode segfaults CPython 3.11 in _decorators.merge_seqs during -# pydantic_settings' CLI-provider model construction (pulled in transitively -# via cachekit.hiredis_compat) — the SIGSEGV killed every nightly target -# during startup, before a single fuzz iteration (LAB-1140/LAB-2528). We fuzz -# cachekit's code; third-party coverage is not the goal. -import pydantic # noqa: F401 -import pydantic_settings # noqa: F401 +# (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 ( @@ -52,14 +68,20 @@ def TestOneInput(data: bytes) -> None: except DecryptionAuthenticationError: pass - # Tenant isolation: different tenant → different ciphertext, and - # cross-tenant decryption must fail authentication. + # 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, _ = wrapper_b.serialize(payload, cache_key=cache_key) + encrypted_b, metadata_b = wrapper_b.serialize(payload, cache_key=cache_key) assert encrypted != encrypted_b, "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, metadata, cache_key=cache_key) + wrapper_b.deserialize(encrypted, forged, cache_key=cache_key) raise RuntimeError("Tenant isolation failed: cross-tenant decrypt succeeded") except DecryptionAuthenticationError: pass From e916dbf56b7e651b3a470a219cfa8e5cb4d170ef Mon Sep 17 00:00:00 2001 From: mark-s Date: Mon, 31 Aug 2026 10:16:46 +1000 Subject: [PATCH 4/6] fix(fuzz): make the oracles survive -O; clear the pip CVE reddening CI (LAB-1140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things stood between this PR and an honestly-green CI. 1. The fuzz oracles were `assert`, which the peephole optimiser strips under -O / PYTHONOPTIMIZE. A target run that way explores millions of inputs, verifies nothing, and reports no crashes — the same "green means nothing" failure this PR exists to remove, just reached by a different route. All six oracles across the three targets now raise AssertionError explicitly, so the type Atheris classifies and the artifact signature are unchanged while the check itself is no longer optional. Kody flagged only fuzz_byte_storage.py; the other two carried the identical latent fault and are already in this PR's diff, so fixing one and leaving two would have been a band-aid. Note the encryption target had already reached this conclusion for its AAD-binding and tenant-isolation oracles, which raise RuntimeError — the remaining asserts were the inconsistency. ruff's tests/** per-file-ignore of S101 is not evidence against this: it exists because pytest is built on assert, rewrites assertions, and never runs under -O. These targets are standalone scripts invoked as `uv run python `, where neither of those protections applies. 2. pip-audit reds the PR on PYSEC-2026-3721 — pip 26.1.2 mishandles doubly-encoded index URLs and can write outside the target directory when installing from a malicious index. Bumped the existing dev-only constraint-dependencies pin to pip>=26.2 (the mechanism and comment style already in place for urllib3/h2/werkzeug) and relocked. Verified against pip-audit directly: clean at 26.2. Verified: byte_storage target runs 9.5M iterations clean, and again under -O; ruff check and ruff format clean on tests/fuzzing/. --- pyproject.toml | 8 +++++--- tests/fuzzing/fuzz_byte_storage.py | 6 ++++-- tests/fuzzing/fuzz_decorator_stack.py | 6 ++++-- tests/fuzzing/fuzz_encryption_wrapper.py | 6 ++++-- uv.lock | 8 ++++---- 5 files changed, 21 insertions(+), 13 deletions(-) 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/tests/fuzzing/fuzz_byte_storage.py b/tests/fuzzing/fuzz_byte_storage.py index 7f17bb5..f271678 100644 --- a/tests/fuzzing/fuzz_byte_storage.py +++ b/tests/fuzzing/fuzz_byte_storage.py @@ -55,8 +55,10 @@ def TestOneInput(data: bytes) -> None: payload = bytes(fdp.ConsumeBytes(fdp.ConsumeIntInRange(0, 4096))) envelope = _STORAGE.store(payload, "msgpack") retrieved, fmt = _STORAGE.retrieve(envelope) - assert bytes(retrieved) == payload, "ByteStorage roundtrip failed" - assert fmt == "msgpack", f"format tag corrupted: {fmt}" + 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. diff --git a/tests/fuzzing/fuzz_decorator_stack.py b/tests/fuzzing/fuzz_decorator_stack.py index 25b0a48..ea2f45f 100644 --- a/tests/fuzzing/fuzz_decorator_stack.py +++ b/tests/fuzzing/fuzz_decorator_stack.py @@ -59,8 +59,10 @@ def TestOneInput(data: bytes) -> None: # First call stores (or hits L1); the repeat exercises the hit path when # the entry survived eviction. Both must return the payload unchanged. - assert _cached_identity(payload) == payload, "Decorator roundtrip failed (first call)" - assert _cached_identity(payload) == payload, "Decorator roundtrip failed (repeat call)" + 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 0e1745b..9804dff 100644 --- a/tests/fuzzing/fuzz_encryption_wrapper.py +++ b/tests/fuzzing/fuzz_encryption_wrapper.py @@ -59,7 +59,8 @@ def TestOneInput(data: bytes) -> None: # 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) - assert decrypted == payload, "Encryption roundtrip failed: data mismatch" + if decrypted != payload: + raise AssertionError("Encryption roundtrip failed: data mismatch") # AAD binding: a different cache_key must fail authentication. try: @@ -76,7 +77,8 @@ def TestOneInput(data: bytes) -> None: 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) - assert encrypted != encrypted_b, "Tenant isolation failed: ciphertexts match" + 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 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]] From 92f177aaec55bf67c79baaff92c83214c44db8a9 Mon Sep 17 00:00:00 2001 From: mark-s Date: Mon, 31 Aug 2026 10:29:08 +1000 Subject: [PATCH 5/6] docs(fuzz): say why the oracles raise instead of asserting (LAB-1140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expert-panel finding (craftsman, high stakes): the assert->raise rewrite is a deliberate deviation from this repo's own convention — pyproject.toml grants S101 to tests/** precisely so tests may assert freely — and a deviation with no stated reason gets "cleaned up" back to a one-line assert, at which point the oracle silently becomes strippable again and the fuzz job goes back to lying. One comment per target names the reason. The encryption target's comment also points at the AAD-binding and tenant-isolation oracles directly below it, which already raise — so the file reads as one consistent rule rather than two conventions. --- tests/fuzzing/fuzz_byte_storage.py | 2 ++ tests/fuzzing/fuzz_decorator_stack.py | 2 ++ tests/fuzzing/fuzz_encryption_wrapper.py | 3 +++ 3 files changed, 7 insertions(+) diff --git a/tests/fuzzing/fuzz_byte_storage.py b/tests/fuzzing/fuzz_byte_storage.py index f271678..47b0133 100644 --- a/tests/fuzzing/fuzz_byte_storage.py +++ b/tests/fuzzing/fuzz_byte_storage.py @@ -55,6 +55,8 @@ def TestOneInput(data: bytes) -> None: 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": diff --git a/tests/fuzzing/fuzz_decorator_stack.py b/tests/fuzzing/fuzz_decorator_stack.py index ea2f45f..637cfe4 100644 --- a/tests/fuzzing/fuzz_decorator_stack.py +++ b/tests/fuzzing/fuzz_decorator_stack.py @@ -59,6 +59,8 @@ def TestOneInput(data: bytes) -> None: # 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: diff --git a/tests/fuzzing/fuzz_encryption_wrapper.py b/tests/fuzzing/fuzz_encryption_wrapper.py index 9804dff..bd0d650 100644 --- a/tests/fuzzing/fuzz_encryption_wrapper.py +++ b/tests/fuzzing/fuzz_encryption_wrapper.py @@ -59,6 +59,9 @@ def TestOneInput(data: bytes) -> None: # 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") From d6bd3d1ef55919a6283b943c9007748d4ec0ef3b Mon Sep 17 00:00:00 2001 From: mark-s Date: Mon, 31 Aug 2026 10:38:40 +1000 Subject: [PATCH 6/6] docs(ci): sync the pip constraint comment the bump left stale (LAB-1140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit follow-up, and a fair catch: both pip-audit steps carry a comment naming the constraint versions, and each ends with "Keep this list IDENTICAL to so the two cannot drift." Bumping pyproject to pip>=26.2 without touching them is exactly the drift the comment exists to prevent. Both now read pip>=26.2. Also replaced "pinned" with "floored" — >= is a minimum, not an exact pin, and the old wording invited someone to go looking for a pin that was never there. --- .github/workflows/ci.yml | 2 +- .github/workflows/security-fast.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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-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.