diff --git a/dojo/reports/queries.py b/dojo/reports/queries.py index 174427ca5c6..46896b8981c 100644 --- a/dojo/reports/queries.py +++ b/dojo/reports/queries.py @@ -24,7 +24,7 @@ def prefetch_related_findings_for_report(findings: QuerySet) -> QuerySet: ) -def prefetch_related_endpoints_for_report(endpoints: QuerySet) -> QuerySet: +def prefetch_related_endpoints_for_report(endpoints: QuerySet, product=None) -> QuerySet: if settings.V3_FEATURE_LOCATIONS: return annotate_location_counts_and_status( endpoints.prefetch_related( @@ -39,23 +39,24 @@ def prefetch_related_endpoints_for_report(endpoints: QuerySet) -> QuerySet: ), ) # TODO: Delete this after the move to Locations + findings_qs = Finding.objects.filter( + active=True, + out_of_scope=False, + mitigated__isnull=True, + false_p=False, + duplicate=False, + status_finding__false_positive=False, + status_finding__out_of_scope=False, + status_finding__risk_accepted=False, + ) + if product is not None: + findings_qs = findings_qs.filter(test__engagement__product=product) return endpoints.prefetch_related( "product", "tags", Prefetch( "findings", - queryset=prefetch_for_findings( - Finding.objects.filter( - active=True, - out_of_scope=False, - mitigated__isnull=True, - false_p=False, - duplicate=False, - status_finding__false_positive=False, - status_finding__out_of_scope=False, - status_finding__risk_accepted=False, - ).order_by("numerical_severity"), - ), + queryset=prefetch_for_findings(findings_qs.order_by("numerical_severity")), to_attr="active_annotated_findings", ), ) diff --git a/dojo/reports/views.py b/dojo/reports/views.py index 3c820564d42..83ef5841704 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -17,6 +17,7 @@ from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.authorization_decorators import user_is_authorized from dojo.authorization.roles_permissions import Permissions +from dojo.endpoint.queries import get_authorized_endpoints from dojo.filters import ( EndpointFilter, EndpointFilterWithoutObjectLookups, @@ -29,6 +30,7 @@ from dojo.forms import ReportOptionsForm from dojo.labels import get_labels from dojo.location.models import Location +from dojo.location.queries import get_authorized_locations from dojo.location.status import FindingLocationStatus from dojo.models import Dojo_User, Endpoint, Engagement, Finding, Product, Product_Type, Test from dojo.reports.queries import prefetch_related_endpoints_for_report, prefetch_related_findings_for_report @@ -189,7 +191,7 @@ def get_context(self): def report_findings(request): - findings = Finding.objects.filter() + findings = get_authorized_findings(Permissions.Finding_View) filter_string_matching = get_system_setting("filter_string_matching", False) filter_class = ReportFindingFilterWithoutObjectLookups if filter_string_matching else ReportFindingFilter findings = filter_class(request.GET, queryset=findings) @@ -212,11 +214,12 @@ def report_findings(request): def report_endpoints(request): if settings.V3_FEATURE_LOCATIONS: - endpoints = Location.objects.filter(findings__status=FindingLocationStatus.Active).distinct() + endpoints = get_authorized_locations(Permissions.Location_View) + endpoints = endpoints.filter(findings__status=FindingLocationStatus.Active).distinct() endpoints = URLFilter(request.GET, queryset=endpoints) else: # TODO: Delete this after the move to Locations - endpoints = Endpoint.objects.filter( + endpoints = get_authorized_endpoints(Permissions.Location_View).filter( finding__active=True, finding__false_p=False, finding__duplicate=False, @@ -289,13 +292,14 @@ def product_endpoint_report(request, pid): endpoints = URLFilter(request.GET, queryset=endpoints) else: # TODO: Delete this after the move to Locations - endpoints = Endpoint.objects.filter(finding__active=True, + endpoints = Endpoint.objects.filter(product=product, + finding__active=True, finding__false_p=False, finding__duplicate=False, finding__out_of_scope=False) if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): endpoints = endpoints.filter(finding__active=True) - endpoints = prefetch_related_endpoints_for_report(endpoints.distinct()) + endpoints = prefetch_related_endpoints_for_report(endpoints.distinct(), product=product) endpoints = EndpointReportFilter(request.GET, queryset=endpoints) paged_endpoints = get_page_items(request, endpoints.qs, 25) diff --git a/unittests/test_product_endpoint_report_scoping.py b/unittests/test_product_endpoint_report_scoping.py new file mode 100644 index 00000000000..aa7eda15177 --- /dev/null +++ b/unittests/test_product_endpoint_report_scoping.py @@ -0,0 +1,164 @@ +from django.test import Client +from django.utils.timezone import now + +from dojo.authorization.roles_permissions import Roles +from dojo.models import ( + Endpoint, + Endpoint_Status, + Engagement, + Finding, + Product, + Product_Member, + Product_Type, + Role, + Test, + Test_Type, + User, +) +from unittests.dojo_test_case import DojoTestCase, skip_unless_v2 + + +@skip_unless_v2 +class TestProductEndpointReportScoping(DojoTestCase): + + """ + The legacy `product_endpoint_report` view must only return endpoints and + findings belonging to the requested product. Previously the Endpoint + queryset was filtered by finding flags only and not scoped by product, + so an unrelated product's findings appeared in the report. + """ + + fixtures = ["dojo_testdata.json"] + + MARKER_A = "PRODUCT_A_UNIQUE_MARKER_b3c8aa1f" + MARKER_B = "PRODUCT_B_UNIQUE_MARKER_d9e2bc54" + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.get(username="admin") + cls.prod_type, _ = Product_Type.objects.get_or_create(name="Scoping Test PT") + cls.test_type, _ = Test_Type.objects.get_or_create(name="Scoping Test Scan") + + cls.product_a = Product.objects.create( + name="Scoping Test Product A", + description=cls.MARKER_A, + prod_type=cls.prod_type, + ) + cls.product_b = Product.objects.create( + name="Scoping Test Product B", + description=cls.MARKER_B, + prod_type=cls.prod_type, + ) + + cls.finding_a = cls._create_finding_with_endpoint( + cls.product_a, "Finding for A", cls.MARKER_A, host="a.example.com", + ) + cls.finding_b = cls._create_finding_with_endpoint( + cls.product_b, "Finding for B", cls.MARKER_B, host="b.example.com", + ) + + cls.restricted_user = User.objects.create_user( + username="report_scoping_reader", + password="not-a-real-secret", # noqa: S106 - test fixture user + ) + reader_role = Role.objects.get(id=Roles.Reader) + Product_Member.objects.create( + user=cls.restricted_user, + product=cls.product_a, + role=reader_role, + ) + + @classmethod + def _create_finding_with_endpoint(cls, product, title, description, *, host): + engagement = Engagement.objects.create( + name=f"{product.name} Engagement", + product=product, + target_start=now(), + target_end=now(), + ) + test = Test.objects.create( + engagement=engagement, + test_type=cls.test_type, + title=f"{product.name} Test", + target_start=now(), + target_end=now(), + ) + finding = Finding.objects.create( + test=test, + title=title, + description=description, + severity="High", + numerical_severity="S0", + active=True, + verified=True, + false_p=False, + duplicate=False, + out_of_scope=False, + mitigated=None, + reporter=cls.user, + ) + endpoint = Endpoint.objects.create( + host=host, + protocol="https", + product=product, + ) + Endpoint_Status.objects.create( + endpoint=endpoint, + finding=finding, + mitigated=False, + false_positive=False, + out_of_scope=False, + risk_accepted=False, + ) + finding.endpoints.add(endpoint) + return finding + + def setUp(self): + super().setUp() + self.client = Client() + self.client.force_login(self.user) + + def test_product_endpoint_report_only_includes_target_product_findings(self): + url = f"/product/{self.product_a.id}/endpoint/report?_generate=1&report_type=HTML" + response = self.client.get(url) + self.assertEqual(response.status_code, 200, response.content[:500]) + body = response.content.decode() + + self.assertIn(self.MARKER_A, body, "Expected Product A's finding description in report") + self.assertNotIn( + self.MARKER_B, + body, + "Product B's finding description must not appear in Product A's report", + ) + + def test_product_b_report_only_includes_product_b_findings(self): + url = f"/product/{self.product_b.id}/endpoint/report?_generate=1&report_type=HTML" + response = self.client.get(url) + self.assertEqual(response.status_code, 200, response.content[:500]) + body = response.content.decode() + + self.assertIn(self.MARKER_B, body) + self.assertNotIn(self.MARKER_A, body) + + def test_reports_findings_only_includes_user_authorized_findings(self): + # /reports/findings was previously unscoped; a Reader on Product A should + # see Product A's findings only. The template renders the title, not + # the description, so we assert against the unique titles. + restricted_client = Client() + restricted_client.force_login(self.restricted_user) + response = restricted_client.get("/reports/findings") + self.assertEqual(response.status_code, 200, response.content[:500]) + body = response.content.decode() + self.assertIn("Finding for A", body) + self.assertNotIn("Finding for B", body) + + def test_reports_endpoints_only_includes_user_authorized_endpoints(self): + # /reports/endpoints was previously unscoped; a Reader on Product A + # should only see Product A's endpoints. + restricted_client = Client() + restricted_client.force_login(self.restricted_user) + response = restricted_client.get("/reports/endpoints") + self.assertEqual(response.status_code, 200, response.content[:500]) + body = response.content.decode() + self.assertIn("a.example.com", body) + self.assertNotIn("b.example.com", body)