Skip to content
Draft
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
10 changes: 10 additions & 0 deletions dojo/finding/deduplication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
59 changes: 59 additions & 0 deletions unittests/test_false_positive_history_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand Down
Loading