Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions vulnerabilities/improvers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -72,5 +73,6 @@
unfurl_version_range_v2.UnfurlVersionRangePipeline,
compute_advisory_todo.ComputeToDo,
collect_ssvc_trees.CollectSSVCPipeline,
relate_severities.RelateSeveritiesPipeline,
]
)
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
6 changes: 6 additions & 0 deletions vulnerabilities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
107 changes: 107 additions & 0 deletions vulnerabilities/pipelines/v2_improvers/relate_severities.py
Original file line number Diff line number Diff line change
@@ -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")
22 changes: 22 additions & 0 deletions vulnerabilities/templates/advisory_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,28 @@
<td class="two-col-right">{{ epss_data.published_at }}</td>
</tr>
{% endif %}
{% if epss_data.source %}
<tr>
<td class="two-col-left">
<span class="has-tooltip-multiline has-tooltip-black has-tooltip-arrow has-tooltip-text-left"
data-tooltip="Source URL of the EPSS Score.">
Source
</span>
</td>
<td class="two-col-right"> <a href="{{ epss_data.source }}" target="_blank">{{ epss_data.source }}</a></td>
</tr>
{% endif %}
{% if epss_data.advisory %}
<tr>
<td class="two-col-left">
<span class="has-tooltip-multiline has-tooltip-black has-tooltip-arrow has-tooltip-text-left"
data-tooltip="Advisory of the EPSS Score.">
Advisory
</span>
</td>
<td class="two-col-right"> <a href="{{ epss_data.advisory.get_absolute_url }}">{{ epss_data.advisory.avid }}</a></td>
</tr>
{% endif %}
</tbody>
</table>
{% else %}
Expand Down
159 changes: 159 additions & 0 deletions vulnerabilities/tests/pipelines/v2_improvers/test_relate_severities.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions vulnerabilities/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down