Skip to content
Open
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
36 changes: 34 additions & 2 deletions vulnerabilities/improvers/valid_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions vulnerabilities/migrations/0117_package_release_date.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
14 changes: 14 additions & 0 deletions vulnerabilities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions vulnerabilities/tests/test_valid_versions_release_date.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions vulnerabilities/tests/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
20 changes: 17 additions & 3 deletions vulnerabilities/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:
Expand All @@ -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 = {
Expand Down