Skip to content
Draft
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
8 changes: 8 additions & 0 deletions Dockerfile.django-alpine
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ RUN \
mkdir -p dojo/migrations && \
chmod g=u dojo/migrations && \
true

# Compile translation catalogs (.mo) into the image (the .mo files are gitignored
# and generated at build). msgfmt is run directly so no Django settings/env are
# needed; gettext is only required at build time, so install and remove it here.
RUN \
apk add --no-cache --virtual .build-i18n gettext && \
find dojo/locale -name 'django.po' -execdir msgfmt django.po -o django.mo \; && \
apk del .build-i18n
USER root
RUN \
addgroup --gid ${gid} ${appuser} && \
Expand Down
11 changes: 11 additions & 0 deletions Dockerfile.django-debian
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ RUN \
mkdir -p dojo/migrations && \
chmod g=u dojo/migrations && \
true

# Compile translation catalogs (.mo) into the image (the .mo files are gitignored
# and generated at build). msgfmt is run directly so no Django settings/env are
# needed; gettext is only required at build time, so install and purge it here.
RUN \
apt-get -y update && \
apt-get -y install --no-install-recommends gettext && \
find dojo/locale -name 'django.po' -execdir msgfmt django.po -o django.mo \; && \
apt-get -y purge --auto-remove gettext && \
apt-get clean && \
rm -rf /var/lib/apt/lists
USER root
RUN \
addgroup --gid ${gid} ${appuser} && \
Expand Down
8 changes: 6 additions & 2 deletions docs/content/en/open_source/upgrading/3.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
title: 'Upgrading to DefectDojo Version 3.1.x'
toc_hide: true
weight: -20260615
description: No special instructions.
description: New optional setting DD_OS_MESSAGE_ENABLED to control the open-source promo banner.
---
There are no special instructions for upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release.
There are no breaking changes when upgrading to 3.1.x. Check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.1.0) for the contents of the release.

### New setting: `DD_OS_MESSAGE_ENABLED`

This release adds the `DD_OS_MESSAGE_ENABLED` setting (default `True`), which controls the open-source promotional ("Upgrade to Pro") banner. The default preserves the existing behavior. Set `DD_OS_MESSAGE_ENABLED=False` to hide the banner; when disabled, DefectDojo skips the outbound request that fetches the message.
10 changes: 9 additions & 1 deletion dojo/announcement/os_message.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import hashlib
import logging

import bleach
import markdown
import requests
from django.conf import settings
from django.core.cache import cache

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -109,11 +111,17 @@ def parse_os_message(text):


def get_os_banner():
if not settings.OS_MESSAGE_ENABLED:
return None
try:
text = fetch_os_message()
if not text:
return None
return parse_os_message(text)
banner = parse_os_message(text)
except Exception:
logger.debug("os_message: get_os_banner failed", exc_info=True)
return None
else:
if banner:
banner["dismiss_token"] = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
return banner
5 changes: 5 additions & 0 deletions dojo/announcement/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@
views.dismiss_announcement,
name="dismiss_announcement",
),
re_path(
r"^dismiss_os_message$",
views.dismiss_os_message,
name="dismiss_os_message",
),
]
27 changes: 25 additions & 2 deletions dojo/announcement/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import logging
import re

from django.contrib import messages
from django.http import HttpResponseRedirect
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST

from dojo.forms import AnnouncementCreateForm, AnnouncementRemoveForm
from dojo.models import Announcement, UserAnnouncement
from dojo.models import Announcement, UserAnnouncement, UserContactInfo
from dojo.utils import add_breadcrumb

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -85,3 +88,23 @@ def dismiss_announcement(request):
)
return render(request, "dojo/dismiss_announcement.html")
return render(request, "dojo/dismiss_announcement.html")


@require_POST
def dismiss_os_message(request):
if not request.user.is_authenticated:
return HttpResponseForbidden()
token = request.POST.get("token", "").strip()
if token and re.fullmatch(r"[0-9a-f]{1,64}", token):
contact = UserContactInfo.objects.get_or_create(user=request.user)[0]
if contact.os_message_dismissed_hash != token:
contact.os_message_dismissed_hash = token
contact.save(update_fields=["os_message_dismissed_hash"])
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return HttpResponse(status=204)
referer = request.META.get("HTTP_REFERER")
if referer and url_has_allowed_host_and_scheme(
referer, allowed_hosts={request.get_host()}, require_https=request.is_secure(),
):
return HttpResponseRedirect(referer)
return HttpResponseRedirect("/")
27 changes: 19 additions & 8 deletions dojo/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,20 @@ def globalize_vars(request):
additional_banners = []

if (os_banner := get_os_banner()) is not None:
additional_banners.append({
"source": "os",
"message": os_banner["message"],
"style": "info",
"url": "",
"link_text": "",
"expanded_html": os_banner["expanded_html"],
})
token = os_banner.get("dismiss_token", "")
user = getattr(request, "user", None)
dismissible = bool(token and getattr(user, "is_authenticated", False))
if not (dismissible and _os_message_dismissed(user, token)):
additional_banners.append({
"source": "os",
"message": os_banner["message"],
"style": "info",
"url": "",
"link_text": "",
"expanded_html": os_banner["expanded_html"],
"dismissible": dismissible,
"dismiss_token": token,
})

if hasattr(request, "session"):
for banner in request.session.pop("_product_banners", []):
Expand Down Expand Up @@ -72,6 +78,11 @@ def _should_show_ui_toggle_banner(request):
return not (contact is not None and getattr(contact, "ui_use_tailwind", False))


def _os_message_dismissed(user, token):
contact = getattr(user, "usercontactinfo", None)
return contact is not None and contact.os_message_dismissed_hash == token


def bind_system_settings(request):
"""Load system settings and display warning if there's a database error."""
try:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-06-25 00:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dojo', '0269_normalize_blank_finding_components'),
]

operations = [
migrations.AddField(
model_name='usercontactinfo',
name='os_message_dismissed_hash',
field=models.CharField(blank=True, default='', editable=False, help_text='Hash of the most recently dismissed open-source promo banner; the banner reappears when the message changes.', max_length=64),
),
]
18 changes: 18 additions & 0 deletions dojo/db_migrations/0271_usercontactinfo_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-06-26 00:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dojo', '0270_usercontactinfo_os_message_dismissed_hash'),
]

operations = [
migrations.AddField(
model_name='usercontactinfo',
name='language',
field=models.CharField(blank=True, default='', help_text='Preferred language for the DefectDojo UI. Leave blank to use the instance default.', max_length=12, verbose_name='Language'),
),
]
6 changes: 6 additions & 0 deletions dojo/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2378,6 +2378,12 @@ class Meta:


class UserContactInfoForm(forms.ModelForm):
language = forms.ChoiceField(
required=False,
choices=[("", _("Use instance default")), *settings.LANGUAGES],
label=_("Language"),
help_text=_("Preferred language for the DefectDojo UI."),
)
reset_api_token = forms.BooleanField(
required=False,
label=_("Reset API token"),
Expand Down
12 changes: 9 additions & 3 deletions dojo/locale/pt_BR/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -3537,14 +3537,18 @@ msgstr "Vulnerabilidades de alta gravidade"
msgid ""
"\n"
" %(name)s is affected by critical vulnerabilities."
msgstr "%(name)s é afetado por vulnerabilidades críticas."
msgstr ""
"\n"
"%(name)s é afetado por vulnerabilidades críticas."

#: dojo/templates/dojo/metrics.html
#, python-format
msgid ""
"\n"
" %(name)s is affected by high severity vulnerabilities."
msgstr "%(name)s é afetado por vulnerabilidades de alta gravidade."
msgstr ""
"\n"
"%(name)s é afetado por vulnerabilidades de alta gravidade."

#: dojo/templates/dojo/metrics.html
msgid "Full Metrics"
Expand Down Expand Up @@ -3789,7 +3793,9 @@ msgid ""
"\n"
" Showing entries %(start_index)s to %(end_index)s of %(count)s\n"
" "
msgstr "Mostrando entradas %(start_index)s a %(end_index)s de %(count)s"
msgstr ""
"\n"
"Mostrando entradas %(start_index)s a %(end_index)s de %(count)s"

#: dojo/templates/dojo/paging_snippet.html
msgid "Page Size"
Expand Down
59 changes: 59 additions & 0 deletions dojo/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.db import models
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import translation
from watson.middleware import SearchContextMiddleware
from watson.search import search_context_manager

Expand Down Expand Up @@ -194,6 +195,64 @@ def __call__(self, request):
return self.get_response(request)


def set_language_cookie(response, language):
"""Persist (or clear) the UI-language cookie that Django's LocaleMiddleware reads."""
if language:
response.set_cookie(
settings.LANGUAGE_COOKIE_NAME,
language,
max_age=settings.LANGUAGE_COOKIE_AGE,
path=settings.LANGUAGE_COOKIE_PATH,
domain=settings.LANGUAGE_COOKIE_DOMAIN,
secure=settings.LANGUAGE_COOKIE_SECURE,
httponly=settings.LANGUAGE_COOKIE_HTTPONLY,
samesite=settings.LANGUAGE_COOKIE_SAMESITE,
)
else:
response.delete_cookie(
settings.LANGUAGE_COOKIE_NAME,
path=settings.LANGUAGE_COOKIE_PATH,
domain=settings.LANGUAGE_COOKIE_DOMAIN,
)
return response


class LanguagePreferenceMiddleware:

"""
Seed the language cookie from the user's stored preference when it is missing.

UserContactInfo.language is the durable, cross-device source of truth. The DB
is read only when the django_language cookie is absent (roughly once per
browser session); thereafter Django's LocaleMiddleware serves the cookie with
no per-request DB access. API requests are skipped: they localize via
Accept-Language and their clients typically do not keep cookies, so seeding
from the DB there would add a query to every API call.
"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
seed = ""
user = getattr(request, "user", None)
if (
user is not None
and getattr(user, "is_authenticated", False)
and settings.LANGUAGE_COOKIE_NAME not in request.COOKIES
and not request.path.startswith("/api/")
):
contact = getattr(user, "usercontactinfo", None)
seed = getattr(contact, "language", "") if contact is not None else ""
if seed:
translation.activate(seed)
request.LANGUAGE_CODE = seed
response = self.get_response(request)
if seed:
set_language_cookie(response, seed)
return response


class PgHistoryMiddleware(pghistory.middleware.HistoryMiddleware):

"""
Expand Down
2 changes: 2 additions & 0 deletions dojo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ class UserContactInfo(models.Model):
ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI."))
token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user."))
password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user."))
os_message_dismissed_hash = models.CharField(max_length=64, blank=True, default="", editable=False, help_text=_("Hash of the most recently dismissed open-source promo banner; the banner reappears when the message changes."))
language = models.CharField(max_length=12, blank=True, default="", verbose_name=_("Language"), help_text=_("Preferred language for the DefectDojo UI. Leave blank to use the instance default."))


class System_Settings(models.Model):
Expand Down
17 changes: 17 additions & 0 deletions dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
DD_FORGOT_PASSWORD=(bool, True), # do we show link "I forgot my password" on login screen
DD_PASSWORD_RESET_TIMEOUT=(int, 259200), # 3 days, in seconds (the deafult)
DD_FORGOT_USERNAME=(bool, True), # do we show link "I forgot my username" on login screen
DD_OS_MESSAGE_ENABLED=(bool, True), # show the open-source "Upgrade to Pro" / OS message promo banner
# Some security policies require allowing users to have only one active session
DD_SINGLE_USER_SESSION=(bool, False),
# if somebody is using own documentation how to use DefectDojo in his own company
Expand Down Expand Up @@ -330,6 +331,16 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
# to load the internationalization machinery.
USE_I18N = env("DD_USE_I18N")

# Languages offered in the UI. Only languages with an audited catalog are listed;
# es/ja/de/fr are added once their translations land (their .po files are generated
# for translators). Stored DB values and serialized API values always remain English
# regardless of the selected language; only displayed text changes.
LANGUAGES = [
("en", "English"),
("pt-br", "Português (Brasil)"),
("ru", "Русский"),
]

# If you set this to False, Django will not use timezone-aware datetimes.
USE_TZ = env("DD_USE_TZ")

Expand Down Expand Up @@ -380,6 +391,9 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param

DOJO_ROOT = env("DD_ROOT")

# Where Django looks for translation catalogs: dojo/locale/<lang>/LC_MESSAGES/.
LOCALE_PATHS = [Path(DOJO_ROOT) / "locale"]

# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/var/www/example.com/media/"
MEDIA_ROOT = env("DD_MEDIA_ROOT")
Expand Down Expand Up @@ -474,6 +488,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
REQUIRE_PASSWORD_ON_USER = env("DD_REQUIRE_PASSWORD_ON_USER")
FORGOT_USERNAME = env("DD_FORGOT_USERNAME")
PASSWORD_RESET_TIMEOUT = env("DD_PASSWORD_RESET_TIMEOUT")
OS_MESSAGE_ENABLED = env("DD_OS_MESSAGE_ENABLED")

DOCUMENTATION_URL = env("DD_DOCUMENTATION_URL")

Expand Down Expand Up @@ -793,10 +808,12 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
"dojo.middleware.APITrailingSlashMiddleware",
"dojo.middleware.DojoSytemSettingsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.middleware.security.SecurityMiddleware",
"django_permissions_policy.PermissionsPolicyMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"dojo.middleware.LanguagePreferenceMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
Expand Down
Loading
Loading