From 9eb7fb78a386807966f8b04627fe33ec8926afc1 Mon Sep 17 00:00:00 2001 From: tdruez <489057+tdruez@users.noreply.github.com> Date: Mon, 9 Dec 2024 15:43:10 +0400 Subject: [PATCH] Add vulnerabilities REST API endpoint #104 (#203) Signed-off-by: tdruez Signed-off-by: Philippe Ombredanne Co-authored-by: Philippe Ombredanne Co-authored-by: Philippe Ombredanne --- CHANGELOG.rst | 9 ++ component_catalog/api.py | 36 ++++++ component_catalog/tests/test_api.py | 40 +++++++ dejacode/urls.py | 2 + dje/api.py | 27 ++++- dje/tests/test_outputs.py | 18 ++- product_portfolio/api.py | 48 ++++++++ product_portfolio/filters.py | 20 ++-- product_portfolio/tests/test_api.py | 70 ++++++++++++ product_portfolio/tests/test_views.py | 38 +------ product_portfolio/views.py | 2 +- vulnerabilities/api.py | 155 ++++++++++++++++++++++++++ vulnerabilities/tests/__init__.py | 17 +++ vulnerabilities/tests/test_api.py | 101 +++++++++++++++++ vulnerabilities/tests/test_models.py | 47 +++----- 15 files changed, 540 insertions(+), 90 deletions(-) create mode 100644 vulnerabilities/api.py create mode 100644 vulnerabilities/tests/test_api.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fbff5294..5f19d8b3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -29,6 +29,15 @@ Release notes analysed are listed and can be selected for "analysis propagation". https://github.com/aboutcode-org/dejacode/issues/105 +- Add vulnerabilities REST API endpoint that mimics the content and features of the + vulnerabilities list view. + Add `risk_score` and `affected_by_vulnerabilities` fields in Package endpoint. + Add `vulnerability_analyses` field in Product and ProductPackage endpoints. + Add `is_vulnerable` and `affected_by` filters in Product, Package, and ProductPackage + endpoints. + Add `risk_score` filter in Package endpoint. + https://github.com/aboutcode-org/dejacode/issues/104 + ### Version 5.2.1 - Fix the models documentation navigation. diff --git a/component_catalog/api.py b/component_catalog/api.py index e39d56e0..f90977c3 100644 --- a/component_catalog/api.py +++ b/component_catalog/api.py @@ -24,6 +24,7 @@ from component_catalog.admin import ComponentAdmin from component_catalog.admin import PackageAdmin +from component_catalog.filters import IsVulnerableFilter from component_catalog.fuzzy import FuzzyPackageNameSearch from component_catalog.license_expression_dje import get_license_objects from component_catalog.license_expression_dje import normalize_and_validate_expression @@ -54,6 +55,9 @@ from dje.views import SendAboutFilesMixin from license_library.models import License from organization.api import OwnerEmbeddedSerializer +from vulnerabilities.api import VulnerabilitySerializer +from vulnerabilities.filters import RISK_SCORE_RANGES +from vulnerabilities.filters import ScoreRangeFilter class LicenseSummaryMixin: @@ -426,6 +430,13 @@ class ComponentFilterSet(DataspacedAPIFilterSet): name_version = NameVersionFilter( label="Name:Version", ) + is_vulnerable = IsVulnerableFilter( + field_name="affected_by_vulnerabilities", + ) + affected_by = django_filters.CharFilter( + field_name="affected_by_vulnerabilities__vulnerability_id", + label="Affected by (vulnerability_id)", + ) class Meta: model = Component @@ -450,6 +461,8 @@ class Meta: "last_modified_date", "name_version", "keywords", + "is_vulnerable", + "affected_by", ) @@ -610,6 +623,15 @@ class PackageSerializer( required=False, allow_null=True, ) + affected_by_vulnerabilities = VulnerabilitySerializer( + read_only=True, + many=True, + fields=[ + "vulnerability_id", + "api_url", + "uuid", + ], + ) class Meta: model = Package @@ -669,6 +691,8 @@ class Meta: "created_date", "last_modified_date", "collect_data", + "risk_score", + "affected_by_vulnerabilities", ) extra_kwargs = { "api_url": { @@ -777,6 +801,14 @@ class PackageAPIFilterSet(DataspacedAPIFilterSet): last_modified_date = LastModifiedDateFilter() fuzzy = FuzzyPackageNameSearch(widget=HiddenInput) purl = PackageURLFilter(label="Package URL") + is_vulnerable = IsVulnerableFilter( + field_name="affected_by_vulnerabilities", + ) + affected_by = django_filters.CharFilter( + field_name="affected_by_vulnerabilities__vulnerability_id", + label="Affected by (vulnerability_id)", + ) + risk_score = ScoreRangeFilter(score_ranges=RISK_SCORE_RANGES) class Meta: model = Package @@ -801,6 +833,9 @@ class Meta: "last_modified_date", "fuzzy", "purl", + "is_vulnerable", + "affected_by", + "risk_score", ) @@ -877,6 +912,7 @@ def get_queryset(self): .prefetch_related( "component_set__owner", "licenses__category", + "affected_by_vulnerabilities", external_references_prefetch, ) ) diff --git a/component_catalog/tests/test_api.py b/component_catalog/tests/test_api.py index 7ea34d3e..fdfea27f 100644 --- a/component_catalog/tests/test_api.py +++ b/component_catalog/tests/test_api.py @@ -45,6 +45,7 @@ from license_library.models import LicenseChoice from organization.models import Owner from policy.models import UsagePolicy +from vulnerabilities.tests import make_vulnerability @override_settings( @@ -1034,6 +1035,14 @@ def test_api_package_list_endpoint_filters(self): self.assertContains(response, self.package1_detail_url) self.assertNotContains(response, self.package2_detail_url) + self.package1.risk_score = 9.0 + self.package1.save() + data = {"risk_score": "critical"} + response = self.client.get(self.package_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.package1_detail_url) + self.assertNotContains(response, self.package2_detail_url) + def test_api_package_list_endpoint_multiple_char_filters(self): self.client.login(username="super_user", password="secret") filters = "?md5={}&md5={}".format(self.package1.md5, self.package2.md5) @@ -1325,6 +1334,37 @@ def test_api_package_endpoint_update_put(self): self.assertEqual(self.base_user, self.package1.created_by) self.assertEqual(self.super_user, self.package1.last_modified_by) + def test_api_package_endpoint_vulnerabilities_features(self): + self.client.login(username="super_user", password="secret") + vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1) + vulnerability2 = make_vulnerability(self.dataspace) + self.package1.update(risk_score=9.0) + + data = {"is_vulnerable": "yes"} + response = self.client.get(self.package_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.package1_detail_url) + self.assertNotContains(response, self.package2_detail_url) + + results = response.data["results"] + self.assertEqual("9.0", results[0]["risk_score"]) + self.assertEqual( + vulnerability1.vulnerability_id, + results[0]["affected_by_vulnerabilities"][0]["vulnerability_id"], + ) + + data = {"affected_by": vulnerability1.vulnerability_id} + response = self.client.get(self.package_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.package1_detail_url) + self.assertNotContains(response, self.package2_detail_url) + + data = {"affected_by": vulnerability2.vulnerability_id} + response = self.client.get(self.package_list_url, data) + self.assertEqual(0, response.data["count"]) + self.assertNotContains(response, self.package1_detail_url) + self.assertNotContains(response, self.package2_detail_url) + def test_api_package_license_choices_fields(self): self.client.login(username="super_user", password="secret") diff --git a/dejacode/urls.py b/dejacode/urls.py index e7f862e3..047dfb9c 100644 --- a/dejacode/urls.py +++ b/dejacode/urls.py @@ -52,6 +52,7 @@ from product_portfolio.api import ProductPackageViewSet from product_portfolio.api import ProductViewSet from reporting.api import ReportViewSet +from vulnerabilities.api import VulnerabilityViewSet from workflow.api import RequestTemplateViewSet from workflow.api import RequestViewSet @@ -78,6 +79,7 @@ api_router.register("reports", ReportViewSet) api_router.register("external_references", ExternalReferenceViewSet) api_router.register("usage_policies", UsagePolicyViewSet) +api_router.register("vulnerabilities", VulnerabilityViewSet) urlpatterns = [ diff --git a/dje/api.py b/dje/api.py index b5b959f7..09b7d751 100644 --- a/dje/api.py +++ b/dje/api.py @@ -226,7 +226,32 @@ def get_permissions(self): return permission_classes + extra_permission -class DataspacedSerializer(serializers.HyperlinkedModelSerializer): +class DynamicFieldsSerializerMixin: + """ + A Serializer mixin that takes an additional `fields` or `exclude_fields` + arguments to customize the field selection. + + Inspired by https://www.django-rest-framework.org/api-guide/serializers/#example + """ + + def __init__(self, *args, **kwargs): + fields = kwargs.pop("fields", []) + exclude_fields = kwargs.pop("exclude_fields", []) + + super().__init__(*args, **kwargs) + + if fields: + self.fields = { + field_name: field + for field_name, field in self.fields.items() + if field_name in fields + } + + for field_name in exclude_fields: + self.fields.pop(field_name) + + +class DataspacedSerializer(DynamicFieldsSerializerMixin, serializers.HyperlinkedModelSerializer): def __init__(self, *args, **kwargs): """ Add the `dataspace` attribute from the request User Dataspace. diff --git a/dje/tests/test_outputs.py b/dje/tests/test_outputs.py index 1848cbd5..331984a6 100644 --- a/dje/tests/test_outputs.py +++ b/dje/tests/test_outputs.py @@ -20,8 +20,8 @@ from dje.tests import create_user from product_portfolio.models import Product from product_portfolio.tests import make_product_package -from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis class OutputsTestCase(TestCase): @@ -117,21 +117,19 @@ def test_outputs_get_cyclonedx_bom_include_vex(self): self.assertEqual(vulnerability1.vulnerability_id, bom.vulnerabilities[0].id) self.assertIsNone(bom.vulnerabilities[0].analysis) - VulnerabilityAnalysis.objects.create( - product_package=product_package1, - vulnerability=vulnerability1, - state=VulnerabilityAnalysis.State.RESOLVED, - justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT, - detail="detail", - dataspace=self.dataspace, - ) + analysis1 = make_vulnerability_analysis(product_package1, vulnerability1) bom = outputs.get_cyclonedx_bom( instance=self.product1, user=self.super_user, include_vex=True, ) analysis = bom.vulnerabilities[0].analysis - expected = {"detail": "detail", "justification": "code_not_present", "state": "resolved"} + expected = { + "detail": analysis1.detail, + "justification": str(analysis1.justification), + "response": [str(response) for response in analysis1.responses], + "state": str(analysis1.state), + } self.assertEqual(expected, json.loads(analysis.as_json())) def test_outputs_get_cyclonedx_bom_json(self): diff --git a/product_portfolio/api.py b/product_portfolio/api.py index a8482fee..c5e21fda 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -19,6 +19,7 @@ from component_catalog.api import KeywordsField from component_catalog.api import PackageEmbeddedSerializer from component_catalog.api import ValidateLicenseExpressionMixin +from component_catalog.filters import IsVulnerableFilter from component_catalog.license_expression_dje import clean_related_expression from dje.api import AboutCodeFilesActionMixin from dje.api import CreateRetrieveUpdateListViewSet @@ -47,6 +48,7 @@ from product_portfolio.models import ProductComponent from product_portfolio.models import ProductDependency from product_portfolio.models import ProductPackage +from vulnerabilities.api import VulnerabilityAnalysisSerializer base_extra_kwargs = { "licenses": { @@ -103,6 +105,10 @@ class ProductSerializer(ValidateLicenseExpressionMixin, DataspacedSerializer): keywords = KeywordsField( required=False, ) + vulnerability_analyses = VulnerabilityAnalysisSerializer( + read_only=True, + many=True, + ) class Meta: model = Product @@ -119,6 +125,7 @@ class Meta: "licenses", "components", "packages", + "vulnerability_analyses", "keywords", "release_date", "description", @@ -184,6 +191,13 @@ class ProductFilterSet(DataspacedAPIFilterSet): help_text="Keyword label contains (case-insensitive)", ) last_modified_date = LastModifiedDateFilter() + is_vulnerable = IsVulnerableFilter( + field_name="packages__affected_by_vulnerabilities", + ) + affected_by = django_filters.CharFilter( + field_name="packages__affected_by_vulnerabilities__vulnerability_id", + label="Affected by (vulnerability_id)", + ) class Meta: model = Product @@ -200,6 +214,8 @@ class Meta: "configuration_status", "license_expression", "last_modified_date", + "is_vulnerable", + "affected_by", ) @@ -320,6 +336,8 @@ def get_queryset(self): "components", "packages", "licenses", + "vulnerability_analyses__vulnerability", + "vulnerability_analyses__product_package", ) ) @@ -528,6 +546,9 @@ class ProductComponentFilterSet(DataspacedAPIFilterSet): help_text='Supported values: "catalog", "custom".', ) last_modified_date = LastModifiedDateFilter() + is_vulnerable = IsVulnerableFilter( + field_name="component__affected_by_vulnerabilities", + ) class Meta: model = ProductComponent @@ -538,8 +559,11 @@ class Meta: "review_status", "purpose", "feature", + "is_deployed", + "is_modified", "completeness", "last_modified_date", + "is_vulnerable", ) @@ -606,6 +630,11 @@ class ProductPackageSerializer(BaseProductRelationSerializer): source="package", read_only=True, ) + vulnerability_analyses = VulnerabilityAnalysisSerializer( + many=True, + read_only=True, + exclude_fields=["product_package"], + ) class Meta: model = ProductPackage @@ -628,6 +657,7 @@ class Meta: "package_paths", "reference_notes", "issue_ref", + "vulnerability_analyses", "created_date", "last_modified_date", ) @@ -664,6 +694,13 @@ class ProductPackageFilterSet(DataspacedAPIFilterSet): help_text="Exact feature label.", ) last_modified_date = LastModifiedDateFilter() + is_vulnerable = IsVulnerableFilter( + field_name="package__affected_by_vulnerabilities", + ) + affected_by = django_filters.CharFilter( + field_name="package__affected_by_vulnerabilities__vulnerability_id", + label="Affected by (vulnerability_id)", + ) class Meta: model = ProductPackage @@ -675,6 +712,8 @@ class Meta: "purpose", "feature", "last_modified_date", + "is_vulnerable", + "affected_by", ) @@ -692,6 +731,15 @@ class ProductPackageViewSet(ProductRelationViewSet): "last_modified_date", ) + def get_queryset(self): + return ( + super() + .get_queryset() + .prefetch_related( + "vulnerability_analyses__vulnerability", + ) + ) + class CodebaseResourceSerializer(DataspacedSerializer): product = NameVersionHyperlinkedRelatedField( diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py index 7a9785bc..de7566b4 100644 --- a/product_portfolio/filters.py +++ b/product_portfolio/filters.py @@ -14,6 +14,7 @@ import django_filters from packageurl.contrib.django.utils import purl_to_lookups +from component_catalog.filters import IsVulnerableFilter from component_catalog.models import ComponentKeyword from component_catalog.programming_languages import PROGRAMMING_LANGUAGES from dje.filters import BooleanChoiceFilter @@ -23,6 +24,7 @@ from dje.filters import MatchOrderedSearchFilter from dje.filters import SearchFilter from dje.widgets import BootstrapSelectMultipleWidget +from dje.widgets import DropDownRightWidget from dje.widgets import DropDownWidget from license_library.models import License from product_portfolio.models import CodebaseResource @@ -105,6 +107,10 @@ class ProductFilterSet(DataspacedFilterSet): search_placeholder="Search keywords", ), ) + is_vulnerable = IsVulnerableFilter( + field_name="packages__affected_by_vulnerabilities", + widget=DropDownRightWidget(link_content=''), + ) affected_by = django_filters.CharFilter( field_name="packages__affected_by_vulnerabilities__vulnerability_id", label=_("Affected by"), @@ -223,13 +229,8 @@ class ProductComponentFilterSet(BaseProductRelationFilterSet): "is_modified", ], ) - is_vulnerable = HasRelationFilter( - label=_("Is Vulnerable"), + is_vulnerable = IsVulnerableFilter( field_name="component__affected_by_vulnerabilities", - choices=( - ("yes", _("Affected by vulnerabilities")), - ("no", _("No vulnerabilities found")), - ), widget=DropDownWidget( anchor="#inventory", right_align=True, link_content='' ), @@ -275,13 +276,8 @@ class ProductPackageFilterSet(BaseProductRelationFilterSet): "is_modified", ], ) - is_vulnerable = HasRelationFilter( - label=_("Is Vulnerable"), + is_vulnerable = IsVulnerableFilter( field_name="package__affected_by_vulnerabilities", - choices=( - ("yes", _("Affected by vulnerabilities")), - ("no", _("No vulnerabilities found")), - ), widget=DropDownWidget( anchor="#inventory", right_align=True, link_content='' ), diff --git a/product_portfolio/tests/test_api.py b/product_portfolio/tests/test_api.py index 3bf50597..de5599eb 100644 --- a/product_portfolio/tests/test_api.py +++ b/product_portfolio/tests/test_api.py @@ -46,6 +46,8 @@ from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ProductStatus from product_portfolio.models import ScanCodeProject +from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis class ProductAPITestCase(MaxQueryMixin, TestCase): @@ -1109,6 +1111,74 @@ def test_api_productpackage_endpoint_create_permissions(self): response = self.client.post(self.productpackage_list_url, data) self.assertEqual(status.HTTP_201_CREATED, response.status_code) + def test_api_productpackage_endpoint_vulnerabilities_features(self): + self.client.login(username="super_user", password="secret") + vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1) + vulnerability2 = make_vulnerability(self.dataspace) + analysis1 = make_vulnerability_analysis(self.pp1, vulnerability1) + + response = self.client.get(self.pp1_detail_url) + response_analysis = response.data["vulnerability_analyses"][0] + self.assertEqual(vulnerability1.vulnerability_id, response_analysis["vulnerability_id"]) + self.assertEqual(analysis1.state, response_analysis["state"]) + self.assertEqual(analysis1.justification, response_analysis["justification"]) + + data = {"is_vulnerable": "no"} + response = self.client.get(self.productpackage_list_url, data) + self.assertEqual(0, response.data["count"]) + self.assertNotContains(response, self.pp1_detail_url) + + data = {"is_vulnerable": "yes"} + response = self.client.get(self.productpackage_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.pp1_detail_url) + + data = {"affected_by": vulnerability1.vulnerability_id} + response = self.client.get(self.productpackage_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.pp1_detail_url) + + data = {"affected_by": vulnerability2.vulnerability_id} + response = self.client.get(self.productpackage_list_url, data) + self.assertEqual(0, response.data["count"]) + self.assertNotContains(response, self.pp1_detail_url) + + def test_api_product_endpoint_vulnerabilities_features(self): + self.client.login(username="super_user", password="secret") + vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1) + vulnerability2 = make_vulnerability(self.dataspace) + analysis1 = make_vulnerability_analysis(self.pp1, vulnerability1) + + response = self.client.get(self.product1_detail_url) + response_analysis = response.data["vulnerability_analyses"][0] + self.assertEqual(vulnerability1.vulnerability_id, response_analysis["vulnerability_id"]) + self.assertEqual(analysis1.state, response_analysis["state"]) + self.assertEqual(analysis1.justification, response_analysis["justification"]) + + data = {"is_vulnerable": "no"} + response = self.client.get(self.product_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertNotContains(response, self.product1_detail_url) + self.assertContains(response, self.product2_detail_url) + + data = {"is_vulnerable": "yes"} + response = self.client.get(self.product_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.product1_detail_url) + self.assertNotContains(response, self.product2_detail_url) + + data = {"affected_by": vulnerability1.vulnerability_id} + response = self.client.get(self.product_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.product1_detail_url) + self.assertNotContains(response, self.product2_detail_url) + + data = {"affected_by": vulnerability2.vulnerability_id} + response = self.client.get(self.product_list_url, data) + self.assertEqual(0, response.data["count"]) + self.assertNotContains(response, self.product1_detail_url) + self.assertNotContains(response, self.product2_detail_url) + def test_api_codebaseresource_list_endpoint_results(self): self.client.login(username="super_user", password="secret") response = self.client.get(self.codebase_resource_list_url) diff --git a/product_portfolio/tests/test_views.py b/product_portfolio/tests/test_views.py index e998f25b..7d136da2 100644 --- a/product_portfolio/tests/test_views.py +++ b/product_portfolio/tests/test_views.py @@ -61,6 +61,7 @@ from product_portfolio.views import ManageComponentGridView from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis from workflow.models import Request from workflow.models import RequestTemplate @@ -347,24 +348,9 @@ def test_product_portfolio_tab_vulnerability_view_queries(self): product_package1 = make_product_package(product1, package=p1) product_package2 = make_product_package(product1, package=p2) - VulnerabilityAnalysis.objects.create( - product_package=product_package1, - vulnerability=vulnerability1, - state=VulnerabilityAnalysis.State.RESOLVED, - dataspace=self.dataspace, - ) - VulnerabilityAnalysis.objects.create( - product_package=product_package2, - vulnerability=vulnerability1, - state=VulnerabilityAnalysis.State.RESOLVED, - dataspace=self.dataspace, - ) - VulnerabilityAnalysis.objects.create( - product_package=product_package2, - vulnerability=vulnerability2, - state=VulnerabilityAnalysis.State.RESOLVED, - dataspace=self.dataspace, - ) + make_vulnerability_analysis(product_package1, vulnerability1) + make_vulnerability_analysis(product_package2, vulnerability1) + make_vulnerability_analysis(product_package2, vulnerability2) url = product1.get_url("tab_vulnerabilities") with self.assertNumQueries(9): @@ -379,19 +365,7 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self): product1 = make_product(self.dataspace) product_package1 = make_product_package(product1, package=p1) make_product_package(product1, package=p2) - - analysis = VulnerabilityAnalysis.objects.create( - product_package=product_package1, - vulnerability=vulnerability1, - state=VulnerabilityAnalysis.State.RESOLVED, - justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT, - responses=[ - VulnerabilityAnalysis.Response.CAN_NOT_FIX, - VulnerabilityAnalysis.Response.ROLLBACK, - ], - detail="detail", - dataspace=self.dataspace, - ) + analysis1 = make_vulnerability_analysis(product_package1, vulnerability1) url = product1.get_url("tab_vulnerabilities") response = self.client.get(url) @@ -404,7 +378,7 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self): for package in vulnerability.affected_packages.all() } self.assertTrue(hasattr(packages.get(p1.uuid), "vulnerability_analysis")) - self.assertEqual(analysis, packages.get(p1.uuid).vulnerability_analysis) + self.assertEqual(analysis1, packages.get(p1.uuid).vulnerability_analysis) self.assertFalse(hasattr(packages.get(p2.uuid), "vulnerability_analysis")) expected = """ diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 69d602bc..99e8614a 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -161,7 +161,7 @@ class ProductListView( put_results_in_session = False group_name_version = True table_headers = ( - Header("name", "Product name"), + Header("name", "Product name", filter="is_vulnerable"), Header("version", "Version"), Header("license_expression", "License", filter="licenses"), Header("primary_language", "Language", filter="primary_language"), diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py new file mode 100644 index 00000000..3d5dbce6 --- /dev/null +++ b/vulnerabilities/api.py @@ -0,0 +1,155 @@ +# +# 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 Prefetch + +from rest_framework import serializers +from rest_framework import viewsets + +from component_catalog.models import Package +from dje.api import DataspacedAPIFilterSet +from dje.api import DataspacedSerializer +from dje.api import ExtraPermissionsViewSetMixin +from dje.api_custom import TabPermission +from dje.filters import LastModifiedDateFilter +from dje.filters import MultipleUUIDFilter +from vulnerabilities.filters import RISK_SCORE_RANGES +from vulnerabilities.filters import ScoreRangeFilter +from vulnerabilities.models import Vulnerability +from vulnerabilities.models import VulnerabilityAnalysis + + +class VulnerabilitySerializer(DataspacedSerializer): + # Using SerializerMethodField to workaround circular imports + affected_packages = serializers.SerializerMethodField() + affected_products = serializers.SerializerMethodField() + + class Meta: + model = Vulnerability + fields = ( + "api_url", + "uuid", + "vulnerability_id", + "resource_url", + "summary", + "aliases", + "references", + "exploitability", + "weighted_severity", + "risk_score", + "affected_packages", + "affected_products", + ) + extra_kwargs = { + "api_url": { + "view_name": "api_v2:vulnerability-detail", + "lookup_field": "uuid", + }, + } + + def get_affected_packages(self, obj): + from component_catalog.api import PackageSerializer + + packages = obj.affected_packages.all() + fields = [ + "display_name", + "api_url", + "uuid", + ] + return PackageSerializer(packages, many=True, context=self.context, fields=fields).data + + def get_affected_products(self, obj): + from product_portfolio.api import ProductSerializer + + products = ( + product_package.product + for package in obj.affected_packages.all() + for product_package in package.productpackages.all() + ) + fields = [ + "display_name", + "api_url", + "uuid", + ] + return ProductSerializer(products, many=True, context=self.context, fields=fields).data + + +class VulnerabilityFilterSet(DataspacedAPIFilterSet): + uuid = MultipleUUIDFilter() + last_modified_date = LastModifiedDateFilter() + weighted_severity = ScoreRangeFilter(score_ranges=RISK_SCORE_RANGES) + risk_score = ScoreRangeFilter(score_ranges=RISK_SCORE_RANGES) + + class Meta: + model = Vulnerability + fields = ( + "uuid", + "exploitability", + "weighted_severity", + "risk_score", + "created_date", + "last_modified_date", + ) + + +class VulnerabilityViewSet(ExtraPermissionsViewSetMixin, viewsets.ReadOnlyModelViewSet): + queryset = Vulnerability.objects.all() + serializer_class = VulnerabilitySerializer + lookup_field = "uuid" + filterset_class = VulnerabilityFilterSet + extra_permissions = (TabPermission,) + search_fields = ("vulnerability_id", "aliases") + ordering_fields = ( + "exploitability", + "weighted_severity", + "risk_score", + "created_date", + "last_modified_date", + ) + + def get_queryset(self): + package_qs = Package.objects.only_rendering_fields() + + return ( + super() + .get_queryset() + .scope(self.request.user.dataspace) + .prefetch_related( + Prefetch("affected_packages", queryset=package_qs), + Prefetch("affected_packages__productpackages__product"), + ) + .order_by_risk() + ) + + +class VulnerabilityAnalysisSerializer(DataspacedSerializer, serializers.ModelSerializer): + vulnerability_id = serializers.ReadOnlyField(source="vulnerability.vulnerability_id") + + class Meta: + model = VulnerabilityAnalysis + fields = ( + "uuid", + "product_package", + "vulnerability", + "vulnerability_id", + "state", + "justification", + "responses", + "detail", + ) + extra_kwargs = { + "product_package": { + "view_name": "api_v2:productpackage-detail", + "lookup_field": "uuid", + }, + "vulnerability": { + "view_name": "api_v2:vulnerability-detail", + "lookup_field": "uuid", + }, + } diff --git a/vulnerabilities/tests/__init__.py b/vulnerabilities/tests/__init__.py index 50af8f8a..855d860b 100644 --- a/vulnerabilities/tests/__init__.py +++ b/vulnerabilities/tests/__init__.py @@ -8,6 +8,7 @@ from dje.tests import make_string from vulnerabilities.models import Vulnerability +from vulnerabilities.models import VulnerabilityAnalysis def make_vulnerability(dataspace, affecting=None, **data): @@ -24,3 +25,19 @@ def make_vulnerability(dataspace, affecting=None, **data): vulnerability.add_affected(affecting) return vulnerability + + +def make_vulnerability_analysis(product_package, vulnerability, **data): + return VulnerabilityAnalysis.objects.create( + dataspace=product_package.dataspace, + product_package=product_package, + vulnerability=vulnerability, + state=VulnerabilityAnalysis.State.RESOLVED, + justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT, + responses=[ + VulnerabilityAnalysis.Response.CAN_NOT_FIX, + VulnerabilityAnalysis.Response.ROLLBACK, + ], + detail="detail", + **data, + ) diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py new file mode 100644 index 00000000..96eab99b --- /dev/null +++ b/vulnerabilities/tests/test_api.py @@ -0,0 +1,101 @@ +# +# 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 django.urls import reverse + +from component_catalog.tests import make_package +from dje.models import Dataspace +from dje.tests import MaxQueryMixin +from dje.tests import create_superuser +from product_portfolio.tests import make_product +from vulnerabilities.tests import make_vulnerability + + +class VulnerabilitiesAPITestCase(MaxQueryMixin, TestCase): + def setUp(self): + self.dataspace = Dataspace.objects.create(name="nexB") + self.super_user = create_superuser("super_user", self.dataspace) + + self.vulnerabilities_list_url = reverse("api_v2:vulnerability-list") + + self.package1 = make_package(self.dataspace) + self.package2 = make_package(self.dataspace) + self.product1 = make_product(self.dataspace, inventory=[self.package1, self.package2]) + self.product2 = make_product(self.dataspace, inventory=[self.package2]) + self.vulnerability1 = make_vulnerability( + dataspace=self.dataspace, + affecting=self.package1, + risk_score=0.0, + ) + self.vulnerability2 = make_vulnerability( + dataspace=self.dataspace, + affecting=self.package2, + risk_score=5.0, + ) + self.vulnerability3 = make_vulnerability( + dataspace=self.dataspace, + affecting=[self.package1, self.package2], + risk_score=10.0, + ) + + def test_api_vulnerabilities_list_endpoint_results(self): + self.client.login(username="super_user", password="secret") + + with self.assertMaxQueries(9): + response = self.client.get(self.vulnerabilities_list_url) + + self.assertEqual(3, response.data["count"]) + results = response.data["results"] + + # Ordered by risk_score + expected = [ + self.vulnerability3.vulnerability_id, + self.vulnerability2.vulnerability_id, + self.vulnerability1.vulnerability_id, + ] + self.assertEqual(expected, [entry["vulnerability_id"] for entry in results]) + + self.assertEqual(str(self.package1), results[2]["affected_packages"][0]["display_name"]) + self.assertEqual(str(self.product1), results[2]["affected_products"][0]["display_name"]) + + def test_api_vulnerabilities_list_endpoint_search(self): + self.client.login(username="super_user", password="secret") + + data = {"search": self.vulnerability1.vulnerability_id} + response = self.client.get(self.vulnerabilities_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertContains(response, self.vulnerability1.vulnerability_id) + self.assertNotContains(response, self.vulnerability2.vulnerability_id) + self.assertNotContains(response, self.vulnerability3.vulnerability_id) + + def test_api_vulnerabilities_list_endpoint_filters(self): + self.client.login(username="super_user", password="secret") + + data = {"risk_score": "critical"} + response = self.client.get(self.vulnerabilities_list_url, data) + self.assertEqual(1, response.data["count"]) + self.assertNotContains(response, self.vulnerability1.vulnerability_id) + self.assertNotContains(response, self.vulnerability2.vulnerability_id) + self.assertContains(response, self.vulnerability3.vulnerability_id) + + def test_api_vulnerabilities_detail_endpoint(self): + detail_url = reverse("api_v2:vulnerability-detail", args=[self.vulnerability1.uuid]) + self.client.login(username="super_user", password="secret") + + with self.assertNumQueries(8): + response = self.client.get(detail_url) + + self.assertContains(response, detail_url) + self.assertIn(detail_url, response.data["api_url"]) + self.assertEqual(self.vulnerability1.vulnerability_id, response.data["vulnerability_id"]) + self.assertEqual(str(self.vulnerability1.uuid), response.data["uuid"]) + self.assertEqual("0.0", response.data["risk_score"]) + self.assertEqual(1, len(response.data["affected_packages"])) + self.assertEqual(1, len(response.data["affected_products"])) diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py index ae290801..d60b4378 100644 --- a/vulnerabilities/tests/test_models.py +++ b/vulnerabilities/tests/test_models.py @@ -23,6 +23,7 @@ from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityAnalysis from vulnerabilities.tests import make_vulnerability +from vulnerabilities.tests import make_vulnerability_analysis class VulnerabilitiesModelsTestCase(TestCase): @@ -211,20 +212,9 @@ def test_vulnerability_model_as_cyclonedx(self): product1 = make_product(self.dataspace) product_package1 = make_product_package(product1, package=package1) - analysis = VulnerabilityAnalysis( - product_package=product_package1, - vulnerability=vulnerability1, - state=VulnerabilityAnalysis.State.RESOLVED, - justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT, - responses=[ - VulnerabilityAnalysis.Response.CAN_NOT_FIX, - VulnerabilityAnalysis.Response.ROLLBACK, - ], - detail="detail", - dataspace=self.dataspace, - ) + analysis1 = make_vulnerability_analysis(product_package1, vulnerability1) vulnerability1_as_cdx = vulnerability1.as_cyclonedx( - affected_instances=[package1], analysis=analysis + affected_instances=[package1], analysis=analysis1 ) as_dict = json.loads(vulnerability1_as_cdx.as_json()) expected = { @@ -263,39 +253,28 @@ def test_vulnerability_model_vulnerability_analysis_save(self): def test_vulnerability_model_vulnerability_propagate(self): vulnerability1 = make_vulnerability(dataspace=self.dataspace) product_package1 = make_product_package(make_product(self.dataspace)) - analysis = VulnerabilityAnalysis.objects.create( - product_package=product_package1, - vulnerability=vulnerability1, - dataspace=self.dataspace, - state=VulnerabilityAnalysis.State.RESOLVED, - justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT, - responses=[ - VulnerabilityAnalysis.Response.CAN_NOT_FIX, - VulnerabilityAnalysis.Response.ROLLBACK, - ], - detail="detail", - ) + analysis1 = make_vulnerability_analysis(product_package1, vulnerability1) product2 = make_product(self.dataspace) - new_analysis = analysis.propagate(product2.uuid, self.super_user) + new_analysis = analysis1.propagate(product2.uuid, self.super_user) self.assertIsNone(new_analysis) new_product_package = make_product_package(product2, package=product_package1.package) - new_analysis = analysis.propagate(product2.uuid, self.super_user) + new_analysis = analysis1.propagate(product2.uuid, self.super_user) self.assertIsNotNone(new_analysis) - self.assertNotEqual(analysis.pk, new_analysis.pk) + self.assertNotEqual(analysis1.pk, new_analysis.pk) self.assertEqual(vulnerability1, new_analysis.vulnerability) self.assertEqual(new_product_package, new_analysis.product_package) self.assertEqual(product2, new_analysis.product) self.assertEqual(new_product_package.package, new_analysis.package) self.assertEqual(self.super_user, new_analysis.created_by) self.assertEqual(self.super_user, new_analysis.last_modified_by) - self.assertEqual(analysis.state, new_analysis.state) - self.assertEqual(analysis.justification, new_analysis.justification) - self.assertEqual(analysis.detail, new_analysis.detail) - self.assertEqual(analysis.responses, new_analysis.responses) + self.assertEqual(analysis1.state, new_analysis.state) + self.assertEqual(analysis1.justification, new_analysis.justification) + self.assertEqual(analysis1.detail, new_analysis.detail) + self.assertEqual(analysis1.responses, new_analysis.responses) # Update - analysis.update(state=VulnerabilityAnalysis.State.EXPLOITABLE) - new_analysis = analysis.propagate(product2.uuid, self.super_user) + analysis1.update(state=VulnerabilityAnalysis.State.EXPLOITABLE) + new_analysis = analysis1.propagate(product2.uuid, self.super_user) self.assertEqual(VulnerabilityAnalysis.State.EXPLOITABLE, new_analysis.state)