diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fbca17..34928f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,7 +165,12 @@ jobs: run: uv sync --python ${{ env.DEFAULT_PYTHON_VERSION }} --group dev - name: Scan Python dependencies for CVEs - run: uv run pip-audit --desc + run: | + # GHSA-5239-wwwm-4pmq: pygments ReDoS in AdlLexer (dev-only, no fix available) + # GHSA-58qw-9mgm-455v: pip tar/zip confusion (pip itself, no fix available) + uv run pip-audit --desc \ + --ignore-vuln GHSA-5239-wwwm-4pmq \ + --ignore-vuln GHSA-58qw-9mgm-455v - name: Run markdown documentation tests run: make test-docs-examples diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6b57c7b..9596c58 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -17,8 +17,8 @@ on: pull_request: branches: [main] schedule: - # Weekly deep scan on Sunday at 3am UTC - - cron: "0 3 * * 0" + # Weekly deep scan: 20:00 UTC Sunday = 7 AM AEDT Monday + - cron: "0 20 * * 0" workflow_dispatch: # Manual trigger for ad-hoc security analysis @@ -35,7 +35,7 @@ concurrency: jobs: analyze: name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 30 strategy: diff --git a/.github/workflows/fuzz-smoke.yml b/.github/workflows/fuzz-smoke.yml index 7151be2..2de85fc 100644 --- a/.github/workflows/fuzz-smoke.yml +++ b/.github/workflows/fuzz-smoke.yml @@ -22,22 +22,14 @@ concurrency: jobs: fuzz-smoke: name: Fuzz Smoke Test (60s per target) - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install Rust nightly - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # nightly - with: - toolchain: nightly - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: rust/fuzz - cache-all-crates: true + run: rustup toolchain install nightly - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main diff --git a/.github/workflows/security-deep.yml b/.github/workflows/security-deep.yml index 15ec3a4..5ca3be9 100644 --- a/.github/workflows/security-deep.yml +++ b/.github/workflows/security-deep.yml @@ -2,7 +2,7 @@ name: Security Deep on: schedule: - - cron: '0 2 * * *' # 2 AM daily + - cron: '0 20 * * *' # 20:00 UTC = 7 AM AEDT next day workflow_dispatch: # Allow manual triggers permissions: @@ -16,20 +16,27 @@ jobs: # Deep security analysis (< 2 hours) - nightly scheduled kani-verification: name: Kani Formal Verification - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 30 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Ensure stable toolchain + run: rustup toolchain install stable + + - name: Install Kani + run: | + cargo +stable install --locked kani-verifier --version 0.67.0 + cargo-kani setup + - name: Run Kani verification - uses: model-checking/kani-github-action@f838096619a707b0f6b2118cf435eaccfa33e51f # v1 - with: - working-directory: rust - args: --tests --no-default-features --features compression,checksum,messagepack,encryption + run: | + cd rust + cargo-kani --tests --no-default-features --features compression,checksum,messagepack,encryption fuzzing: name: Extended Fuzzing (3 targets × 1h) - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 200 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -39,11 +46,6 @@ jobs: rustup toolchain install nightly rustup default nightly - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: rust - - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main @@ -86,37 +88,11 @@ jobs: atheris-fuzzing: name: Atheris Python-Rust Fuzzing - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 60 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Install uv - uses: astral-sh/setup-uv@8d55fbecc275b1c35dbe060458839f8d30439ccf # v3 - with: - enable-cache: true - - - name: Set up Python - run: uv python install 3.11 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: rust - - - name: Cache Python virtual environment - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 - with: - path: .venv - key: venv-${{ runner.os }}-py3.11-${{ hashFiles('**/pyproject.toml', '**/uv.lock') }} - restore-keys: | - venv-${{ runner.os }}-py3.11- - - name: Install dependencies run: | uv sync --group dev --group fuzz @@ -149,7 +125,7 @@ jobs: miri-full: name: Miri Full Suite - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 30 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -159,11 +135,6 @@ jobs: rustup toolchain install nightly --component miri rustup default nightly - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: rust - - name: Run full Miri test suite run: | cd rust @@ -171,7 +142,7 @@ jobs: sanitizers: name: Sanitizers (ASan, TSan, MSan) - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 40 strategy: fail-fast: false @@ -189,12 +160,6 @@ jobs: if: matrix.sanitizer == 'memory' || matrix.sanitizer == 'thread' run: rustup component add rust-src --toolchain nightly - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: rust - key: ${{ matrix.sanitizer }} - - name: Run AddressSanitizer if: matrix.sanitizer == 'address' env: @@ -225,7 +190,7 @@ jobs: generate-security-report: name: Generate Security Report - runs-on: ubuntu-latest + runs-on: cachekit needs: [kani-verification, fuzzing, atheris-fuzzing, miri-full, sanitizers] if: always() steps: diff --git a/.github/workflows/security-fast.yml b/.github/workflows/security-fast.yml index 23fade4..2aa4d0a 100644 --- a/.github/workflows/security-fast.yml +++ b/.github/workflows/security-fast.yml @@ -24,7 +24,7 @@ jobs: # Fast security checks (< 3 min) - parallel execution cargo-audit: name: Vulnerability Scan - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -35,31 +35,24 @@ jobs: cargo-deny: name: License & Supply Chain - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2 + - name: Install cargo-deny + run: cargo install --locked cargo-deny + + - name: Run cargo-deny + run: cargo deny --all-features check clippy-security: name: Security Lints - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable - components: clippy - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: rust - - name: Run Clippy security lints run: | cd rust @@ -68,16 +61,11 @@ jobs: cargo-machete: name: Unused Dependencies - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable - - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main @@ -91,27 +79,11 @@ jobs: pip-audit: name: Python Dependency CVEs - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Install uv - uses: astral-sh/setup-uv@8d55fbecc275b1c35dbe060458839f8d30439ccf # v3 - with: - enable-cache: true - - - name: Set up Python - run: uv python install 3.12 - - - name: Cache Python virtual environment - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 - with: - path: .venv - key: venv-${{ runner.os }}-py3.12-${{ hashFiles('**/pyproject.toml', '**/uv.lock') }} - restore-keys: | - venv-${{ runner.os }}-py3.12- - - name: Install dependencies run: | uv sync --group dev @@ -119,8 +91,10 @@ jobs: - name: Run pip-audit run: | # GHSA-5239-wwwm-4pmq: pygments ReDoS in AdlLexer (dev-only, no fix available) + # GHSA-58qw-9mgm-455v: pip tar/zip confusion (pip itself, no fix available) uv run pip-audit --desc --format json --output pip-audit-report.json \ - --ignore-vuln GHSA-5239-wwwm-4pmq + --ignore-vuln GHSA-5239-wwwm-4pmq \ + --ignore-vuln GHSA-58qw-9mgm-455v - name: Upload report if: always() diff --git a/.github/workflows/security-medium.yml b/.github/workflows/security-medium.yml index 692d216..94fccd8 100644 --- a/.github/workflows/security-medium.yml +++ b/.github/workflows/security-medium.yml @@ -20,16 +20,11 @@ jobs: # Medium security checks (< 15 min) - post-merge validation cargo-geiger: name: Unsafe Code Tracking - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up Rust - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # stable - with: - toolchain: stable - - name: Install cargo-binstall uses: cargo-bins/cargo-binstall@18470a17439d5a7ec5f5ab40c95a6f0b217e652e # main @@ -76,7 +71,7 @@ jobs: miri-subset: name: Miri UB Detection (Subset) - runs-on: ubuntu-latest + runs-on: cachekit timeout-minutes: 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -86,11 +81,6 @@ jobs: rustup toolchain install nightly --component miri rustup default nightly - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: rust - - name: Run Miri on byte_storage module run: | cd rust diff --git a/docs/backends/redis.md b/docs/backends/redis.md index 416e574..64107e4 100644 --- a/docs/backends/redis.md +++ b/docs/backends/redis.md @@ -6,7 +6,8 @@ The default L2 backend. Connects to Redis via environment variable or explicit c ## Basic Usage -```python +```python notest +# notest: RedisBackend() requires DI container setup not available in doc tests from cachekit.backends import RedisBackend from cachekit import cache diff --git a/docs/serializers/encryption.md b/docs/serializers/encryption.md index b5d92ce..988ea7c 100644 --- a/docs/serializers/encryption.md +++ b/docs/serializers/encryption.md @@ -19,7 +19,8 @@ The backend stores opaque ciphertext only. The master key never leaves the clien ## Basic Usage -```python +```python notest +# notest: @cache.secure validation requires CACHEKIT_MASTER_KEY before conftest runs from cachekit import cache from cachekit.serializers import EncryptionWrapper, OrjsonSerializer diff --git a/tests/unit/test_l1_only_mode.py b/tests/unit/test_l1_only_mode.py index 08db76f..e681796 100644 --- a/tests/unit/test_l1_only_mode.py +++ b/tests/unit/test_l1_only_mode.py @@ -14,6 +14,7 @@ from __future__ import annotations +import os import time from unittest.mock import MagicMock, patch @@ -229,17 +230,27 @@ def production_func() -> str: assert production_call_count == 1, f"@cache.production L1 miss - called {production_call_count} times" # Test @cache.secure(master_key="...", backend=None) + # validate_encryption_config() checks CACHEKIT_MASTER_KEY env var + # independently of the inline master_key param, so we must set it. secure_call_count = 0 - - @cache.secure(master_key="a" * 64, backend=None) - def secure_func() -> str: - nonlocal secure_call_count - secure_call_count += 1 - return "secure" - - assert secure_func() == "secure" - assert secure_func() == "secure" - assert secure_call_count == 1, f"@cache.secure L1 miss - called {secure_call_count} times" + old_key = os.environ.get("CACHEKIT_MASTER_KEY") + os.environ["CACHEKIT_MASTER_KEY"] = "a" * 64 + try: + + @cache.secure(master_key="a" * 64, backend=None) + def secure_func() -> str: + nonlocal secure_call_count + secure_call_count += 1 + return "secure" + + assert secure_func() == "secure" + assert secure_func() == "secure" + assert secure_call_count == 1, f"@cache.secure L1 miss - called {secure_call_count} times" + finally: + if old_key is None: + os.environ.pop("CACHEKIT_MASTER_KEY", None) + else: + os.environ["CACHEKIT_MASTER_KEY"] = old_key # Backend provider should NEVER have been called for any preset mock_provider.return_value.get_backend.assert_not_called() diff --git a/tests/integration/test_saas_observability.py b/tests/unit/test_saas_observability.py similarity index 94% rename from tests/integration/test_saas_observability.py rename to tests/unit/test_saas_observability.py index 2d0dcd1..6eedafc 100644 --- a/tests/integration/test_saas_observability.py +++ b/tests/unit/test_saas_observability.py @@ -1,4 +1,4 @@ -"""Integration tests for SaaS observability - headers injection end-to-end.""" +"""Unit tests for SaaS observability - header injection and session ID logic.""" from __future__ import annotations @@ -103,8 +103,8 @@ def test_graceful_degradation_none_stats(self): """Verify graceful degradation when stats is None.""" headers = _inject_metrics_headers(None) - # Should return empty dict, not raise error - assert headers == {} + # Should return L1-Status: disabled for standalone usage, not raise error + assert headers == {"X-CacheKit-L1-Status": "disabled"} assert isinstance(headers, dict) @@ -257,9 +257,9 @@ def test_backend_receives_metrics_headers_on_put(self): def test_backend_graceful_handling_missing_stats(self): """Verify backend gracefully handles missing stats.""" - # When stats is None, should return empty dict, not raise + # When stats is None, should return L1-Status: disabled for standalone usage headers = _inject_metrics_headers(None) - assert headers == {} + assert headers == {"X-CacheKit-L1-Status": "disabled"} assert isinstance(headers, dict) def test_metrics_headers_immutable_after_call(self): @@ -365,16 +365,16 @@ def test_hit_rate_precision_rounding(self): # Should round to 0.333 assert headers["X-CacheKit-L1-Hit-Rate"] == "0.333" - def test_session_unique_per_stats_instance(self): - """Verify each _FunctionStats instance has its own unique session ID. + def test_session_unique_per_function_identifier(self): + """Verify distinct function_identifiers produce distinct session IDs. - This is critical for multi-wrapper scenarios (e.g., Locust load testing where - multiple users each decorate the same function). Without per-instance session IDs, - different wrappers would collide and cause 'counters_decreased' validation errors. + Session IDs are composed as "{process_uuid}:{function_identifier}", so uniqueness + is per function_identifier. This matters for multi-wrapper scenarios where different + decorated functions must not share session state. """ - stats1 = _FunctionStats() - stats2 = _FunctionStats() - stats3 = _FunctionStats() + stats1 = _FunctionStats(function_identifier="module.func_a") + stats2 = _FunctionStats(function_identifier="module.func_b") + stats3 = _FunctionStats(function_identifier="module.func_c") headers1 = _inject_metrics_headers(stats1) headers2 = _inject_metrics_headers(stats2) @@ -494,8 +494,8 @@ def test_inject_metrics_headers_signature(self): # Should have single parameter 'stats' assert "stats" in params - # Should accept None - assert _inject_metrics_headers(None) == {} + # Should accept None and return L1-Status: disabled + assert _inject_metrics_headers(None) == {"X-CacheKit-L1-Status": "disabled"} def test_cache_info_all_fields_present(self): """Verify CacheInfo has all expected fields."""