Skip to content

Commit b50728e

Browse files
Maffoochclaude
andauthored
Scope report views to the requesting user's authorized products (#14870)
* Scope report views to the requesting user's authorized products `product_endpoint_report` (legacy, non-Location branch) built its Endpoint queryset by filtering on `finding__active=True` etc. without restricting to `product=<pid>`, so endpoints (and their findings) from unrelated products appeared in the rendered report. Added `product=product` to the Endpoint filter, and extended `prefetch_related_endpoints_for_report` with an optional `product` parameter so the prefetched Finding queryset is also scoped. While auditing the rest of the module, `report_findings` and `report_endpoints` constructed their initial querysets from `Finding.objects.filter()` / `Endpoint.objects.filter(...)` with no authorization. The rendered output was scoped by the filter wrappers' `qs` property, but moving authorization to the queryset-construction layer (via `get_authorized_findings` / `get_authorized_endpoints` / `get_authorized_locations`) matches the pattern used by `ReportBuilder.get_findings` and removes the implicit reliance on the filter wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Skip product endpoint report scoping tests when V3 locations are enabled The legacy `product_endpoint_report` branch (and its `Endpoint`-based test fixtures) is unreachable when `V3_FEATURE_LOCATIONS=True`, and the `Endpoint` model raises NotImplementedError in that mode. Mark the regression suite with `@skip_unless_v2` so it only runs against the code path it actually covers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ab11f66 commit b50728e

3 files changed

Lines changed: 187 additions & 18 deletions

File tree

dojo/reports/queries.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def prefetch_related_findings_for_report(findings: QuerySet) -> QuerySet:
2424
)
2525

2626

27-
def prefetch_related_endpoints_for_report(endpoints: QuerySet) -> QuerySet:
27+
def prefetch_related_endpoints_for_report(endpoints: QuerySet, product=None) -> QuerySet:
2828
if settings.V3_FEATURE_LOCATIONS:
2929
return annotate_location_counts_and_status(
3030
endpoints.prefetch_related(
@@ -39,23 +39,24 @@ def prefetch_related_endpoints_for_report(endpoints: QuerySet) -> QuerySet:
3939
),
4040
)
4141
# TODO: Delete this after the move to Locations
42+
findings_qs = Finding.objects.filter(
43+
active=True,
44+
out_of_scope=False,
45+
mitigated__isnull=True,
46+
false_p=False,
47+
duplicate=False,
48+
status_finding__false_positive=False,
49+
status_finding__out_of_scope=False,
50+
status_finding__risk_accepted=False,
51+
)
52+
if product is not None:
53+
findings_qs = findings_qs.filter(test__engagement__product=product)
4254
return endpoints.prefetch_related(
4355
"product",
4456
"tags",
4557
Prefetch(
4658
"findings",
47-
queryset=prefetch_for_findings(
48-
Finding.objects.filter(
49-
active=True,
50-
out_of_scope=False,
51-
mitigated__isnull=True,
52-
false_p=False,
53-
duplicate=False,
54-
status_finding__false_positive=False,
55-
status_finding__out_of_scope=False,
56-
status_finding__risk_accepted=False,
57-
).order_by("numerical_severity"),
58-
),
59+
queryset=prefetch_for_findings(findings_qs.order_by("numerical_severity")),
5960
to_attr="active_annotated_findings",
6061
),
6162
)

dojo/reports/views.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from dojo.authorization.authorization import user_has_permission_or_403
1818
from dojo.authorization.authorization_decorators import user_is_authorized
1919
from dojo.authorization.roles_permissions import Permissions
20+
from dojo.endpoint.queries import get_authorized_endpoints
2021
from dojo.filters import (
2122
EndpointFilter,
2223
EndpointFilterWithoutObjectLookups,
@@ -29,6 +30,7 @@
2930
from dojo.forms import ReportOptionsForm
3031
from dojo.labels import get_labels
3132
from dojo.location.models import Location
33+
from dojo.location.queries import get_authorized_locations
3234
from dojo.location.status import FindingLocationStatus
3335
from dojo.models import Dojo_User, Endpoint, Engagement, Finding, Product, Product_Type, Test
3436
from dojo.reports.queries import prefetch_related_endpoints_for_report, prefetch_related_findings_for_report
@@ -189,7 +191,7 @@ def get_context(self):
189191

190192

191193
def report_findings(request):
192-
findings = Finding.objects.filter()
194+
findings = get_authorized_findings(Permissions.Finding_View)
193195
filter_string_matching = get_system_setting("filter_string_matching", False)
194196
filter_class = ReportFindingFilterWithoutObjectLookups if filter_string_matching else ReportFindingFilter
195197
findings = filter_class(request.GET, queryset=findings)
@@ -212,11 +214,12 @@ def report_findings(request):
212214

213215
def report_endpoints(request):
214216
if settings.V3_FEATURE_LOCATIONS:
215-
endpoints = Location.objects.filter(findings__status=FindingLocationStatus.Active).distinct()
217+
endpoints = get_authorized_locations(Permissions.Location_View)
218+
endpoints = endpoints.filter(findings__status=FindingLocationStatus.Active).distinct()
216219
endpoints = URLFilter(request.GET, queryset=endpoints)
217220
else:
218221
# TODO: Delete this after the move to Locations
219-
endpoints = Endpoint.objects.filter(
222+
endpoints = get_authorized_endpoints(Permissions.Location_View).filter(
220223
finding__active=True,
221224
finding__false_p=False,
222225
finding__duplicate=False,
@@ -289,13 +292,14 @@ def product_endpoint_report(request, pid):
289292
endpoints = URLFilter(request.GET, queryset=endpoints)
290293
else:
291294
# TODO: Delete this after the move to Locations
292-
endpoints = Endpoint.objects.filter(finding__active=True,
295+
endpoints = Endpoint.objects.filter(product=product,
296+
finding__active=True,
293297
finding__false_p=False,
294298
finding__duplicate=False,
295299
finding__out_of_scope=False)
296300
if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True):
297301
endpoints = endpoints.filter(finding__active=True)
298-
endpoints = prefetch_related_endpoints_for_report(endpoints.distinct())
302+
endpoints = prefetch_related_endpoints_for_report(endpoints.distinct(), product=product)
299303
endpoints = EndpointReportFilter(request.GET, queryset=endpoints)
300304

301305
paged_endpoints = get_page_items(request, endpoints.qs, 25)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from django.test import Client
2+
from django.utils.timezone import now
3+
4+
from dojo.authorization.roles_permissions import Roles
5+
from dojo.models import (
6+
Endpoint,
7+
Endpoint_Status,
8+
Engagement,
9+
Finding,
10+
Product,
11+
Product_Member,
12+
Product_Type,
13+
Role,
14+
Test,
15+
Test_Type,
16+
User,
17+
)
18+
from unittests.dojo_test_case import DojoTestCase, skip_unless_v2
19+
20+
21+
@skip_unless_v2
22+
class TestProductEndpointReportScoping(DojoTestCase):
23+
24+
"""
25+
The legacy `product_endpoint_report` view must only return endpoints and
26+
findings belonging to the requested product. Previously the Endpoint
27+
queryset was filtered by finding flags only and not scoped by product,
28+
so an unrelated product's findings appeared in the report.
29+
"""
30+
31+
fixtures = ["dojo_testdata.json"]
32+
33+
MARKER_A = "PRODUCT_A_UNIQUE_MARKER_b3c8aa1f"
34+
MARKER_B = "PRODUCT_B_UNIQUE_MARKER_d9e2bc54"
35+
36+
@classmethod
37+
def setUpTestData(cls):
38+
cls.user = User.objects.get(username="admin")
39+
cls.prod_type, _ = Product_Type.objects.get_or_create(name="Scoping Test PT")
40+
cls.test_type, _ = Test_Type.objects.get_or_create(name="Scoping Test Scan")
41+
42+
cls.product_a = Product.objects.create(
43+
name="Scoping Test Product A",
44+
description=cls.MARKER_A,
45+
prod_type=cls.prod_type,
46+
)
47+
cls.product_b = Product.objects.create(
48+
name="Scoping Test Product B",
49+
description=cls.MARKER_B,
50+
prod_type=cls.prod_type,
51+
)
52+
53+
cls.finding_a = cls._create_finding_with_endpoint(
54+
cls.product_a, "Finding for A", cls.MARKER_A, host="a.example.com",
55+
)
56+
cls.finding_b = cls._create_finding_with_endpoint(
57+
cls.product_b, "Finding for B", cls.MARKER_B, host="b.example.com",
58+
)
59+
60+
cls.restricted_user = User.objects.create_user(
61+
username="report_scoping_reader",
62+
password="not-a-real-secret", # noqa: S106 - test fixture user
63+
)
64+
reader_role = Role.objects.get(id=Roles.Reader)
65+
Product_Member.objects.create(
66+
user=cls.restricted_user,
67+
product=cls.product_a,
68+
role=reader_role,
69+
)
70+
71+
@classmethod
72+
def _create_finding_with_endpoint(cls, product, title, description, *, host):
73+
engagement = Engagement.objects.create(
74+
name=f"{product.name} Engagement",
75+
product=product,
76+
target_start=now(),
77+
target_end=now(),
78+
)
79+
test = Test.objects.create(
80+
engagement=engagement,
81+
test_type=cls.test_type,
82+
title=f"{product.name} Test",
83+
target_start=now(),
84+
target_end=now(),
85+
)
86+
finding = Finding.objects.create(
87+
test=test,
88+
title=title,
89+
description=description,
90+
severity="High",
91+
numerical_severity="S0",
92+
active=True,
93+
verified=True,
94+
false_p=False,
95+
duplicate=False,
96+
out_of_scope=False,
97+
mitigated=None,
98+
reporter=cls.user,
99+
)
100+
endpoint = Endpoint.objects.create(
101+
host=host,
102+
protocol="https",
103+
product=product,
104+
)
105+
Endpoint_Status.objects.create(
106+
endpoint=endpoint,
107+
finding=finding,
108+
mitigated=False,
109+
false_positive=False,
110+
out_of_scope=False,
111+
risk_accepted=False,
112+
)
113+
finding.endpoints.add(endpoint)
114+
return finding
115+
116+
def setUp(self):
117+
super().setUp()
118+
self.client = Client()
119+
self.client.force_login(self.user)
120+
121+
def test_product_endpoint_report_only_includes_target_product_findings(self):
122+
url = f"/product/{self.product_a.id}/endpoint/report?_generate=1&report_type=HTML"
123+
response = self.client.get(url)
124+
self.assertEqual(response.status_code, 200, response.content[:500])
125+
body = response.content.decode()
126+
127+
self.assertIn(self.MARKER_A, body, "Expected Product A's finding description in report")
128+
self.assertNotIn(
129+
self.MARKER_B,
130+
body,
131+
"Product B's finding description must not appear in Product A's report",
132+
)
133+
134+
def test_product_b_report_only_includes_product_b_findings(self):
135+
url = f"/product/{self.product_b.id}/endpoint/report?_generate=1&report_type=HTML"
136+
response = self.client.get(url)
137+
self.assertEqual(response.status_code, 200, response.content[:500])
138+
body = response.content.decode()
139+
140+
self.assertIn(self.MARKER_B, body)
141+
self.assertNotIn(self.MARKER_A, body)
142+
143+
def test_reports_findings_only_includes_user_authorized_findings(self):
144+
# /reports/findings was previously unscoped; a Reader on Product A should
145+
# see Product A's findings only. The template renders the title, not
146+
# the description, so we assert against the unique titles.
147+
restricted_client = Client()
148+
restricted_client.force_login(self.restricted_user)
149+
response = restricted_client.get("/reports/findings")
150+
self.assertEqual(response.status_code, 200, response.content[:500])
151+
body = response.content.decode()
152+
self.assertIn("Finding for A", body)
153+
self.assertNotIn("Finding for B", body)
154+
155+
def test_reports_endpoints_only_includes_user_authorized_endpoints(self):
156+
# /reports/endpoints was previously unscoped; a Reader on Product A
157+
# should only see Product A's endpoints.
158+
restricted_client = Client()
159+
restricted_client.force_login(self.restricted_user)
160+
response = restricted_client.get("/reports/endpoints")
161+
self.assertEqual(response.status_code, 200, response.content[:500])
162+
body = response.content.decode()
163+
self.assertIn("a.example.com", body)
164+
self.assertNotIn("b.example.com", body)

0 commit comments

Comments
 (0)