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
16 changes: 9 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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=
Expand Down
33 changes: 31 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())"
Expand All @@ -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"]
}'

Expand Down
8 changes: 5 additions & 3 deletions queryshield/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions queryshield/config.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
8 changes: 3 additions & 5 deletions queryshield/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion queryshield/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ def replace(self, key, value="", *a, **kw): # type: ignore[override]
<option value="sqlite">SQLite</option>
</select>
<label>Connection string (encrypted at rest)</label>
<input id="db-cs" type="text" placeholder="postgresql://reader:secret@host:5432/db">
<input id="db-cs" type="text" placeholder="postgresql://READER:PASSWORD@host:5432/db">
<p class="muted" style="margin-top: 8px;">Tip: use a <em>read-only</em> database role. QueryShield's safety check + RLS only run inside our service; defense in depth means the DB itself should refuse writes.</p>
<button class="btn secondary" onclick="registerDb()">Register</button>
<div id="db-result"></div>
Expand Down
Binary file removed queryshield_local.db-journal
Binary file not shown.
6 changes: 4 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions tests/test_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading