diff --git a/component_catalog/filters.py b/component_catalog/filters.py index eb587191..2f073d7d 100644 --- a/component_catalog/filters.py +++ b/component_catalog/filters.py @@ -85,6 +85,16 @@ class ComponentFilterSet(DataspacedFilterSet): search_placeholder="Search keywords", ), ) + # TODO: Remove duplication with Package + is_vulnerable = HasRelationFilter( + label=_("Is Vulnerable"), + field_name="affected_by_vulnerabilities", + choices=( + ("yes", _("Affected by vulnerabilities")), + ("no", _("No vulnerabilities found")), + ), + widget=DropDownRightWidget, + ) class Meta: model = Component diff --git a/component_catalog/migrations/0007_vulnerability_affected_components_and_more.py b/component_catalog/migrations/0007_vulnerability_affected_components_and_more.py new file mode 100644 index 00000000..a4432b9f --- /dev/null +++ b/component_catalog/migrations/0007_vulnerability_affected_components_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2024-08-05 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('component_catalog', '0006_vulnerability'), + ] + + operations = [ + migrations.AddField( + model_name='vulnerability', + name='affected_components', + field=models.ManyToManyField(help_text='Components affected by this vulnerability.', related_name='affected_by_vulnerabilities', to='component_catalog.component'), + ), + migrations.AlterField( + model_name='vulnerability', + name='affected_packages', + field=models.ManyToManyField(help_text='Packages affected by this vulnerability.', related_name='affected_by_vulnerabilities', to='component_catalog.package'), + ), + ] diff --git a/component_catalog/models.py b/component_catalog/models.py index 0e0aa1d0..2ba0cea2 100644 --- a/component_catalog/models.py +++ b/component_catalog/models.py @@ -129,6 +129,18 @@ def validate_filename(value): ) +class VulnerabilityQuerySetMixin: + def with_vulnerability_count(self): + """Annotate the QuerySet with the vulnerability_count.""" + return self.annotate( + vulnerability_count=models.Count("affected_by_vulnerabilities", distinct=True) + ) + + def with_vulnerabilties(self): + """Return vulnerable Packages.""" + return self.with_vulnerability_count().filter(vulnerability_count__gt=0) + + class LicenseExpressionMixin: """Model mixin for models that store license expressions.""" @@ -852,7 +864,7 @@ def as_cyclonedx(self, license_expression_spdx=None): BaseComponentMixin = component_mixin_factory("component") -class ComponentQuerySet(DataspacedQuerySet): +class ComponentQuerySet(VulnerabilityQuerySetMixin, DataspacedQuerySet): def with_has_hierarchy(self): subcomponents = Subcomponent.objects.filter( models.Q(child_id=OuterRef("pk")) | models.Q(parent_id=OuterRef("pk")) @@ -1622,21 +1634,11 @@ def __str__(self): PACKAGE_URL_FIELDS = ["type", "namespace", "name", "version", "qualifiers", "subpath"] -class PackageQuerySet(PackageURLQuerySetMixin, DataspacedQuerySet): +class PackageQuerySet(PackageURLQuerySetMixin, VulnerabilityQuerySetMixin, DataspacedQuerySet): def has_package_url(self): """Return objects with Package URL defined.""" return self.filter(~models.Q(type="") & ~models.Q(name="")) - def with_vulnerability_count(self): - """Annotate the QuerySet with the vulnerability_count.""" - return self.annotate( - vulnerability_count=models.Count("affected_by_vulnerabilities", distinct=True) - ) - - def with_vulnerabilties(self): - """Return vulnerable Packages.""" - return self.with_vulnerability_count().filter(vulnerability_count__gt=0) - def annotate_sortable_identifier(self): """ Annotate the QuerySet with a `sortable_identifier` value that combines @@ -2528,12 +2530,10 @@ class Vulnerability(HistoryFieldsMixin, DataspacedModel): "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=_( @@ -2541,7 +2541,6 @@ class Vulnerability(HistoryFieldsMixin, DataspacedModel): "(e.g., 'CVE-2017-1000136')." ), ) - references = JSONListField( blank=True, help_text=_( @@ -2549,16 +2548,19 @@ class Vulnerability(HistoryFieldsMixin, DataspacedModel): "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."), ) - affected_packages = models.ManyToManyField( to="component_catalog.Package", related_name="affected_by_vulnerabilities", - help_text=_("Packages that are affected by this vulnerability."), + help_text=_("Packages affected by this vulnerability."), + ) + affected_components = models.ManyToManyField( + to="component_catalog.Component", + related_name="affected_by_vulnerabilities", + help_text=_("Components affected by this vulnerability."), ) class Meta: @@ -2576,11 +2578,20 @@ 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) + @classmethod - def create_from_data(cls, user, data, validate=False, affected_packages=None): + def create_from_data( + cls, user, data, validate=False, affected_packages=None, affected_components=None + ): vulnerability = super().create_from_data(user, data, validate=validate) if affected_packages: vulnerability.add_affected_packages(affected_packages) + if affected_components: + vulnerability.add_affected_component(affected_components) + return vulnerability diff --git a/component_catalog/templates/component_catalog/includes/component_list_table.html b/component_catalog/templates/component_catalog/includes/component_list_table.html index e0d040ea..f2aee581 100644 --- a/component_catalog/templates/component_catalog/includes/component_list_table.html +++ b/component_catalog/templates/component_catalog/includes/component_list_table.html @@ -25,7 +25,7 @@ {% endif %} - {% if object.has_hierarchy or object.request_count or object.cpe %} + {% if object.has_hierarchy or object.request_count or object.vulnerability_count %} diff --git a/component_catalog/views.py b/component_catalog/views.py index 1b55c2a3..b50c24a4 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -408,7 +408,7 @@ class ComponentListView( group_name_version = True table_headers = ( - Header("name", _("Component name")), + Header("name", _("Component name"), filter="is_vulnerable"), Header("version", _("Version")), Header("usage_policy", _("Policy"), filter="usage_policy", condition=include_policy), Header("license_expression", _("Concluded license"), filter="licenses"), @@ -448,6 +448,7 @@ def get_queryset(self): "licenses__usage_policy", ) .with_has_hierarchy() + .with_vulnerability_count() .order_by( "-last_modified_date", )