Files
vulncheck/app/auth/setting_crypto.py
T
vulncheck 4bc3633551 fix(settings): encrypt the AI provider token at rest
ai_config was missing from PROTECTED_SETTING_KEYS, so the AI provider's
api_token sat in plaintext in the settings table, and GET returned it in
full to the browser. It is now Fernet-encrypted like every other
integration config, and GET shows the token as ***set***. The AI clients
and the model-list fallback read the decrypted value. The fallback also
treats the ***set*** placeholder as "use the stored token".

Rows saved in plaintext before their key was protected used to stay that
way until the next edit. encrypt_plaintext_rows now encrypts them at
startup, for ai_config and any key added to the list later.
2026-11-04 09:03:36 +01:00

110 lines
3.4 KiB
Python

"""
Transparent at-rest encryption for sensitive setting values.
Setting rows whose `key` is in PROTECTED_SETTING_KEYS get their `value`
column encrypted with the AUTH_PROVIDER_CRYPTO_KEY Fernet key (same key
that protects TOTP secrets). Encrypted values are prefixed with
`ENC_PREFIX` so reads can detect ciphertext vs. legacy plaintext and
auto-migrate on the next write; `encrypt_plaintext_rows` (run at startup)
encrypts any still-plaintext row, e.g. after a key joins the list.
"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
from app.auth.totp import _fernet # reuses AUTH_PROVIDER_CRYPTO_KEY
from app.models.setting import Setting
logger = logging.getLogger(__name__)
ENC_PREFIX = "enc:v1:"
# Settings whose value field contains secrets (API tokens, passwords).
# Values written under these keys are encrypted at rest; reads transparently
# decrypt. Pre-existing plaintext rows still readable until next write.
PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
"wazuh_config",
"smtp_config",
"nessus_config",
"openrouter_api_key",
"typesafe_api_key",
"intune_config",
"vcenter_config",
"igel_config",
"netdisco_config",
"dockhand_config",
"xenserver_config",
"github_pat",
"ai_config",
})
def is_protected(key: str) -> bool:
return key in PROTECTED_SETTING_KEYS
def encrypt_value(plaintext: str) -> str:
"""Return ENC_PREFIX + base64-ciphertext."""
token = _fernet().encrypt(plaintext.encode()).decode()
return f"{ENC_PREFIX}{token}"
def decrypt_value(stored: str) -> str:
"""Decrypt an ENC_PREFIX-tagged value. Passes plaintext through unchanged."""
if not stored or not stored.startswith(ENC_PREFIX):
return stored
ciphertext = stored[len(ENC_PREFIX):]
return _fernet().decrypt(ciphertext.encode()).decode()
def read_setting_value(db: Session, key: str) -> Optional[str]:
"""Return decrypted setting value (or None if not set)."""
row = db.query(Setting).filter(Setting.key == key).first()
if not row or row.value is None:
return None
if is_protected(key):
try:
return decrypt_value(row.value)
except Exception as e:
logger.error("Failed to decrypt setting %s: %s", key, e)
return None
return row.value
def encrypt_plaintext_rows(db: Session) -> int:
"""Encrypt protected rows still stored in plaintext. Returns how many."""
rows = (db.query(Setting)
.filter(Setting.key.in_(PROTECTED_SETTING_KEYS))
.filter(Setting.value.isnot(None), Setting.value != "")
.all())
pending = [r for r in rows if not r.value.startswith(ENC_PREFIX)]
for row in pending:
row.value = encrypt_value(row.value)
if pending:
db.commit()
logger.info("Encrypted %d plaintext setting(s): %s",
len(pending), ", ".join(r.key for r in pending))
return len(pending)
def write_setting_value(
db: Session,
key: str,
value: str,
description: str = "",
) -> Setting:
"""Upsert a setting, encrypting if the key is protected."""
stored = encrypt_value(value) if is_protected(key) else value
row = db.query(Setting).filter(Setting.key == key).first()
if row:
row.value = stored
else:
row = Setting(key=key, value=stored, description=description)
db.add(row)
db.commit()
db.refresh(row)
return row