Skip to content

Commit c395a2c

Browse files
valentijnscholtenMaffooch
authored andcommitted
feat(finding): autodetected vulnerability_id type + uniqueness constraint
Adds Vulnerability_Id.vulnerability_id_type, autodetected from the id's leading prefix (CVE-2024-1234 -> CVE, GHSA-... -> GHSA), stored and indexed so identifiers can be filtered/grouped by type. Populated on import (bulk) and on save(); existing rows backfilled by migration. Also de-duplicates (finding, vulnerability_id) rows and adds a unique constraint on the pair. CWE is a weakness class and is intentionally NOT part of this change; vulnerability_id_type does not participate in hash_code, so existing hash codes and deduplication are unaffected. Migrations: 0276 (type column + lookup index), 0277 (dedupe + backfill, data), 0278 (unique constraint).
1 parent ad76b1b commit c395a2c

10 files changed

Lines changed: 222 additions & 3 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
title: 'Upgrading to DefectDojo Version 3.2.x'
3+
toc_hide: true
4+
weight: -20260701
5+
description: Vulnerability ids gain an autodetected type. A migration adds the type column, de-duplicates vulnerability-id rows, and adds a uniqueness constraint. Existing hash codes are unaffected.
6+
---
7+
8+
## Vulnerability id type
9+
10+
Each `Vulnerability_Id` gains an autodetected `vulnerability_id_type` — the identifier's leading
11+
prefix (`CVE-2024-1234``CVE`, `GHSA-…``GHSA`, `RUSTSEC-…``RUSTSEC`). It is derived
12+
structurally (no registry) and stored (indexed) so identifiers can be filtered and grouped by type
13+
efficiently. It is `NULL` when there is no non-numeric prefix.
14+
15+
The type is populated automatically: on import (bulk paths) and on `save()`. Existing rows are
16+
backfilled by the migration below. This is a denormalized, derived attribute — it does not
17+
participate in `hash_code`, so **existing hash codes and deduplication are unaffected**.
18+
19+
## Database migration
20+
21+
Three migrations run automatically on upgrade:
22+
23+
- `0276_vulnerability_id_type` — adds the indexed `vulnerability_id_type` column and a leading
24+
index on `vulnerability_id`.
25+
- `0277_backfill_vulnerability_id_type` — backfills `vulnerability_id_type` for existing rows and
26+
removes duplicate `(finding, vulnerability_id)` rows, keeping the earliest of each — such
27+
duplicates are unintended, and consolidating them allows a uniqueness constraint to be added.
28+
- `0278_unique_finding_vulnerability_id` — adds a unique constraint on `(finding, vulnerability_id)`.
29+
30+
### What you need to do
31+
32+
The migrations are applied automatically. No manual steps are required.
33+
34+
For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.2.0).
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
"""Schema only: add the autodetected vulnerability_id_type column and a leading index on
7+
vulnerability_id. The data backfill (0277) and the unique constraint (0278, which requires
8+
the dedupe data step first) are kept in separate migrations so data migrations are never
9+
mixed with schema migrations."""
10+
11+
dependencies = [
12+
("dojo", "0275_usercontactinfo_user_state_details"),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="vulnerability_id",
18+
name="vulnerability_id_type",
19+
field=models.CharField(blank=True, db_index=True, editable=False, max_length=20, null=True),
20+
),
21+
migrations.AddIndex(
22+
model_name="vulnerability_id",
23+
index=models.Index(fields=["vulnerability_id"], name="dojo_vuln_id_lookup_idx"),
24+
),
25+
]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from django.db import migrations
2+
3+
from dojo.finding.vulnerability_id import resolve_vulnerability_id_type
4+
5+
BATCH_SIZE = 1000
6+
7+
8+
def dedupe_vulnerability_ids(apps, schema_editor):
9+
"""Remove duplicate (finding, vulnerability_id) rows, keeping the lowest id, so the unique
10+
constraint added in the following schema migration (0278) can be created. Postgres."""
11+
schema_editor.execute(
12+
"""
13+
DELETE FROM dojo_vulnerability_id a
14+
USING dojo_vulnerability_id b
15+
WHERE a.finding_id = b.finding_id
16+
AND a.vulnerability_id = b.vulnerability_id
17+
AND a.id > b.id
18+
""",
19+
)
20+
21+
22+
def backfill_vulnerability_id_type(apps, schema_editor):
23+
"""Populate vulnerability_id_type for existing rows by autodetecting the id prefix."""
24+
Vulnerability_Id = apps.get_model("dojo", "Vulnerability_Id")
25+
batch = []
26+
qs = Vulnerability_Id.objects.filter(vulnerability_id_type__isnull=True).only("id", "vulnerability_id")
27+
for row in qs.iterator(chunk_size=BATCH_SIZE):
28+
row.vulnerability_id_type = resolve_vulnerability_id_type(row.vulnerability_id)
29+
batch.append(row)
30+
if len(batch) >= BATCH_SIZE:
31+
Vulnerability_Id.objects.bulk_update(batch, ["vulnerability_id_type"], batch_size=BATCH_SIZE)
32+
batch = []
33+
if batch:
34+
Vulnerability_Id.objects.bulk_update(batch, ["vulnerability_id_type"], batch_size=BATCH_SIZE)
35+
36+
37+
class Migration(migrations.Migration):
38+
39+
"""Data only (no schema changes): dedupe (finding, vulnerability_id) rows and backfill
40+
vulnerability_id_type for existing rows."""
41+
42+
dependencies = [
43+
("dojo", "0276_vulnerability_id_type"),
44+
]
45+
46+
operations = [
47+
migrations.RunPython(dedupe_vulnerability_ids, migrations.RunPython.noop),
48+
migrations.RunPython(backfill_vulnerability_id_type, migrations.RunPython.noop),
49+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
"""Schema only: add the (finding, vulnerability_id) unique constraint. Runs after the 0277
7+
dedupe data step so the constraint can be created without violating existing duplicate rows."""
8+
9+
dependencies = [
10+
("dojo", "0277_backfill_vulnerability_id_type"),
11+
]
12+
13+
operations = [
14+
migrations.AddConstraint(
15+
model_name="vulnerability_id",
16+
constraint=models.UniqueConstraint(fields=("finding", "vulnerability_id"), name="unique_finding_vulnerability_id"),
17+
),
18+
]

dojo/finding/helper.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
do_false_positive_history_batch,
2727
get_finding_models_for_deduplication,
2828
)
29+
from dojo.finding.vulnerability_id import resolve_vulnerability_id_type
2930
from dojo.jira import services as jira_services
3031
from dojo.location.models import Location
3132
from dojo.location.status import FindingLocationStatus
@@ -1068,7 +1069,7 @@ def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool
10681069
Vulnerability_Id.objects.filter(finding=finding).delete()
10691070

10701071
Vulnerability_Id.objects.bulk_create([
1071-
Vulnerability_Id(finding=finding, vulnerability_id=vid)
1072+
Vulnerability_Id(finding=finding, vulnerability_id=vid, vulnerability_id_type=resolve_vulnerability_id_type(vid))
10721073
for vid in vulnerability_ids
10731074
])
10741075

dojo/finding/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from titlecase import titlecase
2525

2626
from dojo.base_models.base import BaseModel
27+
from dojo.finding.vulnerability_id import resolve_vulnerability_id_type
2728

2829
# get_current_date/tomorrow/copy_model_util are defined early in dojo.models, before the
2930
# re-export that loads this module — so this resolves despite the partial circular load, and
@@ -700,6 +701,9 @@ def copy(self, test=None):
700701
copy.found_by.set(old_found_by)
701702
# Assign any tags
702703
copy.tags.set(old_tags)
704+
# Copy the vulnerability ids (relation rows aren't copied by copy_model_util)
705+
for vulnerability_id in self.vulnerability_id_set.all():
706+
Vulnerability_Id.objects.create(finding=copy, vulnerability_id=vulnerability_id.vulnerability_id)
703707

704708
return copy
705709

@@ -1377,6 +1381,19 @@ def set_hash_code(self, dedupe_option):
13771381
class Vulnerability_Id(models.Model):
13781382
finding = models.ForeignKey("dojo.Finding", editable=False, on_delete=models.CASCADE)
13791383
vulnerability_id = models.TextField(max_length=50, blank=False, null=False)
1384+
# Autodetected from the id prefix (CVE, GHSA, ...); NULL when there is no non-numeric
1385+
# prefix. Denormalized/indexed so type-scoped queries (e.g. GROUP BY type) stay cheap.
1386+
vulnerability_id_type = models.CharField(max_length=20, null=True, blank=True, editable=False, db_index=True)
1387+
1388+
class Meta:
1389+
constraints = [
1390+
models.UniqueConstraint(fields=["finding", "vulnerability_id"], name="unique_finding_vulnerability_id"),
1391+
]
1392+
indexes = [
1393+
# Leading on vulnerability_id (the unique constraint's index leads on finding), for
1394+
# GROUP BY vulnerability_id / lookups by exact id.
1395+
models.Index(fields=["vulnerability_id"], name="dojo_vuln_id_lookup_idx"),
1396+
]
13801397

13811398
class Meta:
13821399
indexes = [
@@ -1391,6 +1408,11 @@ class Meta:
13911408
def __str__(self):
13921409
return self.vulnerability_id
13931410

1411+
def save(self, *args, **kwargs):
1412+
# bulk_create paths set the type at construction; this covers save()/get_or_create.
1413+
self.vulnerability_id_type = resolve_vulnerability_id_type(self.vulnerability_id)
1414+
super().save(*args, **kwargs)
1415+
13941416
def get_absolute_url(self):
13951417
return reverse("view_finding", args=[str(self.finding.id)])
13961418

dojo/finding/vulnerability_id.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Pure helpers for vulnerability identifiers (no model imports, safe to import anywhere)."""
2+
3+
4+
def resolve_vulnerability_id_type(vulnerability_id: str | None) -> str | None:
5+
"""
6+
Autodetect the type from the id's leading prefix (the part before the first ``-``).
7+
8+
Structural, no registry: ``CVE-2024-1 -> "CVE"``, ``GHSA-... -> "GHSA"``,
9+
``RUSTSEC-2021-0001 -> "RUSTSEC"``, ``ALINUX2-SA-... -> "ALINUX2"``. Returns the uppercased
10+
prefix, or ``None`` when there is no non-numeric prefix (bare numbers / UUIDs / no dash).
11+
"""
12+
if not vulnerability_id:
13+
return None
14+
prefix = str(vulnerability_id).strip().split("-", 1)[0].strip()
15+
if not prefix or prefix.isdigit():
16+
return None
17+
return prefix.upper()

dojo/importers/base_importer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import dojo.finding.helper as finding_helper
1414
import dojo.risk_acceptance.helper as ra_helper
15+
from dojo.finding.vulnerability_id import resolve_vulnerability_id_type
1516
from dojo.importers.options import ImporterOptions
1617
from dojo.jira.services import is_keep_in_sync
1718
from dojo.location.models import Location
@@ -893,7 +894,7 @@ def store_vulnerability_ids(
893894
vulnerability_ids_to_process = list(dict.fromkeys(finding.unsaved_vulnerability_ids or []))
894895
vulnerability_ids_to_process = [x for x in vulnerability_ids_to_process if x.strip()]
895896
self.pending_vulnerability_ids.extend([
896-
Vulnerability_Id(finding=finding, vulnerability_id=vid)
897+
Vulnerability_Id(finding=finding, vulnerability_id=vid, vulnerability_id_type=resolve_vulnerability_id_type(vid))
897898
for vid in vulnerability_ids_to_process
898899
])
899900
if vulnerability_ids_to_process:

dojo/importers/default_reimporter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
find_candidates_for_deduplication_unique_id,
1414
find_candidates_for_reimport_legacy,
1515
)
16+
from dojo.finding.vulnerability_id import resolve_vulnerability_id_type
1617
from dojo.importers.base_importer import BaseImporter, Parser
1718
from dojo.importers.base_location_manager import LocationHandler
1819
from dojo.importers.options import ImporterOptions
@@ -1000,7 +1001,7 @@ def reconcile_vulnerability_ids(
10001001
# Accumulate delete + insert for batch flush
10011002
self.pending_vuln_id_deletes.append(finding.id)
10021003
self.pending_vulnerability_ids.extend([
1003-
Vulnerability_Id(finding=finding, vulnerability_id=vid)
1004+
Vulnerability_Id(finding=finding, vulnerability_id=vid, vulnerability_id_type=resolve_vulnerability_id_type(vid))
10041005
for vid in vulnerability_ids_to_process
10051006
])
10061007
if vulnerability_ids_to_process:
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Vulnerability_Id type autodetection, the backfill helper path, and the uniqueness constraint."""
2+
from django.db import IntegrityError, transaction
3+
from django.test import SimpleTestCase
4+
5+
from dojo.finding.helper import save_vulnerability_ids
6+
from dojo.finding.vulnerability_id import resolve_vulnerability_id_type
7+
from dojo.models import Finding, Vulnerability_Id
8+
from unittests.dojo_test_case import DojoTestCase, versioned_fixtures
9+
10+
11+
class TestVulnerabilityIdTypeResolver(SimpleTestCase):
12+
13+
def test_autodetect(self):
14+
self.assertEqual(resolve_vulnerability_id_type("CVE-2024-1234"), "CVE")
15+
self.assertEqual(resolve_vulnerability_id_type("GHSA-9v3m-xxxx"), "GHSA")
16+
self.assertEqual(resolve_vulnerability_id_type("RUSTSEC-2021-0001"), "RUSTSEC")
17+
self.assertEqual(resolve_vulnerability_id_type("ALINUX2-SA-2021"), "ALINUX2")
18+
self.assertEqual(resolve_vulnerability_id_type("cve-2024-1"), "CVE")
19+
# No non-numeric prefix -> None
20+
self.assertIsNone(resolve_vulnerability_id_type("1234"))
21+
self.assertIsNone(resolve_vulnerability_id_type(""))
22+
self.assertIsNone(resolve_vulnerability_id_type(None))
23+
24+
25+
@versioned_fixtures
26+
class TestVulnerabilityIdType(DojoTestCase):
27+
fixtures = ["dojo_testdata.json"]
28+
29+
def setUp(self):
30+
self.finding = Finding.objects.get(id=2)
31+
Vulnerability_Id.objects.filter(finding=self.finding).delete()
32+
33+
def test_save_sets_type(self):
34+
row = Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="CVE-2024-1234")
35+
self.assertEqual(row.vulnerability_id_type, "CVE")
36+
# bare number -> no type
37+
row2 = Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="12345")
38+
self.assertIsNone(row2.vulnerability_id_type)
39+
40+
def test_bulk_save_sets_type(self):
41+
# save_vulnerability_ids uses bulk_create, which sets the type at construction
42+
save_vulnerability_ids(self.finding, ["CVE-2024-1", "GHSA-aaaa-bbbb"])
43+
types = dict(
44+
Vulnerability_Id.objects.filter(finding=self.finding).values_list("vulnerability_id", "vulnerability_id_type"),
45+
)
46+
self.assertEqual(types, {"CVE-2024-1": "CVE", "GHSA-aaaa-bbbb": "GHSA"})
47+
48+
def test_unique_constraint(self):
49+
Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="CVE-2024-1234")
50+
with self.assertRaises(IntegrityError), transaction.atomic():
51+
Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="CVE-2024-1234")

0 commit comments

Comments
 (0)