Skip to content

Commit f8bc92c

Browse files
committed
Dynamic group
* dynamic-group * own mode for each user
1 parent 57c95cb commit f8bc92c

14 files changed

+1826
-3
lines changed

docker-compose.override.unit_tests_cicd.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ services:
1515
environment:
1616
PYTHONWARNINGS: error # We are strict about Warnings during testing
1717
DD_DEBUG: 'True'
18+
DD_TEST: 'True'
1819
DD_LOG_LEVEL: 'ERROR'
1920
DD_TEST_DATABASE_NAME: ${DD_TEST_DATABASE_NAME:-test_defectdojo}
2021
DD_DATABASE_NAME: ${DD_TEST_DATABASE_NAME:-test_defectdojo}

dojo/filters.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2050,6 +2050,78 @@ def set_related_object_fields(self):
20502050
self.form.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)
20512051

20522052

2053+
class DynamicFindingGroupsFilter(FilterSet):
2054+
name = CharFilter(lookup_expr="icontains", label="Name")
2055+
severity = ChoiceFilter(
2056+
choices=[
2057+
("Low", "Low"),
2058+
("Medium", "Medium"),
2059+
("High", "High"),
2060+
("Critical", "Critical"),
2061+
],
2062+
label="Min Severity",
2063+
)
2064+
engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement")
2065+
product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label="Product")
2066+
2067+
class Meta:
2068+
model = Finding
2069+
fields = ["name", "severity", "engagement", "product"]
2070+
2071+
def __init__(self, *args, **kwargs):
2072+
self.user = kwargs.pop("user", None)
2073+
self.pid = kwargs.pop("pid", None)
2074+
super().__init__(*args, **kwargs)
2075+
self.set_related_object_fields()
2076+
2077+
def set_related_object_fields(self):
2078+
if self.pid is not None:
2079+
self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid)
2080+
if "product" in self.form.fields:
2081+
del self.form.fields["product"]
2082+
else:
2083+
self.form.fields["product"].queryset = get_authorized_products(Permissions.Product_View)
2084+
self.form.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)
2085+
2086+
2087+
class DynamicFindingGroupsFindingsFilter(FilterSet):
2088+
name = CharFilter(lookup_expr="icontains", label="Name")
2089+
severity = MultipleChoiceFilter(
2090+
choices=[
2091+
("Low", "Low"),
2092+
("Medium", "Medium"),
2093+
("High", "High"),
2094+
("Critical", "Critical"),
2095+
],
2096+
label="Severity",
2097+
)
2098+
script_id = CharFilter(lookup_expr="icontains", label="Script ID")
2099+
reporter = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none(), label="Reporter")
2100+
status = ChoiceFilter(choices=[("Yes", "Yes"), ("No", "No")], label="Active")
2101+
engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement")
2102+
product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label="Product")
2103+
2104+
class Meta:
2105+
model = Finding
2106+
fields = ["name", "severity", "script_id", "reporter", "status", "engagement", "product"]
2107+
2108+
def __init__(self, *args, **kwargs):
2109+
self.user = kwargs.pop("user", None)
2110+
self.pid = kwargs.pop("pid", None)
2111+
super().__init__(*args, **kwargs)
2112+
self.set_related_object_fields()
2113+
2114+
def set_related_object_fields(self):
2115+
if self.pid is not None:
2116+
self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid)
2117+
if "product" in self.form.fields:
2118+
del self.form.fields["product"]
2119+
else:
2120+
self.form.fields["product"].queryset = get_authorized_products(Permissions.Product_View)
2121+
self.form.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)
2122+
self.form.fields["reporter"].queryset = get_authorized_users(Permissions.Finding_View)
2123+
2124+
20532125
class AcceptedFindingFilter(FindingFilter):
20542126
risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date")
20552127
risk_acceptance__owner = ModelMultipleChoiceFilter(

dojo/finding/helper.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from dojo.celery import app
1515
from dojo.decorators import dojo_async_task, dojo_model_from_id, dojo_model_to_id
1616
from dojo.endpoint.utils import save_endpoints_to_add
17+
from dojo.finding_group.redis import DynamicFindingGroups
1718
from dojo.models import (
1819
Endpoint,
1920
Endpoint_Status,
@@ -414,6 +415,8 @@ def finding_pre_delete(sender, instance, **kwargs):
414415
instance.found_by.clear()
415416
delete_related_notes(instance)
416417

418+
DynamicFindingGroups.set_last_finding_change()
419+
417420

418421
def finding_delete(instance, **kwargs):
419422
logger.debug("finding delete, instance: %s", instance.id)
@@ -444,6 +447,8 @@ def finding_delete(instance, **kwargs):
444447
logger.debug("finding delete: clearing found by")
445448
instance.found_by.clear()
446449

450+
DynamicFindingGroups.set_last_finding_change()
451+
447452

448453
@receiver(post_delete, sender=Finding)
449454
def finding_post_delete(sender, instance, **kwargs):

dojo/finding_group/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Dynamic Finding Groups
2+
This module manages dynamic grouping of Findings in DefectDojo. Findings can be grouped using different strategies (called GroupModes) such as:
3+
- Grouping by the vuln_id_from_tool
4+
- Grouping by the Finding title
5+
- Grouping by the associated CVE
6+
7+
The grouping is user-configurable through the UI and relies on Redis for fast storage and retrieval of groups.
8+
9+
## How it works
10+
When a user selects a grouping mode, the system builds and stores finding groups in Redis. These groups are refreshed automatically whenever new Findings are added or existing ones are modified.
11+
12+
### Redis is used to:
13+
- Store the mapping between Findings and their groups.
14+
- Store the serialized representation of each DynamicFindingGroups object.
15+
- Manage timestamps that help us detect if the stored groups are outdated.
16+
17+
### Two global keys are important here:
18+
- finding_groups_last_finding_change: Updated whenever a Finding is created/updated.
19+
- finding_groups_last_update: Stores the last time a specific GroupMode was rebuilt.
20+
21+
### When we rebuild groups:
22+
Group rebuilding occurs in the following cases:
23+
- The groups are missing in Redis, or
24+
- The timestamps `finding_groups_last_finding_change` and `finding_groups_last_update` do not match.
25+
26+
In practice, whenever a change occurs, the value of `last_finding_change` becomes more recent than `last_update`. At that point, the groups are rebuilt, and `last_update` is updated to match `last_finding_change`.
27+
28+
The `last_update` entry stores the timestamp per mode. Whenever a user opens the tab for a given mode, the system compares the timestamps. If they differ, the groups are rebuilt; otherwise, the existing groups are reused.
29+
30+
## Adding a new GroupMode
31+
To add a new grouping strategy:
32+
33+
1. Extend the GroupMode enum. Add a new entry, for example:
34+
```python
35+
class GroupMode(StrEnum):
36+
VULN_ID_FROM_TOOL = "vuln_id_from_tool"
37+
TITLE = "title"
38+
CVE = "cve"
39+
CUSTOM_TAG = "custom_tag" # ← New mode
40+
```
41+
42+
2. Update `DynamicFindingGroups.get_group_names`. Define how the Finding should be grouped for the new mode:
43+
```python
44+
if mode == GroupMode.CUSTOM_TAG:
45+
return finding.custom_tags.all().values_list("name", flat=True)
46+
```
47+
48+
3. Expose the mode in the HTML select box. Edit `finding_groups_dynamic_list_snippet.html` to allow the user to select it:
49+
```html
50+
<option value="custom_tag" {% if mode == "custom_tag" %}selected{% endif %}>Custom Tag</option>
51+
```
52+
53+
## User selection in the UI
54+
Users must explicitly choose a grouping mode in the UI for this feature to take effect.
55+
The selection is available in `finding_groups_dynamic_list_snippet.html`:
56+
```html
57+
<form method="get" class="form-inline mb-3">
58+
<label for="mode-select" class="mr-2">{% trans "Group Mode:" %}</label>
59+
<select name="mode" id="mode-select" onchange="this.form.submit()">
60+
<option value="" {% if not mode %}selected{% endif %}></option>
61+
<option value="vuln_id_from_tool" {% if mode == "vuln_id_from_tool" %}selected{% endif %}>Vuln ID from Tool</option>
62+
<option value="title" {% if mode == "title" %}selected{% endif %}>Title</option>
63+
<option value="cve" {% if mode == "cve" %}selected{% endif %}>CVE</option>
64+
</select>
65+
</form>
66+
```
67+
If no mode is selected, **Redis will not be used** and no dynamic finding groups will be built.
68+
69+
## Summary
70+
- Redis stores groups and manages synchronization via timestamps.
71+
- Groups are rebuilt only when necessary.
72+
- Adding a new GroupMode requires extending the enum, defining the grouping logic, and updating the HTML select box.
73+
- Users must explicitly select a mode in the UI; otherwise, grouping is disabled.

0 commit comments

Comments
 (0)