Skip to content
Closed
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: 0 additions & 22 deletions docs/content/releases/os_upgrading/3.2.md

This file was deleted.

113 changes: 0 additions & 113 deletions dojo/db_migrations/0270_reencrypt_tool_config_credentials_aes_gcm.py

This file was deleted.

6 changes: 3 additions & 3 deletions dojo/tool_config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=900, null=True, blank=True)
password = models.CharField(max_length=600, 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=9000, null=True, blank=True)
api_key = models.CharField(max_length=900, null=True, blank=True,
ssh = models.CharField(max_length=6000, null=True, blank=True)
api_key = models.CharField(max_length=600, null=True, blank=True,
verbose_name=_("API Key"))

class Meta:
Expand Down
73 changes: 15 additions & 58 deletions dojo/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,8 @@
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
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 cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cvss import CVSS2, CVSS3, CVSS4
from dateutil.parser import parse
from dateutil.relativedelta import MO, SU, relativedelta
Expand Down Expand Up @@ -970,31 +960,11 @@ 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), OFB(iv), backend=backend)
cipher = Cipher(algorithms.AES(key), modes.OFB(iv), backend=backend)
encryptor = cipher.encryptor()
plaintext = _pad_string(plaintext)
encrypted_text = encryptor.update(plaintext) + encryptor.finalize()
Expand All @@ -1004,7 +974,7 @@ def encrypt(key, iv, plaintext):

def decrypt(key, iv, encrypted_text):
backend = default_backend()
cipher = Cipher(algorithms.AES(key), OFB(iv), backend=backend)
cipher = Cipher(algorithms.AES(key), modes.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 @@ -1024,18 +994,14 @@ 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()
# 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")

iv = os.urandom(16)
data = prepare_for_save(
iv, encrypt(key, iv, plaintext.encode("utf-8")))

return data

Expand All @@ -1060,30 +1026,21 @@ 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:
scheme = encrypted_values[0]
iv = binascii.a2b_hex(encrypted_values[1])
value = encrypted_values[2]

try:
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 = decrypt(key, iv, value)
decrypted_value = decrypted_value.decode("utf-8")
except UnicodeDecodeError:
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==49.0.0
cryptography==46.0.7
python-dateutil==2.9.0.post0
redis==8.0.1
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.3.0
pyopenssl==26.2.0
parameterized==0.9.0
setuptools==82.0.1
41 changes: 3 additions & 38 deletions unittests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import binascii
import logging
import os
from contextlib import contextmanager
from unittest.mock import Mock, patch

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

from .dojo_test_case import DojoTestCase

Expand Down Expand Up @@ -51,43 +49,10 @@
class TestUtils(DojoTestCase):
def test_encryption(self):
test_input = "Hello World!"
encrypted = dojo_crypto_encrypt(test_input)
test_output = prepare_for_view(encrypted)
encrypt = dojo_crypto_encrypt(test_input)
test_output = prepare_for_view(encrypt)
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