From cda0314dffb06f39182aafe7109b52fe2185f668 Mon Sep 17 00:00:00 2001 From: abhey8 Date: Tue, 31 Mar 2026 03:02:42 +0530 Subject: [PATCH 1/2] Fix EPSS tab to show latest score by published date Signed-off-by: abhey8 --- vulnerabilities/tests/test_view.py | 29 +++++++++++++++++++++++++++++ vulnerabilities/views.py | 20 +++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/vulnerabilities/tests/test_view.py b/vulnerabilities/tests/test_view.py index 471e0bf43..0fdf4cb06 100644 --- a/vulnerabilities/tests/test_view.py +++ b/vulnerabilities/tests/test_view.py @@ -9,6 +9,8 @@ import os import time +from datetime import datetime +from datetime import timezone as dt_timezone import pytest from django.test import Client @@ -182,6 +184,33 @@ def test_vulnerabilties_search_view_can_find_alias(self): response = self.client.get(f"/vulnerabilities/search/?search=TEST-2022") self.assertEqual(response.status_code, 200) + def test_vulnerability_details_epss_uses_latest_published_score(self): + older_epss = VulnerabilitySeverity.objects.create( + url="https://api.first.org/data/v1/epss?cve=CVE-2024-39689", + scoring_system="epss", + value="0.00045", + scoring_elements="0.16709", + published_at=datetime(2024, 11, 1, tzinfo=dt_timezone.utc), + ) + latest_epss = VulnerabilitySeverity.objects.create( + url="https://api.first.org/data/v1/epss?cve=CVE-2024-39689", + scoring_system="epss", + value="0.21233", + scoring_elements="0.95432", + published_at=datetime(2025, 8, 14, tzinfo=dt_timezone.utc), + ) + + self.vulnerability.severities.add(older_epss) + self.vulnerability.severities.add(latest_epss) + + response = self.client.get(f"/vulnerabilities/{self.vulnerability.vulnerability_id}") + self.assertEqual(response.status_code, 200) + + epss_data = response.context["epss_data"] + self.assertEqual(epss_data["score"], "0.21233") + self.assertEqual(epss_data["percentile"], "0.95432") + self.assertEqual(epss_data["published_at"], datetime(2025, 8, 14, tzinfo=dt_timezone.utc)) + class CheckRobotsTxtTestCase(TestCase): def test_robots_txt(self): diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 860bde8eb..5c52e6e84 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -49,6 +49,20 @@ PAGE_SIZE = 20 +def get_latest_epss_severity(severities): + """ + Return the latest EPSS severity by publication date. + """ + return ( + severities.filter(scoring_system=EPSS.identifier) + .order_by( + F("published_at").desc(nulls_last=True), + F("id").desc(), + ) + .first() + ) + + class PackageSearch(ListView): model = models.Package template_name = "packages.html" @@ -386,7 +400,7 @@ def get_context_data(self, **kwargs): ): logging.error(f"CVSSMalformedError for {severity.scoring_elements}") - epss_severity = vulnerability.severities.filter(scoring_system="epss").first() + epss_severity = get_latest_epss_severity(vulnerability.severities) epss_data = None if epss_severity: epss_data = { @@ -502,7 +516,7 @@ def get_context_data(self, **kwargs): .exclude(scoring_elements="") ) - epss_severity = advisory.severities.filter(scoring_system="epss").first() + epss_severity = get_latest_epss_severity(advisory.severities) epss_data = None epss_advisory = None if not epss_severity: @@ -514,7 +528,7 @@ def get_context_data(self, **kwargs): .first() ) if epss_advisory: - epss_severity = epss_advisory.severities.filter(scoring_system="epss").first() + epss_severity = get_latest_epss_severity(epss_advisory.severities) 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 = { From ade9fd33e1b9cdbeae1a65a09473f5c47f3d26f7 Mon Sep 17 00:00:00 2001 From: abhey8 Date: Tue, 31 Mar 2026 09:59:20 +0530 Subject: [PATCH 2/2] Store package version release date in DB Signed-off-by: abhey8 --- vulnerabilities/improvers/valid_versions.py | 36 ++++++++++++- .../migrations/0117_package_release_date.py | 39 ++++++++++++++ vulnerabilities/models.py | 14 +++++ .../tests/test_valid_versions_release_date.py | 52 +++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 vulnerabilities/migrations/0117_package_release_date.py create mode 100644 vulnerabilities/tests/test_valid_versions_release_date.py diff --git a/vulnerabilities/improvers/valid_versions.py b/vulnerabilities/improvers/valid_versions.py index 916f36f59..bc05af888 100644 --- a/vulnerabilities/improvers/valid_versions.py +++ b/vulnerabilities/improvers/valid_versions.py @@ -40,6 +40,8 @@ from vulnerabilities.improver import Improver from vulnerabilities.improver import Inference from vulnerabilities.models import Advisory +from vulnerabilities.models import Package +from vulnerabilities.models import PackageV2 from vulnerabilities.pipelines import VulnerableCodeBaseImporterPipeline from vulnerabilities.pipelines.github_importer import GitHubAPIImporterPipeline from vulnerabilities.pipelines.gitlab_importer import GitLabImporterPipeline @@ -73,15 +75,45 @@ def get_package_versions( """ Return a list of versions published before `until` for the `package_url` """ - versions = package_versions.versions(str(package_url)) + versions = list(package_versions.versions(str(package_url)) or []) + self.store_package_release_dates(package_url=package_url, versions=versions) versions_before_until = [] - for version in versions or []: + for version in versions: if until and version.release_date and version.release_date > until: continue versions_before_until.append(version.value) return versions_before_until + def store_package_release_dates(self, package_url: PackageURL, versions: List) -> None: + """ + Persist release dates for known package versions in both Package and PackageV2. + """ + releases_by_version = { + version.value: version.release_date + for version in versions + if getattr(version, "value", None) and getattr(version, "release_date", None) + } + if not releases_by_version: + return + + filters = { + "type": package_url.type, + "namespace": package_url.namespace, + "name": package_url.name, + "version__in": list(releases_by_version), + } + + for model in (Package, PackageV2): + packages_to_update = [] + for package in model.objects.filter(**filters).only("id", "version", "release_date"): + release_date = releases_by_version.get(package.version) + if release_date and package.release_date != release_date: + package.release_date = release_date + packages_to_update.append(package) + if packages_to_update: + model.objects.bulk_update(packages_to_update, fields=["release_date"]) + def get_inferences(self, advisory_data: AdvisoryData) -> Iterable[Inference]: """ Yield Inferences for the given advisory data diff --git a/vulnerabilities/migrations/0117_package_release_date.py b/vulnerabilities/migrations/0117_package_release_date.py new file mode 100644 index 000000000..fad35e3cf --- /dev/null +++ b/vulnerabilities/migrations/0117_package_release_date.py @@ -0,0 +1,39 @@ +# +# 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. +# +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("vulnerabilities", "0116_advisoryv2_advisory_content_hash"), + ] + + operations = [ + migrations.AddField( + model_name="package", + name="release_date", + field=models.DateTimeField( + blank=True, + db_index=True, + help_text="Date when this package version was released by the upstream package source.", + null=True, + ), + ), + migrations.AddField( + model_name="packagev2", + name="release_date", + field=models.DateTimeField( + blank=True, + db_index=True, + help_text="Date when this package version was released by the upstream package source.", + null=True, + ), + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 2e69be49a..190efa4b7 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -896,6 +896,13 @@ class Package(PackageURLMixin): db_index=True, ) + release_date = models.DateTimeField( + null=True, + blank=True, + db_index=True, + help_text="Date when this package version was released by the upstream package source.", + ) + objects = PackageQuerySet.as_manager() class Meta: @@ -3384,6 +3391,13 @@ class PackageV2(PackageURLMixin): db_index=True, ) + release_date = models.DateTimeField( + null=True, + blank=True, + db_index=True, + help_text="Date when this package version was released by the upstream package source.", + ) + def __str__(self): return self.package_url diff --git a/vulnerabilities/tests/test_valid_versions_release_date.py b/vulnerabilities/tests/test_valid_versions_release_date.py new file mode 100644 index 000000000..2b2939488 --- /dev/null +++ b/vulnerabilities/tests/test_valid_versions_release_date.py @@ -0,0 +1,52 @@ +# +# 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. +# + +from dataclasses import dataclass +from datetime import datetime +from datetime import timezone as dt_timezone + +import pytest +from packageurl import PackageURL + +from vulnerabilities.improvers.valid_versions import DebianBasicImprover +from vulnerabilities.models import Package +from vulnerabilities.models import PackageV2 + + +@dataclass +class MockVersion: + value: str + release_date: datetime | None + + +@pytest.mark.django_db +def test_get_package_versions_stores_release_date(monkeypatch): + package = Package.objects.create(type="pypi", name="demo", version="1.0.0") + package_v2 = PackageV2.objects.create(type="pypi", name="demo", version="1.0.0") + + release_date = datetime(2024, 1, 15, tzinfo=dt_timezone.utc) + mock_versions = [ + MockVersion(value="1.0.0", release_date=release_date), + MockVersion(value="2.0.0", release_date=None), + ] + + monkeypatch.setattr( + "vulnerabilities.improvers.valid_versions.package_versions.versions", + lambda *_args, **_kwargs: mock_versions, + ) + + purl = PackageURL(type="pypi", name="demo") + versions = DebianBasicImprover().get_package_versions(package_url=purl) + + assert versions == ["1.0.0", "2.0.0"] + + package.refresh_from_db() + package_v2.refresh_from_db() + assert package.release_date == release_date + assert package_v2.release_date == release_date