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:
2026-07-01 13:17:29 +03:00
parent 1e24452b1a
commit 12118ac4ec
21 changed files with 871 additions and 336 deletions

View 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()