Skip to content

Commit 8702d69

Browse files
valentijnscholtenMaffooch
authored andcommitted
feat(finding): make the CWE set a hashable dedup field (cwes)
Add 'cwes' to HASHCODE_ALLOWED_FIELDS and hash it via a new get_cwes(), which mirrors get_vulnerability_ids. Because the primary cwe is a scalar that is always set (so Finding.cwes is non-empty even before the extra Finding_CWE rows are written during import), get_cwes prefers unsaved_cwes whenever present, so the pre-save and post-save import hashes agree. Tests: get_cwes prefers unsaved_cwes, is stable across save, empty with no cwe, and cwes participates in compute_hash_code.
1 parent f54df99 commit 8702d69

3 files changed

Lines changed: 74 additions & 3 deletions

File tree

dojo/finding/models.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from titlecase import titlecase
2525

2626
from dojo.base_models.base import BaseModel
27-
from dojo.finding.vulnerability_id import cwe_label, resolve_vulnerability_id_type
27+
from dojo.finding.vulnerability_id import cwe_label, finding_cwe_labels, resolve_vulnerability_id_type
2828

2929
# get_current_date/tomorrow/copy_model_util are defined early in dojo.models, before the
3030
# re-export that loads this module — so this resolves despite the partial circular load, and
@@ -798,6 +798,11 @@ def compute_hash_code(self):
798798
my_vulnerability_ids = self.get_vulnerability_ids()
799799
fields_to_hash += my_vulnerability_ids
800800
deduplicationLogger.debug(hashcodeField + " : " + my_vulnerability_ids)
801+
elif hashcodeField == "cwes":
802+
# For the CWE set (primary cwe + additional CWEs), need to compute the field
803+
my_cwes = self.get_cwes()
804+
fields_to_hash += my_cwes
805+
deduplicationLogger.debug(hashcodeField + " : " + my_cwes)
801806
else:
802807
# Generically use the finding attribute having the same name, converts to str in case it's integer
803808
fields_to_hash += str(getattr(self, hashcodeField))
@@ -843,6 +848,22 @@ def _get_saved_vulnerability_ids(finding) -> str:
843848

844849
return _get_saved_vulnerability_ids(self) or _get_unsaved_vulnerability_ids(self)
845850

851+
# Get CWEs (canonical CWE-<n> labels) to use for hash_code computation
852+
def get_cwes(self):
853+
# The extra CWE rows (finding_cwe_set) are written *after* the finding is saved during
854+
# import, but unsaved_cwes carries them at hash time. Unlike vulnerability_ids there is a
855+
# primary scalar (self.cwe) that is always set, so self.cwes is non-empty even before the
856+
# extra rows exist — a plain "saved or unsaved" fallback would miss the extras. Prefer
857+
# unsaved_cwes whenever it is present so the pre-save and post-save hashes agree.
858+
if self.unsaved_cwes:
859+
labels = finding_cwe_labels(self.cwe, self.unsaved_cwes)
860+
elif self.id is not None:
861+
labels = self.cwes
862+
else:
863+
labels = finding_cwe_labels(self.cwe, None)
864+
# sort for a deterministic hash regardless of row/label ordering
865+
return "".join(sorted(labels))
866+
846867
# Get locations/endpoints to use for hash_code computation
847868
def get_locations(self):
848869
# TODO: Delete this after the move to Locations

dojo/settings/settings.dist.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
12321232
# List of fields that are known to be usable in hash_code computation)
12331233
# 'endpoints' is a pseudo field that uses the endpoints (for dynamic scanners). If `V3_FEATURE_LOCATIONS` is True, Dojo uses locations (URLs) instead.
12341234
# 'unique_id_from_tool' is often not needed here as it can be used directly in the dedupe algorithm, but it's also possible to use it for hashing
1235-
HASHCODE_ALLOWED_FIELDS = ["title", "cwe", "vulnerability_ids", "line", "file_path", "payload", "component_name", "component_version", "description", "endpoints", "unique_id_from_tool", "severity", "vuln_id_from_tool", "mitigation"]
1235+
HASHCODE_ALLOWED_FIELDS = ["title", "cwe", "cwes", "vulnerability_ids", "line", "file_path", "payload", "component_name", "component_version", "description", "endpoints", "unique_id_from_tool", "severity", "vuln_id_from_tool", "mitigation"]
12361236

12371237
# Adding fields to the hash_code calculation regardless of the previous settings
12381238
HASH_CODE_FIELDS_ALWAYS = ["service"]

unittests/test_finding_cwe.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""CWE-as-a-separate-relationship (Finding_CWE) and the API."""
2-
from django.test import SimpleTestCase
2+
from django.test import SimpleTestCase, override_settings
33
from rest_framework.authtoken.models import Token
44
from rest_framework.test import APIClient
55

@@ -123,3 +123,53 @@ def test_finding_create_with_cwes(self):
123123
# primary cwe (legacy int) set from the first entry; both stored as canonical Finding_CWE rows
124124
self.assertEqual(79, finding.cwe)
125125
self.assertEqual({"CWE-79", "CWE-89"}, set(finding.finding_cwe_set.values_list("cwe", flat=True)))
126+
127+
128+
@versioned_fixtures
129+
class TestFindingCweHashCode(DojoTestCase):
130+
131+
"""get_cwes() and its use in compute_hash_code, incl. the unsaved_cwes import path."""
132+
133+
fixtures = ["dojo_testdata.json"]
134+
135+
def setUp(self):
136+
self.finding = Finding.objects.get(id=2)
137+
Finding_CWE.objects.filter(finding=self.finding).delete()
138+
139+
def test_get_cwes_prefers_unsaved_cwes(self):
140+
# Extra CWE rows are not written yet; get_cwes must still reflect them via unsaved_cwes.
141+
self.finding.cwe = 79
142+
self.finding.unsaved_cwes = [89, 22]
143+
self.assertEqual(self.finding.get_cwes(), "".join(sorted(["CWE-79", "CWE-89", "CWE-22"])))
144+
145+
def test_get_cwes_stable_before_and_after_save(self):
146+
# The pre-save (unsaved_cwes) and post-save (finding_cwe_set) hashes must agree.
147+
self.finding.cwe = 79
148+
self.finding.unsaved_cwes = [89, 22]
149+
before = self.finding.get_cwes()
150+
save_cwes(self.finding)
151+
reloaded = Finding.objects.get(id=2) # unsaved_cwes is None on a fresh load
152+
self.assertEqual(before, reloaded.get_cwes())
153+
self.assertEqual(reloaded.get_cwes(), "".join(sorted(["CWE-79", "CWE-89", "CWE-22"])))
154+
155+
def test_get_cwes_empty_when_no_cwe(self):
156+
self.finding.cwe = 0
157+
self.finding.unsaved_cwes = None
158+
save_cwes(self.finding)
159+
self.assertEqual(Finding.objects.get(id=2).get_cwes(), "")
160+
161+
def test_compute_hash_code_uses_cwe_set(self):
162+
self.finding.cwe = 79
163+
scanner = self.finding.test.test_type.name
164+
# FINDING_COMPUTE_HASH_METHOD=None forces the OSS compute_hash_code path under test
165+
# (a Pro deployment would otherwise delegate to its tuner-driven hash method).
166+
with override_settings(
167+
FINDING_COMPUTE_HASH_METHOD=None,
168+
HASHCODE_FIELDS_PER_SCANNER={scanner: ["title", "cwes"]},
169+
):
170+
self.finding.unsaved_cwes = [89]
171+
hash_with_89 = self.finding.compute_hash_code()
172+
self.finding.unsaved_cwes = [22]
173+
hash_with_22 = self.finding.compute_hash_code()
174+
# Different CWE set -> different hash, so cwes participates in the hash.
175+
self.assertNotEqual(hash_with_89, hash_with_22)

0 commit comments

Comments
 (0)