Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/content/releases/os_upgrading/3.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: 'Upgrading to DefectDojo Version 3.2.x'
toc_hide: true
weight: -20260623
description: Tool Configuration credentials are re-encrypted to AES-256-GCM.
---

## Tool Configuration credentials upgraded to AES-256-GCM

DefectDojo encrypts the credentials stored on Tool Configurations (the `password`, `ssh`, and `api_key` fields). Previously these values were encrypted with AES-256 in OFB mode (the `AES.1` stored format). This release introduces a modern `AES.2` format that uses AES-256-GCM, an authenticated encryption scheme that detects tampering with the stored ciphertext.

New and updated credentials are written in the `AES.2` format automatically. The encryption key is unchanged — both formats derive their key from the same `DD_CREDENTIAL_AES_256_KEY`, so no key rotation or settings change is required.

A data migration (`0270_reencrypt_tool_config_credentials_aes_gcm`) included in this release eagerly re-encrypts every existing `AES.1` credential to `AES.2` on upgrade. The legacy `AES.1` decryption path is retained for backward compatibility, so any value that has not yet been migrated continues to decrypt normally.

This release also bumps `cryptography` to 49.0.0 and `pyopenssl` to 26.3.0.

### What you need to do

Nothing — the change is applied automatically by the database migration included in this release. Ensure your `DD_CREDENTIAL_AES_256_KEY` is unchanged from your prior deployment so the existing credentials can be decrypted and re-encrypted; a value that fails to decrypt (for example, because it was encrypted under a different key) is left untouched rather than overwritten.

For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.2.0).
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import logging

from django.db import migrations

logger = logging.getLogger(__name__)

# Tool_Configuration fields that hold credentials encrypted via
# dojo_crypto_encrypt()/prepare_for_view(). Each is re-encrypted from the legacy
# "AES.1" (AES-256-OFB) scheme to the modern "AES.2" (AES-256-GCM) scheme.
ENCRYPTED_FIELDS = ("password", "ssh", "api_key")

# Legacy stored-format prefix written by the old prepare_for_save().
LEGACY_PREFIX = "AES.1:"

# Re-encrypt in bounded chunks so a large Tool_Configuration table never loads
# every row into memory at once.
BATCH_SIZE = 500


def reencrypt_tool_config_credentials(apps, schema_editor):
"""
Eagerly upgrade every stored Tool_Configuration credential from the legacy
"AES.1" (AES-256-OFB) format to the modern "AES.2" (AES-256-GCM) format.

prepare_for_view() already reads both formats, so values would otherwise
upgrade only lazily the next time a Tool Config is saved. This migration
performs the transition proactively so the legacy OFB decrypt path can
eventually be removed once no "AES.1" values remain. See the "REMOVAL
TRACKING (legacy OFB path)" note in dojo/utils.py for the conditions under
which that legacy code (encrypt/decrypt/prepare_for_save and the OFB import)
can be deleted.

Both schemes reuse the same key from get_db_key(); no key rotation or
settings change is involved. A value that fails to decrypt (e.g. produced
with a different key) is left untouched rather than clobbered.
"""
# Imported here, not at module load, so the migration graph can be built
# without pulling in the full dojo.utils runtime/settings dependencies.
from dojo.utils import dojo_crypto_encrypt, prepare_for_view

Tool_Configuration = apps.get_model("dojo", "Tool_Configuration")

upgraded = 0
last_id = 0
while True:
page = list(
Tool_Configuration.objects.filter(id__gt=last_id)
.order_by("id")
.values("id", *ENCRYPTED_FIELDS)[:BATCH_SIZE],
)
if not page:
break
last_id = page[-1]["id"]

for row in page:
updates = {}
for field in ENCRYPTED_FIELDS:
value = row[field]
if not value or not value.startswith(LEGACY_PREFIX):
continue
decrypted = prepare_for_view(value)
if not decrypted:
# Decryption failed (wrong key / tampered value). Leave the
# stored value as-is instead of overwriting it with junk.
logger.warning(
"Skipping Tool_Configuration %s field %r: legacy value did not decrypt",
row["id"], field,
)
continue
updates[field] = dojo_crypto_encrypt(decrypted)

if updates:
Tool_Configuration.objects.filter(id=row["id"]).update(**updates)
upgraded += 1

if upgraded:
logger.info("Re-encrypted credentials for %d Tool_Configuration rows to AES-256-GCM", upgraded)


def noop_reverse(apps, schema_editor):
# The "AES.2" values remain readable by prepare_for_view(); there is no need
# (and no benefit) to downgrade them back to the legacy OFB scheme.
pass


class Migration(migrations.Migration):
dependencies = [
("dojo", "0269_normalize_blank_finding_components"),
]

operations = [
migrations.RunPython(reencrypt_tool_config_credentials, noop_reverse),
]
73 changes: 58 additions & 15 deletions dojo/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,18 @@
import redis as redis_lib
import vobject
from amqp.exceptions import ChannelError
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# OFB powers the legacy "AES.1" decryption path only. It has been moved to the
# "decrepit" module and is being removed from primitives.ciphers.modes; import
# it from its new home when available, falling back for older cryptography.
try:
from cryptography.hazmat.decrepit.ciphers.modes import OFB
except ImportError: # cryptography that predates the decrepit modes module
from cryptography.hazmat.primitives.ciphers.modes import OFB
from cvss import CVSS2, CVSS3, CVSS4
from dateutil.parser import parse
from dateutil.relativedelta import MO, SU, relativedelta
Expand Down Expand Up @@ -960,11 +970,31 @@ def reopen_external_issue(finding_id, note, external_issue_provider, **kwargs):
from dojo.notifications.helper import process_tag_notifications # noqa: E402, F401 -- backward compat


# ---------------------------------------------------------------------------
# Legacy "AES.1" credential encryption: AES-256-OFB with null-byte padding.
# Retained for backward-compatible decryption of values already stored in the
# database. New values are written with the "AES.2" (AES-256-GCM) scheme below
# via dojo_crypto_encrypt(); existing "AES.1" values upgrade lazily the next
# time they are saved.
#
# REMOVAL TRACKING (legacy OFB path):
# Migration 0270_reencrypt_tool_config_credentials_aes_gcm eagerly re-encrypts
# every stored Tool_Configuration credential to "AES.2", so after it has run in
# every environment there should be no "AES.1" values left in the database.
# Once that migration is squashed/baked into the release floor (i.e. no upgrade
# path can skip it) and any external integrations have been confirmed not to
# persist their own "AES.1" values, the entire legacy path can be deleted:
# - encrypt() / decrypt() / _pad_string() / _unpad_string() below
# - the OFB import at the top of this module
# - the "AES.1" else-branch in prepare_for_view()
# - prepare_for_save() (only ever produced the "AES.1" format)
# Do NOT remove any of the above until all stored secrets have been re-encrypted.
# ---------------------------------------------------------------------------
def encrypt(key, iv, plaintext):
text = ""
if plaintext and plaintext is not None:
backend = default_backend()
cipher = Cipher(algorithms.AES(key), modes.OFB(iv), backend=backend)
cipher = Cipher(algorithms.AES(key), OFB(iv), backend=backend)
encryptor = cipher.encryptor()
plaintext = _pad_string(plaintext)
encrypted_text = encryptor.update(plaintext) + encryptor.finalize()
Expand All @@ -974,7 +1004,7 @@ def encrypt(key, iv, plaintext):

def decrypt(key, iv, encrypted_text):
backend = default_backend()
cipher = Cipher(algorithms.AES(key), modes.OFB(iv), backend=backend)
cipher = Cipher(algorithms.AES(key), OFB(iv), backend=backend)
encrypted_text_bytes = binascii.a2b_hex(encrypted_text)
decryptor = cipher.decryptor()
decrypted_text = decryptor.update(encrypted_text_bytes) + decryptor.finalize()
Expand All @@ -994,14 +1024,18 @@ def _unpad_string(value):


def dojo_crypto_encrypt(plaintext):
# New values are encrypted with the modern "AES.2" (AES-256-GCM) scheme.
# AESGCM provides authenticated encryption (no separate padding needed) and
# uses the same key derived by get_db_key(), so it stays interoperable with
# the legacy "AES.1" decryption path. See prepare_for_view() for reads.
data = None
if plaintext:
key = None
key = get_db_key()

iv = os.urandom(16)
data = prepare_for_save(
iv, encrypt(key, iv, plaintext.encode("utf-8")))
# GCM standard nonce length is 96 bits (12 bytes); never reuse a nonce
# with the same key, hence a fresh random nonce per encryption.
nonce = os.urandom(12)
ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode("utf-8"), None)
data = "AES.2:" + binascii.b2a_hex(nonce).decode("utf-8") + ":" + binascii.b2a_hex(ciphertext).decode("utf-8")

return data

Expand All @@ -1026,21 +1060,30 @@ def get_db_key():


def prepare_for_view(encrypted_value):

# Reads both the modern "AES.2" (AES-256-GCM) format written by
# dojo_crypto_encrypt() and the legacy "AES.1" (AES-256-OFB) format. Any
# unrecognized prefix falls through to the legacy path so that values
# already stored in the database continue to decrypt unchanged.
key = None
decrypted_value = ""
if encrypted_value is not NotImplementedError and encrypted_value is not None:
key = get_db_key()
encrypted_values = encrypted_value.split(":")

if len(encrypted_values) > 1:
iv = binascii.a2b_hex(encrypted_values[1])
value = encrypted_values[2]

scheme = encrypted_values[0]
try:
decrypted_value = decrypt(key, iv, value)
decrypted_value = decrypted_value.decode("utf-8")
except UnicodeDecodeError:
iv = binascii.a2b_hex(encrypted_values[1])
value = encrypted_values[2]
if scheme == "AES.2":
decrypted_value = AESGCM(key).decrypt(iv, binascii.a2b_hex(value), None).decode("utf-8")
else:
# Legacy "AES.1" (AES-256-OFB) read path. Removable once
# migration 0270 is guaranteed to have run everywhere and no
# "AES.1" values remain -- see the REMOVAL TRACKING note on
# the encrypt()/decrypt() block above.
decrypted_value = decrypt(key, iv, value).decode("utf-8")
except (UnicodeDecodeError, InvalidTag, ValueError, IndexError):
decrypted_value = ""

return decrypted_value
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Markdown==3.10.2
openpyxl==3.1.5
Pillow==12.2.0 # required by django-imagekit
psycopg[c]==3.3.4
cryptography==46.0.7
cryptography==49.0.0
python-dateutil==2.9.0.post0
redis==8.0.0
requests==2.34.2
Expand Down Expand Up @@ -65,6 +65,6 @@ netaddr==1.3.0
vulners==3.1.11
fontawesomefree==6.6.0
PyYAML==6.0.3
pyopenssl==26.2.0
pyopenssl==26.3.0
parameterized==0.9.0
setuptools==82.0.1
41 changes: 38 additions & 3 deletions unittests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import binascii
import logging
import os
from contextlib import contextmanager
from unittest.mock import Mock, patch

Expand All @@ -21,7 +23,7 @@
Test_Import_Finding_Action,
)
from dojo.notifications.signals import create_default_notifications
from dojo.utils import dojo_crypto_encrypt, prepare_for_view
from dojo.utils import dojo_crypto_encrypt, encrypt, get_db_key, prepare_for_save, prepare_for_view

from .dojo_test_case import DojoTestCase

Expand Down Expand Up @@ -49,10 +51,43 @@
class TestUtils(DojoTestCase):
def test_encryption(self):
test_input = "Hello World!"
encrypt = dojo_crypto_encrypt(test_input)
test_output = prepare_for_view(encrypt)
encrypted = dojo_crypto_encrypt(test_input)
test_output = prepare_for_view(encrypted)
self.assertEqual(test_input, test_output)

def test_encryption_uses_aes2_format(self):
# New values must be written with the modern AES-256-GCM ("AES.2") scheme.
encrypted = dojo_crypto_encrypt("some secret")
self.assertTrue(encrypted.startswith("AES.2:"))

def test_encryption_roundtrip_variants(self):
# GCM has no block-size constraint, so cover empty, unicode, and long
# (multi-block) inputs to be sure padding-free encryption round-trips.
for value in ["", "ascii-secret", "ünïcödé-pä$$wörd", "x" * 500]:
with self.subTest(value=value):
self.assertEqual(value, prepare_for_view(dojo_crypto_encrypt(value)))

def test_decrypt_legacy_aes1_value(self):
# Values stored by the legacy AES-256-OFB ("AES.1") scheme must still
# decrypt unchanged so existing database secrets are never stranded.
plaintext = "legacy-secret"
key = get_db_key()
iv = os.urandom(16)
legacy_value = prepare_for_save(iv, encrypt(key, iv, plaintext.encode("utf-8")))
self.assertTrue(legacy_value.startswith("AES.1:"))
self.assertEqual(plaintext, prepare_for_view(legacy_value))

def test_decrypt_tampered_or_garbage_returns_empty(self):
# A tampered AES.2 ciphertext (auth tag mismatch) and unparseable input
# must degrade to "" rather than raising.
encrypted = dojo_crypto_encrypt("tamper-me")
scheme, nonce_hex, ct_hex = encrypted.split(":")
ct = bytearray(binascii.a2b_hex(ct_hex))
ct[0] ^= 0xFF # flip a byte to break the GCM auth tag
tampered = ":".join([scheme, nonce_hex, binascii.b2a_hex(bytes(ct)).decode("utf-8")])
self.assertEqual("", prepare_for_view(tampered))
self.assertEqual("", prepare_for_view("AES.2:zzzz:zzzz"))

@patch("dojo.notifications.signals.Notifications")
def test_create_default_notifications_without_template(self, mock_notifications):
user = Dojo_User()
Expand Down
Loading