diff --git a/component_catalog/models.py b/component_catalog/models.py
index 6d0e360b..a5c92ec6 100644
--- a/component_catalog/models.py
+++ b/component_catalog/models.py
@@ -7,8 +7,10 @@
#
import logging
+import operator
import re
from contextlib import suppress
+from functools import reduce
from urllib.parse import quote_plus
from django.contrib.postgres.fields import ArrayField
@@ -47,6 +49,7 @@
from packageurl.contrib import url2purl
from packageurl.contrib.django.models import PackageURLMixin
from packageurl.contrib.django.models import PackageURLQuerySetMixin
+from packageurl.contrib.django.utils import purl_to_lookups
from packageurl.contrib.django.utils import without_empty_values
from component_catalog.license_expression_dje import build_licensing
@@ -1761,6 +1764,18 @@ def has_download_url(self):
"""Return objects with download URL defined."""
return self.filter(~models.Q(download_url=""))
+ def for_package_urls(self, package_urls):
+ """Return objects matching any of the provided Package URL strings."""
+ package_url_filters = [
+ models.Q(**lookups)
+ for package_url in package_urls
+ if (lookups := purl_to_lookups(package_url))
+ ]
+ if not package_url_filters:
+ return self.none()
+
+ return self.filter(reduce(operator.or_, package_url_filters))
+
def annotate_sortable_identifier(self):
"""
Annotate the QuerySet with a `sortable_identifier` value that combines
diff --git a/component_catalog/tests/test_models.py b/component_catalog/tests/test_models.py
index 4ae69509..fcce4312 100644
--- a/component_catalog/tests/test_models.py
+++ b/component_catalog/tests/test_models.py
@@ -2903,3 +2903,28 @@ def test_package_queryset_annotate_plain_package_url(self):
qs = Package.objects.annotate_plain_package_url()
self.assertEqual("pkg:pypi/django@5.0", qs.get(pk=package1.pk).plain_purl)
self.assertEqual("", qs.get(pk=package2.pk).plain_purl)
+
+ def test_package_queryset_for_package_urls(self):
+ django_package = make_package(self.dataspace, package_url="pkg:pypi/django@5.0")
+ django_qualified_package = make_package(
+ self.dataspace, package_url="pkg:pypi/django@5.0?qualifier=true"
+ )
+ idna_package = make_package(self.dataspace, package_url="pkg:pypi/idna@3.7")
+ make_package(self.dataspace, package_url="pkg:pypi/django@4.2")
+ make_package(self.dataspace)
+
+ qs = Package.objects.for_package_urls(["pkg:pypi/django@5.0", "pkg:pypi/idna@3.7"])
+ expected = [django_package, django_qualified_package, idna_package]
+ self.assertQuerySetEqual(qs, expected, ordered=False)
+
+ qs = Package.objects.for_package_urls(["pkg:pypi/django@5.0?qualifier=true"])
+ self.assertQuerySetEqual(qs, [django_qualified_package])
+
+ def test_package_queryset_for_package_urls_invalid_and_empty(self):
+ idna_package = make_package(self.dataspace, package_url="pkg:pypi/idna@3.7")
+
+ self.assertQuerySetEqual(Package.objects.for_package_urls([]), [])
+ self.assertQuerySetEqual(Package.objects.for_package_urls(["invalid"]), [])
+
+ qs = Package.objects.for_package_urls(["invalid", "pkg:pypi/idna@3.7"])
+ self.assertQuerySetEqual(qs, [idna_package])
diff --git a/component_catalog/tests/test_views.py b/component_catalog/tests/test_views.py
index 186b72de..a46830d6 100644
--- a/component_catalog/tests/test_views.py
+++ b/component_catalog/tests/test_views.py
@@ -16,10 +16,12 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.backends.base import SessionBase
+from django.db import connection
from django.http.response import Http404
from django.test import TestCase
from django.test import TransactionTestCase
from django.test.client import RequestFactory
+from django.test.utils import CaptureQueriesContext
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import timezone
@@ -44,6 +46,7 @@
from component_catalog.views import ComponentListView
from component_catalog.views import PackageListView
from component_catalog.views import PackageTabScanView
+from component_catalog.views import get_packages_for_package_url
from dejacode_toolkit.scancodeio import get_webhook_url
from dejacode_toolkit.vulnerablecode import VulnerableCode
from dejacode_toolkit.vulnerablecode import get_plain_purls
@@ -3173,7 +3176,78 @@ def test_package_details_view_tab_vulnerabilities_fixed_by_packages(self):
self.assertContains(response, fixing_package.get_absolute_url())
# An unknown package offers an "Add Package" link instead.
self.assertContains(response, "idna@9.9.9")
- self.assertContains(response, "package_url=pkg:pypi/idna@9.9.9")
+ self.assertContains(response, "package_url=pkg%3Apypi%2Fidna%409.9.9")
+
+ def test_package_details_view_tab_vulnerabilities_fixed_by_packages_escaped(self):
+ self.vulnerability1.fixed_by_packages = ["pkg:pypi/idna@"]
+ self.vulnerability1.save()
+
+ self.client.login(username=self.super_user.username, password="secret")
+ response = self.client.get(self.package1.details_url)
+
+ self.assertNotContains(response, "
")
+ self.assertContains(response, "pypi/idna@<img src=x onerror=alert(1)>")
+
+ def test_package_details_view_tab_vulnerabilities_fixed_by_packages_qualifiers(self):
+ self.vulnerability1.fixed_by_packages = [
+ "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie"
+ ]
+ self.vulnerability1.save()
+
+ self.client.login(username=self.super_user.username, password="secret")
+ response = self.client.get(self.package1.details_url)
+
+ expected = (
+ "package_url=pkg%3Adeb%2Fdebian%2Fcurl%407.50.3-1%3Farch%3Di386%26distro%3Djessie"
+ )
+ self.assertContains(response, expected)
+
+ def test_package_details_view_tab_vulnerabilities_fixed_by_packages_num_queries(self):
+ make_package(self.dataspace, package_url="pkg:pypi/idna@3.7")
+ self.vulnerability1.fixed_by_packages = ["pkg:pypi/idna@3.7"]
+ self.vulnerability1.save()
+
+ self.client.login(username=self.super_user.username, password="secret")
+ # Warm up the caches so both measured requests start from the same state.
+ self.client.get(self.package1.details_url)
+ with CaptureQueriesContext(connection) as single_fixed_package_queries:
+ self.client.get(self.package1.details_url)
+
+ make_package(self.dataspace, package_url="pkg:pypi/idna@3.8")
+ make_package(self.dataspace, package_url="pkg:pypi/idna@3.9")
+ self.vulnerability1.fixed_by_packages = [
+ "pkg:pypi/idna@3.7",
+ "pkg:pypi/idna@3.8",
+ "pkg:pypi/idna@3.9",
+ "pkg:pypi/idna@9.9.9",
+ ]
+ self.vulnerability1.save()
+ with CaptureQueriesContext(connection) as multiple_fixed_packages_queries:
+ self.client.get(self.package1.details_url)
+
+ self.assertEqual(len(single_fixed_package_queries), len(multiple_fixed_packages_queries))
+
+ def test_package_views_get_packages_for_package_url(self):
+ django_package = make_package(self.dataspace, package_url="pkg:pypi/django@5.0")
+ django_qualified_package = make_package(
+ self.dataspace, package_url="pkg:pypi/django@5.0?qualifier=true"
+ )
+ django_old_package = make_package(self.dataspace, package_url="pkg:pypi/django@4.2")
+ packages = [django_package, django_qualified_package, django_old_package]
+
+ matched_packages = get_packages_for_package_url(packages, "pkg:pypi/django@5.0")
+ self.assertEqual([django_package, django_qualified_package], matched_packages)
+
+ matched_packages = get_packages_for_package_url(
+ packages, "pkg:pypi/django@5.0?qualifier=true"
+ )
+ self.assertEqual([django_qualified_package], matched_packages)
+
+ matched_packages = get_packages_for_package_url(packages, "pkg:pypi/django")
+ self.assertEqual(packages, matched_packages)
+
+ self.assertEqual([], get_packages_for_package_url(packages, "invalid"))
+ self.assertEqual([], get_packages_for_package_url([], "pkg:pypi/django@5.0"))
def test_package_details_view_tab_vulnerabilities_curation_badges(self):
DataspaceConfiguration.objects.create(
diff --git a/component_catalog/views.py b/component_catalog/views.py
index 6330264c..3fcfbba9 100644
--- a/component_catalog/views.py
+++ b/component_catalog/views.py
@@ -34,6 +34,7 @@
from django.utils.html import escape
from django.utils.html import format_html
from django.utils.html import mark_safe
+from django.utils.http import urlencode
from django.utils.text import normalize_newlines
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
@@ -46,6 +47,7 @@
from crispy_forms.utils import render_crispy_form
from packageurl import PackageURL
+from packageurl.contrib.django.utils import purl_to_lookups
from component_catalog.filters import ComponentFilterSet
from component_catalog.filters import PackageFilterSet
@@ -253,6 +255,19 @@ def include_type(view_instance):
return view_instance.dataspace.show_type_in_component_list_view
+def get_packages_for_package_url(packages, package_url):
+ """Return the `packages` matching `package_url`, same as `for_package_url`."""
+ lookups = purl_to_lookups(package_url)
+ if not lookups:
+ return []
+
+ return [
+ package
+ for package in packages
+ if all(getattr(package, field_name) == value for field_name, value in lookups.items())
+ ]
+
+
class TabVulnerabilityMixin:
template = "component_catalog/tabs/tab_vulnerabilities.html"
@@ -267,9 +282,20 @@ def tab_vulnerabilities(self):
f"{len(vulnerabilities_qs)}"
)
+ fixed_by_package_urls = {
+ package_url
+ for vulnerability in vulnerabilities_qs
+ for package_url in vulnerability.fixed_by_packages
+ }
+ fixed_packages = list(
+ Package.objects.scope(self.object.dataspace)
+ .for_package_urls(fixed_by_package_urls)
+ .only_rendering_fields()
+ )
+
vulnerabilities = []
for vulnerability in vulnerabilities_qs:
- fixed_packages_html = self.get_fixed_packages_html(vulnerability, self.object.dataspace)
+ fixed_packages_html = self.get_fixed_packages_html(vulnerability, fixed_packages)
vulnerability.fixed_packages_html = fixed_packages_html
vulnerabilities.append(vulnerability)
@@ -283,7 +309,7 @@ def tab_vulnerabilities(self):
"label": mark_safe(label),
}
- def get_fixed_packages_html(self, vulnerability, dataspace):
+ def get_fixed_packages_html(self, vulnerability, fixed_packages):
if not vulnerability.fixed_by_packages:
return
@@ -292,7 +318,7 @@ def get_fixed_packages_html(self, vulnerability, dataspace):
fixed_packages_values = []
for purl in fixed_packages_sorted:
- package_instances = Package.objects.scope(dataspace).for_package_url(purl)
+ package_instances = get_packages_for_package_url(fixed_packages, purl)
for package in package_instances:
display_value = package.get_html_link(href=package.get_absolute_url())
@@ -301,15 +327,18 @@ def get_fixed_packages_html(self, vulnerability, dataspace):
if not package_instances:
# Warning: do not add spaces between HTML elements as this content
# is displayed in a
- display_value = (
- f"{purl.replace('pkg:', '')}"
- f''
- f''
- f''
- f""
- f""
+ display_value = format_html(
+ "{}"
+ ''
+ ''
+ ''
+ ""
+ "",
+ purl.replace("pkg:", ""),
+ add_package_url,
+ urlencode({"package_url": purl}),
)
fixed_packages_values.append(display_value)