Skip to content

Commit 39418cb

Browse files
authored
Merge branch 'bugfix' into os-messaging-banner
2 parents 62b9b92 + 4decd88 commit 39418cb

File tree

6 files changed

+155
-9
lines changed

6 files changed

+155
-9
lines changed

dojo/api_v2/permissions.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,13 @@ def has_permission(self, request, view):
473473
# Raise an explicit drf exception here
474474
raise ValidationError(e)
475475
if engagement := converted_dict.get("engagement"):
476-
# existing engagement, nothing special to check
476+
# Validate the resolved engagement's parent chain matches any provided identifiers
477+
if (product := converted_dict.get("product")) and engagement.product_id != product.id:
478+
msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
479+
raise ValidationError(msg)
480+
if (engagement_name := converted_dict.get("engagement_name")) and engagement.name != engagement_name:
481+
msg = "The provided identifiers are inconsistent — the engagement name does not match the specified engagement."
482+
raise ValidationError(msg)
477483
return user_has_permission(
478484
request.user, engagement, Permissions.Import_Scan_Result,
479485
)
@@ -764,6 +770,11 @@ def has_permission(self, request, view):
764770
try:
765771
converted_dict = auto_create.convert_querydict_to_dict(request.data)
766772
auto_create.process_import_meta_data_from_dict(converted_dict)
773+
# engagement is not a declared field on ReImportScanSerializer and will be
774+
# stripped during validation — don't use it in the permission check either,
775+
# so the permission check resolves targets the same way execution does
776+
converted_dict.pop("engagement", None)
777+
converted_dict.pop("engagement_id", None)
767778
# Get an existing product
768779
converted_dict["product_type"] = auto_create.get_target_product_type_if_exists(**converted_dict)
769780
converted_dict["product"] = auto_create.get_target_product_if_exists(**converted_dict)
@@ -774,7 +785,20 @@ def has_permission(self, request, view):
774785
raise ValidationError(e)
775786

776787
if test := converted_dict.get("test"):
777-
# existing test, nothing special to check
788+
# Validate the resolved test's parent chain matches any provided identifiers
789+
if (product := converted_dict.get("product")) and test.engagement.product_id != product.id:
790+
msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
791+
raise ValidationError(msg)
792+
if (engagement := converted_dict.get("engagement")) and test.engagement_id != engagement.id:
793+
msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
794+
raise ValidationError(msg)
795+
# Also validate by name when the objects were not resolved (e.g. names that match no existing record)
796+
if not converted_dict.get("product") and (product_name := converted_dict.get("product_name")) and test.engagement.product.name != product_name:
797+
msg = "The provided identifiers are inconsistent — the test does not belong to the specified product."
798+
raise ValidationError(msg)
799+
if not converted_dict.get("engagement") and (engagement_name := converted_dict.get("engagement_name")) and test.engagement.name != engagement_name:
800+
msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
801+
raise ValidationError(msg)
778802
return user_has_permission(
779803
request.user, test, Permissions.Import_Scan_Result,
780804
)
@@ -1181,7 +1205,10 @@ def check_auto_create_permission(
11811205
raise ValidationError(msg)
11821206

11831207
if engagement:
1184-
# existing engagement, nothing special to check
1208+
# Validate the resolved engagement's parent chain matches any provided names
1209+
if product is not None and engagement.product_id != product.id:
1210+
msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
1211+
raise ValidationError(msg)
11851212
return user_has_permission(
11861213
user, engagement, Permissions.Import_Scan_Result,
11871214
)

dojo/importers/auto_create_context.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ def get_target_engagement_if_exists(
181181
"""
182182
if engagement := get_object_or_none(Engagement, pk=engagement_id):
183183
logger.debug("Using existing engagement by id: %s", engagement_id)
184+
if product is not None and engagement.product_id != product.id:
185+
msg = "The provided identifiers are inconsistent — the engagement does not belong to the specified product."
186+
raise ValueError(msg)
184187
return engagement
185188
# if there's no product, then for sure there's no engagement either
186189
if product is None:
@@ -203,6 +206,9 @@ def get_target_test_if_exists(
203206
"""
204207
if test := get_object_or_none(Test, pk=test_id):
205208
logger.debug("Using existing Test by id: %s", test_id)
209+
if engagement is not None and test.engagement_id != engagement.id:
210+
msg = "The provided identifiers are inconsistent — the test does not belong to the specified engagement."
211+
raise ValueError(msg)
206212
return test
207213
# If the engagement is not supplied, we cannot do anything
208214
if not engagement:

dojo/templates/dojo/snippets/endpoints.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,13 @@ <h6>Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})</h6>
7272
{% if V3_FEATURE_LOCATIONS %}
7373
<td style="word-break: break-word">{{ endpoint.location }}{% if endpoint.is_broken %} <span data-toggle="tooltip" title="Endpoint is broken. Check documentation to look for fix process" >&#128681;</span>{% endif %}</td>
7474
<td>{{ endpoint.status }}</td>
75-
<td>{{ endpoint.auditor|safe }}</td>
75+
<td>{{ endpoint.auditor }}</td>
7676
<td>{{ endpoint.audit_time|date }}</td>
7777
{% else %}
78-
{% comment %} TODO: Delete this after the move to Locations {% endcomment %}
78+
{% comment %} TODO: Delete this after the move to Locations {% endcomment %}
7979
<td style="word-break: break-word">{{ endpoint }}{% if endpoint.endpoint.is_broken %} <span data-toggle="tooltip" title="Endpoint is broken. Check documentation to look for fix process" >&#128681;</span>{% endif %}</td>
8080
<td>{{ endpoint.status }}</td>
81-
<td>{{ endpoint.mitigated_by|safe }}</td>
81+
<td>{{ endpoint.mitigated_by }}</td>
8282
<td>{{ endpoint.mitigated_time|date }}</td>
8383
{% endif %}
8484
</tr>
@@ -256,7 +256,7 @@ <h4>Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})
256256
{% include "dojo/snippets/tags.html" with tags=endpoint.location.tags.all %}
257257
</td>
258258
<td>{{ endpoint.get_status_display }}</td>
259-
<td>{{ endpoint.auditor|safe }}</td>
259+
<td>{{ endpoint.auditor }}</td>
260260
<td>{{ endpoint.audit_time|date }}</td>
261261
{% else %}
262262
{% comment %} TODO: Delete this after the move to Locations {% endcomment %}
@@ -265,7 +265,7 @@ <h4>Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})
265265
{% include "dojo/snippets/tags.html" with tags=endpoint.endpoint.tags.all %}
266266
</td>
267267
<td>{{ endpoint.status }}</td>
268-
<td>{{ endpoint.mitigated_by|safe }}</td>
268+
<td>{{ endpoint.mitigated_by }}</td>
269269
<td>{{ endpoint.mitigated_time|date }}</td>
270270
{% endif %}
271271
</tr>

dojo/tools/govulncheck/parser.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ def get_location(data, node):
4040
def get_version(data, node):
4141
return data["Requires"]["Modules"][str(node)]["Version"]
4242

43+
@staticmethod
44+
def get_fix_info(affected_ranges):
45+
for r in affected_ranges:
46+
for event in r.get("events", []):
47+
if "fixed" in event:
48+
return True, event["fixed"]
49+
return False, ""
50+
51+
@staticmethod
52+
def get_introduced_version(affected_ranges):
53+
for r in affected_ranges:
54+
for event in r.get("events", []):
55+
if "introduced" in event:
56+
return event["introduced"]
57+
return ""
58+
4359
def get_finding_trace_info(self, data, osv_id):
4460
# Browse the findings to look for matching OSV-id. If the OSV-id is matching, extract traces.
4561
trace_info_strs = []
@@ -202,8 +218,12 @@ def get_findings(self, scan_file, test):
202218
else:
203219
title = f"{osv_data['id']} - {affected_package['name']}"
204220

205-
affected_version = self.get_affected_version(data, osv_data["id"])
221+
fix_available, fix_version = self.get_fix_info(affected_ranges)
206222

223+
affected_version = (
224+
self.get_affected_version(data, osv_data["id"])
225+
or self.get_introduced_version(affected_ranges)
226+
)
207227
severity = elem["osv"].get("severity", SEVERITY)
208228

209229
d = {
@@ -215,6 +235,8 @@ def get_findings(self, scan_file, test):
215235
"description": description,
216236
"impact": impact,
217237
"references": references,
238+
"fix_available": fix_available,
239+
"fix_version": fix_version,
218240
"file_path": path,
219241
"url": db_specific_url,
220242
"unique_id_from_tool": osv_id,

unittests/test_rest_framework.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3429,6 +3429,95 @@ def test_create_not_authorized_product_name_engagement_name_scan_type_title(self
34293429
importer_mock.assert_not_called()
34303430
reimporter_mock.assert_not_called()
34313431

3432+
# Security tests: verify that conflicting ID-based and name-based identifiers are rejected
3433+
3434+
@patch("dojo.importers.default_reimporter.DefaultReImporter.process_scan")
3435+
@patch("dojo.importers.default_importer.DefaultImporter.process_scan")
3436+
@patch("dojo.api_v2.permissions.user_has_permission")
3437+
def test_reimport_engagement_param_ignored_permission_checked_on_name_resolved_target(self, mock, importer_mock, reimporter_mock):
3438+
"""
3439+
Engagement is not a declared field on ReImportScanSerializer — verify
3440+
the permission check uses the name-resolved target, not the engagement param.
3441+
"""
3442+
mock.return_value = False
3443+
importer_mock.return_value = IMPORTER_MOCK_RETURN_VALUE
3444+
reimporter_mock.return_value = REIMPORTER_MOCK_RETURN_VALUE
3445+
3446+
with Path("tests/zap_sample.xml").open(encoding="utf-8") as testfile:
3447+
payload = {
3448+
"minimum_severity": "Low",
3449+
"active": True,
3450+
"verified": True,
3451+
"scan_type": "ZAP Scan",
3452+
"file": testfile,
3453+
# engagement=1 belongs to Product 2 Engagement 1, but it should be ignored
3454+
"engagement": 1,
3455+
# These names resolve to Product 2's Engagement 4 -> Test 4
3456+
"product_name": "Security How-to",
3457+
"engagement_name": "April monthly engagement",
3458+
"version": "1.0.0",
3459+
}
3460+
response = self.client.post(self.url, payload)
3461+
self.assertEqual(403, response.status_code, response.content[:1000])
3462+
# Permission must be checked on name-resolved Test 4 (in Engagement 4),
3463+
# NOT on Test 3 (which belongs to the engagement=1 param)
3464+
mock.assert_called_with(User.objects.get(username="admin"),
3465+
Test.objects.get(id=4),
3466+
Permissions.Import_Scan_Result)
3467+
importer_mock.assert_not_called()
3468+
reimporter_mock.assert_not_called()
3469+
3470+
@patch("dojo.importers.default_reimporter.DefaultReImporter.process_scan")
3471+
@patch("dojo.importers.default_importer.DefaultImporter.process_scan")
3472+
def test_reimport_with_test_id_mismatched_product_name_is_rejected(self, importer_mock, reimporter_mock):
3473+
"""Sending test ID from one product with product_name from another must be rejected."""
3474+
importer_mock.return_value = IMPORTER_MOCK_RETURN_VALUE
3475+
reimporter_mock.return_value = REIMPORTER_MOCK_RETURN_VALUE
3476+
3477+
with Path("tests/zap_sample.xml").open(encoding="utf-8") as testfile:
3478+
payload = {
3479+
"minimum_severity": "Low",
3480+
"active": True,
3481+
"verified": True,
3482+
"scan_type": "ZAP Scan",
3483+
"file": testfile,
3484+
# Test 3 belongs to Engagement 1 -> Product 2 ("Security How-to")
3485+
"test": 3,
3486+
# But product_name points to Product 1 ("Python How-to")
3487+
"product_name": "Python How-to",
3488+
"version": "1.0.0",
3489+
}
3490+
response = self.client.post(self.url, payload)
3491+
self.assertEqual(400, response.status_code, response.content[:1000])
3492+
importer_mock.assert_not_called()
3493+
reimporter_mock.assert_not_called()
3494+
3495+
@patch("dojo.importers.default_reimporter.DefaultReImporter.process_scan")
3496+
@patch("dojo.importers.default_importer.DefaultImporter.process_scan")
3497+
def test_reimport_with_test_id_mismatched_engagement_name_is_rejected(self, importer_mock, reimporter_mock):
3498+
"""Sending test ID from one engagement with engagement_name from another must be rejected."""
3499+
importer_mock.return_value = IMPORTER_MOCK_RETURN_VALUE
3500+
reimporter_mock.return_value = REIMPORTER_MOCK_RETURN_VALUE
3501+
3502+
with Path("tests/zap_sample.xml").open(encoding="utf-8") as testfile:
3503+
payload = {
3504+
"minimum_severity": "Low",
3505+
"active": True,
3506+
"verified": True,
3507+
"scan_type": "ZAP Scan",
3508+
"file": testfile,
3509+
# Test 3 belongs to Engagement 1 ("1st Quarter Engagement")
3510+
"test": 3,
3511+
# But engagement_name points to a different engagement
3512+
"product_name": "Security How-to",
3513+
"engagement_name": "April monthly engagement",
3514+
"version": "1.0.0",
3515+
}
3516+
response = self.client.post(self.url, payload)
3517+
self.assertEqual(400, response.status_code, response.content[:1000])
3518+
importer_mock.assert_not_called()
3519+
reimporter_mock.assert_not_called()
3520+
34323521

34333522
@versioned_fixtures
34343523
class ProductTypeTest(BaseClass.BaseClassTest):

unittests/tools/test_govulncheck_parser.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ def test_parse_new_version_many_findings_custom_severity(self):
127127
self.assertIsNotNone(finding.impact)
128128
self.assertIsNotNone(finding.description)
129129
self.assertIsNotNone(finding.references)
130+
self.assertTrue(finding.fix_available)
131+
self.assertEqual("0.3.8", finding.fix_version)
130132

131133
def test_parse_issue_14642(self):
132134
with (get_unit_tests_scans_path("govulncheck") / "issue_14642.json").open(encoding="utf-8") as testfile:

0 commit comments

Comments
 (0)