mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 07:11:28 +02:00
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
30 lines
878 B
Python
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
|