Skip to content

Commit ff473c6

Browse files
Maffoochclaude
andcommitted
feat(vuln-id): backfill + reconciliation management commands
- migrate_vulnerability_ids: wraps dojo.vulnerability_id.backfill.run_backfill (the same routine 0286 runs); --window-size; PostgreSQL-guarded; prints per-step row counts; idempotent/resumable so big tenants pre-run it before the deploy. - verify_vulnerability_ids: read-only reconciliation, nonzero exit on any drift so it can gate a ring flip. Per sampled finding (--sample, default 1000): every legacy (finding, id) pair has a reference; extra references allowed only when cve-only-derived (string == finding.cve); the order-0 reference == cve. Plus legacy-row / entity / reference / orphan-entity counts, both table names printed with roles. --json for machine output. Tests (fixture-free): migrate backfills legacy-only findings to entities/refs with order 0 == cve and is a no-op on re-run; verify passes on consistent data and raises SystemExit on a legacy row with no matching reference. Verified on the dev DB: migrate_vulnerability_ids runs clean and idempotent; verify_vulnerability_ids exits 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2976c43 commit ff473c6

3 files changed

Lines changed: 198 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import logging
2+
3+
from django.core.management.base import BaseCommand
4+
from django.db import connection
5+
6+
from dojo.vulnerability_id.backfill import DEFAULT_WINDOW_SIZE, run_backfill
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
class Command(BaseCommand):
12+
13+
"""
14+
Backfill the VulnerabilityId entity + FindingVulnerabilityReference tables from the legacy
15+
dojo_vulnerability_id table and dojo_finding.cve.
16+
17+
Idempotent and resumable (insert-only + ON CONFLICT DO NOTHING), so it is safe to run
18+
repeatedly: large tenants can pre-run it on the previous release, then it is an instant
19+
catch-up during the deploy-time migration. Shares dojo.vulnerability_id.backfill.run_backfill
20+
with migration 0286.
21+
"""
22+
23+
help = "Idempotently backfill the vulnerability-id entity + reference tables (PostgreSQL only)."
24+
25+
def add_arguments(self, parser):
26+
parser.add_argument(
27+
"--window-size",
28+
type=int,
29+
default=DEFAULT_WINDOW_SIZE,
30+
help=f"finding_id window size for the reference build (default {DEFAULT_WINDOW_SIZE}).",
31+
)
32+
33+
def handle(self, *args, **options):
34+
if connection.vendor != "postgresql":
35+
self.stderr.write(f"Vulnerability-id backfill is PostgreSQL only; nothing to do on {connection.vendor}.")
36+
return
37+
counts = run_backfill(logger=logger, window_size=options["window_size"])
38+
for step, count in counts.items():
39+
self.stdout.write(f"{step}: {count}")
40+
self.stdout.write(self.style.SUCCESS("Vulnerability ID backfill complete."))
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import json
2+
import logging
3+
4+
from django.core.management.base import BaseCommand
5+
from django.db.models import Q
6+
7+
from dojo.models import Finding, FindingVulnerabilityReference, Vulnerability_Id, VulnerabilityId
8+
9+
logger = logging.getLogger(__name__)
10+
11+
# Both stores, with their roles — printed so operators know exactly what is being compared.
12+
LEGACY_TABLE = "dojo_vulnerability_id" # legacy per-finding rows (authoritative for writes)
13+
ENTITY_TABLE = "dojo_vulnerabilityid" # normalized VulnerabilityId registry
14+
REFERENCE_TABLE = "dojo_findingvulnerabilityreference" # ordered Finding -> VulnerabilityId links
15+
16+
17+
class Command(BaseCommand):
18+
19+
"""
20+
Read-only reconciliation of the legacy vulnerability-id store against the entity/reference
21+
store. Exits nonzero on any discrepancy, so it can gate a ring flip.
22+
23+
Checks per sampled finding: (1) every legacy (finding, id) pair has a matching reference;
24+
extra references are tolerated ONLY when they are cve-only-derived (string == finding.cve);
25+
(2) the order-0 reference string equals finding.cve wherever cve is set. Plus row / entity /
26+
reference / orphan-entity counts.
27+
"""
28+
29+
help = "Verify the legacy vulnerability-id store matches the entity/reference store (nonzero exit on drift)."
30+
31+
def add_arguments(self, parser):
32+
parser.add_argument(
33+
"--sample",
34+
type=int,
35+
default=1000,
36+
help="Number of findings to spot-check for pair parity / order-0 (default 1000).",
37+
)
38+
parser.add_argument("--json", action="store_true", dest="as_json", help="Emit the report as JSON.")
39+
40+
def handle(self, *args, **options):
41+
sample = options["sample"]
42+
discrepancies = []
43+
44+
counts = {
45+
f"legacy_rows ({LEGACY_TABLE})": Vulnerability_Id.objects.count(),
46+
f"entities ({ENTITY_TABLE})": VulnerabilityId.objects.count(),
47+
f"references ({REFERENCE_TABLE})": FindingVulnerabilityReference.objects.count(),
48+
"orphan_entities": VulnerabilityId.objects.filter(finding_references__isnull=True).count(),
49+
}
50+
51+
sampled = (
52+
Finding.objects
53+
.filter(
54+
# Reverse relation query names: legacy FK -> "vulnerability_id", entity refs ->
55+
# "vulnerability_references" (the "_set" form is the attribute accessor, not a lookup).
56+
Q(vulnerability_id__isnull=False)
57+
| Q(vulnerability_references__isnull=False)
58+
| (Q(cve__isnull=False) & ~Q(cve="")),
59+
)
60+
.distinct()
61+
.order_by("id")
62+
.prefetch_related("vulnerability_id_set", "vulnerability_references__vulnerability")[:sample]
63+
)
64+
65+
checked = 0
66+
for finding in sampled:
67+
checked += 1
68+
legacy_strings = {row.vulnerability_id for row in finding.vulnerability_id_set.all()}
69+
references = list(finding.vulnerability_references.all())
70+
reference_strings = {ref.vulnerability.vulnerability_id for ref in references}
71+
72+
missing = legacy_strings - reference_strings
73+
if missing:
74+
discrepancies.append(f"finding {finding.id}: legacy ids missing a reference: {sorted(missing)}")
75+
76+
extras = reference_strings - legacy_strings
77+
unexpected = {value for value in extras if value != finding.cve}
78+
if unexpected:
79+
discrepancies.append(f"finding {finding.id}: references with no legacy row (not cve-derived): {sorted(unexpected)}")
80+
81+
if finding.cve:
82+
primary = next((ref for ref in references if ref.order == 0), None)
83+
primary_string = primary.vulnerability.vulnerability_id if primary else None
84+
if primary_string != finding.cve:
85+
discrepancies.append(
86+
f"finding {finding.id}: order-0 reference {primary_string!r} != cve {finding.cve!r}",
87+
)
88+
89+
report = {
90+
"counts": counts,
91+
"findings_checked": checked,
92+
"discrepancies": discrepancies,
93+
"ok": not discrepancies,
94+
}
95+
96+
if options["as_json"]:
97+
self.stdout.write(json.dumps(report, indent=2))
98+
else:
99+
self.stdout.write("Vulnerability ID store reconciliation")
100+
for label, value in counts.items():
101+
self.stdout.write(f" {label}: {value}")
102+
self.stdout.write(f" findings checked: {checked}")
103+
if discrepancies:
104+
self.stdout.write(self.style.ERROR(f" {len(discrepancies)} discrepancies:"))
105+
for line in discrepancies:
106+
self.stdout.write(self.style.ERROR(f" - {line}"))
107+
else:
108+
self.stdout.write(self.style.SUCCESS(" no discrepancies"))
109+
110+
if discrepancies:
111+
# Nonzero exit for CI / ring-flip gating.
112+
msg = f"{len(discrepancies)} vulnerability-id reconciliation discrepancies"
113+
raise SystemExit(msg)

unittests/test_vulnerability_id.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
entity rows (orphans are retained).
99
"""
1010
from django.contrib.auth import get_user_model
11+
from django.core.management import call_command
1112
from django.test import TestCase, override_settings
1213
from django.utils.timezone import now
1314

@@ -188,3 +189,47 @@ def test_filter_seam_parity(self):
188189
set(finding_ids_with_vulnerability_ids("CVE-P-1", lookup="exact")),
189190
{self.finding.pk},
190191
)
192+
193+
194+
class TestVulnerabilityIdCommands(VulnerabilityIdDualWriteTestCase):
195+
196+
"""
197+
migrate_vulnerability_ids backfills entities/references from legacy rows (idempotently);
198+
verify_vulnerability_ids passes when the stores agree and exits nonzero on drift.
199+
"""
200+
201+
def test_migrate_command_backfills_idempotently(self):
202+
finding = self._make_finding("cmd-migrate")
203+
finding.cve = "CVE-CMD-1"
204+
finding.save()
205+
# Legacy rows WITHOUT entity references (bypassing the dual-write seam, i.e. pre-migration).
206+
Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-CMD-1")
207+
Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-CMD-2")
208+
self.assertEqual(FindingVulnerabilityReference.objects.filter(finding=finding).count(), 0)
209+
210+
call_command("migrate_vulnerability_ids")
211+
212+
# cve-match lands at order 0.
213+
self.assertEqual(self._refs(finding), {0: "CVE-CMD-1", 1: "CVE-CMD-2"})
214+
self.assertTrue(VulnerabilityId.objects.filter(vulnerability_id="CVE-CMD-1").exists())
215+
216+
# Idempotent re-run: no change.
217+
call_command("migrate_vulnerability_ids")
218+
self.assertEqual(self._refs(finding), {0: "CVE-CMD-1", 1: "CVE-CMD-2"})
219+
self.assertEqual(FindingVulnerabilityReference.objects.filter(finding=finding).count(), 2)
220+
221+
def test_verify_command_passes_when_consistent(self):
222+
finding = self._make_finding("cmd-verify-ok")
223+
save_vulnerability_ids(finding, ["CVE-V-1", "CVE-V-2"])
224+
finding.save()
225+
# Consistent stores => no SystemExit.
226+
call_command("verify_vulnerability_ids")
227+
228+
def test_verify_command_fails_on_discrepancy(self):
229+
finding = self._make_finding("cmd-verify-bad")
230+
finding.cve = "CVE-BAD-1"
231+
finding.save()
232+
# Legacy row with no matching entity reference => a discrepancy.
233+
Vulnerability_Id.objects.create(finding=finding, vulnerability_id="CVE-BAD-1")
234+
with self.assertRaises(SystemExit):
235+
call_command("verify_vulnerability_ids")

0 commit comments

Comments
 (0)