diff --git a/vulnerabilities/improvers/__init__.py b/vulnerabilities/improvers/__init__.py
index 7735ad816..97c18e6f9 100644
--- a/vulnerabilities/improvers/__init__.py
+++ b/vulnerabilities/improvers/__init__.py
@@ -31,6 +31,7 @@
enhance_with_metasploit as enhance_with_metasploit_v2,
)
from vulnerabilities.pipelines.v2_improvers import flag_ghost_packages as flag_ghost_packages_v2
+from vulnerabilities.pipelines.v2_improvers import relate_severities
from vulnerabilities.pipelines.v2_improvers import unfurl_version_range as unfurl_version_range_v2
from vulnerabilities.utils import create_registry
@@ -72,5 +73,6 @@
unfurl_version_range_v2.UnfurlVersionRangePipeline,
compute_advisory_todo.ComputeToDo,
collect_ssvc_trees.CollectSSVCPipeline,
+ relate_severities.RelateSeveritiesPipeline,
]
)
diff --git a/vulnerabilities/migrations/0114_advisoryv2_related_advisory_severities.py b/vulnerabilities/migrations/0114_advisoryv2_related_advisory_severities.py
new file mode 100644
index 000000000..f2051cfd7
--- /dev/null
+++ b/vulnerabilities/migrations/0114_advisoryv2_related_advisory_severities.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.11 on 2026-02-17 13:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("vulnerabilities", "0113_advisoryv2_precedence"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="advisoryv2",
+ name="related_advisory_severities",
+ field=models.ManyToManyField(
+ help_text="Related advisories that are used to calculate the severity of this advisory.",
+ related_name="related_to_advisory_severities",
+ to="vulnerabilities.advisoryv2",
+ ),
+ ),
+ ]
diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py
index 6e3664034..c102a697a 100644
--- a/vulnerabilities/models.py
+++ b/vulnerabilities/models.py
@@ -2997,6 +2997,12 @@ class AdvisoryV2(models.Model):
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.",
)
+ related_advisory_severities = models.ManyToManyField(
+ "AdvisoryV2",
+ related_name="related_to_advisory_severities",
+ help_text="Related advisories that are used to calculate the severity of this advisory.",
+ )
+
@property
def risk_score(self):
"""
diff --git a/vulnerabilities/pipelines/v2_improvers/relate_severities.py b/vulnerabilities/pipelines/v2_improvers/relate_severities.py
new file mode 100644
index 000000000..97a86404b
--- /dev/null
+++ b/vulnerabilities/pipelines/v2_improvers/relate_severities.py
@@ -0,0 +1,107 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# VulnerableCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: Apache-2.0
+# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
+# See https://github.com/aboutcode-org/vulnerablecode for support or download.
+# See https://aboutcode.org for more information about nexB OSS projects.
+#
+
+import logging
+from itertools import batched
+
+from django.db import transaction
+
+from vulnerabilities.models import AdvisoryV2
+from vulnerabilities.pipelines import VulnerableCodePipeline
+from vulnerabilities.pipelines.v2_importers.epss_importer_v2 import EPSSImporterPipeline
+from vulnerabilities.pipelines.v2_importers.suse_score_importer import (
+ SUSESeverityScoreImporterPipeline,
+)
+from vulnerabilities.severity_systems import CVSSV2
+from vulnerabilities.severity_systems import CVSSV3
+from vulnerabilities.severity_systems import CVSSV4
+from vulnerabilities.severity_systems import CVSSV31
+from vulnerabilities.severity_systems import EPSS
+
+logger = logging.getLogger(__name__)
+
+
+class RelateSeveritiesPipeline(VulnerableCodePipeline):
+ """
+ Severity Relations Pipeline: Relate EPSS and SUSE CVSS severities to advisories
+ by matching severity advisory IDs with advisory IDs and aliases.
+ """
+
+ pipeline_id = "relate_severities_v2"
+
+ # Severity systems to process
+ SUPPORTED_SYSTEMS = {
+ EPSS.identifier,
+ CVSSV2.identifier,
+ CVSSV3.identifier,
+ CVSSV31.identifier,
+ CVSSV4.identifier,
+ }
+
+ pipelines = [
+ EPSSImporterPipeline.pipeline_id,
+ SUSESeverityScoreImporterPipeline.pipeline_id,
+ ]
+
+ @classmethod
+ def steps(cls):
+ return (cls.relate_severities,)
+
+ def relate_severities(self):
+ """
+ Relate EPSS and SUSE severities to advisories by matching advisory IDs.
+ """
+ # Filter severities by supported scoring systems
+ severity_score_advisories = (
+ AdvisoryV2.objects.filter(datasource_id__in=self.pipelines)
+ .filter(severities__scoring_system__in=self.SUPPORTED_SYSTEMS)
+ .distinct()
+ .latest_per_avid()
+ )
+
+ total = severity_score_advisories.count()
+ self.log(f"Processing {total:,d} advisories records")
+
+ advisory_id_map = {}
+
+ qs = AdvisoryV2.objects.filter(
+ advisory_id__in=severity_score_advisories.values("advisory_id")
+ ).values("id", "advisory_id")
+
+ alias_qs = AdvisoryV2.objects.filter(
+ aliases__alias__in=severity_score_advisories.values("advisory_id")
+ ).values("id", "aliases__alias")
+
+ for row in qs:
+ advisory_id_map.setdefault(row["advisory_id"], set()).add(row["id"])
+
+ for row in alias_qs:
+ advisory_id_map.setdefault(row["aliases__alias"], set()).add(row["id"])
+
+ through = AdvisoryV2.related_advisory_severities.through
+ relations = []
+
+ for advisory in severity_score_advisories:
+ matches = advisory_id_map.get(advisory.advisory_id, set())
+ for target_id in matches:
+ if target_id != advisory.id:
+ self.log(f"Relating advisory {advisory.avid} to {target_id}")
+ relations.append(
+ through(
+ from_advisoryv2_id=target_id,
+ to_advisoryv2_id=advisory.id,
+ )
+ )
+
+ BATCH_SIZE = 5000
+ with transaction.atomic():
+ for chunk in batched(relations, BATCH_SIZE):
+ through.objects.bulk_create(chunk, ignore_conflicts=True)
+
+ self.log(f"Successfully related {len(relations):,d} severities to advisories")
diff --git a/vulnerabilities/templates/advisory_detail.html b/vulnerabilities/templates/advisory_detail.html
index 24a4b0d2c..595412df4 100644
--- a/vulnerabilities/templates/advisory_detail.html
+++ b/vulnerabilities/templates/advisory_detail.html
@@ -451,6 +451,28 @@
{{ epss_data.published_at }} |
{% endif %}
+ {% if epss_data.source %}
+
+ |
+
+ Source
+
+ |
+ {{ epss_data.source }} |
+
+ {% endif %}
+ {% if epss_data.advisory %}
+
+ |
+
+ Advisory
+
+ |
+ {{ epss_data.advisory.avid }} |
+
+ {% endif %}
{% else %}
diff --git a/vulnerabilities/tests/pipelines/v2_improvers/test_relate_severities.py b/vulnerabilities/tests/pipelines/v2_improvers/test_relate_severities.py
new file mode 100644
index 000000000..0c4c3e901
--- /dev/null
+++ b/vulnerabilities/tests/pipelines/v2_improvers/test_relate_severities.py
@@ -0,0 +1,159 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# VulnerableCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: Apache-2.0
+# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
+# See https://github.com/aboutcode-org/vulnerablecode for support or download.
+# See https://aboutcode.org for more information about nexB OSS projects.
+#
+
+import pytest
+
+from vulnerabilities.models import AdvisoryV2
+from vulnerabilities.pipelines.v2_improvers.relate_severities import RelateSeveritiesPipeline
+from vulnerabilities.severity_systems import EPSS
+
+
+@pytest.mark.django_db
+def test_relate_severities_by_advisory_id():
+ base = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0001",
+ datasource_id="nvd",
+ avid="nvd/CVE-2024-0001",
+ unique_content_id="ab1",
+ url="https://example.com/advisory/CVE-2024-0001",
+ date_collected="2024-01-01",
+ )
+
+ severity_advisory = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0001",
+ datasource_id="epss_importer_v2",
+ avid="epss/CVE-2024-0001",
+ unique_content_id="ab2",
+ url="https://example.com/epss/CVE-2024-0001",
+ date_collected="2024-01-02",
+ )
+ severity_advisory.severities.create(
+ scoring_system=EPSS.identifier,
+ value="0.5",
+ )
+
+ pipeline = RelateSeveritiesPipeline()
+ pipeline.relate_severities()
+
+ assert base.related_advisory_severities.filter(id=severity_advisory.id).exists()
+
+
+@pytest.mark.django_db
+def test_relate_severities_via_alias():
+ base = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0002",
+ datasource_id="nvd",
+ avid="nvd/CVE-2024-0002",
+ unique_content_id="ab3",
+ url="https://example.com/advisory/CVE-2024-0002",
+ date_collected="2024-01-01",
+ )
+
+ base.aliases.create(alias="CVE-2024-ALIAS")
+
+ severity_advisory = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-ALIAS",
+ datasource_id="epss_importer_v2",
+ avid="epss/CVE-2024-ALIAS",
+ unique_content_id="ab4",
+ url="https://example.com/epss/CVE-2024-ALIAS",
+ date_collected="2024-01-02",
+ )
+ severity_advisory.severities.create(
+ scoring_system=EPSS.identifier,
+ value="0.8",
+ )
+
+ pipeline = RelateSeveritiesPipeline()
+ pipeline.relate_severities()
+
+ assert base.related_advisory_severities.filter(id=severity_advisory.id).exists()
+
+
+@pytest.mark.django_db
+def test_no_self_relation_created():
+ advisory = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0003",
+ datasource_id="epss_importer_v2",
+ unique_content_id="ab5",
+ url="https://example.com/advisory/CVE-2024-0003",
+ date_collected="2024-01-03",
+ avid="epss/CVE-2024-0003",
+ )
+ advisory.severities.create(
+ scoring_system=EPSS.identifier,
+ value="0.2",
+ )
+
+ pipeline = RelateSeveritiesPipeline()
+ pipeline.relate_severities()
+
+ assert not advisory.related_advisory_severities.filter(id=advisory.id).exists()
+
+
+@pytest.mark.django_db
+def test_unsupported_severity_system_is_ignored():
+ base = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0004",
+ datasource_id="nvd",
+ unique_content_id="ab6",
+ url="https://example.com/advisory/CVE-2024-0004",
+ date_collected="2024-01-01",
+ avid="nvd/CVE-2024-0004",
+ )
+
+ severity_advisory = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0004",
+ datasource_id="epss_importer_v2",
+ unique_content_id="ab7",
+ url="https://example.com/epss/CVE-2024-0004",
+ date_collected="2024-01-02",
+ avid="epss/CVE-2024-0004",
+ )
+ severity_advisory.severities.create(
+ scoring_system="UNKNOWN_SYSTEM",
+ value="9.9",
+ )
+
+ pipeline = RelateSeveritiesPipeline()
+ pipeline.relate_severities()
+
+ assert base.related_advisory_severities.count() == 0
+
+
+@pytest.mark.django_db
+def test_pipeline_is_idempotent():
+ base = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0005",
+ datasource_id="nvd",
+ unique_content_id="ab8",
+ url="https://example.com/advisory/CVE-2024-0005",
+ date_collected="2024-01-01",
+ avid="nvd/CVE-2024-0005",
+ )
+
+ severity = AdvisoryV2.objects.create(
+ advisory_id="CVE-2024-0005",
+ datasource_id="epss_importer_v2",
+ unique_content_id="ab9",
+ url="https://example.com/epss/CVE-2024-0005",
+ date_collected="2024-01-02",
+ avid="epss/CVE-2024-0005",
+ )
+ severity.severities.create(
+ scoring_system=EPSS.identifier,
+ value="0.9",
+ )
+
+ pipeline = RelateSeveritiesPipeline()
+
+ pipeline.relate_severities()
+ pipeline.relate_severities()
+
+ assert base.related_advisory_severities.count() == 1
diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py
index e67033da9..3603b0f60 100644
--- a/vulnerabilities/views.py
+++ b/vulnerabilities/views.py
@@ -39,6 +39,7 @@
from vulnerabilities.models import ImpactedPackage
from vulnerabilities.models import PipelineRun
from vulnerabilities.models import PipelineSchedule
+from vulnerabilities.pipelines.v2_importers.epss_importer_v2 import EPSSImporterPipeline
from vulnerabilities.severity_systems import EPSS
from vulnerabilities.severity_systems import SCORING_SYSTEMS
from vulnerabilities.utils import group_advisories_by_content
@@ -503,11 +504,25 @@ def get_context_data(self, **kwargs):
epss_severity = advisory.severities.filter(scoring_system="epss").first()
epss_data = None
+ epss_advisory = None
+ if not epss_severity:
+ related_epss_advisory = (
+ advisory.related_advisory_severities.filter(
+ datasource_id=EPSSImporterPipeline.pipeline_id
+ )
+ .latest_per_avid()
+ .first()
+ )
+ epss_advisory = related_epss_advisory
+ epss_severity = related_epss_advisory.severities.filter(scoring_system="epss").first()
if epss_severity:
+ # 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.
epss_data = {
"percentile": epss_severity.scoring_elements,
"score": epss_severity.value,
"published_at": epss_severity.published_at,
+ "source": epss_advisory.url if epss_advisory else advisory.url,
+ "advisory": epss_advisory if epss_advisory else advisory,
}
ssvc_entries = []