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 = []