Skip to content

fix(dedupe): stable original across scan orders via content-ordered finding creation#15222

Open
Maffooch wants to merge 6 commits into
bugfixfrom
dedupe-order-stable-tiebreak
Open

fix(dedupe): stable original across scan orders via content-ordered finding creation#15222
Maffooch wants to merge 6 commits into
bugfixfrom
dedupe-order-stable-tiebreak

Conversation

@Maffooch

@Maffooch Maffooch commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

[sc-13656]

Problem

Deduplication chose the "original" among findings that collide on the dedupe key by lowest DB id — i.e. whichever the scanner listed first in the file. Scanners don't guarantee a stable finding order between scans, so on reimport the original/duplicate assignment flips back and forth between scans even though the underlying vulnerabilities never change. Reported via a customer support ticket; observed with Fortify but not tool-specific (any parser without a stable export order can trigger it).

Fix

Make creation order content-stable on the reimport path instead of making dedupe comparisons content-aware:

  • Add deduplication_ordering_key() — a stable key derived from finding content (hash_code, unique_id_from_tool, file_path, line, title, then id as final tiebreak).
  • DefaultReImporter now fully prepares parsed findings up front (test/service/hash_code) and creates them in this key order, so among findings created by one reimport "lowest id" equals "canonical by content".
  • Every dedupe comparison stays a pure lowest-id-wins comparison (semantics unchanged from before this PR).

Because dedupe picks the lowest id among collisions, the surviving original for intra-report collisions on reimport is now reproducible across re-scans regardless of the scanner's export order — in every execution mode (eager and async post-processing), for any report size — while pre-existing findings (always lower ids) keep winning, so an already-established original never flips.

The plain import path is untouched: an import's intra-report winner is still the first-in-file finding, but once established it is pre-existing for every later import/reimport and therefore permanently stable — the flip between scans was a reimport phenomenon. (Keeping the import path unchanged also keeps order-coupled fixtures such as the JIRA VCR cassettes valid.)

Why not compare by content inside dedupe?

An earlier revision of this PR stamped dedupe batches (_dedupe_batch_min_id) and content-compared "intra-batch" candidates inside _is_candidate_older. That made the "older" relation depend on which batch evaluated it, with two concrete defects:

  • For reports larger than IMPORT_REIMPORT_DEDUPE_BATCH_SIZE (1000), a colliding pair split across two post-processing batches was evaluated with different batch minimums — each side could consider the other "older" (antisymmetry violation), so two concurrently running dedupe batches could mark the pair as duplicates of each other.
  • The dedupe management command re-processes pre-existing findings through the same batch path, so re-running dedupe could flip long-established originals.

Sorting creation order keeps the comparison globally antisymmetric and makes both problems structurally impossible. A regression test for the antisymmetry property and a "re-running dedupe never flips an established original" test are included.

Backward compatibility

  • No migration; no change to existing data at upgrade time. Dedupe only runs during import/reimport (or when explicitly re-run).
  • Pre-existing findings always keep the original role — covered by a dedicated test and verified hands-on: data imported under the pre-PR code, then the reversed report reimported under this PR → 0 findings changed, no flip; manage.py dedupe re-run over the same data → no flips.
  • Visible behavior change: findings created by one reimport get their ids in content-key order rather than report order, so id order no longer reflects the scanner's document order for reimported tests.

Tests

  • End-to-end Fortify fixtures: two .fpr files whose findings share a unique_id_from_tool but differ in content, in opposite document order, each reimported through the real DefaultReImporter; the surviving original must be identical across both orders (fails pre-fix). Also asserts the reimporter creates findings in deduplication_ordering_key order.
  • Batch dedupe keeps the lowest id as original, and re-running dedupe (management-command scenario) never flips an established original.
  • _is_candidate_older antisymmetry regression test.
  • Pre-existing-finding-stays-original guard and ordering-key order-independence test.
  • Note for reviewers: the Fortify test forces unique_id_from_tool dedup via override_settings (open source defaults Fortify to legacy, where different-content findings would not collide at all). This mirrors the realistic customer configuration.

Related

Pairs with the corresponding Dojo Pro change (test-only after this rework — Pro inherits the reimporter creation ordering). They should merge together.

🤖 Generated with Claude Code

Maffooch and others added 2 commits July 10, 2026 13:43
…ring

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) <noreply@anthropic.com>
…ce 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) <noreply@anthropic.com>
@Maffooch
Maffooch requested a review from mtesauro as a code owner July 10, 2026 23:57
…edupe 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 <noreply@anthropic.com>
@Maffooch
Maffooch requested a review from blakeaowens as a code owner July 20, 2026 18:40
@Maffooch Maffooch changed the title fix(dedupe): stable content-based tiebreaker for import/reimport ordering fix(dedupe): stable original across scan orders via content-ordered finding creation Jul 20, 2026
Maffooch and others added 2 commits July 20, 2026 13:12
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>
…ards

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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants