Skip to content

Commit 55ca4f8

Browse files
feat(finding): pluggable false-positive-history candidate filter (#15238)
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. Co-authored-by: Valentijn Scholten <valentijnscholten@gmail.com>
1 parent 75590a9 commit 55ca4f8

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

dojo/finding/deduplication.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,13 @@ def do_false_positive_history_batch(findings):
10981098
# Fetch all candidate existing findings with one DB query
10991099
candidates = _fetch_fp_candidates_for_batch(findings, product, dedup_alg)
11001100

1101+
# Optional plugin hook: refine the per-finding candidate list after it is resolved by
1102+
# deduplication_algorithm. Lets a plugin (e.g. Pro) narrow candidates by fields that are
1103+
# excluded from the hash string but compared per pair (set-match tokens on
1104+
# vulnerability_ids / CWEs). Resolved once; a no-op when unset. See get_custom_method.
1105+
from dojo.utils import get_custom_method # noqa: PLC0415 -- circular import
1106+
fp_candidate_filter = get_custom_method("FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD")
1107+
11011108
to_mark_as_fp_ids: set = set()
11021109

11031110
for finding in findings:
@@ -1121,6 +1128,9 @@ def do_false_positive_history_batch(findings):
11211128
else:
11221129
existing = []
11231130

1131+
if fp_candidate_filter:
1132+
existing = fp_candidate_filter(finding, existing)
1133+
11241134
existing_fps = [ef for ef in existing if ef.false_p]
11251135

11261136
if existing_fps:

unittests/test_false_positive_history_logic.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,24 @@
126126
# product 3: Security Podcast
127127

128128

129+
# Module-level candidate-filter hooks for FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD.
130+
# get_custom_method resolves the setting to a dotted path and imports it, so these must be
131+
# importable at module scope.
132+
_fp_candidate_filter_calls = []
133+
134+
135+
def _drop_all_fp_candidates(finding, candidates):
136+
"""Hook that discards every candidate — simulates a plugin rejecting all matches."""
137+
_fp_candidate_filter_calls.append((finding, list(candidates)))
138+
return []
139+
140+
141+
def _passthrough_fp_candidates(finding, candidates):
142+
"""Hook that keeps every candidate — FP history must behave exactly as with no hook."""
143+
_fp_candidate_filter_calls.append((finding, list(candidates)))
144+
return candidates
145+
146+
129147
@versioned_fixtures
130148
@override_settings(SETTINGS_CACHE_L1_TTL=30, SETTINGS_CACHE_L2_TTL=-1)
131149
class TestFalsePositiveHistoryLogic(DojoTestCase):
@@ -177,6 +195,46 @@ def test_fp_history_equal_hash_code_same_test(self):
177195
self.assert_finding(find_created_before_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code)
178196
self.assert_finding(find_created_after_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code)
179197

198+
# Candidate-filter hook (FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD) #
199+
200+
@override_settings(
201+
FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._drop_all_fp_candidates",
202+
)
203+
def test_fp_history_hook_can_suppress_matches(self):
204+
# A plugin hook that drops all candidates must prevent FP replication even when
205+
# findings share a hash_code with an existing false-positive finding.
206+
_fp_candidate_filter_calls.clear()
207+
find_created_before_mark, find_2 = self.copy_and_reset_finding(find_id=2)
208+
find_created_before_mark.save()
209+
find_2 = Finding.objects.get(id=2)
210+
find_2.false_p = True
211+
find_2.save()
212+
find_created_after_mark, find_2 = self.copy_and_reset_finding(find_id=2)
213+
find_created_after_mark.save()
214+
# Hook discarded every candidate, so neither copy is marked despite the shared hash_code.
215+
self.assert_finding(find_created_before_mark, false_p=False, not_pk=2, test_id=3, hash_code=find_2.hash_code)
216+
self.assert_finding(find_created_after_mark, false_p=False, not_pk=2, test_id=3, hash_code=find_2.hash_code)
217+
# And the hook was actually invoked.
218+
self.assertTrue(_fp_candidate_filter_calls)
219+
220+
@override_settings(
221+
FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._passthrough_fp_candidates",
222+
)
223+
def test_fp_history_hook_passthrough_matches_default(self):
224+
# A passthrough hook must leave default FP-history behavior unchanged.
225+
_fp_candidate_filter_calls.clear()
226+
find_created_before_mark, find_2 = self.copy_and_reset_finding(find_id=2)
227+
find_created_before_mark.save()
228+
find_2 = Finding.objects.get(id=2)
229+
find_2.false_p = True
230+
find_2.save()
231+
find_created_after_mark, find_2 = self.copy_and_reset_finding(find_id=2)
232+
find_created_after_mark.save()
233+
# Identical outcome to test_fp_history_equal_hash_code_same_test — both copies marked.
234+
self.assert_finding(find_created_before_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code)
235+
self.assert_finding(find_created_after_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code)
236+
self.assertTrue(_fp_candidate_filter_calls)
237+
180238
# Finding 2 in Product 2, Engagement 1, Test 3
181239
def test_fp_history_equal_hash_code_same_test_non_retroactive(self):
182240
# Disable retroactive FP history

0 commit comments

Comments
 (0)