diff --git a/.env.example b/.env.example index 3e1fd45..65e9c86 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # --- Required: AI --- -ANTHROPIC_API_KEY=sk-ant-... +# From the Anthropic console. Leave unset and NL translation fails closed. +ANTHROPIC_API_KEY= # --- Required: internal control-plane DB (audit log + agents + databases + encrypted creds) --- # Railway provisions this — set DATABASE_URL automatically. @@ -15,14 +16,15 @@ VAULT_KEY= REDIS_URL= # --- Billing (Stripe) --- -STRIPE_SECRET_KEY=sk_live_... -STRIPE_WEBHOOK_SECRET=whsec_... -STRIPE_PRICE_STARTER=price_... -STRIPE_PRICE_PRO=price_... -STRIPE_PRICE_ENTERPRISE=price_... +# Paste from the Stripe dashboard. Checkout and webhooks fail closed when unset. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_STARTER= +STRIPE_PRICE_PRO= +STRIPE_PRICE_ENTERPRISE= # --- Notifications --- -SENDGRID_API_KEY=SG.... +SENDGRID_API_KEY= SENDGRID_FROM=alerts@queryshield.io # Product-specific Discord webhook — separate channel from other agentic-build products. QUERY_SHIELD_DISCORD_WEBHOOK_URL= diff --git a/.gitignore b/.gitignore index b1c6bc0..a5f16bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,43 @@ +# Python __pycache__/ *.py[cod] +*$py.class .pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Virtual environments .venv/ venv/ +env/ +ENV/ +.direnv/ + +# Secrets and local env (keep the committed template) .env +.env.* +!.env.example +*.pem +*.key + +# Local databases and SQLite sidecars (runtime artifacts, not fixtures) *.db +*.db-journal +*.db-wal +*.db-shm *.sqlite *.sqlite3 -.idea/ -.vscode/ +*.sqlite-journal +*.sqlite3-journal + +# Packaging and editor *.egg-info/ build/ dist/ +.idea/ +.vscode/ +.DS_Store diff --git a/README.md b/README.md index 19b5e65..c0f4651 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,16 @@ Agents never see connection strings. ```bash pip install -r requirements.txt cp .env.example .env -# Set ANTHROPIC_API_KEY, DATABASE_URL, VAULT_KEY (see below) +# Set ANTHROPIC_API_KEY, DATABASE_URL, and VAULT_KEY in .env (see below). +# Do not commit .env. QueryShield reads secrets from the environment only. python -m queryshield.start ``` -Generate a Fernet key for `VAULT_KEY` once and never lose it: +Generate a Fernet key for `VAULT_KEY` once and never lose it. Credential +encryption and session signing both require `VAULT_KEY` (or `SESSION_KEY` +for cookies). If it is unset, those operations fail instead of using a +built-in key: ```bash python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" @@ -50,7 +54,7 @@ curl -X POST localhost:8000/v1/databases \ -d '{ "alias": "prod", "db_type": "postgresql", - "connection_string": "postgresql://reader:secret@db.acme.internal:5432/app", + "connection_string": "postgresql://READER:PASSWORD@db.example.internal:5432/app", "allowed_tables": ["users", "orders"] }' diff --git a/queryshield/auth.py b/queryshield/auth.py index c7cc943..00778a1 100644 --- a/queryshield/auth.py +++ b/queryshield/auth.py @@ -45,10 +45,12 @@ # --- Session cookies --------------------------------------------------- def _session_key() -> bytes: - """Derive a stable HMAC key. Falls back to VAULT_KEY if SESSION_KEY unset.""" + """Derive a stable HMAC key from SESSION_KEY, or from VAULT_KEY if unset.""" s = get_settings() - key = getattr(s, "session_key", None) or s.vault_key or "dev-only-fallback-session-key" - return hashlib.sha256(key.encode("utf-8") if isinstance(key, str) else key).digest() + key = s.session_key or s.vault_key + if not isinstance(key, str) or not key.strip(): + raise RuntimeError("SESSION_KEY or VAULT_KEY is not configured") + return hashlib.sha256(key.encode("utf-8")).digest() def issue_session(tenant_id: str) -> str: diff --git a/queryshield/config.py b/queryshield/config.py index 636bd7d..14b7a45 100644 --- a/queryshield/config.py +++ b/queryshield/config.py @@ -1,8 +1,9 @@ """Centralized settings — one place for env-var reads. Pattern matches the rest of Brett's agentic-build projects: a singleton -``Settings`` instance loaded once at import, with conservative defaults so -local development works without a .env file. +``Settings`` instance loaded once at import, with conservative defaults for +non-secret settings so local development works without a .env file. +Secrets have no built-in default and must come from the environment. """ from __future__ import annotations @@ -29,9 +30,9 @@ class Settings(BaseSettings): database_url: str = "sqlite:///queryshield_local.db" # Vault - vault_key: Optional[str] = None # Fernet key (base64) — required in prod - # Optional: override session-cookie HMAC key. Defaults to a derived key - # off VAULT_KEY if unset. Rotating SESSION_KEY logs everyone out. + vault_key: Optional[str] = None # Fernet key (base64). Required to encrypt credentials. + # Optional: override session-cookie HMAC key. Derived from VAULT_KEY when + # unset. There is no built-in fallback. Rotating SESSION_KEY logs everyone out. session_key: Optional[str] = None # Cache diff --git a/queryshield/vault.py b/queryshield/vault.py index 5208bf4..4ed2d52 100644 --- a/queryshield/vault.py +++ b/queryshield/vault.py @@ -32,11 +32,9 @@ def _fernet() -> Fernet: key = get_settings().vault_key - if not key: - # In dev, fall back to a deterministic key so local boots work. - # In production the startup config check refuses to start without one. - key = "dev-only-vault-key-do-not-use-in-prod-dev-only-vault-key=" - return Fernet(key.encode("utf-8") if isinstance(key, str) else key) + if not isinstance(key, str) or not key.strip(): + raise RuntimeError("VAULT_KEY is not configured") + return Fernet(key.encode("utf-8")) def encrypt_connection(payload: dict) -> bytes: diff --git a/queryshield/web.py b/queryshield/web.py index d453653..7367a72 100644 --- a/queryshield/web.py +++ b/queryshield/web.py @@ -473,7 +473,7 @@ def replace(self, key, value="", *a, **kw): # type: ignore[override] - +
Tip: use a read-only database role. QueryShield's safety check + RLS only run inside our service; defense in depth means the DB itself should refuse writes.
diff --git a/queryshield_local.db-journal b/queryshield_local.db-journal deleted file mode 100644 index bcb0bce..0000000 Binary files a/queryshield_local.db-journal and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py index c632343..88044ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from pathlib import Path import pytest +from cryptography.fernet import Fernet # Make sure the package under test is importable ROOT = Path(__file__).resolve().parent.parent @@ -24,9 +25,10 @@ # SQLAlchemy ENGINE at module import using the env-time DATABASE_URL. _TEST_TMP = tempfile.mkdtemp(prefix="queryshield-tests-") os.environ["DATABASE_URL"] = f"sqlite:///{_TEST_TMP}/control.db" -os.environ["VAULT_KEY"] = "RIaPxXZl9MNm7ESjqpsnbsiBCDkfErP6Mum0lmGD-7w=" # fixed test key +# Fresh key per process. Never a committed shared key, and never logged. +os.environ["VAULT_KEY"] = Fernet.generate_key().decode() os.environ["ENVIRONMENT"] = "test" -os.environ["ANTHROPIC_API_KEY"] = "sk-ant-test" +os.environ["ANTHROPIC_API_KEY"] = "test-not-a-real-key" @pytest.fixture() diff --git a/tests/test_hardening.py b/tests/test_hardening.py index 995b6bf..b309f23 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -63,6 +63,26 @@ def test_register_db_with_private_host_returns_400(client) -> None: # --- Rate limit --------------------------------------------------------- +def test_vault_requires_key(monkeypatch) -> None: + from queryshield.config import get_settings + from queryshield.vault import encrypt_connection + + monkeypatch.setattr(get_settings(), "vault_key", None) + with pytest.raises(RuntimeError, match="VAULT_KEY is not configured"): + encrypt_connection({"type": "sqlite", "url": "sqlite:///x"}) + + +def test_session_requires_key(monkeypatch) -> None: + from queryshield.auth import issue_session + from queryshield.config import get_settings + + settings = get_settings() + monkeypatch.setattr(settings, "vault_key", None) + monkeypatch.setattr(settings, "session_key", None) + with pytest.raises(RuntimeError, match="SESSION_KEY or VAULT_KEY is not configured"): + issue_session("tenant") + + def test_token_bucket_basic() -> None: _BUCKETS.clear() for _ in range(3):