11import logging
22from collections .abc import Iterator
3+ from operator import attrgetter
34
45import hyperlink
56from django .conf import settings
@@ -545,14 +546,20 @@ def find_candidates_for_reimport_legacy(test, findings, service=None):
545546
546547def 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-
607577def _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):
626594def 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
0 commit comments