Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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",
)
script_id = CharFilter(lookup_expr="icontains", label="Script ID")
Copy link
Member

@valentijnscholten valentijnscholten Aug 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this to vuln_id_from_tool to avoid confusion as the script_id field doesn't exist in the Finding model?

reporter = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none(), label="Reporter")
status = ChoiceFilter(choices=[("Yes", "Yes"), ("No", "No")], label="Active")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this to active or is_active to avoid confusion as the status field doesn't exist in the Finding model?

engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement")
product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label="Product")

class Meta:
model = Finding
fields = ["name", "severity", "script_id", "reporter", "status", "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