Skip to content

Commit 4ca3098

Browse files
authored
Product Announcements: Add messages to relevant features (#12525)
* Product Announcements: Add messages to relevant features * Specify exactly where the error is modified * Correcting ruff * Ruff can be dangerous?
1 parent d720d38 commit 4ca3098

File tree

9 files changed

+255
-10
lines changed

9 files changed

+255
-10
lines changed

dojo/api_v2/exception_handler.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from rest_framework.views import exception_handler
1313

1414
from dojo.models import System_Settings
15+
from dojo.product_announcements import ErrorPageProductAnnouncement
1516

1617
logger = logging.getLogger(__name__)
1718

@@ -36,6 +37,7 @@ def custom_exception_handler(exc, context):
3637
response.status_code = HTTP_400_BAD_REQUEST
3738
response.data = {}
3839
response.data["message"] = str(exc)
40+
ErrorPageProductAnnouncement(response=response)
3941
elif response is None:
4042
if System_Settings.objects.get().api_expose_error_details:
4143
exception_message = str(exc.args[0])
@@ -51,6 +53,7 @@ def custom_exception_handler(exc, context):
5153
response.data[
5254
"message"
5355
] = exception_message
56+
ErrorPageProductAnnouncement(response=response)
5457
elif response.status_code < 500:
5558
# HTTP status codes lower than 500 are no technical errors.
5659
# They need not to be logged and we provide the exception
@@ -60,6 +63,7 @@ def custom_exception_handler(exc, context):
6063
exc,
6164
) != response.data.get("detail", ""):
6265
response.data["message"] = str(exc)
66+
ErrorPageProductAnnouncement(response=response)
6367
else:
6468
# HTTP status code 500 or higher are technical errors.
6569
# They get logged and we don't change the response.

dojo/api_v2/serializers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import logging
55
import re
6+
import time
67
from datetime import datetime
78

89
import six
@@ -112,6 +113,10 @@
112113
Vulnerability_Id_Template,
113114
get_current_date,
114115
)
116+
from dojo.product_announcements import (
117+
LargeScanSizeProductAnnouncement,
118+
ScanTypeProductAnnouncement,
119+
)
115120
from dojo.tools.factory import (
116121
get_choices_sorted,
117122
requires_file,
@@ -2193,6 +2198,7 @@ class CommonImportScanSerializer(serializers.Serializer):
21932198
product_id = serializers.IntegerField(read_only=True)
21942199
product_type_id = serializers.IntegerField(read_only=True)
21952200
statistics = ImportStatisticsSerializer(read_only=True, required=False)
2201+
pro = serializers.ListField(read_only=True, required=False)
21962202
apply_tags_to_findings = serializers.BooleanField(
21972203
help_text="If set to True, the tags will be applied to the findings",
21982204
required=False,
@@ -2224,6 +2230,7 @@ def process_scan(
22242230
Raises exceptions in the event of an error
22252231
"""
22262232
try:
2233+
start_time = time.perf_counter()
22272234
importer = self.get_importer(**context)
22282235
context["test"], _, _, _, _, _, _ = importer.process_scan(
22292236
context.pop("scan", None),
@@ -2236,6 +2243,9 @@ def process_scan(
22362243
data["product_id"] = test.engagement.product.id
22372244
data["product_type_id"] = test.engagement.product.prod_type.id
22382245
data["statistics"] = {"after": test.statistics}
2246+
duration = time.perf_counter() - start_time
2247+
LargeScanSizeProductAnnouncement(response_data=data, duration=duration)
2248+
ScanTypeProductAnnouncement(response_data=data, scan_type=context.get("scan_type"))
22392249
# convert to exception otherwise django rest framework will swallow them as 400 error
22402250
# exceptions are already logged in the importer
22412251
except SyntaxError as se:
@@ -2491,6 +2501,7 @@ def process_scan(
24912501
"""
24922502
statistics_before, statistics_delta = None, None
24932503
try:
2504+
start_time = time.perf_counter()
24942505
if test := context.get("test"):
24952506
statistics_before = test.statistics
24962507
context["test"], _, _, _, _, _, test_import = self.get_reimporter(
@@ -2525,6 +2536,9 @@ def process_scan(
25252536
if statistics_delta:
25262537
data["statistics"]["delta"] = statistics_delta
25272538
data["statistics"]["after"] = test.statistics
2539+
duration = time.perf_counter() - start_time
2540+
LargeScanSizeProductAnnouncement(response_data=data, duration=duration)
2541+
ScanTypeProductAnnouncement(response_data=data, scan_type=context.get("scan_type"))
25282542
# convert to exception otherwise django rest framework will swallow them as 400 error
25292543
# exceptions are already logged in the importer
25302544
except SyntaxError as se:

dojo/engagement/views.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import mimetypes
44
import operator
55
import re
6+
import time
67
from datetime import datetime
78
from functools import reduce
89
from pathlib import Path
@@ -88,6 +89,11 @@
8889
)
8990
from dojo.notifications.helper import create_notification
9091
from dojo.product.queries import get_authorized_products
92+
from dojo.product_announcements import (
93+
ErrorPageProductAnnouncement,
94+
LargeScanSizeProductAnnouncement,
95+
ScanTypeProductAnnouncement,
96+
)
9197
from dojo.risk_acceptance.helper import prefetch_for_expiration
9298
from dojo.tools.factory import get_scan_types_sorted
9399
from dojo.user.queries import get_authorized_users
@@ -1027,16 +1033,22 @@ def process_credentials_form(
10271033

10281034
def success_redirect(
10291035
self,
1036+
request: HttpRequest,
10301037
context: dict,
10311038
) -> HttpResponseRedirect:
10321039
"""Redirect the user to a place that indicates a successful import"""
1040+
duration = time.perf_counter() - request._start_time
1041+
LargeScanSizeProductAnnouncement(request=request, duration=duration)
1042+
ScanTypeProductAnnouncement(request=request, scan_type=context.get("scan_type"))
10331043
return HttpResponseRedirect(reverse("view_test", args=(context.get("test").id, )))
10341044

10351045
def failure_redirect(
10361046
self,
1047+
request: HttpRequest,
10371048
context: dict,
10381049
) -> HttpResponseRedirect:
10391050
"""Redirect the user to a place that indicates a failed import"""
1051+
ErrorPageProductAnnouncement(request=request)
10401052
return HttpResponseRedirect(reverse(
10411053
"import_scan_results",
10421054
args=(context.get("engagement", context.get("product")).id, ),
@@ -1071,27 +1083,28 @@ def post(
10711083
engagement_id=engagement_id,
10721084
product_id=product_id,
10731085
)
1086+
request._start_time = time.perf_counter()
10741087
# ensure all three forms are valid first before moving forward
10751088
if not self.validate_forms(context):
1076-
return self.failure_redirect(context)
1089+
return self.failure_redirect(request, context)
10771090
# Process the jira form if it is present
10781091
if form_error := self.process_jira_form(request, context.get("jform"), context):
10791092
add_error_message_to_response(form_error)
1080-
return self.failure_redirect(context)
1093+
return self.failure_redirect(request, context)
10811094
# Process the import form
10821095
if form_error := self.process_form(request, context.get("form"), context):
10831096
add_error_message_to_response(form_error)
1084-
return self.failure_redirect(context)
1097+
return self.failure_redirect(request, context)
10851098
# Kick off the import process
10861099
if import_error := self.import_findings(context):
10871100
add_error_message_to_response(import_error)
1088-
return self.failure_redirect(context)
1101+
return self.failure_redirect(request, context)
10891102
# Process the credential form
10901103
if form_error := self.process_credentials_form(request, context.get("cred_form"), context):
10911104
add_error_message_to_response(form_error)
1092-
return self.failure_redirect(context)
1105+
return self.failure_redirect(request, context)
10931106
# Otherwise return the user back to the engagement (if present) or the product
1094-
return self.success_redirect(context)
1107+
return self.success_redirect(request, context)
10951108

10961109

10971110
@user_is_authorized(Engagement, Permissions.Engagement_Edit, "eid")

dojo/middleware.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import re
3+
import time
34
from contextlib import suppress
45
from threading import local
56
from urllib.parse import quote
@@ -12,6 +13,8 @@
1213
from django.urls import reverse
1314
from django.utils.functional import SimpleLazyObject
1415

16+
from dojo.product_announcements import LongRunningRequestProductAnnouncement
17+
1518
logger = logging.getLogger(__name__)
1619

1720
EXEMPT_URLS = [re.compile(settings.LOGIN_URL.lstrip("/"))]
@@ -184,3 +187,24 @@ def __call__(self, request):
184187

185188
with context:
186189
return self.get_response(request)
190+
191+
192+
class LongRunningRequestAlertMiddleware:
193+
def __init__(self, get_response):
194+
self.get_response = get_response
195+
self.ignored_paths = [
196+
re.compile(r"^/api/v2/.*"),
197+
re.compile(r"^/product/(?P<product_id>\d+)/import_scan_results$"),
198+
re.compile(r"^/engagement/(?P<engagement_id>\d+)/import_scan_results$"),
199+
re.compile(r"^/test/(?P<test_id>\d+)/re_import_scan_results"),
200+
re.compile(r"^/alerts/count"),
201+
]
202+
203+
def __call__(self, request):
204+
start_time = time.perf_counter()
205+
response = self.get_response(request)
206+
duration = time.perf_counter() - start_time
207+
if not any(pattern.match(request.path_info) for pattern in self.ignored_paths):
208+
LongRunningRequestProductAnnouncement(request=request, duration=duration)
209+
210+
return response

dojo/product_announcements.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
from django.conf import settings
2+
from django.contrib import messages
3+
from django.http import HttpRequest, HttpResponse
4+
from django.utils.safestring import mark_safe
5+
from django.utils.translation import gettext_lazy as _
6+
7+
8+
class ProductAnnouncementManager:
9+
10+
"""Base class for centralized helper methods"""
11+
12+
base_try_free = "Try today for free"
13+
base_contact_us = "email us at"
14+
base_email_address = "hello@defectdojo.com"
15+
ui_try_free = f'<b><a href="https://cloud.defectdojo.com/accounts/onboarding/plg_step_1" target="_blank">{base_try_free}</a></b>'
16+
ui_contact_us = f'{base_contact_us} <b><a href="mailto:{base_email_address}">{base_email_address}</a></b>'
17+
ui_outreach = f"{ui_try_free} or {ui_contact_us}."
18+
api_outreach = f"{base_try_free} or {base_contact_us} {base_email_address}"
19+
20+
def __init__(
21+
self,
22+
*args: list,
23+
request: HttpRequest = None,
24+
response: HttpResponse = None,
25+
response_data: dict | None = None,
26+
**kwargs: dict,
27+
):
28+
"""Skip all this if the CREATE_CLOUD_BANNER is not set"""
29+
if not settings.CREATE_CLOUD_BANNER:
30+
return
31+
# Fill in the vars if the were supplied correctly
32+
if request is not None and isinstance(request, HttpRequest):
33+
self._add_django_message(
34+
request=request,
35+
message=mark_safe(f"{self.base_message} {self.ui_outreach}"),
36+
)
37+
elif response is not None and isinstance(response, HttpResponse):
38+
response.data = self._add_api_response_key(
39+
message=f"{self.base_message} {self.api_outreach}", data=response.data,
40+
)
41+
elif response_data is not None and isinstance(response_data, dict):
42+
response_data = self._add_api_response_key(
43+
message=f"{self.base_message} {self.api_outreach}", data=response_data,
44+
)
45+
else:
46+
msg = "At least one of request, response, or response_data must be supplied"
47+
raise ValueError(msg)
48+
49+
def _add_django_message(self, request: HttpRequest, message: str):
50+
"""Add a message to the UI"""
51+
messages.add_message(
52+
request=request,
53+
level=messages.INFO,
54+
message=_(message),
55+
extra_tags="alert-info",
56+
)
57+
58+
def _add_api_response_key(self, message: str, data: dict) -> dict:
59+
"""Update the response data in place"""
60+
if (feature_list := data.get("pro")) is not None and isinstance(
61+
feature_list,
62+
list,
63+
):
64+
data["pro"] = [*feature_list, _(message)]
65+
else:
66+
data["pro"] = [_(message)]
67+
return data
68+
69+
70+
class ErrorPageProductAnnouncement(ProductAnnouncementManager):
71+
def __init__(
72+
self,
73+
*args: list,
74+
request: HttpRequest = None,
75+
response: HttpResponse = None,
76+
response_data: dict | None = None,
77+
**kwargs: dict,
78+
):
79+
self.base_message = "Pro comes with support."
80+
super().__init__(
81+
*args,
82+
request=request,
83+
response=response,
84+
response_data=response_data,
85+
**kwargs,
86+
)
87+
88+
89+
class LargeScanSizeProductAnnouncement(ProductAnnouncementManager):
90+
def __init__(
91+
self,
92+
*args: list,
93+
request: HttpRequest = None,
94+
response: HttpResponse = None,
95+
response_data: dict | None = None,
96+
duration: float = 0.0, # seconds
97+
**kwargs: dict,
98+
):
99+
self.trigger_threshold = 60.0
100+
minute_duration = round(duration / 60.0)
101+
self.base_message = f"Your import took about {minute_duration} minute(s). Did you know Pro has async imports?"
102+
if duration > self.trigger_threshold:
103+
super().__init__(
104+
*args,
105+
request=request,
106+
response=response,
107+
response_data=response_data,
108+
**kwargs,
109+
)
110+
111+
112+
class LongRunningRequestProductAnnouncement(ProductAnnouncementManager):
113+
def __init__(
114+
self,
115+
*args: list,
116+
request: HttpRequest = None,
117+
response: HttpResponse = None,
118+
response_data: dict | None = None,
119+
duration: float = 0.0, # seconds
120+
**kwargs: dict,
121+
):
122+
self.trigger_threshold = 15.0
123+
self.base_message = "Did you know, Pro has a new UI and is performance tested up to 22M findings?"
124+
if duration > self.trigger_threshold:
125+
super().__init__(
126+
*args,
127+
request=request,
128+
response=response,
129+
response_data=response_data,
130+
**kwargs,
131+
)
132+
133+
134+
class ScanTypeProductAnnouncement(ProductAnnouncementManager):
135+
supported_scan_types = [
136+
"Snyk Scan",
137+
"Semgrep JSON Report",
138+
"Burp Enterprise Scan",
139+
"AWS Security Hub Scan",
140+
"Probely Scan", # No OS support here
141+
"Checkmarx One Scan",
142+
"Tenable Scan",
143+
"SonarQube Scan",
144+
"Dependency Track Finding Packaging Format (FPF) Export",
145+
"Wiz Scan",
146+
]
147+
148+
def __init__(
149+
self,
150+
*args: list,
151+
request: HttpRequest = None,
152+
response: HttpResponse = None,
153+
response_data: dict | None = None,
154+
scan_type: str | None = None,
155+
**kwargs: dict,
156+
):
157+
self.base_message = (
158+
f"Did you know, Pro has an automated no-code connector for {scan_type}?"
159+
)
160+
if scan_type in self.supported_scan_types:
161+
super().__init__(
162+
*args,
163+
request=request,
164+
response=response,
165+
response_data=response_data,
166+
**kwargs,
167+
)

dojo/settings/settings.dist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
913913
"dojo.middleware.AuditlogMiddleware",
914914
"crum.CurrentRequestUserMiddleware",
915915
"dojo.request_cache.middleware.RequestCacheMiddleware",
916+
"dojo.middleware.LongRunningRequestAlertMiddleware",
916917
]
917918

918919
MIDDLEWARE = DJANGO_MIDDLEWARE_CLASSES

0 commit comments

Comments
 (0)