diff --git a/dojo/announcement/os_message.py b/dojo/announcement/os_message.py new file mode 100644 index 00000000000..dfdb9288710 --- /dev/null +++ b/dojo/announcement/os_message.py @@ -0,0 +1,119 @@ +import logging + +import bleach +import markdown +import requests +from django.core.cache import cache + +logger = logging.getLogger(__name__) + +BUCKET_URL = "https://storage.googleapis.com/defectdojo-os-messages-prod/open_source_message.md" +CACHE_SECONDS = 3600 +HTTP_TIMEOUT_SECONDS = 2 +CACHE_KEY = "os_message:v1" + +INLINE_TAGS = ["strong", "em", "a"] +INLINE_ATTRS = {"a": ["href", "title"]} + +# Keep BLOCK_TAGS / BLOCK_ATTRS in sync with the DaaS publisher's +# MARKDOWNIFY["default"]["WHITELIST_TAGS"] / WHITELIST_ATTRS so previews +# on DaaS and rendering in OSS stay byte-identical. +BLOCK_TAGS = [ + "p", "ul", "ol", "li", "a", "strong", "em", "code", "pre", + "blockquote", "h2", "h3", "h4", "hr", "br", "b", "i", + "abbr", "acronym", +] +BLOCK_ATTRS = { + "a": ["href", "title"], + "abbr": ["title"], + "acronym": ["title"], +} + +_MISS = object() + + +def fetch_os_message(): + cached = cache.get(CACHE_KEY, default=_MISS) + if cached is not _MISS: + return cached + + try: + response = requests.get(BUCKET_URL, timeout=HTTP_TIMEOUT_SECONDS) + except Exception: + logger.debug("os_message: fetch failed", exc_info=True) + cache.set(CACHE_KEY, None, CACHE_SECONDS) + return None + + if response.status_code != 200 or not response.text.strip(): + cache.set(CACHE_KEY, None, CACHE_SECONDS) + return None + + cache.set(CACHE_KEY, response.text, CACHE_SECONDS) + return response.text + + +def _strip_outer_p(html): + stripped = html.strip() + if stripped.startswith("
") and stripped.endswith("
"): + return stripped[3:-4] + return stripped + + +def parse_os_message(text): + lines = text.splitlines() + + headline_source = None + body_start = None + for index, line in enumerate(lines): + if line.startswith("# "): + headline_source = line[2:].strip() + body_start = index + 1 + break + + if not headline_source: + return None + + headline_source = headline_source[:100] + headline_rendered = markdown.markdown(headline_source) + headline_cleaned = bleach.clean( + headline_rendered, + tags=INLINE_TAGS, + attributes=INLINE_ATTRS, + strip=True, + ) + headline_html = _strip_outer_p(headline_cleaned) + + expanded_html = None + expanded_marker = "## Expanded Message" + expanded_body_lines = None + for offset, line in enumerate(lines[body_start:], start=body_start): + if line.strip() == expanded_marker: + expanded_body_lines = lines[offset + 1:] + break + + if expanded_body_lines is not None: + expanded_source = "\n".join(expanded_body_lines).strip() + if expanded_source: + expanded_rendered = markdown.markdown( + expanded_source, + extensions=["extra", "fenced_code", "nl2br"], + ) + expanded_html = bleach.clean( + expanded_rendered, + tags=BLOCK_TAGS, + attributes=BLOCK_ATTRS, + strip=True, + ) + + return {"message": headline_html, "expanded_html": expanded_html} + + +def get_os_banner(): + try: + text = fetch_os_message() + if not text: + return None + return parse_os_message(text) + except Exception: + logger.debug("os_message: get_os_banner failed", exc_info=True) + return None diff --git a/dojo/announcement/signals.py b/dojo/announcement/signals.py index dedd3444654..c74fd0e5d50 100644 --- a/dojo/announcement/signals.py +++ b/dojo/announcement/signals.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver @@ -7,22 +6,11 @@ @receiver(post_save, sender=Dojo_User) def add_announcement_to_new_user(sender, instance, **kwargs): - announcements = Announcement.objects.all() - if announcements.count() > 0: - dojo_user = Dojo_User.objects.get(id=instance.id) - announcement = announcements.first() - cloud_announcement = ( - "DefectDojo Pro Cloud and On-Premise Subscriptions Now Available!" - in announcement.message + announcement = Announcement.objects.first() + if announcement is not None: + UserAnnouncement.objects.get_or_create( + user=instance, announcement=announcement, ) - if not cloud_announcement or settings.CREATE_CLOUD_BANNER: - user_announcements = UserAnnouncement.objects.filter( - user=dojo_user, announcement=announcement, - ) - if user_announcements.count() == 0: - UserAnnouncement.objects.get_or_create( - user=dojo_user, announcement=announcement, - ) @receiver(post_save, sender=Announcement) diff --git a/dojo/context_processors.py b/dojo/context_processors.py index cc53af0f1e0..792e1eb6b42 100644 --- a/dojo/context_processors.py +++ b/dojo/context_processors.py @@ -5,13 +5,14 @@ from django.conf import settings from django.contrib import messages +from dojo.announcement.os_message import get_os_banner from dojo.labels import get_labels from dojo.models import Alerts, System_Settings, UserAnnouncement def globalize_vars(request): # return the value you want as a dictionnary. you may add multiple values in there. - return { + context = { "SHOW_LOGIN_FORM": settings.SHOW_LOGIN_FORM, "FORGOT_PASSWORD": settings.FORGOT_PASSWORD, "FORGOT_USERNAME": settings.FORGOT_USERNAME, @@ -35,11 +36,32 @@ def globalize_vars(request): "DOCUMENTATION_URL": settings.DOCUMENTATION_URL, "API_TOKENS_ENABLED": settings.API_TOKENS_ENABLED, "API_TOKEN_AUTH_ENDPOINT_ENABLED": settings.API_TOKEN_AUTH_ENDPOINT_ENABLED, - "CREATE_CLOUD_BANNER": settings.CREATE_CLOUD_BANNER, + "SHOW_PLG_LINK": True, # V3 Feature Flags "V3_FEATURE_LOCATIONS": settings.V3_FEATURE_LOCATIONS, } + 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"], + }) + + if hasattr(request, "session"): + for banner in request.session.pop("_product_banners", []): + additional_banners.append(banner) + + if additional_banners: + context["additional_banners"] = additional_banners + + return context + def bind_system_settings(request): """Load system settings and display warning if there's a database error.""" diff --git a/dojo/management/commands/complete_initialization.py b/dojo/management/commands/complete_initialization.py index 556c77867fb..9ae58926324 100644 --- a/dojo/management/commands/complete_initialization.py +++ b/dojo/management/commands/complete_initialization.py @@ -14,7 +14,6 @@ from django.db.utils import ProgrammingError from dojo.auditlog import configure_pghistory_triggers -from dojo.models import Announcement, Dojo_User, UserAnnouncement class Command(BaseCommand): @@ -38,13 +37,11 @@ def handle(self, *args: Any, **options: Any) -> None: if self.admin_user_exists(): self.stdout.write("Admin user already exists; skipping first-boot setup") - self.create_announcement_banner() self.initialize_data() return self.ensure_admin_secrets() self.first_boot_setup() - self.create_announcement_banner() self.initialize_data() # ------------------------------------------------------------------ @@ -58,29 +55,6 @@ def initialize_data(self) -> None: self.stdout.write("Initializing non-standard permissions") call_command("initialize_permissions") - def create_announcement_banner(self) -> None: - if os.getenv("DD_CREATE_CLOUD_BANNER"): - return - - self.stdout.write("Creating announcement banner") - - announcement, _ = Announcement.objects.get_or_create(id=1) - announcement.message = ( - '' - "DefectDojo Pro Cloud and On-Premise Subscriptions Now Available! " - "Create an account to try Pro for free!" - "" - ) - announcement.dismissable = True - announcement.save() - - for user in Dojo_User.objects.all(): - UserAnnouncement.objects.get_or_create( - user=user, - announcement=announcement, - ) - # ------------------------------------------------------------------ # Auditlog consistency # ------------------------------------------------------------------ diff --git a/dojo/product_announcements.py b/dojo/product_announcements.py index 8510b42a0f8..90280885007 100644 --- a/dojo/product_announcements.py +++ b/dojo/product_announcements.py @@ -1,8 +1,6 @@ import logging -from django.conf import settings -from django.contrib import messages from django.http import HttpRequest, HttpResponse from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -30,12 +28,8 @@ def __init__( response_data: dict | None = None, **kwargs: dict, ): - """Skip all this if the CREATE_CLOUD_BANNER is not set""" - if not settings.CREATE_CLOUD_BANNER: - return - # Fill in the vars if the were supplied correctly if request is not None and isinstance(request, HttpRequest): - self._add_django_message( + self._add_session_banner( request=request, message=mark_safe(f"{self.base_message} {self.ui_outreach}"), ) @@ -51,18 +45,21 @@ def __init__( msg = "At least one of request, response, or response_data must be supplied" raise ValueError(msg) - def _add_django_message(self, request: HttpRequest, message: str): - """Add a message to the UI""" + def _add_session_banner(self, request: HttpRequest, message: str): + """Store a banner in the session for rendering via additional_banners.""" try: - messages.add_message( - request=request, - level=messages.INFO, - message=_(message), - extra_tags="alert-info", - ) + banners = request.session.get("_product_banners", []) + banners.append({ + "source": "product_announcement", + "message": str(_(message)), + "style": "info", + "url": "", + "link_text": "", + "expanded_html": None, + }) + request.session["_product_banners"] = banners except Exception: - # make sure we catch any exceptions that might happen: https://github.com/DefectDojo/django-DefectDojo/issues/14041 - logger.exception(f"Error adding message to Django: {message}") + logger.exception(f"Error storing product announcement banner: {message}") def _add_api_response_key(self, message: str, data: dict) -> dict: """Update the response data in place""" diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 429b5646b90..7dd95aae1f4 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -356,8 +356,6 @@ DD_HASHCODE_FIELDS_PER_SCANNER=(str, ""), # Set deduplication algorithms per parser, via en env variable that contains a JSON string DD_DEDUPLICATION_ALGORITHM_PER_PARSER=(str, ""), - # Dictates whether cloud banner is created or not - DD_CREATE_CLOUD_BANNER=(bool, True), # With this setting turned on, Dojo maintains an audit log of changes made to entities (Findings, Tests, Engagements, Products, ...) # If you run big import you may want to disable this because there's a performance hit during (re-)imports. DD_ENABLE_AUDITLOG=(bool, True), @@ -1339,13 +1337,6 @@ def saml2_attrib_map_format(din): "expires": int(60 * 1 * 1.2), # If a task is not executed within 72 seconds, it should be dropped from the queue. Two more tasks should be scheduled in the meantime. }, }, - "trigger_evaluate_pro_proposition": { - "task": "dojo.tasks.evaluate_pro_proposition", - "schedule": timedelta(hours=8), - "options": { - "expires": int(60 * 60 * 8 * 1.2), # If a task is not executed within 9.6 hours, it should be dropped from the queue. Two more tasks should be scheduled in the meantime. - }, - }, "clear_sessions": { "task": "dojo.tasks.clear_sessions", "schedule": crontab(hour=0, minute=0, day_of_week=0), @@ -2082,9 +2073,6 @@ def saml2_attrib_map_format(din): AUDITLOG_DISABLE_ON_RAW_SAVE = False # You can set extra Jira headers by suppling a dictionary in header: value format (pass as env var like "headr_name=value,another_header=anohter_value") ADDITIONAL_HEADERS = env("DD_ADDITIONAL_HEADERS") -# Dictates whether cloud banner is created or not -CREATE_CLOUD_BANNER = env("DD_CREATE_CLOUD_BANNER") - # ------------------------------------------------------------------------------ # Auditlog # ------------------------------------------------------------------------------ diff --git a/dojo/static/dojo/css/dojo.css b/dojo/static/dojo/css/dojo.css index deba72474c9..fea91da6c94 100644 --- a/dojo/static/dojo/css/dojo.css +++ b/dojo/static/dojo/css/dojo.css @@ -1124,6 +1124,41 @@ div.custom-search-form { .announcement-banner { margin: 0px -15px; border-radius: 0px 0px 4px 4px; + color: #000; +} + +.announcement-banner a { + color: #0645ad; + text-decoration: underline; +} + +.announcement-banner strong, +.announcement-banner b { + color: #222; +} + +.banner-toggle { + background: transparent; + border: 0; + padding: 0; + margin-left: 6px; + color: inherit; + cursor: pointer; + line-height: 1; +} + +.banner-toggle:focus, +.banner-toggle:active { + outline: none; + box-shadow: none; +} + +.banner-toggle:not(.collapsed) .fa-caret-down { + transform: rotate(180deg); +} + +.banner-expanded { + margin-top: 8px; } @media (min-width: 795px) { diff --git a/dojo/tasks.py b/dojo/tasks.py index 5a494072a8b..d1f90275dc2 100644 --- a/dojo/tasks.py +++ b/dojo/tasks.py @@ -16,9 +16,8 @@ from dojo.celery import app from dojo.celery_dispatch import dojo_dispatch_task from dojo.finding.helper import fix_loop_duplicates -from dojo.location.models import Location from dojo.management.commands.jira_status_reconciliation import jira_status_reconciliation -from dojo.models import Alerts, Announcement, Endpoint, Engagement, Finding, Product, System_Settings, User +from dojo.models import Alerts, Engagement, Finding, Product, System_Settings, User from dojo.notifications.helper import create_notification from dojo.utils import calculate_grade, sla_compute_and_notify @@ -218,37 +217,6 @@ def fix_loop_duplicates_task(*args, **kwargs): return fix_loop_duplicates() -@app.task -def evaluate_pro_proposition(*args, **kwargs): - # Ensure we should be doing this - if not settings.CREATE_CLOUD_BANNER: - return - # Get the announcement object - announcement = Announcement.objects.get_or_create(id=1)[0] - # Quick check for a user has modified the current banner - if not, exit early as we dont want to stomp - if not any( - entry in announcement.message - for entry in [ - "", - "DefectDojo Pro Cloud and On-Premise Subscriptions Now Available!", - "Findings/Endpoints in their systems", - ] - ): - return - # Count the objects the determine if the banner should be updated - if settings.V3_FEATURE_LOCATIONS: - object_count = Finding.objects.count() + Location.objects.count() - else: - # TODO: Delete this after the move to Locations - object_count = Finding.objects.count() + Endpoint.objects.count() - # Unless the count is greater than 100k, exit early - if object_count < 100000: - return - # Update the announcement - announcement.message = f'Only professionals have {object_count:,} Findings and Endpoints in their systems... Get DefectDojo Pro today!' - announcement.save() - - @app.task def clear_sessions(*args, **kwargs): call_command("clearsessions") diff --git a/dojo/templates/base.html b/dojo/templates/base.html index 54d6e0e58af..b8f5489f1d7 100644 --- a/dojo/templates/base.html +++ b/dojo/templates/base.html @@ -199,7 +199,7 @@ {% endif %} - {% if CREATE_CLOUD_BANNER %} + {% if SHOW_PLG_LINK %}