11import logging
22from collections .abc import Iterator
3- from operator import attrgetter
43
54import hyperlink
65from django .conf import settings
@@ -544,12 +543,81 @@ def find_candidates_for_reimport_legacy(test, findings, service=None):
544543 return existing_by_key
545544
546545
546+ def deduplication_ordering_key (finding ):
547+ """
548+ Stable, content-derived sort key for choosing the canonical "original"
549+ among findings that collide on the deduplication key.
550+
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).
556+
557+ All fields referenced here are part of ``Finding.DEDUPLICATION_FIELDS``, so
558+ building this key never triggers extra database queries during dedupe.
559+ """
560+ return (
561+ finding .hash_code or "" ,
562+ finding .unique_id_from_tool or "" ,
563+ finding .file_path or "" ,
564+ finding .line if finding .line is not None else - 1 ,
565+ finding .title or "" ,
566+ finding .id or 0 ,
567+ )
568+
569+
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+
547607def _is_candidate_older (new_finding , candidate ):
548608 # Unsaved findings (e.g. preview mode) have no PK — all DB candidates are older by definition
549609 if new_finding .pk is None :
550610 return True
551- # Ensure the newer finding is marked as duplicate of the older finding
552- is_older = candidate .id < new_finding .id
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
553621 if not is_older :
554622 deduplicationLogger .debug (f"candidate is newer than or equal to new finding: { new_finding .id } and candidate { candidate .id } " )
555623 return is_older
@@ -558,7 +626,11 @@ def _is_candidate_older(new_finding, candidate):
558626def get_matches_from_hash_candidates (new_finding , candidates_by_hash ) -> Iterator [Finding ]:
559627 if new_finding .hash_code is None :
560628 return
561- possible_matches = candidates_by_hash .get (new_finding .hash_code , [])
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+ )
562634 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 ]} " )
563635
564636 for candidate in possible_matches :
@@ -575,7 +647,11 @@ def get_matches_from_unique_id_candidates(new_finding, candidates_by_uid) -> Ite
575647 if new_finding .unique_id_from_tool is None :
576648 return
577649
578- possible_matches = candidates_by_uid .get (new_finding .unique_id_from_tool , [])
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+ )
579655 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 ]} " )
580656 for candidate in possible_matches :
581657 if not _is_candidate_older (new_finding , candidate ):
@@ -595,9 +671,10 @@ def get_matches_from_uid_or_hash_candidates(new_finding, candidates_by_uid, cand
595671 combined_by_id = {c .id : c for c in uid_list }
596672 for c in hash_list :
597673 combined_by_id .setdefault (c .id , c )
598- deduplicationLogger .debug ("Finding %s: UID_OR_HASH: combined candidate ids (sorted)=%s" , new_finding .id , sorted (combined_by_id .keys ()))
599- for candidate_id in sorted (combined_by_id .keys ()):
600- candidate = combined_by_id [candidate_id ]
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 :
601678 if not _is_candidate_older (new_finding , candidate ):
602679 continue
603680 if is_deduplication_on_engagement_mismatch (new_finding , candidate ):
@@ -624,6 +701,14 @@ def get_matches_from_legacy_candidates(new_finding, candidates_by_title, candida
624701 if getattr (new_finding , "cwe" , 0 ):
625702 candidates .extend (candidates_by_cwe .get (new_finding .cwe , []))
626703
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+
627712 for candidate in candidates :
628713 if not _is_candidate_older (new_finding , candidate ):
629714 continue
@@ -813,9 +898,9 @@ def match_batch_of_findings(findings):
813898 enabled = System_Settings .objects .get ().enable_deduplication
814899 if not enabled :
815900 return []
816- # Only sort by id for saved findings; unsaved findings have no id
901+ # Only stamp/ sort for saved findings; unsaved findings have no id or batch context
817902 if findings [0 ].pk is not None :
818- findings = sorted (findings , key = attrgetter ( "id" ) )
903+ findings = _prepare_batch_ordering (findings )
819904 test = findings [0 ].test
820905 dedup_alg = test .deduplication_algorithm
821906 if dedup_alg == settings .DEDUPE_ALGO_HASH_CODE :
@@ -934,8 +1019,10 @@ def dedupe_batch_of_findings(findings, *args, **kwargs):
9341019 enabled = System_Settings .objects .get ().enable_deduplication
9351020
9361021 if enabled :
937- # sort findings by id to ensure deduplication is deterministic/reproducible
938- findings = sorted (findings , key = attrgetter ("id" ))
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 )
9391026
9401027 test = findings [0 ].test
9411028 dedup_alg = test .deduplication_algorithm
0 commit comments