fix(dedupe): stable original across scan orders via content-ordered finding creation#15222
Open
Maffooch wants to merge 6 commits into
Open
fix(dedupe): stable original across scan orders via content-ordered finding creation#15222Maffooch wants to merge 6 commits into
Maffooch wants to merge 6 commits into
Conversation
…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>
…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>
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>
blakeaowens
approved these changes
Jul 21, 2026
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
[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:
deduplication_ordering_key()— a stable key derived from finding content (hash_code, unique_id_from_tool, file_path, line, title, thenidas final tiebreak).DefaultReImporternow 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".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: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.dedupemanagement 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
manage.py dedupere-run over the same data → no flips.Tests
.fprfiles whose findings share aunique_id_from_toolbut differ in content, in opposite document order, each reimported through the realDefaultReImporter; the surviving original must be identical across both orders (fails pre-fix). Also asserts the reimporter creates findings indeduplication_ordering_keyorder._is_candidate_olderantisymmetry regression test.unique_id_from_tooldedup viaoverride_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