Skip to content

Commit d900ef2

Browse files
performance optimization: add tags in bulk (#13285)
1 parent 6142157 commit d900ef2

File tree

8 files changed

+735
-24
lines changed

8 files changed

+735
-24
lines changed

dojo/finding/views.py

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
Vulnerability_Id_Template,
101101
)
102102
from dojo.notifications.helper import create_notification
103+
from dojo.tag_utils import bulk_add_tags_to_instances
103104
from dojo.test.queries import get_authorized_tests
104105
from dojo.tools import tool_issue_updater
105106
from dojo.utils import (
@@ -2811,17 +2812,10 @@ def finding_bulk_update_all(request, pid=None):
28112812
finding.save()
28122813

28132814
if form.cleaned_data["tags"]:
2814-
for finding in finds:
2815-
tags = form.cleaned_data["tags"]
2816-
logger.debug(
2817-
"bulk_edit: setting tags for: %i %s %s",
2818-
finding.id,
2819-
finding,
2820-
tags,
2821-
)
2822-
# currently bulk edit overwrites existing tags
2823-
finding.tags = tags
2824-
finding.save()
2815+
tags = form.cleaned_data["tags"]
2816+
logger.debug("bulk_edit: adding tags to %d findings: %s", finds.count(), tags)
2817+
# Delegate parsing and handling of strings/iterables to helper
2818+
bulk_add_tags_to_instances(tag_or_tags=tags, instances=finds, tag_field_name="tags")
28252819

28262820
error_counts = defaultdict(lambda: 0)
28272821
success_count = 0

dojo/importers/base_importer.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
Vulnerability_Id,
3535
)
3636
from dojo.notifications.helper import create_notification
37+
from dojo.tag_utils import bulk_add_tags_to_instances
3738
from dojo.tools.factory import get_parser
3839
from dojo.tools.parser_test import ParserTest
3940
from dojo.utils import max_safe
@@ -424,15 +425,34 @@ def update_import_history(
424425

425426
# Add any tags to the findings imported if necessary
426427
if self.apply_tags_to_findings and self.tags:
427-
for finding in test_import.findings_affected.all():
428-
for tag in self.tags:
429-
self.add_tags_safe(finding, tag)
428+
findings_qs = test_import.findings_affected.all()
429+
try:
430+
bulk_add_tags_to_instances(
431+
tag_or_tags=self.tags,
432+
instances=findings_qs,
433+
tag_field_name="tags",
434+
)
435+
except IntegrityError:
436+
# Fallback to safe per-instance tagging if concurrent deletes occur
437+
for finding in findings_qs:
438+
for tag in self.tags:
439+
self.add_tags_safe(finding, tag)
440+
430441
# Add any tags to any endpoints of the findings imported if necessary
431442
if self.apply_tags_to_endpoints and self.tags:
432-
for finding in test_import.findings_affected.all():
433-
for endpoint in finding.endpoints.all():
434-
for tag in self.tags:
435-
self.add_tags_safe(endpoint, tag)
443+
# Collect all endpoints linked to the affected findings
444+
endpoints_qs = Endpoint.objects.filter(finding__in=test_import.findings_affected.all()).distinct()
445+
try:
446+
bulk_add_tags_to_instances(
447+
tag_or_tags=self.tags,
448+
instances=endpoints_qs,
449+
tag_field_name="tags",
450+
)
451+
except IntegrityError:
452+
for finding in test_import.findings_affected.all():
453+
for endpoint in finding.endpoints.all():
454+
for tag in self.tags:
455+
self.add_tags_safe(endpoint, tag)
436456

437457
return test_import
438458

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import json
2+
import logging
3+
import time
4+
from importlib import import_module
5+
from importlib.util import find_spec
6+
from inspect import isclass
7+
from pathlib import Path
8+
9+
from django.core.management.base import BaseCommand, CommandError
10+
from django.urls import reverse
11+
from rest_framework.authtoken.models import Token
12+
from rest_framework.test import APIClient
13+
14+
from unittests.test_dashboard import User
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class Command(BaseCommand):
20+
21+
help = (
22+
"Import a specific unittest scan by filename. "
23+
"Automatically deduces scan type from path and creates product/engagement using auto_create_context."
24+
)
25+
26+
def add_arguments(self, parser):
27+
parser.add_argument(
28+
"scan_file",
29+
type=str,
30+
help="Path to scan file relative to unittests/scans/ (e.g., 'zap/zap_sample.json')",
31+
)
32+
parser.add_argument(
33+
"--product-name",
34+
type=str,
35+
default="command import",
36+
help="Product name to import into (default: 'command import')",
37+
)
38+
parser.add_argument(
39+
"--engagement-name",
40+
type=str,
41+
default="command import",
42+
help="Engagement name to import into (default: 'command import')",
43+
)
44+
parser.add_argument(
45+
"--product-type-name",
46+
type=str,
47+
default="command import",
48+
help="Product type name to use (default: 'command import')",
49+
)
50+
parser.add_argument(
51+
"--minimum-severity",
52+
type=str,
53+
default="Low",
54+
choices=["Critical", "High", "Medium", "Low", "Info"],
55+
help="Minimum severity to import (default: Low)",
56+
)
57+
parser.add_argument(
58+
"--active",
59+
action="store_true",
60+
default=True,
61+
help="Mark findings as active (default: True)",
62+
)
63+
parser.add_argument(
64+
"--verified",
65+
action="store_true",
66+
default=False,
67+
help="Mark findings as verified (default: False)",
68+
)
69+
parser.add_argument(
70+
"--tags",
71+
action="append",
72+
default=[],
73+
help=(
74+
"Tag(s) to apply to the imported Test (repeat --tags to add multiple). "
75+
"Example: --tags perf --tags jfrog"
76+
),
77+
)
78+
79+
def get_test_admin(self):
80+
return User.objects.get(username="admin")
81+
82+
def import_scan(self, payload, expected_http_status_code=201):
83+
testuser = self.get_test_admin()
84+
token = Token.objects.get(user=testuser)
85+
client = APIClient()
86+
client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
87+
88+
response = client.post(reverse("importscan-list"), payload)
89+
if expected_http_status_code != response.status_code:
90+
msg = f"Expected HTTP status code {expected_http_status_code}, got {response.status_code}: {response.content[:1000]}"
91+
raise CommandError(msg)
92+
return json.loads(response.content)
93+
94+
def deduce_scan_type_from_path(self, scan_file_path):
95+
"""
96+
Deduce the scan type from the file path by finding the corresponding parser.
97+
98+
Args:
99+
scan_file_path: Path like 'zap/zap_sample.json' or 'stackhawk/stackhawk_sample.json'
100+
101+
Returns:
102+
tuple: (scan_type, parser_class) or raises CommandError if not found
103+
104+
"""
105+
# Extract the directory name (parser module name)
106+
path_parts = Path(scan_file_path).parts
107+
if len(path_parts) < 2:
108+
msg = f"Scan file path must include directory: {scan_file_path}"
109+
raise CommandError(msg)
110+
111+
module_name = path_parts[0]
112+
113+
# Try to find and load the parser module
114+
try:
115+
if not find_spec(f"dojo.tools.{module_name}.parser"):
116+
msg = f"No parser module found for '{module_name}'"
117+
raise CommandError(msg)
118+
119+
module = import_module(f"dojo.tools.{module_name}.parser")
120+
121+
# Find the parser class
122+
parser_class = None
123+
expected_class_name = module_name.replace("_", "") + "parser"
124+
125+
for attribute_name in dir(module):
126+
attribute = getattr(module, attribute_name)
127+
if isclass(attribute) and attribute_name.lower() == expected_class_name:
128+
parser_class = attribute
129+
break
130+
131+
if not parser_class:
132+
msg = f"No parser class found in module '{module_name}'"
133+
raise CommandError(msg)
134+
135+
# Get the scan type from the parser
136+
parser_instance = parser_class()
137+
scan_types = parser_instance.get_scan_types()
138+
139+
if not scan_types:
140+
msg = f"Parser '{module_name}' has no scan types"
141+
raise CommandError(msg)
142+
143+
return scan_types[0], parser_class
144+
145+
except ImportError as e:
146+
msg = f"Failed to import parser module '{module_name}': {e}"
147+
raise CommandError(msg)
148+
149+
def import_unittest_scan(self, scan_file, product_name, engagement_name, product_type_name,
150+
minimum_severity, active, verified, tags):
151+
"""
152+
Import a specific unittest scan file.
153+
154+
Args:
155+
scan_file: Path to scan file relative to unittests/scans/
156+
product_name: Name of product to create/use
157+
engagement_name: Name of engagement to create/use
158+
product_type_name: Name of product type to create/use
159+
minimum_severity: Minimum severity level
160+
active: Whether findings should be active
161+
verified: Whether findings should be verified
162+
163+
"""
164+
# Validate scan file exists
165+
scan_path = Path("unittests/scans") / scan_file
166+
if not scan_path.exists():
167+
msg = f"Scan file not found: {scan_path}"
168+
raise CommandError(msg)
169+
170+
# Deduce scan type from path
171+
scan_type, _parser_class = self.deduce_scan_type_from_path(scan_file)
172+
173+
logger.info(f"Importing scan '{scan_file}' using scan type '{scan_type}'")
174+
logger.info(f"Target: Product '{product_name}' -> Engagement '{engagement_name}'")
175+
176+
# Import the scan using auto_create_context
177+
with scan_path.open(encoding="utf-8") as testfile:
178+
payload = {
179+
"minimum_severity": minimum_severity,
180+
"scan_type": scan_type,
181+
"file": testfile,
182+
"version": "1.0.1",
183+
"active": active,
184+
"verified": verified,
185+
"apply_tags_to_findings": True,
186+
"apply_tags_to_endpoints": True,
187+
"auto_create_context": True,
188+
"product_type_name": product_type_name,
189+
"product_name": product_name,
190+
"engagement_name": engagement_name,
191+
"close_old_findings": False,
192+
}
193+
194+
if tags:
195+
payload["tags"] = tags
196+
197+
result = self.import_scan(payload)
198+
199+
logger.info(f"Successfully imported scan. Test ID: {result.get('test_id')}")
200+
logger.info(f"Import summary: {result.get('scan_save_message', 'No summary available')}")
201+
202+
return result
203+
204+
def handle(self, *args, **options):
205+
scan_file = options["scan_file"]
206+
product_name = options["product_name"]
207+
engagement_name = options["engagement_name"]
208+
product_type_name = options["product_type_name"]
209+
minimum_severity = options["minimum_severity"]
210+
active = options["active"]
211+
verified = options["verified"]
212+
tags = options["tags"]
213+
214+
start_time = time.time()
215+
216+
try:
217+
self.import_unittest_scan(
218+
scan_file=scan_file,
219+
product_name=product_name,
220+
engagement_name=engagement_name,
221+
product_type_name=product_type_name,
222+
minimum_severity=minimum_severity,
223+
active=active,
224+
verified=verified,
225+
tags=tags,
226+
)
227+
228+
end_time = time.time()
229+
duration = end_time - start_time
230+
231+
self.stdout.write(
232+
self.style.SUCCESS(
233+
f"Successfully imported '{scan_file}' into product '{product_name}' "
234+
f"(took {duration:.2f} seconds)",
235+
),
236+
)
237+
238+
except Exception as e:
239+
end_time = time.time()
240+
duration = end_time - start_time
241+
logger.exception(f"Failed to import scan '{scan_file}' after {duration:.2f} seconds")
242+
msg = f"Import failed after {duration:.2f} seconds: {e}"
243+
raise CommandError(msg)

dojo/settings/settings.dist.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
DD_CELERY_TASK_SERIALIZER=(str, "pickle"),
9191
DD_CELERY_PASS_MODEL_BY_ID=(str, True),
9292
DD_CELERY_LOG_LEVEL=(str, "INFO"),
93+
DD_TAG_BULK_ADD_BATCH_SIZE=(int, 1000),
9394
# Minimum number of model updated instances before search index updates as performaed asynchronously. Set to -1 to disable async updates.
9495
DD_WATSON_ASYNC_INDEX_UPDATE_THRESHOLD=(int, 100),
9596
DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE=(int, 1000),
@@ -402,6 +403,9 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
402403
MAX_ALERTS_PER_USER = env("DD_MAX_ALERTS_PER_USER")
403404

404405
TAG_PREFETCHING = env("DD_TAG_PREFETCHING")
406+
# Tag bulk add batch size (used by dojo.tag_utils.bulk_add_tag_to_instances)
407+
TAG_BULK_ADD_BATCH_SIZE = env("DD_TAG_BULK_ADD_BATCH_SIZE")
408+
405409

406410
# ------------------------------------------------------------------------------
407411
# DATABASE

0 commit comments

Comments
 (0)