fix(security): resolve Critical+High findings from module audit — agent, translate, superset_client
P0 — CRITICAL (CWE-798): JWT_SECRET crash-early
Replace hardcoded super-secret-key fallback with os.environ["JWT_SECRET"]
and ${JWT_SECRET:?} syntax in app.py + docker-compose files
P1 — HIGH: Frontend dependency CVEs
Upgrade svelte 5.43.8 → 5.56.4 — resolves devalue DoS (GHSA-g2pg-6438-jwpf)
and svelte XSS (GHSA-crpf-4hrx-3jrp, GHSA-m56q-vw4c-c2cp, GHSA-rcqx-6q8c-2c42)
P2 — MEDIUM: Logging hygiene + contract gaps + tool resolver refactor
Apply _redact_sensitive_fields() in middleware + event streaming
Truncate LLM error body to 100 chars
Add @RATIONALE/@REJECTED to HandleResume + SaveConversation
Refactor deterministic intent matching → LLM-driven tool resolution
P3 — LOW: Translate logging hardening
Move _sanitize_url() to _utils.py (shared, no circular imports)
Sanitize base_url before logging in _llm_call.py and _llm_async_http.py
Emit EXPLORE warning when LLM_SSL_VERIFY=false disables TLS
superset_client module: passed clean — no changes needed
This commit is contained in:
236
backend/src/scripts/reencrypt.py
Normal file
236
backend/src/scripts/reencrypt.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
# #region Scripts.Reencrypt [C:4] [TYPE Module] [SEMANTICS encryption,rotation,migration]
|
||||
# @defgroup Scripts Module group.
|
||||
# @BRIEF Key rotation tool — re-encrypts all stored secrets with a new ENCRYPTION_KEY.
|
||||
# @LAYER Infrastructure
|
||||
# @PRE Old and new ENCRYPTION_KEY must be set via environment variables.
|
||||
# @POST All DatabaseConnection passwords, Environment passwords, and LLMProvider API keys
|
||||
# are re-encrypted with the new key. Original data rejected if decryption fails.
|
||||
# @SIDE_EFFECT Reads/writes app_configurations payload and llm_providers table.
|
||||
# @RELATION DEPENDS_ON -> [EncryptionManager]
|
||||
# @RELATION DEPENDS_ON -> [AppConfigRecord]
|
||||
# @RELATION DEPENDS_ON -> [LLMProvider]
|
||||
# @RATIONALE Fernet is symmetric — re-encryption requires decrypt with old key,
|
||||
# encrypt with new key. There is no key-wrapping or key-derivation layer.
|
||||
# @REJECTED In-place re-encryption without old key rejected — impossible with Fernet.
|
||||
# Auto-rotation on startup rejected — would break on first restart after key change.
|
||||
#
|
||||
# Usage:
|
||||
# OLD_ENCRYPTION_KEY=<old> NEW_ENCRYPTION_KEY=<new> python -m src.scripts.reencrypt
|
||||
# OLD_ENCRYPTION_KEY=<old> NEW_ENCRYPTION_KEY=<new> python -m src.scripts.reencrypt --dry-run
|
||||
# #endregion Scripts.Reencrypt
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
except ImportError:
|
||||
sys.exit("ERROR: cryptography is not installed. Run: pip install cryptography")
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# ── Fernet helpers (standalone — no app dependency) ────────────────────
|
||||
|
||||
|
||||
def _make_fernet(key_b64: str) -> Fernet:
|
||||
try:
|
||||
return Fernet(key_b64.encode())
|
||||
except Exception as e:
|
||||
sys.exit(f"ERROR: Invalid Fernet key: {e}")
|
||||
|
||||
|
||||
def _is_fernet_token(value: str) -> bool:
|
||||
if not value or len(value) < 60:
|
||||
return False
|
||||
try:
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
except ImportError:
|
||||
sys.exit("ERROR: cryptography is not installed. Run: pip install cryptography")
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.encryption import is_fernet_token
|
||||
|
||||
# ── Fernet helpers (standalone — no app dependency) ────────────────────
|
||||
|
||||
|
||||
def _make_fernet(key_b64: str) -> Fernet:
|
||||
try:
|
||||
return Fernet(key_b64.encode())
|
||||
except Exception as e:
|
||||
sys.exit(f"ERROR: Invalid Fernet key: {e}")
|
||||
|
||||
|
||||
def _reencrypt_value(value: str, old_fernet: Fernet, new_fernet: Fernet) -> str | None:
|
||||
"""Decrypt with old key, encrypt with new key. Returns None on failure."""
|
||||
if not is_fernet_token(value):
|
||||
print(f" ⚠ Skipping non-Fernet value (length={len(value)})")
|
||||
return None
|
||||
try:
|
||||
plaintext = old_fernet.decrypt(value.encode()).decode()
|
||||
except Exception as e:
|
||||
print(f" ✗ Decryption failed: {e}")
|
||||
return None
|
||||
return new_fernet.encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
# ── Report helpers ────────────────────────────────────────────────────
|
||||
|
||||
_report: list[str] = []
|
||||
|
||||
|
||||
def _r(msg: str) -> None:
|
||||
_report.append(msg)
|
||||
print(msg)
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Re-encrypt all stored secrets with a new Fernet ENCRYPTION_KEY."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Only scan and report what would be changed; no writes.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
old_key = os.getenv("OLD_ENCRYPTION_KEY", "").strip()
|
||||
new_key = os.getenv("NEW_ENCRYPTION_KEY", "").strip()
|
||||
|
||||
if not old_key or not new_key:
|
||||
_r("ERROR: Set OLD_ENCRYPTION_KEY and NEW_ENCRYPTION_KEY environment variables.")
|
||||
_r("")
|
||||
_r("Usage:")
|
||||
_r(" OLD_ENCRYPTION_KEY=<old> NEW_ENCRYPTION_KEY=<new> python -m src.scripts.reencrypt")
|
||||
_r(" OLD_ENCRYPTION_KEY=<old> NEW_ENCRYPTION_KEY=<new> python -m src.scripts.reencrypt --dry-run")
|
||||
sys.exit(1)
|
||||
|
||||
old_fernet = _make_fernet(old_key)
|
||||
new_fernet = _make_fernet(new_key)
|
||||
|
||||
if args.dry_run:
|
||||
_r("🔍 DRY RUN — no changes will be made")
|
||||
else:
|
||||
_r("🔐 Re-encrypting all secrets with new ENCRYPTION_KEY...")
|
||||
_r(f" Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||
_r("")
|
||||
|
||||
# ── Load database URL ──────────────────────────────────────────
|
||||
db_url = (
|
||||
os.getenv("DATABASE_URL", "")
|
||||
or os.getenv("POSTGRES_URL", "")
|
||||
or "postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools"
|
||||
)
|
||||
# Use psycopg2 for sync access in script
|
||||
engine = create_engine(db_url)
|
||||
|
||||
# ── Step 1: Environment passwords (AppConfigRecord.payload.environments) ──
|
||||
_r("── Environment passwords (ConfigManager) ──")
|
||||
from sqlalchemy import Column, String, Integer, Text
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class AppConfigRecord(Base):
|
||||
__tablename__ = "app_configurations"
|
||||
id = Column(String, primary_key=True)
|
||||
payload = Column(Text)
|
||||
|
||||
total_env_passwords = 0
|
||||
reencrypted_env = 0
|
||||
skipped_env = 0
|
||||
|
||||
with Session(engine) as session:
|
||||
record = session.query(AppConfigRecord).filter(AppConfigRecord.id == "global").first()
|
||||
if record and record.payload:
|
||||
import json
|
||||
payload = record.payload if isinstance(record.payload, dict) else json.loads(record.payload)
|
||||
environments = payload.get("environments", [])
|
||||
for env in environments:
|
||||
pwd = env.get("password", "")
|
||||
if not pwd or pwd == "********":
|
||||
skipped_env += 1
|
||||
continue
|
||||
re = _reencrypt_value(pwd, old_fernet, new_fernet)
|
||||
if re is not None:
|
||||
if not args.dry_run:
|
||||
env["password"] = re
|
||||
reencrypted_env += 1
|
||||
else:
|
||||
_r(f" ✗ Failed to re-encrypt password for env '{env.get('id', '?')}'")
|
||||
total_env_passwords += 1
|
||||
|
||||
if not args.dry_run and reencrypted_env > 0:
|
||||
record.payload = payload
|
||||
session.commit()
|
||||
_r(f" ✓ Committed {reencrypted_env} re-encrypted environment passwords")
|
||||
else:
|
||||
_r(" - No AppConfigRecord found, skipping")
|
||||
|
||||
_r(f" Environment passwords: {reencrypted_env} re-encrypted, {total_env_passwords - reencrypted_env - skipped_env} failed, {skipped_env} skipped")
|
||||
_r("")
|
||||
|
||||
# ── Step 2: LLM Provider API keys ──────────────────────────────
|
||||
_r("── LLM Provider API keys ──")
|
||||
from sqlalchemy import Column, String as SAString, Boolean, Integer as SAInteger
|
||||
|
||||
class LLMProvider(Base):
|
||||
__tablename__ = "llm_providers"
|
||||
id = Column(SAString, primary_key=True)
|
||||
api_key = Column(SAString)
|
||||
|
||||
total_providers = 0
|
||||
reencrypted_keys = 0
|
||||
skipped_providers = 0
|
||||
|
||||
with Session(engine) as session:
|
||||
providers = session.query(LLMProvider).all()
|
||||
for prov in providers:
|
||||
key = prov.api_key
|
||||
if not key:
|
||||
skipped_providers += 1
|
||||
continue
|
||||
re = _reencrypt_value(key, old_fernet, new_fernet)
|
||||
if re is not None:
|
||||
if not args.dry_run:
|
||||
prov.api_key = re
|
||||
reencrypted_keys += 1
|
||||
else:
|
||||
_r(f" ✗ Failed to re-encrypt API key for provider '{prov.id}'")
|
||||
total_providers += 1
|
||||
|
||||
if not args.dry_run and reencrypted_keys > 0:
|
||||
session.commit()
|
||||
_r(f" ✓ Committed {reencrypted_keys} re-encrypted API keys")
|
||||
|
||||
_r(f" Provider API keys: {reencrypted_keys} re-encrypted, {total_providers - reencrypted_keys - skipped_providers} failed, {skipped_providers} skipped")
|
||||
_r("")
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────
|
||||
_r("── Summary ──")
|
||||
_r(f" Total re-encrypted: {reencrypted_env + reencrypted_keys}")
|
||||
_r(f" Total failed: {(total_env_passwords - reencrypted_env - skipped_env) + (total_providers - reencrypted_keys - skipped_providers)}")
|
||||
_r(f" Total skipped: {skipped_env + skipped_providers}")
|
||||
if args.dry_run:
|
||||
_r(" (dry run — no changes written)")
|
||||
_r("")
|
||||
_r("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user