Skip to content

Commit 0f2a5af

Browse files
valentijnscholtenMaffoochclaude
authored
feat(finding): copy finding fix + autodetected vulnerability id type + uniqueness constraint (#15145)
* 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). * fix(vuln-id): cap autodetected vulnerability_id_type at column width (20) resolve_vulnerability_id_type returned the raw uppercased prefix with no length bound, but vulnerability_id_type is CharField(max_length=20) while vulnerability_id is unbounded text. A >20-char prefix (e.g. a dash-less token/hash used as an id) raised DataError on save()/bulk_create (import 500) and was silently truncated by the 0277 backfill's bulk_update CAST. Treat an over-length prefix as untyped (None) so save, bulk_create, and the backfill all agree and never error or truncate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): rebase onto dev, renumber vuln-id chain 0280-0282 Resolves the migration-graph leaf collision: dev has advanced to 0279_jira_project_transition_fields, so the vulnerability-id migrations are renumbered 0276/0277/0278 -> 0280/0281/0282 and the first is re-parented onto the current dev leaf. Also merges a duplicate `class Meta` on Vulnerability_Id introduced by the rebase: dev's global-search FTS work added its own Meta (GIN indexes) adjacent to this branch's Meta (unique constraint + dojo_vuln_id_lookup_idx). Python keeps only the last class body, which silently dropped the constraint/index from the model state (makemigrations wanted to remove them). Both are now in a single Meta. `makemigrations --check dojo` reports no changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(migrations): build vuln-id index + unique constraint concurrently (F9) 0280's lookup index and 0282's (finding, vulnerability_id) unique constraint were built with plain AddIndex / AddConstraint, taking a SHARE lock (blocks writes) and an ACCESS EXCLUSIVE lock (blocks reads+writes) for the full build over the whole dojo_vulnerability_id table -- a material downtime window on large instances. Build both CONCURRENTLY instead (atomic=False), keeping the work in-migration: - 0280: AddIndexConcurrently for dojo_vuln_id_lookup_idx. - 0282: CREATE UNIQUE INDEX CONCURRENTLY, then ADD CONSTRAINT ... USING INDEX (brief lock to adopt the pre-built index); SeparateDatabaseAndState keeps the UniqueConstraint in Django's model state. Verified on a 100k-finding / 174k-id dataset with duplicate rows: 0281 dedupe -> 0, constraint built valid as a real UNIQUE constraint, makemigrations --check clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ad76b1b commit 0f2a5af

10 files changed

Lines changed: 257 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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from django.contrib.postgres.operations import AddIndexConcurrently
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
7+
"""
8+
Schema only: add the autodetected vulnerability_id_type column and a leading index on
9+
vulnerability_id. The data backfill (0281) and the unique constraint (0282, which requires
10+
the dedupe data step first) are kept in separate migrations so data migrations are never
11+
mixed with schema migrations.
12+
13+
The lookup index is built with CREATE INDEX CONCURRENTLY (no SHARE lock, so writes to
14+
dojo_vulnerability_id keep flowing during the build), which requires a non-atomic migration.
15+
Adding the nullable, all-NULL vulnerability_id_type column and its own index is a fast catalog
16+
operation, so it stays a plain AddField.
17+
"""
18+
19+
atomic = False
20+
21+
dependencies = [
22+
("dojo", "0279_jira_project_transition_fields"),
23+
]
24+
25+
operations = [
26+
migrations.AddField(
27+
model_name="vulnerability_id",
28+
name="vulnerability_id_type",
29+
field=models.CharField(blank=True, db_index=True, editable=False, max_length=20, null=True),
30+
),
31+
AddIndexConcurrently(
32+
model_name="vulnerability_id",
33+
index=models.Index(fields=["vulnerability_id"], name="dojo_vuln_id_lookup_idx"),
34+
),
35+
]
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", "0280_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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
"""
7+
Schema only: add the (finding, vulnerability_id) unique constraint. Runs after the 0281
8+
dedupe data step so the constraint can be created without violating existing duplicate rows.
9+
10+
The unique index is built with CREATE UNIQUE INDEX CONCURRENTLY (no ACCESS EXCLUSIVE
11+
full-build lock, so reads and writes keep flowing during the build) and then attached as the
12+
constraint with ADD CONSTRAINT ... USING INDEX, which only takes a brief lock to adopt the
13+
already-built index. Both steps run inside this migration; CONCURRENTLY requires atomic=False.
14+
"""
15+
16+
atomic = False
17+
18+
dependencies = [
19+
("dojo", "0281_backfill_vulnerability_id_type"),
20+
]
21+
22+
operations = [
23+
migrations.RunSQL(
24+
sql="CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS unique_finding_vulnerability_id "
25+
"ON dojo_vulnerability_id (finding_id, vulnerability_id);",
26+
reverse_sql="DROP INDEX CONCURRENTLY IF EXISTS unique_finding_vulnerability_id;",
27+
),
28+
migrations.SeparateDatabaseAndState(
29+
database_operations=[
30+
migrations.RunSQL(
31+
sql="ALTER TABLE dojo_vulnerability_id ADD CONSTRAINT unique_finding_vulnerability_id "
32+
"UNIQUE USING INDEX unique_finding_vulnerability_id;",
33+
reverse_sql="ALTER TABLE dojo_vulnerability_id DROP CONSTRAINT IF EXISTS unique_finding_vulnerability_id;",
34+
),
35+
],
36+
state_operations=[
37+
migrations.AddConstraint(
38+
model_name="vulnerability_id",
39+
constraint=models.UniqueConstraint(fields=("finding", "vulnerability_id"), name="unique_finding_vulnerability_id"),
40+
),
41+
],
42+
),
43+
]

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: 18 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,9 +1381,18 @@ 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)
13801387

13811388
class Meta:
1389+
constraints = [
1390+
models.UniqueConstraint(fields=["finding", "vulnerability_id"], name="unique_finding_vulnerability_id"),
1391+
]
13821392
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"),
13831396
# Global search (pro/search/): weighted tsvector FTS + trigram fuzzy match.
13841397
GinIndex(
13851398
SearchVector("vulnerability_id", weight="A", config="english"),
@@ -1391,6 +1404,11 @@ class Meta:
13911404
def __str__(self):
13921405
return self.vulnerability_id
13931406

1407+
def save(self, *args, **kwargs):
1408+
# bulk_create paths set the type at construction; this covers save()/get_or_create.
1409+
self.vulnerability_id_type = resolve_vulnerability_id_type(self.vulnerability_id)
1410+
super().save(*args, **kwargs)
1411+
13941412
def get_absolute_url(self):
13951413
return reverse("view_finding", args=[str(self.finding.id)])
13961414

dojo/finding/vulnerability_id.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
The prefix is capped at the ``vulnerability_id_type`` column width (20). A longer prefix is
13+
not a real scheme (e.g. a dash-less hash used as an id), so it is treated as untyped rather
14+
than truncated/erroring on save, bulk_create, and the 0277 backfill.
15+
"""
16+
if not vulnerability_id:
17+
return None
18+
prefix = str(vulnerability_id).strip().split("-", 1)[0].strip()
19+
if not prefix or prefix.isdigit() or len(prefix) > 20:
20+
return None
21+
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)