From ae8c325525104760f84faa11d97121694c818b88 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:43:52 -0600 Subject: [PATCH 1/2] fix(dedupe): stable content-based tiebreaker for import/reimport ordering Deduplication picked the "original" among findings that collide on the dedupe key by lowest DB id, i.e. whichever the scanner emitted first. When a tool reorders content-duplicate findings between scans, the original/duplicate assignment flipped even though the underlying vulnerabilities were unchanged. Reported via a customer support ticket; observed with Fortify but not tool-specific (any parser without a stable export order can trigger it). Introduce deduplication_ordering_key() (hash_code, unique_id_from_tool, file_path, line, title, then id) and order the batch dedupe and reimport matching by it. Findings created before the current import batch keep id ordering (via a batch-minimum-id marker), so an already-established original never flips; only the previously-arbitrary intra-batch / intra-report winner becomes deterministic and independent of the scanner's export order. Co-Authored-By: Claude Opus 4.8 (1M context) --- dojo/finding/deduplication.py | 111 +++++++++++++++++++++++--- dojo/importers/default_reimporter.py | 53 +++++++----- unittests/test_deduplication_logic.py | 84 ++++++++++++++++++- 3 files changed, 214 insertions(+), 34 deletions(-) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index bf91b610b6d..3f8e0a24640 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -1,6 +1,5 @@ import logging from collections.abc import Iterator -from operator import attrgetter import hyperlink from django.conf import settings @@ -544,12 +543,81 @@ def find_candidates_for_reimport_legacy(test, findings, service=None): return existing_by_key +def deduplication_ordering_key(finding): + """ + Stable, content-derived sort key for choosing the canonical "original" + among findings that collide on the deduplication key. + + Independent of parser emission order and DB id, so the winner is + reproducible across re-scans regardless of the order the scanner exports + its findings in. id is the final tiebreak and is only reached when two + findings are identical across every content field in the key (in which + case the choice is immaterial because the findings are interchangeable). + + All fields referenced here are part of ``Finding.DEDUPLICATION_FIELDS``, so + building this key never triggers extra database queries during dedupe. + """ + return ( + finding.hash_code or "", + finding.unique_id_from_tool or "", + finding.file_path or "", + finding.line if finding.line is not None else -1, + finding.title or "", + finding.id or 0, + ) + + +def _candidate_sort_key(candidate, batch_min_id): + """ + Order candidates so the first "older" one encountered is the canonical original. + + Findings that predate the current import batch (id < batch_min_id, or when no + batch context is available) are ranked first and ordered by id, preserving the + historical "pre-existing finding stays the original" behaviour. Findings created + within the current batch are ranked after pre-existing ones and ordered by the + stable content key so the winner does not depend on parser emission order. + + ``batch_min_id`` is an optional marker; when it is not an integer (absent) or the + candidate has no integer id yet (unsaved/preview), the candidate is treated as + pre-existing and ordered by id, so arithmetic is only ever done on real ids. + """ + candidate_id = candidate.id if isinstance(candidate.id, int) else None + if not isinstance(batch_min_id, int) or candidate_id is None or candidate_id < batch_min_id: + return (0, candidate_id or 0) + return (1, deduplication_ordering_key(candidate)) + + +def _prepare_batch_ordering(findings): + """ + Stamp every finding in a dedup batch with the batch's minimum id and return the + batch sorted by the stable content key. + + The stamped ``_dedupe_batch_min_id`` lets ``_is_candidate_older`` tell findings + created within this batch apart from pre-existing candidates loaded from the DB + (which always have smaller ids), so pre-existing findings keep their id-based + ordering while intra-batch ties are broken deterministically by content. + """ + batch_ids = [f.id for f in findings if f.id is not None] + batch_min_id = min(batch_ids) if batch_ids else None + for f in findings: + f._dedupe_batch_min_id = batch_min_id + return sorted(findings, key=deduplication_ordering_key) + + def _is_candidate_older(new_finding, candidate): # Unsaved findings (e.g. preview mode) have no PK — all DB candidates are older by definition if new_finding.pk is None: return True - # Ensure the newer finding is marked as duplicate of the older finding - is_older = candidate.id < new_finding.id + # Findings created within the current import batch all have ids >= batch_min_id + # (auto-increment guarantees pre-existing findings have smaller ids). For those + # we break ties by the stable content key so the "original" is deterministic + # regardless of the order the scanner emitted the findings. Pre-existing + # candidates keep the id ordering so an already-existing finding stays original. + batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None) + if batch_min_id is not None and candidate.id is not None and candidate.id >= batch_min_id: + is_older = deduplication_ordering_key(candidate) < deduplication_ordering_key(new_finding) + else: + is_older = candidate.id < new_finding.id if not is_older: deduplicationLogger.debug(f"candidate is newer than or equal to new finding: {new_finding.id} and candidate {candidate.id}") return is_older @@ -558,7 +626,11 @@ def _is_candidate_older(new_finding, candidate): def get_matches_from_hash_candidates(new_finding, candidates_by_hash) -> Iterator[Finding]: if new_finding.hash_code is None: return - possible_matches = candidates_by_hash.get(new_finding.hash_code, []) + batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None) + possible_matches = sorted( + candidates_by_hash.get(new_finding.hash_code, []), + key=lambda c: _candidate_sort_key(c, batch_min_id), + ) 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]}") for candidate in possible_matches: @@ -575,7 +647,11 @@ def get_matches_from_unique_id_candidates(new_finding, candidates_by_uid) -> Ite if new_finding.unique_id_from_tool is None: return - possible_matches = candidates_by_uid.get(new_finding.unique_id_from_tool, []) + batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None) + possible_matches = sorted( + candidates_by_uid.get(new_finding.unique_id_from_tool, []), + key=lambda c: _candidate_sort_key(c, batch_min_id), + ) 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]}") for candidate in possible_matches: 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 combined_by_id = {c.id: c for c in uid_list} for c in hash_list: combined_by_id.setdefault(c.id, c) - deduplicationLogger.debug("Finding %s: UID_OR_HASH: combined candidate ids (sorted)=%s", new_finding.id, sorted(combined_by_id.keys())) - for candidate_id in sorted(combined_by_id.keys()): - candidate = combined_by_id[candidate_id] + batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None) + ordered_candidates = sorted(combined_by_id.values(), key=lambda c: _candidate_sort_key(c, batch_min_id)) + deduplicationLogger.debug("Finding %s: UID_OR_HASH: combined candidate ids (ordered)=%s", new_finding.id, [c.id for c in ordered_candidates]) + for candidate in ordered_candidates: if not _is_candidate_older(new_finding, candidate): continue 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 if getattr(new_finding, "cwe", 0): candidates.extend(candidates_by_cwe.get(new_finding.cwe, [])) + # De-duplicate (a candidate can match on both title and CWE) and walk in + # canonical order so the first "older" candidate is the stable original. + batch_min_id = getattr(new_finding, "_dedupe_batch_min_id", None) + candidates = sorted( + {c.id: c for c in candidates}.values(), + key=lambda c: _candidate_sort_key(c, batch_min_id), + ) + for candidate in candidates: if not _is_candidate_older(new_finding, candidate): continue @@ -813,9 +898,9 @@ def match_batch_of_findings(findings): enabled = System_Settings.objects.get().enable_deduplication if not enabled: return [] - # Only sort by id for saved findings; unsaved findings have no id + # Only stamp/sort for saved findings; unsaved findings have no id or batch context if findings[0].pk is not None: - findings = sorted(findings, key=attrgetter("id")) + findings = _prepare_batch_ordering(findings) test = findings[0].test dedup_alg = test.deduplication_algorithm if dedup_alg == settings.DEDUPE_ALGO_HASH_CODE: @@ -934,8 +1019,10 @@ def dedupe_batch_of_findings(findings, *args, **kwargs): enabled = System_Settings.objects.get().enable_deduplication if enabled: - # sort findings by id to ensure deduplication is deterministic/reproducible - findings = sorted(findings, key=attrgetter("id")) + # Stamp each finding with the batch's minimum id and sort by the stable + # content key so deduplication is deterministic and independent of the + # order the scanner emitted its findings (see _is_candidate_older). + findings = _prepare_batch_ordering(findings) test = findings[0].test dedup_alg = test.deduplication_algorithm diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index e3c6cfbd387..818b936b4d0 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -8,6 +8,7 @@ import dojo.finding.helper as finding_helper from dojo.celery_dispatch import dojo_dispatch_task from dojo.finding.deduplication import ( + deduplication_ordering_key, find_candidates_for_deduplication_hash, find_candidates_for_deduplication_uid_or_hash, find_candidates_for_deduplication_unique_id, @@ -306,7 +307,12 @@ def _process_findings_internal( logger.debug("STEP 1: looping over findings from the reimported report and trying to match them to existing findings") deduplicationLogger.debug(f"Algorithm used for matching new findings to existing findings: {self.deduplication_algorithm}") - # Pre-sanitize and filter by minimum severity to avoid loop control pitfalls + # Pre-sanitize and filter by minimum severity to avoid loop control pitfalls. + # Findings are also fully prepared here (test/service/hash_code) so they can be + # sorted by a stable content key below — this makes the "which duplicate becomes + # the surviving finding" decision deterministic regardless of the order the + # scanner emitted its findings (intra-report duplicates are matched to whichever + # is created first; see add_new_finding_to_candidates). cleaned_findings = [] for raw_finding in parsed_findings or []: sanitized = self.sanitize_severity(raw_finding) @@ -318,8 +324,28 @@ def _process_findings_internal( self.minimum_severity, ) continue + # Some parsers provide "mitigated" field but do not set timezone (because they are probably not available in the report) + # Finding.mitigated is DateTimeField and it requires timezone + if sanitized.mitigated and not sanitized.mitigated.tzinfo: + sanitized.mitigated = sanitized.mitigated.replace(tzinfo=self.now.tzinfo) + # Override the test if needed + if not hasattr(sanitized, "test"): + sanitized.test = self.test + # Set the service supplied at import time + if self.service is not None: + sanitized.service = self.service + self.location_handler.clean_unsaved(sanitized) + # Calculate the hash code to be used to identify duplicates + sanitized.hash_code = self.calculate_unsaved_finding_hash_code(sanitized) + deduplicationLogger.debug(f"unsaved finding's hash_code: {sanitized.hash_code}") cleaned_findings.append(sanitized) + # Sort by the stable content key so intra-report duplicate resolution is + # independent of the scanner's export order. Unsaved findings have no id, so + # the id tiebreak is inert here and byte-identical findings keep their relative + # (immaterial) order via Python's stable sort. + cleaned_findings.sort(key=deduplication_ordering_key) + # Each entry carries the finding's own push_to_jira flag: grouped findings are pushed to # JIRA as a group, so their individual push is suppressed while ungrouped findings in the # same batch must still be pushed. The batch is partitioned by flag at dispatch time @@ -346,22 +372,7 @@ def _process_findings_internal( logger.debug(f"Processing reimport batch {batch_start}-{batch_end} of {len(cleaned_findings)} findings") - # Prepare findings in batch: set test, service, calculate hash codes - for unsaved_finding in unsaved_findings_batch: - # Some parsers provide "mitigated" field but do not set timezone (because they are probably not available in the report) - # Finding.mitigated is DateTimeField and it requires timezone - if unsaved_finding.mitigated and not unsaved_finding.mitigated.tzinfo: - unsaved_finding.mitigated = unsaved_finding.mitigated.replace(tzinfo=self.now.tzinfo) - # Override the test if needed - if not hasattr(unsaved_finding, "test"): - unsaved_finding.test = self.test - # Set the service supplied at import time - if self.service is not None: - unsaved_finding.service = self.service - self.location_handler.clean_unsaved(unsaved_finding) - # Calculate the hash code to be used to identify duplicates - unsaved_finding.hash_code = self.calculate_unsaved_finding_hash_code(unsaved_finding) - deduplicationLogger.debug(f"unsaved finding's hash_code: {unsaved_finding.hash_code}") + # Findings are already prepared (test/service/hash_code) and globally sorted above. # Fetch all candidates for this batch at once (batch candidate finding) candidates_by_hash, candidates_by_uid, candidates_by_key = self.get_reimport_match_candidates_for_batch( @@ -652,13 +663,13 @@ def match_finding_to_candidate_reimport( if candidates_by_hash is None or unsaved_finding.hash_code is None: return [] matches = candidates_by_hash.get(unsaved_finding.hash_code, []) - return sorted(matches, key=lambda f: f.id) + return sorted(matches, key=deduplication_ordering_key) if self.deduplication_algorithm == "unique_id_from_tool": if candidates_by_uid is None or unsaved_finding.unique_id_from_tool is None: return [] matches = candidates_by_uid.get(unsaved_finding.unique_id_from_tool, []) - return sorted(matches, key=lambda f: f.id) + return sorted(matches, key=deduplication_ordering_key) if self.deduplication_algorithm == "unique_id_from_tool_or_hash_code": if candidates_by_hash is None and candidates_by_uid is None: @@ -681,14 +692,14 @@ def match_finding_to_candidate_reimport( matches_by_id[match.id] = match matches = list(matches_by_id.values()) - return sorted(matches, key=lambda f: f.id) + return sorted(matches, key=deduplication_ordering_key) if self.deduplication_algorithm == "legacy": if candidates_by_key is None or not unsaved_finding.title: return [] key = (unsaved_finding.title.lower(), unsaved_finding.severity) matches = candidates_by_key.get(key, []) - return sorted(matches, key=lambda f: f.id) + return sorted(matches, key=deduplication_ordering_key) logger.error(f'Internal error: unexpected deduplication_algorithm: "{self.deduplication_algorithm}"') return [] diff --git a/unittests/test_deduplication_logic.py b/unittests/test_deduplication_logic.py index fd6b5d2847c..a5fffa8f9ca 100644 --- a/unittests/test_deduplication_logic.py +++ b/unittests/test_deduplication_logic.py @@ -9,7 +9,13 @@ from django.core import serializers from django.utils import timezone -from dojo.finding.deduplication import build_candidate_scope_queryset, set_duplicate +from dojo.finding.deduplication import ( + build_candidate_scope_queryset, + dedupe_batch_of_findings, + deduplication_ordering_key, + get_finding_models_for_deduplication, + set_duplicate, +) from dojo.importers.default_importer import DefaultImporter from dojo.models import ( Development_Environment, @@ -535,6 +541,82 @@ def test_identical_ordering_hash_code(self): # reset for further tests settings.DEDUPE_ALGO_ENDPOINT_FIELDS = dedupe_algo_endpoint_fields + def _make_finding_in_test(self, template_id, title, hash_code): + """ + Create a saved, active finding cloned from template_id with a forced hash_code. + + The hash_code is written directly to the DB (bypassing save()'s recompute) so we + can make two findings with different content collide on the deduplication key. + """ + new, _ = self.copy_and_reset_finding(find_id=template_id) + new.title = title + new.save(dedupe_option=False) + Finding.objects.filter(id=new.id).update(hash_code=hash_code) + new.refresh_from_db() + return new + + def test_dedupe_batch_winner_is_content_stable_hash_code(self): + # Regression for the Fortify import-order dedupe flip: among findings that + # collide on the deduplication key within one import, the surviving + # "original" must be chosen by a stable content key, NOT by whichever was + # created first (lowest DB id / parser emission order). + dedupe_algo_endpoint_fields = settings.DEDUPE_ALGO_ENDPOINT_FIELDS + settings.DEDUPE_ALGO_ENDPOINT_FIELDS = [] + try: + shared_hash = "content_stable_hash_flip_test" + # Created in REVERSE content order: "zzz" first (lower id), "aaa" second + # (higher id). The old behaviour would keep the lower-id "zzz" as original. + finding_z = self._make_finding_in_test(2, "zzz order flip finding", shared_hash) + finding_a = self._make_finding_in_test(2, "aaa order flip finding", shared_hash) + self.assertLess(finding_z.id, finding_a.id) + self.assertEqual(finding_z.test_id, finding_a.test_id) + + batch = get_finding_models_for_deduplication([finding_z.id, finding_a.id]) + dedupe_batch_of_findings(batch) + + finding_a.refresh_from_db() + finding_z.refresh_from_db() + # "aaa" is the canonical original even though it has the HIGHER id. + self.assert_finding(finding_a, duplicate=False) + self.assert_finding(finding_z, duplicate=True, duplicate_finding_id=finding_a.id) + finally: + settings.DEDUPE_ALGO_ENDPOINT_FIELDS = dedupe_algo_endpoint_fields + + def test_dedupe_batch_pre_existing_finding_stays_original(self): + # Backward-compat guard: a finding that already existed before this import + # batch must remain the original regardless of the new finding's content key. + dedupe_algo_endpoint_fields = settings.DEDUPE_ALGO_ENDPOINT_FIELDS + settings.DEDUPE_ALGO_ENDPOINT_FIELDS = [] + try: + shared_hash = "content_stable_hash_preexisting_test" + # Pre-existing finding has a content key that sorts AFTER the new one. + pre_existing = self._make_finding_in_test(2, "zzz pre-existing", shared_hash) + # Dedupe the pre-existing finding alone first (simulates an earlier import). + dedupe_batch_of_findings(get_finding_models_for_deduplication([pre_existing.id])) + + new_finding = self._make_finding_in_test(2, "aaa newer import", shared_hash) + self.assertLess(pre_existing.id, new_finding.id) + + # New batch contains only the new finding; pre-existing is a DB candidate. + dedupe_batch_of_findings(get_finding_models_for_deduplication([new_finding.id])) + + pre_existing.refresh_from_db() + new_finding.refresh_from_db() + # Pre-existing stays original even though "aaa" would sort before "zzz". + self.assert_finding(pre_existing, duplicate=False) + self.assert_finding(new_finding, duplicate=True, duplicate_finding_id=pre_existing.id) + finally: + settings.DEDUPE_ALGO_ENDPOINT_FIELDS = dedupe_algo_endpoint_fields + + def test_deduplication_ordering_key_is_order_independent(self): + # The ordering key must be a pure function of finding content (+ id tiebreak), + # so sorting a set of findings yields the same result regardless of input order. + f_a = Finding.objects.get(id=2) + f_b = Finding.objects.get(id=3) + forward = sorted([f_a, f_b], key=deduplication_ordering_key) + backward = sorted([f_b, f_a], key=deduplication_ordering_key) + self.assertEqual([f.id for f in forward], [f.id for f in backward]) + def test_identical_except_title_hash_code(self): # 4 is already a duplicate of 2, let's see what happens if we create an identical finding with different title (and reset status) # expect: NOT marked as duplicate as title is part of hash_code calculation From 178b0981acb08f2104b28f6a622903722c19ef89 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:46:10 -0600 Subject: [PATCH 2/2] test(dedupe): add Fortify unique_id repro fixtures + order-independence test Two .fpr fixtures contain findings that share a unique_id_from_tool but differ in content, in opposite document order. Under unique_id_from_tool dedup the surviving original must be the same finding regardless of the scanner's export order (asserted by test_fortify_same_uid_different_content_original_is_order_independent). Fails without the ordering fix (surviving finding flips with file order), passes with it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fortify_dedupe_uid_repro_scan1.fpr | Bin 0 -> 23863 bytes .../fortify_dedupe_uid_repro_scan2.fpr | Bin 0 -> 23862 bytes unittests/test_deduplication_logic.py | 69 ++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 unittests/scans/fortify/fortify_dedupe_uid_repro_scan1.fpr create mode 100644 unittests/scans/fortify/fortify_dedupe_uid_repro_scan2.fpr diff --git a/unittests/scans/fortify/fortify_dedupe_uid_repro_scan1.fpr b/unittests/scans/fortify/fortify_dedupe_uid_repro_scan1.fpr new file mode 100644 index 0000000000000000000000000000000000000000..4b05f6e584466b09e8b8cfde7488e254350af873 GIT binary patch literal 23863 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 Oq`%$iudFxtr~MbH*IMiV literal 0 HcmV?d00001 diff --git a/unittests/scans/fortify/fortify_dedupe_uid_repro_scan2.fpr b/unittests/scans/fortify/fortify_dedupe_uid_repro_scan2.fpr new file mode 100644 index 0000000000000000000000000000000000000000..033a5324bfc05f30dcd1e9610e18f2c367a1fb58 GIT binary patch literal 23862 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 JtTp(j{TKOjG~fUL literal 0 HcmV?d00001 diff --git a/unittests/test_deduplication_logic.py b/unittests/test_deduplication_logic.py index a5fffa8f9ca..33ca9d97ed7 100644 --- a/unittests/test_deduplication_logic.py +++ b/unittests/test_deduplication_logic.py @@ -7,6 +7,7 @@ from crum import impersonate from django.conf import settings from django.core import serializers +from django.test import override_settings from django.utils import timezone from dojo.finding.deduplication import ( @@ -24,10 +25,12 @@ Engagement, Finding, Product, + Product_Type, System_Settings, Test, Test_Import, Test_Import_Finding_Action, + Test_Type, User, copy_model_util, ) @@ -617,6 +620,72 @@ def test_deduplication_ordering_key_is_order_independent(self): backward = sorted([f_b, f_a], key=deduplication_ordering_key) self.assertEqual([f.id for f in forward], [f.id for f in backward]) + def _dedupe_fortify_repro_scan(self, fpr_filename, engagement_name): + """ + Import one corrected Fortify repro .fpr into its own (isolated) engagement + under a shared product, using unique_id_from_tool deduplication, and return + the single surviving non-duplicate finding. + + The two fixtures contain the same pair of findings - which share a + unique_id_from_tool but have different content (different rule/title/line) - + in opposite document order. + """ + from dojo.tools.fortify.parser import FortifyParser # noqa: PLC0415 + + product_type, _ = Product_Type.objects.get_or_create(name="Fortify UID Repro PT") + product, _ = Product.objects.get_or_create( + name="Fortify UID Repro Product", + defaults={"description": "Fortify UID dedupe repro", "prod_type": product_type}, + ) + test_type, _ = Test_Type.objects.get_or_create(name="Fortify Scan") + engagement = Engagement.objects.create( + name=engagement_name, + product=product, + target_start=timezone.now().date(), + target_end=timezone.now().date(), + deduplication_on_engagement=True, # isolate each scan's dedupe scope + ) + test = Test.objects.create( + engagement=engagement, + test_type=test_type, + scan_type="Fortify Scan", + target_start=timezone.now(), + target_end=timezone.now(), + ) + with (get_unit_tests_scans_path("fortify") / fpr_filename).open(encoding="utf-8") as testfile: + parsed_findings = FortifyParser().get_findings(testfile, test) + + admin = User.objects.get(username="admin") + saved = [] + for parsed in parsed_findings: + parsed.test = test + parsed.reporter = admin + parsed.save(dedupe_option=False) # defer dedupe to the batch call below + saved.append(parsed) + + dedupe_batch_of_findings(get_finding_models_for_deduplication([f.id for f in saved])) + + refreshed = [Finding.objects.get(id=f.id) for f in saved] + non_duplicates = [f for f in refreshed if not f.duplicate] + # unique_id_from_tool dedupe collapses the shared-uid pair to one original + self.assertEqual(len(non_duplicates), 1, f"expected exactly one original, got {[(f.id, f.line) for f in refreshed]}") + return non_duplicates[0] + + @override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL}) + def test_fortify_same_uid_different_content_original_is_order_independent(self): + # Corrected Fortify import-order repro. Two findings share unique_id_from_tool + # but differ in content, provided in opposite order across two .fpr files. + # Under unique_id_from_tool dedupe the surviving "original" must be the same + # (content-canonical) finding regardless of the scanner's export order. + # Pre-fix this flips (lowest-id / first-in-file wins); with the fix it is stable. + original_scan1 = self._dedupe_fortify_repro_scan("fortify_dedupe_uid_repro_scan1.fpr", "fortify-uid-repro-scan1") + original_scan2 = self._dedupe_fortify_repro_scan("fortify_dedupe_uid_repro_scan2.fpr", "fortify-uid-repro-scan2") + + self.assertEqual(original_scan1.unique_id_from_tool, original_scan2.unique_id_from_tool) + # The same content survives regardless of file order (line/title stable). + self.assertEqual(original_scan1.line, original_scan2.line) + self.assertEqual(original_scan1.title, original_scan2.title) + def test_identical_except_title_hash_code(self): # 4 is already a duplicate of 2, let's see what happens if we create an identical finding with different title (and reset status) # expect: NOT marked as duplicate as title is part of hash_code calculation