Skip to content

Commit d604c30

Browse files
fix(api): explicit scalar cwe stays primary when cwes list is also supplied (#15305)
PR #15143 added multiple-CWE support (cwes object list, writable on create and update). When a request supplied BOTH the scalar cwe and a cwes list, cwes[0] unconditionally overwrote the explicit scalar - the more specific input lost. Precedence now mirrors the vulnerability_ids->cve pattern: an explicit scalar cwe in the request stays the primary and every cwes entry is treated as an extra row (save_cwes dedupes the overlap); without a scalar, cwes[0] becomes the primary and is mirrored into the scalar. Detection uses initial_data presence - the established pattern in this serializer (FindingTemplateSerializer). Behavior change only for the unreleased cwes field when both inputs appear in one request; no released consumer can depend on the old behavior. Wire shape unchanged. +8 API tests: precedence on create/update, cwes-only primary promotion, replace, omission-untouched, explicit-empty semantics, read-back shape.
1 parent f291d7c commit d604c30

2 files changed

Lines changed: 120 additions & 11 deletions

File tree

dojo/finding/api/serializer.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -447,12 +447,20 @@ def update(self, instance, validated_data):
447447
logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0])
448448
validated_data["cve"] = parsed_vulnerability_ids[0]
449449

450-
# CWEs (mirror vulnerability_ids): the first entry is the primary Finding.cwe; the rest
451-
# become Finding_CWE rows via save_cwes() below.
450+
# CWEs (mirror vulnerability_ids): the primary Finding.cwe plus extra Finding_CWE rows
451+
# persisted via save_cwes() below. Precedence: an explicit scalar `cwe` in the request
452+
# stays the primary and every `cwes` entry is treated as an extra (save_cwes dedupes the
453+
# overlap); otherwise the first `cwes` entry becomes the primary and is mirrored into the
454+
# scalar, with the remainder kept as the extras.
452455
parsed_cwes = None
456+
cwe_extras = []
453457
if (cwes := validated_data.pop("finding_cwe_set", None)) is not None:
454458
parsed_cwes = [entry["cwe"] for entry in cwes]
455-
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
459+
if "cwe" in getattr(self, "initial_data", {}):
460+
cwe_extras = parsed_cwes
461+
else:
462+
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
463+
cwe_extras = parsed_cwes[1:]
456464

457465
# Save the reporter on the finding
458466
if reporter_id := validated_data.get("reporter"):
@@ -487,7 +495,7 @@ def update(self, instance, validated_data):
487495
# unconditionally would wipe a finding's extra Finding_CWE rows on any partial PATCH that
488496
# omits the field (mirrors the guarded vulnerability-id path above).
489497
if parsed_cwes is not None:
490-
instance.unsaved_cwes = parsed_cwes[1:]
498+
instance.unsaved_cwes = cwe_extras
491499
save_cwes(instance)
492500

493501
if settings.V3_FEATURE_LOCATIONS and locations is not None:
@@ -649,11 +657,19 @@ def create(self, validated_data):
649657
validated_data["cve"] = parsed_vulnerability_ids[0]
650658
# validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids
651659

652-
# CWEs (mirror vulnerability_ids): first entry is the primary cwe, the rest are extras.
660+
# CWEs (mirror vulnerability_ids): the primary cwe plus extras. Precedence: an explicit
661+
# scalar `cwe` in the request stays the primary and every `cwes` entry is treated as an
662+
# extra (save_cwes dedupes the overlap); otherwise the first `cwes` entry becomes the
663+
# primary and is mirrored into the scalar, with the remainder kept as the extras.
653664
parsed_cwes = None
665+
cwe_extras = []
654666
if (cwes := validated_data.pop("finding_cwe_set", None)) is not None:
655667
parsed_cwes = [entry["cwe"] for entry in cwes]
656-
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
668+
if "cwe" in getattr(self, "initial_data", {}):
669+
cwe_extras = parsed_cwes
670+
else:
671+
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
672+
cwe_extras = parsed_cwes[1:]
657673

658674
# super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly.
659675
new_finding = Finding(**validated_data)
@@ -672,7 +688,7 @@ def create(self, validated_data):
672688
if parsed_vulnerability_ids:
673689
save_vulnerability_ids(new_finding, parsed_vulnerability_ids)
674690
if parsed_cwes is not None:
675-
new_finding.unsaved_cwes = parsed_cwes[1:]
691+
new_finding.unsaved_cwes = cwe_extras
676692
save_cwes(new_finding)
677693

678694
if push_to_jira:

unittests/test_finding_cwe.py

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,22 @@ def setUp(self):
108108
self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key)
109109
self.client.force_login(self.get_test_admin())
110110

111+
def _base_create_payload(self):
112+
# Reuse finding id=2 as a template; drop the identity/derived CWE fields so each test
113+
# supplies exactly the cwe/cwes it is exercising.
114+
payload = self.get_finding_api(2)
115+
del payload["id"]
116+
payload.pop("cve", None)
117+
payload.pop("cwe", None)
118+
payload.pop("cwes", None)
119+
return payload
120+
121+
def _stored_cwes(self, finding_id):
122+
return set(Finding_CWE.objects.filter(finding_id=finding_id).values_list("cwe", flat=True))
123+
111124
def test_finding_create_with_cwes(self):
112125
# cwes mirror vulnerability_ids: nested [{"cwe": "CWE-79"}], first is the primary Finding.cwe
113-
finding_details = self.get_finding_api(2)
114-
del finding_details["id"]
115-
finding_details.pop("cve", None)
116-
finding_details.pop("cwe", None)
126+
finding_details = self._base_create_payload()
117127
new_cwes = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]
118128
finding_details["cwes"] = new_cwes
119129
response = self.post_new_finding_api(finding_details)
@@ -124,6 +134,89 @@ def test_finding_create_with_cwes(self):
124134
self.assertEqual(79, finding.cwe)
125135
self.assertEqual({"CWE-79", "CWE-89"}, set(finding.finding_cwe_set.values_list("cwe", flat=True)))
126136

137+
def test_finding_create_precedence_explicit_scalar_wins(self):
138+
# When BOTH a scalar `cwe` and a `cwes` list are supplied, the explicit scalar stays the
139+
# primary; the cwes entries become the extras (mirrors vulnerability_ids precedence).
140+
finding_details = self._base_create_payload()
141+
finding_details["cwe"] = 22
142+
finding_details["cwes"] = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]
143+
response = self.post_new_finding_api(finding_details)
144+
finding = Finding.objects.get(id=response.get("id"))
145+
self.assertEqual(22, finding.cwe)
146+
self.assertEqual({"CWE-22", "CWE-79", "CWE-89"}, self._stored_cwes(finding.id))
147+
# primary-first ordering guaranteed by the model property / save_cwes
148+
self.assertEqual("CWE-22", finding.cwes[0])
149+
150+
def test_finding_create_cwes_only_first_becomes_primary(self):
151+
# No scalar cwe supplied -> the first cwes entry becomes the primary and is mirrored.
152+
finding_details = self._base_create_payload()
153+
finding_details["cwes"] = [{"cwe": "CWE-89"}, {"cwe": "CWE-22"}]
154+
response = self.post_new_finding_api(finding_details)
155+
finding = Finding.objects.get(id=response.get("id"))
156+
self.assertEqual(89, finding.cwe)
157+
self.assertEqual({"CWE-89", "CWE-22"}, self._stored_cwes(finding.id))
158+
self.assertEqual("CWE-89", finding.cwes[0])
159+
160+
def test_finding_update_replaces_cwes(self):
161+
finding_details = self._base_create_payload()
162+
finding_details["cwes"] = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]
163+
finding_id = self.post_new_finding_api(finding_details).get("id")
164+
# PATCH with a new set replaces the rows entirely (delete-then-recreate via save_cwes).
165+
self.patch_finding_api(finding_id, {"cwes": [{"cwe": "CWE-22"}, {"cwe": "CWE-352"}]})
166+
finding = Finding.objects.get(id=finding_id)
167+
self.assertEqual(22, finding.cwe)
168+
self.assertEqual({"CWE-22", "CWE-352"}, self._stored_cwes(finding_id))
169+
170+
def test_finding_update_precedence_explicit_scalar_wins(self):
171+
finding_details = self._base_create_payload()
172+
finding_details["cwes"] = [{"cwe": "CWE-79"}]
173+
finding_id = self.post_new_finding_api(finding_details).get("id")
174+
# Both scalar + list on the same PATCH: explicit scalar stays primary.
175+
self.patch_finding_api(finding_id, {"cwe": 22, "cwes": [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]})
176+
finding = Finding.objects.get(id=finding_id)
177+
self.assertEqual(22, finding.cwe)
178+
self.assertEqual({"CWE-22", "CWE-79", "CWE-89"}, self._stored_cwes(finding_id))
179+
self.assertEqual("CWE-22", finding.cwes[0])
180+
181+
def test_finding_update_omission_leaves_cwes_untouched(self):
182+
finding_details = self._base_create_payload()
183+
finding_details["cwes"] = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]
184+
finding_id = self.post_new_finding_api(finding_details).get("id")
185+
# A PATCH that omits cwes must not touch the Finding_CWE rows.
186+
self.patch_finding_api(finding_id, {"title": "cwes untouched by this patch"})
187+
self.assertEqual({"CWE-79", "CWE-89"}, self._stored_cwes(finding_id))
188+
189+
def test_finding_update_explicit_empty_clears(self):
190+
finding_details = self._base_create_payload()
191+
finding_details["cwes"] = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]
192+
finding_id = self.post_new_finding_api(finding_details).get("id")
193+
# Explicit [] clears the rows; with no scalar supplied the primary resets to unset (0).
194+
self.patch_finding_api(finding_id, {"cwes": []})
195+
finding = Finding.objects.get(id=finding_id)
196+
self.assertEqual(set(), self._stored_cwes(finding_id))
197+
self.assertEqual(0, finding.cwe)
198+
199+
def test_finding_update_explicit_empty_with_scalar_keeps_primary(self):
200+
finding_details = self._base_create_payload()
201+
finding_details["cwes"] = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]
202+
finding_id = self.post_new_finding_api(finding_details).get("id")
203+
# Explicit [] with an explicit scalar clears the extras but keeps the scalar-derived primary.
204+
self.patch_finding_api(finding_id, {"cwe": 79, "cwes": []})
205+
finding = Finding.objects.get(id=finding_id)
206+
self.assertEqual(79, finding.cwe)
207+
self.assertEqual({"CWE-79"}, self._stored_cwes(finding_id))
208+
209+
def test_finding_cwes_read_back_shape_is_object_list(self):
210+
# Wire-shape stability: cwes reads back as a list of {"cwe": "CWE-<n>"} objects, never a
211+
# flat list of ints. This is the contract v2 must preserve.
212+
finding_details = self._base_create_payload()
213+
finding_details["cwes"] = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}]
214+
finding_id = self.post_new_finding_api(finding_details).get("id")
215+
cwes = self.get_finding_api(finding_id)["cwes"]
216+
self.assertTrue(all(isinstance(entry, dict) and set(entry.keys()) == {"cwe"} for entry in cwes))
217+
self.assertTrue(all(entry["cwe"].startswith("CWE-") for entry in cwes))
218+
self.assertEqual({"CWE-79", "CWE-89"}, {entry["cwe"] for entry in cwes})
219+
127220

128221
@versioned_fixtures
129222
class TestFindingCweHashCode(DojoTestCase):

0 commit comments

Comments
 (0)