diff --git a/dojo/filters.py b/dojo/filters.py index dabd1aec119..3c38ce7246f 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -2020,6 +2020,33 @@ def filter_mitigated_on(self, queryset, name, value): return queryset.filter(mitigated=value) +def get_finding_group_queryset_for_context(pid=None, eid=None, tid=None): + """ + Helper function to build finding group queryset based on context hierarchy. + Context priority: test > engagement > product > global + + Args: + pid: Product ID (least specific) + eid: Engagement ID + tid: Test ID (most specific) + + Returns: + QuerySet of Finding_Group filtered by context + + """ + if tid is not None: + # Most specific: filter by test + return Finding_Group.objects.filter(test_id=tid).only("id", "name") + if eid is not None: + # Filter by engagement's tests + return Finding_Group.objects.filter(test__engagement_id=eid).only("id", "name") + if pid is not None: + # Filter by product's tests + return Finding_Group.objects.filter(test__engagement__product_id=pid).only("id", "name") + # Global: return all (authorization will be applied separately) + return Finding_Group.objects.all().only("id", "name") + + class FindingFilterWithoutObjectLookups(FindingFilterHelper, FindingTagStringFilter): test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) test__engagement__product = NumberFilter(widget=HiddenInput()) @@ -2111,20 +2138,45 @@ class Meta: def __init__(self, *args, **kwargs): self.user = None self.pid = None + self.eid = None + self.tid = None if "user" in kwargs: self.user = kwargs.pop("user") if "pid" in kwargs: self.pid = kwargs.pop("pid") + if "eid" in kwargs: + self.eid = kwargs.pop("eid") + if "tid" in kwargs: + self.tid = kwargs.pop("tid") super().__init__(*args, **kwargs) # Set some date fields self.set_date_fields(*args, **kwargs) - # Don't show the product filter on the product finding view - if self.pid: - del self.form.fields["test__engagement__product__name"] - del self.form.fields["test__engagement__product__name_contains"] - del self.form.fields["test__engagement__product__prod_type__name"] - del self.form.fields["test__engagement__product__prod_type__name_contains"] + # Don't show the product/engagement/test filter fields when in specific context + if self.tid or self.eid or self.pid: + if "test__engagement__product__name" in self.form.fields: + del self.form.fields["test__engagement__product__name"] + if "test__engagement__product__name_contains" in self.form.fields: + del self.form.fields["test__engagement__product__name_contains"] + if "test__engagement__product__prod_type__name" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type__name"] + if "test__engagement__product__prod_type__name_contains" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type__name_contains"] + # Also hide engagement and test fields if in test or engagement context + if self.tid: + if "test__engagement__name" in self.form.fields: + del self.form.fields["test__engagement__name"] + if "test__engagement__name_contains" in self.form.fields: + del self.form.fields["test__engagement__name_contains"] + if "test__name" in self.form.fields: + del self.form.fields["test__name"] + if "test__name_contains" in self.form.fields: + del self.form.fields["test__name_contains"] + elif self.eid: + if "test__engagement__name" in self.form.fields: + del self.form.fields["test__engagement__name"] + if "test__engagement__name_contains" in self.form.fields: + del self.form.fields["test__engagement__name_contains"] class FindingFilter(FindingFilterHelper, FindingTagFilter): @@ -2163,11 +2215,17 @@ class Meta: def __init__(self, *args, **kwargs): self.user = None self.pid = None + self.eid = None + self.tid = None if "user" in kwargs: self.user = kwargs.pop("user") if "pid" in kwargs: self.pid = kwargs.pop("pid") + if "eid" in kwargs: + self.eid = kwargs.pop("eid") + if "tid" in kwargs: + self.tid = kwargs.pop("tid") super().__init__(*args, **kwargs) # Set some date fields self.set_date_fields(*args, **kwargs) @@ -2175,26 +2233,61 @@ def __init__(self, *args, **kwargs): self.set_related_object_fields(*args, **kwargs) def set_related_object_fields(self, *args: list, **kwargs: dict): - finding_group_query = Finding_Group.objects.all() - if self.pid is not None: - del self.form.fields["test__engagement__product"] - del self.form.fields["test__engagement__product__prod_type"] + # Use helper to get contextual finding group queryset + finding_group_query = get_finding_group_queryset_for_context( + pid=self.pid, + eid=self.eid, + tid=self.tid, + ) + + # Filter by most specific context: test > engagement > product + if self.tid is not None: + # Test context: filter finding groups by test + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + if "test__engagement" in self.form.fields: + del self.form.fields["test__engagement"] + if "test" in self.form.fields: + del self.form.fields["test"] + elif self.eid is not None: + # Engagement context: filter finding groups by engagement + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + if "test__engagement" in self.form.fields: + del self.form.fields["test__engagement"] + # Filter tests by engagement - get_authorized_tests doesn't support engagement param + engagement = Engagement.objects.filter(id=self.eid).select_related("product").first() + if engagement: + self.form.fields["test"].queryset = get_authorized_tests(Permissions.Test_View, product=engagement.product).filter(engagement_id=self.eid).prefetch_related("test_type") + elif self.pid is not None: + # Product context: filter finding groups by product + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] # TODO: add authorized check to be sure - self.form.fields["test__engagement"].queryset = Engagement.objects.filter( - product_id=self.pid, - ).all() - self.form.fields["test"].queryset = get_authorized_tests(Permissions.Test_View, product=self.pid).prefetch_related("test_type") - finding_group_query = Finding_Group.objects.filter(test__engagement__product_id=self.pid) + if "test__engagement" in self.form.fields: + self.form.fields["test__engagement"].queryset = Engagement.objects.filter( + product_id=self.pid, + ).all() + if "test" in self.form.fields: + self.form.fields["test"].queryset = get_authorized_tests(Permissions.Test_View, product=self.pid).prefetch_related("test_type") else: + # Global context: show all authorized finding groups self.form.fields[ "test__engagement__product__prod_type"].queryset = get_authorized_product_types(Permissions.Product_Type_View) self.form.fields["test__engagement"].queryset = get_authorized_engagements(Permissions.Engagement_View) - del self.form.fields["test"] + if "test" in self.form.fields: + del self.form.fields["test"] if self.form.fields.get("test__engagement__product"): self.form.fields["test__engagement__product"].queryset = get_authorized_products(Permissions.Product_View) if self.form.fields.get("finding_group", None): - self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset(Permissions.Finding_Group_View, finding_group_query) + self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset(Permissions.Finding_Group_View, finding_group_query, user=self.user) self.form.fields["reporter"].queryset = get_authorized_users(Permissions.Finding_View) self.form.fields["reviewers"].queryset = self.form.fields["reporter"].queryset diff --git a/dojo/finding/views.py b/dojo/finding/views.py index 5dfd50d601b..46c4d8ae471 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -267,6 +267,8 @@ def filter_findings_by_form(self, request: HttpRequest, findings: QuerySet[Findi kwargs = { "user": request.user, "pid": self.get_product_id(), + "eid": self.get_engagement_id(), + "tid": self.get_test_id(), } filter_string_matching = get_system_setting("filter_string_matching", False) @@ -360,10 +362,11 @@ def add_breadcrumbs(self, request: HttpRequest, context: dict): return request, context - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): - # Store the product and engagement ids + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): + # Store the product, engagement, and test ids self.product_id = product_id self.engagement_id = engagement_id + self.test_id = test_id # Get the initial context request, context = self.get_initial_context(request) # Get the filtered findings @@ -386,46 +389,46 @@ def get(self, request: HttpRequest, product_id: int | None = None, engagement_id class ListOpenFindings(ListFindings): - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): self.filter_name = "Open" - return super().get(request, product_id=product_id, engagement_id=engagement_id) + return super().get(request, product_id=product_id, engagement_id=engagement_id, test_id=test_id) class ListVerifiedFindings(ListFindings): - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): self.filter_name = "Verified" - return super().get(request, product_id=product_id, engagement_id=engagement_id) + return super().get(request, product_id=product_id, engagement_id=engagement_id, test_id=test_id) class ListOutOfScopeFindings(ListFindings): - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): self.filter_name = "Out of Scope" - return super().get(request, product_id=product_id, engagement_id=engagement_id) + return super().get(request, product_id=product_id, engagement_id=engagement_id, test_id=test_id) class ListFalsePositiveFindings(ListFindings): - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): self.filter_name = "False Positive" - return super().get(request, product_id=product_id, engagement_id=engagement_id) + return super().get(request, product_id=product_id, engagement_id=engagement_id, test_id=test_id) class ListInactiveFindings(ListFindings): - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): self.filter_name = "Inactive" - return super().get(request, product_id=product_id, engagement_id=engagement_id) + return super().get(request, product_id=product_id, engagement_id=engagement_id, test_id=test_id) class ListAcceptedFindings(ListFindings): - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): self.filter_name = "Accepted" - return super().get(request, product_id=product_id, engagement_id=engagement_id) + return super().get(request, product_id=product_id, engagement_id=engagement_id, test_id=test_id) class ListClosedFindings(ListFindings): - def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None): + def get(self, request: HttpRequest, product_id: int | None = None, engagement_id: int | None = None, test_id: int | None = None): self.filter_name = "Closed" self.order_by = "-mitigated" - return super().get(request, product_id=product_id, engagement_id=engagement_id) + return super().get(request, product_id=product_id, engagement_id=engagement_id, test_id=test_id) class ViewFinding(View): diff --git a/dojo/test/views.py b/dojo/test/views.py index c8b52bb6f14..f3510147290 100644 --- a/dojo/test/views.py +++ b/dojo/test/views.py @@ -122,7 +122,7 @@ def get_findings(self, request: HttpRequest, test: Test): findings = Finding.objects.filter(test=test).order_by("numerical_severity") filter_string_matching = get_system_setting("filter_string_matching", False) finding_filter_class = FindingFilterWithoutObjectLookups if filter_string_matching else FindingFilter - findings = finding_filter_class(request.GET, pid=test.engagement.product.id, queryset=findings) + findings = finding_filter_class(request.GET, pid=test.engagement.product.id, eid=test.engagement.id, tid=test.id, queryset=findings) paged_findings = get_page_items_and_count(request, prefetch_for_findings(findings.qs), 25, prefix="findings") fix_available_count = findings.qs.filter(fix_available=True).count() diff --git a/unittests/test_finding_group_filter_context.py b/unittests/test_finding_group_filter_context.py new file mode 100644 index 00000000000..f9811aa5942 --- /dev/null +++ b/unittests/test_finding_group_filter_context.py @@ -0,0 +1,252 @@ +from django.utils.timezone import now + +from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups +from dojo.models import ( + Dojo_User, + Engagement, + Finding, + Finding_Group, + Product, + Product_Type, + Test, + Test_Type, +) + +from .dojo_test_case import DojoTestCase + + +class TestFindingGroupFilterContext(DojoTestCase): + + """Test that Finding Group filter respects Test/Engagement/Product context.""" + + @classmethod + def setUpTestData(cls): + """Create test data hierarchy.""" + # Create test type + cls.test_type = Test_Type.objects.create(name="Test Type") + + # Create user + cls.user = Dojo_User.objects.create( + username="testuser", + is_staff=True, + is_superuser=True, + ) + + # Create Product Type + cls.prod_type = Product_Type.objects.create(name="Product Type") + + # Create two products + cls.product1 = Product.objects.create( + name="Product 1", + prod_type=cls.prod_type, + description="Test product 1", + ) + cls.product2 = Product.objects.create( + name="Product 2", + prod_type=cls.prod_type, + description="Test product 2", + ) + + # Create engagements for each product + cls.engagement1 = Engagement.objects.create( + name="Engagement 1", + product=cls.product1, + target_start=now(), + target_end=now(), + ) + cls.engagement2 = Engagement.objects.create( + name="Engagement 2", + product=cls.product2, + target_start=now(), + target_end=now(), + ) + cls.engagement3 = Engagement.objects.create( + name="Engagement 3", + product=cls.product1, # Same product as engagement1 + target_start=now(), + target_end=now(), + ) + + # Create tests for each engagement + cls.test1 = Test.objects.create( + title="Test 1", + engagement=cls.engagement1, + test_type=cls.test_type, + target_start=now(), + target_end=now(), + ) + cls.test2 = Test.objects.create( + title="Test 2", + engagement=cls.engagement2, + test_type=cls.test_type, + target_start=now(), + target_end=now(), + ) + cls.test3 = Test.objects.create( + title="Test 3", + engagement=cls.engagement3, + test_type=cls.test_type, + target_start=now(), + target_end=now(), + ) + + # Create finding groups in each test + cls.group1 = Finding_Group.objects.create( + name="Group 1", + test=cls.test1, + creator=cls.user, + ) + cls.group2 = Finding_Group.objects.create( + name="Group 2", + test=cls.test2, + creator=cls.user, + ) + cls.group3 = Finding_Group.objects.create( + name="Group 3", + test=cls.test3, + creator=cls.user, + ) + + # Create a finding in each group (required for group to be valid) + cls.finding1 = Finding.objects.create( + title="Finding 1", + test=cls.test1, + reporter=cls.user, + severity="High", + finding_group=cls.group1, + ) + cls.finding2 = Finding.objects.create( + title="Finding 2", + test=cls.test2, + reporter=cls.user, + severity="High", + finding_group=cls.group2, + ) + cls.finding3 = Finding.objects.create( + title="Finding 3", + test=cls.test3, + reporter=cls.user, + severity="High", + finding_group=cls.group3, + ) + + def test_finding_group_filter_in_test_context(self): + """Test filter shows only groups from specific test.""" + # Create filter with test context + finding_filter = FindingFilter( + data={}, + queryset=Finding.objects.all(), + user=self.user, + tid=self.test1.id, + ) + + # Get the finding group queryset + group_queryset = finding_filter.form.fields["finding_group"].queryset + + # Should only show group from test1 + self.assertEqual(group_queryset.count(), 1) + self.assertIn(self.group1, group_queryset) + self.assertNotIn(self.group2, group_queryset) + self.assertNotIn(self.group3, group_queryset) + + def test_finding_group_filter_in_engagement_context(self): + """Test filter shows only groups from engagement's tests.""" + # Create filter with engagement context + finding_filter = FindingFilter( + data={}, + queryset=Finding.objects.all(), + user=self.user, + eid=self.engagement1.id, + ) + + # Get the finding group queryset + group_queryset = finding_filter.form.fields["finding_group"].queryset + + # Should only show group from engagement1's tests (test1) + self.assertEqual(group_queryset.count(), 1) + self.assertIn(self.group1, group_queryset) + self.assertNotIn(self.group2, group_queryset) + self.assertNotIn(self.group3, group_queryset) + + def test_finding_group_filter_in_product_context(self): + """Test filter shows only groups from product's tests.""" + # Create filter with product context + finding_filter = FindingFilter( + data={}, + queryset=Finding.objects.all(), + user=self.user, + pid=self.product1.id, + ) + + # Get the finding group queryset + group_queryset = finding_filter.form.fields["finding_group"].queryset + + # Should show groups from product1's tests (test1 and test3) + self.assertEqual(group_queryset.count(), 2) + self.assertIn(self.group1, group_queryset) + self.assertIn(self.group3, group_queryset) + self.assertNotIn(self.group2, group_queryset) + + def test_finding_group_filter_global_context(self): + """Test filter shows all authorized groups in global context.""" + # Create filter without context + finding_filter = FindingFilter( + data={}, + queryset=Finding.objects.all(), + user=self.user, + ) + + # Get the finding group queryset + group_queryset = finding_filter.form.fields["finding_group"].queryset + + # Should show all groups (user is superuser) + self.assertEqual(group_queryset.count(), 3) + self.assertIn(self.group1, group_queryset) + self.assertIn(self.group2, group_queryset) + self.assertIn(self.group3, group_queryset) + + def test_finding_group_filter_hierarchy_precedence(self): + """Test that test context takes precedence over engagement/product.""" + # Create filter with all contexts (test should win) + finding_filter = FindingFilter( + data={}, + queryset=Finding.objects.all(), + user=self.user, + pid=self.product1.id, + eid=self.engagement1.id, + tid=self.test3.id, # Different test + ) + + # Get the finding group queryset + group_queryset = finding_filter.form.fields["finding_group"].queryset + + # Should only show group from test3 (most specific context) + self.assertEqual(group_queryset.count(), 1) + self.assertIn(self.group3, group_queryset) + + def test_finding_group_filter_without_object_lookups_test_context(self): + """Test FindingFilterWithoutObjectLookups respects test context.""" + # Create filter with test context + finding_filter = FindingFilterWithoutObjectLookups( + data={}, + queryset=Finding.objects.all(), + user=self.user, + tid=self.test1.id, + ) + + # Verify test filter fields are hidden in test context + self.assertNotIn("test__name", finding_filter.form.fields) + self.assertNotIn("test__engagement__name", finding_filter.form.fields) + + def test_finding_group_filter_without_object_lookups_engagement_context(self): + """Test FindingFilterWithoutObjectLookups respects engagement context.""" + # Create filter with engagement context + finding_filter = FindingFilterWithoutObjectLookups( + data={}, + queryset=Finding.objects.all(), + user=self.user, + eid=self.engagement1.id, + ) + + # Verify engagement filter fields are hidden in engagement context + self.assertNotIn("test__engagement__name", finding_filter.form.fields)