Skip to content

Commit 928b773

Browse files
authored
Merge pull request #2174 from aboutcode-org/relate_severity_scores
Relate severity scores with advisories
2 parents 4038b83 + 0f2f8e9 commit 928b773

File tree

7 files changed

+333
-0
lines changed

7 files changed

+333
-0
lines changed

vulnerabilities/improvers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
enhance_with_metasploit as enhance_with_metasploit_v2,
3232
)
3333
from vulnerabilities.pipelines.v2_improvers import flag_ghost_packages as flag_ghost_packages_v2
34+
from vulnerabilities.pipelines.v2_improvers import relate_severities
3435
from vulnerabilities.pipelines.v2_improvers import unfurl_version_range as unfurl_version_range_v2
3536
from vulnerabilities.utils import create_registry
3637

@@ -72,5 +73,6 @@
7273
unfurl_version_range_v2.UnfurlVersionRangePipeline,
7374
compute_advisory_todo.ComputeToDo,
7475
collect_ssvc_trees.CollectSSVCPipeline,
76+
relate_severities.RelateSeveritiesPipeline,
7577
]
7678
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 5.2.11 on 2026-02-17 13:27
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("vulnerabilities", "0113_advisoryv2_precedence"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="advisoryv2",
15+
name="related_advisory_severities",
16+
field=models.ManyToManyField(
17+
help_text="Related advisories that are used to calculate the severity of this advisory.",
18+
related_name="related_to_advisory_severities",
19+
to="vulnerabilities.advisoryv2",
20+
),
21+
),
22+
]

vulnerabilities/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2997,6 +2997,12 @@ class AdvisoryV2(models.Model):
29972997
help_text="Precedence indicates the priority of advisory from different datasources. It is determined based on the reliability of the datasource and how close it is to the source.",
29982998
)
29992999

3000+
related_advisory_severities = models.ManyToManyField(
3001+
"AdvisoryV2",
3002+
related_name="related_to_advisory_severities",
3003+
help_text="Related advisories that are used to calculate the severity of this advisory.",
3004+
)
3005+
30003006
@property
30013007
def risk_score(self):
30023008
"""
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import logging
11+
from itertools import batched
12+
13+
from django.db import transaction
14+
15+
from vulnerabilities.models import AdvisoryV2
16+
from vulnerabilities.pipelines import VulnerableCodePipeline
17+
from vulnerabilities.pipelines.v2_importers.epss_importer_v2 import EPSSImporterPipeline
18+
from vulnerabilities.pipelines.v2_importers.suse_score_importer import (
19+
SUSESeverityScoreImporterPipeline,
20+
)
21+
from vulnerabilities.severity_systems import CVSSV2
22+
from vulnerabilities.severity_systems import CVSSV3
23+
from vulnerabilities.severity_systems import CVSSV4
24+
from vulnerabilities.severity_systems import CVSSV31
25+
from vulnerabilities.severity_systems import EPSS
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class RelateSeveritiesPipeline(VulnerableCodePipeline):
31+
"""
32+
Severity Relations Pipeline: Relate EPSS and SUSE CVSS severities to advisories
33+
by matching severity advisory IDs with advisory IDs and aliases.
34+
"""
35+
36+
pipeline_id = "relate_severities_v2"
37+
38+
# Severity systems to process
39+
SUPPORTED_SYSTEMS = {
40+
EPSS.identifier,
41+
CVSSV2.identifier,
42+
CVSSV3.identifier,
43+
CVSSV31.identifier,
44+
CVSSV4.identifier,
45+
}
46+
47+
pipelines = [
48+
EPSSImporterPipeline.pipeline_id,
49+
SUSESeverityScoreImporterPipeline.pipeline_id,
50+
]
51+
52+
@classmethod
53+
def steps(cls):
54+
return (cls.relate_severities,)
55+
56+
def relate_severities(self):
57+
"""
58+
Relate EPSS and SUSE severities to advisories by matching advisory IDs.
59+
"""
60+
# Filter severities by supported scoring systems
61+
severity_score_advisories = (
62+
AdvisoryV2.objects.filter(datasource_id__in=self.pipelines)
63+
.filter(severities__scoring_system__in=self.SUPPORTED_SYSTEMS)
64+
.distinct()
65+
.latest_per_avid()
66+
)
67+
68+
total = severity_score_advisories.count()
69+
self.log(f"Processing {total:,d} advisories records")
70+
71+
advisory_id_map = {}
72+
73+
qs = AdvisoryV2.objects.filter(
74+
advisory_id__in=severity_score_advisories.values("advisory_id")
75+
).values("id", "advisory_id")
76+
77+
alias_qs = AdvisoryV2.objects.filter(
78+
aliases__alias__in=severity_score_advisories.values("advisory_id")
79+
).values("id", "aliases__alias")
80+
81+
for row in qs:
82+
advisory_id_map.setdefault(row["advisory_id"], set()).add(row["id"])
83+
84+
for row in alias_qs:
85+
advisory_id_map.setdefault(row["aliases__alias"], set()).add(row["id"])
86+
87+
through = AdvisoryV2.related_advisory_severities.through
88+
relations = []
89+
90+
for advisory in severity_score_advisories:
91+
matches = advisory_id_map.get(advisory.advisory_id, set())
92+
for target_id in matches:
93+
if target_id != advisory.id:
94+
self.log(f"Relating advisory {advisory.avid} to {target_id}")
95+
relations.append(
96+
through(
97+
from_advisoryv2_id=target_id,
98+
to_advisoryv2_id=advisory.id,
99+
)
100+
)
101+
102+
BATCH_SIZE = 5000
103+
with transaction.atomic():
104+
for chunk in batched(relations, BATCH_SIZE):
105+
through.objects.bulk_create(chunk, ignore_conflicts=True)
106+
107+
self.log(f"Successfully related {len(relations):,d} severities to advisories")

vulnerabilities/templates/advisory_detail.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,28 @@
451451
<td class="two-col-right">{{ epss_data.published_at }}</td>
452452
</tr>
453453
{% endif %}
454+
{% if epss_data.source %}
455+
<tr>
456+
<td class="two-col-left">
457+
<span class="has-tooltip-multiline has-tooltip-black has-tooltip-arrow has-tooltip-text-left"
458+
data-tooltip="Source URL of the EPSS Score.">
459+
Source
460+
</span>
461+
</td>
462+
<td class="two-col-right"> <a href="{{ epss_data.source }}" target="_blank">{{ epss_data.source }}</a></td>
463+
</tr>
464+
{% endif %}
465+
{% if epss_data.advisory %}
466+
<tr>
467+
<td class="two-col-left">
468+
<span class="has-tooltip-multiline has-tooltip-black has-tooltip-arrow has-tooltip-text-left"
469+
data-tooltip="Advisory of the EPSS Score.">
470+
Advisory
471+
</span>
472+
</td>
473+
<td class="two-col-right"> <a href="{{ epss_data.advisory.get_absolute_url }}">{{ epss_data.advisory.avid }}</a></td>
474+
</tr>
475+
{% endif %}
454476
</tbody>
455477
</table>
456478
{% else %}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import pytest
11+
12+
from vulnerabilities.models import AdvisoryV2
13+
from vulnerabilities.pipelines.v2_improvers.relate_severities import RelateSeveritiesPipeline
14+
from vulnerabilities.severity_systems import EPSS
15+
16+
17+
@pytest.mark.django_db
18+
def test_relate_severities_by_advisory_id():
19+
base = AdvisoryV2.objects.create(
20+
advisory_id="CVE-2024-0001",
21+
datasource_id="nvd",
22+
avid="nvd/CVE-2024-0001",
23+
unique_content_id="ab1",
24+
url="https://example.com/advisory/CVE-2024-0001",
25+
date_collected="2024-01-01",
26+
)
27+
28+
severity_advisory = AdvisoryV2.objects.create(
29+
advisory_id="CVE-2024-0001",
30+
datasource_id="epss_importer_v2",
31+
avid="epss/CVE-2024-0001",
32+
unique_content_id="ab2",
33+
url="https://example.com/epss/CVE-2024-0001",
34+
date_collected="2024-01-02",
35+
)
36+
severity_advisory.severities.create(
37+
scoring_system=EPSS.identifier,
38+
value="0.5",
39+
)
40+
41+
pipeline = RelateSeveritiesPipeline()
42+
pipeline.relate_severities()
43+
44+
assert base.related_advisory_severities.filter(id=severity_advisory.id).exists()
45+
46+
47+
@pytest.mark.django_db
48+
def test_relate_severities_via_alias():
49+
base = AdvisoryV2.objects.create(
50+
advisory_id="CVE-2024-0002",
51+
datasource_id="nvd",
52+
avid="nvd/CVE-2024-0002",
53+
unique_content_id="ab3",
54+
url="https://example.com/advisory/CVE-2024-0002",
55+
date_collected="2024-01-01",
56+
)
57+
58+
base.aliases.create(alias="CVE-2024-ALIAS")
59+
60+
severity_advisory = AdvisoryV2.objects.create(
61+
advisory_id="CVE-2024-ALIAS",
62+
datasource_id="epss_importer_v2",
63+
avid="epss/CVE-2024-ALIAS",
64+
unique_content_id="ab4",
65+
url="https://example.com/epss/CVE-2024-ALIAS",
66+
date_collected="2024-01-02",
67+
)
68+
severity_advisory.severities.create(
69+
scoring_system=EPSS.identifier,
70+
value="0.8",
71+
)
72+
73+
pipeline = RelateSeveritiesPipeline()
74+
pipeline.relate_severities()
75+
76+
assert base.related_advisory_severities.filter(id=severity_advisory.id).exists()
77+
78+
79+
@pytest.mark.django_db
80+
def test_no_self_relation_created():
81+
advisory = AdvisoryV2.objects.create(
82+
advisory_id="CVE-2024-0003",
83+
datasource_id="epss_importer_v2",
84+
unique_content_id="ab5",
85+
url="https://example.com/advisory/CVE-2024-0003",
86+
date_collected="2024-01-03",
87+
avid="epss/CVE-2024-0003",
88+
)
89+
advisory.severities.create(
90+
scoring_system=EPSS.identifier,
91+
value="0.2",
92+
)
93+
94+
pipeline = RelateSeveritiesPipeline()
95+
pipeline.relate_severities()
96+
97+
assert not advisory.related_advisory_severities.filter(id=advisory.id).exists()
98+
99+
100+
@pytest.mark.django_db
101+
def test_unsupported_severity_system_is_ignored():
102+
base = AdvisoryV2.objects.create(
103+
advisory_id="CVE-2024-0004",
104+
datasource_id="nvd",
105+
unique_content_id="ab6",
106+
url="https://example.com/advisory/CVE-2024-0004",
107+
date_collected="2024-01-01",
108+
avid="nvd/CVE-2024-0004",
109+
)
110+
111+
severity_advisory = AdvisoryV2.objects.create(
112+
advisory_id="CVE-2024-0004",
113+
datasource_id="epss_importer_v2",
114+
unique_content_id="ab7",
115+
url="https://example.com/epss/CVE-2024-0004",
116+
date_collected="2024-01-02",
117+
avid="epss/CVE-2024-0004",
118+
)
119+
severity_advisory.severities.create(
120+
scoring_system="UNKNOWN_SYSTEM",
121+
value="9.9",
122+
)
123+
124+
pipeline = RelateSeveritiesPipeline()
125+
pipeline.relate_severities()
126+
127+
assert base.related_advisory_severities.count() == 0
128+
129+
130+
@pytest.mark.django_db
131+
def test_pipeline_is_idempotent():
132+
base = AdvisoryV2.objects.create(
133+
advisory_id="CVE-2024-0005",
134+
datasource_id="nvd",
135+
unique_content_id="ab8",
136+
url="https://example.com/advisory/CVE-2024-0005",
137+
date_collected="2024-01-01",
138+
avid="nvd/CVE-2024-0005",
139+
)
140+
141+
severity = AdvisoryV2.objects.create(
142+
advisory_id="CVE-2024-0005",
143+
datasource_id="epss_importer_v2",
144+
unique_content_id="ab9",
145+
url="https://example.com/epss/CVE-2024-0005",
146+
date_collected="2024-01-02",
147+
avid="epss/CVE-2024-0005",
148+
)
149+
severity.severities.create(
150+
scoring_system=EPSS.identifier,
151+
value="0.9",
152+
)
153+
154+
pipeline = RelateSeveritiesPipeline()
155+
156+
pipeline.relate_severities()
157+
pipeline.relate_severities()
158+
159+
assert base.related_advisory_severities.count() == 1

vulnerabilities/views.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from vulnerabilities.models import ImpactedPackage
4040
from vulnerabilities.models import PipelineRun
4141
from vulnerabilities.models import PipelineSchedule
42+
from vulnerabilities.pipelines.v2_importers.epss_importer_v2 import EPSSImporterPipeline
4243
from vulnerabilities.severity_systems import EPSS
4344
from vulnerabilities.severity_systems import SCORING_SYSTEMS
4445
from vulnerabilities.utils import group_advisories_by_content
@@ -503,11 +504,25 @@ def get_context_data(self, **kwargs):
503504

504505
epss_severity = advisory.severities.filter(scoring_system="epss").first()
505506
epss_data = None
507+
epss_advisory = None
508+
if not epss_severity:
509+
related_epss_advisory = (
510+
advisory.related_advisory_severities.filter(
511+
datasource_id=EPSSImporterPipeline.pipeline_id
512+
)
513+
.latest_per_avid()
514+
.first()
515+
)
516+
epss_advisory = related_epss_advisory
517+
epss_severity = related_epss_advisory.severities.filter(scoring_system="epss").first()
506518
if epss_severity:
519+
# If the advisory itself does not have EPSS severity, but has a related advisory with EPSS severity, we use the related advisory's EPSS severity and URL as the source of EPSS data.
507520
epss_data = {
508521
"percentile": epss_severity.scoring_elements,
509522
"score": epss_severity.value,
510523
"published_at": epss_severity.published_at,
524+
"source": epss_advisory.url if epss_advisory else advisory.url,
525+
"advisory": epss_advisory if epss_advisory else advisory,
511526
}
512527

513528
ssvc_entries = []

0 commit comments

Comments
 (0)