diff --git a/docs/content/releases/os_upgrading/3.2.md b/docs/content/releases/os_upgrading/3.2.md new file mode 100644 index 00000000000..46df4ea70ac --- /dev/null +++ b/docs/content/releases/os_upgrading/3.2.md @@ -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. The same migration also widens the `password`, `ssh`, and `api_key` columns by 50% so that credentials stored at the old maximum length still fit once the GCM nonce and authentication tag are added. + +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). diff --git a/dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py b/dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py new file mode 100644 index 00000000000..78ea9ecc04f --- /dev/null +++ b/dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py @@ -0,0 +1,113 @@ +import logging + +from django.db import migrations, models + +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 = [ + # Widen the encrypted credential columns first. AES-256-GCM appends a + # 12-byte nonce and a 16-byte authentication tag (rendered as hex in the + # "AES.2::" payload), so a value stored at the old max length + # under AES.1 would overflow the column when re-encrypted below. Each + # field is grown by 50% of its previous max_length to leave ample room. + migrations.AlterField( + model_name="tool_configuration", + name="password", + field=models.CharField(blank=True, max_length=900, null=True), + ), + migrations.AlterField( + model_name="tool_configuration", + name="ssh", + field=models.CharField(blank=True, max_length=9000, null=True), + ), + migrations.AlterField( + model_name="tool_configuration", + name="api_key", + field=models.CharField(blank=True, max_length=900, null=True, verbose_name="API Key"), + ), + migrations.RunPython(reencrypt_tool_config_credentials, noop_reverse), + ] diff --git a/dojo/tool_config/models.py b/dojo/tool_config/models.py index 6190fe839ce..f8281c098db 100644 --- a/dojo/tool_config/models.py +++ b/dojo/tool_config/models.py @@ -17,11 +17,11 @@ class Tool_Configuration(models.Model): extras = models.CharField(max_length=255, null=True, blank=True, help_text=_("Additional definitions that will be " "consumed by scanner")) username = models.CharField(max_length=200, null=True, blank=True) - password = models.CharField(max_length=600, null=True, blank=True) + password = models.CharField(max_length=900, null=True, blank=True) auth_title = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Title for SSH/API Key")) - ssh = models.CharField(max_length=6000, null=True, blank=True) - api_key = models.CharField(max_length=600, null=True, blank=True, + ssh = models.CharField(max_length=9000, null=True, blank=True) + api_key = models.CharField(max_length=900, null=True, blank=True, verbose_name=_("API Key")) class Meta: diff --git a/dojo/utils.py b/dojo/utils.py index 44fdd51ca21..e68a5e92b4e 100644 --- a/dojo/utils.py +++ b/dojo/utils.py @@ -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 @@ -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() @@ -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() @@ -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 @@ -1026,7 +1060,10 @@ 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: @@ -1034,13 +1071,19 @@ def prepare_for_view(encrypted_value): 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 diff --git a/requirements.txt b/requirements.txt index 44e593ee9e3..ac5c98fea4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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.1 requests==2.34.2 @@ -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 diff --git a/unittests/test_utils.py b/unittests/test_utils.py index 6d28e57dede..7206653426a 100644 --- a/unittests/test_utils.py +++ b/unittests/test_utils.py @@ -1,4 +1,6 @@ +import binascii import logging +import os from contextlib import contextmanager from unittest.mock import Mock, patch @@ -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 @@ -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()