Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 9 additions & 18 deletions .github/workflows/security-medium.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,29 +37,20 @@ jobs:
cargo geiger --output-format Json > geiger-report.json
cat geiger-report.json

- name: Check unsafe ratio (< 5%)
- name: Check unsafe in cachekit-rs (must be zero)
run: |
cd rust
# Parse total and unsafe counts from geiger JSON
TOTAL_FUNCS=$(jq '[.packages[].package.functions.safe + .packages[].package.functions.unsafe] | add' geiger-report.json)
UNSAFE_FUNCS=$(jq '[.packages[].package.functions.unsafe] | add' geiger-report.json)

if [ "$TOTAL_FUNCS" -eq 0 ]; then
echo "⚠️ No functions found in geiger report"
exit 0
UNSAFE_FUNCS=$(jq '.packages[] | select(.package.id.name == "cachekit-rs") | .unsafety.used.functions.unsafe_' geiger-report.json)
if [ -z "$UNSAFE_FUNCS" ]; then
echo "cachekit-rs not found in geiger report"
exit 1
fi

UNSAFE_RATIO=$(echo "scale=4; $UNSAFE_FUNCS / $TOTAL_FUNCS * 100" | bc)

echo "Total functions: $TOTAL_FUNCS"
echo "Unsafe functions: $UNSAFE_FUNCS"
echo "Unsafe ratio: $UNSAFE_RATIO%"

if (( $(echo "$UNSAFE_RATIO > 5.0" | bc -l) )); then
echo "❌ Unsafe ratio ($UNSAFE_RATIO%) exceeds 5% threshold"
echo "cachekit-rs unsafe functions: $UNSAFE_FUNCS"
if [ "$UNSAFE_FUNCS" -ne 0 ]; then
echo "cachekit-rs has $UNSAFE_FUNCS unsafe functions — must be zero"
exit 1
fi
echo "✅ Unsafe ratio ($UNSAFE_RATIO%) is within acceptable limits"
echo "cachekit-rs has zero unsafe functions"

- name: Archive geiger report
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
Expand Down
45 changes: 25 additions & 20 deletions src/cachekit/config/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ class ConfigurationError(Exception):
pass


def validate_encryption_config(encryption: bool = False) -> None:
def validate_encryption_config(encryption: bool = False, master_key: str | None = None) -> None:
"""Validate encryption configuration when encryption is enabled.

Checks that CACHEKIT_MASTER_KEY is set via pydantic-settings when encryption=True.
Checks for a master key: first from the explicit parameter, then from
CACHEKIT_MASTER_KEY env var via pydantic-settings.

Args:
encryption: Whether encryption is enabled. If False, no validation.
master_key: Explicit master key (hex string). Takes precedence over env var.

Raises:
ConfigurationError: If encryption config is invalid
Expand All @@ -59,33 +61,36 @@ def validate_encryption_config(encryption: bool = False) -> None:
if not encryption:
return

# Get master key from pydantic-settings (handles env vars properly)
from cachekit.config.singleton import get_settings
# Resolve master key: explicit param > env var via settings
resolved_key = master_key
if not resolved_key:
from cachekit.config.singleton import get_settings

settings = get_settings()
master_key = settings.master_key.get_secret_value() if settings.master_key else None
settings = get_settings()
resolved_key = settings.master_key.get_secret_value() if settings.master_key else None

# Check if master_key is set
if not master_key:
if not resolved_key:
raise ConfigurationError(
"CACHEKIT_MASTER_KEY environment variable required when encryption=True. "
"Master key required when encryption=True. Either pass master_key= "
"or set CACHEKIT_MASTER_KEY environment variable. "
"Generate with: python -c 'import secrets; print(secrets.token_hex(32))'"
)

# Production environment warning - check via settings (already loaded above)
# Check deployment indicators
if not settings.dev_mode:
logger.warning(
"🔒 SECURITY WARNING: Master key loaded from environment variable in PRODUCTION. "
"Environment variables are NOT secure key storage. "
"For production deployments, use secrets management system: "
"HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, or Google Secret Manager. "
"KMS integration planned for future release."
)
# Production environment warning when key came from env var (not inline)
if not master_key:
from cachekit.config.singleton import get_settings

settings = get_settings()
if not settings.dev_mode:
logger.warning(
"Master key loaded from environment variable. "
"For production, use a secrets management system "
"(HashiCorp Vault, AWS Secrets Manager, etc.)."
)

# Validate key format and length
try:
key_bytes = bytes.fromhex(master_key)
key_bytes = bytes.fromhex(resolved_key)
if len(key_bytes) < 32:
raise ConfigurationError(
f"CACHEKIT_MASTER_KEY must be at least 32 bytes (256 bits). "
Expand Down
2 changes: 1 addition & 1 deletion src/cachekit/decorators/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ def create_cache_wrapper(
# Validate encryption configuration if encryption is enabled
from ..config import validate_encryption_config

validate_encryption_config(encryption)
validate_encryption_config(encryption, master_key=master_key)

# Note: L1 cache + encryption is supported.
# L1 stores encrypted bytes (not plaintext), decryption happens at read time only.
Expand Down
22 changes: 11 additions & 11 deletions tests/integration/test_redis_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,19 +136,19 @@ def service_b_func(key):
def test_concurrent_access(self):
"""Concurrent access with distributed locking (async required)."""
call_count = 0
lock = asyncio.Lock()

@cache(ttl=300)
async def thread_safe_func(key):
nonlocal call_count
async with lock:
call_count += 1
count = call_count
await asyncio.sleep(0.1) # Simulate work
return f"result_{key}_{count}"

async def run_concurrent_requests():
# Run concurrent requests using asyncio.gather
lock = asyncio.Lock() # Must create inside running loop (Python 3.9 compat)

@cache(ttl=300)
async def thread_safe_func(key):
nonlocal call_count
async with lock:
call_count += 1
count = call_count
await asyncio.sleep(0.1) # Simulate work
return f"result_{key}_{count}"

tasks = [thread_safe_func("shared") for _ in range(10)]
return await asyncio.gather(*tasks)

Expand Down
6 changes: 3 additions & 3 deletions tests/unit/backends/test_file_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,14 +936,14 @@ def test_exists_expired_ttl_deletes_file(self, backend: FileBackend, config: Fil
key = "exists_expired"
value = b"value"

# Set with 1 second TTL
backend.set(key, value, ttl=1)
# Set with 2 second TTL (1s too tight under CI load)
backend.set(key, value, ttl=2)

# Verify it exists
assert backend.exists(key) is True

# Wait for expiration
time.sleep(1.5)
time.sleep(2.5)

# exists should return False and delete the file
result = backend.exists(key)
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/test_encryption_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,10 @@ def test_production_warning_logged_for_prod_envs(self, env_name, monkeypatch, ca
warning_messages = [record.message for record in caplog.records if record.levelname == "WARNING"]
assert len(warning_messages) > 0, f"Expected warning for ENV={env_name}"

# Warning should mention security
has_security_warning = any("SECURITY" in msg.upper() or "WARNING" in msg.upper() for msg in warning_messages)
# Warning should mention env var key usage
has_security_warning = any(
"environment variable" in msg.lower() or "secrets management" in msg.lower() for msg in warning_messages
)
assert has_security_warning

def test_no_production_warning_for_dev_env(self, monkeypatch, caplog):
Expand Down
29 changes: 10 additions & 19 deletions tests/unit/test_l1_only_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

from __future__ import annotations

import os
import time
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -233,24 +232,16 @@ def production_func() -> str:
# 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
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

@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"

# Backend provider should NEVER have been called for any preset
mock_provider.return_value.get_backend.assert_not_called()
Expand Down
Loading