Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.override.unit_tests_cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ services:
environment:
PYTHONWARNINGS: error # We are strict about Warnings during testing
DD_DEBUG: 'True'
DD_TEST: 'True'
DD_LOG_LEVEL: 'ERROR'
DD_TEST_DATABASE_NAME: ${DD_TEST_DATABASE_NAME:-test_defectdojo}
DD_DATABASE_NAME: ${DD_TEST_DATABASE_NAME:-test_defectdojo}
Expand Down
72 changes: 72 additions & 0 deletions dojo/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2043,6 +2043,78 @@ def set_related_object_fields(self):
self.form.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)


class DynamicFindingGroupsFilter(FilterSet):
name = CharFilter(lookup_expr="icontains", label="Name")
severity = ChoiceFilter(
choices=[
("Low", "Low"),
("Medium", "Medium"),
("High", "High"),
("Critical", "Critical"),
],
label="Min Severity",
)
engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement")
product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label="Product")

class Meta:
model = Finding
fields = ["name", "severity", "engagement", "product"]

def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
self.pid = kwargs.pop("pid", None)
super().__init__(*args, **kwargs)
self.set_related_object_fields()

def set_related_object_fields(self):
if self.pid is not None:
self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid)
if "product" in self.form.fields:
del self.form.fields["product"]
else:
self.form.fields["product"].queryset = get_authorized_products(Permissions.Product_View)
self.form.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)


class DynamicFindingGroupsFindingsFilter(FilterSet):
name = CharFilter(lookup_expr="icontains", label="Name")
severity = MultipleChoiceFilter(
choices=[
("Low", "Low"),
("Medium", "Medium"),
("High", "High"),
("Critical", "Critical"),
],
label="Severity",
)
vuln_id_from_tool = CharFilter(lookup_expr="icontains", label="Vulnerability Id From Tool")
reporter = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none(), label="Reporter")
active = ChoiceFilter(choices=[("Yes", "Yes"), ("No", "No")], label="Active")
engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement")
product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label="Product")

class Meta:
model = Finding
fields = ["name", "severity", "vuln_id_from_tool", "reporter", "active", "engagement", "product"]

def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
self.pid = kwargs.pop("pid", None)
super().__init__(*args, **kwargs)
self.set_related_object_fields()

def set_related_object_fields(self):
if self.pid is not None:
self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid)
if "product" in self.form.fields:
del self.form.fields["product"]
else:
self.form.fields["product"].queryset = get_authorized_products(Permissions.Product_View)
self.form.fields["engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View)
self.form.fields["reporter"].queryset = get_authorized_users(Permissions.Finding_View)


class AcceptedFindingFilter(FindingFilter):
risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date")
risk_acceptance__owner = ModelMultipleChoiceFilter(
Expand Down
5 changes: 5 additions & 0 deletions dojo/finding/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from dojo.decorators import dojo_async_task, dojo_model_from_id, dojo_model_to_id
from dojo.endpoint.utils import save_endpoints_to_add
from dojo.file_uploads.helper import delete_related_files
from dojo.finding_group.redis import DynamicFindingGroups
from dojo.models import (
Endpoint,
Endpoint_Status,
Expand Down Expand Up @@ -416,6 +417,8 @@ def finding_pre_delete(sender, instance, **kwargs):
delete_related_notes(instance)
delete_related_files(instance)

DynamicFindingGroups.set_last_finding_change()


def finding_delete(instance, **kwargs):
logger.debug("finding delete, instance: %s", instance.id)
Expand Down Expand Up @@ -446,6 +449,8 @@ def finding_delete(instance, **kwargs):
logger.debug("finding delete: clearing found by")
instance.found_by.clear()

DynamicFindingGroups.set_last_finding_change()


@receiver(post_delete, sender=Finding)
def finding_post_delete(sender, instance, **kwargs):
Expand Down
73 changes: 73 additions & 0 deletions dojo/finding_group/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Dynamic Finding Groups
This module manages dynamic grouping of Findings in DefectDojo. Findings can be grouped using different strategies (called GroupModes) such as:
- Grouping by the vuln_id_from_tool
- Grouping by the Finding title
- Grouping by the associated CVE

The grouping is user-configurable through the UI and relies on Redis for fast storage and retrieval of groups.

## How it works
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.

### Redis is used to:
- Store the mapping between Findings and their groups.
- Store the serialized representation of each DynamicFindingGroups object.
- Manage timestamps that help us detect if the stored groups are outdated.

### Two global keys are important here:
- finding_groups_last_finding_change: Updated whenever a Finding is created/updated.
- finding_groups_last_update: Stores the last time a specific GroupMode was rebuilt.

### When we rebuild groups:
Group rebuilding occurs in the following cases:
- The groups are missing in Redis, or
- The timestamps `finding_groups_last_finding_change` and `finding_groups_last_update` do not match.

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`.

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.

## Adding a new GroupMode
To add a new grouping strategy:

1. Extend the GroupMode enum. Add a new entry, for example:
```python
class GroupMode(StrEnum):
VULN_ID_FROM_TOOL = "vuln_id_from_tool"
TITLE = "title"
CVE = "cve"
CUSTOM_TAG = "custom_tag" # ← New mode
```

2. Update `DynamicFindingGroups.get_group_names`. Define how the Finding should be grouped for the new mode:
```python
if mode == GroupMode.CUSTOM_TAG:
return finding.custom_tags.all().values_list("name", flat=True)
```

3. Expose the mode in the HTML select box. Edit `finding_groups_dynamic_list_snippet.html` to allow the user to select it:
```html
<option value="custom_tag" {% if mode == "custom_tag" %}selected{% endif %}>Custom Tag</option>
```

## User selection in the UI
Users must explicitly choose a grouping mode in the UI for this feature to take effect.
The selection is available in `finding_groups_dynamic_list_snippet.html`:
```html
<form method="get" class="form-inline mb-3">
<label for="mode-select" class="mr-2">{% trans "Group Mode:" %}</label>
<select name="mode" id="mode-select" onchange="this.form.submit()">
<option value="" {% if not mode %}selected{% endif %}></option>
<option value="vuln_id_from_tool" {% if mode == "vuln_id_from_tool" %}selected{% endif %}>Vuln ID from Tool</option>
<option value="title" {% if mode == "title" %}selected{% endif %}>Title</option>
<option value="cve" {% if mode == "cve" %}selected{% endif %}>CVE</option>
</select>
</form>
```
If no mode is selected, **Redis will not be used** and no dynamic finding groups will be built.

## Summary
- Redis stores groups and manages synchronization via timestamps.
- Groups are rebuilt only when necessary.
- Adding a new GroupMode requires extending the enum, defining the grouping logic, and updating the HTML select box.
- Users must explicitly select a mode in the UI; otherwise, grouping is disabled.
Loading
Loading