Skip to content

Commit ae9fca1

Browse files
Maffoochclaude
andcommitted
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 <noreply@anthropic.com>
1 parent a154a44 commit ae9fca1

3 files changed

Lines changed: 67 additions & 71 deletions

File tree

dojo/finding/deduplication.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -546,16 +546,16 @@ def find_candidates_for_reimport_legacy(test, findings, service=None):
546546

547547
def deduplication_ordering_key(finding):
548548
"""
549-
Stable, content-derived sort key used by the importers to decide the order
549+
Stable, content-derived sort key used by the reimporter to decide the order
550550
findings from one report are created (and therefore get their ids) in.
551551
552552
Deduplication itself always picks the lowest-id finding as the canonical
553-
"original". Because the importers sort a report's findings by this key
554-
BEFORE saving them, "lowest id" within one import equals "canonical by
555-
content", so the winner among findings that collide on the deduplication
556-
key is reproducible across re-scans regardless of the order the scanner
557-
exports its findings in. Findings from earlier imports always have smaller
558-
ids, so an already-established original never flips.
553+
"original". Because the reimporter sorts a report's findings by this key
554+
BEFORE saving them, "lowest id" among findings created by one reimport
555+
equals "canonical by content", so the winner among findings that collide
556+
on the deduplication key is reproducible across re-scans regardless of the
557+
order the scanner exports its findings in. Findings from earlier imports
558+
always have smaller ids, so an already-established original never flips.
559559
560560
id is the final tiebreak and is only reached when two findings are
561561
identical across every content field in the key (in which case the choice
@@ -583,7 +583,7 @@ def _is_candidate_older(new_finding, candidate):
583583
# independently from concurrent dedupe batches (and from the `dedupe`
584584
# management command over pre-existing findings), so it has to be globally
585585
# antisymmetric — for any pair, exactly one side may see the other as
586-
# "older". Content-stable winner selection is achieved by the importers
586+
# "older". Content-stable winner selection is achieved by the reimporter
587587
# creating a report's findings in deduplication_ordering_key order instead.
588588
is_older = candidate.id < new_finding.id
589589
if not is_older:

dojo/importers/default_importer.py

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

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

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

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

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

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

unittests/test_deduplication_logic.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
set_duplicate,
2020
)
2121
from dojo.importers.default_importer import DefaultImporter
22+
from dojo.importers.default_reimporter import DefaultReImporter
2223
from dojo.models import (
2324
Development_Environment,
2425
Endpoint,
@@ -31,6 +32,7 @@
3132
Test,
3233
Test_Import,
3334
Test_Import_Finding_Action,
35+
Test_Type,
3436
User,
3537
copy_model_util,
3638
)
@@ -654,14 +656,17 @@ def test_deduplication_ordering_key_is_order_independent(self):
654656

655657
def _dedupe_fortify_repro_scan(self, fpr_filename, engagement_name):
656658
"""
657-
Import one corrected Fortify repro .fpr into its own (isolated) engagement
658-
under a shared product via the real importer (so findings are created in
659-
stable content-key order), using unique_id_from_tool deduplication, and
660-
return the single surviving non-duplicate finding.
661-
662-
The two fixtures contain the same pair of findings - which share a
663-
unique_id_from_tool but have different content (different rule/title/line) -
664-
in opposite document order.
659+
Reimport one corrected Fortify repro .fpr into its own (isolated)
660+
engagement under a shared product via the real reimporter (so findings
661+
are created in stable content-key order), using unique_id_from_tool
662+
deduplication, and return the single surviving non-duplicate finding.
663+
664+
The customer-reported flip is a reimport phenomenon: the surviving
665+
"original" among findings that collide on the dedupe key changed with
666+
the scanner's export order between re-scans. The two fixtures contain
667+
the same pair of findings - which share a unique_id_from_tool but have
668+
different content (different rule/title/line) - in opposite document
669+
order.
665670
"""
666671
product_type, _ = Product_Type.objects.get_or_create(name="Fortify UID Repro PT")
667672
product, _ = Product.objects.get_or_create(
@@ -675,38 +680,42 @@ def _dedupe_fortify_repro_scan(self, fpr_filename, engagement_name):
675680
target_end=timezone.now().date(),
676681
deduplication_on_engagement=True, # isolate each scan's dedupe scope
677682
)
683+
test_type, _ = Test_Type.objects.get_or_create(name="Fortify Scan")
684+
test = Test.objects.create(
685+
engagement=engagement,
686+
test_type=test_type,
687+
scan_type="Fortify Scan",
688+
target_start=timezone.now(),
689+
target_end=timezone.now(),
690+
)
678691
admin = User.objects.get(username="admin")
679-
environment, _ = Development_Environment.objects.get_or_create(name="Development")
680692
with (get_unit_tests_scans_path("fortify") / fpr_filename).open(encoding="utf-8") as scan:
681-
importer = DefaultImporter(
682-
scan=scan,
683-
scan_type="Fortify Scan",
684-
engagement=engagement,
693+
reimporter = DefaultReImporter(
694+
test=test,
685695
user=admin,
686696
lead=admin,
687-
environment=environment,
688-
scan_date=timezone.now(),
689-
min_severity="Info",
697+
scan_date=None,
698+
minimum_severity="Info",
690699
active=True,
691700
verified=True,
692-
push_to_jira=False,
693-
close_old_findings=False,
701+
force_sync=True,
702+
scan_type="Fortify Scan",
694703
)
695-
test, _, _len_new, _, _, _, _ = importer.process_scan(scan)
704+
reimporter.process_scan(scan)
696705

697706
# Deduplicate explicitly so the test does not depend on the async/eager
698-
# configuration of import post-processing. Dedupe is idempotent, so this
707+
# configuration of reimport post-processing. Dedupe is idempotent, so this
699708
# is safe even when post-processing already deduplicated the batch.
700709
finding_ids = list(Finding.objects.filter(test=test).values_list("id", flat=True))
701710
dedupe_batch_of_findings(get_finding_models_for_deduplication(finding_ids))
702711

703712
findings = list(Finding.objects.filter(test=test).order_by("id"))
704-
# The importer must have created the findings in stable content-key order,
713+
# The reimporter must have created the findings in stable content-key order,
705714
# i.e. id order equals deduplication_ordering_key order.
706715
self.assertEqual(
707716
[f.id for f in findings],
708717
[f.id for f in sorted(findings, key=deduplication_ordering_key)],
709-
"importer did not create findings in deduplication_ordering_key order",
718+
"reimporter did not create findings in deduplication_ordering_key order",
710719
)
711720
non_duplicates = [f for f in findings if not f.duplicate]
712721
# unique_id_from_tool dedupe collapses the shared-uid pair to one original

0 commit comments

Comments
 (0)