Skip to content

Commit e8a9b66

Browse files
committed
Product Announcements: Add messages to relevant features
1 parent daeaa43 commit e8a9b66

9 files changed

Lines changed: 252 additions & 10 deletions

File tree

dojo/api_v2/exception_handler.py

Lines changed: 2 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

@@ -65,4 +66,5 @@ def custom_exception_handler(exc, context):
6566
# They get logged and we don't change the response.
6667
logger.error(exc)
6768

69+
ErrorPageProductAnnouncement(response=response)
6870
return 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
@@ -119,6 +120,10 @@
119120
)
120121
from dojo.user.utils import get_configuration_permissions_codenames
121122
from dojo.utils import is_scan_file_too_large, tag_validator
123+
from dojo.product_announcements import (
124+
LargeScanSizeProductAnnouncement,
125+
ScanTypeProductAnnouncement,
126+
)
122127

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

dojo/settings/settings.dist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
919919
"dojo.middleware.AuditlogMiddleware",
920920
"crum.CurrentRequestUserMiddleware",
921921
"dojo.request_cache.middleware.RequestCacheMiddleware",
922+
"dojo.middleware.LongRunningRequestAlertMiddleware",
922923
]
923924

924925
MIDDLEWARE = DJANGO_MIDDLEWARE_CLASSES

0 commit comments

Comments
 (0)