Files
rengine/shared/models/types.py
T
Yogesh OjhaandClaude Opus 5 f0d4bb21ba fix(security): scan context credentials were stored in the clear
API keys, proxy credentials and notification channel secrets are all
Fernet-encrypted at rest. Scan context credentials were not: the bearer
token, basic password, cookie and API key a user gives reNgine to
authenticate into their own applications sat in scan_contexts.auth as
plain JSON, and a sensitive extra header sat beside it in
extra_headers. The API masks both on read, so the interface implied a
protection the database did not have — anyone with a dump, a backup or a
replica read the credentials directly.

Both columns now use an EncryptedJSON type, which encrypts on the way in
and decrypts on the way out, so the masking, the merge-on-update that
restores a secret behind the mask, and the worker's own read all keep
working on plaintext exactly as before. A row written before the
migration is still read correctly, the same way the API key change
handled its own history.

Verified end to end against a listener: stored ciphertext, masked on
read, the real Authorization header arriving at the target, a masked
edit preserving the secret, and a rotated password taking effect.

Still in the clear and not addressed here: the resolved headers copied
into scans.execution_config on every run that uses a context. Encrypting
that column would re-encrypt every historical scan; the better fix is
for the worker to resolve credentials from the context at run time
rather than persist them per scan, which changes what execution_config
means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6LcH8uUddjJbKrahf34ys
2026-09-06 23:08:10 +05:30

30 lines
878 B
Python

"""Column types that keep a value encrypted at rest."""
import json
from sqlalchemy import Text, TypeDecorator
from shared.utils.crypto import encrypt_secret, try_decrypt
class EncryptedJSON(TypeDecorator):
"""A JSON value stored as a Fernet token, so a database dump carries no secret."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect): # noqa: ARG002
if value is None:
return None
return encrypt_secret(json.dumps(value))
def process_result_value(self, value, dialect): # noqa: ARG002
if value is None:
return None
# a row written before the column was encrypted is still plain JSON
plain = try_decrypt(value)
try:
return json.loads(plain if plain is not None else value)
except (TypeError, ValueError):
return None