From bfe43b246d16a621585dbd810b555d38f56501fb Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sat, 4 Jul 2026 16:13:53 +0200 Subject: [PATCH] feat(finding): pluggable false-positive-history candidate filter Add FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD hook to do_false_positive_history_batch. When configured, the per-finding candidate list resolved by the deduplication algorithm is passed through the plugin method before the FP decision, letting a plugin (Pro) narrow candidates by fields excluded from the hash string but compared per pair (set-match tokens on vulnerability_ids / CWEs). Resolved once via get_custom_method; a no-op when unset, so default behavior is unchanged. Tests: hook can suppress matches (drops candidates -> no FP replication) and passthrough matches the default path. --- dojo/finding/deduplication.py | 10 ++++ .../test_false_positive_history_logic.py | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index dfb7747ddca..36d2a4a402e 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -1098,6 +1098,13 @@ def do_false_positive_history_batch(findings): # Fetch all candidate existing findings with one DB query candidates = _fetch_fp_candidates_for_batch(findings, product, dedup_alg) + # Optional plugin hook: refine the per-finding candidate list after it is resolved by + # deduplication_algorithm. Lets a plugin (e.g. Pro) narrow candidates by fields that are + # excluded from the hash string but compared per pair (set-match tokens on + # vulnerability_ids / CWEs). Resolved once; a no-op when unset. See get_custom_method. + from dojo.utils import get_custom_method # noqa: PLC0415 -- circular import + fp_candidate_filter = get_custom_method("FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD") + to_mark_as_fp_ids: set = set() for finding in findings: @@ -1121,6 +1128,9 @@ def do_false_positive_history_batch(findings): else: existing = [] + if fp_candidate_filter: + existing = fp_candidate_filter(finding, existing) + existing_fps = [ef for ef in existing if ef.false_p] if existing_fps: diff --git a/unittests/test_false_positive_history_logic.py b/unittests/test_false_positive_history_logic.py index 5975348e14e..bbe1beb3585 100644 --- a/unittests/test_false_positive_history_logic.py +++ b/unittests/test_false_positive_history_logic.py @@ -4,6 +4,7 @@ from crum import impersonate from django.conf import settings +from django.test import override_settings from dojo.finding.deduplication import do_false_positive_history_batch from dojo.finding.ui.views import EditFinding @@ -125,6 +126,24 @@ # product 3: Security Podcast +# Module-level candidate-filter hooks for FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD. +# get_custom_method resolves the setting to a dotted path and imports it, so these must be +# importable at module scope. +_fp_candidate_filter_calls = [] + + +def _drop_all_fp_candidates(finding, candidates): + """Hook that discards every candidate — simulates a plugin rejecting all matches.""" + _fp_candidate_filter_calls.append((finding, list(candidates))) + return [] + + +def _passthrough_fp_candidates(finding, candidates): + """Hook that keeps every candidate — FP history must behave exactly as with no hook.""" + _fp_candidate_filter_calls.append((finding, list(candidates))) + return candidates + + @versioned_fixtures class TestFalsePositiveHistoryLogic(DojoTestCase): fixtures = ["dojo_testdata.json"] @@ -175,6 +194,46 @@ def test_fp_history_equal_hash_code_same_test(self): self.assert_finding(find_created_before_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) self.assert_finding(find_created_after_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) + # Candidate-filter hook (FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD) # + + @override_settings( + FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._drop_all_fp_candidates", + ) + def test_fp_history_hook_can_suppress_matches(self): + # A plugin hook that drops all candidates must prevent FP replication even when + # findings share a hash_code with an existing false-positive finding. + _fp_candidate_filter_calls.clear() + find_created_before_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_before_mark.save() + find_2 = Finding.objects.get(id=2) + find_2.false_p = True + find_2.save() + find_created_after_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_after_mark.save() + # Hook discarded every candidate, so neither copy is marked despite the shared hash_code. + self.assert_finding(find_created_before_mark, false_p=False, not_pk=2, test_id=3, hash_code=find_2.hash_code) + self.assert_finding(find_created_after_mark, false_p=False, not_pk=2, test_id=3, hash_code=find_2.hash_code) + # And the hook was actually invoked. + self.assertTrue(_fp_candidate_filter_calls) + + @override_settings( + FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._passthrough_fp_candidates", + ) + def test_fp_history_hook_passthrough_matches_default(self): + # A passthrough hook must leave default FP-history behavior unchanged. + _fp_candidate_filter_calls.clear() + find_created_before_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_before_mark.save() + find_2 = Finding.objects.get(id=2) + find_2.false_p = True + find_2.save() + find_created_after_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_after_mark.save() + # Identical outcome to test_fp_history_equal_hash_code_same_test — both copies marked. + self.assert_finding(find_created_before_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) + self.assert_finding(find_created_after_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) + self.assertTrue(_fp_candidate_filter_calls) + # Finding 2 in Product 2, Engagement 1, Test 3 def test_fp_history_equal_hash_code_same_test_non_retroactive(self): # Disable retroactive FP history