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/5] 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/5] 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 zcmX_HQ*b6s(~WK0wr%akwrxMLZQHh!jcwbuZD;>|zl*=BZ_b?VuIahyGgH$F(x6~y zKtMoHK%amMox;uchFVP^Aael_AeeuvhAt+S00uKx6PxR9OXp3l6{;@}rRN{W)Vxab z1!F}NOxtrZWi)bfIWOx;$qsXtl)~S?&14c_FgGW#Sm4mThND{w_kv~w)lp`Z=me$j)yAOba) zuB_XDX2ei39mA_us*@9iSXDU^apgh>7ZBV9_^{eV2LYJz=7t^mw1(SRM~-awvQioh zJJIQM+C|n_kW%mIWoT2QtB$8DGV?8K$KW`cO7zqnG@?k8D{Hcm2hOaTwfF``d?4Ru zD8WjN2Itv1uyu@+8rX50+xw~Mo7XE>17cL(_pGG&cntV1Avo_xOsQIY;n+=+gr}0F zejzVfcGNmH=nQZL;|@mTBbN+o$c8w_uI7=9lJ_vz<7;?oSe{HLa}@GQAmO!(#=VZ_ z^4e>A)@&ziuq&o%yC~XfRj$=OTN%}mwpvYl>RMe2Njh4py>bdA9LMawL|_9KWyA3A z)h5UXuBN=9(z-(j`FbI!+Vk(C$ib~*B``Y`}FhR4rHN{t0g=tbd!LeXcxi)YjKZJ;>-e zc#it5G~Flcn1X+L3CrSW!1dc6yCFFMcL!$GxW7@8+^4v<*vTEB-Rq4`6_eS+O^g$5!=|`%vH#5slAa_^ic7=E)e9EsM-6Xw$ z$?(m`nTahM2nhv|e;5Y}nyPKi$y9QYsN8_Y?4+uZ$q-lG(Ci`i)XKM3;qJ;gS5N&! zjqvvzz$1`4;lY~$9yQ~xgQX14rT~xa$Vz?w!lglN5ukWIhTpJm**X+ktJ0aMvux#b zYnU8VoP8bm!)A8uT82LsC&3&47w4LY6q6us%SPZ&+d=X(&_YFqq5CvlZ_?wtZu7j2 zMdnT=$0dD@;x)%{Dh2Rv(z2T9<)4z6H{C~7=YUOqw#N!O9NKKKS^inWt?;Dcn1MZ_ zmMhoNrl02zD+zU3-^TgTNz_xae#jDqtd+R-1D|7CQd)alP4|=yxGsvIn{YXCsaSs5 z%L^%Kr6H-lL5_iVpo|O|JP@>QEzy*t+_m#DSw&S|-G+5#FC*1U zs+tZR+oh}z;KN1Gc=d9YuR7G9%BIr4ZfHNA9;T23Sl^lhK(;;sz(j9D0VSkBcubr_ zVdRux$z*|v4qm2N>OlHn!|+0HH3n@U{$}Z`Lzp6nWEMhSNSQ7+i!X&jOw>N@chp@Z zAP#C4b_ZS(|9 zq$!Sb#!$Zz4ES7;U|L_2jR2nBdV`dx)(j zCCh4jph1Ljn_GnN8!Uz=pZ*8L$JR7p@rqppRs{o?dElZk;Kd+@Z0#annXuqn^q&PQ^I;z=!(J^$IQItzaP|RV%pIM zZE_sJ3o;R^t`2DN{}QeaNd}9d2Dyb7A(i&$U=S4g0?UcFL7_WC)(&Qg$Ckc^>MfkDG+ zfKagp{h<{5EHaav1>k{TD2NCh?s1zQ^?mOTNYNfOi^xqFi5qRH&VhQc6r%4!;xI=8 zn4Y?s^B~TaDGTgDU(6jv6fH}Xr++)}#BtI5Jr8#WqZ)%_G);8Hg8J9AWRUStmroRj z$OMnW=d`dm5vP!Gys*C-sq;+>AcLnd@aQNa<%&UOL(~$+4EVu-6#J}4k|nZ6RpE%{ zUqFrC#$(~iBgC7#uG7IT&`!9WcY=#BMIaUiz8ExE=2D2vQgsa18#YaPJM*RhM_X|0-@%caygc%q(wLCHY z@Hq0CKN^edWln}HJd80Yi9JwCX*Xh(#X2j5toYNDGMvZ?fA2W5Tilqul>M2EtxxXe zKP(YPv!wgJ$wkF=V>2CwYu&En+n_-r4WC?S+~MKqaTx$4#sM+`RT&CwDy)(NY#ZJd zYVTElAuN_joED-}+6&^FMk63YOu{jt+QDb2y9PW?=b-@;tvk((ZC~Jh`iF2;b1vB7 z8`6efyCqKO{m4rUWDkt$Z#`m%S{p`lhxL+EVrcu%F_0HFlNvf=E!`i*P04xSF0fQP zx*rt0Xd#{9ZilF7>fl`@InpAO2TRi{Ux_xQ__grwv65+FZUY43v&M*B+;06=qSAorIiZB z>36hyR-j{mxdmhqdzSkzCZ`0M_#T!OU60VifEySstV%NHD_T%qbHQA}*;TrH(T+c; za^O_ijSe5)q+B?$xRrH*H8XV6?Y_nm5Pv3`E5R7&g3Up$jmwTPqee_te7%KZ{E|8@ z=t^T6Uh0qQ2Wo9GN+55^I&x$r?N~a$v0LLYdxpiry00XW9PLkg#rYDWLimM8= zM#T^skPIB5>;z81C9qKD^iIOm5Iz3EBeL89EGfb5=Fp{d+3uf=X5euJXsv~%de?*{VHJrJ*WjHd>axRV+6*Hq^BI~Azzd%r*w)jp!n+_# zCQ}nA6c9_q>yAExd<`K(jE&tQa0;L*+5}<=q16?s#b^$`;7~)eHf;MWs#IX0WP&m9 zdGSyQGSpo5g`8qiGE9gLTPYLCJV!QeNU&TMo`P;z4A7=|I2h*P!7mkGlP4gU(YPpC^ z(j26P3DsT*klGJK&aW`-1XMcmId(F;nWmynNhLlcCUfyXs%Q$3fPRxIKlv_j7_CFf zXqh~Xu5$Y0C{VLl*B8Bx4U{@w+&LSOfIcf9rKU567qPg4C=PH)s(}|43$P+&2AKyf za2zA#1tT{Xo$NUH#BafIPw2qpSCRbWC5AmK?8x#&1&`is}xncPjBQi82aK*q=?FrPVq9XPSq@ z31$lCbsYqt3gMi$2x2o&hQTvJjwN?<^JzUyEYV|f8OM%y?S>mlxc zD0aARM3k~sobyHE98^PxZTrRV5CdI3akpQ;PqMiV&Jr4WZ4(h~S`-7jR?!4=zV>^w z^%N4@Pnb~j>zqmt4YP?Y9NuJdnjQIF?klA1KpMWCZh2BkQ;CL+hP(_)8g`cb5KEa+ z3L|!S;6k!{y^fW(G_lhJqxYl}g;EZz<9)c%l)KZDmR5T9t@;GJKf2Ru-XViiCH)>{ z)HzXs!Y_e>b*(^j=ONf=f1&MB$DR-Ur%p)OHw2Y8-LS7N*seN?DR*6xJrS^=uddPL zNO?Nn*8s|7x|1t>E)CcINT9F2VDD&&;p`h#?BB_4M=}Z?L$fn4?B;~jYJ{v{p5r%w z$r8x0tKg1!x?i#eTPUnV2k|#P&@cU;f#(IqgW0lbO!65?36rB%(Id5Qligan9JR!$ z1lyq!bRo=*#1evql2;=6>qc1tlMupPX`kHe88Vf+iHQG1@i?a^#532tk4B4t;Q+xa zI;LzrGfkk&WE`_ntUM9yZ;-B#IZ25XOMY^nU-=x&(zCvBLRUOnGs-5<;kj-{db&LCm^b8AEo zA1QjweDM@c0v9>*fNA6Oc< zB6k&N_9Pkuc$|0y`Li~lEwYtdiezvIaT3!RlM#2a1th-BNui$nj^8e`$st@jTR@iU z%tQ&j7Mk7jzH~_1GV<7|R-CF@9t&lS<*dH4TF`Ft2f>YcdZ1I4i$0WHEy0*KMXCvq zzf^V5!G;kK$yM(I8q>^OgF-GsZq)mVBqS`!1 zfc)40g$q-&!1-A56>$ywEDnw&vsBjB+e{TmS|n`YI%Tx0|A{vSJGdIBIb&gUU9)WG zH_J#L=e|Ixjc`Zgf&xFyN%`C+nlmWV-@3(Yv>X5SUgJAH!_lo1<7hYxL#s&An#VfQ zzzVCgdw-N211xZgXF8@;cu&dnQ^WrE&au~27BBnI^~LBMqJCZy`tW$<_1`{yLk8Ha zw7D$KgIW%yq5)hS2~$Hq&xzT5JL)2K3paz8M9f%W)Hbj%zm-N@5f{G>!WK9?5?2<{ zH8F527m!t(w=tSi7f`j8)K|50%tSAws-9!OU8Aq8WrRXCqM{pdDJ4fT;!TePvb*iZ zV*<8>j0{!c-ofZ@j%KQ_t7jRCT8UN%#XfKckAPd(^kJeyqT#|4Yfge$4|(>#=bJ-P zL|Hb~Q5ZlyPGTYis{+AuEtLiFFl`vNMWZ};H$i?isANFeE6$*)H_v#ya<-h-n@$9h z`O5`DV<>5IonqzhnXOX`XEBDW;sTj!# z7g`Vy_3-hGz&}7qx33_#@xCTSHY5u#o2dJCSuF~N4_l!KjfhBhnsA6GD`lx@l8z*e zD>K+y=YElU*YZhYz(*4oV2P}{%k)H?{G!j&C^1w6w}pL9a?EzoDx~(dSi};bf@x)w z>Gz19R>qUnAoM?ed~%`4=c@lj8hw1{6d~$$1&^d-wy6-u4MBk{uP|TMzHHE*Lk=IK z>Bh$S1T&>MFh!8$3kg`GKwySiQ!0%hSNR^=LW-jnUz(>KF(h3wC0RZB9Z`dcfPsY{ zi%&TZo!7we7vvG0WD@KpkAA4pZqWhCG?+8p1_j(Pm%S<6Tjben@O}%K{nlbC5BSQ| zNpn4Y%>nN-lj{W?XC@p!R}^%}7&X+Nna8Za6!Sd1WE1O#ojw^P0!wUZxDj1*dl^s~ zj0~Le4m=48RWZ65>?+)e84aA7O81e0jKhY9W-elUK)r`CMjx(wUX9Pug&l0l65xK8 zg3uZ#tP00H^~6%=G3rqwUIyq)2bnGGS4kPVDx?9dq_YGk#MrU<=aRkSXIj7K+Ct}z z?;ux3rea(RY&G$>@cp zB{9fsoiV_ruw*;VJG_8vi*Ky({ri<^)O`cc(Im)ig%hRJ6{}nM& zB#G*3C!pBV11SigAeBE!onjM0Pd<0A#LV*6lAG2G1XQ~mOMO%#JNey<`8Q6w60a!x z)L|B)3#3OjkhhZQa_t2)HU655EiJ-}R0=x8tPGkPrbNTKAIUTtZWw}Toz_V4HnATd zl?r;|MIaqhQ2kCmxopgQ$NU@B;w4RnDI>F_t4`&8#3iM+5~v;YggUvgehM_3uG&SK zhO&-O-1>`O8&Y(NEiSJ_fQ6oP+nD)bOci?I?4IVu^a$A8p}}b2IOT0Puvp=+Yey zY8-R6P4^8PSTHdy!yXerTiW4;!uDSwg&@e9ZJWe1HH7LS~K*x<|ku=vFA;SUkVIF(CP zk|*1A#40OgqD~sG+4qp(hA_{h_(-e-wvj?@gp}A2gxByy1P%F2c0zuD6}PXhIke9K z@{p}nfHofTXZ#%cMVh)<>m3BGoB1mT2sZnHEQ0k6O#PH^LC{0N-Db_X>Any#lu-J< z==UrH(iwtg>NU18ux04`UnO>@?0JQF?}KS+!)W6c?Z753rrv?E!mTXZ{_sl*(Z_@q z4FP-+Va|OG0RE@~+=7WtD)oh@r2tUMm5LgVasqEX{uFL_keB6ht!>o0_E|!~ zl=Bn_oyruM1pY4siULym(~-ehzl6sq z<@5uTF-_6fU4Rvor1SIQFxFgs(DC#T)*GDTp=)f0(5O>$!U;g>8q;M2nln{Tp zKTXCmP)!jD0z42#6=e%#f)IJUSXU zpvj+fnz5zw1M-V%-B9g)#H; zv8y7tqwamg{FpVj`JfcBa;&e`2SU0hgw+yv84uk}U=w-aPpFWz+t|$w#(`c{tuub% zXBa>v(L;sw0UkH*E}f{`Yh%3iS7giG&za63h1DPEZ3i0-?^;`^?-nZR>5jWw9`EzM zAyV>S0I0MXR7tS9xlVW6iw$R?$-s7xiY5RqPtGaPy*sFW?X>FMz#a~1jpz<*PGMLT zh7uFF!6G!mw32@Y_w`Ij5itu?e67gQN(DZUb_94$W}(_87dS^rf}@GmXVxn!2v^Q1Lykd z6GfiMv@4~h0g9&yHVLXINKZpymEbtThps zdQXyafCd=Q)TLtyxuD+W1P9@VwJc2w-0dIMQRmqV1? zhD&dW-Vh({UAa!5?r(>!!xcD-L z75I%8F~zVx0{!kG93>WJ(Z^pnC7a-q*Yro@3j*v^o-!h{|J#o|J6W4;5)P;AG=Hea zgadYof8p=<+KCT>1prF#>;+6(`7h_Om_%l}ZZ2)#fvVjVMxXFB`#2Bi1b7TIG>`@A z5{i}GcykArzoO-~FCS7*0c%C{2gI>1@`xa7Z5kIopChK?DbOzoCBfPI%4)~1nYKUi z$W9|Xg^=hbp|>oHbT92QZ_Sn*YFBx2pc2&Zi!mv#U9lNz+4#DNsw_d_nq_pX7ECwl z2tB+M;FZ~M+0!Cnf-Hi$L{mNVqzG3A-%yF|7xQAyu-fywhL(jKDqq<6#Pr+ z+F4q=?5%%Zftl#8EaxNy6W21L#}hO!&*Q?B<)7M)o$Y+37{LYfOcsqoh@YNl(<QG&mdrl=&ga{L96DorXYC& zh#%sdNW0r;gFaqhdu2s+kZiRfM+kG+xtF>G!xF?HyV|%LWCKl%r88xo1&v|*VPz8jsst(jOqx!p2@5j2dTGuV=yZQtDX3 z?Q%Pv%Qrl5j}UP&U$D z%9YHOmXOO@O5E24`9rKnTJi=So)hB6mps50E5jWNUwNb-eEx^ zbB9F4IVCoYqRrO=c{*BT8YpILu+}53D>u0Ijy!Z>cp?)KorC<#&fqv-p#?hvhEY=m z^b~i+9gRik162x`GVe*hTs1DWN3@l64l8~VH-9q_H8J0oG99Hn3TL*jEatGn2en1g z2ZlByYeE3I{S_#N;{qKH#30?0luMF=BQUJ9VNv&dM$+U0l#lJ4gIH~|jCpZQ9~8+E z6L*xJB#ec5_JrGcD>dS*MfIYl+i->Q`FGNrMIzX+<`DVslTI+8ZlR;#3M$Ei$9VjR zC=4)j7NbmB$Nolss?e~6S-2daS%NHL4Iak`0-<-X$%vycZIj8D!;0=YOnbZVb{d+2}rGAcGQnPkaqV%Cr)N!TR{N7F5s{cV2w@FMcQAh?$~lQN-4C<62o`d zbpSa1f;Fb<>M0$)fa*18&tUE53ds)tSbzrMq)&ZR`|kMyC!L6Mmu9|)?1QT}u7XAC zuN~Qt$JYUex~HF7Y~ir{tr*=4wr5Ub0WW zgUPy5qx}W|BpQspJ z=X+?R2~Nm&U;tEU7b-=Hfu?oOyn(QqPcU4wQ5xPi4ej)!JFX1>8jOyCsZH!TWe$FB zTvU{u&57S1-BdAdBXXLz$B^f;jY6M_fL@yOGB_XnZp+hEf$a|diD&K&Ou@HG%U+@7 zU2u6I>ip`D+t9V7qAxi>BNojpS^3!|)Bg4=L^LG|;rZ<8>#&5XJ2Hi10Ij^&X~QZc zg2g-vJMAjZi1vhKw@h)E0b{U~ZPRN&dm`C9KRYp{#M0*DGEXWU3~ByEh>#8^=y**KWp+s;3j_ z2|dhK?1;DU ztw)_(Bg6q&3(y~flQ091j4l0B&-?fD=r40o$EB+N(PWcfl$Jw-?#b&|UWv7M_26PJ z^^e)dJU~dlZ(f+V!1E5O5v&ZZmU* zv6OCtQMvO9z)5v`;eXAEJ?K_6ruk?Ad z=LT-Tf^7CUXK)Oo`%g|5DT%6azz=` zDFBO@x}1E8kc~;BmA)HmSd5yb2ueoXd{~B@V_X#j4)rXhi>;#^yvt6YET@{aUdOGo zzYNj%U_|HAaq&lZOe<8#JFclg@#V> zC%b)V#Y`bfuyRidoqR*%`%vJ=bmSVaEIsH}OVl4mhX~FvG?B+`mx5_x^9CvsjVmb7fm zzJa_MOrliq0y5{^*E8|v<@{yt|8L(@)OsNc@{wlZQ*{Q?6>yo~w!oneNdBjr&-mNpNP=R3LETRTVj z5};S;Y14UB16EIgcxAlWe5QFn?+H#`GV$1y6sc8fr2Xfps{EYz%WnBFN?WLWx&DN1S*Wrm9=Mi_k6jr(w z#4BhOt2Wl~?OJJvyt5(QJDv>EQLTTPpog!=`A4z9@2jq)eX+mWS8n|TI?ueX_4V*_ zdL~!urZ|zRn3!4Aj4hg?rU*eqonHs+qY9#m)t^gX>bqMtQ}xS*PMG5tTsfpUco6HqbjC+%~q%44SKA zn^Tz;ZVU+FU(t>?HYK27-ez|#C@Hqxl&xMJRF)`n{N)Hn%2n*cdabK!{xK7krTj%s zq$(53cIwK-9cyQ*!go*pqgLByy2-fBW_9xy)Sjea9(+YCkSX}O*6hz#y49{)^vJgG zikB9Z{?gnGKs$ODx3zv zYiYVOwd}*U3FZnns)9W8TX~Gi=$p|rt$O$q_;GQh9As_Wgd|s0 zn-$gBx_2%sX3MvtX|k?8J4@BY>%Ym5Qt%sU#tm-&WXkH=Qa|6Y(r}8EEL6H|E;x%f51nFKauf$h{EK<4^_^-;ACpBG2K;U(8;F@I+!IDzdorWrol6uOsYtwu5 z#I(C9W}B%x2!bYLkjiqFZUcBS6~~iS-LjIkXY0JL#IZ#ARY1dHjVq2(S{4Te%U08#)+10 zBJc9YzY0-#J=GV{^M}={5LRNfY$NHhnKmn0&{WR>9YX2<4rmHu_o=qST%}gDgkn|O zshc_iUfeFca=|v-;4c-C>yca&jb&ru_&-mZ08X|VEn5yTaS8NpZR>z$P(QZr?Mmuq zEnQpAT*dYbmL)6K<;>!6)e?9Vo!aq2HFed7)fKCnj+N`?V{>$$LH{A+@arYk^y*on z#`{O22WBzXTo4jMah+4if8cF44~&E`X!4bs=s}UoJMTY+?|%%{u{=mZT))pf8qzzJ z6Z8LK@J|18dz<{vEu{79e=+X=B@k`dP!>8Wx=RuS7ZZS4y-5-}GX9O_4ioN;R-q~n zoDfHXd-k7qFpiqdkQajAH)r9$JY;!LG=xE{*OvJYiejk$0a3pDi2Q$BM9YJqArzOn z|Bp$JmIqCVBYC@HQ+TKHPW~TAeEbjK_52Uez5I_!`40?ezyAjkt8@QL;5xS`bX*k9 zCIGJ@5a}JA2b*#Q&+89AiG10Fub1PA&vK-CLS)Q|N(XX+bz_12rwtJD+&njUIWgs! zUe|d8rI3~#Gfwc%Jz{U#Om_%9suYt;k@+1Qa^LpmJSa00AC-q1Jj58wV}Ci_QQLA% zK*s~{Rz1#KvwK!)*h%SEP}{T;t?8{bp&7D$^80*yIXG+pBLTA~3@Lk?q$VQc*5d27 z#f+mYfm}~JyPi+5KJ5SOI22#GgsDO0jE6(bru1Lscb*Z4z4H+rW;hv{&z%@J%VEr!N+!wbm2PC^C2^$-E zKjQL#--%R?r$qP=vzHZJzWPaMT^bv*J$u%eRc+Kn;Y$`K)>DJ*rp_6cysF^Cg=co* z$S$Wg@@L*j!1)r&IDSzPZsv)0JH2hb$`?P@T;TiJeO39rb*^5j?zI1kta%d9d>*|0 zSoUgW@+A|Jtl6-Q;+I_zR|WlI+sp@0*AN(#(n{-=bVCTnm??gdwK}4W&X+Yc32r}b zUJCZmwr+5KZeVHse4e)&o_>7k6aM1s>tr3JhEZ?3N5@7HA_wgPCFQ6Vp zyRkH4S`N~pYBA}^%y?VL8hvn{A6xNyb|3O^P?xE#Qe$*^e0sijV0LiVV0_%^P46P9 zfE-^pM_qpBuLZ`4TyyO*Fh6`lMG&2OC%m{#eBz8cP^CEUeFz&ZMS-DrC|Sm5mo)aKW%zK{}CvA(Wds~oIrDXoaCwXiB{kyui%YPMxw z8t$B+>(bPC-74UZ^!7EDRFrwH6%^}zhTIJ_JH~y#zPdS)`I)P>eCQQej@y1L+8N)A zYx$79UiJkEaq-h(r6VeE3O=rdm`d(#}R+uw9fYQ_F{jl{blg!ij)uxZQb2r_YK|{a#)(Y zSU;XGi)6#eY0Hbc6kV>=Qbkk6AkQ3MTGBRZ(uP!NChuOo0eE?u=LnFk-Uj6VcsPEo z)lfVByv?_N`6}^#UqwF=`qEyBn@fG3#KkgqaSOH{ZQO?4IMfFkmdQ_5f`FQfcz5wA zL7nG3U#SJYEr9=U^V{LVSGA^M9{5;sXj#Q7x7hr38J>=f+`)cT^LsvSy4KpUy*nP_ z@IkZ}Va#)7?0q`G)xEe#TSZq(ALQBGPyD&J@~CH@R1})1y&iAx4)Ya@WSfC>D_WWu zH+8}3%*U?(W0Sw7P>cx)p`QpF9sI{EG6?XcZ1;{UvqLtOca<*;)|eQLHz#jA*dm7f+i3A3U09^zhBpxExvuk#p-L3rt6BffG5X(Rz7>9f9;|SCk z8_qmS%xkE*Y@JBRKl%bM5@TqJm3f{-7tXvvfv=i)pJq>b3L}2b+D9ZhLe0f%mBxu$ zoH~G)Om*pCi~0BbBWq9O9hk(;yHQ`oKFD0m)I(U!+gXlY;8naeJHvA!%{EQ~+}ad* z6E+Tmw*L)q2w%?71LF`6KxWkAy$4r$+`xtj4p z%dNY?(CPCNFRY2@Fsr zx(0_moj7OHJX3Q`9A4i-tPIKk--#C#s_WHgm+uK5!+Z3e! z0#&iQ2qP>$ZvM!7#%nK$K%5+}dETc!NaZ25(~0hjbDrPNgNM+_JK@?;wpZ)Wtufxy zB>dWvzSHDEPTTj7*M}p9Vy3B#$mhs!-!uPzGIHnpI99k-Jfx#TgBa8x2&6s|?yzHh z93+%dD#odquHrb<{Wzq5sObB17TT!)Pmi8NBt4_I0DK+at90JZ z3a+=NuGORBBh0A8QDFMR>Z;8A&^hq0Zm>)@*SBFjZB~87*liqvDb&u(R9#|Pnq#we z>Vs^Co>Gl*O_tLuyIoiXvK7ZHsVh;Cdps?7S~80ULoQhpR}19bJg@e-UB%NFx+)?& zUeWwNLz&*9jQ9}L{5?t9iN(2lvqCj$_rH+F-i%5cq!Ez-zU=Tq=KwjOm(NAc$c4nJ zm`tU*DsElBH$dTg(cW~E(_J;*m(4`~gemhWkB758X9Q6=C~=Da9TcIiFy6>&lev>y zXp)FtiYm{STn(d+4hFW^`wvYX7Z}BrzzoNMR!f5YC66l>&#>(Xt$oCBXJ0w2TR;>= zAoSREC|V0xJC(*c4I5>p(imp^rKa9R7VjhcV!wYFqD`8Yi|dK>3l@`?%+o2o_fF8)N2{NApj%0KEmHCfW zjniKh-rHWmPP&D|LUc8g(5NSw=K_BjA$sUEBTk?@ ze&CXHRjMPy&P#~?mDedMniG$Oro;=Ln^x3=;P}LT67N@^N1hpF+ zUNsT7d__{)8A7+iXsSXm#!y6wF}utU_t)Lsc}7HuC9<2pUvb)a1TT(g+1OY+@<(tL zPG=EF@|~kFN@(w@ycO?vU_VhA(Jm_2T1593AY#o7TR3V^(9E`ckBoExvVHK~4$Fg~ z?%tYIb7J!?DiqtJV4BDkIvB-tm?|mrOnOCWUma1z{uod=poC*`>S37GxhGcbbA@9F z{?>PAa9wFFy`oxN`p8RLD?=Ynh#wi6cG8%j9an+?UsMJS;eyMZwVu;hOFc8;D1(v~ z>F!cqvDd~Y|AEQa_B3JVS5nq!ZzTw1HH7)rxLAreaFP2KI4K#sR?$J~lk9$PgiUsz zi7a@u#rg+OrIsn}&C{JLwToX+B{&owq{=v3HJf%||_sJuuTh>E{{tpZ&# zjq@}FZ*^l4LR%Hnq7@Vu8$O+p-eq0?6mi-nni%TR4D2yi4ZLZs>DcW4^y%G+pPPHr zohPI+GoHMfNkkpq4LP=a&+k`<=^FJN*s5#V5rF=P;#`h)&w$|341gp`!&JrbfuBCa zRFG<7jLj|UJDYSbZhSfsZo6o4XZ=PezG^!l&qgpB?iW`8xnbwg4?#b>@QcoGmVnp7 z9^E(yMp=cK)B+dj&nY?%({oQmy{B|IcZdq6JMWfl+#+`TyUUgTX;$DYE zGj>6OjtH&Rppbf}qXr?du#_?;P6lcPwG7uIye_$=)9CZqhbkQ;`ThiIu6&@15gW>F zL&sfmK2o_hjV49pVUEvolMRl)T)1dJgn56d-dO`dx&MY?qM_ zGLkJd<#6vnabiI1pfJiAKC^8?hlkwYGn00pJA_mem_qyILpRr-;5Q0;@K?it`vQ!9sc`1NB2gX!AC=1^Zn1db)JT7SdSf*9qO!O}I_ zoEHL~^m@}P{m`S}eUZjE_6mhm_&hrcOwU%@{JHJiB#kQciSSD<=^8d0l+Ux8!KpzlY)!*95jzuC$;+F3KfFJCQ0srK`2*yB*_%JAuslW zu=%jAy9u_tGt77NDLWwBSN`A3?~bV7BKh`r#dS&NxJyrfqwm&VAHwgDeqB4iG{0Q8 z&yV4Gj{>lH7cO=JjEda{$BtEI1bF9&#TU`PUvc~Z3yA!uQ`fe<5JQBAQw2fmylYca zoe=5rldtc0f{)JTZAagaC&|ASl%Pxekk02b5pnI6?mAZHnl#U~3&Tq1mYH!vCuVdx%^FXn=`5HvGtT2g=&Gu~d{Euu)+1!Ri`oP6H}3sV5iZ)TVymazl# z*Q<}VLHP_2>MvX?^y#fS+3quFsbQk%5?=(~AtSv6E^fuzfHh6Rd+xqdg6WlQ$0_S! zt+7*DYbZuX;9GNXjr`~iPH~{ixlCRiWasRA9)0QW>#K*iGXUZ@e6+XJ+Y-YcZF5KH9kRuIYGhe8!>)X zyiKUH2*u+iv57oLFGS;F8}GP|p{@rbjT0up=z2oiIjGtP_=xT4y)PtCT7CY_qz+uV zWx-dDA^+flYld~xrXoW#mHNR!k&jiAMrq(SuF3b`yR&p*5|#J%xmd7I+}C=vb1Xx zb--g1@)){Wh>Iur_%_DB;E%T-8dH~8-S7Jg#pnt=4h~I1372{kyLJvuN}DtP-v8pm zT@-Zt{@@_kEn5ruiNF1>p9a( zK@MO`6$f!MkL!<4HH^qzU^{o-!cU7!)1B`3-ip*4jyI3B$b-4w(z>+J#?anQ5N{lK+xb0!QI^g3&D~suECuki@R%Z$l}3m z@g=ys24`6$!QCy`CHK2^-}~|2$m!~u>Y6{(RXx)^XJYEi@$RD(n&eUNlz3+7_2N^l z_@E;$l)~jeRt5;~ubZja7cY`m;dPfhW{^?DV&cT$^6Py~AudcO;$R&R^eF_y5)Q~s zE&TKlZgWom=a?1Lk`S3Nsdkeb?tG5dop^`0$SYw&9ougYjbTmFPC-Px%^dHbtDPEXcL@eHzDrrBf3V1PpJa+ zQkNI>)c3>RZi(3eajQV10xHyeq1ATkp2DN!G4gI`ECg(Cfg7a{7K^6neV&?JYnkl$ zlBV^L%;sN+~-{%ecls)Op1;hbrOye(r*5MQzskFgtI+4CK zo#Q2O5u78yfMjT4{5RBL9PAa*Wp0eZ9X|EMbHOPOXvmfKFyn0|mrIwc+l2niuGiTg zVegyx+pz6_{_9XNg;9v>Da)5S@cQihu)@DBxn15W{>sK#xrb9M71@k0Jwq{N`D{GT z9A&@|Bb%1-LtK!Q*#loQTfU3omnDXx#qhN)@i-h@tmBd;2RcQ;EI~o$JHZ)P0!(Mk z&KrQfh<_F_isX>Z_ta%oNCGzVEvQ70 zsk(7+@+IDEW>}flN%T*i*P%c9D&F-EUgsLOk{lVj0o*SbONLiZ-2@zsqbQO(d4G`H`3a8$#cDSA3!^R{MJL>6w~fO2 zYs=nfFpr-OxR}rOk9f$`#<1v2Bj-f&xK+xy_#7EwB-YtuyL+lY2D96LD4Ej}GXQR5 zCZPH?`PH$3pVyH0BLlYeO#NQU3g+z07O*G=NO3uY0BH-9slZq?3_=F(r!mdYYb zq0T3KDImSLEA(*c)DLgo_ua&aIw4?Fv+%0PPAX6&VvXjAr1v&^C~{g`40pxx>~_td z(i%1d$JDZ+TGReXu13CEvm?2$k^bd=RYPWxL2cn#eX_W`FUguT!2ldc<{@0kHuufIZ-%(`a62P2Y97J{Z#N_Hm$?`J(*_BTfAcq+GxvkHDC?=uSkJ>Q z%(J-UcLQZ_8|aX+w)|YG_!DS!Q0h8S%081}ZL+h~J}#E?{9Qb^hzp1JbiSkTgxFe! zfo6d(X~-^Ak8S|@>LES+smTKW$z_8sO6#3894MDqA6>#+5_2q8(vD1$n+78+2J{E%h_}i z5_!Hje_WDOyX$(j7s5yh+ur=TiFIZuL>Kb_knFPG5*|Gb`&~=Qy6Z{k2p&kh;AO;M(X~5 zzPI~WE|KnZsXq0<;;aI5&~6aU^1}Gf7;!cXi=u8+L!90zUHco7Jz0OBcj;86Xe)7L zChN|0t0%2C#@Wd6IN7D%CTI0_TCf@PxTc3~h;;r*AJ7IVs9m(aU1F)hZ4RqV_FX)C z>US%u;iR(teu{Wx8drG=KNWk@QQNJ1biG$*q1al3TtTK(BQE^1<<+bs+$v~l`TL|z zPP7NdBTEH@2oJZ$#?FktI{NG`7yPPw-DEQC*WM=VeFO2SZ8eDFd3T;Mf&6zRDYO4f zZqM;Br2{obzl9)#?Us+*5SdyX*lxF9 zaqvA{9h8)sg89}?gwKc`{1z9M&)R}1b7y5cNo)P~H|2>~bAmq+d)&CG%@H}VyQ>bU z&g?4@d*h3K$kNEUrs-UKGElYpeB9?1b_aJ=EXk~rb?MkkiP`j7;~(R5(uQL<#l7#< zTNd(989#Gv#%lLCSl~G3^eWFIS>KVJy*Sm#U`}20d_Fo$E z%`1?Y@=6(6L&qrPf^wU7R=2ek0G)W#^NkmL_;5~;^k$$ZQzAR8PJE{P`L49-fTFOs zHyH7BU}T}r1-YbPdALB)UY?j6peif9*R1Uu;k zUm5o_8X*C5PmTx4=TyWWbTCg#i}4r#Z7QFM5gx2#$s^q+)5;(A+-tfw;-*Yr?MB%X zAj2Z@t2`ygQKKbFiRBa_=BFI`jZA;Z^05r&6Dmn`fPRnajqAB6XA;s1qk0X+E}yeD z&Fh0j(8f7fDtdsMOV;AJU;oXm!a4U1hY!v`cV>g+p}j*4ob^@qkAS= zeBH>yZ|wpZZO|^K5b;mfMqKAjaKe|%x9sG&mV92_9UBTFmpo9ov38QSFKhRD1c2OZ zwPR}q4JoG)+*Zgv?tTwujH+2q^JEjv_)9;T+tKg06?}a6^9EBr<)t3n8?u<&vYmR; zY|^fSq{2r$;TU{-RSv#iEti{k}u~~)81~ogKi%Ow><-Y zx~C@1L(FZ?DMtE+;MMX>LKpk(KuSDc9|PUH->ZGh?T)L22 z!NTMS$xOesPMPrHnZunzoblT7r(DJ94OT~*ckYlR)NCr6bN7B6tsti6-V0(6chDj& z@TUnZJWdhA(6qP%cRwH{Rg^{;B?Ehcb6gVfilyqz0j(W| z#D5Vhk8SfQo0)tF1IK^Y5-a&5hJW`K{8}k3e8%o$O-~OG*TTNg+g&vz)vO`Q+nOoz z>!`eRvavKwRPa)Me<@hxj&(s1iY+7^1ddy-n$M;b4$aA-zS!-MaCsfO?kG^@*&I7{ zW+JHGuF*Bl7+SY|e`Ya*_?ggZIFA7N`}p|y$(yqhCH&A}Z^QzUNdqfXB0I>I546!d z>6aVc-)*8;lE4xxsS^}e(fI75A0)I?^PKwo`T{Z*Lhaz!Q^)wZO(_Uyb(s*}bs2pm z5-j-zxoqoHeuK*8GrC>X+3HvC_S2lP|Nb3uZ}gtn70`wtvi9Bm(yC9b1GD2FH@Z!; zki$5kktm&yAEOdSIfvdMZ>d9joiPVvGYkaM;8$0_UNiN{``cN}>m}wRSkZC6>IUuk zlKk$91)cdP0O8NUI*E3qHM^x`@1yL!Y{YF%I$F(mu_a8@@_Js=$*+Lu)r8w8rs@04 z{={~smbpva<%ugXngvB~>b!~FZ-y-o(1ELALRD=sk85FllAV)>tMkX=QRvG@4Y zm}aHM@Z!3rte7w0&-9{#^pGd~s5Kh!*BR{|b6k0w@*ai^8*{XF?RWKbiiWqplHz^m z&U=dzp2T}cMXIKOjp=5Z>fi+3KNxA#y&>a!&kJRe#DrdL=h14nMr&2aG=hs`Y!#0q z$JJUHL2gB`)Xj3OWFPdXINZZgCH?&B)jK2pH&d@(D5enSM&2fdG`8wc*z+n2j}MC_g{=ArjusK(nr zmR3K`r;kiccI)Te3T$LohzR<79$WE7@*yC+R)Fmhhp(?V8J1NNm+yJeM+CAG=%VI7 zagYU;qRDY3jKtM7_#qF+vmK!;ZOdx-9#gf2N%d$dmTv4}W-hc|GP$Til&g+fH-TG7 zQv<-J0WCcsrxB3F2q@us?^Je19Yn7WaqQ?5tqGEAWYorVyT~|>u+kY+V(1YXy&2=|hco={bCxmDXx-gv zzEmlzj&b1&(JG$B-Fp4LH?mZBUg*&ViM(vVf*MCXumqmvr)ue3&y$RD_cWX(In zM<9A~OyxkkDT-JU3HChi$wGdo1)8^6U5ACXL8(3aH&Rzk>3t3R81t#MoR5sxT$7v) z9)_B*I#S#QCM0V#R*A$<%6U4R@(7jvZ#fn#LyytO5Rs7Ri8&>?=cUk=`TZc&fKsm0 zcY18D+Apw#vFV zIOeUT?-uWpM~e66*Ok6uQQUnEoLNx2&(pQHo2ap!hJL}S=qH|qLO(p8?^13_!tR>s zRHR|S5JODGmw(aurNHZ<9NPz*<@pL92cs3cgT{8K(VE>sTsw3XW_G|z52&FK&7IP5vmfKMpQ9DvNM&^s&>6 zVke*ZfU}C@J6W@h*$EjNC^XXMZV;f}vfXP0DcUZ`8#>M@G}@GXX`Qc#YK(`M3mmwY zRaGo2HhbciN*5S{54;a`woI-(&nuj$*gpy3rd7QT7)KeMf4N4p>o51Z)*ND>jc)2y z^aTj&jR$|N4^UAi!?P7seooUHcCFRoDZF+9WIF&Mt4%xYam3C=tduD!&UmFSd!)y zeX(g7?ML)%y@e5c?2dg>C^BW`uZwG9+7upP9>u=F>x7I?1hXr#y;Sq_wG0%aJ+j`=*EwRZN4(DcO4FQwr^ua zQgJx*9r}-imOxB%40Z=ca*`f{>n=x_?*yyo%K+=5E~UHBSC_&cM)C2kU%|}=;Ex5F z_aR45K~s<2pl9=_f7llN4+&%cp?%yx?2G@06bXLa&=|MG$L?!SuM^HBjce3p&U4^3 z(sKJpeO}E@k>vQIpP!o|1uZ@qq(NJOD+K%Ifw2 zLqjKb@arb4>@pQkWVN;gH5rV~Q!*9uQye#ym^|cuibSz0(PjGSeu6ji%8OJEK$RHN}SlQ|D zb=HyKw5V4JlXqNK-{b*c zv1_!Bp_>sAU9pMpv_^O{7gb|Q+PE$=XInT`N*yEDQCQ+KMVy;O_yq^z7@$?$ma3-w zM}7dyxxw|gaZVszI-^?t1ce1eSxPf!Mje#v|B)tLeu+1I(|?avi4BbW**rpfqw@^2u5girGQ*Zrc$* zp=-4|@!K~4JGMh$N>{+Yb|Bk1GXKaK?_Rspf5zteUD7AW(MvEPDP6`z=G5fZ14>eM zHhto!j|E8|2;F*{AmW2P7s6PMSvucEE|Xu2mV6{_BH7OCw?s2>#LaQK&1G`e1Hm8Nr#opaf60WoT-;6AB)ZGv z4s!F^MG*YqPTKL&o@T*cGSTqUJ(>IFrB2UJ9}~fg<-4vzXAxh-Ak7q&XJWf-1Mm_I!2en z_M*pgU<4I~;viXa#tzTPJpCP)n%!D`HXW z)~hgwT;HFU$B?fGC;0}a;io`HKCYb+osdihBgIJ9M?U)14+{o53Yc70D4+0$HhKHv zde^)ha>cj-&vJ=+`p9JH+*q^aMCCxP&vyEVU=-peh(G{c(DaC_ zNSRkW0|4l{%Bt+nEYsadn4}m&a94})L!4N8b`VMFs21U40DB-m6&B6-i+b=Cfwwd0 znG0nzk-Oop>Mnqk=HmYJnN0fl&CpT@S=@rXM%B8+n?vn~t^Nb$1OAvvlRsO?9Uo!B-^Z2;g2WRKcc_V8#R%$ecBq#x$ zQre5jWgy*b=j+!Erirc1;0hDB#^LX1XcKcoi)MpVegUm3-eCz~A~7z4)ap8-sz`O) zwi(n=X5^H2muX7ggc&y1J(p@TwubnsxFSF5UG3+-G!^gTkp&E;vo!LoWwF~RczqM0 z5@V}cI8h{CHp=f_qSy+z)4GDGOyB@hwj+N29@Mi|tpl!T4PF9k3p$|r%$w7=H1bbP z_pQ~cd80q4L#@Kio9GisL_qW%N@vNI-V<1+l(>aIlEq~9kM8` z*Y7RmEH0c3lzK*poqLR1XOc+g0&c+eO~p>M9?ub%m7GqX$58vkh~If!`VkIv)I_Om zMkK1Kt8aUQMLo1j4+f=Hplg7K9N4f4@x-*SDgmJ)OQ>f;s12E^TUg1=(jg4dw%$mk zoKV1;L+CQ)>G^YRo*$fm`hG_#7raHihcAmuciX|4I|{dCS1NP`BsGgLcct-X-B6G1}- z5s47_|4&)|a=!n&6$bqi|A)Z*|F-*oImLgh5D*%I=3fZV|6}*!A{r{FX#bXwUb@qZ 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 From a154a44736c352a71c1bc7252d5a809c0e41ce83 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:58:57 -0600 Subject: [PATCH 3/5] 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 --- dojo/finding/deduplication.py | 113 +++++++----------------- dojo/importers/default_importer.py | 69 +++++++++------ dojo/importers/default_reimporter.py | 8 +- unittests/test_deduplication_logic.py | 118 +++++++++++++++++--------- 4 files changed, 156 insertions(+), 152 deletions(-) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index 3f8e0a24640..4cb400bda1d 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -1,5 +1,6 @@ import logging from collections.abc import Iterator +from operator import attrgetter import hyperlink from django.conf import settings @@ -545,14 +546,20 @@ def find_candidates_for_reimport_legacy(test, findings, service=None): def deduplication_ordering_key(finding): """ - Stable, content-derived sort key for choosing the canonical "original" - among findings that collide on the deduplication key. + Stable, content-derived sort key used by the importers to decide the order + findings from one report are created (and therefore get their ids) in. - 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). + Deduplication itself always picks the lowest-id finding as the canonical + "original". Because the importers sort a report's findings by this key + BEFORE saving them, "lowest id" within one import equals "canonical by + content", so the winner among findings that collide on the deduplication + key is reproducible across re-scans regardless of the order the scanner + exports its findings in. Findings from earlier imports always have smaller + ids, so an already-established original never flips. + + 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. @@ -567,57 +574,18 @@ def deduplication_ordering_key(finding): ) -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 - # 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 + # Ensure the newer finding is marked as duplicate of the older finding. + # This comparison must stay a pure id comparison: it is evaluated + # independently from concurrent dedupe batches (and from the `dedupe` + # management command over pre-existing findings), so it has to be globally + # antisymmetric — for any pair, exactly one side may see the other as + # "older". Content-stable winner selection is achieved by the importers + # creating a report's findings in deduplication_ordering_key order instead. + 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 @@ -626,11 +594,7 @@ 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 - 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), - ) + possible_matches = candidates_by_hash.get(new_finding.hash_code, []) 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: @@ -647,11 +611,7 @@ def get_matches_from_unique_id_candidates(new_finding, candidates_by_uid) -> Ite if new_finding.unique_id_from_tool is None: return - 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), - ) + possible_matches = candidates_by_uid.get(new_finding.unique_id_from_tool, []) 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): @@ -671,10 +631,9 @@ 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) - 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: + 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] if not _is_candidate_older(new_finding, candidate): continue 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 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 @@ -898,9 +849,9 @@ def match_batch_of_findings(findings): enabled = System_Settings.objects.get().enable_deduplication if not enabled: return [] - # Only stamp/sort for saved findings; unsaved findings have no id or batch context + # Only sort by id for saved findings; unsaved findings have no id if findings[0].pk is not None: - findings = _prepare_batch_ordering(findings) + findings = sorted(findings, key=attrgetter("id")) test = findings[0].test dedup_alg = test.deduplication_algorithm if dedup_alg == settings.DEDUPE_ALGO_HASH_CODE: @@ -1019,10 +970,8 @@ def dedupe_batch_of_findings(findings, *args, **kwargs): enabled = System_Settings.objects.get().enable_deduplication if enabled: - # 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) + # sort findings by id to ensure deduplication is deterministic/reproducible + findings = sorted(findings, key=attrgetter("id")) test = findings[0].test dedup_alg = test.deduplication_algorithm diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index 72e98c7efc2..7d819963cff 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -7,6 +7,7 @@ from dojo.celery_dispatch import dojo_dispatch_task from dojo.finding import helper as finding_helper +from dojo.finding.deduplication import deduplication_ordering_key from dojo.importers.base_importer import BaseImporter, Parser from dojo.importers.base_location_manager import LocationHandler from dojo.importers.options import ImporterOptions @@ -203,54 +204,66 @@ def _process_findings_internal( logger.debug("starting import of %i parsed findings.", len(parsed_findings) if parsed_findings else 0) group_names_to_findings_dict = {} - # Pre-sanitize and filter by minimum severity + # Pre-sanitize and filter by minimum severity. Findings are also fully + # prepared here (fields/overrides/hash_code) so they can be sorted by a + # stable content key below. cleaned_findings = [] for raw_finding in parsed_findings or []: sanitized = self.sanitize_severity(raw_finding) if Finding.SEVERITIES[sanitized.severity] > Finding.SEVERITIES[self.minimum_severity]: logger.debug("skipping finding due to minimum severity filter (finding=%s severity=%s min=%s)", sanitized.title, sanitized.severity, self.minimum_severity) continue - cleaned_findings.append(sanitized) - - for idx, unsaved_finding in enumerate(cleaned_findings): - is_final_finding = idx == len(cleaned_findings) - 1 - # 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) + if sanitized.mitigated and not sanitized.mitigated.tzinfo: + sanitized.mitigated = sanitized.mitigated.replace(tzinfo=self.now.tzinfo) # Set some explicit fields on the finding - unsaved_finding.test = self.test - unsaved_finding.reporter = self.user - unsaved_finding.last_reviewed_by = self.user - unsaved_finding.last_reviewed = self.now - 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) - # indicates an override. Otherwise, do not change the value of unsaved_finding.active + sanitized.test = self.test + sanitized.reporter = self.user + sanitized.last_reviewed_by = self.user + sanitized.last_reviewed = self.now + 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) + # indicates an override. Otherwise, do not change the value of sanitized.active if self.active is not None: - unsaved_finding.active = self.active + sanitized.active = self.active # indicates an override. Otherwise, do not change the value of verified if self.verified is not None: - unsaved_finding.verified = self.verified + sanitized.verified = self.verified # scan_date was provided, override value from parser if self.scan_date_override: - unsaved_finding.date = self.scan_date.date() + sanitized.date = self.scan_date.date() if self.service is not None: - unsaved_finding.service = self.service + sanitized.service = self.service # Parsers shouldn't use the tags field, and use unsaved_tags instead. # Merge any tags set by parser into unsaved_tags - tags_from_parser = unsaved_finding.tags if isinstance(unsaved_finding.tags, list) else [] - unsaved_tags_from_parser = unsaved_finding.unsaved_tags if isinstance(unsaved_finding.unsaved_tags, list) else [] + tags_from_parser = sanitized.tags if isinstance(sanitized.tags, list) else [] + unsaved_tags_from_parser = sanitized.unsaved_tags if isinstance(sanitized.unsaved_tags, list) else [] merged_tags = unsaved_tags_from_parser + tags_from_parser if merged_tags: - unsaved_finding.unsaved_tags = merged_tags - unsaved_finding.tags = None - finding = self.process_cve(unsaved_finding) + sanitized.unsaved_tags = merged_tags + sanitized.tags = None + prepared = self.process_cve(sanitized) # Calculate hash_code before saving based on unsaved_endpoints/unsaved_locations and unsaved_vulnerability_ids - finding.set_hash_code(True) + prepared.set_hash_code(True) + cleaned_findings.append(prepared) + + # Create findings in stable content-key order so their ids follow content, not + # the scanner's export order. Deduplication picks the lowest id among findings + # that collide on the dedupe key, so this makes the surviving "original" for + # intra-report collisions reproducible across re-scans regardless of the order + # the scanner emitted its findings. 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) + + for idx, finding in enumerate(cleaned_findings): + is_final_finding = idx == len(cleaned_findings) - 1 - # postprocessing will be done after processing related fields like locations, vulnerability ids, etc. - unsaved_finding.save_no_options() + # Findings are already prepared (fields/overrides/hash_code) and globally + # sorted above. postprocessing will be done after processing related + # fields like locations, vulnerability ids, etc. + finding.save_no_options() # Determine how the finding should be grouped finding_will_be_grouped = self.process_finding_groups( @@ -269,8 +282,8 @@ def _process_findings_internal( findings_with_parser_tags.append((finding, [cleaned_tags])) # Process any files self.process_files(finding) - # Process vulnerability IDs - finding = self.store_vulnerability_ids(finding) + # Process vulnerability IDs (mutates the finding in place) + self.store_vulnerability_ids(finding) # Categorize this finding as a new one new_findings.append(finding) # all data is already saved on the finding, we only need to trigger post processing in batches diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 818b936b4d0..0b0d7cf5840 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -663,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=deduplication_ordering_key) + return sorted(matches, key=lambda f: f.id) 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=deduplication_ordering_key) + return sorted(matches, key=lambda f: f.id) if self.deduplication_algorithm == "unique_id_from_tool_or_hash_code": if candidates_by_hash is None and candidates_by_uid is None: @@ -692,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=deduplication_ordering_key) + return sorted(matches, key=lambda f: f.id) 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=deduplication_ordering_key) + return sorted(matches, key=lambda f: f.id) 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 33ca9d97ed7..85fbfa89545 100644 --- a/unittests/test_deduplication_logic.py +++ b/unittests/test_deduplication_logic.py @@ -11,6 +11,7 @@ from django.utils import timezone from dojo.finding.deduplication import ( + _is_candidate_older, # noqa: PLC2701 -- the antisymmetry regression test targets this helper directly build_candidate_scope_queryset, dedupe_batch_of_findings, deduplication_ordering_key, @@ -30,7 +31,6 @@ Test, Test_Import, Test_Import_Finding_Action, - Test_Type, User, copy_model_util, ) @@ -558,17 +558,20 @@ def _make_finding_in_test(self, template_id, title, 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). + def test_dedupe_batch_winner_is_lowest_id_and_rerun_is_stable(self): + # Batch deduplication itself must always keep the LOWEST id as the original. + # Content-stable winner selection for one report is achieved by the importers + # creating findings in deduplication_ordering_key order (see the Fortify + # order-independence test below), NOT by re-picking winners inside dedupe. + # This keeps the "older" relation globally antisymmetric across concurrent + # dedupe batches and keeps re-running dedupe (e.g. the `dedupe` management + # command) from flipping established originals on pre-existing data. dedupe_algo_endpoint_fields = settings.DEDUPE_ALGO_ENDPOINT_FIELDS settings.DEDUPE_ALGO_ENDPOINT_FIELDS = [] try: - shared_hash = "content_stable_hash_flip_test" + shared_hash = "lowest_id_rerun_stability_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. + # (higher id) — simulating pre-existing data created before this upgrade. 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) @@ -579,12 +582,41 @@ def test_dedupe_batch_winner_is_content_stable_hash_code(self): 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) + # The lower-id "zzz" stays the original even though "aaa" sorts first by content. + self.assert_finding(finding_z, duplicate=False) + self.assert_finding(finding_a, duplicate=True, duplicate_finding_id=finding_z.id) + + # Re-running dedupe over the same findings (the `dedupe` management command + # scenario) must not flip the established original. + dedupe_batch_of_findings(get_finding_models_for_deduplication([finding_z.id, finding_a.id])) + finding_a.refresh_from_db() + finding_z.refresh_from_db() + self.assert_finding(finding_z, duplicate=False) + self.assert_finding(finding_a, duplicate=True, duplicate_finding_id=finding_z.id) finally: settings.DEDUPE_ALGO_ENDPOINT_FIELDS = dedupe_algo_endpoint_fields + def test_is_candidate_older_is_antisymmetric(self): + # For any pair of saved findings, at most one direction may be "older" — + # dedupe batches run concurrently (and the `dedupe` command re-processes + # pre-existing findings), so a symmetric or context-dependent answer could + # mark two findings as duplicates of each other. + from types import SimpleNamespace # noqa: PLC0415 + + def mk(fid, title): + return SimpleNamespace(pk=fid, id=fid, hash_code="SAMEHASH", unique_id_from_tool=None, + file_path=None, line=None, title=title) + + # Content key order ("aaa" < "zzz") deliberately opposes id order. + older_by_id = mk(100, "zzz content") + newer_by_id = mk(1200, "aaa content") + self.assertFalse( + _is_candidate_older(older_by_id, newer_by_id) and _is_candidate_older(newer_by_id, older_by_id), + "both directions claim the other finding is older — mutual set_duplicate would corrupt data", + ) + self.assertTrue(_is_candidate_older(newer_by_id, older_by_id)) + self.assertFalse(_is_candidate_older(older_by_id, newer_by_id)) + 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. @@ -623,21 +655,19 @@ def test_deduplication_ordering_key_is_order_independent(self): 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. + under a shared product via the real importer (so findings are created in + stable content-key order), 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, @@ -645,30 +675,42 @@ def _dedupe_fortify_repro_scan(self, fpr_filename, engagement_name): 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] + environment, _ = Development_Environment.objects.get_or_create(name="Development") + with (get_unit_tests_scans_path("fortify") / fpr_filename).open(encoding="utf-8") as scan: + importer = DefaultImporter( + scan=scan, + scan_type="Fortify Scan", + engagement=engagement, + user=admin, + lead=admin, + environment=environment, + scan_date=timezone.now(), + min_severity="Info", + active=True, + verified=True, + push_to_jira=False, + close_old_findings=False, + ) + test, _, _len_new, _, _, _, _ = importer.process_scan(scan) + + # Deduplicate explicitly so the test does not depend on the async/eager + # configuration of import post-processing. Dedupe is idempotent, so this + # is safe even when post-processing already deduplicated the batch. + finding_ids = list(Finding.objects.filter(test=test).values_list("id", flat=True)) + dedupe_batch_of_findings(get_finding_models_for_deduplication(finding_ids)) + + findings = list(Finding.objects.filter(test=test).order_by("id")) + # The importer must have created the findings in stable content-key order, + # i.e. id order equals deduplication_ordering_key order. + self.assertEqual( + [f.id for f in findings], + [f.id for f in sorted(findings, key=deduplication_ordering_key)], + "importer did not create findings in deduplication_ordering_key order", + ) + non_duplicates = [f for f in findings 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]}") + self.assertEqual(len(non_duplicates), 1, f"expected exactly one original, got {[(f.id, f.line) for f in findings]}") return non_duplicates[0] @override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL}) From ae9fca1fca5b51755aba96416a4b8c5bd06b6316 Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:12:10 -0600 Subject: [PATCH 4/5] fix(dedupe): scope content-ordered creation to the reimport path Drop the import-path creation sort: the customer-visible flip is a reimport phenomenon. On plain imports the intra-report winner is chosen once (first in file) and is pre-existing - and therefore permanently stable - for every later import/reimport, so sorting there only changed the one-time initial winner while breaking order-coupled fixtures (JIRA VCR cassettes replay recorded issue interactions positionally, so changing import creation order made test_keep_sync_push_finding_then_reimport_with_no_push request an issue a fourth time against a cassette with three recordings). The reimporter keeps creating findings in deduplication_ordering_key order, and the Fortify order-independence repro now exercises the reimport path it protects. Co-Authored-By: Claude Fable 5 --- dojo/finding/deduplication.py | 16 +++---- dojo/importers/default_importer.py | 69 +++++++++++---------------- unittests/test_deduplication_logic.py | 53 +++++++++++--------- 3 files changed, 67 insertions(+), 71 deletions(-) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index 4cb400bda1d..b0b6d13d589 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -546,16 +546,16 @@ def find_candidates_for_reimport_legacy(test, findings, service=None): def deduplication_ordering_key(finding): """ - Stable, content-derived sort key used by the importers to decide the order + Stable, content-derived sort key used by the reimporter to decide the order findings from one report are created (and therefore get their ids) in. Deduplication itself always picks the lowest-id finding as the canonical - "original". Because the importers sort a report's findings by this key - BEFORE saving them, "lowest id" within one import equals "canonical by - content", so the winner among findings that collide on the deduplication - key is reproducible across re-scans regardless of the order the scanner - exports its findings in. Findings from earlier imports always have smaller - ids, so an already-established original never flips. + "original". Because the reimporter sorts a report's findings by this key + BEFORE saving them, "lowest id" among findings created by one reimport + equals "canonical by content", so the winner among findings that collide + on the deduplication key is reproducible across re-scans regardless of the + order the scanner exports its findings in. Findings from earlier imports + always have smaller ids, so an already-established original never flips. 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 @@ -583,7 +583,7 @@ def _is_candidate_older(new_finding, candidate): # independently from concurrent dedupe batches (and from the `dedupe` # management command over pre-existing findings), so it has to be globally # antisymmetric — for any pair, exactly one side may see the other as - # "older". Content-stable winner selection is achieved by the importers + # "older". Content-stable winner selection is achieved by the reimporter # creating a report's findings in deduplication_ordering_key order instead. is_older = candidate.id < new_finding.id if not is_older: diff --git a/dojo/importers/default_importer.py b/dojo/importers/default_importer.py index 7d819963cff..72e98c7efc2 100644 --- a/dojo/importers/default_importer.py +++ b/dojo/importers/default_importer.py @@ -7,7 +7,6 @@ from dojo.celery_dispatch import dojo_dispatch_task from dojo.finding import helper as finding_helper -from dojo.finding.deduplication import deduplication_ordering_key from dojo.importers.base_importer import BaseImporter, Parser from dojo.importers.base_location_manager import LocationHandler from dojo.importers.options import ImporterOptions @@ -204,66 +203,54 @@ def _process_findings_internal( logger.debug("starting import of %i parsed findings.", len(parsed_findings) if parsed_findings else 0) group_names_to_findings_dict = {} - # Pre-sanitize and filter by minimum severity. Findings are also fully - # prepared here (fields/overrides/hash_code) so they can be sorted by a - # stable content key below. + # Pre-sanitize and filter by minimum severity cleaned_findings = [] for raw_finding in parsed_findings or []: sanitized = self.sanitize_severity(raw_finding) if Finding.SEVERITIES[sanitized.severity] > Finding.SEVERITIES[self.minimum_severity]: logger.debug("skipping finding due to minimum severity filter (finding=%s severity=%s min=%s)", sanitized.title, sanitized.severity, self.minimum_severity) continue + cleaned_findings.append(sanitized) + + for idx, unsaved_finding in enumerate(cleaned_findings): + is_final_finding = idx == len(cleaned_findings) - 1 + # 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) + if unsaved_finding.mitigated and not unsaved_finding.mitigated.tzinfo: + unsaved_finding.mitigated = unsaved_finding.mitigated.replace(tzinfo=self.now.tzinfo) # Set some explicit fields on the finding - sanitized.test = self.test - sanitized.reporter = self.user - sanitized.last_reviewed_by = self.user - sanitized.last_reviewed = self.now - 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) - # indicates an override. Otherwise, do not change the value of sanitized.active + unsaved_finding.test = self.test + unsaved_finding.reporter = self.user + unsaved_finding.last_reviewed_by = self.user + unsaved_finding.last_reviewed = self.now + 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) + # indicates an override. Otherwise, do not change the value of unsaved_finding.active if self.active is not None: - sanitized.active = self.active + unsaved_finding.active = self.active # indicates an override. Otherwise, do not change the value of verified if self.verified is not None: - sanitized.verified = self.verified + unsaved_finding.verified = self.verified # scan_date was provided, override value from parser if self.scan_date_override: - sanitized.date = self.scan_date.date() + unsaved_finding.date = self.scan_date.date() if self.service is not None: - sanitized.service = self.service + unsaved_finding.service = self.service # Parsers shouldn't use the tags field, and use unsaved_tags instead. # Merge any tags set by parser into unsaved_tags - tags_from_parser = sanitized.tags if isinstance(sanitized.tags, list) else [] - unsaved_tags_from_parser = sanitized.unsaved_tags if isinstance(sanitized.unsaved_tags, list) else [] + tags_from_parser = unsaved_finding.tags if isinstance(unsaved_finding.tags, list) else [] + unsaved_tags_from_parser = unsaved_finding.unsaved_tags if isinstance(unsaved_finding.unsaved_tags, list) else [] merged_tags = unsaved_tags_from_parser + tags_from_parser if merged_tags: - sanitized.unsaved_tags = merged_tags - sanitized.tags = None - prepared = self.process_cve(sanitized) + unsaved_finding.unsaved_tags = merged_tags + unsaved_finding.tags = None + finding = self.process_cve(unsaved_finding) # Calculate hash_code before saving based on unsaved_endpoints/unsaved_locations and unsaved_vulnerability_ids - prepared.set_hash_code(True) - cleaned_findings.append(prepared) - - # Create findings in stable content-key order so their ids follow content, not - # the scanner's export order. Deduplication picks the lowest id among findings - # that collide on the dedupe key, so this makes the surviving "original" for - # intra-report collisions reproducible across re-scans regardless of the order - # the scanner emitted its findings. 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) - - for idx, finding in enumerate(cleaned_findings): - is_final_finding = idx == len(cleaned_findings) - 1 + finding.set_hash_code(True) - # Findings are already prepared (fields/overrides/hash_code) and globally - # sorted above. postprocessing will be done after processing related - # fields like locations, vulnerability ids, etc. - finding.save_no_options() + # postprocessing will be done after processing related fields like locations, vulnerability ids, etc. + unsaved_finding.save_no_options() # Determine how the finding should be grouped finding_will_be_grouped = self.process_finding_groups( @@ -282,8 +269,8 @@ def _process_findings_internal( findings_with_parser_tags.append((finding, [cleaned_tags])) # Process any files self.process_files(finding) - # Process vulnerability IDs (mutates the finding in place) - self.store_vulnerability_ids(finding) + # Process vulnerability IDs + finding = self.store_vulnerability_ids(finding) # Categorize this finding as a new one new_findings.append(finding) # all data is already saved on the finding, we only need to trigger post processing in batches diff --git a/unittests/test_deduplication_logic.py b/unittests/test_deduplication_logic.py index 85fbfa89545..31bd6f63282 100644 --- a/unittests/test_deduplication_logic.py +++ b/unittests/test_deduplication_logic.py @@ -19,6 +19,7 @@ set_duplicate, ) from dojo.importers.default_importer import DefaultImporter +from dojo.importers.default_reimporter import DefaultReImporter from dojo.models import ( Development_Environment, Endpoint, @@ -31,6 +32,7 @@ Test, Test_Import, Test_Import_Finding_Action, + Test_Type, User, copy_model_util, ) @@ -654,14 +656,17 @@ def test_deduplication_ordering_key_is_order_independent(self): 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 via the real importer (so findings are created in - stable content-key order), 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. + Reimport one corrected Fortify repro .fpr into its own (isolated) + engagement under a shared product via the real reimporter (so findings + are created in stable content-key order), using unique_id_from_tool + deduplication, and return the single surviving non-duplicate finding. + + The customer-reported flip is a reimport phenomenon: the surviving + "original" among findings that collide on the dedupe key changed with + the scanner's export order between re-scans. 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. """ product_type, _ = Product_Type.objects.get_or_create(name="Fortify UID Repro PT") product, _ = Product.objects.get_or_create( @@ -675,38 +680,42 @@ def _dedupe_fortify_repro_scan(self, fpr_filename, engagement_name): target_end=timezone.now().date(), deduplication_on_engagement=True, # isolate each scan's dedupe scope ) + test_type, _ = Test_Type.objects.get_or_create(name="Fortify Scan") + test = Test.objects.create( + engagement=engagement, + test_type=test_type, + scan_type="Fortify Scan", + target_start=timezone.now(), + target_end=timezone.now(), + ) admin = User.objects.get(username="admin") - environment, _ = Development_Environment.objects.get_or_create(name="Development") with (get_unit_tests_scans_path("fortify") / fpr_filename).open(encoding="utf-8") as scan: - importer = DefaultImporter( - scan=scan, - scan_type="Fortify Scan", - engagement=engagement, + reimporter = DefaultReImporter( + test=test, user=admin, lead=admin, - environment=environment, - scan_date=timezone.now(), - min_severity="Info", + scan_date=None, + minimum_severity="Info", active=True, verified=True, - push_to_jira=False, - close_old_findings=False, + force_sync=True, + scan_type="Fortify Scan", ) - test, _, _len_new, _, _, _, _ = importer.process_scan(scan) + reimporter.process_scan(scan) # Deduplicate explicitly so the test does not depend on the async/eager - # configuration of import post-processing. Dedupe is idempotent, so this + # configuration of reimport post-processing. Dedupe is idempotent, so this # is safe even when post-processing already deduplicated the batch. finding_ids = list(Finding.objects.filter(test=test).values_list("id", flat=True)) dedupe_batch_of_findings(get_finding_models_for_deduplication(finding_ids)) findings = list(Finding.objects.filter(test=test).order_by("id")) - # The importer must have created the findings in stable content-key order, + # The reimporter must have created the findings in stable content-key order, # i.e. id order equals deduplication_ordering_key order. self.assertEqual( [f.id for f in findings], [f.id for f in sorted(findings, key=deduplication_ordering_key)], - "importer did not create findings in deduplication_ordering_key order", + "reimporter did not create findings in deduplication_ordering_key order", ) non_duplicates = [f for f in findings if not f.duplicate] # unique_id_from_tool dedupe collapses the shared-uid pair to one original From 85f6050035e9160304b4301175f566a1060621af Mon Sep 17 00:00:00 2001 From: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:49:48 -0600 Subject: [PATCH 5/5] test(dedupe): add black-box reimport order-independence regression guards Add behavior-level regression tests that drive only the public reimport API (DefaultReImporter.process_scan with force_sync) and assert only on observable Finding state, so they keep protecting the customer-visible dedupe contract even if the import/reimport machinery is rewritten: - surviving active original is export-order independent (unique_id_from_tool) - same, under unique_id_from_tool_or_hash_code (combined matching path) - re-scanning the same report keeps the same surviving original (stability) The survivor is asserted as the single active, non-duplicate finding rather than by row count, so an implementation that keeps the loser as an inactive duplicate row instead of collapsing it away still passes. All three fail if the reimporter stops creating findings in a content-stable order (the flip this PR fixes); the re-scan-stability guard holds independently. Co-Authored-By: Claude Opus 4.8 (1M context) --- unittests/test_deduplication_logic.py | 136 ++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/unittests/test_deduplication_logic.py b/unittests/test_deduplication_logic.py index 31bd6f63282..24aef13a6b2 100644 --- a/unittests/test_deduplication_logic.py +++ b/unittests/test_deduplication_logic.py @@ -737,6 +737,142 @@ def test_fortify_same_uid_different_content_original_is_order_independent(self): self.assertEqual(original_scan1.line, original_scan2.line) self.assertEqual(original_scan1.title, original_scan2.title) + # ------------------------------------------------------------------ + # Black-box behavior guards for the import/reimport dedupe baseline. + # + # These drive ONLY the public reimport API (DefaultReImporter.process_scan + # with force_sync so post-processing runs inline) and assert ONLY on observable + # Finding state (which finding survives as the active original). They import no + # deduplication internals and make no claim about HOW the behavior is achieved, + # so they keep protecting the user-visible contract even if the import/reimport + # machinery is rewritten. If a rewrite regresses the baseline, these go red. + # ------------------------------------------------------------------ + + def _reimport_fortify_repro(self, fpr_filename, engagement_name): + """ + Reimport one Fortify repro .fpr through the real reimporter into its own + isolated engagement and return every Finding in the resulting test + (ordered by id). Uses force_sync so deduplication has completed by the + time process_scan returns; no dedupe internals are touched. + """ + 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}, + ) + 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_type, _ = Test_Type.objects.get_or_create(name="Fortify Scan") + test = Test.objects.create( + engagement=engagement, + test_type=test_type, + scan_type="Fortify Scan", + target_start=timezone.now(), + target_end=timezone.now(), + ) + admin = User.objects.get(username="admin") + with (get_unit_tests_scans_path("fortify") / fpr_filename).open(encoding="utf-8") as scan: + reimporter = DefaultReImporter( + test=test, + user=admin, + lead=admin, + scan_date=None, + minimum_severity="Info", + active=True, + verified=True, + force_sync=True, + scan_type="Fortify Scan", + ) + reimporter.process_scan(scan) + return test, list(Finding.objects.filter(test=test).order_by("id")) + + def _assert_single_surviving_original(self, findings): + """ + The colliding pair must resolve to exactly one active, non-duplicate finding. + + We assert on the observable survivor (active and not a duplicate) rather than + the total row count: whether the loser is collapsed away during reimport + matching or kept as an inactive duplicate row is an implementation detail, but + either way the user must see exactly one active original. + """ + originals = [f for f in findings if f.active and not f.duplicate] + self.assertEqual( + len(originals), 1, + f"expected exactly one active original, got {[(f.id, f.active, f.duplicate, f.line) for f in findings]}", + ) + return originals[0] + + @override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL}) + def test_reimport_surviving_original_is_export_order_independent_uid(self): + # BASELINE: two scans contain the same pair of findings (shared + # unique_id_from_tool, different content) in opposite document order. + # After reimport, the finding that survives as the active original must be + # the SAME content regardless of the scanner's export order. This is the + # customer-reported contract; pre-fix the surviving finding flips with order. + _, findings1 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan1.fpr", "fortify-uid-behavior-1") + _, findings2 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan2.fpr", "fortify-uid-behavior-2") + original1 = self._assert_single_surviving_original(findings1) + original2 = self._assert_single_surviving_original(findings2) + + self.assertEqual(original1.unique_id_from_tool, original2.unique_id_from_tool) + self.assertEqual( + (original1.title, original1.line, original1.hash_code), + (original2.title, original2.line, original2.hash_code), + "surviving original differs between scan orders — dedupe is export-order dependent", + ) + + @override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE}) + def test_reimport_surviving_original_is_export_order_independent_uid_or_hash(self): + # BASELINE (combined algorithm): same repro under unique_id_from_tool_or_hash_code. + # The pair collides through the shared unique_id_from_tool (their content, and + # thus hash_code, differ), so this exercises the combined UID/hash matching path. + # The surviving active original must still be content-stable across scan orders. + _, findings1 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan1.fpr", "fortify-uidhash-behavior-1") + _, findings2 = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan2.fpr", "fortify-uidhash-behavior-2") + original1 = self._assert_single_surviving_original(findings1) + original2 = self._assert_single_surviving_original(findings2) + + self.assertEqual(original1.unique_id_from_tool, original2.unique_id_from_tool) + self.assertEqual( + (original1.title, original1.line, original1.hash_code), + (original2.title, original2.line, original2.hash_code), + "surviving original differs between scan orders under uid_or_hash", + ) + + @override_settings(DEDUPLICATION_ALGORITHM_PER_PARSER={"Fortify Scan": settings.DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL}) + def test_reimport_same_report_twice_keeps_the_same_surviving_original(self): + # BASELINE (re-scan stability): re-scanning the same content must not flip the + # original/duplicate assignment. Reimport the repro once, note the surviving + # active original, then reimport the SAME file again into the same test and + # assert the surviving original is unchanged (same finding id and content, still + # exactly one active original). No new duplicate chain is spun up on re-scan. + test, findings = self._reimport_fortify_repro("fortify_dedupe_uid_repro_scan1.fpr", "fortify-uid-rescan") + original_first = self._assert_single_surviving_original(findings) + + admin = User.objects.get(username="admin") + with (get_unit_tests_scans_path("fortify") / "fortify_dedupe_uid_repro_scan1.fpr").open(encoding="utf-8") as scan: + reimporter = DefaultReImporter( + test=test, user=admin, lead=admin, scan_date=None, minimum_severity="Info", + active=True, verified=True, force_sync=True, scan_type="Fortify Scan", + ) + reimporter.process_scan(scan) + + findings_after = list(Finding.objects.filter(test=test).order_by("id")) + original_after = self._assert_single_surviving_original(findings_after) + self.assertEqual( + original_after.id, original_first.id, + "re-scanning the same report flipped which finding is the surviving original", + ) + self.assertEqual( + (original_after.title, original_after.line, original_after.hash_code), + (original_first.title, original_first.line, original_first.hash_code), + ) + 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