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