Skip to content
Open
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
27 changes: 14 additions & 13 deletions dojo/reports/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
),
)
14 changes: 9 additions & 5 deletions dojo/reports/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
164 changes: 164 additions & 0 deletions unittests/test_product_endpoint_report_scoping.py
Original file line number Diff line number Diff line change
@@ -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)
Loading