Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion dojo/finding/deduplication.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,11 +544,47 @@ def find_candidates_for_reimport_legacy(test, findings, service=None):
return existing_by_key


def deduplication_ordering_key(finding):
"""
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 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
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 _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
# 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 reimporter
# 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}")
Expand Down
45 changes: 28 additions & 17 deletions dojo/importers/default_reimporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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(
Expand Down
Binary file not shown.
Binary file not shown.
Loading
Loading