Skip to content

Commit e366fbc

Browse files
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 03609eb commit e366fbc

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
@@ -22,7 +22,7 @@
2222
from titlecase import titlecase
2323

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

2727
# get_current_date/tomorrow/copy_model_util are defined early in dojo.models, before the
2828
# re-export that loads this module — so this resolves despite the partial circular load, and
@@ -787,6 +787,11 @@ def compute_hash_code(self):
787787
my_vulnerability_ids = self.get_vulnerability_ids()
788788
fields_to_hash += my_vulnerability_ids
789789
deduplicationLogger.debug(hashcodeField + " : " + my_vulnerability_ids)
790+
elif hashcodeField == "cwes":
791+
# For the CWE set (primary cwe + additional CWEs), need to compute the field
792+
my_cwes = self.get_cwes()
793+
fields_to_hash += my_cwes
794+
deduplicationLogger.debug(hashcodeField + " : " + my_cwes)
790795
else:
791796
# Generically use the finding attribute having the same name, converts to str in case it's integer
792797
fields_to_hash += str(getattr(self, hashcodeField))
@@ -832,6 +837,22 @@ def _get_saved_vulnerability_ids(finding) -> str:
832837

833838
return _get_saved_vulnerability_ids(self) or _get_unsaved_vulnerability_ids(self)
834839

840+
# Get CWEs (canonical CWE-<n> labels) to use for hash_code computation
841+
def get_cwes(self):
842+
# The extra CWE rows (finding_cwe_set) are written *after* the finding is saved during
843+
# import, but unsaved_cwes carries them at hash time. Unlike vulnerability_ids there is a
844+
# primary scalar (self.cwe) that is always set, so self.cwes is non-empty even before the
845+
# extra rows exist — a plain "saved or unsaved" fallback would miss the extras. Prefer
846+
# unsaved_cwes whenever it is present so the pre-save and post-save hashes agree.
847+
if self.unsaved_cwes:
848+
labels = finding_cwe_labels(self.cwe, self.unsaved_cwes)
849+
elif self.id is not None:
850+
labels = self.cwes
851+
else:
852+
labels = finding_cwe_labels(self.cwe, None)
853+
# sort for a deterministic hash regardless of row/label ordering
854+
return "".join(sorted(labels))
855+
835856
# Get locations/endpoints to use for hash_code computation
836857
def get_locations(self):
837858
# 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
@@ -1201,7 +1201,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
12011201
# List of fields that are known to be usable in hash_code computation)
12021202
# 'endpoints' is a pseudo field that uses the endpoints (for dynamic scanners). If `V3_FEATURE_LOCATIONS` is True, Dojo uses locations (URLs) instead.
12031203
# '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
1204-
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"]
1204+
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"]
12051205

12061206
# Adding fields to the hash_code calculation regardless of the previous settings
12071207
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)