Skip to content

Commit cbd1acb

Browse files
perf(reimporter): batch vulnerability_id reconciliation
Extend the cross-finding accumulation pattern to DefaultReImporter: - reconcile_vulnerability_ids now accumulates changed findings into pending_vuln_id_deletes / pending_vulnerability_ids instead of issuing per-finding DELETE + INSERT immediately - flush_vulnerability_ids (BaseImporter) runs one bulk DELETE WHERE finding_id IN (...) followed by one bulk_create for all new IDs - flush called at both dedupe batch boundaries (alongside location_handler.persist()) and after the mitigation loop Early-exit path (unchanged IDs) never touches either buffer, so the common case pays zero extra cost. Add two unit tests: cross-finding batch (3 findings, 2 changed + 1 unchanged, verify buffer contents before flush and DB state after) and unchanged-IDs early-exit (verify buffers stay empty).
1 parent a13dff2 commit cbd1acb

3 files changed

Lines changed: 116 additions & 17 deletions

File tree

dojo/importers/base_importer.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def __init__(
7979
"""
8080
ImporterOptions.__init__(self, *args, **kwargs)
8181
self.pending_vulnerability_ids: list[Vulnerability_Id] = []
82+
self.pending_vuln_id_deletes: list[int] = []
8283

8384
def check_child_implementation_exception(self):
8485
"""
@@ -797,7 +798,10 @@ def store_vulnerability_ids(
797798
return finding
798799

799800
def flush_vulnerability_ids(self) -> None:
800-
"""Bulk-insert all accumulated Vulnerability_Id objects and clear the buffer."""
801+
"""Delete stale and bulk-insert accumulated Vulnerability_Id objects, then clear buffers."""
802+
if self.pending_vuln_id_deletes:
803+
Vulnerability_Id.objects.filter(finding_id__in=self.pending_vuln_id_deletes).delete()
804+
self.pending_vuln_id_deletes.clear()
801805
if self.pending_vulnerability_ids:
802806
Vulnerability_Id.objects.bulk_create(self.pending_vulnerability_ids, batch_size=1000)
803807
self.pending_vulnerability_ids.clear()

dojo/importers/default_reimporter.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Notes,
2424
Test,
2525
Test_Import,
26+
Vulnerability_Id,
2627
)
2728
from dojo.tags import inheritance as tag_inheritance
2829
from dojo.tags.inheritance import apply_inherited_tags_for_findings
@@ -438,6 +439,7 @@ def _process_findings_internal(
438439
# They don't need to be aligned since they optimize different operations.
439440
if len(batch_finding_ids) >= dedupe_batch_max_size or is_final:
440441
self.location_handler.persist()
442+
self.flush_vulnerability_ids()
441443
# Apply parser-supplied tags for this batch before post-processing starts,
442444
# so rules/deduplication tasks see the tags already on the findings.
443445
bulk_apply_parser_tags(findings_with_parser_tags)
@@ -561,6 +563,7 @@ def close_old_findings(
561563
mitigated_findings.append(finding)
562564
# Persist any accumulated location/endpoint status changes
563565
self.location_handler.persist()
566+
self.flush_vulnerability_ids()
564567
# push finding groups to jira since we only only want to push whole groups
565568
# We dont check if the finding jira sync is applicable quite yet until we can get in the loop
566569
# but this is a way to at least make it that far
@@ -944,33 +947,34 @@ def reconcile_vulnerability_ids(
944947
) -> Finding:
945948
"""
946949
Reconcile vulnerability IDs for an existing finding.
947-
Checks if IDs have changed before updating to avoid unnecessary database operations.
948-
Uses prefetched data if available, otherwise fetches efficiently.
949-
950-
Args:
951-
finding: The existing finding to reconcile vulnerability IDs for.
952-
Must have unsaved_vulnerability_ids set.
953-
954-
Returns:
955-
The finding object
956-
950+
Accumulates changes into pending_vuln_id_deletes / pending_vulnerability_ids
951+
for batch flush at the batch boundary via flush_vulnerability_ids().
957952
"""
958-
vulnerability_ids_to_process = finding.unsaved_vulnerability_ids or []
953+
vulnerability_ids_to_process = list(dict.fromkeys(finding.unsaved_vulnerability_ids or []))
954+
vulnerability_ids_to_process = [x for x in vulnerability_ids_to_process if x.strip()]
959955

960956
# Use prefetched data directly without triggering queries
961957
existing_vuln_ids = {v.vulnerability_id for v in finding.vulnerability_id_set.all()}
962958
new_vuln_ids = set(vulnerability_ids_to_process)
963959

964-
# Early exit if unchanged
960+
# Early exit if unchanged — no DB work needed
965961
if existing_vuln_ids == new_vuln_ids:
966962
logger.debug(
967963
f"Skipping vulnerability_ids update for finding {finding.id} - "
968964
f"vulnerability_ids unchanged: {sorted(existing_vuln_ids)}",
969965
)
970966
return finding
971967

972-
# Update if changed
973-
finding_helper.save_vulnerability_ids(finding, vulnerability_ids_to_process, delete_existing=True)
968+
# Accumulate delete + insert for batch flush
969+
self.pending_vuln_id_deletes.append(finding.id)
970+
self.pending_vulnerability_ids.extend([
971+
Vulnerability_Id(finding=finding, vulnerability_id=vid)
972+
for vid in vulnerability_ids_to_process
973+
])
974+
if vulnerability_ids_to_process:
975+
finding.cve = vulnerability_ids_to_process[0]
976+
else:
977+
finding.cve = None
974978
return finding
975979

976980
def finding_post_processing(

unittests/test_importers_importer.py

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,9 @@ def test_clear_vulnerability_ids_on_empty_list(self):
887887

888888
# Process with empty list - should clear all IDs
889889
finding.unsaved_vulnerability_ids = []
890-
DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"]).reconcile_vulnerability_ids(finding)
890+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
891+
reimporter.reconcile_vulnerability_ids(finding)
892+
reimporter.flush_vulnerability_ids()
891893
# Save the finding to persist the cve=None change
892894
finding.save()
893895

@@ -924,7 +926,9 @@ def test_change_vulnerability_ids_on_reimport(self):
924926
# Process with different IDs - should replace old IDs
925927
new_vulnerability_ids = ["CVE-2021-9999", "GHSA-xxxx-yyyy"]
926928
finding.unsaved_vulnerability_ids = new_vulnerability_ids
927-
DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"]).reconcile_vulnerability_ids(finding)
929+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
930+
reimporter.reconcile_vulnerability_ids(finding)
931+
reimporter.flush_vulnerability_ids()
928932
# Save the finding to persist the cve change
929933
finding.save()
930934

@@ -940,3 +944,90 @@ def test_change_vulnerability_ids_on_reimport(self):
940944
vuln_ids = list(Vulnerability_Id.objects.filter(finding=finding).values_list("vulnerability_id", flat=True))
941945
self.assertEqual(set(new_vulnerability_ids), set(vuln_ids))
942946
finding.delete()
947+
948+
def test_reconcile_vulnerability_ids_cross_finding_batch(self):
949+
"""Multiple findings accumulated before flush — one delete+insert pair per changed finding."""
950+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
951+
952+
# finding_a: IDs change (CVE-A → CVE-B)
953+
finding_a = Finding(test=self.test, reporter=self.testuser)
954+
finding_a.save()
955+
Vulnerability_Id.objects.create(finding=finding_a, vulnerability_id="CVE-A-OLD")
956+
finding_a.cve = "CVE-A-OLD"
957+
finding_a.save()
958+
959+
# finding_b: IDs change (CVE-B1, CVE-B2 → CVE-B-NEW)
960+
finding_b = Finding(test=self.test, reporter=self.testuser)
961+
finding_b.save()
962+
Vulnerability_Id.objects.create(finding=finding_b, vulnerability_id="CVE-B1")
963+
Vulnerability_Id.objects.create(finding=finding_b, vulnerability_id="CVE-B2")
964+
finding_b.cve = "CVE-B1"
965+
finding_b.save()
966+
967+
# finding_c: IDs unchanged — should not appear in delete/insert buffers
968+
finding_c = Finding(test=self.test, reporter=self.testuser)
969+
finding_c.save()
970+
Vulnerability_Id.objects.create(finding=finding_c, vulnerability_id="CVE-C-SAME")
971+
finding_c.cve = "CVE-C-SAME"
972+
finding_c.save()
973+
974+
finding_a.unsaved_vulnerability_ids = ["CVE-A-NEW"]
975+
finding_b.unsaved_vulnerability_ids = ["CVE-B-NEW"]
976+
finding_c.unsaved_vulnerability_ids = ["CVE-C-SAME"]
977+
978+
# Accumulate all three before any flush
979+
reimporter.reconcile_vulnerability_ids(finding_a)
980+
reimporter.reconcile_vulnerability_ids(finding_b)
981+
reimporter.reconcile_vulnerability_ids(finding_c)
982+
983+
# pending_vuln_id_deletes only contains changed findings, not finding_c
984+
self.assertIn(finding_a.id, reimporter.pending_vuln_id_deletes)
985+
self.assertIn(finding_b.id, reimporter.pending_vuln_id_deletes)
986+
self.assertNotIn(finding_c.id, reimporter.pending_vuln_id_deletes)
987+
self.assertEqual(2, len(reimporter.pending_vulnerability_ids))
988+
989+
# Old IDs still in DB (not yet deleted)
990+
self.assertEqual(1, Vulnerability_Id.objects.filter(finding=finding_a).count())
991+
self.assertEqual(2, Vulnerability_Id.objects.filter(finding=finding_b).count())
992+
993+
reimporter.flush_vulnerability_ids()
994+
995+
# Buffers cleared
996+
self.assertEqual([], reimporter.pending_vuln_id_deletes)
997+
self.assertEqual([], reimporter.pending_vulnerability_ids)
998+
999+
# finding_a: old deleted, new inserted
1000+
vuln_ids_a = list(Vulnerability_Id.objects.filter(finding=finding_a).values_list("vulnerability_id", flat=True))
1001+
self.assertEqual(["CVE-A-NEW"], vuln_ids_a)
1002+
self.assertEqual("CVE-A-NEW", finding_a.cve)
1003+
1004+
# finding_b: both old deleted, new inserted
1005+
vuln_ids_b = list(Vulnerability_Id.objects.filter(finding=finding_b).values_list("vulnerability_id", flat=True))
1006+
self.assertEqual(["CVE-B-NEW"], vuln_ids_b)
1007+
self.assertEqual("CVE-B-NEW", finding_b.cve)
1008+
1009+
# finding_c: unchanged — IDs untouched
1010+
vuln_ids_c = list(Vulnerability_Id.objects.filter(finding=finding_c).values_list("vulnerability_id", flat=True))
1011+
self.assertEqual(["CVE-C-SAME"], vuln_ids_c)
1012+
1013+
finding_a.delete()
1014+
finding_b.delete()
1015+
finding_c.delete()
1016+
1017+
def test_reconcile_vulnerability_ids_unchanged_no_db_write(self):
1018+
"""Early-exit path: unchanged IDs never touch pending buffers."""
1019+
reimporter = DefaultReImporter(test=self.test, environment=self.importer_data["environment"], scan_type=self.importer_data["scan_type"])
1020+
1021+
finding = Finding(test=self.test, reporter=self.testuser)
1022+
finding.save()
1023+
Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-2020-1234")
1024+
finding.cve = "CVE-2020-1234"
1025+
finding.save()
1026+
1027+
finding.unsaved_vulnerability_ids = ["CVE-2020-1234"]
1028+
reimporter.reconcile_vulnerability_ids(finding)
1029+
1030+
self.assertEqual([], reimporter.pending_vuln_id_deletes)
1031+
self.assertEqual([], reimporter.pending_vulnerability_ids)
1032+
1033+
finding.delete()

0 commit comments

Comments
 (0)