diff --git a/component_catalog/filters.py b/component_catalog/filters.py
index 6baabfda..83294143 100644
--- a/component_catalog/filters.py
+++ b/component_catalog/filters.py
@@ -8,7 +8,6 @@
from django import forms
from django.contrib.admin.options import IncorrectLookupParameters
-from django.db.models import F
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
@@ -17,14 +16,12 @@
from component_catalog.models import Component
from component_catalog.models import ComponentKeyword
from component_catalog.models import Package
-from component_catalog.models import Vulnerability
from component_catalog.programming_languages import PROGRAMMING_LANGUAGES
from dje.filters import DataspacedFilterSet
from dje.filters import DefaultOrderingFilter
from dje.filters import HasRelationFilter
from dje.filters import MatchOrderedSearchFilter
from dje.filters import RelatedLookupListFilter
-from dje.filters import SearchFilter
from dje.widgets import BootstrapSelectMultipleWidget
from dje.widgets import DropDownRightWidget
from dje.widgets import SortDropDownWidget
@@ -283,82 +280,3 @@ def show_created_date(self):
@cached_property
def show_last_modified_date(self):
return not self.sort_value or self.has_sort_by("last_modified_date")
-
-
-class NullsLastOrderingFilter(django_filters.OrderingFilter):
- """
- A custom ordering filter that ensures null values are sorted last.
-
- When sorting by fields with potential null values, this filter modifies the
- ordering to use Django's `nulls_last` clause for better handling of null values,
- whether in ascending or descending order.
- """
-
- def filter(self, qs, value):
- if not value:
- return qs
-
- ordering = []
- for field in value:
- if field.startswith("-"):
- field_name = field[1:]
- ordering.append(F(field_name).desc(nulls_last=True))
- else:
- ordering.append(F(field).asc(nulls_last=True))
-
- return qs.order_by(*ordering)
-
-
-vulnerability_score_ranges = {
- "low": (0.1, 3),
- "medium": (4.0, 6.9),
- "high": (7.0, 8.9),
- "critical": (9.0, 10.0),
-}
-
-SCORE_CHOICES = [
- (key, f"{key.capitalize()} ({value[0]} - {value[1]})")
- for key, value in vulnerability_score_ranges.items()
-]
-
-
-class VulnerabilityFilterSet(DataspacedFilterSet):
- q = SearchFilter(
- label=_("Search"),
- search_fields=["vulnerability_id", "aliases"],
- )
- sort = NullsLastOrderingFilter(
- label=_("Sort"),
- fields=[
- "max_score",
- "min_score",
- "affected_products_count",
- "affected_packages_count",
- "fixed_packages_count",
- "created_date",
- "last_modified_date",
- ],
- widget=SortDropDownWidget,
- )
- max_score = django_filters.ChoiceFilter(
- choices=SCORE_CHOICES,
- method="filter_by_score_range",
- label="Score Range",
- help_text="Select a score range to filter.",
- )
-
- class Meta:
- model = Vulnerability
- fields = [
- "q",
- ]
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.filters["max_score"].extra["widget"] = DropDownRightWidget(anchor=self.anchor)
-
- def filter_by_score_range(self, queryset, name, value):
- if value in vulnerability_score_ranges:
- low, high = vulnerability_score_ranges[value]
- return queryset.filter(max_score__gte=low, max_score__lte=high)
- return queryset
diff --git a/component_catalog/importers.py b/component_catalog/importers.py
index 760f9d55..be05c3ae 100644
--- a/component_catalog/importers.py
+++ b/component_catalog/importers.py
@@ -30,7 +30,6 @@
from component_catalog.models import Package
from component_catalog.models import Subcomponent
from component_catalog.programming_languages import PROGRAMMING_LANGUAGES
-from component_catalog.vulnerabilities import fetch_for_queryset
from dje.fields import SmartFileField
from dje.forms import JSONListField
from dje.importers import BaseImporter
@@ -42,6 +41,7 @@
from policy.models import UsagePolicy
from product_portfolio.models import ProductComponent
from product_portfolio.models import ProductPackage
+from vulnerabilities.fetch import fetch_for_queryset
keywords_help = (
get_help_text(Component, "keywords")
diff --git a/component_catalog/migrations/0008_remove_package_affected_by_vulnerabilities_and_more.py b/component_catalog/migrations/0008_remove_package_affected_by_vulnerabilities_and_more.py
new file mode 100644
index 00000000..f796e69a
--- /dev/null
+++ b/component_catalog/migrations/0008_remove_package_affected_by_vulnerabilities_and_more.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.0.6 on 2024-09-04 08:17
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('component_catalog', '0007_vulnerability_fixed_packages_count_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='package',
+ name='affected_by_vulnerabilities',
+ ),
+ migrations.RemoveField(
+ model_name='component',
+ name='affected_by_vulnerabilities',
+ ),
+ migrations.DeleteModel(
+ name='Vulnerability',
+ ),
+ ]
diff --git a/component_catalog/migrations/0009_componentaffectedbyvulnerability_and_more.py b/component_catalog/migrations/0009_componentaffectedbyvulnerability_and_more.py
new file mode 100644
index 00000000..16fbccfa
--- /dev/null
+++ b/component_catalog/migrations/0009_componentaffectedbyvulnerability_and_more.py
@@ -0,0 +1,53 @@
+# Generated by Django 5.0.6 on 2024-09-04 09:22
+
+import django.db.models.deletion
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('component_catalog', '0008_remove_package_affected_by_vulnerabilities_and_more'),
+ ('dje', '0004_dataspace_vulnerabilities_updated_at'),
+ ('vulnerabilities', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ComponentAffectedByVulnerability',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
+ ('component', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='component_catalog.component')),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ('vulnerability', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vulnerabilities.vulnerability')),
+ ],
+ options={
+ 'unique_together': {('component', 'vulnerability'), ('dataspace', 'uuid')},
+ },
+ ),
+ migrations.AddField(
+ model_name='component',
+ name='affected_by_vulnerabilities',
+ field=models.ManyToManyField(help_text='Vulnerabilities affecting this object.', related_name='affected_%(class)ss', through='component_catalog.ComponentAffectedByVulnerability', to='vulnerabilities.vulnerability'),
+ ),
+ migrations.CreateModel(
+ name='PackageAffectedByVulnerability',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='component_catalog.package')),
+ ('vulnerability', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='vulnerabilities.vulnerability')),
+ ],
+ options={
+ 'unique_together': {('dataspace', 'uuid'), ('package', 'vulnerability')},
+ },
+ ),
+ migrations.AddField(
+ model_name='package',
+ name='affected_by_vulnerabilities',
+ field=models.ManyToManyField(help_text='Vulnerabilities affecting this object.', related_name='affected_%(class)ss', through='component_catalog.PackageAffectedByVulnerability', to='vulnerabilities.vulnerability'),
+ ),
+ ]
diff --git a/component_catalog/models.py b/component_catalog/models.py
index e999abdf..3d0db089 100644
--- a/component_catalog/models.py
+++ b/component_catalog/models.py
@@ -6,7 +6,6 @@
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#
-import decimal
import logging
import re
from contextlib import suppress
@@ -38,7 +37,6 @@
from cyclonedx.model import component as cyclonedx_component
from cyclonedx.model import contact as cyclonedx_contact
from cyclonedx.model import license as cyclonedx_license
-from cyclonedx.model import vulnerability as cdx_vulnerability
from license_expression import ExpressionError
from packageurl import PackageURL
from packageurl.contrib import purl2url
@@ -67,7 +65,6 @@
from dje.models import DataspacedQuerySet
from dje.models import ExternalReferenceMixin
from dje.models import History
-from dje.models import HistoryDateFieldsMixin
from dje.models import HistoryFieldsMixin
from dje.models import ParentChildModelMixin
from dje.models import ParentChildRelationshipModel
@@ -82,6 +79,8 @@
from license_library.models import LicenseChoice
from policy.models import SetPolicyFromLicenseMixin
from policy.models import UsagePolicyMixin
+from vulnerabilities.models import AffectedByVulnerabilityMixin
+from vulnerabilities.models import AffectedByVulnerabilityRelationship
from workflow.models import RequestMixin
logger = logging.getLogger("dje")
@@ -466,85 +465,6 @@ def get_spdx_cpe_external_ref(self):
)
-class VulnerabilityMixin(models.Model):
- """Add the `vulnerability` many to many field."""
-
- affected_by_vulnerabilities = models.ManyToManyField(
- to="component_catalog.Vulnerability",
- related_name="affected_%(class)ss",
- help_text=_("Vulnerabilities affecting this object."),
- )
-
- class Meta:
- abstract = True
-
- @property
- def is_vulnerable(self):
- return self.affected_by_vulnerabilities.exists()
-
- def get_entry_for_package(self, vulnerablecode):
- if not self.package_url:
- return
-
- vulnerable_packages = vulnerablecode.get_vulnerabilities_by_purl(
- self.package_url,
- timeout=10,
- )
-
- if vulnerable_packages:
- affected_by_vulnerabilities = vulnerable_packages[0].get("affected_by_vulnerabilities")
- return affected_by_vulnerabilities
-
- def get_entry_for_component(self, vulnerablecode):
- if not self.cpe:
- return
-
- # Support for Component is paused as the CPES endpoint do not work properly.
- # https://github.com/aboutcode-org/vulnerablecode/issues/1557
- # vulnerabilities = vulnerablecode.get_vulnerabilities_by_cpe(self.cpe, timeout=10)
-
- def get_entry_from_vulnerablecode(self):
- from dejacode_toolkit.vulnerablecode import VulnerableCode
-
- dataspace = self.dataspace
- vulnerablecode = VulnerableCode(dataspace)
-
- is_vulnerablecode_enabled = all(
- [
- vulnerablecode.is_configured(),
- dataspace.enable_vulnerablecodedb_access,
- ]
- )
- if not is_vulnerablecode_enabled:
- return
-
- if isinstance(self, Component):
- return self.get_entry_for_component(vulnerablecode)
- elif isinstance(self, Package):
- return self.get_entry_for_package(vulnerablecode)
-
- def fetch_vulnerabilities(self):
- affected_by_vulnerabilities = self.get_entry_from_vulnerablecode()
- if affected_by_vulnerabilities:
- self.create_vulnerabilities(vulnerabilities_data=affected_by_vulnerabilities)
-
- def create_vulnerabilities(self, vulnerabilities_data):
- vulnerabilities = []
- vulnerability_qs = Vulnerability.objects.scope(self.dataspace)
-
- for vulnerability_data in vulnerabilities_data:
- vulnerability_id = vulnerability_data["vulnerability_id"]
- vulnerability = vulnerability_qs.get_or_none(vulnerability_id=vulnerability_id)
- if not vulnerability:
- vulnerability = Vulnerability.create_from_data(
- dataspace=self.dataspace,
- data=vulnerability_data,
- )
- vulnerabilities.append(vulnerability)
-
- self.affected_by_vulnerabilities.add(*vulnerabilities)
-
-
class URLFieldsMixin(models.Model):
homepage_url = models.URLField(
_("Homepage URL"),
@@ -970,7 +890,7 @@ class Component(
HolderMixin,
KeywordsMixin,
CPEMixin,
- VulnerabilityMixin,
+ AffectedByVulnerabilityMixin,
LicenseFieldsMixin,
ParentChildModelMixin,
BaseComponentMixin,
@@ -1307,6 +1227,13 @@ class Component(
through="ComponentAssignedPackage",
)
+ affected_by_vulnerabilities = models.ManyToManyField(
+ to="vulnerabilities.Vulnerability",
+ through="ComponentAffectedByVulnerability",
+ related_name="affected_%(class)ss",
+ help_text=_("Vulnerabilities affecting this object."),
+ )
+
objects = DataspacedManager.from_queryset(ComponentQuerySet)()
class Meta(BaseComponentMixin.Meta):
@@ -1774,7 +1701,7 @@ class Package(
HolderMixin,
KeywordsMixin,
CPEMixin,
- VulnerabilityMixin,
+ AffectedByVulnerabilityMixin,
URLFieldsMixin,
HashFieldsMixin,
PackageURLMixin,
@@ -1915,6 +1842,12 @@ class Package(
to="license_library.License",
through="PackageAssignedLicense",
)
+ affected_by_vulnerabilities = models.ManyToManyField(
+ to="vulnerabilities.Vulnerability",
+ through="PackageAffectedByVulnerability",
+ related_name="affected_%(class)ss",
+ help_text=_("Vulnerabilities affecting this object."),
+ )
objects = DataspacedManager.from_queryset(PackageQuerySet)()
@@ -2570,6 +2503,26 @@ def __str__(self):
return f"{self.package} is under {self.license}."
+class PackageAffectedByVulnerability(AffectedByVulnerabilityRelationship):
+ package = models.ForeignKey(
+ to="component_catalog.Package",
+ on_delete=models.CASCADE,
+ )
+
+ class Meta:
+ unique_together = (("package", "vulnerability"), ("dataspace", "uuid"))
+
+
+class ComponentAffectedByVulnerability(AffectedByVulnerabilityRelationship):
+ component = models.ForeignKey(
+ to="component_catalog.Component",
+ on_delete=models.CASCADE,
+ )
+
+ class Meta:
+ unique_together = (("component", "vulnerability"), ("dataspace", "uuid"))
+
+
class ComponentAssignedPackage(DataspacedModel):
component = models.ForeignKey(
to="component_catalog.Component",
@@ -2588,230 +2541,3 @@ class Meta:
def __str__(self):
return f"<{self.component}>: {self.package}"
-
-
-class VulnerabilityQuerySet(DataspacedQuerySet):
- def with_affected_products_count(self):
- """Annotate the QuerySet with the affected_products_count."""
- return self.annotate(
- affected_products_count=Count(
- "affected_packages__productpackages__product", distinct=True
- ),
- )
-
- def with_affected_packages_count(self):
- """Annotate the QuerySet with the affected_packages_count."""
- return self.annotate(
- affected_packages_count=Count("affected_packages", distinct=True),
- )
-
-
-class Vulnerability(HistoryDateFieldsMixin, DataspacedModel):
- """
- A software vulnerability with a unique identifier and alternate aliases.
-
- Adapted from the VulnerabeCode models at
- https://github.com/nexB/vulnerablecode/blob/main/vulnerabilities/models.py#L164
-
- Note that this model implements the HistoryDateFieldsMixin but not the
- HistoryUserFieldsMixin as the Vulnerability records are usually created
- automatically on object addition or during schedule tasks.
- """
-
- # The first set of fields are storing data as fetched from VulnerableCode
- vulnerability_id = models.CharField(
- max_length=20,
- help_text=_(
- "A unique identifier for the vulnerability, prefixed with 'VCID-'. "
- "For example, 'VCID-2024-0001'."
- ),
- )
- summary = models.TextField(
- help_text=_("A brief summary of the vulnerability, outlining its nature and impact."),
- blank=True,
- )
- aliases = JSONListField(
- blank=True,
- help_text=_(
- "A list of aliases for this vulnerability, such as CVE identifiers "
- "(e.g., 'CVE-2017-1000136')."
- ),
- )
- references = JSONListField(
- blank=True,
- help_text=_(
- "A list of references for this vulnerability. Each reference includes a "
- "URL, an optional reference ID, scores, and the URL for further details. "
- ),
- )
- fixed_packages = JSONListField(
- blank=True,
- help_text=_("A list of packages that are not affected by this vulnerability."),
- )
- fixed_packages_count = models.GeneratedField(
- expression=models.Func(models.F("fixed_packages"), function="jsonb_array_length"),
- output_field=models.IntegerField(),
- db_persist=True,
- )
- min_score = models.FloatField(
- null=True,
- blank=True,
- help_text=_("The minimum score of the range."),
- )
- max_score = models.FloatField(
- null=True,
- blank=True,
- help_text=_("The maximum score of the range."),
- )
-
- objects = DataspacedManager.from_queryset(VulnerabilityQuerySet)()
-
- class Meta:
- verbose_name_plural = "Vulnerabilities"
- unique_together = (("dataspace", "vulnerability_id"), ("dataspace", "uuid"))
- indexes = [
- models.Index(fields=["vulnerability_id"]),
- ]
-
- def __str__(self):
- return self.vulnerability_id
-
- @property
- def vcid(self):
- return self.vulnerability_id
-
- def add_affected(self, instances):
- """
- Assign the ``instances`` (Package or Component) as affected to this
- vulnerability.
- """
- if not isinstance(instances, list):
- instances = [instances]
-
- for instance in instances:
- if isinstance(instance, Package):
- self.affected_packages.add(instance)
- if isinstance(instance, Component):
- self.affected_components.add(instance)
-
- def add_affected_packages(self, packages):
- """Assign the ``packages`` as affected to this vulnerability."""
- self.affected_packages.add(*packages)
-
- def add_affected_components(self, components):
- """Assign the ``components`` as affected to this vulnerability."""
- self.affected_components.add(*components)
-
- @staticmethod
- def range_to_values(self, range_str):
- try:
- min_score, max_score = range_str.split("-")
- return float(min_score.strip()), float(max_score.strip())
- except Exception:
- return
-
- @classmethod
- def create_from_data(cls, dataspace, data, validate=False, affecting=None):
- # Computing the min_score and max_score from the `references` as those data
- # are not provided by the VulnerableCode API.
- # https://github.com/aboutcode-org/vulnerablecode/issues/1573
- # severity_range_score = data.get("severity_range_score")
- # if severity_range_score:
- # min_score, max_score = self.range_to_values(severity_range_score)
- # data["min_score"] = min_score
- # data["max_score"] = max_score
-
- severities = [
- score for reference in data.get("references") for score in reference.get("scores", [])
- ]
- if scores := cls.get_severity_scores(severities):
- data["min_score"] = min(scores)
- data["max_score"] = max(scores)
-
- instance = super().create_from_data(user=dataspace, data=data, validate=False)
-
- if affecting:
- instance.add_affected(affecting)
-
- return instance
-
- @staticmethod
- def get_severity_scores(severities):
- score_map = {
- "low": [0.1, 3],
- "moderate": [4.0, 6.9],
- "medium": [4.0, 6.9],
- "high": [7.0, 8.9],
- "important": [7.0, 8.9],
- "critical": [9.0, 10.0],
- }
-
- consolidated_scores = []
- for severity in severities:
- score = severity.get("value")
- try:
- consolidated_scores.append(float(score))
- except ValueError:
- if score_range := score_map.get(score.lower(), None):
- consolidated_scores.extend(score_range)
-
- return consolidated_scores
-
- def as_cyclonedx(self, affected_instances):
- affects = [
- cdx_vulnerability.BomTarget(ref=instance.cyclonedx_bom_ref)
- for instance in affected_instances
- ]
-
- source_url = f"https://public.vulnerablecode.io/vulnerabilities/{self.vulnerability_id}"
- source = cdx_vulnerability.VulnerabilitySource(
- name="VulnerableCode",
- url=source_url,
- )
-
- references = []
- ratings = []
- for reference in self.references:
- reference_source = cdx_vulnerability.VulnerabilitySource(
- url=reference.get("reference_url"),
- )
- references.append(
- cdx_vulnerability.VulnerabilityReference(
- id=reference.get("reference_id"),
- source=reference_source,
- )
- )
-
- for score_entry in reference.get("scores", []):
- # CycloneDX only support a float value for the score field,
- # where on the VulnerableCode data it can be either a score float value
- # or a severity string value.
- score_value = score_entry.get("value")
- try:
- score = decimal.Decimal(score_value)
- severity = None
- except decimal.DecimalException:
- score = None
- severity = getattr(
- cdx_vulnerability.VulnerabilitySeverity,
- score_value.upper(),
- None,
- )
-
- ratings.append(
- cdx_vulnerability.VulnerabilityRating(
- source=reference_source,
- score=score,
- severity=severity,
- vector=score_entry.get("scoring_elements"),
- )
- )
-
- return cdx_vulnerability.Vulnerability(
- id=self.vulnerability_id,
- source=source,
- description=self.summary,
- affects=affects,
- references=sorted(references),
- ratings=ratings,
- )
diff --git a/component_catalog/templates/component_catalog/includes/component_list_table.html b/component_catalog/templates/component_catalog/tables/component_list_table.html
similarity index 100%
rename from component_catalog/templates/component_catalog/includes/component_list_table.html
rename to component_catalog/templates/component_catalog/tables/component_list_table.html
diff --git a/component_catalog/templates/component_catalog/includes/package_list_table.html b/component_catalog/templates/component_catalog/tables/package_list_table.html
similarity index 100%
rename from component_catalog/templates/component_catalog/includes/package_list_table.html
rename to component_catalog/templates/component_catalog/tables/package_list_table.html
diff --git a/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html b/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html
index 136ef7fe..77b010f8 100644
--- a/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html
+++ b/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html
@@ -34,10 +34,14 @@
-
+ {% if vulnerability.resource_url %}
+
+ {{ vulnerability.vulnerability_id }}
+
+
+ {% else %}
{{ vulnerability.vulnerability_id }}
-
-
+ {% endif %}
|
diff --git a/component_catalog/tests/__init__.py b/component_catalog/tests/__init__.py
index f94e5fc5..f364fc75 100644
--- a/component_catalog/tests/__init__.py
+++ b/component_catalog/tests/__init__.py
@@ -8,8 +8,8 @@
from component_catalog.models import Component
from component_catalog.models import Package
-from component_catalog.models import Vulnerability
from dje.tests import make_string
+from vulnerabilities.tests import make_vulnerability
def make_package(dataspace, package_url=None, is_vulnerable=False, **data):
@@ -42,19 +42,3 @@ def make_component(dataspace, is_vulnerable=False, **data):
make_vulnerability(dataspace, affecting=component)
return component
-
-
-def make_vulnerability(dataspace, affecting=None, **data):
- """Create a vulnerability for test purposes."""
- if "vulnerability_id" not in data:
- data["vulnerability_id"] = f"VCID-0000-{make_string(4)}"
-
- vulnerability = Vulnerability.objects.create(
- dataspace=dataspace,
- **data,
- )
-
- if affecting:
- vulnerability.add_affected(affecting)
-
- return vulnerability
diff --git a/component_catalog/tests/test_admin.py b/component_catalog/tests/test_admin.py
index 5d70ace2..6d11ffe4 100644
--- a/component_catalog/tests/test_admin.py
+++ b/component_catalog/tests/test_admin.py
@@ -1980,7 +1980,7 @@ def test_package_changeform_filename_validation(self):
self.assertEqual(200, response.status_code)
self.assertEqual(errors, response.context_data["adminform"].form.errors)
- @mock.patch("component_catalog.models.VulnerabilityMixin.fetch_vulnerabilities")
+ @mock.patch("vulnerabilities.models.AffectedByVulnerabilityMixin.fetch_vulnerabilities")
def test_package_changeform_fetch_vulnerabilities(self, mock_fetch_vulnerabilities):
mock_fetch_vulnerabilities.return_value = None
self.dataspace1.enable_vulnerablecodedb_access = True
diff --git a/component_catalog/tests/test_command.py b/component_catalog/tests/test_command.py
index 3d0def24..9de66692 100644
--- a/component_catalog/tests/test_command.py
+++ b/component_catalog/tests/test_command.py
@@ -7,7 +7,6 @@
#
from io import StringIO
-from unittest import mock
from django.core import management
from django.core.management.base import CommandError
@@ -126,26 +125,3 @@ def test_componentfrompackage_management_command(self):
expected = "Error: the following arguments are required: dataspace, username"
self.assertEqual(expected, str(error.exception))
-
- @mock.patch("component_catalog.vulnerabilities.fetch_from_vulnerablecode")
- @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured")
- def test_fetchvulnerabilities_management_command(self, mock_is_configured, mock_fetch):
- mock_is_configured.return_value = False
- self.assertFalse(self.dataspace.enable_vulnerablecodedb_access)
-
- options = [self.dataspace.name]
- with self.assertRaises(CommandError) as error:
- management.call_command("fetchvulnerabilities", *options)
- expected = "VulnerableCode is not enabled on this Dataspace."
- self.assertEqual(expected, str(error.exception))
-
- self.dataspace.enable_vulnerablecodedb_access = True
- self.dataspace.save()
- with self.assertRaises(CommandError) as error:
- management.call_command("fetchvulnerabilities", *options)
- expected = "VulnerableCode is not configured."
- self.assertEqual(expected, str(error.exception))
-
- mock_is_configured.return_value = True
- management.call_command("fetchvulnerabilities", *options)
- mock_fetch.assert_called_once()
diff --git a/component_catalog/tests/test_copy.py b/component_catalog/tests/test_copy.py
index ae7c326a..b979eb8e 100644
--- a/component_catalog/tests/test_copy.py
+++ b/component_catalog/tests/test_copy.py
@@ -12,10 +12,10 @@
from component_catalog.models import Package
from component_catalog.models import PackageAssignedLicense
-from component_catalog.models import Vulnerability
from component_catalog.tests import make_package
from dje.models import Dataspace
from dje.tests import create_superuser
+from vulnerabilities.models import Vulnerability
class ComponentCatalogCopyTestCase(TestCase):
diff --git a/component_catalog/tests/test_filters.py b/component_catalog/tests/test_filters.py
index 61ee9b76..1b2764ac 100644
--- a/component_catalog/tests/test_filters.py
+++ b/component_catalog/tests/test_filters.py
@@ -14,19 +14,18 @@
from component_catalog.filters import ComponentFilterSet
from component_catalog.filters import PackageFilterSet
-from component_catalog.filters import VulnerabilityFilterSet
from component_catalog.models import Component
from component_catalog.models import ComponentKeyword
from component_catalog.models import ComponentType
from component_catalog.tests import make_component
from component_catalog.tests import make_package
-from component_catalog.tests import make_vulnerability
from dje.models import Dataspace
from dje.tests import create_superuser
from dje.tests import create_user
from license_library.models import License
from organization.models import Owner
from policy.models import UsagePolicy
+from vulnerabilities.tests import make_vulnerability
class ComponentFilterSetTest(TestCase):
@@ -410,58 +409,3 @@ def test_package_filterset_affected_by_filter(self):
data = {"affected_by": vulnerability1.vulnerability_id}
filterset = PackageFilterSet(dataspace=self.dataspace, data=data)
self.assertQuerySetEqual(filterset.qs, [package1])
-
-
-class VulnerabilityFilterSetTestCase(TestCase):
- def setUp(self):
- self.dataspace = Dataspace.objects.create(name="Reference")
- self.vulnerability1 = make_vulnerability(self.dataspace, max_score=10.0)
- self.vulnerability2 = make_vulnerability(
- self.dataspace, max_score=5.5, aliases=["ALIAS-V2"]
- )
- self.vulnerability3 = make_vulnerability(self.dataspace, max_score=2.0)
- self.vulnerability4 = make_vulnerability(self.dataspace, max_score=None)
-
- def test_vulnerability_filterset_search(self):
- data = {"q": self.vulnerability1.vulnerability_id}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- self.assertQuerySetEqual(filterset.qs, [self.vulnerability1])
-
- data = {"q": "ALIAS-V2"}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- self.assertQuerySetEqual(filterset.qs, [self.vulnerability2])
-
- def test_vulnerability_filterset_sort_nulls_last_ordering(self):
- data = {"sort": "max_score"}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- expected = [
- self.vulnerability3,
- self.vulnerability2,
- self.vulnerability1,
- self.vulnerability4, # The max_score=None are always last
- ]
- self.assertQuerySetEqual(filterset.qs, expected)
-
- data = {"sort": "-max_score"}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- expected = [
- self.vulnerability1,
- self.vulnerability2,
- self.vulnerability3,
- self.vulnerability4, # The max_score=None are always last
- ]
- self.assertQuerySetEqual(filterset.qs, expected)
-
- def test_vulnerability_filterset_max_score(self):
- data = {"max_score": "critical"}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- self.assertQuerySetEqual(filterset.qs, [self.vulnerability1])
- data = {"max_score": "high"}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- self.assertQuerySetEqual(filterset.qs, [])
- data = {"max_score": "medium"}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- self.assertQuerySetEqual(filterset.qs, [self.vulnerability2])
- data = {"max_score": "low"}
- filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
- self.assertQuerySetEqual(filterset.qs, [self.vulnerability3])
diff --git a/component_catalog/tests/test_models.py b/component_catalog/tests/test_models.py
index 25749a53..5f7b2cbb 100644
--- a/component_catalog/tests/test_models.py
+++ b/component_catalog/tests/test_models.py
@@ -38,14 +38,10 @@
from component_catalog.models import Package
from component_catalog.models import PackageAlreadyExistsWarning
from component_catalog.models import Subcomponent
-from component_catalog.models import Vulnerability
-from component_catalog.tests import make_component
from component_catalog.tests import make_package
-from component_catalog.tests import make_vulnerability
from dejacode_toolkit import download
from dejacode_toolkit.download import DataCollectionException
from dejacode_toolkit.download import collect_package_data
-from dejacode_toolkit.vulnerablecode import VulnerableCode
from dje.copier import copy_object
from dje.models import Dataspace
from dje.models import History
@@ -2585,178 +2581,3 @@ def test_vulnerability_mixin_is_vulnerable_property(self):
package2 = make_package(self.dataspace)
self.assertTrue(package1.is_vulnerable)
self.assertFalse(package2.is_vulnerable)
-
- @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.request_get")
- def test_vulnerability_mixin_get_entry_for_package(self, mock_request_get):
- vulnerablecode = VulnerableCode(self.dataspace)
- package1 = make_package(self.dataspace, package_url="pkg:composer/guzzlehttp/psr7@1.9.0")
- response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
- mock_request_get.return_value = json.loads(response_file.read_text())
-
- affected_by_vulnerabilities = package1.get_entry_for_package(vulnerablecode)
- self.assertEqual(1, len(affected_by_vulnerabilities))
- self.assertEqual("VCID-j3au-usaz-aaag", affected_by_vulnerabilities[0]["vulnerability_id"])
-
- @mock.patch("component_catalog.models.VulnerabilityMixin.get_entry_for_package")
- @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured")
- def test_vulnerability_mixin_get_entry_from_vulnerablecode(
- self, mock_is_configured, mock_get_entry_for_package
- ):
- package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6")
- self.assertIsNone(package1.get_entry_from_vulnerablecode())
-
- mock_get_entry_for_package.return_value = None
- self.dataspace.enable_vulnerablecodedb_access = True
- self.dataspace.save()
- mock_is_configured.return_value = True
- package1.get_entry_from_vulnerablecode()
- mock_get_entry_for_package.assert_called_once()
-
- @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.request_get")
- @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured")
- def test_vulnerability_mixin_fetch_vulnerabilities(self, mock_is_configured, mock_request_get):
- mock_is_configured.return_value = True
- self.dataspace.enable_vulnerablecodedb_access = True
- self.dataspace.save()
- response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
- mock_request_get.return_value = json.loads(response_file.read_text())
-
- package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6")
- package1.fetch_vulnerabilities()
-
- self.assertEqual(1, Vulnerability.objects.scope(self.dataspace).count())
- self.assertEqual(1, package1.affected_by_vulnerabilities.count())
- vulnerability = package1.affected_by_vulnerabilities.get()
- self.assertEqual("VCID-j3au-usaz-aaag", vulnerability.vulnerability_id)
-
- def test_vulnerability_mixin_create_vulnerabilities(self):
- response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
- response_json = json.loads(response_file.read_text())
- vulnerabilities_data = response_json["results"][0]["affected_by_vulnerabilities"]
-
- package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6")
- package1.create_vulnerabilities(vulnerabilities_data)
-
- self.assertEqual(1, Vulnerability.objects.scope(self.dataspace).count())
- self.assertEqual(1, package1.affected_by_vulnerabilities.count())
- vulnerability = package1.affected_by_vulnerabilities.get()
- self.assertEqual("VCID-j3au-usaz-aaag", vulnerability.vulnerability_id)
-
- def test_vulnerability_model_affected_packages_m2m(self):
- package1 = make_package(self.dataspace)
- vulnerablity1 = make_vulnerability(dataspace=self.dataspace, affecting=package1)
- self.assertEqual(package1, vulnerablity1.affected_packages.get())
- self.assertEqual(vulnerablity1, package1.affected_by_vulnerabilities.get())
-
- def test_vulnerability_model_add_affected(self):
- vulnerablity1 = make_vulnerability(dataspace=self.dataspace)
- package1 = make_package(self.dataspace)
- package2 = make_package(self.dataspace)
- vulnerablity1.add_affected(package1)
- vulnerablity1.add_affected([package2])
- self.assertEqual(2, vulnerablity1.affected_packages.count())
-
- vulnerablity2 = make_vulnerability(dataspace=self.dataspace)
- component1 = make_component(self.dataspace)
- vulnerablity2.add_affected([component1, package1])
- self.assertQuerySetEqual(vulnerablity2.affected_packages.all(), [package1])
- self.assertQuerySetEqual(vulnerablity2.affected_components.all(), [component1])
-
- def test_vulnerability_model_fixed_packages_count_generated_field(self):
- vulnerablity1 = make_vulnerability(dataspace=self.dataspace)
- self.assertEqual(0, vulnerablity1.fixed_packages_count)
-
- vulnerablity1.fixed_packages = [
- {"purl": "pkg:pypi/gitpython@3.1.41", "is_vulnerable": True},
- {"purl": "pkg:pypi/gitpython@3.2", "is_vulnerable": False},
- ]
- vulnerablity1.save()
- vulnerablity1.refresh_from_db()
- self.assertEqual(2, vulnerablity1.fixed_packages_count)
-
- def test_vulnerability_model_create_from_data(self):
- package1 = make_package(self.dataspace)
- vulnerability_data = {
- "vulnerability_id": "VCID-q4q6-yfng-aaag",
- "summary": "In Django 3.2 before 3.2.25, 4.2 before 4.2.11, and 5.0.",
- "aliases": ["CVE-2024-27351", "GHSA-vm8q-m57g-pff3", "PYSEC-2024-47"],
- "references": [
- {
- "reference_url": "https://access.redhat.com/hydra/rest/"
- "securitydata/cve/CVE-2024-27351.json",
- "reference_id": "",
- "scores": [
- {
- "value": "7.5",
- "scoring_system": "cvssv3",
- "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
- }
- ],
- },
- ],
- }
-
- vulnerability1 = Vulnerability.create_from_data(
- dataspace=self.dataspace,
- data=vulnerability_data,
- affecting=package1,
- )
- self.assertEqual(vulnerability_data["vulnerability_id"], vulnerability1.vulnerability_id)
- self.assertEqual(vulnerability_data["summary"], vulnerability1.summary)
- self.assertEqual(vulnerability_data["aliases"], vulnerability1.aliases)
- self.assertEqual(vulnerability_data["references"], vulnerability1.references)
- self.assertEqual(7.5, vulnerability1.min_score)
- self.assertEqual(7.5, vulnerability1.max_score)
- self.assertQuerySetEqual(vulnerability1.affected_packages.all(), [package1])
-
- def test_vulnerability_model_create_from_data_computed_scores(self):
- response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
- json_data = json.loads(response_file.read_text())
- affected_by_vulnerabilities = json_data["results"][0]["affected_by_vulnerabilities"]
- vulnerability1 = Vulnerability.create_from_data(
- dataspace=self.dataspace,
- data=affected_by_vulnerabilities[0],
- )
- self.assertEqual(2.1, vulnerability1.min_score)
- self.assertEqual(7.5, vulnerability1.max_score)
-
- def test_vulnerability_model_queryset_count_methods(self):
- package1 = make_package(self.dataspace)
- package2 = make_package(self.dataspace)
- vulnerablity1 = make_vulnerability(dataspace=self.dataspace)
- vulnerablity1.add_affected([package1, package2])
- make_product(self.dataspace, inventory=[package1, package2])
-
- qs = (
- Vulnerability.objects.scope(self.dataspace)
- .with_affected_products_count()
- .with_affected_packages_count()
- )
- self.assertEqual(2, qs[0].affected_packages_count)
- self.assertEqual(1, qs[0].affected_products_count)
-
- def test_vulnerability_model_as_cyclonedx(self):
- response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
- json_data = json.loads(response_file.read_text())
- affected_by_vulnerabilities = json_data["results"][0]["affected_by_vulnerabilities"]
- vulnerability1 = Vulnerability.create_from_data(
- dataspace=self.dataspace,
- data=affected_by_vulnerabilities[0],
- )
- package1 = make_package(
- self.dataspace,
- package_url="pkg:type/name@1.9.0",
- uuid="dd0afd00-89bd-46d6-b1f0-57b553c44d32",
- )
-
- vulnerability1_as_cdx = vulnerability1.as_cyclonedx(affected_instances=[package1])
- as_dict = json.loads(vulnerability1_as_cdx.as_json())
- as_dict.pop("ratings", None) # The sorting is inconsistent
- results = json.dumps(as_dict, indent=2)
-
- expected_location = self.data / "vulnerabilities" / "idna_3.6_as_cyclonedx.json"
- # Uncomment to regen the expected results
- # if True:
- # expected_location.write_text(results)
-
- self.assertJSONEqual(results, expected_location.read_text())
diff --git a/component_catalog/tests/test_views.py b/component_catalog/tests/test_views.py
index 7e3f31d8..7dcd5f2f 100644
--- a/component_catalog/tests/test_views.py
+++ b/component_catalog/tests/test_views.py
@@ -39,10 +39,6 @@
from component_catalog.models import ComponentType
from component_catalog.models import Package
from component_catalog.models import Subcomponent
-from component_catalog.models import Vulnerability
-from component_catalog.tests import make_component
-from component_catalog.tests import make_package
-from component_catalog.tests import make_vulnerability
from component_catalog.views import ComponentAddView
from component_catalog.views import ComponentListView
from component_catalog.views import PackageTabScanView
@@ -51,7 +47,6 @@
from dejacode_toolkit.vulnerablecode import get_plain_purls
from dje.copier import copy_object
from dje.models import Dataspace
-from dje.models import DataspaceConfiguration
from dje.models import ExternalReference
from dje.models import ExternalSource
from dje.models import History
@@ -72,6 +67,7 @@
from product_portfolio.models import ProductItemPurpose
from product_portfolio.models import ProductPackage
from product_portfolio.models import ProductRelationStatus
+from vulnerabilities.tests import make_vulnerability
from workflow.models import Request
from workflow.models import RequestTemplate
@@ -1243,7 +1239,7 @@ def test_package_list_multi_send_about_files_view(self):
def test_package_details_view_num_queries(self):
self.client.login(username=self.super_user.username, password="secret")
- with self.assertNumQueries(29):
+ with self.assertNumQueries(28):
self.client.get(self.package1.get_absolute_url())
def test_package_details_view_content(self):
@@ -1735,7 +1731,7 @@ def test_package_create_ajax_view(self):
Package.objects.filter(download_url=collected_data["download_url"]).exists()
)
- @mock.patch("component_catalog.models.VulnerabilityMixin.fetch_vulnerabilities")
+ @mock.patch("vulnerabilities.models.AffectedByVulnerabilityMixin.fetch_vulnerabilities")
def test_package_create_ajax_view_fetch_vulnerabilities(self, mock_fetch_vulnerabilities):
mock_fetch_vulnerabilities.return_value = None
package_add_url = reverse("component_catalog:package_add_urls")
@@ -3400,7 +3396,7 @@ def test_component_catalog_package_add_view_initial_data(
}
self.assertEqual(expected, response.context["form"].initial)
- @mock.patch("component_catalog.models.VulnerabilityMixin.fetch_vulnerabilities")
+ @mock.patch("vulnerabilities.models.AffectedByVulnerabilityMixin.fetch_vulnerabilities")
def test_component_catalog_package_add_view_fetch_vulnerabilities(
self, mock_fetch_vulnerabilities
):
@@ -4815,63 +4811,3 @@ def test_anonymous_user_cannot_access_reference_data(self):
self.assertEqual(404, self.client.get(self.d1c2.get_absolute_url()).status_code)
self.assertEqual(200, self.client.get(self.dmc1.get_absolute_url()).status_code)
self.assertEqual(200, self.client.get(self.dmc2.get_absolute_url()).status_code)
-
-
-class VulnerabilityViewsTestCase(TestCase):
- def setUp(self):
- self.dataspace = Dataspace.objects.create(
- name="Dataspace",
- enable_vulnerablecodedb_access=True,
- )
- DataspaceConfiguration.objects.create(
- dataspace=self.dataspace,
- vulnerablecode_url="vulnerablecode_url/",
- )
- self.super_user = create_superuser("super_user", self.dataspace)
-
- self.component1 = make_component(self.dataspace)
- self.component2 = make_component(self.dataspace)
- self.package1 = make_package(self.dataspace)
- self.package2 = make_package(self.dataspace)
- self.vulnerability_p1 = make_vulnerability(self.dataspace, affecting=self.component1)
- self.vulnerability_c1 = make_vulnerability(self.dataspace, affecting=self.package1)
- self.vulnerability1 = make_vulnerability(self.dataspace)
-
- def test_vulnerability_list_view_num_queries(self):
- self.client.login(username=self.super_user.username, password="secret")
- with self.assertNumQueries(8):
- response = self.client.get(reverse("component_catalog:vulnerability_list"))
-
- vulnerability_count = Vulnerability.objects.count()
- expected = f'{vulnerability_count} results'
- self.assertContains(response, expected, html=True)
-
- def test_vulnerability_list_view_enable_vulnerablecodedb_access(self):
- self.client.login(username=self.super_user.username, password="secret")
- vulnerability_list_url = reverse("component_catalog:vulnerability_list")
- response = self.client.get(vulnerability_list_url)
- self.assertEqual(200, response.status_code)
- vulnerability_header_link = (
- f''
- )
- self.assertContains(response, vulnerability_header_link)
-
- self.dataspace.enable_vulnerablecodedb_access = False
- self.dataspace.save()
- response = self.client.get(reverse("component_catalog:vulnerability_list"))
- self.assertEqual(404, response.status_code)
-
- response = self.client.get(reverse("component_catalog:package_list"))
- self.assertNotContains(response, vulnerability_header_link)
-
- def test_vulnerability_list_view_vulnerability_id_link(self):
- self.client.login(username=self.super_user.username, password="secret")
- response = self.client.get(reverse("component_catalog:vulnerability_list"))
- expected = f"""
-
- {self.vulnerability1.vulnerability_id}
-
-
- """
- self.assertContains(response, expected, html=True)
diff --git a/component_catalog/urls.py b/component_catalog/urls.py
index 50e87277..1c3deddb 100644
--- a/component_catalog/urls.py
+++ b/component_catalog/urls.py
@@ -21,7 +21,6 @@
from component_catalog.views import PackageTabScanView
from component_catalog.views import PackageUpdateView
from component_catalog.views import ScanListView
-from component_catalog.views import VulnerabilityListView
from component_catalog.views import component_create_ajax_view
from component_catalog.views import delete_scan_view
from component_catalog.views import package_create_ajax_view
@@ -149,17 +148,9 @@
),
]
-vulnerabilities_patterns = [
- path(
- "vulnerabilities/",
- VulnerabilityListView.as_view(),
- name="vulnerability_list",
- ),
-]
-
# WARNING: we moved the components/ patterns from the include to the following
-# since we need the packages/ and scans/ to be register on the root "/"
+# since we need the packages/ and scans/ to be registered on the root "/"
def component_path(path_segment, view):
@@ -248,4 +239,4 @@ def component_path(path_segment, view):
]
-urlpatterns = packages_patterns + component_patterns + scans_patterns + vulnerabilities_patterns
+urlpatterns = packages_patterns + component_patterns + scans_patterns
diff --git a/component_catalog/views.py b/component_catalog/views.py
index aa703af2..05bca001 100644
--- a/component_catalog/views.py
+++ b/component_catalog/views.py
@@ -22,7 +22,6 @@
from django.core import signing
from django.core.validators import EMPTY_VALUES
from django.db.models import Count
-from django.db.models import F
from django.db.models import Prefetch
from django.http import FileResponse
from django.http import Http404
@@ -53,7 +52,6 @@
from component_catalog.filters import ComponentFilterSet
from component_catalog.filters import PackageFilterSet
-from component_catalog.filters import VulnerabilityFilterSet
from component_catalog.forms import AddMultipleToComponentForm
from component_catalog.forms import AddToComponentForm
from component_catalog.forms import AddToProductAdminForm
@@ -73,13 +71,11 @@
from component_catalog.models import Package
from component_catalog.models import PackageAlreadyExistsWarning
from component_catalog.models import Subcomponent
-from component_catalog.models import Vulnerability
from dejacode_toolkit.download import DataCollectionException
from dejacode_toolkit.purldb import PurlDB
from dejacode_toolkit.scancodeio import ScanCodeIO
from dejacode_toolkit.scancodeio import get_package_download_url
from dejacode_toolkit.scancodeio import get_scan_results_as_file_url
-from dejacode_toolkit.vulnerablecode import VulnerableCode
from dje import tasks
from dje.client_data import add_client_data
from dje.models import DejacodeUser
@@ -259,7 +255,6 @@ def tab_vulnerabilities(self):
if not vulnerabilities_qs:
return
- vulnerablecode = VulnerableCode(self.object.dataspace)
label = (
f"Vulnerabilities"
f' {len(vulnerabilities_qs)}'
@@ -273,7 +268,6 @@ def tab_vulnerabilities(self):
context = {
"vulnerabilities": vulnerabilities,
- "vulnerablecode_url": vulnerablecode.service_url,
}
return {
@@ -352,7 +346,7 @@ class ComponentListView(
add_to_product_perm = "product_portfolio.add_productcomponent"
template_name = "component_catalog/base_component_package_list.html"
filterset_class = ComponentFilterSet
- template_list_table = "component_catalog/includes/component_list_table.html"
+ template_list_table = "component_catalog/tables/component_list_table.html"
include_reference_dataspace = True
put_results_in_session = True
paginate_by = settings.PAGINATE_BY or 200
@@ -966,7 +960,7 @@ class PackageListView(
add_to_product_perm = "product_portfolio.add_productpackage"
filterset_class = PackageFilterSet
template_name = "component_catalog/package_list.html"
- template_list_table = "component_catalog/includes/package_list_table.html"
+ template_list_table = "component_catalog/tables/package_list_table.html"
include_reference_dataspace = True
put_results_in_session = True
table_headers = (
@@ -2479,57 +2473,3 @@ def get_tab_fields(self):
tab_fields.extend(get_purldb_tab_fields(purldb_entry, user.dataspace))
return {"fields": tab_fields}
-
-
-class VulnerabilityListView(
- LoginRequiredMixin,
- DataspacedFilterView,
-):
- model = Vulnerability
- filterset_class = VulnerabilityFilterSet
- template_name = "component_catalog/vulnerability_list.html"
- template_list_table = "component_catalog/tables/vulnerability_list_table.html"
- table_headers = (
- Header("vulnerability_id", _("Vulnerability")),
- Header("aliases", _("Aliases")),
- # Keep `max_score` to enable column sorting
- Header("max_score", _("Score"), help_text="Severity score range", filter="max_score"),
- Header("summary", _("Summary")),
- Header("affected_products_count", _("Affected products"), help_text="Affected products"),
- Header("affected_packages_count", _("Affected packages"), help_text="Affected packages"),
- Header("fixed_packages_count", _("Fixed by"), help_text="Fixed by packages"),
- )
-
- def get_queryset(self):
- return (
- super()
- .get_queryset()
- .only(
- "uuid",
- "vulnerability_id",
- "aliases",
- "summary",
- "fixed_packages_count",
- "max_score",
- "min_score",
- "created_date",
- "last_modified_date",
- "dataspace",
- )
- .with_affected_products_count()
- .with_affected_packages_count()
- .order_by(
- F("max_score").desc(nulls_last=True),
- "-min_score",
- )
- )
-
- def get_context_data(self, **kwargs):
- context_data = super().get_context_data(**kwargs)
-
- if not self.dataspace.enable_vulnerablecodedb_access:
- raise Http404("VulnerableCode access is not enabled.")
-
- vulnerablecode = VulnerableCode(self.dataspace)
- context_data["vulnerablecode_url"] = vulnerablecode.service_url
- return context_data
diff --git a/dejacode/settings.py b/dejacode/settings.py
index 953fcc25..80a69249 100644
--- a/dejacode/settings.py
+++ b/dejacode/settings.py
@@ -344,6 +344,7 @@ def gettext_noop(s):
"purldb",
"policy",
"notification",
+ "vulnerabilities",
]
EXTRA_APPS = env.list("EXTRA_APPS", default=[])
diff --git a/dejacode/urls.py b/dejacode/urls.py
index 8bfabfe8..e7f862e3 100644
--- a/dejacode/urls.py
+++ b/dejacode/urls.py
@@ -142,6 +142,7 @@
path("owners/", include(("organization.urls", "organization"))),
path("requests/", include(("workflow.urls", "workflow"))),
path("reports/", include(("reporting.urls", "reporting"))),
+ path("vulnerabilities/", include(("vulnerabilities.urls", "vulnerabilities"))),
path("global_search/", GlobalSearchListView.as_view(), name="global_search"),
]
diff --git a/dje/management/commands/dumpdataset.py b/dje/management/commands/dumpdataset.py
index 5b693da6..7911a24a 100644
--- a/dje/management/commands/dumpdataset.py
+++ b/dje/management/commands/dumpdataset.py
@@ -41,6 +41,7 @@
class ExcludeFieldsSerializer(Serializer):
exclude_fields = [
"request_count",
+ "affected_by_vulnerabilities",
]
def handle_field(self, obj, field):
diff --git a/dje/management/commands/flushdataset.py b/dje/management/commands/flushdataset.py
index b8193060..c20d3345 100644
--- a/dje/management/commands/flushdataset.py
+++ b/dje/management/commands/flushdataset.py
@@ -18,6 +18,7 @@
from dje.models import ExternalSource
from dje.models import get_unsecured_manager
from notification.models import Webhook
+from vulnerabilities.models import Vulnerability
class Command(DataspacedCommand):
@@ -55,6 +56,7 @@ def handle(self, *args, **options):
ExternalReference,
ExternalSource,
Webhook,
+ Vulnerability,
]
)
diff --git a/dje/tasks.py b/dje/tasks.py
index a13f1cba..806cdd89 100644
--- a/dje/tasks.py
+++ b/dje/tasks.py
@@ -304,7 +304,7 @@ def improve_packages_from_purldb(product_uuid, user_uuid):
@job("default", timeout="3h")
def update_vulnerabilities():
"""Fetch vulnerabilities for all Dataspaces that enable vulnerablecodedb access."""
- from component_catalog.vulnerabilities import fetch_from_vulnerablecode
+ from vulnerabilities.fetch import fetch_from_vulnerablecode
logger.info("Entering update_vulnerabilities task")
Dataspace = apps.get_model("dje", "Dataspace")
diff --git a/dje/templates/admin/base_site.html b/dje/templates/admin/base_site.html
index ae0db44c..f413d611 100644
--- a/dje/templates/admin/base_site.html
+++ b/dje/templates/admin/base_site.html
@@ -42,7 +42,7 @@
{% url 'workflow:request_list' as request_list_url %}
{% url 'component_catalog:scan_list' as scan_list_url %}
{% url 'purldb:purldb_list' as purldb_list_url %}
- {% url 'component_catalog:vulnerability_list' as vulnerability_list_url %}
+ {% url 'vulnerabilities:vulnerability_list' as vulnerability_list_url %}
{% url 'api_v2:api-root' as api_root_url %}
{% if report_list_url or request_list_url or api_root_url %}
diff --git a/dje/templates/includes/navbar_header.html b/dje/templates/includes/navbar_header.html
index 44948a75..90ca1343 100644
--- a/dje/templates/includes/navbar_header.html
+++ b/dje/templates/includes/navbar_header.html
@@ -11,7 +11,7 @@
{% url 'workflow:request_list' as request_list_url %}
{% url 'component_catalog:scan_list' as scan_list_url %}
{% url 'purldb:purldb_list' as purldb_list_url %}
-{% url 'component_catalog:vulnerability_list' as vulnerability_list_url %}
+{% url 'vulnerabilities:vulnerability_list' as vulnerability_list_url %}
{% url 'django_registration_register' as register_url %}
{% url 'api_v2:api-root' as api_root_url %}
{% url 'account_profile' as account_profile_url %}
diff --git a/dje/tests/test_forms.py b/dje/tests/test_forms.py
index 668c90ca..ed21e465 100644
--- a/dje/tests/test_forms.py
+++ b/dje/tests/test_forms.py
@@ -110,8 +110,8 @@ def test_tabs_permission_formset_load_perms(self):
def test_copy_defaults_form_get_all_dataspaced_models(self):
dataspaced_models = CopyDefaultsForm.get_all_dataspaced_models()
- self.assertEqual(9, len(dataspaced_models))
- self.assertEqual(8, len(dataspaced_models.get("Component Catalog")))
+ self.assertEqual(10, len(dataspaced_models))
+ self.assertEqual(7, len(dataspaced_models.get("Component Catalog")))
self.assertIn("Subcomponent", str(dataspaced_models.get("Component Catalog")))
def test_copy_defaults_formset_serialize_perms(self):
diff --git a/dje/tests/test_outputs.py b/dje/tests/test_outputs.py
index 03406e1a..7cdcb152 100644
--- a/dje/tests/test_outputs.py
+++ b/dje/tests/test_outputs.py
@@ -11,7 +11,6 @@
from cyclonedx.model import bom as cyclonedx_bom
from component_catalog.tests import make_package
-from component_catalog.tests import make_vulnerability
from dejacode import __version__ as dejacode_version
from dje import outputs
from dje.models import Dataspace
@@ -19,6 +18,7 @@
from dje.tests import create_user
from product_portfolio.models import Product
from product_portfolio.tests import make_product_package
+from vulnerabilities.tests import make_vulnerability
class OutputsTestCase(TestCase):
diff --git a/dje/tests/testfiles/test_dataset_cc_only.json b/dje/tests/testfiles/test_dataset_cc_only.json
index 63648859..cb14fe0a 100644
--- a/dje/tests/testfiles/test_dataset_cc_only.json
+++ b/dje/tests/testfiles/test_dataset_cc_only.json
@@ -100,8 +100,7 @@
"acceptable_linkages": null,
"export_restrictions": "",
"approved_download_location": "",
- "approved_community_interaction": "",
- "affected_by_vulnerabilities": []
+ "approved_community_interaction": ""
}
},
{
@@ -171,8 +170,7 @@
"acceptable_linkages": null,
"export_restrictions": "",
"approved_download_location": "",
- "approved_community_interaction": "",
- "affected_by_vulnerabilities": []
+ "approved_community_interaction": ""
}
},
{
@@ -313,8 +311,7 @@
"api_data_url": "",
"datasource_id": "",
"file_references": [],
- "parties": [],
- "affected_by_vulnerabilities": []
+ "parties": []
}
},
{
diff --git a/dje/tests/testfiles/test_dataset_pp_only.json b/dje/tests/testfiles/test_dataset_pp_only.json
index a71b26c2..fab48776 100644
--- a/dje/tests/testfiles/test_dataset_pp_only.json
+++ b/dje/tests/testfiles/test_dataset_pp_only.json
@@ -47,8 +47,7 @@
"api_data_url": "",
"datasource_id": "",
"file_references": [],
- "parties": [],
- "affected_by_vulnerabilities": []
+ "parties": []
}
},
{
diff --git a/product_portfolio/models.py b/product_portfolio/models.py
index ed05a489..d4355f16 100644
--- a/product_portfolio/models.py
+++ b/product_portfolio/models.py
@@ -28,9 +28,7 @@
from component_catalog.models import KeywordsMixin
from component_catalog.models import LicenseExpressionMixin
from component_catalog.models import Package
-from component_catalog.models import Vulnerability
from component_catalog.models import component_mixin_factory
-from component_catalog.vulnerabilities import fetch_for_queryset
from dje import tasks
from dje.fields import LastModifiedByField
from dje.models import DataspacedManager
@@ -43,6 +41,8 @@
from dje.validators import generic_uri_validator
from dje.validators import validate_url_segment
from dje.validators import validate_version
+from vulnerabilities.fetch import fetch_for_queryset
+from vulnerabilities.models import Vulnerability
RELATION_LICENSE_EXPRESSION_HELP_TEXT = _(
"The License Expression assigned to a DejaCode Product Package or Product "
diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html
index f189c4d3..1931d05d 100644
--- a/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html
+++ b/product_portfolio/templates/product_portfolio/tabs/tab_vulnerabilities.html
@@ -7,10 +7,14 @@
-
+ {% if vulnerability.resource_url %}
+
+ {{ vulnerability.vulnerability_id }}
+
+
+ {% else %}
{{ vulnerability.vulnerability_id }}
-
-
+ {% endif %}
|
diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py
index 6c48c812..505a84f2 100644
--- a/product_portfolio/tests/test_views.py
+++ b/product_portfolio/tests/test_views.py
@@ -29,7 +29,6 @@
from component_catalog.models import ComponentKeyword
from component_catalog.models import Package
from component_catalog.tests import make_package
-from component_catalog.tests import make_vulnerability
from dejacode_toolkit import scancodeio
from dje.models import Dataspace
from dje.models import History
@@ -60,6 +59,7 @@
from product_portfolio.tests import make_product
from product_portfolio.tests import make_product_package
from product_portfolio.views import ManageComponentGridView
+from vulnerabilities.tests import make_vulnerability
from workflow.models import Request
from workflow.models import RequestTemplate
diff --git a/product_portfolio/views.py b/product_portfolio/views.py
index c07bfb41..df994e7c 100644
--- a/product_portfolio/views.py
+++ b/product_portfolio/views.py
@@ -55,13 +55,11 @@
from openpyxl.styles import NamedStyle
from openpyxl.styles import Side
-from component_catalog.filters import VulnerabilityFilterSet
from component_catalog.forms import ComponentAjaxForm
from component_catalog.license_expression_dje import build_licensing
from component_catalog.license_expression_dje import parse_expression
from component_catalog.models import Component
from component_catalog.models import Package
-from component_catalog.models import Vulnerability
from dejacode_toolkit.purldb import PurlDB
from dejacode_toolkit.scancodeio import ScanCodeIO
from dejacode_toolkit.scancodeio import get_hash_uid
@@ -127,6 +125,8 @@
from product_portfolio.models import ProductDependency
from product_portfolio.models import ProductPackage
from product_portfolio.models import ScanCodeProject
+from vulnerabilities.filters import VulnerabilityFilterSet
+from vulnerabilities.models import Vulnerability
class BaseProductViewMixin:
@@ -1136,7 +1136,6 @@ def get_context_data(self, **kwargs):
"page_obj": page_obj,
"total_count": total_count,
"search_query": self.request.GET.get("vulnerabilities-q", ""),
- "vulnerablecode_url": VulnerableCode(product.dataspace).service_url,
}
)
diff --git a/reporting/forms.py b/reporting/forms.py
index b4f2b512..d5e65835 100644
--- a/reporting/forms.py
+++ b/reporting/forms.py
@@ -28,7 +28,6 @@
from component_catalog.models import PackageAssignedLicense
from component_catalog.models import Subcomponent
from component_catalog.models import SubcomponentAssignedLicense
-from component_catalog.models import Vulnerability
from dje.forms import DataspacedAdminForm
from dje.mass_update import DejacodeMassUpdateForm
from dje.models import DejacodeUser
@@ -73,6 +72,7 @@
from reporting.models import Query
from reporting.models import Report
from reporting.models import get_reportable_models
+from vulnerabilities.models import Vulnerability
from workflow.models import Request
from workflow.models import RequestTemplate
diff --git a/vulnerabilities/__init__.py b/vulnerabilities/__init__.py
new file mode 100644
index 00000000..f61c4aae
--- /dev/null
+++ b/vulnerabilities/__init__.py
@@ -0,0 +1,7 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
diff --git a/vulnerabilities/apps.py b/vulnerabilities/apps.py
new file mode 100644
index 00000000..3b92523d
--- /dev/null
+++ b/vulnerabilities/apps.py
@@ -0,0 +1,13 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.apps import AppConfig
+
+
+class VulnerabilitiesConfig(AppConfig):
+ name = "vulnerabilities"
diff --git a/component_catalog/vulnerabilities.py b/vulnerabilities/fetch.py
similarity index 98%
rename from component_catalog/vulnerabilities.py
rename to vulnerabilities/fetch.py
index a946ebac..e5fe9bc9 100644
--- a/component_catalog/vulnerabilities.py
+++ b/vulnerabilities/fetch.py
@@ -14,10 +14,10 @@
from component_catalog.models import PACKAGE_URL_FIELDS
from component_catalog.models import Package
-from component_catalog.models import Vulnerability
from dejacode_toolkit.vulnerablecode import VulnerableCode
from dje.utils import chunked_queryset
from dje.utils import humanize_time
+from vulnerabilities.models import Vulnerability
def fetch_from_vulnerablecode(dataspace, batch_size, timeout, log_func=None):
diff --git a/vulnerabilities/filters.py b/vulnerabilities/filters.py
new file mode 100644
index 00000000..e1ae9e5a
--- /dev/null
+++ b/vulnerabilities/filters.py
@@ -0,0 +1,97 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.db.models import F
+from django.utils.translation import gettext_lazy as _
+
+import django_filters
+
+from dje.filters import DataspacedFilterSet
+from dje.filters import SearchFilter
+from dje.widgets import DropDownRightWidget
+from dje.widgets import SortDropDownWidget
+from vulnerabilities.models import Vulnerability
+
+
+class NullsLastOrderingFilter(django_filters.OrderingFilter):
+ """
+ A custom ordering filter that ensures null values are sorted last.
+
+ When sorting by fields with potential null values, this filter modifies the
+ ordering to use Django's `nulls_last` clause for better handling of null values,
+ whether in ascending or descending order.
+ """
+
+ def filter(self, qs, value):
+ if not value:
+ return qs
+
+ ordering = []
+ for field in value:
+ if field.startswith("-"):
+ field_name = field[1:]
+ ordering.append(F(field_name).desc(nulls_last=True))
+ else:
+ ordering.append(F(field).asc(nulls_last=True))
+
+ return qs.order_by(*ordering)
+
+
+vulnerability_score_ranges = {
+ "low": (0.1, 3),
+ "medium": (4.0, 6.9),
+ "high": (7.0, 8.9),
+ "critical": (9.0, 10.0),
+}
+
+SCORE_CHOICES = [
+ (key, f"{key.capitalize()} ({value[0]} - {value[1]})")
+ for key, value in vulnerability_score_ranges.items()
+]
+
+
+class VulnerabilityFilterSet(DataspacedFilterSet):
+ q = SearchFilter(
+ label=_("Search"),
+ search_fields=["vulnerability_id", "aliases"],
+ )
+ sort = NullsLastOrderingFilter(
+ label=_("Sort"),
+ fields=[
+ "max_score",
+ "min_score",
+ "affected_products_count",
+ "affected_packages_count",
+ "fixed_packages_count",
+ "created_date",
+ "last_modified_date",
+ ],
+ widget=SortDropDownWidget,
+ )
+ max_score = django_filters.ChoiceFilter(
+ choices=SCORE_CHOICES,
+ method="filter_by_score_range",
+ label="Score Range",
+ help_text="Select a score range to filter.",
+ )
+
+ class Meta:
+ model = Vulnerability
+ fields = [
+ "q",
+ ]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.filters["max_score"].extra["widget"] = DropDownRightWidget(anchor=self.anchor)
+
+ def filter_by_score_range(self, queryset, name, value):
+ if value in vulnerability_score_ranges:
+ low, high = vulnerability_score_ranges[value]
+ return queryset.filter(max_score__gte=low, max_score__lte=high)
+ return queryset
diff --git a/vulnerabilities/management/__init__.py b/vulnerabilities/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/vulnerabilities/management/commands/__init__.py b/vulnerabilities/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/component_catalog/management/commands/fetchvulnerabilities.py b/vulnerabilities/management/commands/fetchvulnerabilities.py
similarity index 95%
rename from component_catalog/management/commands/fetchvulnerabilities.py
rename to vulnerabilities/management/commands/fetchvulnerabilities.py
index 55fce0c1..741be62d 100644
--- a/component_catalog/management/commands/fetchvulnerabilities.py
+++ b/vulnerabilities/management/commands/fetchvulnerabilities.py
@@ -8,9 +8,9 @@
from django.core.management.base import CommandError
-from component_catalog.vulnerabilities import fetch_from_vulnerablecode
from dejacode_toolkit.vulnerablecode import VulnerableCode
from dje.management.commands import DataspacedCommand
+from vulnerabilities.fetch import fetch_from_vulnerablecode
class Command(DataspacedCommand):
diff --git a/vulnerabilities/migrations/0001_initial.py b/vulnerabilities/migrations/0001_initial.py
new file mode 100644
index 00000000..72acf7f8
--- /dev/null
+++ b/vulnerabilities/migrations/0001_initial.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.0.6 on 2024-09-04 08:13
+
+import django.db.models.deletion
+import dje.fields
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('dje', '0004_dataspace_vulnerabilities_updated_at'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Vulnerability',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
+ ('created_date', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The date and time the object was created.')),
+ ('last_modified_date', models.DateTimeField(auto_now=True, db_index=True, help_text='The date and time the object was last modified.')),
+ ('vulnerability_id', models.CharField(help_text="A unique identifier for the vulnerability, prefixed with 'VCID-'. For example, 'VCID-2024-0001'.", max_length=20)),
+ ('summary', models.TextField(blank=True, help_text='A brief summary of the vulnerability, outlining its nature and impact.')),
+ ('aliases', dje.fields.JSONListField(blank=True, default=list, help_text="A list of aliases for this vulnerability, such as CVE identifiers (e.g., 'CVE-2017-1000136').")),
+ ('references', dje.fields.JSONListField(blank=True, default=list, help_text='A list of references for this vulnerability. Each reference includes a URL, an optional reference ID, scores, and the URL for further details. ')),
+ ('fixed_packages', dje.fields.JSONListField(blank=True, default=list, help_text='A list of packages that are not affected by this vulnerability.')),
+ ('fixed_packages_count', models.GeneratedField(db_persist=True, expression=models.Func(models.F('fixed_packages'), function='jsonb_array_length'), output_field=models.IntegerField())),
+ ('min_score', models.FloatField(blank=True, help_text='The minimum score of the range.', null=True)),
+ ('max_score', models.FloatField(blank=True, help_text='The maximum score of the range.', null=True)),
+ ('resource_url', models.URLField(blank=True, help_text='URL of the data source for this Vulnerability.', max_length=1024, verbose_name='Resource URL')),
+ ('dataspace', models.ForeignKey(editable=False, help_text='A Dataspace is an independent, exclusive set of DejaCode data, which can be either nexB master reference data or installation-specific data.', on_delete=django.db.models.deletion.PROTECT, to='dje.dataspace')),
+ ],
+ options={
+ 'verbose_name_plural': 'Vulnerabilities',
+ 'indexes': [models.Index(fields=['vulnerability_id'], name='vulnerabili_vulnera_92f044_idx')],
+ 'unique_together': {('dataspace', 'uuid'), ('dataspace', 'vulnerability_id')},
+ },
+ ),
+ ]
diff --git a/vulnerabilities/migrations/__init__.py b/vulnerabilities/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py
new file mode 100644
index 00000000..dbd789b4
--- /dev/null
+++ b/vulnerabilities/models.py
@@ -0,0 +1,452 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+import decimal
+import logging
+
+from django.contrib.postgres.fields import ArrayField
+from django.db import models
+from django.db.models import Count
+from django.utils.translation import gettext_lazy as _
+
+from cyclonedx.model import vulnerability as cdx_vulnerability
+
+from dje.fields import JSONListField
+from dje.models import DataspacedManager
+from dje.models import DataspacedModel
+from dje.models import DataspacedQuerySet
+from dje.models import HistoryDateFieldsMixin
+
+logger = logging.getLogger("dje")
+
+
+class VulnerabilityQuerySet(DataspacedQuerySet):
+ def with_affected_products_count(self):
+ """Annotate the QuerySet with the affected_products_count."""
+ return self.annotate(
+ affected_products_count=Count(
+ "affected_packages__productpackages__product", distinct=True
+ ),
+ )
+
+ def with_affected_packages_count(self):
+ """Annotate the QuerySet with the affected_packages_count."""
+ return self.annotate(
+ affected_packages_count=Count("affected_packages", distinct=True),
+ )
+
+
+class Vulnerability(HistoryDateFieldsMixin, DataspacedModel):
+ """
+ A software vulnerability with a unique identifier and alternate aliases.
+
+ Adapted from the VulnerableCode models at
+ https://github.com/nexB/vulnerablecode/blob/main/vulnerabilities/models.py#L164
+
+ Note that this model implements the HistoryDateFieldsMixin but not the
+ HistoryUserFieldsMixin as the Vulnerability records are usually created
+ automatically on object addition or during schedule tasks.
+ """
+
+ # The first set of fields are storing data as fetched from VulnerableCode
+ vulnerability_id = models.CharField(
+ max_length=20,
+ help_text=_(
+ "A unique identifier for the vulnerability, prefixed with 'VCID-'. "
+ "For example, 'VCID-2024-0001'."
+ ),
+ )
+ resource_url = models.URLField(
+ _("Resource URL"),
+ max_length=1024,
+ blank=True,
+ help_text=_("URL of the data source for this Vulnerability."),
+ )
+ summary = models.TextField(
+ help_text=_("A brief summary of the vulnerability, outlining its nature and impact."),
+ blank=True,
+ )
+ aliases = JSONListField(
+ blank=True,
+ help_text=_(
+ "A list of aliases for this vulnerability, such as CVE identifiers "
+ "(e.g., 'CVE-2017-1000136')."
+ ),
+ )
+ references = JSONListField(
+ blank=True,
+ help_text=_(
+ "A list of references for this vulnerability. Each reference includes a "
+ "URL, an optional reference ID, scores, and the URL for further details. "
+ ),
+ )
+ fixed_packages = JSONListField(
+ blank=True,
+ help_text=_("A list of packages that are not affected by this vulnerability."),
+ )
+ fixed_packages_count = models.GeneratedField(
+ expression=models.Func(models.F("fixed_packages"), function="jsonb_array_length"),
+ output_field=models.IntegerField(),
+ db_persist=True,
+ )
+ min_score = models.FloatField(
+ null=True,
+ blank=True,
+ help_text=_("The minimum score of the range."),
+ )
+ max_score = models.FloatField(
+ null=True,
+ blank=True,
+ help_text=_("The maximum score of the range."),
+ )
+
+ objects = DataspacedManager.from_queryset(VulnerabilityQuerySet)()
+
+ class Meta:
+ verbose_name_plural = "Vulnerabilities"
+ unique_together = (("dataspace", "vulnerability_id"), ("dataspace", "uuid"))
+ indexes = [
+ models.Index(fields=["vulnerability_id"]),
+ ]
+
+ def __str__(self):
+ return self.vulnerability_id
+
+ @property
+ def vcid(self):
+ return self.vulnerability_id
+
+ def add_affected(self, instances):
+ """
+ Assign the ``instances`` (Package or Component) as affected to this
+ vulnerability.
+ """
+ from component_catalog.models import Component
+ from component_catalog.models import Package
+
+ if not isinstance(instances, list):
+ instances = [instances]
+
+ for instance in instances:
+ if isinstance(instance, Package):
+ self.add_affected_packages([instance])
+ if isinstance(instance, Component):
+ self.add_affected_components([instance])
+
+ def add_affected_packages(self, packages):
+ """Assign the ``packages`` as affected to this vulnerability."""
+ through_defaults = {"dataspace_id": self.dataspace_id}
+ self.affected_packages.add(*packages, through_defaults=through_defaults)
+
+ def add_affected_components(self, components):
+ """Assign the ``components`` as affected to this vulnerability."""
+ through_defaults = {"dataspace_id": self.dataspace_id}
+ self.affected_components.add(*components, through_defaults=through_defaults)
+
+ @staticmethod
+ def range_to_values(self, range_str):
+ try:
+ min_score, max_score = range_str.split("-")
+ return float(min_score.strip()), float(max_score.strip())
+ except Exception:
+ return
+
+ @classmethod
+ def create_from_data(cls, dataspace, data, validate=False, affecting=None):
+ # Computing the min_score and max_score from the `references` as those data
+ # are not provided by the VulnerableCode API.
+ # https://github.com/aboutcode-org/vulnerablecode/issues/1573
+ # severity_range_score = data.get("severity_range_score")
+ # if severity_range_score:
+ # min_score, max_score = self.range_to_values(severity_range_score)
+ # data["min_score"] = min_score
+ # data["max_score"] = max_score
+
+ severities = [
+ score for reference in data.get("references") for score in reference.get("scores", [])
+ ]
+ if scores := cls.get_severity_scores(severities):
+ data["min_score"] = min(scores)
+ data["max_score"] = max(scores)
+
+ instance = super().create_from_data(user=dataspace, data=data, validate=False)
+
+ if affecting:
+ instance.add_affected(affecting)
+
+ return instance
+
+ @staticmethod
+ def get_severity_scores(severities):
+ score_map = {
+ "low": [0.1, 3],
+ "moderate": [4.0, 6.9],
+ "medium": [4.0, 6.9],
+ "high": [7.0, 8.9],
+ "important": [7.0, 8.9],
+ "critical": [9.0, 10.0],
+ }
+
+ consolidated_scores = []
+ for severity in severities:
+ score = severity.get("value")
+ try:
+ consolidated_scores.append(float(score))
+ except ValueError:
+ if score_range := score_map.get(score.lower(), None):
+ consolidated_scores.extend(score_range)
+
+ return consolidated_scores
+
+ def as_cyclonedx(self, affected_instances):
+ affects = [
+ cdx_vulnerability.BomTarget(ref=instance.cyclonedx_bom_ref)
+ for instance in affected_instances
+ ]
+
+ source = cdx_vulnerability.VulnerabilitySource(
+ name="VulnerableCode",
+ url=self.resource_url,
+ )
+
+ references = []
+ ratings = []
+ for reference in self.references:
+ reference_source = cdx_vulnerability.VulnerabilitySource(
+ url=reference.get("reference_url"),
+ )
+ references.append(
+ cdx_vulnerability.VulnerabilityReference(
+ id=reference.get("reference_id"),
+ source=reference_source,
+ )
+ )
+
+ for score_entry in reference.get("scores", []):
+ # CycloneDX only support a float value for the score field,
+ # where on the VulnerableCode data it can be either a score float value
+ # or a severity string value.
+ score_value = score_entry.get("value")
+ try:
+ score = decimal.Decimal(score_value)
+ severity = None
+ except decimal.DecimalException:
+ score = None
+ severity = getattr(
+ cdx_vulnerability.VulnerabilitySeverity,
+ score_value.upper(),
+ None,
+ )
+
+ ratings.append(
+ cdx_vulnerability.VulnerabilityRating(
+ source=reference_source,
+ score=score,
+ severity=severity,
+ vector=score_entry.get("scoring_elements"),
+ )
+ )
+
+ return cdx_vulnerability.Vulnerability(
+ id=self.vulnerability_id,
+ source=source,
+ description=self.summary,
+ affects=affects,
+ references=sorted(references),
+ ratings=ratings,
+ )
+
+
+class VulnerabilityAnalysisMixin(models.Model):
+ """Aligned with the cyclonedx.model.vulnerability.VulnerabilityAnalysis"""
+
+ # cyclonedx.model.impact_analysis.ImpactAnalysisState
+ class State(models.TextChoices):
+ RESOLVED = "resolved"
+ RESOLVED_WITH_PEDIGREE = "resolved_with_pedigree"
+ EXPLOITABLE = "exploitable"
+ IN_TRIAGE = "in_triage"
+ FALSE_POSITIVE = "false_positive"
+ NOT_AFFECTED = "not_affected"
+
+ # cyclonedx.model.impact_analysis.ImpactAnalysisJustification
+ class Justification(models.TextChoices):
+ CODE_NOT_PRESENT = "code_not_present"
+ CODE_NOT_REACHABLE = "code_not_reachable"
+ PROTECTED_AT_PERIMITER = "protected_at_perimeter"
+ PROTECTED_AT_RUNTIME = "protected_at_runtime"
+ PROTECTED_BY_COMPILER = "protected_by_compiler"
+ PROTECTED_BY_MITIGATING_CONTROL = "protected_by_mitigating_control"
+ REQUIRES_CONFIGURATION = "requires_configuration"
+ REQUIRES_DEPENDENCY = "requires_dependency"
+ REQUIRES_ENVIRONMENT = "requires_environment"
+
+ # cyclonedx.model.impact_analysis.ImpactAnalysisResponse
+ class Response(models.TextChoices):
+ CAN_NOT_FIX = "can_not_fix"
+ ROLLBACK = "rollback"
+ UPDATE = "update"
+ WILL_NOT_FIX = "will_not_fix"
+ WORKAROUND_AVAILABLE = "workaround_available"
+
+ state = models.CharField(
+ max_length=25,
+ blank=True,
+ choices=State.choices,
+ help_text=_(
+ "Declares the current state of an occurrence of a vulnerability, "
+ "after automated or manual analysis."
+ ),
+ )
+ justification = models.CharField(
+ max_length=35,
+ blank=True,
+ choices=Justification.choices,
+ help_text=_("The rationale of why the impact analysis state was asserted."),
+ )
+ responses = ArrayField(
+ models.CharField(
+ max_length=20,
+ choices=Response.choices,
+ ),
+ blank=True,
+ null=True,
+ help_text=_(
+ "A response to the vulnerability by the manufacturer, supplier, or project "
+ "responsible for the affected component or service. "
+ "More than one response is allowed. "
+ "Responses are strongly encouraged for vulnerabilities where the analysis "
+ "state is exploitable."
+ ),
+ )
+ detail = models.TextField(
+ blank=True,
+ help_text=_(
+ "Detailed description of the impact including methods used during assessment. "
+ "If a vulnerability is not exploitable, this field should include specific "
+ "details on why the component or service is not impacted by this vulnerability."
+ ),
+ )
+ first_issued = models.DateTimeField(
+ auto_now_add=True,
+ help_text=_("The date and time (timestamp) when the analysis was first issued."),
+ )
+ last_updated = models.DateTimeField(
+ auto_now=True,
+ help_text=_("The date and time (timestamp) when the analysis was last updated."),
+ )
+
+ class Meta:
+ abstract = True
+
+ def save(self, *args, **kwargs):
+ # At least one of those fields must be provided.
+ main_fields = [
+ self.state,
+ self.justification,
+ self.responses,
+ self.detail,
+ ]
+ if not any(main_fields):
+ raise ValueError(
+ "At least one of state, justification, responses or detail must be provided."
+ )
+
+ super().save(*args, **kwargs)
+
+
+class AffectedByVulnerabilityRelationship(DataspacedModel):
+ vulnerability = models.ForeignKey(
+ to="vulnerabilities.Vulnerability",
+ on_delete=models.CASCADE,
+ )
+
+ class Meta:
+ abstract = True
+
+
+class AffectedByVulnerabilityMixin(models.Model):
+ """Add the `vulnerability` many to many field."""
+
+ affected_by_vulnerabilities = models.ManyToManyField(
+ to="vulnerabilities.Vulnerability",
+ related_name="affected_%(class)ss",
+ help_text=_("Vulnerabilities affecting this object."),
+ )
+
+ class Meta:
+ abstract = True
+
+ @property
+ def is_vulnerable(self):
+ return self.affected_by_vulnerabilities.exists()
+
+ def get_entry_for_package(self, vulnerablecode):
+ if not self.package_url:
+ return
+
+ vulnerable_packages = vulnerablecode.get_vulnerabilities_by_purl(
+ self.package_url,
+ timeout=10,
+ )
+
+ if vulnerable_packages:
+ affected_by_vulnerabilities = vulnerable_packages[0].get("affected_by_vulnerabilities")
+ return affected_by_vulnerabilities
+
+ def get_entry_for_component(self, vulnerablecode):
+ if not self.cpe:
+ return
+
+ # Support for Component is paused as the CPES endpoint do not work properly.
+ # https://github.com/aboutcode-org/vulnerablecode/issues/1557
+ # vulnerabilities = vulnerablecode.get_vulnerabilities_by_cpe(self.cpe, timeout=10)
+
+ def get_entry_from_vulnerablecode(self):
+ from component_catalog.models import Component
+ from component_catalog.models import Package
+ from dejacode_toolkit.vulnerablecode import VulnerableCode
+
+ dataspace = self.dataspace
+ vulnerablecode = VulnerableCode(dataspace)
+
+ is_vulnerablecode_enabled = all(
+ [
+ vulnerablecode.is_configured(),
+ dataspace.enable_vulnerablecodedb_access,
+ ]
+ )
+ if not is_vulnerablecode_enabled:
+ return
+
+ if isinstance(self, Component):
+ return self.get_entry_for_component(vulnerablecode)
+ elif isinstance(self, Package):
+ return self.get_entry_for_package(vulnerablecode)
+
+ def fetch_vulnerabilities(self):
+ affected_by_vulnerabilities = self.get_entry_from_vulnerablecode()
+ if affected_by_vulnerabilities:
+ self.create_vulnerabilities(vulnerabilities_data=affected_by_vulnerabilities)
+
+ def create_vulnerabilities(self, vulnerabilities_data):
+ vulnerabilities = []
+ vulnerability_qs = Vulnerability.objects.scope(self.dataspace)
+
+ for vulnerability_data in vulnerabilities_data:
+ vulnerability_id = vulnerability_data["vulnerability_id"]
+ vulnerability = vulnerability_qs.get_or_none(vulnerability_id=vulnerability_id)
+ if not vulnerability:
+ vulnerability = Vulnerability.create_from_data(
+ dataspace=self.dataspace,
+ data=vulnerability_data,
+ )
+ vulnerabilities.append(vulnerability)
+
+ through_defaults = {"dataspace_id": self.dataspace_id}
+ self.affected_by_vulnerabilities.add(*vulnerabilities, through_defaults=through_defaults)
diff --git a/component_catalog/templates/component_catalog/tables/vulnerability_list_table.html b/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html
similarity index 87%
rename from component_catalog/templates/component_catalog/tables/vulnerability_list_table.html
rename to vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html
index c6da9176..012e9c6b 100644
--- a/component_catalog/templates/component_catalog/tables/vulnerability_list_table.html
+++ b/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html
@@ -10,10 +10,14 @@
|
-
+ {% if vulnerability.resource_url %}
+
+ {{ vulnerability.vulnerability_id }}
+
+
+ {% else %}
{{ vulnerability.vulnerability_id }}
-
-
+ {% endif %}
|
diff --git a/component_catalog/templates/component_catalog/vulnerability_list.html b/vulnerabilities/templates/vulnerabilities/vulnerability_list.html
similarity index 100%
rename from component_catalog/templates/component_catalog/vulnerability_list.html
rename to vulnerabilities/templates/vulnerabilities/vulnerability_list.html
diff --git a/vulnerabilities/tests/__init__.py b/vulnerabilities/tests/__init__.py
new file mode 100644
index 00000000..50af8f8a
--- /dev/null
+++ b/vulnerabilities/tests/__init__.py
@@ -0,0 +1,26 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from dje.tests import make_string
+from vulnerabilities.models import Vulnerability
+
+
+def make_vulnerability(dataspace, affecting=None, **data):
+ """Create a vulnerability for test purposes."""
+ if "vulnerability_id" not in data:
+ data["vulnerability_id"] = f"VCID-0000-{make_string(4)}"
+
+ vulnerability = Vulnerability.objects.create(
+ dataspace=dataspace,
+ **data,
+ )
+
+ if affecting:
+ vulnerability.add_affected(affecting)
+
+ return vulnerability
diff --git a/component_catalog/tests/testfiles/vulnerabilities/idna_3.6_as_cyclonedx.json b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_as_cyclonedx.json
similarity index 97%
rename from component_catalog/tests/testfiles/vulnerabilities/idna_3.6_as_cyclonedx.json
rename to vulnerabilities/tests/data/vulnerabilities/idna_3.6_as_cyclonedx.json
index 2f579ffc..bac5f524 100644
--- a/component_catalog/tests/testfiles/vulnerabilities/idna_3.6_as_cyclonedx.json
+++ b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_as_cyclonedx.json
@@ -130,6 +130,6 @@
],
"source": {
"name": "VulnerableCode",
- "url": "https://public.vulnerablecode.io/vulnerabilities/VCID-j3au-usaz-aaag"
+ "url": "http://public.vulnerablecode.io/vulnerabilities/VCID-j3au-usaz-aaag"
}
}
\ No newline at end of file
diff --git a/component_catalog/tests/testfiles/vulnerabilities/idna_3.6_response.json b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json
similarity index 100%
rename from component_catalog/tests/testfiles/vulnerabilities/idna_3.6_response.json
rename to vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json
diff --git a/vulnerabilities/tests/test_commands.py b/vulnerabilities/tests/test_commands.py
new file mode 100644
index 00000000..7535d593
--- /dev/null
+++ b/vulnerabilities/tests/test_commands.py
@@ -0,0 +1,45 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from unittest import mock
+
+from django.core import management
+from django.core.management.base import CommandError
+from django.test import TestCase
+
+from dje.models import Dataspace
+from dje.tests import create_superuser
+
+
+class VulnerabilityManagementCommandsTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+ self.super_user = create_superuser("super_user", self.dataspace)
+
+ @mock.patch("vulnerabilities.fetch.fetch_from_vulnerablecode")
+ @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured")
+ def test_fetchvulnerabilities_management_command(self, mock_is_configured, mock_fetch):
+ mock_is_configured.return_value = False
+ self.assertFalse(self.dataspace.enable_vulnerablecodedb_access)
+
+ options = [self.dataspace.name]
+ with self.assertRaises(CommandError) as error:
+ management.call_command("fetchvulnerabilities", *options)
+ expected = "VulnerableCode is not enabled on this Dataspace."
+ self.assertEqual(expected, str(error.exception))
+
+ self.dataspace.enable_vulnerablecodedb_access = True
+ self.dataspace.save()
+ with self.assertRaises(CommandError) as error:
+ management.call_command("fetchvulnerabilities", *options)
+ expected = "VulnerableCode is not configured."
+ self.assertEqual(expected, str(error.exception))
+
+ mock_is_configured.return_value = True
+ management.call_command("fetchvulnerabilities", *options)
+ mock_fetch.assert_called_once()
diff --git a/component_catalog/tests/test_vulnerabilities.py b/vulnerabilities/tests/test_fetch.py
similarity index 89%
rename from component_catalog/tests/test_vulnerabilities.py
rename to vulnerabilities/tests/test_fetch.py
index 7ea637de..77dfa5c3 100644
--- a/component_catalog/tests/test_vulnerabilities.py
+++ b/vulnerabilities/tests/test_fetch.py
@@ -15,18 +15,18 @@
from component_catalog.models import Package
from component_catalog.tests import make_package
-from component_catalog.vulnerabilities import fetch_for_queryset
-from component_catalog.vulnerabilities import fetch_from_vulnerablecode
from dje.models import Dataspace
+from vulnerabilities.fetch import fetch_for_queryset
+from vulnerabilities.fetch import fetch_from_vulnerablecode
-class VulnerabilitiesTestCase(TestCase):
- data = Path(__file__).parent / "testfiles"
+class VulnerabilitiesFetchTestCase(TestCase):
+ data = Path(__file__).parent / "data"
def setUp(self):
self.dataspace = Dataspace.objects.create(name="nexB")
- @mock.patch("component_catalog.vulnerabilities.fetch_for_queryset")
+ @mock.patch("vulnerabilities.fetch.fetch_for_queryset")
@mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured")
def test_vulnerabilities_fetch_from_vulnerablecode(
self, mock_is_configured, mock_fetch_for_queryset
diff --git a/vulnerabilities/tests/test_filters.py b/vulnerabilities/tests/test_filters.py
new file mode 100644
index 00000000..b10e5ad7
--- /dev/null
+++ b/vulnerabilities/tests/test_filters.py
@@ -0,0 +1,68 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.test import TestCase
+
+from dje.models import Dataspace
+from vulnerabilities.filters import VulnerabilityFilterSet
+from vulnerabilities.tests import make_vulnerability
+
+
+class VulnerabilityFilterSetTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="Reference")
+ self.vulnerability1 = make_vulnerability(self.dataspace, max_score=10.0)
+ self.vulnerability2 = make_vulnerability(
+ self.dataspace, max_score=5.5, aliases=["ALIAS-V2"]
+ )
+ self.vulnerability3 = make_vulnerability(self.dataspace, max_score=2.0)
+ self.vulnerability4 = make_vulnerability(self.dataspace, max_score=None)
+
+ def test_vulnerability_filterset_search(self):
+ data = {"q": self.vulnerability1.vulnerability_id}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ self.assertQuerySetEqual(filterset.qs, [self.vulnerability1])
+
+ data = {"q": "ALIAS-V2"}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ self.assertQuerySetEqual(filterset.qs, [self.vulnerability2])
+
+ def test_vulnerability_filterset_sort_nulls_last_ordering(self):
+ data = {"sort": "max_score"}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ expected = [
+ self.vulnerability3,
+ self.vulnerability2,
+ self.vulnerability1,
+ self.vulnerability4, # The max_score=None are always last
+ ]
+ self.assertQuerySetEqual(filterset.qs, expected)
+
+ data = {"sort": "-max_score"}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ expected = [
+ self.vulnerability1,
+ self.vulnerability2,
+ self.vulnerability3,
+ self.vulnerability4, # The max_score=None are always last
+ ]
+ self.assertQuerySetEqual(filterset.qs, expected)
+
+ def test_vulnerability_filterset_max_score(self):
+ data = {"max_score": "critical"}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ self.assertQuerySetEqual(filterset.qs, [self.vulnerability1])
+ data = {"max_score": "high"}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ self.assertQuerySetEqual(filterset.qs, [])
+ data = {"max_score": "medium"}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ self.assertQuerySetEqual(filterset.qs, [self.vulnerability2])
+ data = {"max_score": "low"}
+ filterset = VulnerabilityFilterSet(dataspace=self.dataspace, data=data)
+ self.assertQuerySetEqual(filterset.qs, [self.vulnerability3])
diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py
new file mode 100644
index 00000000..a504ab9a
--- /dev/null
+++ b/vulnerabilities/tests/test_models.py
@@ -0,0 +1,219 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+import json
+from pathlib import Path
+from unittest import mock
+
+from django.test import TestCase
+
+from component_catalog.models import Package
+from component_catalog.tests import make_component
+from component_catalog.tests import make_package
+from dejacode_toolkit.vulnerablecode import VulnerableCode
+from dje.models import Dataspace
+from product_portfolio.tests import make_product
+from vulnerabilities.models import Vulnerability
+from vulnerabilities.tests import make_vulnerability
+
+
+class VulnerabilitiesFetchTestCase(TestCase):
+ data = Path(__file__).parent / "data"
+
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(name="nexB")
+
+ @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.request_get")
+ def test_vulnerability_mixin_get_entry_for_package(self, mock_request_get):
+ vulnerablecode = VulnerableCode(self.dataspace)
+ package1 = make_package(self.dataspace, package_url="pkg:composer/guzzlehttp/psr7@1.9.0")
+ response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
+ mock_request_get.return_value = json.loads(response_file.read_text())
+
+ affected_by_vulnerabilities = package1.get_entry_for_package(vulnerablecode)
+ self.assertEqual(1, len(affected_by_vulnerabilities))
+ self.assertEqual("VCID-j3au-usaz-aaag", affected_by_vulnerabilities[0]["vulnerability_id"])
+
+ @mock.patch("vulnerabilities.models.AffectedByVulnerabilityMixin.get_entry_for_package")
+ @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured")
+ def test_vulnerability_mixin_get_entry_from_vulnerablecode(
+ self, mock_is_configured, mock_get_entry_for_package
+ ):
+ package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6")
+ self.assertIsNone(package1.get_entry_from_vulnerablecode())
+
+ mock_get_entry_for_package.return_value = None
+ self.dataspace.enable_vulnerablecodedb_access = True
+ self.dataspace.save()
+ mock_is_configured.return_value = True
+ package1.get_entry_from_vulnerablecode()
+ mock_get_entry_for_package.assert_called_once()
+
+ @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.request_get")
+ @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured")
+ def test_vulnerability_mixin_fetch_vulnerabilities(self, mock_is_configured, mock_request_get):
+ mock_is_configured.return_value = True
+ self.dataspace.enable_vulnerablecodedb_access = True
+ self.dataspace.save()
+ response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
+ mock_request_get.return_value = json.loads(response_file.read_text())
+
+ package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6")
+ package1.fetch_vulnerabilities()
+
+ self.assertEqual(1, Vulnerability.objects.scope(self.dataspace).count())
+ self.assertEqual(1, package1.affected_by_vulnerabilities.count())
+ vulnerability = package1.affected_by_vulnerabilities.get()
+ self.assertEqual("VCID-j3au-usaz-aaag", vulnerability.vulnerability_id)
+
+ def test_vulnerability_mixin_create_vulnerabilities(self):
+ response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
+ response_json = json.loads(response_file.read_text())
+ vulnerabilities_data = response_json["results"][0]["affected_by_vulnerabilities"]
+
+ package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6")
+ package1.create_vulnerabilities(vulnerabilities_data)
+
+ self.assertEqual(1, Vulnerability.objects.scope(self.dataspace).count())
+ self.assertEqual(1, package1.affected_by_vulnerabilities.count())
+ vulnerability = package1.affected_by_vulnerabilities.get()
+ self.assertEqual("VCID-j3au-usaz-aaag", vulnerability.vulnerability_id)
+
+ def test_vulnerability_model_affected_packages_m2m(self):
+ package1 = make_package(self.dataspace)
+ vulnerability1 = make_vulnerability(dataspace=self.dataspace, affecting=package1)
+ self.assertEqual(package1, vulnerability1.affected_packages.get())
+ self.assertEqual(vulnerability1, package1.affected_by_vulnerabilities.get())
+
+ def test_vulnerability_model_affected_by_vulnerability_relationship_delete(self):
+ package1 = make_package(self.dataspace)
+ vulnerability1 = make_vulnerability(dataspace=self.dataspace, affecting=package1)
+ package1.delete()
+ self.assertEqual(vulnerability1, Vulnerability.objects.get())
+ self.assertEqual(0, Package.objects.count())
+
+ package1 = make_package(self.dataspace)
+ vulnerability1.add_affected(package1)
+ vulnerability1.delete()
+ self.assertEqual(package1, Package.objects.get())
+ self.assertEqual(0, Vulnerability.objects.count())
+
+ def test_vulnerability_model_add_affected(self):
+ vulnerability1 = make_vulnerability(dataspace=self.dataspace)
+ package1 = make_package(self.dataspace)
+ package2 = make_package(self.dataspace)
+ vulnerability1.add_affected(package1)
+ vulnerability1.add_affected([package2])
+ self.assertEqual(2, vulnerability1.affected_packages.count())
+
+ vulnerability2 = make_vulnerability(dataspace=self.dataspace)
+ component1 = make_component(self.dataspace)
+ vulnerability2.add_affected([component1, package1])
+ self.assertQuerySetEqual(vulnerability2.affected_packages.all(), [package1])
+ self.assertQuerySetEqual(vulnerability2.affected_components.all(), [component1])
+
+ def test_vulnerability_model_fixed_packages_count_generated_field(self):
+ vulnerability1 = make_vulnerability(dataspace=self.dataspace)
+ self.assertEqual(0, vulnerability1.fixed_packages_count)
+
+ vulnerability1.fixed_packages = [
+ {"purl": "pkg:pypi/gitpython@3.1.41", "is_vulnerable": True},
+ {"purl": "pkg:pypi/gitpython@3.2", "is_vulnerable": False},
+ ]
+ vulnerability1.save()
+ vulnerability1.refresh_from_db()
+ self.assertEqual(2, vulnerability1.fixed_packages_count)
+
+ def test_vulnerability_model_create_from_data(self):
+ package1 = make_package(self.dataspace)
+ vulnerability_data = {
+ "vulnerability_id": "VCID-q4q6-yfng-aaag",
+ "summary": "In Django 3.2 before 3.2.25, 4.2 before 4.2.11, and 5.0.",
+ "aliases": ["CVE-2024-27351", "GHSA-vm8q-m57g-pff3", "PYSEC-2024-47"],
+ "references": [
+ {
+ "reference_url": "https://access.redhat.com/hydra/rest/"
+ "securitydata/cve/CVE-2024-27351.json",
+ "reference_id": "",
+ "scores": [
+ {
+ "value": "7.5",
+ "scoring_system": "cvssv3",
+ "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+ }
+ ],
+ },
+ ],
+ "resource_url": "http://public.vulnerablecode.io/vulnerabilities/VCID-q4q6-yfng-aaag",
+ }
+
+ vulnerability1 = Vulnerability.create_from_data(
+ dataspace=self.dataspace,
+ data=vulnerability_data,
+ affecting=package1,
+ )
+ self.assertEqual(vulnerability_data["vulnerability_id"], vulnerability1.vulnerability_id)
+ self.assertEqual(vulnerability_data["summary"], vulnerability1.summary)
+ self.assertEqual(vulnerability_data["aliases"], vulnerability1.aliases)
+ self.assertEqual(vulnerability_data["references"], vulnerability1.references)
+ self.assertEqual(vulnerability_data["resource_url"], vulnerability1.resource_url)
+ self.assertEqual(7.5, vulnerability1.min_score)
+ self.assertEqual(7.5, vulnerability1.max_score)
+ self.assertQuerySetEqual(vulnerability1.affected_packages.all(), [package1])
+
+ def test_vulnerability_model_create_from_data_computed_scores(self):
+ response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
+ json_data = json.loads(response_file.read_text())
+ affected_by_vulnerabilities = json_data["results"][0]["affected_by_vulnerabilities"]
+ vulnerability1 = Vulnerability.create_from_data(
+ dataspace=self.dataspace,
+ data=affected_by_vulnerabilities[0],
+ )
+ self.assertEqual(2.1, vulnerability1.min_score)
+ self.assertEqual(7.5, vulnerability1.max_score)
+
+ def test_vulnerability_model_queryset_count_methods(self):
+ package1 = make_package(self.dataspace)
+ package2 = make_package(self.dataspace)
+ vulnerability1 = make_vulnerability(dataspace=self.dataspace)
+ vulnerability1.add_affected([package1, package2])
+ make_product(self.dataspace, inventory=[package1, package2])
+
+ qs = (
+ Vulnerability.objects.scope(self.dataspace)
+ .with_affected_products_count()
+ .with_affected_packages_count()
+ )
+ self.assertEqual(2, qs[0].affected_packages_count)
+ self.assertEqual(1, qs[0].affected_products_count)
+
+ def test_vulnerability_model_as_cyclonedx(self):
+ response_file = self.data / "vulnerabilities" / "idna_3.6_response.json"
+ json_data = json.loads(response_file.read_text())
+ affected_by_vulnerabilities = json_data["results"][0]["affected_by_vulnerabilities"]
+ vulnerability1 = Vulnerability.create_from_data(
+ dataspace=self.dataspace,
+ data=affected_by_vulnerabilities[0],
+ )
+ package1 = make_package(
+ self.dataspace,
+ package_url="pkg:type/name@1.9.0",
+ uuid="dd0afd00-89bd-46d6-b1f0-57b553c44d32",
+ )
+
+ vulnerability1_as_cdx = vulnerability1.as_cyclonedx(affected_instances=[package1])
+ as_dict = json.loads(vulnerability1_as_cdx.as_json())
+ as_dict.pop("ratings", None) # The sorting is inconsistent
+ results = json.dumps(as_dict, indent=2)
+
+ expected_location = self.data / "vulnerabilities" / "idna_3.6_as_cyclonedx.json"
+ # Uncomment to regen the expected results
+ # if True:
+ # expected_location.write_text(results)
+
+ self.assertJSONEqual(results, expected_location.read_text())
diff --git a/vulnerabilities/tests/test_views.py b/vulnerabilities/tests/test_views.py
new file mode 100644
index 00000000..d08bda62
--- /dev/null
+++ b/vulnerabilities/tests/test_views.py
@@ -0,0 +1,84 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+from component_catalog.tests import make_component
+from component_catalog.tests import make_package
+from dje.models import Dataspace
+from dje.tests import create_superuser
+from vulnerabilities.models import Vulnerability
+from vulnerabilities.tests import make_vulnerability
+
+User = get_user_model()
+
+
+class VulnerabilityViewsTestCase(TestCase):
+ def setUp(self):
+ self.dataspace = Dataspace.objects.create(
+ name="Dataspace",
+ enable_vulnerablecodedb_access=True,
+ )
+ self.super_user = create_superuser("super_user", self.dataspace)
+
+ self.component1 = make_component(self.dataspace)
+ self.component2 = make_component(self.dataspace)
+ self.package1 = make_package(self.dataspace)
+ self.package2 = make_package(self.dataspace)
+ self.vulnerability_p1 = make_vulnerability(self.dataspace, affecting=self.component1)
+ self.vulnerability_c1 = make_vulnerability(self.dataspace, affecting=self.package1)
+ self.vulnerability1 = make_vulnerability(self.dataspace)
+
+ def test_vulnerability_list_view_num_queries(self):
+ self.client.login(username=self.super_user.username, password="secret")
+ with self.assertNumQueries(7):
+ response = self.client.get(reverse("vulnerabilities:vulnerability_list"))
+
+ vulnerability_count = Vulnerability.objects.count()
+ expected = f'{vulnerability_count} results'
+ self.assertContains(response, expected, html=True)
+
+ def test_vulnerability_list_view_enable_vulnerablecodedb_access(self):
+ self.client.login(username=self.super_user.username, password="secret")
+ vulnerability_list_url = reverse("vulnerabilities:vulnerability_list")
+ response = self.client.get(vulnerability_list_url)
+ self.assertEqual(200, response.status_code)
+ vulnerability_header_link = (
+ f''
+ )
+ self.assertContains(response, vulnerability_header_link)
+
+ self.dataspace.enable_vulnerablecodedb_access = False
+ self.dataspace.save()
+ response = self.client.get(reverse("vulnerabilities:vulnerability_list"))
+ self.assertEqual(404, response.status_code)
+
+ response = self.client.get(reverse("component_catalog:package_list"))
+ self.assertNotContains(response, vulnerability_header_link)
+
+ def test_vulnerability_list_view_vulnerability_id_link(self):
+ self.client.login(username=self.super_user.username, password="secret")
+ response = self.client.get(reverse("vulnerabilities:vulnerability_list"))
+
+ expected = f"{self.vulnerability1.vulnerability_id}"
+ self.assertContains(response, expected, html=True)
+
+ self.vulnerability1.resource_url = (
+ f"https://url/vulnerabilities/{self.vulnerability1.vulnerability_id}"
+ )
+ self.vulnerability1.save()
+ expected = f"""
+
+ {self.vulnerability1.vulnerability_id}
+
+
+ """
+ response = self.client.get(reverse("vulnerabilities:vulnerability_list"))
+ self.assertContains(response, expected, html=True)
diff --git a/vulnerabilities/urls.py b/vulnerabilities/urls.py
new file mode 100644
index 00000000..ffd2763a
--- /dev/null
+++ b/vulnerabilities/urls.py
@@ -0,0 +1,15 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.urls import path
+
+from vulnerabilities.views import VulnerabilityListView
+
+urlpatterns = [
+ path("", VulnerabilityListView.as_view(), name="vulnerability_list"),
+]
diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py
new file mode 100644
index 00000000..7480031f
--- /dev/null
+++ b/vulnerabilities/views.py
@@ -0,0 +1,70 @@
+#
+# Copyright (c) nexB Inc. and others. All rights reserved.
+# DejaCode is a trademark of nexB Inc.
+# SPDX-License-Identifier: AGPL-3.0-only
+# See https://github.com/aboutcode-org/dejacode for support or download.
+# See https://aboutcode.org for more information about AboutCode FOSS projects.
+#
+
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.db.models import F
+from django.http import Http404
+from django.utils.translation import gettext_lazy as _
+
+from dje.views import DataspacedFilterView
+from dje.views import Header
+from vulnerabilities.filters import VulnerabilityFilterSet
+from vulnerabilities.models import Vulnerability
+
+
+class VulnerabilityListView(
+ LoginRequiredMixin,
+ DataspacedFilterView,
+):
+ model = Vulnerability
+ filterset_class = VulnerabilityFilterSet
+ template_name = "vulnerabilities/vulnerability_list.html"
+ template_list_table = "vulnerabilities/tables/vulnerability_list_table.html"
+ table_headers = (
+ Header("vulnerability_id", _("Vulnerability")),
+ Header("aliases", _("Aliases")),
+ # Keep `max_score` to enable column sorting
+ Header("max_score", _("Score"), help_text="Severity score range", filter="max_score"),
+ Header("summary", _("Summary")),
+ Header("affected_products_count", _("Affected products"), help_text="Affected products"),
+ Header("affected_packages_count", _("Affected packages"), help_text="Affected packages"),
+ Header("fixed_packages_count", _("Fixed by"), help_text="Fixed by packages"),
+ )
+
+ def get_queryset(self):
+ return (
+ super()
+ .get_queryset()
+ .only(
+ "uuid",
+ "vulnerability_id",
+ "resource_url",
+ "aliases",
+ "summary",
+ "fixed_packages_count",
+ "max_score",
+ "min_score",
+ "created_date",
+ "last_modified_date",
+ "dataspace",
+ )
+ .with_affected_products_count()
+ .with_affected_packages_count()
+ .order_by(
+ F("max_score").desc(nulls_last=True),
+ "-min_score",
+ )
+ )
+
+ def get_context_data(self, **kwargs):
+ context_data = super().get_context_data(**kwargs)
+
+ if not self.dataspace.enable_vulnerablecodedb_access:
+ raise Http404("VulnerableCode access is not enabled.")
+
+ return context_data
| |