Skip to content

Commit e984c77

Browse files
feat(finding): multiple CWEs per finding
Stacked on top of the vulnerability_id-type change (feat/vulnerability-id-type). Adds a Finding_CWE relationship so a finding can carry multiple CWEs, mirroring vulnerability ids: the primary CWE stays on Finding.cwe; additional CWEs live in the relationship and are exposed via finding.cwes. Wired through the UI, the API (cwes field), and parsers (finding.unsaved_cwes). CWE is a weakness class, kept out of hash_code and the cve field, so existing hash codes/dedup are unaffected. Migrations 0279_finding_cwe (create table) and 0280_backfill_finding_cwe (seed from legacy Finding.cwe), chained after the vulnerability_id migrations (0278). Backfill existing findings with: manage.py migrate_cwe
1 parent c356327 commit e984c77

128 files changed

Lines changed: 3656 additions & 161 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/content/releases/os_upgrading/3.2.md

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,57 @@
22
title: 'Upgrading to DefectDojo Version 3.2.x'
33
toc_hide: true
44
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.
5+
description: Vulnerability ids gain an autodetected type and a uniqueness constraint; findings can now carry multiple CWEs via a new Finding_CWE relationship. Migrations add the type column, de-duplicate vulnerability-id rows, add the uniqueness constraint, create the CWE table, and backfill it. Existing hash codes are unaffected.
66
---
77

88
## Vulnerability id type
99

1010
Each `Vulnerability_Id` gains an autodetected `vulnerability_id_type` — the identifier's leading
1111
prefix (`CVE-2024-1234``CVE`, `GHSA-…``GHSA`, `RUSTSEC-…``RUSTSEC`). It is derived
1212
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.
13+
efficiently. It is `NULL` when there is no non-numeric prefix. It is populated automatically on
14+
import and on `save()`; existing rows are backfilled by migration. It does not participate in
15+
`hash_code`, so **existing hash codes and deduplication are unaffected**.
1416

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**.
17+
A unique constraint is also added on `(finding, vulnerability_id)`; pre-existing duplicate rows
18+
(unintended) are consolidated first.
19+
20+
## Multiple CWEs per finding
21+
22+
A finding could previously store only one CWE (the integer `cwe` field). This release adds a
23+
dedicated `Finding_CWE` relationship so a finding can carry **multiple CWEs**, using the same
24+
approach as vulnerability ids: the primary CWE stays on `Finding.cwe` (unchanged — legacy
25+
deduplication and hash codes still use it), and additional CWEs live in the relationship.
26+
27+
CWE is modeled separately from vulnerability identifiers on purpose: a CWE is a weakness *class*,
28+
not a vulnerability *instance* identifier, so it must not participate in `hash_code`,
29+
vulnerability-id deduplication, or the `cve` field. Because of this separation, **existing hash
30+
codes and deduplication are unaffected**.
31+
32+
CWEs are populated automatically on import and when a finding is created or edited (from the
33+
finding's CWE field, plus any additional CWEs a parser supplies). The finding exposes them via
34+
`finding.cwes` (primary first, deduplicated).
1835

1936
## Database migration
2037

21-
Three migrations run automatically on upgrade:
38+
Five migrations run automatically on upgrade:
2239

2340
- `0276_vulnerability_id_type` — adds the indexed `vulnerability_id_type` column and a leading
2441
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)`.
42+
- `0277_backfill_vulnerability_id_type` — backfills `vulnerability_id_type` and removes duplicate
43+
`(finding, vulnerability_id)` rows (keeping the earliest).
44+
- `0278_unique_finding_vulnerability_id` — adds the unique constraint on `(finding, vulnerability_id)`.
45+
- `0279_finding_cwe` — creates the `Finding_CWE` table (unique per `(finding, cwe)`).
46+
- `0280_backfill_finding_cwe` — seeds `Finding_CWE` rows from the legacy `Finding.cwe` values.
2947

3048
### What you need to do
3149

32-
The migrations are applied automatically. No manual steps are required.
50+
The migrations are applied automatically. New and edited findings populate their CWE relationship
51+
automatically. To backfill `Finding_CWE` rows for **existing** findings, run the idempotent command
52+
after upgrading:
53+
54+
```
55+
manage.py migrate_cwe
56+
```
3357

3458
For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.2.0).
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
7+
"""Schema only: create the Finding_CWE relationship (multiple CWEs per finding). The data
8+
backfill from the legacy Finding.cwe field is kept in a separate migration (0280) so data
9+
migrations are never mixed with schema migrations."""
10+
11+
dependencies = [
12+
("dojo", "0278_unique_finding_vulnerability_id"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="Finding_CWE",
18+
fields=[
19+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
20+
("cwe", models.CharField(db_index=True, max_length=11)),
21+
("finding", models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to="dojo.finding")),
22+
],
23+
),
24+
migrations.AddConstraint(
25+
model_name="finding_cwe",
26+
constraint=models.UniqueConstraint(fields=("finding", "cwe"), name="unique_finding_cwe"),
27+
),
28+
]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from django.db import migrations
2+
3+
from dojo.finding.vulnerability_id import cwe_label
4+
5+
BATCH_SIZE = 1000
6+
7+
8+
def create_finding_cwe_records(apps, schema_editor):
9+
"""Backfill Finding_CWE rows (canonical CWE-<n>) from the legacy int Finding.cwe field."""
10+
Finding = apps.get_model("dojo", "Finding")
11+
Finding_CWE = apps.get_model("dojo", "Finding_CWE")
12+
batch = []
13+
for finding in Finding.objects.filter(cwe__gt=0).only("id", "cwe").iterator(chunk_size=BATCH_SIZE):
14+
label = cwe_label(finding.cwe)
15+
if label is None:
16+
continue
17+
batch.append(Finding_CWE(finding_id=finding.id, cwe=label))
18+
if len(batch) >= BATCH_SIZE:
19+
Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True)
20+
batch = []
21+
if batch:
22+
Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True)
23+
24+
25+
class Migration(migrations.Migration):
26+
27+
"""Data only (no schema changes): create the initial Finding_CWE rows from the legacy
28+
Finding.cwe field."""
29+
30+
dependencies = [
31+
("dojo", "0279_finding_cwe"),
32+
]
33+
34+
operations = [
35+
migrations.RunPython(create_finding_cwe_records, migrations.RunPython.noop),
36+
]

dojo/finding/api/serializer.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
from dojo.authorization.authorization import user_has_permission
1717
from dojo.celery_dispatch import dojo_dispatch_task
1818
from dojo.finding.helper import (
19+
save_cwes,
1920
save_endpoints_template,
2021
save_vulnerability_ids,
2122
save_vulnerability_ids_template,
2223
)
2324
from dojo.finding.models import BurpRawRequestResponse
25+
from dojo.finding.vulnerability_id import cwe_label, cwe_number
2426
from dojo.jira import services as jira_services
2527
from dojo.jira.api.serializers import JIRAIssueSerializer
2628
from dojo.location.models import LocationFindingReference
@@ -32,6 +34,7 @@
3234
Endpoint,
3335
Engagement,
3436
Finding,
37+
Finding_CWE,
3538
Finding_Group,
3639
Finding_Template,
3740
Note_Type,
@@ -298,6 +301,30 @@ class Meta:
298301
fields = ["vulnerability_id"]
299302

300303

304+
@extend_schema_field(serializers.CharField())
305+
class CweField(serializers.Field):
306+
307+
"""Serialize a CWE as the canonical ``CWE-<n>`` string; accept ``"CWE-79"`` or ``"79"`` on write."""
308+
309+
def to_representation(self, value):
310+
return cwe_label(value) or value
311+
312+
def to_internal_value(self, data):
313+
label = cwe_label(data)
314+
if label is None:
315+
msg = "Enter a CWE number, e.g. 89 or CWE-89."
316+
raise serializers.ValidationError(msg)
317+
return label
318+
319+
320+
class FindingCweSerializer(serializers.ModelSerializer):
321+
cwe = CweField()
322+
323+
class Meta:
324+
model = Finding_CWE
325+
fields = ["cwe"]
326+
327+
301328
class FindingSerializer(serializers.ModelSerializer):
302329
mitigated = serializers.DateTimeField(required=False, allow_null=True)
303330
mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all())
@@ -321,6 +348,9 @@ class FindingSerializer(serializers.ModelSerializer):
321348
vulnerability_ids = VulnerabilityIdSerializer(
322349
source="vulnerability_id_set", many=True, required=False,
323350
)
351+
cwes = FindingCweSerializer(
352+
source="finding_cwe_set", many=True, required=False,
353+
)
324354
reporter = serializers.PrimaryKeyRelatedField(
325355
required=False, queryset=User.objects.all(),
326356
)
@@ -417,6 +447,13 @@ def update(self, instance, validated_data):
417447
logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0])
418448
validated_data["cve"] = parsed_vulnerability_ids[0]
419449

450+
# CWEs (mirror vulnerability_ids): the first entry is the primary Finding.cwe; the rest
451+
# become Finding_CWE rows via save_cwes() below.
452+
parsed_cwes = None
453+
if (cwes := validated_data.pop("finding_cwe_set", None)) is not None:
454+
parsed_cwes = [entry["cwe"] for entry in cwes]
455+
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
456+
420457
# Save the reporter on the finding
421458
if reporter_id := validated_data.get("reporter"):
422459
instance.reporter = reporter_id
@@ -445,6 +482,11 @@ def update(self, instance, validated_data):
445482
instance, validated_data,
446483
)
447484

485+
# Sync the CWE relation (separate from vulnerability ids) after the new cwe is applied.
486+
if parsed_cwes is not None:
487+
instance.unsaved_cwes = parsed_cwes[1:]
488+
save_cwes(instance)
489+
448490
if settings.V3_FEATURE_LOCATIONS and locations is not None:
449491
for location_ref in instance.locations.all():
450492
location_ref.location.disassociate_from_finding(instance)
@@ -561,6 +603,9 @@ class FindingCreateSerializer(serializers.ModelSerializer):
561603
vulnerability_ids = VulnerabilityIdSerializer(
562604
source="vulnerability_id_set", many=True, required=False,
563605
)
606+
cwes = FindingCweSerializer(
607+
source="finding_cwe_set", many=True, required=False,
608+
)
564609
reporter = serializers.PrimaryKeyRelatedField(
565610
required=False, queryset=User.objects.all(),
566611
)
@@ -601,6 +646,12 @@ def create(self, validated_data):
601646
validated_data["cve"] = parsed_vulnerability_ids[0]
602647
# validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids
603648

649+
# CWEs (mirror vulnerability_ids): first entry is the primary cwe, the rest are extras.
650+
parsed_cwes = None
651+
if (cwes := validated_data.pop("finding_cwe_set", None)) is not None:
652+
parsed_cwes = [entry["cwe"] for entry in cwes]
653+
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
654+
604655
# super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly.
605656
new_finding = Finding(**validated_data)
606657
new_finding.unsaved_vulnerability_ids = parsed_vulnerability_ids or []
@@ -617,6 +668,9 @@ def create(self, validated_data):
617668
new_finding.reviewers.set(reviewers)
618669
if parsed_vulnerability_ids:
619670
save_vulnerability_ids(new_finding, parsed_vulnerability_ids)
671+
if parsed_cwes is not None:
672+
new_finding.unsaved_cwes = parsed_cwes[1:]
673+
save_cwes(new_finding)
620674

621675
if push_to_jira:
622676
jira_services.push(new_finding)

dojo/finding/deduplication.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,11 +340,11 @@ def build_candidate_scope_queryset(test, mode="deduplication", service=None):
340340
queryset = Finding.objects.filter(scope_q)
341341

342342
if settings.V3_FEATURE_LOCATIONS:
343-
prefetch_list = ["locations__location__url", "vulnerability_id_set", "found_by"]
343+
prefetch_list = ["locations__location__url", "vulnerability_id_set", "finding_cwe_set", "found_by"]
344344
else:
345345
# TODO: Delete this after the move to Locations
346346
# Base prefetches for both modes
347-
prefetch_list = ["endpoints", "vulnerability_id_set", "found_by"]
347+
prefetch_list = ["endpoints", "vulnerability_id_set", "finding_cwe_set", "found_by"]
348348

349349
# Prefetch all endpoint statuses with their endpoint for reimport mode.
350350
# The non-special filtering (excluding false_positive, out_of_scope, risk_accepted)

dojo/finding/helper.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +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
29+
from dojo.finding.vulnerability_id import finding_cwe_labels, resolve_vulnerability_id_type
3030
from dojo.jira import services as jira_services
3131
from dojo.location.models import Location
3232
from dojo.location.status import FindingLocationStatus
@@ -37,6 +37,7 @@
3737
Engagement,
3838
FileUpload,
3939
Finding,
40+
Finding_CWE,
4041
Finding_Group,
4142
JIRA_Instance,
4243
Notes,
@@ -1016,6 +1017,24 @@ def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool
10161017
finding.cve = None
10171018

10181019

1020+
def save_cwes(finding, *, delete_existing: bool = True):
1021+
"""
1022+
Persist the finding's CWEs as Finding_CWE rows.
1023+
1024+
The primary Finding.cwe plus any parser-supplied unsaved_cwes, stored as canonical CWE-<n>
1025+
strings. CWE is a weakness class, kept separate from vulnerability ids.
1026+
"""
1027+
cwe_values = finding_cwe_labels(finding.cwe, getattr(finding, "unsaved_cwes", None))
1028+
1029+
if delete_existing:
1030+
Finding_CWE.objects.filter(finding=finding).delete()
1031+
1032+
Finding_CWE.objects.bulk_create(
1033+
[Finding_CWE(finding=finding, cwe=cwe) for cwe in cwe_values],
1034+
ignore_conflicts=True,
1035+
)
1036+
1037+
10191038
def save_vulnerability_ids_template(finding_template, vulnerability_ids):
10201039
"""Save vulnerability IDs as newline-separated string in TextField."""
10211040
# Remove duplicates and empty strings

0 commit comments

Comments
 (0)