Skip to content

Commit a154a44

Browse files
Maffoochclaude
andcommitted
fix(dedupe): choose stable original by sorting creation order, keep dedupe comparisons id-based
The batch-stamping approach (_dedupe_batch_min_id + content-key comparisons inside _is_candidate_older) made the "older" relation depend on which dedupe batch evaluated it. That broke global antisymmetry: for a colliding pair split across two post-processing batches of one large import (> IMPORT_REIMPORT_DEDUPE_BATCH_SIZE findings), each side could consider the other "older", and two concurrently running dedupe batches could mark the pair as duplicates of each other. It also re-picked winners by content when re-running dedupe over pre-existing findings (the dedupe management command), flipping long-established originals. Instead, sort a report's findings by the stable content key BEFORE creating them - in DefaultImporter now, mirroring what DefaultReImporter already does - and keep every dedupe comparison a pure lowest-id-wins comparison: - within one import, id order now equals content order, so the surviving original is content-canonical and reproducible across re-scans regardless of scanner export order (in every execution mode, for any report size) - pre-existing findings always have smaller ids, so an established original never flips - including when the dedupe command re-processes old data - the "older" relation stays globally antisymmetric, so concurrent dedupe batches can never mark two findings as duplicates of each other Reimport match candidate ordering returns to id order (already stable across scans); the reimporter keeps the up-front preparation and global content sort so intra-report duplicate resolution stays order-independent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 178b098 commit a154a44

4 files changed

Lines changed: 156 additions & 152 deletions

File tree

dojo/finding/deduplication.py

Lines changed: 31 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
from collections.abc import Iterator
3+
from operator import attrgetter
34

45
import hyperlink
56
from django.conf import settings
@@ -545,14 +546,20 @@ def find_candidates_for_reimport_legacy(test, findings, service=None):
545546

546547
def deduplication_ordering_key(finding):
547548
"""
548-
Stable, content-derived sort key for choosing the canonical "original"
549-
among findings that collide on the deduplication key.
549+
Stable, content-derived sort key used by the importers to decide the order
550+
findings from one report are created (and therefore get their ids) in.
550551
551-
Independent of parser emission order and DB id, so the winner is
552-
reproducible across re-scans regardless of the order the scanner exports
553-
its findings in. id is the final tiebreak and is only reached when two
554-
findings are identical across every content field in the key (in which
555-
case the choice is immaterial because the findings are interchangeable).
552+
Deduplication itself always picks the lowest-id finding as the canonical
553+
"original". Because the importers sort a report's findings by this key
554+
BEFORE saving them, "lowest id" within one import equals "canonical by
555+
content", so the winner among findings that collide on the deduplication
556+
key is reproducible across re-scans regardless of the order the scanner
557+
exports its findings in. Findings from earlier imports always have smaller
558+
ids, so an already-established original never flips.
559+
560+
id is the final tiebreak and is only reached when two findings are
561+
identical across every content field in the key (in which case the choice
562+
is immaterial because the findings are interchangeable).
556563
557564
All fields referenced here are part of ``Finding.DEDUPLICATION_FIELDS``, so
558565
building this key never triggers extra database queries during dedupe.
@@ -567,57 +574,18 @@ def deduplication_ordering_key(finding):
567574
)
568575

569576

570-
def _candidate_sort_key(candidate, batch_min_id):
571-
"""
572-
Order candidates so the first "older" one encountered is the canonical original.
573-
574-
Findings that predate the current import batch (id < batch_min_id, or when no
575-
batch context is available) are ranked first and ordered by id, preserving the
576-
historical "pre-existing finding stays the original" behaviour. Findings created
577-
within the current batch are ranked after pre-existing ones and ordered by the
578-
stable content key so the winner does not depend on parser emission order.
579-
580-
``batch_min_id`` is an optional marker; when it is not an integer (absent) or the
581-
candidate has no integer id yet (unsaved/preview), the candidate is treated as
582-
pre-existing and ordered by id, so arithmetic is only ever done on real ids.
583-
"""
584-
candidate_id = candidate.id if isinstance(candidate.id, int) else None
585-
if not isinstance(batch_min_id, int) or candidate_id is None or candidate_id < batch_min_id:
586-
return (0, candidate_id or 0)
587-
return (1, deduplication_ordering_key(candidate))
588-
589-
590-
def _prepare_batch_ordering(findings):
591-
"""
592-
Stamp every finding in a dedup batch with the batch's minimum id and return the
593-
batch sorted by the stable content key.
594-
595-
The stamped ``_dedupe_batch_min_id`` lets ``_is_candidate_older`` tell findings
596-
created within this batch apart from pre-existing candidates loaded from the DB
597-
(which always have smaller ids), so pre-existing findings keep their id-based
598-
ordering while intra-batch ties are broken deterministically by content.
599-
"""
600-
batch_ids = [f.id for f in findings if f.id is not None]
601-
batch_min_id = min(batch_ids) if batch_ids else None
602-
for f in findings:
603-
f._dedupe_batch_min_id = batch_min_id
604-
return sorted(findings, key=deduplication_ordering_key)
605-
606-
607577
def _is_candidate_older(new_finding, candidate):
608578
# Unsaved findings (e.g. preview mode) have no PK — all DB candidates are older by definition
609579
if new_finding.pk is None:
610580
return True
611-
# Findings created within the current import batch all have ids >= batch_min_id
612-
# (auto-increment guarantees pre-existing findings have smaller ids). For those
613-
# we break ties by the stable content key so the "original" is deterministic
614-
# regardless of the order the scanner emitted the findings. Pre-existing
615-
# candidates keep the id ordering so an already-existing finding stays original.
616-
batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None)
617-
if batch_min_id is not None and candidate.id is not None and candidate.id >= batch_min_id:
618-
is_older = deduplication_ordering_key(candidate) < deduplication_ordering_key(new_finding)
619-
else:
620-
is_older = candidate.id < new_finding.id
581+
# Ensure the newer finding is marked as duplicate of the older finding.
582+
# This comparison must stay a pure id comparison: it is evaluated
583+
# independently from concurrent dedupe batches (and from the `dedupe`
584+
# management command over pre-existing findings), so it has to be globally
585+
# antisymmetric — for any pair, exactly one side may see the other as
586+
# "older". Content-stable winner selection is achieved by the importers
587+
# creating a report's findings in deduplication_ordering_key order instead.
588+
is_older = candidate.id < new_finding.id
621589
if not is_older:
622590
deduplicationLogger.debug(f"candidate is newer than or equal to new finding: {new_finding.id} and candidate {candidate.id}")
623591
return is_older
@@ -626,11 +594,7 @@ def _is_candidate_older(new_finding, candidate):
626594
def get_matches_from_hash_candidates(new_finding, candidates_by_hash) -> Iterator[Finding]:
627595
if new_finding.hash_code is None:
628596
return
629-
batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None)
630-
possible_matches = sorted(
631-
candidates_by_hash.get(new_finding.hash_code, []),
632-
key=lambda c: _candidate_sort_key(c, batch_min_id),
633-
)
597+
possible_matches = candidates_by_hash.get(new_finding.hash_code, [])
634598
deduplicationLogger.debug(f"Finding {new_finding.id}: Found {len(possible_matches)} findings with same hash_code, ids={[(c.id, c.hash_code) for c in possible_matches]}")
635599

636600
for candidate in possible_matches:
@@ -647,11 +611,7 @@ def get_matches_from_unique_id_candidates(new_finding, candidates_by_uid) -> Ite
647611
if new_finding.unique_id_from_tool is None:
648612
return
649613

650-
batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None)
651-
possible_matches = sorted(
652-
candidates_by_uid.get(new_finding.unique_id_from_tool, []),
653-
key=lambda c: _candidate_sort_key(c, batch_min_id),
654-
)
614+
possible_matches = candidates_by_uid.get(new_finding.unique_id_from_tool, [])
655615
deduplicationLogger.debug(f"Finding {new_finding.id}: Found {len(possible_matches)} findings with same unique_id_from_tool, ids={[(c.id, c.unique_id_from_tool) for c in possible_matches]}")
656616
for candidate in possible_matches:
657617
if not _is_candidate_older(new_finding, candidate):
@@ -671,10 +631,9 @@ def get_matches_from_uid_or_hash_candidates(new_finding, candidates_by_uid, cand
671631
combined_by_id = {c.id: c for c in uid_list}
672632
for c in hash_list:
673633
combined_by_id.setdefault(c.id, c)
674-
batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None)
675-
ordered_candidates = sorted(combined_by_id.values(), key=lambda c: _candidate_sort_key(c, batch_min_id))
676-
deduplicationLogger.debug("Finding %s: UID_OR_HASH: combined candidate ids (ordered)=%s", new_finding.id, [c.id for c in ordered_candidates])
677-
for candidate in ordered_candidates:
634+
deduplicationLogger.debug("Finding %s: UID_OR_HASH: combined candidate ids (sorted)=%s", new_finding.id, sorted(combined_by_id.keys()))
635+
for candidate_id in sorted(combined_by_id.keys()):
636+
candidate = combined_by_id[candidate_id]
678637
if not _is_candidate_older(new_finding, candidate):
679638
continue
680639
if is_deduplication_on_engagement_mismatch(new_finding, candidate):
@@ -701,14 +660,6 @@ def get_matches_from_legacy_candidates(new_finding, candidates_by_title, candida
701660
if getattr(new_finding, "cwe", 0):
702661
candidates.extend(candidates_by_cwe.get(new_finding.cwe, []))
703662

704-
# De-duplicate (a candidate can match on both title and CWE) and walk in
705-
# canonical order so the first "older" candidate is the stable original.
706-
batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None)
707-
candidates = sorted(
708-
{c.id: c for c in candidates}.values(),
709-
key=lambda c: _candidate_sort_key(c, batch_min_id),
710-
)
711-
712663
for candidate in candidates:
713664
if not _is_candidate_older(new_finding, candidate):
714665
continue
@@ -898,9 +849,9 @@ def match_batch_of_findings(findings):
898849
enabled = System_Settings.objects.get().enable_deduplication
899850
if not enabled:
900851
return []
901-
# Only stamp/sort for saved findings; unsaved findings have no id or batch context
852+
# Only sort by id for saved findings; unsaved findings have no id
902853
if findings[0].pk is not None:
903-
findings = _prepare_batch_ordering(findings)
854+
findings = sorted(findings, key=attrgetter("id"))
904855
test = findings[0].test
905856
dedup_alg = test.deduplication_algorithm
906857
if dedup_alg == settings.DEDUPE_ALGO_HASH_CODE:
@@ -1019,10 +970,8 @@ def dedupe_batch_of_findings(findings, *args, **kwargs):
1019970
enabled = System_Settings.objects.get().enable_deduplication
1020971

1021972
if enabled:
1022-
# Stamp each finding with the batch's minimum id and sort by the stable
1023-
# content key so deduplication is deterministic and independent of the
1024-
# order the scanner emitted its findings (see _is_candidate_older).
1025-
findings = _prepare_batch_ordering(findings)
973+
# sort findings by id to ensure deduplication is deterministic/reproducible
974+
findings = sorted(findings, key=attrgetter("id"))
1026975

1027976
test = findings[0].test
1028977
dedup_alg = test.deduplication_algorithm

dojo/importers/default_importer.py

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from dojo.celery_dispatch import dojo_dispatch_task
99
from dojo.finding import helper as finding_helper
10+
from dojo.finding.deduplication import deduplication_ordering_key
1011
from dojo.importers.base_importer import BaseImporter, Parser
1112
from dojo.importers.base_location_manager import LocationHandler
1213
from dojo.importers.options import ImporterOptions
@@ -203,54 +204,66 @@ def _process_findings_internal(
203204
logger.debug("starting import of %i parsed findings.", len(parsed_findings) if parsed_findings else 0)
204205
group_names_to_findings_dict = {}
205206

206-
# Pre-sanitize and filter by minimum severity
207+
# Pre-sanitize and filter by minimum severity. Findings are also fully
208+
# prepared here (fields/overrides/hash_code) so they can be sorted by a
209+
# stable content key below.
207210
cleaned_findings = []
208211
for raw_finding in parsed_findings or []:
209212
sanitized = self.sanitize_severity(raw_finding)
210213
if Finding.SEVERITIES[sanitized.severity] > Finding.SEVERITIES[self.minimum_severity]:
211214
logger.debug("skipping finding due to minimum severity filter (finding=%s severity=%s min=%s)", sanitized.title, sanitized.severity, self.minimum_severity)
212215
continue
213-
cleaned_findings.append(sanitized)
214-
215-
for idx, unsaved_finding in enumerate(cleaned_findings):
216-
is_final_finding = idx == len(cleaned_findings) - 1
217-
218216
# Some parsers provide "mitigated" field but do not set timezone (because they are probably not available in the report)
219217
# Finding.mitigated is DateTimeField and it requires timezone
220-
if unsaved_finding.mitigated and not unsaved_finding.mitigated.tzinfo:
221-
unsaved_finding.mitigated = unsaved_finding.mitigated.replace(tzinfo=self.now.tzinfo)
218+
if sanitized.mitigated and not sanitized.mitigated.tzinfo:
219+
sanitized.mitigated = sanitized.mitigated.replace(tzinfo=self.now.tzinfo)
222220
# Set some explicit fields on the finding
223-
unsaved_finding.test = self.test
224-
unsaved_finding.reporter = self.user
225-
unsaved_finding.last_reviewed_by = self.user
226-
unsaved_finding.last_reviewed = self.now
227-
logger.debug("process_parsed_finding: unique_id_from_tool: %s, hash_code: %s, active from report: %s, verified from report: %s", unsaved_finding.unique_id_from_tool, unsaved_finding.hash_code, unsaved_finding.active, unsaved_finding.verified)
228-
# indicates an override. Otherwise, do not change the value of unsaved_finding.active
221+
sanitized.test = self.test
222+
sanitized.reporter = self.user
223+
sanitized.last_reviewed_by = self.user
224+
sanitized.last_reviewed = self.now
225+
logger.debug("process_parsed_finding: unique_id_from_tool: %s, hash_code: %s, active from report: %s, verified from report: %s", sanitized.unique_id_from_tool, sanitized.hash_code, sanitized.active, sanitized.verified)
226+
# indicates an override. Otherwise, do not change the value of sanitized.active
229227
if self.active is not None:
230-
unsaved_finding.active = self.active
228+
sanitized.active = self.active
231229
# indicates an override. Otherwise, do not change the value of verified
232230
if self.verified is not None:
233-
unsaved_finding.verified = self.verified
231+
sanitized.verified = self.verified
234232
# scan_date was provided, override value from parser
235233
if self.scan_date_override:
236-
unsaved_finding.date = self.scan_date.date()
234+
sanitized.date = self.scan_date.date()
237235
if self.service is not None:
238-
unsaved_finding.service = self.service
236+
sanitized.service = self.service
239237

240238
# Parsers shouldn't use the tags field, and use unsaved_tags instead.
241239
# Merge any tags set by parser into unsaved_tags
242-
tags_from_parser = unsaved_finding.tags if isinstance(unsaved_finding.tags, list) else []
243-
unsaved_tags_from_parser = unsaved_finding.unsaved_tags if isinstance(unsaved_finding.unsaved_tags, list) else []
240+
tags_from_parser = sanitized.tags if isinstance(sanitized.tags, list) else []
241+
unsaved_tags_from_parser = sanitized.unsaved_tags if isinstance(sanitized.unsaved_tags, list) else []
244242
merged_tags = unsaved_tags_from_parser + tags_from_parser
245243
if merged_tags:
246-
unsaved_finding.unsaved_tags = merged_tags
247-
unsaved_finding.tags = None
248-
finding = self.process_cve(unsaved_finding)
244+
sanitized.unsaved_tags = merged_tags
245+
sanitized.tags = None
246+
prepared = self.process_cve(sanitized)
249247
# Calculate hash_code before saving based on unsaved_endpoints/unsaved_locations and unsaved_vulnerability_ids
250-
finding.set_hash_code(True)
248+
prepared.set_hash_code(True)
249+
cleaned_findings.append(prepared)
250+
251+
# Create findings in stable content-key order so their ids follow content, not
252+
# the scanner's export order. Deduplication picks the lowest id among findings
253+
# that collide on the dedupe key, so this makes the surviving "original" for
254+
# intra-report collisions reproducible across re-scans regardless of the order
255+
# the scanner emitted its findings. Unsaved findings have no id, so the id
256+
# tiebreak is inert here and byte-identical findings keep their relative
257+
# (immaterial) order via Python's stable sort.
258+
cleaned_findings.sort(key=deduplication_ordering_key)
259+
260+
for idx, finding in enumerate(cleaned_findings):
261+
is_final_finding = idx == len(cleaned_findings) - 1
251262

252-
# postprocessing will be done after processing related fields like locations, vulnerability ids, etc.
253-
unsaved_finding.save_no_options()
263+
# Findings are already prepared (fields/overrides/hash_code) and globally
264+
# sorted above. postprocessing will be done after processing related
265+
# fields like locations, vulnerability ids, etc.
266+
finding.save_no_options()
254267

255268
# Determine how the finding should be grouped
256269
finding_will_be_grouped = self.process_finding_groups(
@@ -269,8 +282,8 @@ def _process_findings_internal(
269282
findings_with_parser_tags.append((finding, [cleaned_tags]))
270283
# Process any files
271284
self.process_files(finding)
272-
# Process vulnerability IDs
273-
finding = self.store_vulnerability_ids(finding)
285+
# Process vulnerability IDs (mutates the finding in place)
286+
self.store_vulnerability_ids(finding)
274287
# Categorize this finding as a new one
275288
new_findings.append(finding)
276289
# all data is already saved on the finding, we only need to trigger post processing in batches

dojo/importers/default_reimporter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -663,13 +663,13 @@ def match_finding_to_candidate_reimport(
663663
if candidates_by_hash is None or unsaved_finding.hash_code is None:
664664
return []
665665
matches = candidates_by_hash.get(unsaved_finding.hash_code, [])
666-
return sorted(matches, key=deduplication_ordering_key)
666+
return sorted(matches, key=lambda f: f.id)
667667

668668
if self.deduplication_algorithm == "unique_id_from_tool":
669669
if candidates_by_uid is None or unsaved_finding.unique_id_from_tool is None:
670670
return []
671671
matches = candidates_by_uid.get(unsaved_finding.unique_id_from_tool, [])
672-
return sorted(matches, key=deduplication_ordering_key)
672+
return sorted(matches, key=lambda f: f.id)
673673

674674
if self.deduplication_algorithm == "unique_id_from_tool_or_hash_code":
675675
if candidates_by_hash is None and candidates_by_uid is None:
@@ -692,14 +692,14 @@ def match_finding_to_candidate_reimport(
692692
matches_by_id[match.id] = match
693693

694694
matches = list(matches_by_id.values())
695-
return sorted(matches, key=deduplication_ordering_key)
695+
return sorted(matches, key=lambda f: f.id)
696696

697697
if self.deduplication_algorithm == "legacy":
698698
if candidates_by_key is None or not unsaved_finding.title:
699699
return []
700700
key = (unsaved_finding.title.lower(), unsaved_finding.severity)
701701
matches = candidates_by_key.get(key, [])
702-
return sorted(matches, key=deduplication_ordering_key)
702+
return sorted(matches, key=lambda f: f.id)
703703

704704
logger.error(f'Internal error: unexpected deduplication_algorithm: "{self.deduplication_algorithm}"')
705705
return []

0 commit comments

Comments
 (0)