From 89f68239729ac05ac205dbeb0624d3c115f65bf5 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 16:54:04 +0200 Subject: [PATCH 01/40] refactor(product_type): extract Product_Type model into dojo/product_type/ Phase 1 of module reorg per AGENTS.md. Move Product_Type class + admin registration into dojo/product_type/{models,admin}.py with backward-compat re-export in dojo/models.py. No migration change (app_label unchanged). --- dojo/models.py | 71 +------------------------------ dojo/product_type/__init__.py | 1 + dojo/product_type/admin.py | 9 ++++ dojo/product_type/models.py | 80 +++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 70 deletions(-) create mode 100644 dojo/product_type/admin.py create mode 100644 dojo/product_type/models.py diff --git a/dojo/models.py b/dojo/models.py index a41f5640889..eaf21960650 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -758,75 +758,7 @@ def clean(self): raise ValidationError(msg) -class Product_Type(BaseModel): - - """ - Product types represent the top level model, these can be business unit divisions, different offices or locations, development teams, or any other logical way of distinguishing "types" of products. - ` - Examples: - * IAM Team - * Internal / 3rd Party - * Main company / Acquisition - * San Francisco / New York offices - """ - - name = models.CharField(max_length=255, unique=True) - description = models.CharField(max_length=4000, null=True, blank=True) - critical_product = models.BooleanField(default=False) - key_product = models.BooleanField(default=False) - authorized_users = models.ManyToManyField(Dojo_User, related_name="authorized_product_types", blank=True) - - class Meta: - ordering = ("name",) - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse("product_type", args=[str(self.id)]) - - def get_breadcrumbs(self): - return [{"title": str(self), - "url": reverse("edit_product_type", args=(self.id,))}] - - @cached_property - def critical_present(self): - c_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="Critical") - if c_findings.count() > 0: - return True - return None - - @cached_property - def high_present(self): - c_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="High") - if c_findings.count() > 0: - return True - return None - - @cached_property - def calc_health(self): - h_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="High") - c_findings = Finding.objects.filter( - test__engagement__product__prod_type=self, severity="Critical") - health = 100 - if c_findings.count() > 0: - health = 40 - health -= ((c_findings.count() - 1) * 5) - if h_findings.count() > 0: - if health == 100: - health = 60 - health -= ((h_findings.count() - 1) * 2) - if health < 5: - return 5 - return health - - # only used by bulk risk acceptance api - @property - def unaccepted_open_findings(self): - return Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test__engagement__product__prod_type=self) +from dojo.product_type.models import Product_Type # noqa: E402 -- re-export; mid-file as Product FK uses it below class Product_Line(models.Model): @@ -4432,7 +4364,6 @@ def __str__(self): admin.site.register(Endpoint_Status) admin.site.register(Endpoint) admin.site.register(Product) -admin.site.register(Product_Type) admin.site.register(UserContactInfo) admin.site.register(Notes) admin.site.register(Note_Type) diff --git a/dojo/product_type/__init__.py b/dojo/product_type/__init__.py index e69de29bb2d..83aa70f8a17 100644 --- a/dojo/product_type/__init__.py +++ b/dojo/product_type/__init__.py @@ -0,0 +1 @@ +import dojo.product_type.admin # noqa: F401 diff --git a/dojo/product_type/admin.py b/dojo/product_type/admin.py new file mode 100644 index 00000000000..86cf1fd6bc3 --- /dev/null +++ b/dojo/product_type/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from dojo.product_type.models import Product_Type + + +@admin.register(Product_Type) +class Product_TypeAdmin(admin.ModelAdmin): + + """Admin support for the Product_Type model.""" diff --git a/dojo/product_type/models.py b/dojo/product_type/models.py new file mode 100644 index 00000000000..50bfcc722b4 --- /dev/null +++ b/dojo/product_type/models.py @@ -0,0 +1,80 @@ +from django.db import models +from django.urls import reverse +from django.utils.functional import cached_property + +from dojo.base_models.base import BaseModel + + +class Product_Type(BaseModel): + + """ + Product types represent the top level model, these can be business unit divisions, different offices or locations, development teams, or any other logical way of distinguishing "types" of products. + ` + Examples: + * IAM Team + * Internal / 3rd Party + * Main company / Acquisition + * San Francisco / New York offices + """ + + name = models.CharField(max_length=255, unique=True) + description = models.CharField(max_length=4000, null=True, blank=True) + critical_product = models.BooleanField(default=False) + key_product = models.BooleanField(default=False) + authorized_users = models.ManyToManyField("dojo.Dojo_User", related_name="authorized_product_types", blank=True) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("product_type", args=[str(self.id)]) + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": reverse("edit_product_type", args=(self.id,))}] + + @cached_property + def critical_present(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + c_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="Critical") + if c_findings.count() > 0: + return True + return None + + @cached_property + def high_present(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + c_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="High") + if c_findings.count() > 0: + return True + return None + + @cached_property + def calc_health(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + h_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="High") + c_findings = Finding.objects.filter( + test__engagement__product__prod_type=self, severity="Critical") + health = 100 + if c_findings.count() > 0: + health = 40 + health -= ((c_findings.count() - 1) * 5) + if h_findings.count() > 0: + if health == 100: + health = 60 + health -= ((h_findings.count() - 1) * 2) + if health < 5: + return 5 + return health + + # only used by bulk risk acceptance api + @property + def unaccepted_open_findings(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + return Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test__engagement__product__prod_type=self) From 94bd6fa31f26fb02c6b982d14dd80ecc13aab116 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:44:38 +0200 Subject: [PATCH 02/40] refactor(product_type): move forms + UI filter into dojo/product_type/ui/ [Phase 3,4] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3+4 of module reorg per AGENTS.md. Move Product_TypeForm, Delete_Product_TypeForm, Add_Product_Type_AuthorizedUsersForm into ui/forms.py (re-export from dojo/forms.py) and ProductTypeFilter into ui/filters.py. The filter keeps its DojoFilter base; its only consumer is the product_type view, so no dojo/filters.py re-export is kept (matches the url module) — avoids the extracted-filter<->dojo.filters circular import. --- dojo/filters.py | 16 ---------- dojo/forms.py | 39 +----------------------- dojo/product_type/ui/__init__.py | 0 dojo/product_type/ui/filters.py | 24 +++++++++++++++ dojo/product_type/ui/forms.py | 51 ++++++++++++++++++++++++++++++++ dojo/product_type/views.py | 3 +- 6 files changed, 78 insertions(+), 55 deletions(-) create mode 100644 dojo/product_type/ui/__init__.py create mode 100644 dojo/product_type/ui/filters.py create mode 100644 dojo/product_type/ui/forms.py diff --git a/dojo/filters.py b/dojo/filters.py index 96184d92e2d..8354e51b2f4 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -3780,22 +3780,6 @@ class Meta: # re-exported at the bottom of this module for backward compatibility. -class ProductTypeFilter(DojoFilter): - name = CharFilter(lookup_expr="icontains") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ), - ) - - class Meta: - model = Product_Type - exclude = [] - include = ("name",) - - class TestTypeFilter(DojoFilter): name = CharFilter(lookup_expr="icontains") diff --git a/dojo/forms.py b/dojo/forms.py index cb90d0f57de..862d641b968 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -243,44 +243,7 @@ def value_from_datadict(self, data, files, name): return data.get(name, None) -class Product_TypeForm(forms.ModelForm): - description = forms.CharField(widget=forms.Textarea(attrs={}), - required=False) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["critical_product"].label = labels.ORG_CRITICAL_PRODUCT_LABEL - self.fields["key_product"].label = labels.ORG_KEY_PRODUCT_LABEL - - class Meta: - model = Product_Type - fields = ["name", "description", "critical_product", "key_product"] - - -class Delete_Product_TypeForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Product_Type - fields = ["id"] - - -class Add_Product_Type_AuthorizedUsersForm(forms.Form): - users = forms.ModelMultipleChoiceField( - queryset=Dojo_User.objects.none(), required=True, label="Users", - ) - - def __init__(self, *args, product_type=None, **kwargs): - super().__init__(*args, **kwargs) - self.product_type = product_type - current = product_type.authorized_users.values_list("pk", flat=True) - self.fields["users"].queryset = ( - Dojo_User.objects.filter(is_active=True) - .exclude(is_superuser=True) - .exclude(pk__in=current) - .order_by("first_name", "last_name") - ) +from dojo.product_type.ui.forms import Add_Product_Type_AuthorizedUsersForm, Delete_Product_TypeForm, Product_TypeForm # noqa: E402, F401, I001 class Test_TypeForm(forms.ModelForm): diff --git a/dojo/product_type/ui/__init__.py b/dojo/product_type/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/product_type/ui/filters.py b/dojo/product_type/ui/filters.py new file mode 100644 index 00000000000..bc4880b73bb --- /dev/null +++ b/dojo/product_type/ui/filters.py @@ -0,0 +1,24 @@ +import logging + +from django_filters import CharFilter, OrderingFilter + +from dojo.filters import DojoFilter +from dojo.product_type.models import Product_Type + +logger = logging.getLogger(__name__) + + +class ProductTypeFilter(DojoFilter): + name = CharFilter(lookup_expr="icontains") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ), + ) + + class Meta: + model = Product_Type + exclude = [] + include = ("name",) diff --git a/dojo/product_type/ui/forms.py b/dojo/product_type/ui/forms.py new file mode 100644 index 00000000000..68405a9ba19 --- /dev/null +++ b/dojo/product_type/ui/forms.py @@ -0,0 +1,51 @@ +import logging + +from django import forms + +from dojo.labels import get_labels +from dojo.models import Dojo_User +from dojo.product_type.models import Product_Type + +logger = logging.getLogger(__name__) + +labels = get_labels() + + +class Product_TypeForm(forms.ModelForm): + description = forms.CharField(widget=forms.Textarea(attrs={}), + required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["critical_product"].label = labels.ORG_CRITICAL_PRODUCT_LABEL + self.fields["key_product"].label = labels.ORG_KEY_PRODUCT_LABEL + + class Meta: + model = Product_Type + fields = ["name", "description", "critical_product", "key_product"] + + +class Delete_Product_TypeForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Product_Type + fields = ["id"] + + +class Add_Product_Type_AuthorizedUsersForm(forms.Form): + users = forms.ModelMultipleChoiceField( + queryset=Dojo_User.objects.none(), required=True, label="Users", + ) + + def __init__(self, *args, product_type=None, **kwargs): + super().__init__(*args, **kwargs) + self.product_type = product_type + current = product_type.authorized_users.values_list("pk", flat=True) + self.fields["users"].queryset = ( + Dojo_User.objects.filter(is_active=True) + .exclude(is_superuser=True) + .exclude(pk__in=current) + .order_by("first_name", "last_name") + ) diff --git a/dojo/product_type/views.py b/dojo/product_type/views.py index f16a06c2e17..79353d5fd4b 100644 --- a/dojo/product_type/views.py +++ b/dojo/product_type/views.py @@ -15,7 +15,7 @@ from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.roles_permissions import Permissions -from dojo.filters import ProductFilter, ProductFilterWithoutObjectLookups, ProductTypeFilter +from dojo.filters import ProductFilter, ProductFilterWithoutObjectLookups from dojo.forms import ( Add_Product_Type_AuthorizedUsersForm, Delete_Product_TypeForm, @@ -27,6 +27,7 @@ from dojo.product_type.queries import ( get_authorized_product_types, ) +from dojo.product_type.ui.filters import ProductTypeFilter from dojo.query_utils import build_count_subquery from dojo.utils import ( add_breadcrumb, From 80add28b20dd6e54d94e3cd006ab5c5f8ab118c7 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:45:56 +0200 Subject: [PATCH 03/40] refactor(product_type): move views into dojo/product_type/ui/views.py [Phase 5] Phase 5 of module reorg per AGENTS.md. Move dojo/product_type/views.py to dojo/product_type/ui/views.py and update its two importers (dojo/organization/urls.py and the counts unit test). product_type has no urls.py (routes live in dojo/organization/urls.py), so only the views move. --- dojo/organization/urls.py | 2 +- dojo/product_type/{ => ui}/views.py | 0 unittests/test_product_type_counts.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename dojo/product_type/{ => ui}/views.py (100%) diff --git a/dojo/organization/urls.py b/dojo/organization/urls.py index 0555a654b20..3487cee1bf2 100644 --- a/dojo/organization/urls.py +++ b/dojo/organization/urls.py @@ -2,7 +2,7 @@ from django.urls import re_path from dojo.product import views as product_views -from dojo.product_type import views +from dojo.product_type.ui import views from dojo.utils import redirect_view # TODO: remove the else: branch once v3 migration is complete diff --git a/dojo/product_type/views.py b/dojo/product_type/ui/views.py similarity index 100% rename from dojo/product_type/views.py rename to dojo/product_type/ui/views.py diff --git a/unittests/test_product_type_counts.py b/unittests/test_product_type_counts.py index 5bac04f3d1c..9ea01cfdcc3 100644 --- a/unittests/test_product_type_counts.py +++ b/unittests/test_product_type_counts.py @@ -1,5 +1,5 @@ from dojo.models import Product, Product_Type -from dojo.product_type.views import prefetch_for_product_type +from dojo.product_type.ui.views import prefetch_for_product_type from unittests.dojo_test_case import DojoTestCase, versioned_fixtures From 7e0af09e7956f41ec069d85f9b92728cab12b6c9 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:54:21 +0200 Subject: [PATCH 04/40] refactor(product_type): extract API layer into dojo/product_type/api/ [Phase 6,8,9] Phase 6/8/9 of module reorg per AGENTS.md (Phase 7 N/A - no product_type API filter). Move ProductTypeSerializer into api/serializer.py (re-exported from api_v2/serializers.py, still used by ReportGenerateSerializer) and ProductTypeViewSet into api/views.py. Add api/urls.py with add_product_type_urls() preserving route 'product_types' + basename 'product_type'; wire it into dojo/urls.py. Viewset re-export omitted (would cycle api_v2.views<->product_type.api.views; only consumer was a test, now imports new path). --- dojo/api_v2/serializers.py | 5 +- dojo/api_v2/views.py | 84 -------------------------- dojo/product_type/api/__init__.py | 1 + dojo/product_type/api/serializer.py | 9 +++ dojo/product_type/api/urls.py | 6 ++ dojo/product_type/api/views.py | 94 +++++++++++++++++++++++++++++ dojo/urls.py | 4 +- unittests/test_rest_framework.py | 2 +- 8 files changed, 114 insertions(+), 91 deletions(-) create mode 100644 dojo/product_type/api/__init__.py create mode 100644 dojo/product_type/api/serializer.py create mode 100644 dojo/product_type/api/urls.py create mode 100644 dojo/product_type/api/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index eb0f67eb7be..55d9cb9494b 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -727,10 +727,7 @@ class Meta: fields = ["path"] -class ProductTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Product_Type - fields = "__all__" +from dojo.product_type.api.serializer import ProductTypeSerializer # noqa: E402 class EngagementSerializer(serializers.ModelSerializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 3f3663ecb7f..e1dff0f520a 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -104,7 +104,6 @@ Notes, Product, Product_API_Scan_Configuration, - Product_Type, Regulation, Risk_Acceptance, SLA_Configuration, @@ -128,9 +127,6 @@ get_authorized_product_api_scan_configurations, get_authorized_products, ) -from dojo.product_type.queries import ( - get_authorized_product_types, -) from dojo.query_utils import build_count_subquery from dojo.reports.views import ( prefetch_related_findings_for_report, @@ -1784,86 +1780,6 @@ def generate_report(self, request, pk=None): return Response(report.data) -# Authorization: object-based -@extend_schema_view(**schema_with_prefetch()) -# Authorization: object-based -@extend_schema_view(**schema_with_prefetch()) -class ProductTypeViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.ProductTypeSerializer - queryset = Product_Type.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "name", - "critical_product", - "key_product", - "created", - "updated", - ] - permission_classes = ( - IsAuthenticated, - permissions.UserHasProductTypePermission, - ) - - def get_queryset(self): - return get_authorized_product_types( - "view", - ).distinct() - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if get_setting("ASYNC_OBJECT_DELETE"): - async_del = async_delete() - async_del.delete(instance) - else: - with Endpoint.allow_endpoint_init(): # TODO: Delete this after the move to Locations - instance.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=True, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request, pk=None): - product_type = self.get_object() - - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, product_type, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - # Authorization: authenticated, configuration class DevelopmentEnvironmentViewSet( DojoModelViewSet, diff --git a/dojo/product_type/api/__init__.py b/dojo/product_type/api/__init__.py new file mode 100644 index 00000000000..9ac0eff9870 --- /dev/null +++ b/dojo/product_type/api/__init__.py @@ -0,0 +1 @@ +path = "product_types" # noqa: RUF067 diff --git a/dojo/product_type/api/serializer.py b/dojo/product_type/api/serializer.py new file mode 100644 index 00000000000..fc09e6e7100 --- /dev/null +++ b/dojo/product_type/api/serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from dojo.product_type.models import Product_Type + + +class ProductTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Product_Type + fields = "__all__" diff --git a/dojo/product_type/api/urls.py b/dojo/product_type/api/urls.py new file mode 100644 index 00000000000..419dc829307 --- /dev/null +++ b/dojo/product_type/api/urls.py @@ -0,0 +1,6 @@ +from dojo.product_type.api.views import ProductTypeViewSet + + +def add_product_type_urls(router): + router.register("product_types", ProductTypeViewSet, basename="product_type") + return router diff --git a/dojo/product_type/api/views.py b/dojo/product_type/api/views.py new file mode 100644 index 00000000000..d67abb83014 --- /dev/null +++ b/dojo/product_type/api/views.py @@ -0,0 +1,94 @@ +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2.serializers import ReportGenerateOptionSerializer, ReportGenerateSerializer +from dojo.api_v2.views import PrefetchDojoModelViewSet, report_generate, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.models import Endpoint, Product_Type +from dojo.product_type.api.serializer import ProductTypeSerializer +from dojo.product_type.queries import get_authorized_product_types +from dojo.utils import async_delete, get_setting + + +# Authorization: object-based +@extend_schema_view(**schema_with_prefetch()) +# Authorization: object-based +@extend_schema_view(**schema_with_prefetch()) +class ProductTypeViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = ProductTypeSerializer + queryset = Product_Type.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "name", + "critical_product", + "key_product", + "created", + "updated", + ] + permission_classes = ( + IsAuthenticated, + permissions.UserHasProductTypePermission, + ) + + def get_queryset(self): + return get_authorized_product_types( + "view", + ).distinct() + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if get_setting("ASYNC_OBJECT_DELETE"): + async_del = async_delete() + async_del.delete(instance) + else: + with Endpoint.allow_endpoint_init(): # TODO: Delete this after the move to Locations + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + request=ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: ReportGenerateSerializer}, + ) + @action( + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request, pk=None): + product_type = self.get_object() + + options = {} + # prepare post data + report_options = ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, product_type, options) + report = ReportGenerateSerializer(data) + return Response(report.data) diff --git a/dojo/urls.py b/dojo/urls.py index 9b9a8d6a399..a31c6e62bd3 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -37,7 +37,6 @@ NotesViewSet, NoteTypeViewSet, ProductAPIScanConfigurationViewSet, - ProductTypeViewSet, ProductViewSet, RegulationsViewSet, ReImportScanView, @@ -80,6 +79,7 @@ from dojo.object.urls import urlpatterns as object_urls from dojo.organization.api.urls import add_organization_urls from dojo.organization.urls import urlpatterns as organization_urls +from dojo.product_type.api.urls import add_product_type_urls from dojo.regulations.urls import urlpatterns as regulations from dojo.reports.urls import urlpatterns as reports_urls from dojo.search.urls import urlpatterns as search_urls @@ -136,7 +136,7 @@ v2_api.register(r"product_api_scan_configurations", ProductAPIScanConfigurationViewSet, basename="product_api_scan_configuration") # RBAC endpoints moved to Pro under legacy authorization: # product_groups, product_members → pro/product_groups, pro/product_members -v2_api.register(r"product_types", ProductTypeViewSet, basename="product_type") +v2_api = add_product_type_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 4455dba1374..c6b9d747231 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -57,7 +57,6 @@ NotesViewSet, NoteTypeViewSet, ProductAPIScanConfigurationViewSet, - ProductTypeViewSet, ProductViewSet, RiskAcceptanceViewSet, SonarqubeIssueViewSet, @@ -116,6 +115,7 @@ from dojo.organization.api.views import ( OrganizationViewSet, ) +from dojo.product_type.api.views import ProductTypeViewSet from dojo.url.api.views import URLViewSet from dojo.url.models import URL From 464c1bf2141257913d66b628995b06b422092c1b Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:12:34 +0200 Subject: [PATCH 05/40] docs(agents): fold Phase 1 reorg lessons into the playbook Update AGENTS.md with conventions learned doing the Phase 1 model extractions: string FK refs to break circular imports, ruff noqa conventions (PLC0415/E402/F401), consolidated re-export placement, single-sourced constants, load-bearing side-effect imports, and corrected docker-based verify commands (manage.py shell, run-unittest.sh). --- AGENTS.md | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64279977619..3882c7f292f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,17 +117,29 @@ grep -rn "from dojo.models import.*{Model}" dojo/ unittests/ 5. Remove original model code (keep re-export line) **Import rules for models.py:** -- Upward FKs (e.g., Test -> Engagement): import from `dojo.models` if not yet extracted, or `dojo.{module}.models` if already extracted -- Downward references (e.g., Product_Type querying Finding): use lazy imports inside method bodies -- Shared utilities (`copy_model_util`, `_manage_inherited_tags`, `get_current_date`, etc.): import from `dojo.models` +- **Prefer string FK refs to break circular imports.** Convert EVERY ForeignKey/ManyToMany/OneToOne whose target is NOT a class being moved into a string ref `"dojo."` (e.g. `models.ForeignKey(Engagement, ...)` → `models.ForeignKey("dojo.Engagement", ...)`). This lets the extracted `models.py` carry ZERO top-level `from dojo.models import ...`, which is what actually prevents circular imports. String refs produce identical migrations (Django resolves via the app registry) — `makemigrations --check` must still say "No changes detected". +- References AMONG the classes being moved together also use string refs, for uniformity and to avoid in-file ordering issues. +- Downward/other dojo references inside METHOD bodies: lazy imports inside the method. +- Shared utilities (`copy_model_util`, `_manage_inherited_tags`, `get_current_date`, `tomorrow`, etc.): import from `dojo.models`. CAVEAT: if a utility is used as a class-body field default (e.g. `default=get_current_date`), it must be imported (not redefined locally) so its `__module__` stays `dojo.models` — otherwise migration serialization changes and `makemigrations` flags a diff. These utils are defined early in `dojo.models` (before the re-export that loads your module), so a top-level `from dojo.models import get_current_date, tomorrow, copy_model_util` resolves correctly despite the partial circular load. - Do NOT set `app_label` in Meta — all models inherit `dojo` app_label automatically -**Verify:** +**Lint conventions (the repo pre-commit ruff is strict — match exactly):** +- Method-body lazy imports need `# noqa: PLC0415 -- lazy import, avoids circular dependency`. +- Mid-file / non-top re-exports in `dojo/models.py` need `# noqa: E402`, plus `# noqa: F401` ONLY on names not referenced elsewhere in `dojo/models.py` (a name still used by a remaining class body must NOT get F401). +- Self-check before committing: `/home/valentijn/.local/bin/ruff check --config ruff.toml ` (ruff is a host binary, NOT in the uwsgi container). Never let `ruff --fix` wrap a re-export into a parenthesized multiline — shorten the comment instead. + +**Re-export placement:** use ONE consolidated re-export block per module, placed at the earliest moved class's original position. A name referenced in a class-body FK at load-time must be re-exported BEFORE that line. + +**Constants:** single-source module-level constants in the extracted module and re-export from `dojo/models.py` (done for `IMPORT_ACTIONS`, `ENGAGEMENT_STATUS_CHOICES`). Do not duplicate. + +**Watch for load-bearing imports:** some imports in `dojo/models.py` exist for side effects, not the imported name (e.g. `from dojo.utils import parse_cvss_data` transitively registers `dojo.location` models for `apps.py:ready()`). If you remove the last consumer of such an import, keep it as a re-export or `apps.py` breaks. + +**Verify** (runs in docker; model imports need `manage.py shell -c`, not bare `python -c`): ```bash -python manage.py check -python manage.py makemigrations --check -python -c "from dojo.{module}.models import {Model}" -python -c "from dojo.models import {Model}" +docker compose exec -T uwsgi python manage.py check +docker compose exec -T uwsgi python manage.py makemigrations --check --dry-run # must say "No changes detected" +docker compose exec -T uwsgi python manage.py shell -c "from dojo.{module}.models import {Model}; print('ok')" +docker compose exec -T uwsgi python manage.py shell -c "from dojo.models import {Model}; print('ok')" ``` ### Phase 2: Extract Services @@ -217,9 +229,10 @@ Update UI views and API viewsets to call the service instead of containing logic ### After Each Phase: Verify ```bash -python manage.py check -python manage.py makemigrations --check -python -m pytest unittests/ -x --timeout=120 +docker compose exec -T uwsgi python manage.py check +docker compose exec -T uwsgi python manage.py makemigrations --check --dry-run +# Tests run via the wrapper (NOT pytest/manage.py test directly); tee to capture output: +./run-unittest.sh --test-case unittests.{relevant_test_module} 2>&1 | tee /tmp/test.log ``` --- From d4b7aab238699c8a1429daeedd2b6383f2dce528 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 20:19:53 +0200 Subject: [PATCH 06/40] docs(agents): add Phase 2-9 lessons from the product_type full reorg Conditional services phase; keep filter base class + drop re-export when sole consumer is the module's own view (circular-import fix); per-symbol re-export decisions by actual consumers (incl. multi-line imports); preserve DRF route+basename; run real tests after dropping a re-export. --- AGENTS.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3882c7f292f..2bcbf2467e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,8 @@ docker compose exec -T uwsgi python manage.py shell -c "from dojo.models import ### Phase 2: Extract Services +**This phase is conditional.** If the module's views are pure CRUD (form save/delete, simple field add/remove) with none of the "belongs in services" items below, there is NO `services.py` — skip the phase (the `url`/`location` reference modules have none). Don't invent a service just to have one. + Create `dojo/{module}/services.py` with business logic extracted from UI views. **What belongs in services.py:** @@ -185,8 +187,14 @@ Update UI views and API viewsets to call the service instead of containing logic ### Phase 4: Extract UI Filters to `ui/filters.py` 1. Create `dojo/{module}/ui/filters.py` — move module-specific filters from `dojo/filters.py` -2. Shared base classes (`DojoFilter`, `DateRangeFilter`, `ReportBooleanFilter`) stay in `dojo/filters.py` -3. Add re-exports in `dojo/filters.py` +2. Shared base classes (`DojoFilter`, `DateRangeFilter`, `ReportBooleanFilter`) stay in `dojo/filters.py`. **Keep the original base class** (`class XFilter(DojoFilter)`) — do NOT switch to `FilterSet` to dodge an import. +3. **Circular-import caveat**: a re-export in `dojo/filters.py` (`from dojo.{module}.ui.filters import XFilter`) while `ui/filters.py` imports `DojoFilter` back from `dojo.filters` creates a real cycle (fails when `ui/filters.py` loads first). Resolve per the re-export rule below — usually: **drop the `dojo/filters.py` re-export** when the filter's only consumer is the module's own view, and import the filter directly from `dojo.{module}.ui.filters` in that view (matches the `url` module). + +> **Re-export decisions (Phases 3,4,6,8) — decide per symbol, by actual remaining consumers:** +> - `grep -rn` the symbol across `dojo/` and `unittests/` first. Account for multi-line `from x import (\n ...\n)` blocks — a one-line grep misses them. +> - If a symbol is still referenced by code that REMAINS in the monolith (e.g. `ProductTypeSerializer` used by `ReportGenerateSerializer` in `api_v2/serializers.py`) → **keep** the re-export (`# noqa: E402` + `F401` as needed). +> - If the ONLY consumers are code you are moving/updating anyway (the module's own views/tests) → **omit** the re-export and point those consumers at the new path. This is required when a re-export would cycle (filter↔`dojo.filters`, `api_v2.views`↔`{module}.api.views`). +> - After dropping any re-export, run the module's real unit tests (not just `manage.py check`) — `check` won't catch a broken import in a test module. ### Phase 5: Move UI Views/URLs into `ui/` @@ -226,6 +234,8 @@ Update UI views and API viewsets to call the service instead of containing logic ``` 2. Update `dojo/urls.py` — replace `v2_api.register(...)` with `add_{module}_urls(v2_api)` +**Preserve the exact route and basename** from the original `v2_api.register(...)` call. They often differ (e.g. route `product_types`, `basename="product_type"`); `path` in `api/__init__.py` should be the route string, and pass `basename=` explicitly if the original did. Changing either breaks DRF URL reversing and the API tests. Verify with `reverse('{basename}-list')`. + ### After Each Phase: Verify ```bash From 021480da8637140c7b0c77f40cb716ac073060dd Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 21:37:24 +0200 Subject: [PATCH 07/40] docs(agents): add API serializer/viewset cycle-break + class-copy lessons from engagement reorg --- AGENTS.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2bcbf2467e3..20a3e8b5259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,9 @@ Update UI views and API viewsets to call the service instead of containing logic 1. Create `dojo/{module}/api/__init__.py` with `path = "{module}"` 2. Create `dojo/{module}/api/serializer.py` — move from `dojo/api_v2/serializers.py` -3. Add re-exports in `dojo/api_v2/serializers.py` +3. Re-export ONLY the serializers still referenced by code REMAINING in `api_v2/serializers.py` (e.g. one nested by `ReportGenerateSerializer` / used in a `RiskAcceptance` representation). Serializers consumed only by the viewset are imported by their new path in Phase 8, so omit those re-exports. + +**Cycle-break for serializers that reference api_v2 serializers** (matches `dojo/test/api/serializer.py`, `dojo/engagement/api/serializer.py`): a moved serializer cannot import `NoteSerializer`/`FileSerializer`/`TagListSerializerField` etc. from `dojo.api_v2.serializers` at module level — that cycles once `api_v2/serializers.py` re-imports your serializer. Convert class-body field assignments (`tags = TagListSerializerField(...)`, `notes = NoteSerializer(many=True)`) into a lazy `get_fields()` override that imports inside the method (`# noqa: PLC0415`); `build_relational_field` lazy-imports the same way. The extracted module then carries ZERO top-level `dojo.api_v2.serializers` import. ### Phase 7: Extract API Filters to `api/filters.py` @@ -219,7 +221,9 @@ Update UI views and API viewsets to call the service instead of containing logic ### Phase 8: Extract API ViewSets to `api/views.py` 1. Create `dojo/{module}/api/views.py` — move from `dojo/api_v2/views.py` -2. Add re-exports in `dojo/api_v2/views.py` +2. Do NOT re-export the viewset in `dojo/api_v2/views.py` — it would cycle (`api_v2.views` ↔ `{module}.api.views`, because the viewset imports its base classes back from `api_v2.views`). Update the consumers instead: the `dojo/urls.py` registration (Phase 9) and `unittests/test_rest_framework.py`, which imports viewsets by name (a dropped re-export there is an ImportError that `manage.py check` won't catch — only the test run does). + +**Viewset import pattern (matches `dojo/test/api/views.py`, `dojo/engagement/api/views.py`):** `from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, report_generate, schema_with_prefetch` — base classes and helpers stay in the monolith. Requalify every `serializers.X` reference that stays in `api_v2` to `api_v2_serializers.X` via `from dojo.api_v2 import serializers as api_v2_serializers`; import the MOVED serializers by name from `dojo.{module}.api.serializer`. PRESERVE active class decorators such as `@extend_schema_view(**schema_with_prefetch())` — they are easy to drop when copying a viewset and silently change the generated schema. After moving, prune the now-unused engagement-specific imports left behind in `api_v2/views.py` (filter, services, queries, models) — ruff flags them. ### Phase 9: Extract API URL Registration @@ -238,6 +242,8 @@ Update UI views and API viewsets to call the service instead of containing logic ### After Each Phase: Verify +**When copying a class/function out, capture through to the next top-level `class`/dedent.** A fixed-line-window read can silently truncate a long class (trailing fields + `Meta` + `__init__`), yielding a partial copy that still imports cleanly but drops behavior. Confirm the last line of the source class before deleting it from the monolith. + ```bash docker compose exec -T uwsgi python manage.py check docker compose exec -T uwsgi python manage.py makemigrations --check --dry-run From 671062a5323019d1214982fad2630d599c9a572f Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 23:28:13 +0200 Subject: [PATCH 08/40] docs(agents): note prefetcher full-reexport + extend_schema_field cycle-break (Phase 6) --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 20a3e8b5259..c91d5362456 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,8 +211,12 @@ Update UI views and API viewsets to call the service instead of containing logic 2. Create `dojo/{module}/api/serializer.py` — move from `dojo/api_v2/serializers.py` 3. Re-export ONLY the serializers still referenced by code REMAINING in `api_v2/serializers.py` (e.g. one nested by `ReportGenerateSerializer` / used in a `RiskAcceptance` representation). Serializers consumed only by the viewset are imported by their new path in Phase 8, so omit those re-exports. + **EXCEPTION — prefetcher discovery (re-export the FULL moved ModelSerializer set):** `dojo/api_v2/prefetch/prefetcher.py` builds its model→serializer map via `inspect.getmembers(sys.modules["dojo.api_v2.serializers"], ...)`. Any moved `ModelSerializer` that drops out of `api_v2/serializers.py`'s module members disappears from that map, so prefetch breaks (e.g. `test_detail_prefetch` / `test_list_prefetch` fail with `'' not found`) — and `manage.py check` does NOT catch it; only the `test_rest_framework` prefetch tests do. So re-export the ENTIRE set of moved `ModelSerializer`s (not just the ReportGenerate-nested ones), even nested/sub serializers with no other consumer. This re-export block is byte-identical module membership → zero behavior change. (Pure `serializers.Serializer` subclasses that aren't tied to a model and aren't referenced elsewhere can still be omitted.) This bit the finding module (18 serializers); revisit earlier modules if their prefetch tests ever regress. + **Cycle-break for serializers that reference api_v2 serializers** (matches `dojo/test/api/serializer.py`, `dojo/engagement/api/serializer.py`): a moved serializer cannot import `NoteSerializer`/`FileSerializer`/`TagListSerializerField` etc. from `dojo.api_v2.serializers` at module level — that cycles once `api_v2/serializers.py` re-imports your serializer. Convert class-body field assignments (`tags = TagListSerializerField(...)`, `notes = NoteSerializer(many=True)`) into a lazy `get_fields()` override that imports inside the method (`# noqa: PLC0415`); `build_relational_field` lazy-imports the same way. The extracted module then carries ZERO top-level `dojo.api_v2.serializers` import. +**`@extend_schema_field` decorators referencing api_v2 serializers also cycle** (their argument is evaluated eagerly at class-body load). A class-body `@extend_schema_field(RiskAcceptanceSerializer)` / `@extend_schema_field(BurpRawRequestResponseSerializer)` cannot stay. Drop the decorator and reapply the override at the bottom of the module via `drf_spectacular.utils.set_override(Cls.method, "field", LazyImportedSerializer)` inside a small `_apply_schema_overrides()` that lazy-imports the api_v2 serializer (`# noqa: PLC0415`). This preserves the generated schema with no top-level api_v2 reference. (Decorators whose argument is one of the MOVED serializers in the same file are fine as-is.) + ### Phase 7: Extract API Filters to `api/filters.py` 1. Create `dojo/{module}/api/filters.py` — move `Api{Model}Filter` from `dojo/filters.py` From afaf2f40878143bdb8845dbed6f18c0872e072a5 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 22:26:13 +0200 Subject: [PATCH 09/40] docs(agents): add Phase 10 peripheral-module 10-PR stack plan Self-contained brief for finishing the reorg: 5 new draft PRs (#6-10) stacked on top of the finding PR, plus CWE+BurpRawRequestResponse folded into the existing finding module PR. Bundles, line ranges, stack/cascade mechanics, and module-specific gotchas. Marks the 5 core modules Complete. --- AGENTS.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c91d5362456..e53dcd7cadf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,11 +54,12 @@ Modules in various stages of reorganization: |--------|-----------|-------------|-----|------|--------| | **url** | In module | N/A | Done | Done | **Complete** | | **location** | In module | N/A | N/A | Done | **Complete** | -| **product_type** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **test** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **engagement** | In dojo/models.py | Partial (32 lines) | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **product** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | -| **finding** | In dojo/models.py | Missing | Partial (views at root) | In dojo/api_v2/ | Needs work | +| **product_type** | In module | N/A | Done | Done | **Complete** (#14970) | +| **test** | In module | N/A | Done | Done | **Complete** (#14971) | +| **engagement** | In module | In module | Done | Done | **Complete** (#14972) | +| **product** | In module | N/A | Done | Done | **Complete** (#14973) | +| **finding** | In module | N/A (helper.py) | Done | Done | **Complete** (#14974); CWE+Burp pending | +| **peripheral (×18)** | In dojo/models.py | — | Partial/none | Partial/none | **Phase 10** (PRs #6–10, see below) | ### Monolithic Files Being Decomposed @@ -280,3 +281,70 @@ def critical_present(self): - **Signal registration**: Handled in `dojo/apps.py` via `import dojo.{module}.signals`. Already set up for test, engagement, product, product_type. - **Watson search**: Uses `self.get_model("Product")` in `apps.py` — works via Django's model registry regardless of file location. - **Admin registration**: Currently at the bottom of `dojo/models.py` (lines 4888-4973). Must be moved to `{module}/admin.py` and removed from `dojo/models.py` to avoid `AlreadyRegistered` errors. + +--- + +## Phase 10: Peripheral Model Modules — 10-PR Stack Continuation + +> **This section is the complete, self-contained brief for a fresh agent session (auto mode) to finish the reorganization.** The 5 core hierarchy modules (`product_type`, `test`, `engagement`, `product`, `finding`) are DONE — they are the templates. What remains is moving the ~45 *peripheral* model classes still defined in `dojo/models.py` into their domain modules, each as a **full vertical slice** (all 9 phases), reusing the playbook above. + +### Goal & scope + +`dojo/models.py` is now ~2,254 lines and still **defines** these peripheral model classes. Move each into its module (most module dirs already exist with `views.py`/`urls.py`/helpers but NO `models.py`/`admin.py` — only `dojo/url/` and `dojo/location/` are complete-with-models templates). Leave backward-compat re-exports in every monolith (`dojo/models.py`, `forms.py`, `filters.py`, `api_v2/serializers.py`, `api_v2/views.py`) per the rules above. + +**Decisions already locked with the user (do NOT relitigate):** +- **Full vertical slice per module** (Phases 1–9), not models-only. Skip a phase only when the module genuinely has no code for it (e.g. no API serializer/viewset exists → no `api/` layer; no module-specific form → no `ui/forms.py`). Follow the "Phase 2 is conditional" / re-export-by-actual-consumer rules above. +- **These models STAY in `dojo/models.py`** (no module worth creating — do NOT extract): `DojoMeta`, `Network_Locations`, `Sonarqube_Issue`, `Sonarqube_Issue_Transition`, `Check_List`, `Testing_Guide_Category`, `Testing_Guide`, `Language_Type`, `Languages`, `App_Analysis`. Leave them untouched. +- **`CWE` + `BurpRawRequestResponse` fold into `finding`** (they are finding-domain), and are done FIRST on the EXISTING finding PR (#14974), not a new PR. + +### The 10-PR stack + +The 5 core PRs already exist (stacked, merge bottom-up): `dev ← #14970 product_type ← #14971 test ← #14972 engagement ← #14973 product ← #14974 finding`. **The new work CONTINUES this stack on top of #14974.** All branches and PRs follow the same conventions as the existing 5. + +| PR | Branch (head) | Base | Contents | +|----|---------------|------|----------| +| 1–5 | existing | existing | DONE: product_type, test, engagement, product, finding | +| **5 (#14974)** | `reorg/finding-models` | `reorg/product-models` | **ADD `CWE` + `BurpRawRequestResponse` to `dojo/finding/`** (full slice). Existing PR — do NOT create a new one. | +| **6** | `reorg/peripheral-user` | `reorg/finding-models` | **Bundle A**: `user` (`Dojo_User`, `UserContactInfo`, `Contact`) + `system_settings` (`System_Settings`) | +| **7** | `reorg/peripheral-tools-endpoint` | `reorg/peripheral-user` | **Bundle B**: `endpoint` (`Endpoint_Params`, `Endpoint_Status`, `Endpoint`) + `tool_type` (`Tool_Type`) + `tool_config` (`Tool_Configuration`, + admin classes `ToolConfigForm_Admin`/`Tool_Configuration_Admin`) + `tool_product` (`Tool_Product_Settings`, `Tool_Product_History`) | +| **8** | `reorg/peripheral-survey-benchmark` | `reorg/peripheral-tools-endpoint` | **Bundle C**: `survey` (`Question`, `TextQuestion`, `Choice`, `ChoiceQuestion`, `Engagement_Survey`, `Answered_Survey`, `General_Survey`, `Answer`, `TextAnswer`, `ChoiceAnswer`) + `benchmark` (`Benchmark_Type`, `Benchmark_Category`, `Benchmark_Requirement`, `Benchmark_Product`, `Benchmark_Product_Summary`) | +| **9** | `reorg/peripheral-notes-files` | `reorg/peripheral-survey-benchmark` | **Bundle D**: `notes` (`NoteHistory`, `Notes`) + `note_type` (`Note_Type`) + `file_uploads` (`UniqueUploadNameProvider`, `FileUpload`, `FileAccessToken`) + `reports` (`Report_Type`) + `risk_acceptance` (`Risk_Acceptance`) | +| **10** | `reorg/peripheral-misc` | `reorg/peripheral-notes-files` | **Bundle E**: `regulations` (`Regulation`) + `banner` (`BannerConf`) + `announcement` (`Announcement`, `UserAnnouncement`) + `development_environment` (`Development_Environment`) + `object` (`Objects_Review`, `Objects_Product`) | + +**Bundle order is by FK direction**: `user` first (`Dojo_User` is an FK target almost everywhere); everything else references already-moved or string-ref'd models. Inside a bundle, FKs between same-bundle models are real class refs; FKs to anything OUTSIDE the bundle become string refs `"dojo."` (per the string-FK rule above — this keeps the extracted `models.py` free of top-level `from dojo.models import`). + +### Stack & PR mechanics (locked with user) + +- **Branches live on the `upstream` remote** (`git@github.com:DefectDojo/django-DefectDojo.git`), exactly like the existing 5 (their head branches are on upstream, e.g. `upstream/reorg/finding-models`). Push each new branch to `upstream`, and **force-push with `--force-with-lease`** on cascade (`git push --force-with-lease upstream :`). +- **The 5 new PRs are DRAFT PRs.** Create with `gh pr create --draft --repo DefectDojo/django-DefectDojo --base --head `. +- Each new branch is created from its predecessor's tip: `git checkout -b reorg/peripheral-user reorg/finding-models`, etc. Merge bottom-up. +- **PR descriptions**: every PR in the stack (all 10) must include a stack map listing all 10 PRs in order with checkboxes and the bottom-up merge note, so reviewers see the whole picture. Summary section only — NO test-plan section (see CLAUDE.local.md / PR rules). Format PR URLs as markdown links. Read an existing body with `gh pr view --json body -q '.body'` before editing; edit via `--body-file` or the REST `gh api -X PATCH` path (inline `--body` silently fails on this repo). +- **Cascade after editing a lower branch** (e.g. this AGENTS.md commit on #14970): `git rebase --onto ` up the chain, then force-push all with `--force-with-lease`. AGENTS.md edits always land on the bottom branch (#14970) and cascade. + +### Per-module execution = the 9-phase playbook above + +For EACH module in a bundle, run **Phase 0 pre-flight first** (the grep block above) to discover its exact forms/filters/serializers/viewsets/urls/admin/signals/consumers — do NOT trust a memorized list. Then Phases 1–9. Reference complete templates: `dojo/url/`, `dojo/location/` (models), and `dojo/finding/`, `dojo/product/`, `dojo/test/`, `dojo/engagement/` (full API+UI slices). Verify gates after each phase (`manage.py check`, `makemigrations --check --dry-run`, `./run-unittest.sh --test-case unittests. 2>&1 | tee /tmp/test.log`). All gates run in docker (`docker compose exec -T uwsgi ...`); model imports need `manage.py shell -c`. + +### Model line ranges in `dojo/models.py` (snapshot — re-grep before editing; line numbers shift as you extract) + +- **CWE** 1027–1031 · **BurpRawRequestResponse** 1563–1575 → `finding` (PR #14974) +- **Dojo_User** 174–209 · **UserContactInfo** 211–234 · **Contact** 605–612 · **System_Settings** 236–595 +- **Tool_Type** 940–949 · **Tool_Configuration** 951–979 · **ToolConfigForm_Admin/Tool_Configuration_Admin** 981–1010 · **Endpoint_Params** 1033–1039 · **Endpoint_Status** 1041–1093 · **Endpoint** 1095–1470 · **Tool_Product_Settings** 1765–1777 · **Tool_Product_History** 1779–1785 +- **Benchmark_Type** 1890–1905 · **Benchmark_Category** 1907–1921 · **Benchmark_Requirement** 1923–1939 · **Benchmark_Product** 1941–1957 · **Benchmark_Product_Summary** 1959–1989 · **Question** 1992–2012 · **TextQuestion** 2014–2024 · **Choice** 2026–2039 · **ChoiceQuestion** 2041–2058 · **Engagement_Survey** 2060–2076 · **Answered_Survey** 2078–2101 · **General_Survey** 2107–2123 · **Answer** 2126–2138 · **TextAnswer** 2140–2149 · **ChoiceAnswer** 2151–2253 +- **Note_Type** 614–623 · **NoteHistory** 625–636 · **Notes** 638–669 · **UniqueUploadNameProvider** 108–135 · **FileUpload** 671–749 · **FileAccessToken** 1679–1703 · **Report_Type** 751–753 · **Risk_Acceptance** 1577–1677 +- **Regulation** 136–168 · **Announcement** 1713–1725 · **UserAnnouncement** 1727–1730 · **BannerConf** 1732–1763 · **Development_Environment** 1472–1481 · **Objects_Review** 1829–1835 · **Objects_Product** 1837–1861 + +### Module-specific gotchas (beyond the generic playbook) + +- **`Question` / `Answer` (survey)**: base classes are defined inside a `with warnings.catch_warnings(): ...` block (polymorphic-model deprecation suppression). PRESERVE that block structure when moving to `dojo/survey/models.py` — don't flatten it. +- **survey & benchmark have NO serializers/viewsets in `api_v2`** (verified). So Bundle C likely has no `api/` layer — skip Phases 6–9 for those modules (confirm with Phase 0). They DO have UI views/urls/forms/filters. +- **`Benchmark_Requirement` → M2M `CWE`**: `CWE` moves to `finding` in PR #14974 (lands lower in the stack), so by the time Bundle C runs, use string ref `"dojo.CWE"` (the `dojo.models` re-export stays valid). Same for any other `CWE` reference. +- **`Risk_Acceptance`**: M2M `accepted_findings`→Finding, FK `owner`→Dojo_User, M2M `notes`→Notes — all cross-bundle → string refs. `dojo/risk_acceptance/` already has `api.py`/`helper.py`/`queries.py`/`signals.py` but no `models.py`; reconcile `api.py` vs the playbook's `api/` dir layout. +- **`Endpoint`**: references `Dojo_User`, `Finding`, `Product`, `Endpoint_Status` — string-ref everything except same-bundle `Endpoint_Params`/`Endpoint_Status`. `dojo/endpoint/` already has `queries.py`/`utils.py`/`signals.py`. +- **`tool_config` admin**: `ToolConfigForm_Admin` (a `forms.ModelForm`) and `Tool_Configuration_Admin` (an `admin.ModelAdmin`) currently sit in `dojo/models.py` — move them to `dojo/tool_config/admin.py` (form + admin), not `models.py`. +- **`CWE` / `BurpRawRequestResponse` are heavily imported** (20+ files across `dojo/` and `unittests/`, including tool parsers for CWE and importers for Burp). Run the Phase 0 consumer grep (`grep -rn "import.*\bCWE\b" dojo/ unittests/`, same for `BurpRawRequestResponse`) and rely on the `dojo.models` re-export for external consumers — only repoint finding's own code. +- **Shared bases (the `FindingTagStringFilter` trap)**: before moving any form/filter, grep for subclasses/consumers OUTSIDE the module. If a base form/filter is also used by a model staying in `dojo/models.py` or another module, KEEP it in the monolith and import it, rather than moving + back-importing (which cycles). The prefetcher full-re-export rule (Phase 6) applies to any moved `ModelSerializer`. + +### After the stack is built + +Update the **Current State** table above (mark the newly-completed modules **Complete**), and update the monolith line counts in "Monolithic Files Being Decomposed" (they are stale — `dojo/models.py` is ~2,254 lines now, not 4,973). From cd7b73fb3ea35cebe6129b4d443c1b3cf6055e16 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 17:08:44 +0200 Subject: [PATCH 10/40] refactor(test): extract Test/Test_Type/Test_Import models into dojo/test/ Phase 1 of module reorg per AGENTS.md. Move Test, Test_Type, Test_Import, Test_Import_Finding_Action + admin registrations into dojo/test/{models,admin}.py. Cross-module FKs use string refs to avoid circular imports; IMPORT_* action constants single-sourced in dojo/test/models.py with re-export in dojo/models.py. No migration change. --- dojo/models.py | 278 ++-------------------------------------- dojo/test/__init__.py | 1 + dojo/test/admin.py | 21 ++++ dojo/test/models.py | 287 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 266 deletions(-) create mode 100644 dojo/test/admin.py create mode 100644 dojo/test/models.py diff --git a/dojo/models.py b/dojo/models.py index eaf21960650..dd833e0a427 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -26,7 +26,7 @@ from django.core.files.base import ContentFile from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator, validate_ipv46_address from django.db import connection, models -from django.db.models import Count, F, JSONField, Q +from django.db.models import Count, F, Q from django.db.models.expressions import Case, When from django.db.models.functions import Lower from django.urls import reverse @@ -66,18 +66,6 @@ # default template with all values set to 0 DEFAULT_STATS = {sev.lower(): dict.fromkeys(STATS_FIELDS, 0) for sev in SEVERITIES} -IMPORT_CREATED_FINDING = "N" -IMPORT_CLOSED_FINDING = "C" -IMPORT_REACTIVATED_FINDING = "R" -IMPORT_UNTOUCHED_FINDING = "U" - -IMPORT_ACTIONS = [ - (IMPORT_CREATED_FINDING, "created"), - (IMPORT_CLOSED_FINDING, "closed"), - (IMPORT_REACTIVATED_FINDING, "reactivated"), - (IMPORT_UNTOUCHED_FINDING, "untouched"), -] - def _get_annotations_for_statistics(): annotations = {stats_field.lower(): Count(Case(When(**{stats_field: True}, then=1))) for stats_field in STATS_FIELDS if stats_field != "total"} @@ -759,6 +747,17 @@ def clean(self): from dojo.product_type.models import Product_Type # noqa: E402 -- re-export; mid-file as Product FK uses it below +from dojo.test.models import ( # noqa: E402 -- re-export; class-body FKs below reference these + IMPORT_ACTIONS, # noqa: F401 -- re-export + IMPORT_CLOSED_FINDING, # noqa: F401 -- re-export + IMPORT_CREATED_FINDING, # noqa: F401 -- re-export + IMPORT_REACTIVATED_FINDING, # noqa: F401 -- re-export + IMPORT_UNTOUCHED_FINDING, # noqa: F401 -- re-export + Test, + Test_Import, # noqa: F401 -- re-export + Test_Import_Finding_Action, # noqa: F401 -- re-export + Test_Type, +) class Product_Line(models.Model): @@ -773,26 +772,6 @@ class Report_Type(models.Model): name = models.CharField(max_length=255) -class Test_Type(models.Model): - name = models.CharField(max_length=200, unique=True) - static_tool = models.BooleanField(default=False) - dynamic_tool = models.BooleanField(default=False) - active = models.BooleanField(default=True) - dynamically_generated = models.BooleanField( - default=False, - help_text=_("Set to True for test types that are created at import time")) - - class Meta: - ordering = ("name",) - - def __str__(self): - return self.name - - def get_breadcrumbs(self): - return [{"title": str(self), - "url": None}] - - class DojoMeta(models.Model): name = models.CharField(max_length=120) value = models.CharField(max_length=300) @@ -1978,235 +1957,6 @@ class Meta: ordering = ("-created", ) -class Test(models.Model): - engagement = models.ForeignKey(Engagement, editable=False, on_delete=models.CASCADE) - lead = models.ForeignKey(Dojo_User, editable=True, null=True, blank=True, on_delete=models.RESTRICT) - test_type = models.ForeignKey(Test_Type, on_delete=models.CASCADE) - scan_type = models.TextField(null=True) - title = models.CharField(max_length=255, null=True, blank=True) - description = models.TextField(null=True, blank=True) - target_start = models.DateTimeField() - target_end = models.DateTimeField() - percent_complete = models.IntegerField(null=True, blank=True, - editable=True) - notes = models.ManyToManyField(Notes, blank=True, - editable=False) - files = models.ManyToManyField(FileUpload, blank=True, editable=False) - environment = models.ForeignKey(Development_Environment, null=True, - blank=False, on_delete=models.RESTRICT) - - updated = models.DateTimeField(auto_now=True, null=True) - created = models.DateTimeField(auto_now_add=True, null=True) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this test. Choose from the list or add new tags. Press Enter key to add.")) - inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) - - version = models.CharField(max_length=100, null=True, blank=True) - - build_id = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Build ID that was tested, a reimport may update this field."), verbose_name=_("Build ID")) - commit_hash = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Commit hash tested, a reimport may update this field."), verbose_name=_("Commit Hash")) - branch_tag = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Tag or branch that was tested, a reimport may update this field."), verbose_name=_("Branch/Tag")) - api_scan_configuration = models.ForeignKey(Product_API_Scan_Configuration, null=True, editable=True, blank=True, on_delete=models.CASCADE, verbose_name=_("API Scan Configuration")) - - class Meta: - indexes = [ - models.Index(fields=["engagement", "test_type"]), - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.unsaved_metadata: list = [] - - def __str__(self): - if self.title: - return f"{self.title} ({self.test_type})" - return str(self.test_type) - - def get_absolute_url(self): - return reverse("view_test", args=[str(self.id)]) - - def test_type_name(self) -> str: - return self.test_type.name - - def get_breadcrumbs(self): - bc = self.engagement.get_breadcrumbs() - bc += [{"title": str(self), - "url": reverse("view_test", args=(self.id,))}] - return bc - - def copy(self, engagement=None): - copy = copy_model_util(self) - # Save the necessary ManyToMany relationships - old_notes = list(self.notes.all()) - old_files = list(self.files.all()) - old_tags = list(self.tags.all()) - old_findings = list(Finding.objects.filter(test=self)) - if engagement: - copy.engagement = engagement - # Save the object before setting any ManyToMany relationships - copy.save() - # Copy the notes - for notes in old_notes: - copy.notes.add(notes.copy()) - # Copy the files - for files in old_files: - copy.files.add(files.copy()) - # Copy the Findings - for finding in old_findings: - finding.copy(test=copy) - # Assign any tags - copy.tags.set(old_tags) - - return copy - - # only used by bulk risk acceptance api - @property - def unaccepted_open_findings(self): - from dojo.utils import get_system_setting # noqa: PLC0415 circular import - findings = Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test=self) - if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): - findings = findings.filter(verified=True) - - return findings - - def accept_risks(self, accepted_risks): - self.engagement.risk_acceptance.add(*accepted_risks) - - @property - def deduplication_algorithm(self): - deduplicationAlgorithm = settings.DEDUPE_ALGO_LEGACY - - if hasattr(settings, "DEDUPLICATION_ALGORITHM_PER_PARSER"): - if (self.test_type.name in settings.DEDUPLICATION_ALGORITHM_PER_PARSER): - deduplicationLogger.debug(f"using DEDUPLICATION_ALGORITHM_PER_PARSER for test_type.name: {self.test_type.name}") - deduplicationAlgorithm = settings.DEDUPLICATION_ALGORITHM_PER_PARSER[self.test_type.name] - elif (self.scan_type in settings.DEDUPLICATION_ALGORITHM_PER_PARSER): - deduplicationLogger.debug(f"using DEDUPLICATION_ALGORITHM_PER_PARSER for scan_type: {self.scan_type}") - deduplicationAlgorithm = settings.DEDUPLICATION_ALGORITHM_PER_PARSER[self.scan_type] - else: - deduplicationLogger.debug("Section DEDUPLICATION_ALGORITHM_PER_PARSER not found in settings.dist.py") - - deduplicationLogger.debug(f"DEDUPLICATION_ALGORITHM_PER_PARSER is: {deduplicationAlgorithm}") - return deduplicationAlgorithm - - @property - def hash_code_fields(self): - """Retrieve OS HASH_CODE_FIELDS_PER_SCANNER settings. Be aware when calling this to make sure Pro doesn't use these OS seetings""" - hashCodeFields = None - - if hasattr(settings, "HASHCODE_FIELDS_PER_SCANNER"): - if (self.test_type.name in settings.HASHCODE_FIELDS_PER_SCANNER): - deduplicationLogger.debug(f"using HASHCODE_FIELDS_PER_SCANNER for test_type.name: {self.test_type.name}") - hashCodeFields = settings.HASHCODE_FIELDS_PER_SCANNER[self.test_type.name] - elif (self.scan_type in settings.HASHCODE_FIELDS_PER_SCANNER): - deduplicationLogger.debug(f"using HASHCODE_FIELDS_PER_SCANNER for scan_type: {self.scan_type}") - hashCodeFields = settings.HASHCODE_FIELDS_PER_SCANNER[self.scan_type] - else: - deduplicationLogger.warning(f"test_type name {self.test_type.name} and scan_type {self.scan_type} not found in HASHCODE_FIELDS_PER_SCANNER") - else: - deduplicationLogger.debug("Section HASHCODE_FIELDS_PER_SCANNER not found in settings.dist.py") - - hash_code_fields_always = getattr(settings, "HASH_CODE_FIELDS_ALWAYS", []) - deduplicationLogger.debug(f"HASHCODE_FIELDS_PER_SCANNER is: {hashCodeFields} + HASH_CODE_FIELDS_ALWAYS: {hash_code_fields_always}") - - return hashCodeFields - - @property - def hash_code_allows_null_cwe(self): - hashCodeAllowsNullCwe = True - - if hasattr(settings, "HASHCODE_ALLOWS_NULL_CWE"): - if (self.test_type.name in settings.HASHCODE_ALLOWS_NULL_CWE): - deduplicationLogger.debug(f"using HASHCODE_ALLOWS_NULL_CWE for test_type.name: {self.test_type.name}") - hashCodeAllowsNullCwe = settings.HASHCODE_ALLOWS_NULL_CWE[self.test_type.name] - elif (self.scan_type in settings.HASHCODE_ALLOWS_NULL_CWE): - deduplicationLogger.debug(f"using HASHCODE_ALLOWS_NULL_CWE for scan_type: {self.scan_type}") - hashCodeAllowsNullCwe = settings.HASHCODE_ALLOWS_NULL_CWE[self.scan_type] - else: - deduplicationLogger.debug("Section HASHCODE_ALLOWS_NULL_CWE not found in settings.dist.py") - - deduplicationLogger.debug(f"HASHCODE_ALLOWS_NULL_CWE is: {hashCodeAllowsNullCwe}") - return hashCodeAllowsNullCwe - - def delete(self, *args, product_grading_option=True, **kwargs): - logger.debug("%d test delete", self.id) - super().delete(*args, **kwargs) - if product_grading_option: - with suppress(Test.DoesNotExist, Engagement.DoesNotExist, Product.DoesNotExist): - # Suppressing a potential issue created from async delete removing - # related objects in a separate task - from dojo.utils import perform_product_grading # noqa: PLC0415 circular import - perform_product_grading(self.engagement.product) - - @property - def statistics(self): - """Queries the database, no prefetching, so could be slow for lists of model instances""" - return _get_statistics_for_queryset(Finding.objects.filter(test=self), _get_annotations_for_statistics) - - -class Test_Import(TimeStampedModel): - - IMPORT_TYPE = "import" - REIMPORT_TYPE = "reimport" - - test = models.ForeignKey(Test, editable=False, null=False, blank=False, on_delete=models.CASCADE) - findings_affected = models.ManyToManyField("Finding", through="Test_Import_Finding_Action") - import_settings = JSONField(null=True) - type = models.CharField(max_length=64, null=False, blank=False, default="unknown") - - version = models.CharField(max_length=100, null=True, blank=True) - build_id = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Build ID that was tested, a reimport may update this field."), verbose_name=_("Build ID")) - commit_hash = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Commit hash tested, a reimport may update this field."), verbose_name=_("Commit Hash")) - branch_tag = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Tag or branch that was tested, a reimport may update this field."), verbose_name=_("Branch/Tag")) - - def get_queryset(self): - logger.debug("prefetch test_import counts") - super_query = super().get_queryset() - super_query = super_query.annotate(created_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_CREATED_FINDING))) - super_query = super_query.annotate(closed_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_CLOSED_FINDING))) - super_query = super_query.annotate(reactivated_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_REACTIVATED_FINDING))) - return super_query.annotate(untouched_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_UNTOUCHED_FINDING))) - - class Meta: - ordering = ("-id",) - indexes = [ - models.Index(fields=["created", "test", "type"]), - ] - - def __str__(self): - return self.created.strftime("%Y-%m-%d %H:%M:%S") - - @property - def statistics(self): - """Queries the database, no prefetching, so could be slow for lists of model instances""" - stats = {} - for action in IMPORT_ACTIONS: - stats[action[1].lower()] = _get_statistics_for_queryset(Finding.objects.filter(test_import_finding_action__test_import=self, test_import_finding_action__action=action[0]), _get_annotations_for_statistics) - return stats - - -class Test_Import_Finding_Action(TimeStampedModel): - test_import = models.ForeignKey(Test_Import, editable=False, null=False, blank=False, on_delete=models.CASCADE) - finding = models.ForeignKey("Finding", editable=False, null=False, blank=False, on_delete=models.CASCADE) - action = models.CharField(max_length=100, null=True, blank=True, choices=IMPORT_ACTIONS) - - class Meta: - indexes = [ - models.Index(fields=["finding", "action", "test_import"]), - ] - unique_together = (("test_import", "finding")) - ordering = ("test_import", "action", "finding") - - def __str__(self): - return f"{self.finding.id}: {self.action}" - - class Finding(BaseModel): # Fields loaded when performing deduplication (used by get_finding_models_for_deduplication # and build_candidate_scope_queryset to restrict the SELECT to only what is needed). @@ -4352,14 +4102,12 @@ def __str__(self): admin.site.register(Languages) admin.site.register(Language_Type) admin.site.register(App_Analysis) -admin.site.register(Test) admin.site.register(Finding, FindingAdmin) admin.site.register(FileUpload) admin.site.register(FileAccessToken) admin.site.register(Engagement) admin.site.register(Risk_Acceptance) admin.site.register(Check_List) -admin.site.register(Test_Type) admin.site.register(Endpoint_Params) admin.site.register(Endpoint_Status) admin.site.register(Endpoint) @@ -4414,6 +4162,4 @@ def __str__(self): admin.site.register(BannerConf) admin.site.register(Tool_Product_History) admin.site.register(General_Survey) -admin.site.register(Test_Import) -admin.site.register(Test_Import_Finding_Action) admin.site.register(Finding_Group) diff --git a/dojo/test/__init__.py b/dojo/test/__init__.py index e69de29bb2d..1a931c0dba9 100644 --- a/dojo/test/__init__.py +++ b/dojo/test/__init__.py @@ -0,0 +1 @@ +import dojo.test.admin # noqa: F401 diff --git a/dojo/test/admin.py b/dojo/test/admin.py new file mode 100644 index 00000000000..a6f18c4bb82 --- /dev/null +++ b/dojo/test/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from dojo.test.models import Test, Test_Import, Test_Type + + +@admin.register(Test_Type) +class Test_TypeAdmin(admin.ModelAdmin): + + """Admin support for the Test_Type model.""" + + +@admin.register(Test) +class TestAdmin(admin.ModelAdmin): + + """Admin support for the Test model.""" + + +@admin.register(Test_Import) +class Test_ImportAdmin(admin.ModelAdmin): + + """Admin support for the Test_Import model.""" diff --git a/dojo/test/models.py b/dojo/test/models.py new file mode 100644 index 00000000000..31cefaf52ac --- /dev/null +++ b/dojo/test/models.py @@ -0,0 +1,287 @@ +import logging +from contextlib import suppress + +from django.conf import settings +from django.db import models +from django.db.models import Count, Q +from django.urls import reverse +from django.utils.translation import gettext as _ +from django_extensions.db.models import TimeStampedModel +from tagulous.models import TagField + +logger = logging.getLogger(__name__) +deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") + +IMPORT_CREATED_FINDING = "N" +IMPORT_CLOSED_FINDING = "C" +IMPORT_REACTIVATED_FINDING = "R" +IMPORT_UNTOUCHED_FINDING = "U" + +IMPORT_ACTIONS = [ + (IMPORT_CREATED_FINDING, "created"), + (IMPORT_CLOSED_FINDING, "closed"), + (IMPORT_REACTIVATED_FINDING, "reactivated"), + (IMPORT_UNTOUCHED_FINDING, "untouched"), +] + + +class Test_Type(models.Model): + name = models.CharField(max_length=200, unique=True) + static_tool = models.BooleanField(default=False) + dynamic_tool = models.BooleanField(default=False) + active = models.BooleanField(default=True) + dynamically_generated = models.BooleanField( + default=False, + help_text=_("Set to True for test types that are created at import time")) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": None}] + + +class Test(models.Model): + engagement = models.ForeignKey("dojo.Engagement", editable=False, on_delete=models.CASCADE) + lead = models.ForeignKey("dojo.Dojo_User", editable=True, null=True, blank=True, on_delete=models.RESTRICT) + test_type = models.ForeignKey("dojo.Test_Type", on_delete=models.CASCADE) + scan_type = models.TextField(null=True) + title = models.CharField(max_length=255, null=True, blank=True) + description = models.TextField(null=True, blank=True) + target_start = models.DateTimeField() + target_end = models.DateTimeField() + percent_complete = models.IntegerField(null=True, blank=True, + editable=True) + notes = models.ManyToManyField("dojo.Notes", blank=True, + editable=False) + files = models.ManyToManyField("dojo.FileUpload", blank=True, editable=False) + environment = models.ForeignKey("dojo.Development_Environment", null=True, + blank=False, on_delete=models.RESTRICT) + + updated = models.DateTimeField(auto_now=True, null=True) + created = models.DateTimeField(auto_now_add=True, null=True) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this test. Choose from the list or add new tags. Press Enter key to add.")) + inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) + + version = models.CharField(max_length=100, null=True, blank=True) + + build_id = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Build ID that was tested, a reimport may update this field."), verbose_name=_("Build ID")) + commit_hash = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Commit hash tested, a reimport may update this field."), verbose_name=_("Commit Hash")) + branch_tag = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Tag or branch that was tested, a reimport may update this field."), verbose_name=_("Branch/Tag")) + api_scan_configuration = models.ForeignKey("dojo.Product_API_Scan_Configuration", null=True, editable=True, blank=True, on_delete=models.CASCADE, verbose_name=_("API Scan Configuration")) + + class Meta: + indexes = [ + models.Index(fields=["engagement", "test_type"]), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.unsaved_metadata: list = [] + + def __str__(self): + if self.title: + return f"{self.title} ({self.test_type})" + return str(self.test_type) + + def get_absolute_url(self): + return reverse("view_test", args=[str(self.id)]) + + def test_type_name(self) -> str: + return self.test_type.name + + def get_breadcrumbs(self): + bc = self.engagement.get_breadcrumbs() + bc += [{"title": str(self), + "url": reverse("view_test", args=(self.id,))}] + return bc + + def copy(self, engagement=None): + from dojo.models import Finding, copy_model_util # noqa: PLC0415 -- lazy import, avoids circular dependency # isort: skip + copy = copy_model_util(self) + # Save the necessary ManyToMany relationships + old_notes = list(self.notes.all()) + old_files = list(self.files.all()) + old_tags = list(self.tags.all()) + old_findings = list(Finding.objects.filter(test=self)) + if engagement: + copy.engagement = engagement + # Save the object before setting any ManyToMany relationships + copy.save() + # Copy the notes + for notes in old_notes: + copy.notes.add(notes.copy()) + # Copy the files + for files in old_files: + copy.files.add(files.copy()) + # Copy the Findings + for finding in old_findings: + finding.copy(test=copy) + # Assign any tags + copy.tags.set(old_tags) + + return copy + + # only used by bulk risk acceptance api + @property + def unaccepted_open_findings(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + from dojo.utils import get_system_setting # noqa: PLC0415 circular import + findings = Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test=self) + if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): + findings = findings.filter(verified=True) + + return findings + + def accept_risks(self, accepted_risks): + self.engagement.risk_acceptance.add(*accepted_risks) + + @property + def deduplication_algorithm(self): + deduplicationAlgorithm = settings.DEDUPE_ALGO_LEGACY + + if hasattr(settings, "DEDUPLICATION_ALGORITHM_PER_PARSER"): + if (self.test_type.name in settings.DEDUPLICATION_ALGORITHM_PER_PARSER): + deduplicationLogger.debug(f"using DEDUPLICATION_ALGORITHM_PER_PARSER for test_type.name: {self.test_type.name}") + deduplicationAlgorithm = settings.DEDUPLICATION_ALGORITHM_PER_PARSER[self.test_type.name] + elif (self.scan_type in settings.DEDUPLICATION_ALGORITHM_PER_PARSER): + deduplicationLogger.debug(f"using DEDUPLICATION_ALGORITHM_PER_PARSER for scan_type: {self.scan_type}") + deduplicationAlgorithm = settings.DEDUPLICATION_ALGORITHM_PER_PARSER[self.scan_type] + else: + deduplicationLogger.debug("Section DEDUPLICATION_ALGORITHM_PER_PARSER not found in settings.dist.py") + + deduplicationLogger.debug(f"DEDUPLICATION_ALGORITHM_PER_PARSER is: {deduplicationAlgorithm}") + return deduplicationAlgorithm + + @property + def hash_code_fields(self): + """Retrieve OS HASH_CODE_FIELDS_PER_SCANNER settings. Be aware when calling this to make sure Pro doesn't use these OS seetings""" + hashCodeFields = None + + if hasattr(settings, "HASHCODE_FIELDS_PER_SCANNER"): + if (self.test_type.name in settings.HASHCODE_FIELDS_PER_SCANNER): + deduplicationLogger.debug(f"using HASHCODE_FIELDS_PER_SCANNER for test_type.name: {self.test_type.name}") + hashCodeFields = settings.HASHCODE_FIELDS_PER_SCANNER[self.test_type.name] + elif (self.scan_type in settings.HASHCODE_FIELDS_PER_SCANNER): + deduplicationLogger.debug(f"using HASHCODE_FIELDS_PER_SCANNER for scan_type: {self.scan_type}") + hashCodeFields = settings.HASHCODE_FIELDS_PER_SCANNER[self.scan_type] + else: + deduplicationLogger.warning(f"test_type name {self.test_type.name} and scan_type {self.scan_type} not found in HASHCODE_FIELDS_PER_SCANNER") + else: + deduplicationLogger.debug("Section HASHCODE_FIELDS_PER_SCANNER not found in settings.dist.py") + + hash_code_fields_always = getattr(settings, "HASH_CODE_FIELDS_ALWAYS", []) + deduplicationLogger.debug(f"HASHCODE_FIELDS_PER_SCANNER is: {hashCodeFields} + HASH_CODE_FIELDS_ALWAYS: {hash_code_fields_always}") + + return hashCodeFields + + @property + def hash_code_allows_null_cwe(self): + hashCodeAllowsNullCwe = True + + if hasattr(settings, "HASHCODE_ALLOWS_NULL_CWE"): + if (self.test_type.name in settings.HASHCODE_ALLOWS_NULL_CWE): + deduplicationLogger.debug(f"using HASHCODE_ALLOWS_NULL_CWE for test_type.name: {self.test_type.name}") + hashCodeAllowsNullCwe = settings.HASHCODE_ALLOWS_NULL_CWE[self.test_type.name] + elif (self.scan_type in settings.HASHCODE_ALLOWS_NULL_CWE): + deduplicationLogger.debug(f"using HASHCODE_ALLOWS_NULL_CWE for scan_type: {self.scan_type}") + hashCodeAllowsNullCwe = settings.HASHCODE_ALLOWS_NULL_CWE[self.scan_type] + else: + deduplicationLogger.debug("Section HASHCODE_ALLOWS_NULL_CWE not found in settings.dist.py") + + deduplicationLogger.debug(f"HASHCODE_ALLOWS_NULL_CWE is: {hashCodeAllowsNullCwe}") + return hashCodeAllowsNullCwe + + def delete(self, *args, product_grading_option=True, **kwargs): + logger.debug("%d test delete", self.id) + super().delete(*args, **kwargs) + if product_grading_option: + from dojo.models import Engagement, Product # noqa: PLC0415 -- lazy import, avoids circular dependency + with suppress(Test.DoesNotExist, Engagement.DoesNotExist, Product.DoesNotExist): + # Suppressing a potential issue created from async delete removing + # related objects in a separate task + from dojo.utils import perform_product_grading # noqa: PLC0415 circular import + perform_product_grading(self.engagement.product) + + @property + def statistics(self): + """Queries the database, no prefetching, so could be slow for lists of model instances""" + from dojo.models import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + Finding, + _get_annotations_for_statistics, + _get_statistics_for_queryset, + ) + return _get_statistics_for_queryset(Finding.objects.filter(test=self), _get_annotations_for_statistics) + + +class Test_Import(TimeStampedModel): + + IMPORT_TYPE = "import" + REIMPORT_TYPE = "reimport" + + test = models.ForeignKey("dojo.Test", editable=False, null=False, blank=False, on_delete=models.CASCADE) + findings_affected = models.ManyToManyField("dojo.Finding", through="dojo.Test_Import_Finding_Action") + import_settings = models.JSONField(null=True) + type = models.CharField(max_length=64, null=False, blank=False, default="unknown") + + version = models.CharField(max_length=100, null=True, blank=True) + build_id = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Build ID that was tested, a reimport may update this field."), verbose_name=_("Build ID")) + commit_hash = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Commit hash tested, a reimport may update this field."), verbose_name=_("Commit Hash")) + branch_tag = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Tag or branch that was tested, a reimport may update this field."), verbose_name=_("Branch/Tag")) + + def get_queryset(self): + logger.debug("prefetch test_import counts") + super_query = super().get_queryset() + super_query = super_query.annotate(created_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_CREATED_FINDING))) + super_query = super_query.annotate(closed_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_CLOSED_FINDING))) + super_query = super_query.annotate(reactivated_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_REACTIVATED_FINDING))) + return super_query.annotate(untouched_findings_count=Count("findings", filter=Q(test_import_finding_action__action=IMPORT_UNTOUCHED_FINDING))) + + class Meta: + ordering = ("-id",) + indexes = [ + models.Index(fields=["created", "test", "type"]), + ] + + def __str__(self): + return self.created.strftime("%Y-%m-%d %H:%M:%S") + + @property + def statistics(self): + """Queries the database, no prefetching, so could be slow for lists of model instances""" + from dojo.models import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + Finding, + _get_annotations_for_statistics, + _get_statistics_for_queryset, + ) + stats = {} + for action in IMPORT_ACTIONS: + stats[action[1].lower()] = _get_statistics_for_queryset(Finding.objects.filter(test_import_finding_action__test_import=self, test_import_finding_action__action=action[0]), _get_annotations_for_statistics) + return stats + + +class Test_Import_Finding_Action(TimeStampedModel): + test_import = models.ForeignKey("dojo.Test_Import", editable=False, null=False, blank=False, on_delete=models.CASCADE) + finding = models.ForeignKey("dojo.Finding", editable=False, null=False, blank=False, on_delete=models.CASCADE) + action = models.CharField(max_length=100, null=True, blank=True, choices=IMPORT_ACTIONS) + + class Meta: + indexes = [ + models.Index(fields=["finding", "action", "test_import"]), + ] + unique_together = (("test_import", "finding")) + ordering = ("test_import", "action", "finding") + + def __str__(self): + return f"{self.finding.id}: {self.action}" From 39b906d66fee52af2811178cc55b2991f9f22e12 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 20:22:58 +0200 Subject: [PATCH 11/40] refactor(test): extract copy_test workflow into services.py [test Phase 2] Move the test copy workflow (clone into engagement + product grade recalc + notification) out of the copy_test view into an HTTP-free copy_test(test, engagement, user) service, mirroring copy_engagement. View thinned to call it. Add a unit test for the service (was previously untested). Notification URL uses relative reverse() per codebase convention. --- dojo/test/services.py | 33 +++++++++++++++++++++++++++++++++ dojo/test/views.py | 14 ++------------ unittests/test_copy_model.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 dojo/test/services.py diff --git a/dojo/test/services.py b/dojo/test/services.py new file mode 100644 index 00000000000..fbd5dbf3b59 --- /dev/null +++ b/dojo/test/services.py @@ -0,0 +1,33 @@ +# # tests +import logging + +from django.urls import reverse +from django.utils.translation import gettext as _ + +from dojo.celery_dispatch import dojo_dispatch_task +from dojo.notifications.helper import create_notification +from dojo.utils import calculate_grade + +logger = logging.getLogger(__name__) + + +def copy_test(test, engagement, user): + """ + Copy a test (and its findings) into the given engagement, recalculate the product + grade, and notify. Returns the new test. + + HTTP-free so both the UI view and (eventually) the API can call it. + """ + product = test.engagement.product + test_copy = test.copy(engagement=engagement) + dojo_dispatch_task(calculate_grade, product.id) + create_notification( + event="test_copied", + title=_("Copying of %s") % test.title, + description=f'The test "{test.title}" was copied by {user} to {engagement.name}', + product=product, + url=reverse("view_test", args=(test_copy.id,)), + recipients=[test.engagement.lead], + icon="exclamation-triangle", + ) + return test_copy diff --git a/dojo/test/views.py b/dojo/test/views.py index 4e7f9c54dba..49ef13c3d37 100644 --- a/dojo/test/views.py +++ b/dojo/test/views.py @@ -24,7 +24,6 @@ import dojo.finding.helper as finding_helper from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.celery_dispatch import dojo_dispatch_task from dojo.engagement.queries import get_authorized_engagements from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter, TestImportFilter from dojo.finding.queries import prefetch_for_findings @@ -63,6 +62,7 @@ ScanTypeProductAnnouncement, ) from dojo.test.queries import get_authorized_tests +from dojo.test.services import copy_test as copy_test_service from dojo.tools.factory import get_choices_sorted, get_scan_types_sorted from dojo.user.queries import get_authorized_users from dojo.utils import ( @@ -72,7 +72,6 @@ add_field_errors_to_response, add_success_message_to_response, async_delete, - calculate_grade, get_cal_event, get_page_items, get_page_items_and_count, @@ -325,21 +324,12 @@ def copy_test(request, tid): form = CopyTestForm(request.POST, engagements=engagement_list) if form.is_valid(): engagement = form.cleaned_data.get("engagement") - product = test.engagement.product - test_copy = test.copy(engagement=engagement) - dojo_dispatch_task(calculate_grade, product.id) + copy_test_service(test, engagement, request.user) messages.add_message( request, messages.SUCCESS, "Test Copied successfully.", extra_tags="alert-success") - create_notification(event="test_copied", # TODO: - if 'copy' functionality will be supported by API as well, 'create_notification' needs to be migrated to place where it will be able to cover actions from both interfaces - title=f"Copying of {test.title}", - description=f'The test "{test.title}" was copied by {request.user} to {engagement.name}', - product=product, - url=request.build_absolute_uri(reverse("view_test", args=(test_copy.id,))), - recipients=[test.engagement.lead], - icon="exclamation-triangle") return redirect_to_return_url_or_else(request, reverse("view_engagement", args=(engagement.id, ))) messages.add_message( request, diff --git a/unittests/test_copy_model.py b/unittests/test_copy_model.py index f36348753b2..d67246630cc 100644 --- a/unittests/test_copy_model.py +++ b/unittests/test_copy_model.py @@ -1,6 +1,9 @@ +from unittest.mock import patch + from dojo.location.models import Location, LocationFindingReference from dojo.models import Endpoint, Endpoint_Status, Engagement, Finding, Product, Test, User +from dojo.test.services import copy_test from dojo.url.models import URL from .dojo_test_case import DojoTestCase, skip_unless_v2, skip_unless_v3 @@ -276,6 +279,34 @@ def test_duplicate_test_with_tags_and_notes(self): self.assertEqual(test.tags, test_copy.tags) +class TestCopyTestService(DojoTestCase): + + """Phase 2: the copy_test service holds the copy workflow extracted from the UI view.""" + + @patch("dojo.test.services.create_notification") + @patch("dojo.test.services.dojo_dispatch_task") + def test_copy_test_service(self, mock_dispatch, mock_notification): + user, _ = User.objects.get_or_create(username="admin") + product_type = self.create_product_type("svc_pt_test") + product = self.create_product("svc_copy_test_product", prod_type=product_type) + engagement = self.create_engagement("svc_eng_test", product) + test = self.create_test(engagement=engagement, scan_type="NPM Audit Scan", title="test") + _ = Finding.objects.create(test=test, reporter=user) + before_tests = Test.objects.filter(engagement=engagement).count() + before_findings = Finding.objects.filter(test__engagement=engagement).count() + # Run the service (copy into the same engagement) + test_copy = copy_test(test, engagement, user) + # A new test was created under the engagement, with its findings + self.assertEqual(before_tests + 1, Test.objects.filter(engagement=engagement).count()) + self.assertNotEqual(test.id, test_copy.id) + self.assertEqual(engagement, test_copy.engagement) + self.assertEqual(before_findings + 1, Finding.objects.filter(test__engagement=engagement).count()) + # Side effects: grade recalculation dispatched and a notification raised + mock_dispatch.assert_called_once() + mock_notification.assert_called_once() + self.assertEqual(mock_notification.call_args.kwargs["event"], "test_copied") + + class TestCopyEngagementModel(DojoTestCase): def test_duplicate_engagement(self): From 76887f5b063f3bbf3ba73cef39ad7219e60c7d6b Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 20:36:35 +0200 Subject: [PATCH 12/40] refactor(test): move forms + UI filters into dojo/test/ui/ [test Phase 3,4] Move TestForm/DeleteTestForm/CopyTestForm into ui/forms.py (TestForm re-exported from dojo/forms.py since engagement views use it; the other two have no external consumers). Move TestImportFilter/TestImportFindingActionFilter/TestTypeFilter into ui/filters.py keeping DojoFilter bases; re-exports omitted (would cycle) and consumers (test/finding/test_type views) updated to the new path. EngagementTest* filters left in dojo/filters.py (engagement domain); Api* filters left for Phase 7. --- dojo/filters.py | 62 ++--------------------------- dojo/finding/views.py | 3 +- dojo/forms.py | 76 +---------------------------------- dojo/test/ui/__init__.py | 0 dojo/test/ui/filters.py | 64 ++++++++++++++++++++++++++++++ dojo/test/ui/forms.py | 86 ++++++++++++++++++++++++++++++++++++++++ dojo/test/views.py | 6 +-- dojo/test_type/views.py | 2 +- 8 files changed, 159 insertions(+), 140 deletions(-) create mode 100644 dojo/test/ui/__init__.py create mode 100644 dojo/test/ui/filters.py create mode 100644 dojo/test/ui/forms.py diff --git a/dojo/filters.py b/dojo/filters.py index 8354e51b2f4..54d71357fa1 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -57,7 +57,6 @@ from dojo.models import ( EFFORT_FOR_FIXING_CHOICES, ENGAGEMENT_STATUS_CHOICES, - IMPORT_ACTIONS, SEVERITY_CHOICES, App_Analysis, ChoiceQuestion, @@ -79,7 +78,6 @@ Risk_Acceptance, Test, Test_Import, - Test_Import_Finding_Action, Test_Type, TextQuestion, User, @@ -3705,47 +3703,8 @@ class Meta: fields = ["is_superuser", "is_staff", "is_active", "first_name", "last_name", "username", "email"] -# This class is used exclusively by Findings -class TestImportFilter(DojoFilter): - version = CharFilter(field_name="version", lookup_expr="icontains") - version_exact = CharFilter(field_name="version", lookup_expr="iexact", label="Version Exact") - branch_tag = CharFilter(lookup_expr="icontains", label="Branch/Tag") - build_id = CharFilter(lookup_expr="icontains", label="Build ID") - commit_hash = CharFilter(lookup_expr="icontains", label="Commit hash") - - findings_affected = BooleanFilter(field_name="findings_affected", lookup_expr="isnull", exclude=True, label="Findings affected") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("date", "date"), - ("version", "version"), - ("branch_tag", "branch_tag"), - ("build_id", "build_id"), - ("commit_hash", "commit_hash"), - - ), - ) - - class Meta: - model = Test_Import - fields = [] - - -# This class is used exclusively by Findings -class TestImportFindingActionFilter(DojoFilter): - action = MultipleChoiceFilter(choices=IMPORT_ACTIONS) - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("action", "action"), - ), - ) - - class Meta: - model = Test_Import_Finding_Action - fields = [] - +# TestImportFilter and TestImportFindingActionFilter live in dojo/test/ui/filters.py and are +# re-exported at the bottom of this module for backward compatibility. # Used within the TestImport API class TestImportAPIFilter(DojoFilter): @@ -3778,22 +3737,7 @@ class Meta: # LogEntryFilter and PgHistoryFilter live in dojo/auditlog/filters.py and are # re-exported at the bottom of this module for backward compatibility. - - -class TestTypeFilter(DojoFilter): - name = CharFilter(lookup_expr="icontains") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ), - ) - - class Meta: - model = Test_Type - exclude = [] - include = ("name",) +# TestTypeFilter lives in dojo/test/ui/filters.py and is re-exported below. class DevelopmentEnvironmentFilter(DojoFilter): diff --git a/dojo/finding/views.py b/dojo/finding/views.py index e39a0a8fea8..58013751ebd 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -41,8 +41,6 @@ SimilarFindingFilter, SimilarFindingFilterWithoutObjectLookups, TemplateFindingFilter, - TestImportFilter, - TestImportFindingActionFilter, ) from dojo.finding.deduplication import ( _fetch_fp_candidates_for_batch, @@ -97,6 +95,7 @@ from dojo.notifications.helper import create_notification from dojo.tags.utils import bulk_add_tags_to_instances from dojo.test.queries import get_authorized_tests +from dojo.test.ui.filters import TestImportFilter, TestImportFindingActionFilter from dojo.tools import tool_issue_updater from dojo.utils import ( FileIterWrapper, diff --git a/dojo/forms.py b/dojo/forms.py index 862d641b968..b90ab7ab4b1 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -109,7 +109,6 @@ from dojo.user.utils import get_configuration_permissions_fields from dojo.utils import ( get_password_requirements_string, - get_product, get_system_setting, is_finding_groups_enabled, is_scan_file_too_large, @@ -1061,80 +1060,7 @@ class Meta: fields = ["id"] -class TestForm(forms.ModelForm): - title = forms.CharField(max_length=255, required=False) - description = forms.CharField(widget=forms.Textarea(attrs={"rows": "3"}), required=False) - test_type = forms.ModelChoiceField(queryset=Test_Type.objects.all().order_by("name")) - environment = forms.ModelChoiceField( - queryset=Development_Environment.objects.all().order_by("name")) - target_start = forms.DateTimeField(widget=forms.TextInput( - attrs={"class": "datepicker", "autocomplete": "off"})) - target_end = forms.DateTimeField(widget=forms.TextInput( - attrs={"class": "datepicker", "autocomplete": "off"})) - lead = forms.ModelChoiceField( - queryset=None, - required=False, label="Testing Lead") - - def __init__(self, *args, **kwargs): - obj = None - - if "engagement" in kwargs: - obj = kwargs.pop("engagement") - - if "instance" in kwargs: - obj = kwargs.get("instance") - - super().__init__(*args, **kwargs) - - if obj: - product = get_product(obj) - self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, "view").filter(is_active=True) - self.fields["api_scan_configuration"].queryset = Product_API_Scan_Configuration.objects.filter(product=product) - else: - self.fields["lead"].queryset = get_authorized_users("view").filter(is_active=True) - - def is_valid(self): - valid = super().is_valid() - - # we're done now if not valid - if not valid: - return valid - if self.cleaned_data["target_start"] > self.cleaned_data["target_end"]: - self.add_error("target_start", "Your target start date exceeds your target end date") - self.add_error("target_end", "Your target start date exceeds your target end date") - return False - return True - - class Meta: - model = Test - fields = ["title", "test_type", "target_start", "target_end", "description", - "environment", "percent_complete", "tags", "lead", "version", "branch_tag", "build_id", "commit_hash", - "api_scan_configuration"] - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - -class DeleteTestForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Test - fields = ["id"] - - -class CopyTestForm(forms.Form): - engagement = forms.ModelChoiceField( - required=True, - queryset=Engagement.objects.none(), - error_messages={"required": "*"}) - - def __init__(self, *args, **kwargs): - authorized_lists = kwargs.pop("engagements", None) - super().__init__(*args, **kwargs) - self.fields["engagement"].queryset = authorized_lists +from dojo.test.ui.forms import TestForm # noqa: E402, F401 -- backward compat class AddFindingForm(forms.ModelForm): diff --git a/dojo/test/ui/__init__.py b/dojo/test/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/test/ui/filters.py b/dojo/test/ui/filters.py new file mode 100644 index 00000000000..60bc7960d54 --- /dev/null +++ b/dojo/test/ui/filters.py @@ -0,0 +1,64 @@ +import logging + +from django_filters import BooleanFilter, CharFilter, MultipleChoiceFilter, OrderingFilter + +from dojo.filters import DojoFilter +from dojo.models import IMPORT_ACTIONS, Test_Import, Test_Import_Finding_Action, Test_Type + +logger = logging.getLogger(__name__) + + +class TestImportFilter(DojoFilter): + version = CharFilter(field_name="version", lookup_expr="icontains") + version_exact = CharFilter(field_name="version", lookup_expr="iexact", label="Version Exact") + branch_tag = CharFilter(lookup_expr="icontains", label="Branch/Tag") + build_id = CharFilter(lookup_expr="icontains", label="Build ID") + commit_hash = CharFilter(lookup_expr="icontains", label="Commit hash") + + findings_affected = BooleanFilter(field_name="findings_affected", lookup_expr="isnull", exclude=True, label="Findings affected") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("date", "date"), + ("version", "version"), + ("branch_tag", "branch_tag"), + ("build_id", "build_id"), + ("commit_hash", "commit_hash"), + + ), + ) + + class Meta: + model = Test_Import + fields = [] + + +class TestImportFindingActionFilter(DojoFilter): + action = MultipleChoiceFilter(choices=IMPORT_ACTIONS) + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("action", "action"), + ), + ) + + class Meta: + model = Test_Import_Finding_Action + fields = [] + + +class TestTypeFilter(DojoFilter): + name = CharFilter(lookup_expr="icontains") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ), + ) + + class Meta: + model = Test_Type + exclude = [] + include = ("name",) diff --git a/dojo/test/ui/forms.py b/dojo/test/ui/forms.py new file mode 100644 index 00000000000..6114817ef00 --- /dev/null +++ b/dojo/test/ui/forms.py @@ -0,0 +1,86 @@ +import logging + +from django import forms + +from dojo.models import Development_Environment, Engagement, Product_API_Scan_Configuration, Test, Test_Type +from dojo.user.queries import get_authorized_users, get_authorized_users_for_product_and_product_type +from dojo.utils import get_product +from dojo.validators import tag_validator + +logger = logging.getLogger(__name__) + + +class TestForm(forms.ModelForm): + title = forms.CharField(max_length=255, required=False) + description = forms.CharField(widget=forms.Textarea(attrs={"rows": "3"}), required=False) + test_type = forms.ModelChoiceField(queryset=Test_Type.objects.all().order_by("name")) + environment = forms.ModelChoiceField( + queryset=Development_Environment.objects.all().order_by("name")) + target_start = forms.DateTimeField(widget=forms.TextInput( + attrs={"class": "datepicker", "autocomplete": "off"})) + target_end = forms.DateTimeField(widget=forms.TextInput( + attrs={"class": "datepicker", "autocomplete": "off"})) + lead = forms.ModelChoiceField( + queryset=None, + required=False, label="Testing Lead") + + def __init__(self, *args, **kwargs): + obj = None + + if "engagement" in kwargs: + obj = kwargs.pop("engagement") + + if "instance" in kwargs: + obj = kwargs.get("instance") + + super().__init__(*args, **kwargs) + + if obj: + product = get_product(obj) + self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, "view").filter(is_active=True) + self.fields["api_scan_configuration"].queryset = Product_API_Scan_Configuration.objects.filter(product=product) + else: + self.fields["lead"].queryset = get_authorized_users("view").filter(is_active=True) + + def is_valid(self): + valid = super().is_valid() + + # we're done now if not valid + if not valid: + return valid + if self.cleaned_data["target_start"] > self.cleaned_data["target_end"]: + self.add_error("target_start", "Your target start date exceeds your target end date") + self.add_error("target_end", "Your target start date exceeds your target end date") + return False + return True + + class Meta: + model = Test + fields = ["title", "test_type", "target_start", "target_end", "description", + "environment", "percent_complete", "tags", "lead", "version", "branch_tag", "build_id", "commit_hash", + "api_scan_configuration"] + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + +class DeleteTestForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Test + fields = ["id"] + + +class CopyTestForm(forms.Form): + engagement = forms.ModelChoiceField( + required=True, + queryset=Engagement.objects.none(), + error_messages={"required": "*"}) + + def __init__(self, *args, **kwargs): + authorized_lists = kwargs.pop("engagements", None) + super().__init__(*args, **kwargs) + self.fields["engagement"].queryset = authorized_lists diff --git a/dojo/test/views.py b/dojo/test/views.py index 49ef13c3d37..7d56d4e6587 100644 --- a/dojo/test/views.py +++ b/dojo/test/views.py @@ -25,13 +25,11 @@ import dojo.finding.helper as finding_helper from dojo.authorization.authorization import user_has_permission_or_403 from dojo.engagement.queries import get_authorized_engagements -from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter, TestImportFilter +from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter from dojo.finding.queries import prefetch_for_findings from dojo.finding.views import find_available_notetypes from dojo.forms import ( AddFindingForm, - CopyTestForm, - DeleteTestForm, FindingBulkUpdateForm, JIRAFindingForm, JIRAImportScanForm, @@ -63,6 +61,8 @@ ) from dojo.test.queries import get_authorized_tests from dojo.test.services import copy_test as copy_test_service +from dojo.test.ui.filters import TestImportFilter +from dojo.test.ui.forms import CopyTestForm, DeleteTestForm from dojo.tools.factory import get_choices_sorted, get_scan_types_sorted from dojo.user.queries import get_authorized_users from dojo.utils import ( diff --git a/dojo/test_type/views.py b/dojo/test_type/views.py index 5a25e9ed00a..c025761ba76 100644 --- a/dojo/test_type/views.py +++ b/dojo/test_type/views.py @@ -7,9 +7,9 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse -from dojo.filters import TestTypeFilter from dojo.forms import Test_TypeForm from dojo.models import Test_Type +from dojo.test.ui.filters import TestTypeFilter from dojo.utils import add_breadcrumb, get_page_items logger = logging.getLogger(__name__) From 7d6cae8e2cb8e19e8ee2bb2531db10587a05bfca Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 20:37:59 +0200 Subject: [PATCH 13/40] refactor(test): move views + urls into dojo/test/ui/ [test Phase 5] Move dojo/test/{views,urls}.py to dojo/test/ui/ and update consumers (dojo/urls.py include + test_apply_finding_template test). urls.py now imports views from dojo.test.ui. --- dojo/test/{ => ui}/urls.py | 2 +- dojo/test/{ => ui}/views.py | 0 dojo/urls.py | 2 +- unittests/test_apply_finding_template.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename dojo/test/{ => ui}/urls.py (97%) rename dojo/test/{ => ui}/views.py (100%) diff --git a/dojo/test/urls.py b/dojo/test/ui/urls.py similarity index 97% rename from dojo/test/urls.py rename to dojo/test/ui/urls.py index 335cf260b86..403068023cd 100644 --- a/dojo/test/urls.py +++ b/dojo/test/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.test import views +from dojo.test.ui import views urlpatterns = [ # tests diff --git a/dojo/test/views.py b/dojo/test/ui/views.py similarity index 100% rename from dojo/test/views.py rename to dojo/test/ui/views.py diff --git a/dojo/urls.py b/dojo/urls.py index a31c6e62bd3..65605d9b6f3 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -86,7 +86,7 @@ from dojo.sla_config.urls import urlpatterns as sla_urls from dojo.survey.urls import urlpatterns as survey_urls from dojo.system_settings.urls import urlpatterns as system_settings_urls -from dojo.test.urls import urlpatterns as test_urls +from dojo.test.ui.urls import urlpatterns as test_urls from dojo.test_type.urls import urlpatterns as test_type_urls from dojo.tool_config.urls import urlpatterns as tool_config_urls from dojo.tool_product.urls import urlpatterns as tool_product_urls diff --git a/unittests/test_apply_finding_template.py b/unittests/test_apply_finding_template.py index f2fd228a7c0..51404069ac3 100644 --- a/unittests/test_apply_finding_template.py +++ b/unittests/test_apply_finding_template.py @@ -28,7 +28,7 @@ Test_Type, Vulnerability_Id, ) -from dojo.test import views as test_views +from dojo.test.ui import views as test_views from unittests.dojo_test_case import DojoTestCase, versioned_fixtures From 2ae0e8588f2ad5f19b04e48c096fffd636f750f9 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 20:52:44 +0200 Subject: [PATCH 14/40] refactor(test): extract API layer into dojo/test/api/ [test Phase 6,7,8,9] Move 8 Test-domain serializers into api/serializer.py (TestSerializer re-exported from api_v2/serializers.py for ReportGenerateSerializer; rest omitted, sole consumers were the moved viewsets), ApiTestFilter/TestImportAPIFilter into api/filters.py, and TestsViewSet/TestTypesViewSet/TestImportViewSet into api/views.py. api/urls.py adds add_test_urls() preserving routes tests/test_types/test_imports + basenames test/test_type/test_imports. Viewset re-exports omitted (would cycle); dojo/urls.py + test_rest_framework updated. Finding* serializers left in place. Full rest_framework suite green (871 tests). --- dojo/api_v2/serializers.py | 93 +-------- dojo/api_v2/views.py | 293 +--------------------------- dojo/filters.py | 97 --------- dojo/test/api/__init__.py | 1 + dojo/test/api/filters.py | 114 +++++++++++ dojo/test/api/serializer.py | 132 +++++++++++++ dojo/test/api/urls.py | 8 + dojo/test/api/views.py | 325 +++++++++++++++++++++++++++++++ dojo/urls.py | 8 +- unittests/test_rest_framework.py | 3 +- 10 files changed, 585 insertions(+), 489 deletions(-) create mode 100644 dojo/test/api/__init__.py create mode 100644 dojo/test/api/filters.py create mode 100644 dojo/test/api/serializer.py create mode 100644 dojo/test/api/urls.py create mode 100644 dojo/test/api/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 55d9cb9494b..85371da3fca 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -77,8 +77,6 @@ Sonarqube_Issue_Transition, System_Settings, Test, - Test_Import, - Test_Import_Finding_Action, Test_Type, Tool_Configuration, Tool_Product_Settings, @@ -1022,96 +1020,7 @@ class Meta: fields = ("id", "name", "test", "jira_issue") -class TestSerializer(serializers.ModelSerializer): - tags = TagListSerializerField(required=False) - test_type_name = serializers.ReadOnlyField() - finding_groups = FindingGroupSerializer( - source="finding_group_set", many=True, read_only=True, - ) - - class Meta: - model = Test - exclude = ("inherited_tags",) - - def build_relational_field(self, field_name, relation_info): - if field_name == "notes": - return NoteSerializer, {"many": True, "read_only": True} - if field_name == "files": - return FileSerializer, {"many": True, "read_only": True} - return super().build_relational_field(field_name, relation_info) - - -class TestCreateSerializer(serializers.ModelSerializer): - engagement = serializers.PrimaryKeyRelatedField( - queryset=Engagement.objects.all(), - ) - notes = serializers.PrimaryKeyRelatedField( - allow_null=True, - queryset=Notes.objects.all(), - many=True, - required=False, - ) - tags = TagListSerializerField(required=False) - - class Meta: - model = Test - exclude = ("inherited_tags",) - - -class TestTypeCreateSerializer(serializers.ModelSerializer): - - class Meta: - model = Test_Type - exclude = ("dynamically_generated",) - - -class TestTypeSerializer(serializers.ModelSerializer): - name = serializers.ReadOnlyField() - - class Meta: - model = Test_Type - exclude = ("dynamically_generated",) - - -class TestToNotesSerializer(serializers.Serializer): - test_id = serializers.PrimaryKeyRelatedField( - queryset=Test.objects.all(), many=False, allow_null=True, - ) - notes = NoteSerializer(many=True) - - -class TestToFilesSerializer(serializers.Serializer): - test_id = serializers.PrimaryKeyRelatedField( - queryset=Test.objects.all(), many=False, allow_null=True, - ) - files = FileSerializer(many=True) - - def to_representation(self, data): - test = data.get("test_id") - files = data.get("files") - new_files = [{ - "id": file.id, - "file": f"{settings.SITE_URL}/{file.get_accessible_url(test, test.id)}", - "title": file.title, - } for file in files] - return {"test_id": test.id, "files": new_files} - - -class TestImportFindingActionSerializer(serializers.ModelSerializer): - class Meta: - model = Test_Import_Finding_Action - fields = "__all__" - - -class TestImportSerializer(serializers.ModelSerializer): - # findings = TestImportFindingActionSerializer(source='test_import_finding_action', many=True, read_only=True) - test_import_finding_action_set = TestImportFindingActionSerializer( - many=True, read_only=True, - ) - - class Meta: - model = Test_Import - fields = "__all__" +from dojo.test.api.serializer import TestSerializer # noqa: E402 -- backward compat re-export class RiskAcceptanceSerializer(serializers.ModelSerializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index e1dff0f520a..78a7e87761a 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -64,11 +64,9 @@ ApiProductFilter, ApiRiskAcceptanceFilter, ApiTemplateFindingFilter, - ApiTestFilter, ApiUserFilter, ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, - TestImportAPIFilter, ) from dojo.finding.queries import ( get_authorized_findings, @@ -111,8 +109,6 @@ Sonarqube_Issue_Transition, System_Settings, Test, - Test_Import, - Test_Type, Tool_Configuration, Tool_Product_Settings, Tool_Type, @@ -135,7 +131,7 @@ from dojo.risk_acceptance import api as ra_api from dojo.risk_acceptance.helper import remove_finding_from_risk_acceptance from dojo.risk_acceptance.queries import get_authorized_risk_acceptances -from dojo.test.queries import get_authorized_test_imports, get_authorized_tests +from dojo.test.queries import get_authorized_tests from dojo.tool_product.queries import get_authorized_tool_product_settings from dojo.user.authentication import reset_token_for_user from dojo.user.utils import get_configuration_permissions_codenames @@ -1793,293 +1789,6 @@ def get_queryset(self): return Development_Environment.objects.all().order_by("id") -# Authorization: object-based -# @extend_schema_view(**schema_with_prefetch()) -# Nested models with prefetch make the response schema too long for Swagger UI -class TestsViewSet( - PrefetchDojoModelViewSet, - ra_api.AcceptedRisksMixin, -): - serializer_class = serializers.TestSerializer - queryset = Test.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiTestFilter - permission_classes = (IsAuthenticated, permissions.UserHasTestPermission) - - @property - def risk_application_model_class(self): - return Test - - def get_queryset(self): - return ( - get_authorized_tests("view") - .prefetch_related("notes", "files") - .distinct() - ) - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if get_setting("ASYNC_OBJECT_DELETE"): - async_del = async_delete() - async_del.delete(instance) - else: - instance.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - def get_serializer_class(self): - if self.request and self.request.method == "POST": - if self.action == "accept_risks": - return ra_api.AcceptedRiskSerializer - return serializers.TestCreateSerializer - return serializers.TestSerializer - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=True, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request, pk=None): - test = self.get_object() - - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, test, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.TestToNotesSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewNoteOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.NoteSerializer}, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasTestNotePermission)) - def notes(self, request, pk=None): - test = self.get_object() - if request.method == "POST": - new_note = serializers.AddNewNoteOptionSerializer( - data=request.data, - ) - if new_note.is_valid(): - entry = new_note.validated_data["entry"] - private = new_note.validated_data.get("private", False) - note_type = new_note.validated_data.get("note_type", None) - else: - return Response( - new_note.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - notes = test.notes.filter(note_type=note_type).first() - if notes and note_type and note_type.is_single: - return Response("Only one instance of this note_type allowed on a test.", status=status.HTTP_400_BAD_REQUEST) - - author = request.user - note = Notes( - entry=entry, - author=author, - private=private, - note_type=note_type, - ) - note.save() - # Add an entry to the note history - history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) - note.history.add(history) - # Now add the note to the object - test.notes.add(note) - # Determine if we need to send any notifications for user mentioned - process_tag_notifications( - request=request, - note=note, - parent_url=request.build_absolute_uri( - reverse("view_test", args=(test.id,)), - ), - parent_title=f"Test: {test.title}", - ) - - serialized_note = serializers.NoteSerializer( - {"author": author, "entry": entry, "private": private}, - ) - return Response( - serialized_note.data, status=status.HTTP_201_CREATED, - ) - notes = test.notes.all() - - serialized_notes = serializers.TestToNotesSerializer( - {"test_id": test, "notes": notes}, - ) - return Response(serialized_notes.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.TestToFilesSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewFileOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.FileSerializer}, - ) - @action( - detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=(IsAuthenticated, permissions.UserHasTestRelatedObjectPermission), - ) - def files(self, request, pk=None): - test = self.get_object() - if request.method == "POST": - new_file = serializers.FileSerializer(data=request.data) - if new_file.is_valid(): - title = new_file.validated_data["title"] - file = new_file.validated_data["file"] - else: - return Response( - new_file.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - file = FileUpload(title=title, file=file) - file.save() - test.files.add(file) - - serialized_file = serializers.FileSerializer(file) - return Response( - serialized_file.data, status=status.HTTP_201_CREATED, - ) - - files = test.files.all() - serialized_files = serializers.TestToFilesSerializer( - {"test_id": test, "files": files}, - ) - return Response(serialized_files.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.RawFileSerializer, - }, - ) - @action( - detail=True, - methods=["get"], - url_path=r"files/download/(?P\d+)", - permission_classes=(IsAuthenticated, permissions.UserHasTestRelatedObjectPermission), - ) - def download_file(self, request, file_id, pk=None): - test = self.get_object() - # Get the file object - file_object_qs = test.files.filter(id=file_id) - file_object = ( - file_object_qs.first() if len(file_object_qs) > 0 else None - ) - if file_object is None: - return Response( - {"error": "File ID not associated with Test"}, - status=status.HTTP_404_NOT_FOUND, - ) - # send file - return generate_file_response(file_object) - - -# Authorization: authenticated, configuration -class TestTypesViewSet( - mixins.UpdateModelMixin, - mixins.CreateModelMixin, - viewsets.ReadOnlyModelViewSet, -): - serializer_class = serializers.TestTypeSerializer - queryset = Test_Type.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "name", - ] - permission_classes = (IsAuthenticated, DjangoModelPermissions) - - def get_queryset(self): - return Test_Type.objects.all().order_by("id") - - def get_serializer_class(self): - if self.action == "create": - return serializers.TestTypeCreateSerializer - return serializers.TestTypeSerializer - - -# @extend_schema_view(**schema_with_prefetch()) -# Nested models with prefetch make the response schema too long for Swagger UI -class TestImportViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.TestImportSerializer - queryset = Test_Import.objects.none() - filter_backends = (DjangoFilterBackend,) - - filterset_class = TestImportAPIFilter - - permission_classes = ( - IsAuthenticated, - permissions.UserHasTestImportPermission, - ) - - def get_queryset(self): - return get_authorized_test_imports( - "view", - ).prefetch_related( - "test_import_finding_action_set", - "findings_affected", - "findings_affected__endpoints", - "findings_affected__status_finding", - "findings_affected__finding_meta", - "findings_affected__jira_issue", - "findings_affected__burprawrequestresponse_set", - "findings_affected__jira_issue", - "findings_affected__jira_issue", - "findings_affected__jira_issue", - "findings_affected__reviewers", - "findings_affected__notes", - "findings_affected__notes__author", - "findings_affected__notes__history", - "findings_affected__files", - "findings_affected__found_by", - "findings_affected__tags", - "findings_affected__risk_acceptance_set", - "test", - "test__tags", - "test__notes", - "test__notes__author", - "test__files", - "test__test_type", - "test__engagement", - "test__environment", - "test__engagement__product", - "test__engagement__product__prod_type", - ) - - # Authorization: configurations @extend_schema_view(**schema_with_prefetch()) class ToolConfigurationsViewSet( diff --git a/dojo/filters.py b/dojo/filters.py index 54d71357fa1..14af254bb69 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -77,7 +77,6 @@ Question, Risk_Acceptance, Test, - Test_Import, Test_Type, TextQuestion, User, @@ -3283,74 +3282,6 @@ def __init__(self, *args, **kwargs): self.form.fields["test_type"].queryset = Test_Type.objects.filter(test__engagement=self.engagement).distinct().order_by("name") -class ApiTestFilter(DojoFilter): - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - engagement__tags = CharFieldInFilter( - field_name="engagement__tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags present on engagement (uses OR for multiple values)") - engagement__tags__and = CharFieldFilterANDExpression( - field_name="engagement__tags__name", - help_text="Comma separated list of exact tags to match with an AND expression present on engagement") - engagement__product__tags = CharFieldInFilter( - field_name="engagement__product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) - engagement__product__tags__and = CharFieldFilterANDExpression( - field_name="engagement__product__tags__name", - help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on model", exclude="True") - not_engagement__tags = CharFieldInFilter(field_name="engagement__tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on engagement", - exclude="True") - not_engagement__product__tags = CharFieldInFilter(field_name="engagement__product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, - exclude="True") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("title", "title"), - ("version", "version"), - ("target_start", "target_start"), - ("target_end", "target_end"), - ("test_type", "test_type"), - ("lead", "lead"), - ("version", "version"), - ("branch_tag", "branch_tag"), - ("build_id", "build_id"), - ("commit_hash", "commit_hash"), - ("api_scan_configuration", "api_scan_configuration"), - ("engagement", "engagement"), - ("created", "created"), - ("updated", "updated"), - ), - field_labels={ - "name": "Test Name", - }, - ) - - class Meta: - model = Test - fields = ["id", "title", "test_type", "target_start", - "target_end", "notes", "percent_complete", - "engagement", "version", - "branch_tag", "build_id", "commit_hash", - "api_scan_configuration", "scan_type"] - - class ApiAppAnalysisFilter(DojoFilter): tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") tags = CharFieldInFilter( @@ -3706,34 +3637,6 @@ class Meta: # TestImportFilter and TestImportFindingActionFilter live in dojo/test/ui/filters.py and are # re-exported at the bottom of this module for backward compatibility. -# Used within the TestImport API -class TestImportAPIFilter(DojoFilter): - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("id", "id"), - ("created", "created"), - ("modified", "modified"), - ("version", "version"), - ("branch_tag", "branch_tag"), - ("build_id", "build_id"), - ("commit_hash", "commit_hash"), - - ), - ) - - class Meta: - model = Test_Import - fields = ["test", - "findings_affected", - "version", - "branch_tag", - "build_id", - "commit_hash", - "test_import_finding_action__action", - "test_import_finding_action__finding", - "test_import_finding_action__created"] - # LogEntryFilter and PgHistoryFilter live in dojo/auditlog/filters.py and are # re-exported at the bottom of this module for backward compatibility. diff --git a/dojo/test/api/__init__.py b/dojo/test/api/__init__.py new file mode 100644 index 00000000000..ab9d3d2e082 --- /dev/null +++ b/dojo/test/api/__init__.py @@ -0,0 +1 @@ +path = "tests" # noqa: RUF067 diff --git a/dojo/test/api/filters.py b/dojo/test/api/filters.py new file mode 100644 index 00000000000..9d5d0614653 --- /dev/null +++ b/dojo/test/api/filters.py @@ -0,0 +1,114 @@ +from django_filters import ( + BooleanFilter, + CharFilter, + OrderingFilter, +) + +from dojo.filters import ( + CharFieldFilterANDExpression, + CharFieldInFilter, + DojoFilter, +) +from dojo.labels import get_labels +from dojo.models import ( + Test, + Test_Import, +) + +labels = get_labels() + + +class ApiTestFilter(DojoFilter): + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + engagement__tags = CharFieldInFilter( + field_name="engagement__tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags present on engagement (uses OR for multiple values)") + engagement__tags__and = CharFieldFilterANDExpression( + field_name="engagement__tags__name", + help_text="Comma separated list of exact tags to match with an AND expression present on engagement") + engagement__product__tags = CharFieldInFilter( + field_name="engagement__product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) + engagement__product__tags__and = CharFieldFilterANDExpression( + field_name="engagement__product__tags__name", + help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on model", exclude="True") + not_engagement__tags = CharFieldInFilter(field_name="engagement__tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on engagement", + exclude="True") + not_engagement__product__tags = CharFieldInFilter(field_name="engagement__product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, + exclude="True") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("title", "title"), + ("version", "version"), + ("target_start", "target_start"), + ("target_end", "target_end"), + ("test_type", "test_type"), + ("lead", "lead"), + ("version", "version"), + ("branch_tag", "branch_tag"), + ("build_id", "build_id"), + ("commit_hash", "commit_hash"), + ("api_scan_configuration", "api_scan_configuration"), + ("engagement", "engagement"), + ("created", "created"), + ("updated", "updated"), + ), + field_labels={ + "name": "Test Name", + }, + ) + + class Meta: + model = Test + fields = ["id", "title", "test_type", "target_start", + "target_end", "notes", "percent_complete", + "engagement", "version", + "branch_tag", "build_id", "commit_hash", + "api_scan_configuration", "scan_type"] + + +class TestImportAPIFilter(DojoFilter): + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("id", "id"), + ("created", "created"), + ("modified", "modified"), + ("version", "version"), + ("branch_tag", "branch_tag"), + ("build_id", "build_id"), + ("commit_hash", "commit_hash"), + + ), + ) + + class Meta: + model = Test_Import + fields = ["test", + "findings_affected", + "version", + "branch_tag", + "build_id", + "commit_hash", + "test_import_finding_action__action", + "test_import_finding_action__finding", + "test_import_finding_action__created"] diff --git a/dojo/test/api/serializer.py b/dojo/test/api/serializer.py new file mode 100644 index 00000000000..fcce90fdd82 --- /dev/null +++ b/dojo/test/api/serializer.py @@ -0,0 +1,132 @@ +from django.conf import settings +from rest_framework import serializers + +from dojo.models import ( + Engagement, + Notes, + Test, + Test_Import, + Test_Import_Finding_Action, + Test_Type, +) + + +class TestSerializer(serializers.ModelSerializer): + test_type_name = serializers.ReadOnlyField() + + class Meta: + model = Test + exclude = ("inherited_tags",) + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + FindingGroupSerializer, + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + fields["finding_groups"] = FindingGroupSerializer( + source="finding_group_set", many=True, read_only=True, + ) + return fields + + def build_relational_field(self, field_name, relation_info): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + FileSerializer, + NoteSerializer, + ) + if field_name == "notes": + return NoteSerializer, {"many": True, "read_only": True} + if field_name == "files": + return FileSerializer, {"many": True, "read_only": True} + return super().build_relational_field(field_name, relation_info) + + +class TestCreateSerializer(serializers.ModelSerializer): + engagement = serializers.PrimaryKeyRelatedField( + queryset=Engagement.objects.all(), + ) + notes = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Notes.objects.all(), + many=True, + required=False, + ) + + class Meta: + model = Test + exclude = ("inherited_tags",) + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + +class TestTypeCreateSerializer(serializers.ModelSerializer): + + class Meta: + model = Test_Type + exclude = ("dynamically_generated",) + + +class TestTypeSerializer(serializers.ModelSerializer): + name = serializers.ReadOnlyField() + + class Meta: + model = Test_Type + exclude = ("dynamically_generated",) + + +class TestToNotesSerializer(serializers.Serializer): + test_id = serializers.PrimaryKeyRelatedField( + queryset=Test.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import NoteSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["notes"] = NoteSerializer(many=True) + return fields + + +class TestToFilesSerializer(serializers.Serializer): + test_id = serializers.PrimaryKeyRelatedField( + queryset=Test.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import FileSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["files"] = FileSerializer(many=True) + return fields + + def to_representation(self, data): + test = data.get("test_id") + files = data.get("files") + new_files = [{ + "id": file.id, + "file": f"{settings.SITE_URL}/{file.get_accessible_url(test, test.id)}", + "title": file.title, + } for file in files] + return {"test_id": test.id, "files": new_files} + + +class TestImportFindingActionSerializer(serializers.ModelSerializer): + class Meta: + model = Test_Import_Finding_Action + fields = "__all__" + + +class TestImportSerializer(serializers.ModelSerializer): + # findings = TestImportFindingActionSerializer(source='test_import_finding_action', many=True, read_only=True) + test_import_finding_action_set = TestImportFindingActionSerializer( + many=True, read_only=True, + ) + + class Meta: + model = Test_Import + fields = "__all__" diff --git a/dojo/test/api/urls.py b/dojo/test/api/urls.py new file mode 100644 index 00000000000..b98f633d3bd --- /dev/null +++ b/dojo/test/api/urls.py @@ -0,0 +1,8 @@ +from dojo.test.api.views import TestImportViewSet, TestsViewSet, TestTypesViewSet + + +def add_test_urls(router): + router.register("tests", TestsViewSet, basename="test") + router.register("test_types", TestTypesViewSet, basename="test_type") + router.register("test_imports", TestImportViewSet, basename="test_imports") + return router diff --git a/dojo/test/api/views.py b/dojo/test/api/views.py new file mode 100644 index 00000000000..7c2066374c4 --- /dev/null +++ b/dojo/test/api/views.py @@ -0,0 +1,325 @@ +from django.urls import reverse +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2 import serializers as api_v2_serializers +from dojo.api_v2.views import PrefetchDojoModelViewSet, report_generate +from dojo.authorization import api_permissions as permissions +from dojo.models import ( + FileUpload, + NoteHistory, + Notes, + Test, + Test_Import, + Test_Type, +) +from dojo.risk_acceptance import api as ra_api +from dojo.test.api.filters import ApiTestFilter, TestImportAPIFilter +from dojo.test.api.serializer import ( + TestCreateSerializer, + TestImportSerializer, + TestSerializer, + TestToFilesSerializer, + TestToNotesSerializer, + TestTypeCreateSerializer, + TestTypeSerializer, +) +from dojo.test.queries import get_authorized_test_imports, get_authorized_tests +from dojo.utils import ( + async_delete, + generate_file_response, + get_setting, + process_tag_notifications, +) + + +# Authorization: object-based +# @extend_schema_view(**schema_with_prefetch()) +# Nested models with prefetch make the response schema too long for Swagger UI +class TestsViewSet( + PrefetchDojoModelViewSet, + ra_api.AcceptedRisksMixin, +): + serializer_class = TestSerializer + queryset = Test.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiTestFilter + permission_classes = (IsAuthenticated, permissions.UserHasTestPermission) + + @property + def risk_application_model_class(self): + return Test + + def get_queryset(self): + return ( + get_authorized_tests("view") + .prefetch_related("notes", "files") + .distinct() + ) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if get_setting("ASYNC_OBJECT_DELETE"): + async_del = async_delete() + async_del.delete(instance) + else: + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_serializer_class(self): + if self.request and self.request.method == "POST": + if self.action == "accept_risks": + return ra_api.AcceptedRiskSerializer + return TestCreateSerializer + return TestSerializer + + @extend_schema( + request=api_v2_serializers.ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: api_v2_serializers.ReportGenerateSerializer}, + ) + @action( + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request, pk=None): + test = self.get_object() + + options = {} + # prepare post data + report_options = api_v2_serializers.ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, test, options) + report = api_v2_serializers.ReportGenerateSerializer(data) + return Response(report.data) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: TestToNotesSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewNoteOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.NoteSerializer}, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasTestNotePermission)) + def notes(self, request, pk=None): + test = self.get_object() + if request.method == "POST": + new_note = api_v2_serializers.AddNewNoteOptionSerializer( + data=request.data, + ) + if new_note.is_valid(): + entry = new_note.validated_data["entry"] + private = new_note.validated_data.get("private", False) + note_type = new_note.validated_data.get("note_type", None) + else: + return Response( + new_note.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + notes = test.notes.filter(note_type=note_type).first() + if notes and note_type and note_type.is_single: + return Response("Only one instance of this note_type allowed on a test.", status=status.HTTP_400_BAD_REQUEST) + + author = request.user + note = Notes( + entry=entry, + author=author, + private=private, + note_type=note_type, + ) + note.save() + # Add an entry to the note history + history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) + note.history.add(history) + # Now add the note to the object + test.notes.add(note) + # Determine if we need to send any notifications for user mentioned + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri( + reverse("view_test", args=(test.id,)), + ), + parent_title=f"Test: {test.title}", + ) + + serialized_note = api_v2_serializers.NoteSerializer( + {"author": author, "entry": entry, "private": private}, + ) + return Response( + serialized_note.data, status=status.HTTP_201_CREATED, + ) + notes = test.notes.all() + + serialized_notes = TestToNotesSerializer( + {"test_id": test, "notes": notes}, + ) + return Response(serialized_notes.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: TestToFilesSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewFileOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.FileSerializer}, + ) + @action( + detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=(IsAuthenticated, permissions.UserHasTestRelatedObjectPermission), + ) + def files(self, request, pk=None): + test = self.get_object() + if request.method == "POST": + new_file = api_v2_serializers.FileSerializer(data=request.data) + if new_file.is_valid(): + title = new_file.validated_data["title"] + file = new_file.validated_data["file"] + else: + return Response( + new_file.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + file = FileUpload(title=title, file=file) + file.save() + test.files.add(file) + + serialized_file = api_v2_serializers.FileSerializer(file) + return Response( + serialized_file.data, status=status.HTTP_201_CREATED, + ) + + files = test.files.all() + serialized_files = TestToFilesSerializer( + {"test_id": test, "files": files}, + ) + return Response(serialized_files.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: api_v2_serializers.RawFileSerializer, + }, + ) + @action( + detail=True, + methods=["get"], + url_path=r"files/download/(?P\d+)", + permission_classes=(IsAuthenticated, permissions.UserHasTestRelatedObjectPermission), + ) + def download_file(self, request, file_id, pk=None): + test = self.get_object() + # Get the file object + file_object_qs = test.files.filter(id=file_id) + file_object = ( + file_object_qs.first() if len(file_object_qs) > 0 else None + ) + if file_object is None: + return Response( + {"error": "File ID not associated with Test"}, + status=status.HTTP_404_NOT_FOUND, + ) + # send file + return generate_file_response(file_object) + + +# Authorization: authenticated, configuration +class TestTypesViewSet( + mixins.UpdateModelMixin, + mixins.CreateModelMixin, + viewsets.ReadOnlyModelViewSet, +): + serializer_class = TestTypeSerializer + queryset = Test_Type.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "name", + ] + permission_classes = (IsAuthenticated, DjangoModelPermissions) + + def get_queryset(self): + return Test_Type.objects.all().order_by("id") + + def get_serializer_class(self): + if self.action == "create": + return TestTypeCreateSerializer + return TestTypeSerializer + + +# @extend_schema_view(**schema_with_prefetch()) +# Nested models with prefetch make the response schema too long for Swagger UI +class TestImportViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = TestImportSerializer + queryset = Test_Import.objects.none() + filter_backends = (DjangoFilterBackend,) + + filterset_class = TestImportAPIFilter + + permission_classes = ( + IsAuthenticated, + permissions.UserHasTestImportPermission, + ) + + def get_queryset(self): + return get_authorized_test_imports( + "view", + ).prefetch_related( + "test_import_finding_action_set", + "findings_affected", + "findings_affected__endpoints", + "findings_affected__status_finding", + "findings_affected__finding_meta", + "findings_affected__jira_issue", + "findings_affected__burprawrequestresponse_set", + "findings_affected__jira_issue", + "findings_affected__jira_issue", + "findings_affected__jira_issue", + "findings_affected__reviewers", + "findings_affected__notes", + "findings_affected__notes__author", + "findings_affected__notes__history", + "findings_affected__files", + "findings_affected__found_by", + "findings_affected__tags", + "findings_affected__risk_acceptance_set", + "test", + "test__tags", + "test__notes", + "test__notes__author", + "test__files", + "test__test_type", + "test__engagement", + "test__environment", + "test__engagement__product", + "test__engagement__product__prod_type", + ) diff --git a/dojo/urls.py b/dojo/urls.py index 65605d9b6f3..2930b601b11 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -45,9 +45,6 @@ SonarqubeIssueTransitionViewSet, SonarqubeIssueViewSet, SystemSettingsViewSet, - TestImportViewSet, - TestsViewSet, - TestTypesViewSet, ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, @@ -86,6 +83,7 @@ from dojo.sla_config.urls import urlpatterns as sla_urls from dojo.survey.urls import urlpatterns as survey_urls from dojo.system_settings.urls import urlpatterns as system_settings_urls +from dojo.test.api.urls import add_test_urls from dojo.test.ui.urls import urlpatterns as test_urls from dojo.test_type.urls import urlpatterns as test_type_urls from dojo.tool_config.urls import urlpatterns as tool_config_urls @@ -149,9 +147,7 @@ v2_api.register(r"sonarqube_transitions", SonarqubeIssueTransitionViewSet, basename="sonarqube_issue_transition") v2_api.register(r"system_settings", SystemSettingsViewSet, basename="system_settings") v2_api.register(r"technologies", AppAnalysisViewSet, basename="app_analysis") -v2_api.register(r"tests", TestsViewSet, basename="test") -v2_api.register(r"test_types", TestTypesViewSet, basename="test_type") -v2_api.register(r"test_imports", TestImportViewSet, basename="test_imports") +v2_api = add_test_urls(v2_api) v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index c6b9d747231..4b16e26d358 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -60,8 +60,6 @@ ProductViewSet, RiskAcceptanceViewSet, SonarqubeIssueViewSet, - TestsViewSet, - TestTypesViewSet, ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, @@ -116,6 +114,7 @@ OrganizationViewSet, ) from dojo.product_type.api.views import ProductTypeViewSet +from dojo.test.api.views import TestsViewSet, TestTypesViewSet from dojo.url.api.views import URLViewSet from dojo.url.models import URL From 8266808a236c9a3a263cd8775b86dad49c8659d6 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 17:14:53 +0200 Subject: [PATCH 15/40] refactor(engagement): extract Engagement/Engagement_Presets models into dojo/engagement/ Phase 1 of module reorg per AGENTS.md. Move Engagement, Engagement_Presets + admin registrations into dojo/engagement/{models,admin}.py. Cross-module FKs use string refs to avoid circular imports; ENGAGEMENT_STATUS_CHOICES single-sourced with re-export. No migration change. --- dojo/engagement/__init__.py | 1 + dojo/engagement/admin.py | 15 +++ dojo/engagement/models.py | 184 ++++++++++++++++++++++++++++++++++++ dojo/models.py | 173 +-------------------------------- 4 files changed, 205 insertions(+), 168 deletions(-) create mode 100644 dojo/engagement/admin.py create mode 100644 dojo/engagement/models.py diff --git a/dojo/engagement/__init__.py b/dojo/engagement/__init__.py index e69de29bb2d..4dd8749c6cf 100644 --- a/dojo/engagement/__init__.py +++ b/dojo/engagement/__init__.py @@ -0,0 +1 @@ +import dojo.engagement.admin # noqa: F401 diff --git a/dojo/engagement/admin.py b/dojo/engagement/admin.py new file mode 100644 index 00000000000..921b7593b64 --- /dev/null +++ b/dojo/engagement/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from dojo.engagement.models import Engagement, Engagement_Presets + + +@admin.register(Engagement_Presets) +class EngagementPresetsAdmin(admin.ModelAdmin): + + """Admin support for the Engagement_Presets model.""" + + +@admin.register(Engagement) +class EngagementAdmin(admin.ModelAdmin): + + """Admin support for the Engagement model.""" diff --git a/dojo/engagement/models.py b/dojo/engagement/models.py new file mode 100644 index 00000000000..ec658e4f6f7 --- /dev/null +++ b/dojo/engagement/models.py @@ -0,0 +1,184 @@ +import logging +from contextlib import suppress + +from dateutil.relativedelta import relativedelta +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ +from tagulous.models import TagField + +from dojo.base_models.base import BaseModel + +logger = logging.getLogger(__name__) + + +class Engagement_Presets(models.Model): + title = models.CharField(max_length=500, default=None, help_text=_("Brief description of preset.")) + test_type = models.ManyToManyField("dojo.Test_Type", default=None, blank=True) + network_locations = models.ManyToManyField("dojo.Network_Locations", default=None, blank=True) + notes = models.CharField(max_length=2000, help_text=_("Description of what needs to be tested or setting up environment for testing"), null=True, blank=True) + scope = models.CharField(max_length=800, help_text=_("Scope of Engagement testing, IP's/Resources/URL's)"), default=None, blank=True) + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True, null=False) + + class Meta: + ordering = ["title"] + + def __str__(self): + return self.title + + +ENGAGEMENT_STATUS_CHOICES = (("Not Started", "Not Started"), + ("Blocked", "Blocked"), + ("Cancelled", "Cancelled"), + ("Completed", "Completed"), + ("In Progress", "In Progress"), + ("On Hold", "On Hold"), + ("Scheduled", "Scheduled"), + ("Waiting for Resource", "Waiting for Resource")) + + +class Engagement(BaseModel): + name = models.CharField(max_length=300, null=True, blank=True) + description = models.CharField(max_length=2000, null=True, blank=True) + version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Version of the product the engagement tested.")) + first_contacted = models.DateField(null=True, blank=True) + target_start = models.DateField(null=False, blank=False) + target_end = models.DateField(null=False, blank=False) + lead = models.ForeignKey("dojo.Dojo_User", editable=True, null=True, blank=True, on_delete=models.RESTRICT) + requester = models.ForeignKey("dojo.Contact", null=True, blank=True, on_delete=models.CASCADE) + preset = models.ForeignKey("dojo.Engagement_Presets", null=True, blank=True, help_text=_("Settings and notes for performing this engagement."), on_delete=models.CASCADE) + reason = models.CharField(max_length=2000, null=True, blank=True) + report_type = models.ForeignKey("dojo.Report_Type", null=True, blank=True, on_delete=models.CASCADE) + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE) + active = models.BooleanField(default=True, editable=False) + tracker = models.URLField(max_length=200, help_text=_("Link to epic or ticket system with changes to version."), editable=True, blank=True, null=True) + test_strategy = models.URLField(editable=True, blank=True, null=True) + threat_model = models.BooleanField(default=True) + api_test = models.BooleanField(default=True) + pen_test = models.BooleanField(default=True) + check_list = models.BooleanField(default=True) + notes = models.ManyToManyField("dojo.Notes", blank=True, editable=False) + files = models.ManyToManyField("dojo.FileUpload", blank=True, editable=False) + status = models.CharField(editable=True, max_length=2000, default="Not Started", + null=True, + choices=ENGAGEMENT_STATUS_CHOICES) + progress = models.CharField(max_length=100, + default="threat_model", editable=False) + tmodel_path = models.CharField(max_length=1000, default="none", + editable=False, blank=True, null=True) + risk_acceptance = models.ManyToManyField("dojo.Risk_Acceptance", + default=None, + editable=False, + blank=True) + done_testing = models.BooleanField(default=False, editable=False) + engagement_type = models.CharField(editable=True, max_length=30, default="Interactive", + null=True, + choices=(("Interactive", "Interactive"), + ("CI/CD", "CI/CD"))) + build_id = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Build ID of the product the engagement tested."), verbose_name=_("Build ID")) + commit_hash = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Commit hash from repo"), verbose_name=_("Commit Hash")) + branch_tag = models.CharField(editable=True, max_length=150, + null=True, blank=True, help_text=_("Tag or branch of the product the engagement tested."), verbose_name=_("Branch/Tag")) + build_server = models.ForeignKey("dojo.Tool_Configuration", verbose_name=_("Build Server"), help_text=_("Build server responsible for CI/CD test"), null=True, blank=True, related_name="build_server", on_delete=models.CASCADE) + source_code_management_server = models.ForeignKey("dojo.Tool_Configuration", null=True, blank=True, verbose_name=_("SCM Server"), help_text=_("Source code server for CI/CD test"), related_name="source_code_management_server", on_delete=models.CASCADE) + source_code_management_uri = models.URLField(max_length=600, null=True, blank=True, editable=True, verbose_name=_("Repo"), help_text=_("Resource link to source code")) + orchestration_engine = models.ForeignKey("dojo.Tool_Configuration", verbose_name=_("Orchestration Engine"), help_text=_("Orchestration service responsible for CI/CD test"), null=True, blank=True, related_name="orchestration", on_delete=models.CASCADE) + deduplication_on_engagement = models.BooleanField(default=False, verbose_name=_("Deduplication within this engagement only"), help_text=_("If enabled deduplication will only mark a finding in this engagement as duplicate of another finding if both findings are in this engagement. If disabled, deduplication is on the product level.")) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this engagement. Choose from the list or add new tags. Press Enter key to add.")) + inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) + + class Meta: + ordering = ["-target_start"] + indexes = [ + models.Index(fields=["product", "active"]), + ] + + def __str__(self): + return "Engagement {}: {} ({})".format(self.id if id else 0, self.name or "", + self.target_start.strftime( + "%b %d, %Y")) + + def get_absolute_url(self): + return reverse("view_engagement", args=[str(self.id)]) + + def copy(self): + from dojo.models import Test, copy_model_util # noqa: PLC0415 -- lazy import, avoids circular dependency + copy = copy_model_util(self) + # Save the necessary ManyToMany relationships + old_notes = list(self.notes.all()) + old_files = list(self.files.all()) + old_tags = list(self.tags.all()) + old_risk_acceptances = list(self.risk_acceptance.all()) + old_tests = list(Test.objects.filter(engagement=self)) + # Save the object before setting any ManyToMany relationships + copy.save() + # Copy the notes + for notes in old_notes: + copy.notes.add(notes.copy()) + # Copy the files + for files in old_files: + copy.files.add(files.copy()) + # Copy the tests + for test in old_tests: + test.copy(engagement=copy) + # Copy the risk_acceptances + for risk_acceptance in old_risk_acceptances: + copy.risk_acceptance.add(risk_acceptance.copy(engagement=copy)) + # Assign any tags + copy.tags.set(old_tags) + + return copy + + def is_overdue(self): + overdue_grace_days = 10 if self.engagement_type == "CI/CD" else 0 + + max_end_date = timezone.now() - relativedelta(days=overdue_grace_days) + + return self.target_end < max_end_date.date() + + def get_breadcrumbs(self): + bc = self.product.get_breadcrumbs() + bc += [{"title": str(self), + "url": reverse("view_engagement", args=(self.id,))}] + return bc + + # only used by bulk risk acceptance api + @property + def unaccepted_open_findings(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + from dojo.utils import get_system_setting # noqa: PLC0415 circular import + + findings = Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test__engagement=self) + if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): + findings = findings.filter(verified=True) + + return findings + + def accept_risks(self, accepted_risks): + self.risk_acceptance.add(*accepted_risks) + + @property + def has_jira_issue(self): + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_issue(self) + + @property + def is_ci_cd(self): + return self.engagement_type == "CI/CD" + + def delete(self, *args, **kwargs): + logger.debug("%d engagement delete", self.id) + from dojo.finding import helper as finding_helper # noqa: PLC0415 circular import + finding_helper.prepare_duplicates_for_delete(self) + super().delete(*args, **kwargs) + from dojo.models import Product # noqa: PLC0415 -- lazy import, avoids circular dependency + with suppress(Engagement.DoesNotExist, Product.DoesNotExist): + # Suppressing a potential issue created from async delete removing + # related objects in a separate task + from dojo.utils import perform_product_grading # noqa: PLC0415 circular import + perform_product_grading(self.product) diff --git a/dojo/models.py b/dojo/models.py index dd833e0a427..00d53ad3ba5 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1313,172 +1313,11 @@ def __str__(self): return self.location -class Engagement_Presets(models.Model): - title = models.CharField(max_length=500, default=None, help_text=_("Brief description of preset.")) - test_type = models.ManyToManyField(Test_Type, default=None, blank=True) - network_locations = models.ManyToManyField(Network_Locations, default=None, blank=True) - notes = models.CharField(max_length=2000, help_text=_("Description of what needs to be tested or setting up environment for testing"), null=True, blank=True) - scope = models.CharField(max_length=800, help_text=_("Scope of Engagement testing, IP's/Resources/URL's)"), default=None, blank=True) - product = models.ForeignKey(Product, on_delete=models.CASCADE) - created = models.DateTimeField(auto_now_add=True, null=False) - - class Meta: - ordering = ["title"] - - def __str__(self): - return self.title - - -ENGAGEMENT_STATUS_CHOICES = (("Not Started", "Not Started"), - ("Blocked", "Blocked"), - ("Cancelled", "Cancelled"), - ("Completed", "Completed"), - ("In Progress", "In Progress"), - ("On Hold", "On Hold"), - ("Scheduled", "Scheduled"), - ("Waiting for Resource", "Waiting for Resource")) - - -class Engagement(BaseModel): - name = models.CharField(max_length=300, null=True, blank=True) - description = models.CharField(max_length=2000, null=True, blank=True) - version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Version of the product the engagement tested.")) - first_contacted = models.DateField(null=True, blank=True) - target_start = models.DateField(null=False, blank=False) - target_end = models.DateField(null=False, blank=False) - lead = models.ForeignKey(Dojo_User, editable=True, null=True, blank=True, on_delete=models.RESTRICT) - requester = models.ForeignKey(Contact, null=True, blank=True, on_delete=models.CASCADE) - preset = models.ForeignKey(Engagement_Presets, null=True, blank=True, help_text=_("Settings and notes for performing this engagement."), on_delete=models.CASCADE) - reason = models.CharField(max_length=2000, null=True, blank=True) - report_type = models.ForeignKey(Report_Type, null=True, blank=True, on_delete=models.CASCADE) - product = models.ForeignKey(Product, on_delete=models.CASCADE) - active = models.BooleanField(default=True, editable=False) - tracker = models.URLField(max_length=200, help_text=_("Link to epic or ticket system with changes to version."), editable=True, blank=True, null=True) - test_strategy = models.URLField(editable=True, blank=True, null=True) - threat_model = models.BooleanField(default=True) - api_test = models.BooleanField(default=True) - pen_test = models.BooleanField(default=True) - check_list = models.BooleanField(default=True) - notes = models.ManyToManyField(Notes, blank=True, editable=False) - files = models.ManyToManyField(FileUpload, blank=True, editable=False) - status = models.CharField(editable=True, max_length=2000, default="Not Started", - null=True, - choices=ENGAGEMENT_STATUS_CHOICES) - progress = models.CharField(max_length=100, - default="threat_model", editable=False) - tmodel_path = models.CharField(max_length=1000, default="none", - editable=False, blank=True, null=True) - risk_acceptance = models.ManyToManyField("Risk_Acceptance", - default=None, - editable=False, - blank=True) - done_testing = models.BooleanField(default=False, editable=False) - engagement_type = models.CharField(editable=True, max_length=30, default="Interactive", - null=True, - choices=(("Interactive", "Interactive"), - ("CI/CD", "CI/CD"))) - build_id = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Build ID of the product the engagement tested."), verbose_name=_("Build ID")) - commit_hash = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Commit hash from repo"), verbose_name=_("Commit Hash")) - branch_tag = models.CharField(editable=True, max_length=150, - null=True, blank=True, help_text=_("Tag or branch of the product the engagement tested."), verbose_name=_("Branch/Tag")) - build_server = models.ForeignKey(Tool_Configuration, verbose_name=_("Build Server"), help_text=_("Build server responsible for CI/CD test"), null=True, blank=True, related_name="build_server", on_delete=models.CASCADE) - source_code_management_server = models.ForeignKey(Tool_Configuration, null=True, blank=True, verbose_name=_("SCM Server"), help_text=_("Source code server for CI/CD test"), related_name="source_code_management_server", on_delete=models.CASCADE) - source_code_management_uri = models.URLField(max_length=600, null=True, blank=True, editable=True, verbose_name=_("Repo"), help_text=_("Resource link to source code")) - orchestration_engine = models.ForeignKey(Tool_Configuration, verbose_name=_("Orchestration Engine"), help_text=_("Orchestration service responsible for CI/CD test"), null=True, blank=True, related_name="orchestration", on_delete=models.CASCADE) - deduplication_on_engagement = models.BooleanField(default=False, verbose_name=_("Deduplication within this engagement only"), help_text=_("If enabled deduplication will only mark a finding in this engagement as duplicate of another finding if both findings are in this engagement. If disabled, deduplication is on the product level.")) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this engagement. Choose from the list or add new tags. Press Enter key to add.")) - inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) - - class Meta: - ordering = ["-target_start"] - indexes = [ - models.Index(fields=["product", "active"]), - ] - - def __str__(self): - return "Engagement {}: {} ({})".format(self.id if id else 0, self.name or "", - self.target_start.strftime( - "%b %d, %Y")) - - def get_absolute_url(self): - return reverse("view_engagement", args=[str(self.id)]) - - def copy(self): - copy = copy_model_util(self) - # Save the necessary ManyToMany relationships - old_notes = list(self.notes.all()) - old_files = list(self.files.all()) - old_tags = list(self.tags.all()) - old_risk_acceptances = list(self.risk_acceptance.all()) - old_tests = list(Test.objects.filter(engagement=self)) - # Save the object before setting any ManyToMany relationships - copy.save() - # Copy the notes - for notes in old_notes: - copy.notes.add(notes.copy()) - # Copy the files - for files in old_files: - copy.files.add(files.copy()) - # Copy the tests - for test in old_tests: - test.copy(engagement=copy) - # Copy the risk_acceptances - for risk_acceptance in old_risk_acceptances: - copy.risk_acceptance.add(risk_acceptance.copy(engagement=copy)) - # Assign any tags - copy.tags.set(old_tags) - - return copy - - def is_overdue(self): - overdue_grace_days = 10 if self.engagement_type == "CI/CD" else 0 - - max_end_date = timezone.now() - relativedelta(days=overdue_grace_days) - - return self.target_end < max_end_date.date() - - def get_breadcrumbs(self): - bc = self.product.get_breadcrumbs() - bc += [{"title": str(self), - "url": reverse("view_engagement", args=(self.id,))}] - return bc - - # only used by bulk risk acceptance api - @property - def unaccepted_open_findings(self): - from dojo.utils import get_system_setting # noqa: PLC0415 circular import - - findings = Finding.objects.filter(risk_accepted=False, active=True, duplicate=False, test__engagement=self) - if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): - findings = findings.filter(verified=True) - - return findings - - def accept_risks(self, accepted_risks): - self.risk_acceptance.add(*accepted_risks) - - @property - def has_jira_issue(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_issue(self) - - @property - def is_ci_cd(self): - return self.engagement_type == "CI/CD" - - def delete(self, *args, **kwargs): - logger.debug("%d engagement delete", self.id) - from dojo.finding import helper as finding_helper # noqa: PLC0415 circular import - finding_helper.prepare_duplicates_for_delete(self) - super().delete(*args, **kwargs) - with suppress(Engagement.DoesNotExist, Product.DoesNotExist): - # Suppressing a potential issue created from async delete removing - # related objects in a separate task - from dojo.utils import perform_product_grading # noqa: PLC0415 circular import - perform_product_grading(self.product) +from dojo.engagement.models import ( # noqa: E402 -- re-export; class-body FKs below reference these + ENGAGEMENT_STATUS_CHOICES, # noqa: F401 -- re-export + Engagement, + Engagement_Presets, # noqa: F401 -- re-export +) class CWE(models.Model): @@ -4095,7 +3934,6 @@ def __str__(self): admin.site.register(Testing_Guide_Category) admin.site.register(Testing_Guide) -admin.site.register(Engagement_Presets) admin.site.register(Network_Locations) admin.site.register(Objects_Product) admin.site.register(Objects_Review) @@ -4105,7 +3943,6 @@ def __str__(self): admin.site.register(Finding, FindingAdmin) admin.site.register(FileUpload) admin.site.register(FileAccessToken) -admin.site.register(Engagement) admin.site.register(Risk_Acceptance) admin.site.register(Check_List) admin.site.register(Endpoint_Params) From 59ac54ba557db8b9ca0079a882426f9b2d7c2cd4 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 19:12:10 +0200 Subject: [PATCH 16/40] refactor(engagement): extract copy_engagement workflow into services.py [Phase 2 pilot] Phase 2 of module reorg per AGENTS.md. Move the engagement copy workflow (copy + product grade recalc + notification) out of the copy_engagement view into an HTTP-free copy_engagement(engagement, user) service, so both UI and (future) API can reuse it. The inline notification carried a TODO asking for exactly this. View is thinned to call the service. Add a unit test for the service (the workflow was previously untested). Notification URL uses relative reverse() to match the codebase convention. --- dojo/engagement/services.py | 26 ++++++++++++++++++++++++++ dojo/engagement/views.py | 21 ++++++++------------- unittests/test_copy_model.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/dojo/engagement/services.py b/dojo/engagement/services.py index b78844fc6bc..6a5b7570103 100644 --- a/dojo/engagement/services.py +++ b/dojo/engagement/services.py @@ -3,10 +3,14 @@ from django.db.models.signals import pre_save from django.dispatch import receiver +from django.urls import reverse +from django.utils.translation import gettext as _ from dojo.celery_dispatch import dojo_dispatch_task from dojo.jira import services as jira_services from dojo.models import Engagement +from dojo.notifications.helper import create_notification +from dojo.utils import calculate_grade logger = logging.getLogger(__name__) @@ -28,6 +32,28 @@ def reopen_engagement(eng): eng.save() +def copy_engagement(engagement, user): + """ + Copy an engagement (and its tests/findings) within the same product, recalculate the + product grade, and notify. Returns the new engagement. + + HTTP-free so both the UI view and (eventually) the API can call it. + """ + product = engagement.product + engagement_copy = engagement.copy() + dojo_dispatch_task(calculate_grade, product.id) + create_notification( + event="engagement_copied", + title=_("Copying of %s") % engagement.name, + description=f'The engagement "{engagement.name}" was copied by {user}', + product=product, + url=reverse("view_engagement", args=(engagement_copy.id,)), + recipients=[engagement.lead], + icon="exclamation-triangle", + ) + return engagement_copy + + @receiver(pre_save, sender=Engagement) def set_name_if_none(sender, instance, *args, **kwargs): if not instance.name: diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py index 0154aa5d336..8ec3693aa4f 100644 --- a/dojo/engagement/views.py +++ b/dojo/engagement/views.py @@ -24,7 +24,6 @@ from django.shortcuts import get_object_or_404, render from django.urls import Resolver404, reverse from django.utils import timezone -from django.utils.translation import gettext as _ from django.views import View from django.views.decorators.cache import cache_page from django.views.decorators.http import require_POST @@ -34,10 +33,15 @@ import dojo.risk_acceptance.helper as ra_helper from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.utils import save_endpoints_to_add from dojo.engagement.queries import get_authorized_engagements -from dojo.engagement.services import close_engagement, reopen_engagement +from dojo.engagement.services import ( + close_engagement, + reopen_engagement, +) +from dojo.engagement.services import ( + copy_engagement as copy_engagement_service, +) from dojo.filters import ( EngagementDirectFilter, EngagementDirectFilterWithoutObjectLookups, @@ -107,7 +111,6 @@ add_error_message_to_response, add_success_message_to_response, async_delete, - calculate_grade, generate_file_response_from_file_path, get_cal_event, get_page_items, @@ -391,20 +394,12 @@ def copy_engagement(request, eid): if request.method == "POST": form = DoneForm(request.POST) if form.is_valid(): - engagement_copy = engagement.copy() - dojo_dispatch_task(calculate_grade, product.id) + copy_engagement_service(engagement, request.user) messages.add_message( request, messages.SUCCESS, "Engagement Copied successfully.", extra_tags="alert-success") - create_notification(event="engagement_copied", # TODO: - if 'copy' functionality will be supported by API as well, 'create_notification' needs to be migrated to place where it will be able to cover actions from both interfaces - title=_("Copying of %s") % engagement.name, - description=f'The engagement "{engagement.name}" was copied by {request.user}', - product=product, - url=request.build_absolute_uri(reverse("view_engagement", args=(engagement_copy.id, ))), - recipients=[engagement.lead], - icon="exclamation-triangle") return redirect_to_return_url_or_else(request, reverse("view_engagements", args=(product.id, ))) messages.add_message( request, diff --git a/unittests/test_copy_model.py b/unittests/test_copy_model.py index d67246630cc..2b262847ac5 100644 --- a/unittests/test_copy_model.py +++ b/unittests/test_copy_model.py @@ -1,6 +1,7 @@ from unittest.mock import patch +from dojo.engagement.services import copy_engagement from dojo.location.models import Location, LocationFindingReference from dojo.models import Endpoint, Endpoint_Status, Engagement, Finding, Product, Test, User from dojo.test.services import copy_test @@ -389,3 +390,32 @@ def test_duplicate_engagement_with_tags_and_notes(self): self.assertQuerySetEqual(engagement.notes.all(), engagement_copy.notes.all()) # Do the tags match self.assertEqual(engagement.tags, engagement_copy.tags) + + +class TestCopyEngagementService(DojoTestCase): + + """Phase 2: the copy_engagement service holds the copy workflow extracted from the UI view.""" + + @patch("dojo.engagement.services.create_notification") + @patch("dojo.engagement.services.dojo_dispatch_task") + def test_copy_engagement_service(self, mock_dispatch, mock_notification): + user, _ = User.objects.get_or_create(username="admin") + product_type = self.create_product_type("svc_prod_type") + product = self.create_product("svc_copy_product", prod_type=product_type) + engagement = self.create_engagement("svc_eng", product) + test = self.create_test(engagement=engagement, scan_type="NPM Audit Scan", title="test") + _ = Finding.objects.create(test=test, reporter=user) + before = Engagement.objects.filter(product=product).count() + before_findings = Finding.objects.filter(test__engagement__product=product).count() + # Run the service + engagement_copy = copy_engagement(engagement, user) + # A new engagement was created under the same product + self.assertEqual(before + 1, Engagement.objects.filter(product=product).count()) + self.assertNotEqual(engagement.id, engagement_copy.id) + self.assertEqual(product, engagement_copy.product) + # Findings were duplicated along with the engagement + self.assertEqual(before_findings + 1, Finding.objects.filter(test__engagement__product=product).count()) + # Side effects: grade recalculation dispatched and a notification raised + mock_dispatch.assert_called_once() + mock_notification.assert_called_once() + self.assertEqual(mock_notification.call_args.kwargs["event"], "engagement_copied") From 4bd08a9e07a83afe34b977e60c61b1ee6ee5cb77 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 21:13:45 +0200 Subject: [PATCH 17/40] refactor(engagement): move forms + UI filters into dojo/engagement/ui/ [engagement Phase 3,4] --- dojo/engagement/ui/__init__.py | 0 dojo/engagement/ui/filters.py | 399 +++++++++++++++++++++++++++++++++ dojo/engagement/ui/forms.py | 151 +++++++++++++ dojo/engagement/views.py | 5 +- dojo/filters.py | 374 ------------------------------ dojo/forms.py | 153 +------------ dojo/product/views.py | 8 +- 7 files changed, 567 insertions(+), 523 deletions(-) create mode 100644 dojo/engagement/ui/__init__.py create mode 100644 dojo/engagement/ui/filters.py create mode 100644 dojo/engagement/ui/forms.py diff --git a/dojo/engagement/ui/__init__.py b/dojo/engagement/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/engagement/ui/filters.py b/dojo/engagement/ui/filters.py new file mode 100644 index 00000000000..055da964976 --- /dev/null +++ b/dojo/engagement/ui/filters.py @@ -0,0 +1,399 @@ +from django.conf import settings +from django_filters import ( + BooleanFilter, + CharFilter, + FilterSet, + ModelChoiceFilter, + ModelMultipleChoiceFilter, + MultipleChoiceFilter, + OrderingFilter, +) + +from dojo.filters import DateRangeFilter, DojoFilter +from dojo.labels import get_labels +from dojo.models import ( + ENGAGEMENT_STATUS_CHOICES, + Dojo_User, + Engagement, + Product, + Product_API_Scan_Configuration, + Product_Type, + Test, + Test_Type, +) +from dojo.product_type.queries import get_authorized_product_types +from dojo.user.queries import get_authorized_users + +labels = get_labels() + + +class EngagementDirectFilterHelper(FilterSet): + name = CharFilter(lookup_expr="icontains", label="Engagement name contains") + version = CharFilter(field_name="version", lookup_expr="icontains", label="Engagement version") + test__version = CharFilter(field_name="test__version", lookup_expr="icontains", label="Test version") + product__name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) + status = MultipleChoiceFilter(choices=ENGAGEMENT_STATUS_CHOICES, label="Status") + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + target_start = DateRangeFilter() + target_end = DateRangeFilter() + test__engagement__product__lifecycle = MultipleChoiceFilter( + choices=Product.LIFECYCLE_CHOICES, + label=labels.ASSET_LIFECYCLE_LABEL, + null_label="Empty") + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("target_start", "target_start"), + ("name", "name"), + ("product__name", "product__name"), + ("product__prod_type__name", "product__prod_type__name"), + ("lead__first_name", "lead__first_name"), + ), + field_labels={ + "target_start": "Start date", + "name": "Engagement", + "product__name": labels.ASSET_FILTERS_NAME_LABEL, + "product__prod_type__name": labels.ORG_FILTERS_LABEL, + "lead__first_name": "Lead", + }, + ) + + +class EngagementDirectFilter(EngagementDirectFilterHelper, DojoFilter): + lead = ModelChoiceFilter(queryset=Dojo_User.objects.none(), label="Lead") + product__prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["product__prod_type"].queryset = get_authorized_product_types("view") + self.form.fields["lead"].queryset = get_authorized_users("view") \ + .filter(engagement__lead__isnull=False).distinct() + + class Meta: + model = Engagement + fields = ["product__name", "product__prod_type"] + + +class EngagementDirectFilterWithoutObjectLookups(EngagementDirectFilterHelper): + lead = CharFilter( + field_name="lead__username", + lookup_expr="iexact", + label="Lead Username", + help_text="Search for Lead username that are an exact match") + lead_contains = CharFilter( + field_name="lead__username", + lookup_expr="icontains", + label="Lead Username Contains", + help_text="Search for Lead username that contain a given pattern") + product__prod_type__name = CharFilter( + field_name="product__prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_NAME_LABEL, + help_text=labels.ORG_FILTERS_NAME_HELP) + product__prod_type__name_contains = CharFilter( + field_name="product__prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + + class Meta: + model = Engagement + fields = ["product__name"] + + +class EngagementFilterHelper(FilterSet): + name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + engagement__name = CharFilter(lookup_expr="icontains", label="Engagement name contains") + engagement__version = CharFilter(field_name="engagement__version", lookup_expr="icontains", label="Engagement version") + engagement__test__version = CharFilter(field_name="engagement__test__version", lookup_expr="icontains", label="Test version") + engagement__product__lifecycle = MultipleChoiceFilter( + choices=Product.LIFECYCLE_CHOICES, + label=labels.ASSET_LIFECYCLE_LABEL, + null_label="Empty") + engagement__status = MultipleChoiceFilter( + choices=ENGAGEMENT_STATUS_CHOICES, + label="Status") + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ("prod_type__name", "prod_type__name"), + ), + field_labels={ + "name": labels.ASSET_FILTERS_NAME_LABEL, + "prod_type__name": labels.ORG_FILTERS_LABEL, + }, + ) + + +class EngagementFilter(EngagementFilterHelper, DojoFilter): + engagement__lead = ModelChoiceFilter( + queryset=Dojo_User.objects.none(), + label="Lead") + prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["prod_type"].queryset = get_authorized_product_types("view") + self.form.fields["engagement__lead"].queryset = get_authorized_users("view") \ + .filter(engagement__lead__isnull=False).distinct() + self.form.fields["tags"].help_text = labels.ASSET_FILTERS_TAGS_HELP + self.form.fields["not_tags"].help_text = labels.ASSET_FILTERS_NOT_TAGS_HELP + + class Meta: + model = Product + fields = ["name", "prod_type"] + + +class ProductEngagementsFilter(DojoFilter): + engagement__name = CharFilter(field_name="name", lookup_expr="icontains", label="Engagement name contains") + engagement__lead = ModelChoiceFilter(field_name="lead", queryset=Dojo_User.objects.none(), label="Lead") + engagement__version = CharFilter(field_name="version", lookup_expr="icontains", label="Engagement version") + engagement__test__version = CharFilter(field_name="test__version", lookup_expr="icontains", label="Test version") + engagement__status = MultipleChoiceFilter(field_name="status", choices=ENGAGEMENT_STATUS_CHOICES, + label="Status") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["engagement__lead"].queryset = get_authorized_users("view") \ + .filter(engagement__lead__isnull=False).distinct() + + class Meta: + model = Engagement + fields = [] + + +class ProductEngagementsFilterWithoutObjectLookups(ProductEngagementsFilter): + engagement__lead = CharFilter( + field_name="lead__username", + lookup_expr="iexact", + label="Lead Username", + help_text="Search for Lead username that are an exact match") + + +class EngagementFilterWithoutObjectLookups(EngagementFilterHelper): + engagement__lead = CharFilter( + field_name="engagement__lead__username", + lookup_expr="iexact", + label="Lead Username", + help_text="Search for Lead username that are an exact match") + engagement__lead_contains = CharFilter( + field_name="engagement__lead__username", + lookup_expr="icontains", + label="Lead Username Contains", + help_text="Search for Lead username that contain a given pattern") + prod_type__name = CharFilter( + field_name="prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_LABEL, + help_text=labels.ORG_FILTERS_LABEL_HELP) + prod_type__name_contains = CharFilter( + field_name="prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + + class Meta: + model = Product + fields = ["name"] + + +class ProductEngagementFilterHelper(FilterSet): + version = CharFilter(lookup_expr="icontains", label="Engagement version") + test__version = CharFilter(field_name="test__version", lookup_expr="icontains", label="Test version") + name = CharFilter(lookup_expr="icontains") + status = MultipleChoiceFilter(choices=ENGAGEMENT_STATUS_CHOICES, label="Status") + target_start = DateRangeFilter() + target_end = DateRangeFilter() + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ("version", "version"), + ("target_start", "target_start"), + ("target_end", "target_end"), + ("status", "status"), + ("lead", "lead"), + ), + field_labels={ + "name": "Engagement Name", + }, + ) + + class Meta: + model = Product + fields = ["name"] + + +class ProductEngagementFilter(ProductEngagementFilterHelper, DojoFilter): + lead = ModelChoiceFilter(queryset=Dojo_User.objects.none(), label="Lead") + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["lead"].queryset = get_authorized_users( + "view").filter(engagement__lead__isnull=False).distinct() + + +class ProductEngagementFilterWithoutObjectLookups(ProductEngagementFilterHelper, DojoFilter): + lead = CharFilter( + field_name="lead__username", + lookup_expr="iexact", + label="Lead Username", + help_text="Search for Lead username that are an exact match") + lead_contains = CharFilter( + field_name="lead__username", + lookup_expr="icontains", + label="Lead Username Contains", + help_text="Search for Lead username that contain a given pattern") + + +class EngagementTestFilterHelper(FilterSet): + version = CharFilter(lookup_expr="icontains", label="Version") + if settings.TRACK_IMPORT_HISTORY: + test_import__version = CharFilter(field_name="test_import__version", lookup_expr="icontains", label="Reimported Version") + target_start = DateRangeFilter() + target_end = DateRangeFilter() + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("title", "title"), + ("version", "version"), + ("target_start", "target_start"), + ("target_end", "target_end"), + ("lead", "lead"), + ("api_scan_configuration", "api_scan_configuration"), + ), + field_labels={ + "name": "Test Name", + }, + ) + + +class EngagementTestFilter(EngagementTestFilterHelper, DojoFilter): + lead = ModelChoiceFilter(queryset=Dojo_User.objects.none(), label="Lead") + api_scan_configuration = ModelChoiceFilter( + queryset=Product_API_Scan_Configuration.objects.none(), + label="API Scan Configuration") + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + queryset=Test.tags.tag_model.objects.all().order_by("name")) + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Test.tags.tag_model.objects.all().order_by("name")) + + class Meta: + model = Test + fields = [ + "title", "test_type", "target_start", + "target_end", "percent_complete", + "version", "api_scan_configuration", + ] + + def __init__(self, *args, **kwargs): + self.engagement = kwargs.pop("engagement") + super(DojoFilter, self).__init__(*args, **kwargs) + self.form.fields["test_type"].queryset = Test_Type.objects.filter(test__engagement=self.engagement).distinct().order_by("name") + self.form.fields["api_scan_configuration"].queryset = Product_API_Scan_Configuration.objects.filter(product=self.engagement.product).distinct() + self.form.fields["lead"].queryset = get_authorized_users("view") \ + .filter(test__lead__isnull=False).distinct() + + +class EngagementTestFilterWithoutObjectLookups(EngagementTestFilterHelper): + lead = CharFilter( + field_name="lead__username", + lookup_expr="iexact", + label="Lead Username", + help_text="Search for Lead username that are an exact match") + lead_contains = CharFilter( + field_name="lead__username", + lookup_expr="icontains", + label="Lead Username Contains", + help_text="Search for Lead username that contain a given pattern") + api_scan_configuration__tool_configuration__name = CharFilter( + field_name="api_scan_configuration__tool_configuration__name", + lookup_expr="iexact", + label="API Scan Configuration Name", + help_text="Search for Lead username that are an exact match") + api_scan_configuration__tool_configuration__name_contains = CharFilter( + field_name="api_scan_configuration__tool_configuration__name", + lookup_expr="icontains", + label="API Scan Configuration Name Contains", + help_text="Search for Lead username that contain a given pattern") + tags_contains = CharFilter( + label="Test Tag Contains", + field_name="tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Test that contain a given pattern") + tags = CharFilter( + label="Test Tag", + field_name="tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Test that are an exact match") + not_tags_contains = CharFilter( + label="Test Tag Does Not Contain", + field_name="tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Test that contain a given pattern, and exclude them", + exclude=True) + not_tags = CharFilter( + label="Not Test Tag", + field_name="tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Test that are an exact match, and exclude them", + exclude=True) + + class Meta: + model = Test + fields = [ + "title", "test_type", "target_start", + "target_end", "percent_complete", "version", + ] + + def __init__(self, *args, **kwargs): + self.engagement = kwargs.pop("engagement") + super().__init__(*args, **kwargs) + self.form.fields["test_type"].queryset = Test_Type.objects.filter(test__engagement=self.engagement).distinct().order_by("name") diff --git a/dojo/engagement/ui/forms.py b/dojo/engagement/ui/forms.py new file mode 100644 index 00000000000..a325e9a3d69 --- /dev/null +++ b/dojo/engagement/ui/forms.py @@ -0,0 +1,151 @@ +from django import forms + +from dojo.engagement.queries import get_authorized_engagements +from dojo.labels import get_labels +from dojo.models import Engagement, Engagement_Presets, Product +from dojo.product.queries import get_authorized_products +from dojo.user.queries import get_authorized_users, get_authorized_users_for_product_and_product_type +from dojo.utils import get_system_setting +from dojo.validators import tag_validator + +labels = get_labels() + + +class EngForm(forms.ModelForm): + name = forms.CharField( + max_length=300, required=False, + help_text=( + "Add a descriptive name to identify this engagement. " + "Without a name the target start date will be set." + )) + description = forms.CharField(widget=forms.Textarea(attrs={}), + required=False, help_text="Description of the engagement and details regarding the engagement.") + product = forms.ModelChoiceField(label=labels.ASSET_LABEL, + queryset=Product.objects.none(), + required=True) + target_start = forms.DateField(widget=forms.TextInput( + attrs={"class": "datepicker", "autocomplete": "off"})) + target_end = forms.DateField(widget=forms.TextInput( + attrs={"class": "datepicker", "autocomplete": "off"})) + lead = forms.ModelChoiceField( + queryset=None, + required=True, label="Testing Lead") + test_strategy = forms.URLField(required=False, label="Test Strategy URL") + + def __init__(self, *args, **kwargs): + cicd = False + product = None + if "cicd" in kwargs: + cicd = kwargs.pop("cicd") + + if "product" in kwargs: + product = kwargs.pop("product") + + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + + super().__init__(*args, **kwargs) + + if product: + self.fields["preset"] = forms.ModelChoiceField(help_text="Settings and notes for performing this engagement.", required=False, queryset=Engagement_Presets.objects.filter(product=product)) + self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, "view").filter(is_active=True) + else: + self.fields["lead"].queryset = get_authorized_users("view").filter(is_active=True) + + self.fields["product"].queryset = get_authorized_products("add") + + # Don't show CICD fields on a interactive engagement + if cicd is False: + del self.fields["build_id"] + del self.fields["commit_hash"] + del self.fields["branch_tag"] + del self.fields["build_server"] + del self.fields["source_code_management_server"] + # del self.fields['source_code_management_uri'] + del self.fields["orchestration_engine"] + else: + del self.fields["test_strategy"] + del self.fields["status"] + + def is_valid(self): + valid = super().is_valid() + + # we're done now if not valid + if not valid: + return valid + if self.cleaned_data["target_start"] > self.cleaned_data["target_end"]: + self.add_error("target_start", "Your target start date exceeds your target end date") + self.add_error("target_end", "Your target start date exceeds your target end date") + return False + return True + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Engagement + exclude = ("first_contacted", "real_start", "engagement_type", "inherited_tags", + "real_end", "requester", "reason", "updated", "report_type", + "product", "threat_model", "api_test", "pen_test", "check_list") + + +class DeleteEngagementForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Engagement + fields = ["id"] + + +class EngagementPresetsForm(forms.ModelForm): + + notes = forms.CharField(widget=forms.Textarea(attrs={}), + required=False, help_text="Description of what needs to be tested or setting up environment for testing") + + scope = forms.CharField(widget=forms.Textarea(attrs={}), + required=False, help_text="Scope of Engagement testing, IP's/Resources/URL's)") + + class Meta: + model = Engagement_Presets + exclude = ["product"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + +class DeleteEngagementPresetsForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Engagement_Presets + fields = ["id"] + + +class AddEngagementForm(forms.Form): + product = forms.ModelChoiceField( + queryset=Product.objects.none(), + required=True, + widget=forms.widgets.Select(), + help_text="Select which product to attach Engagement") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["product"].queryset = get_authorized_products("add") + + +class ExistingEngagementForm(forms.Form): + engagement = forms.ModelChoiceField( + queryset=Engagement.objects.none(), + required=True, + widget=forms.widgets.Select(), + help_text="Select which Engagement to link the Questionnaire to") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["engagement"].queryset = get_authorized_engagements("edit").order_by("-target_start") diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py index 8ec3693aa4f..4c0978dea56 100644 --- a/dojo/engagement/views.py +++ b/dojo/engagement/views.py @@ -42,7 +42,7 @@ from dojo.engagement.services import ( copy_engagement as copy_engagement_service, ) -from dojo.filters import ( +from dojo.engagement.ui.filters import ( EngagementDirectFilter, EngagementDirectFilterWithoutObjectLookups, EngagementFilter, @@ -52,15 +52,14 @@ ProductEngagementsFilter, ProductEngagementsFilterWithoutObjectLookups, ) +from dojo.engagement.ui.forms import DeleteEngagementForm, EngForm from dojo.finding.helper import NOT_ACCEPTED_FINDINGS_QUERY from dojo.finding.views import find_available_notetypes from dojo.forms import ( AddFindingsRiskAcceptanceForm, CheckForm, - DeleteEngagementForm, DoneForm, EditRiskAcceptanceForm, - EngForm, ImportScanForm, JIRAEngagementForm, JIRAImportScanForm, diff --git a/dojo/filters.py b/dojo/filters.py index 14af254bb69..21357ca5b9e 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -56,7 +56,6 @@ from dojo.location.status import FindingLocationStatus, ProductLocationStatus from dojo.models import ( EFFORT_FOR_FIXING_CHOICES, - ENGAGEMENT_STATUS_CHOICES, SEVERITY_CHOICES, App_Analysis, ChoiceQuestion, @@ -72,7 +71,6 @@ Finding_Template, Note_Type, Product, - Product_API_Scan_Configuration, Product_Type, Question, Risk_Acceptance, @@ -1058,264 +1056,6 @@ def __init__(self, *args, **kwargs): "test__engagement__product"].queryset = get_authorized_products("view") -class EngagementDirectFilterHelper(FilterSet): - name = CharFilter(lookup_expr="icontains", label="Engagement name contains") - version = CharFilter(field_name="version", lookup_expr="icontains", label="Engagement version") - test__version = CharFilter(field_name="test__version", lookup_expr="icontains", label="Test version") - product__name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) - status = MultipleChoiceFilter(choices=ENGAGEMENT_STATUS_CHOICES, label="Status") - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - target_start = DateRangeFilter() - target_end = DateRangeFilter() - test__engagement__product__lifecycle = MultipleChoiceFilter( - choices=Product.LIFECYCLE_CHOICES, - label=labels.ASSET_LIFECYCLE_LABEL, - null_label="Empty") - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("target_start", "target_start"), - ("name", "name"), - ("product__name", "product__name"), - ("product__prod_type__name", "product__prod_type__name"), - ("lead__first_name", "lead__first_name"), - ), - field_labels={ - "target_start": "Start date", - "name": "Engagement", - "product__name": labels.ASSET_FILTERS_NAME_LABEL, - "product__prod_type__name": labels.ORG_FILTERS_LABEL, - "lead__first_name": "Lead", - }, - ) - - -class EngagementDirectFilter(EngagementDirectFilterHelper, DojoFilter): - lead = ModelChoiceFilter(queryset=Dojo_User.objects.none(), label="Lead") - product__prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["product__prod_type"].queryset = get_authorized_product_types("view") - self.form.fields["lead"].queryset = get_authorized_users("view") \ - .filter(engagement__lead__isnull=False).distinct() - - class Meta: - model = Engagement - fields = ["product__name", "product__prod_type"] - - -class EngagementDirectFilterWithoutObjectLookups(EngagementDirectFilterHelper): - lead = CharFilter( - field_name="lead__username", - lookup_expr="iexact", - label="Lead Username", - help_text="Search for Lead username that are an exact match") - lead_contains = CharFilter( - field_name="lead__username", - lookup_expr="icontains", - label="Lead Username Contains", - help_text="Search for Lead username that contain a given pattern") - product__prod_type__name = CharFilter( - field_name="product__prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_NAME_LABEL, - help_text=labels.ORG_FILTERS_NAME_HELP) - product__prod_type__name_contains = CharFilter( - field_name="product__prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - - class Meta: - model = Engagement - fields = ["product__name"] - - -class EngagementFilterHelper(FilterSet): - name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - engagement__name = CharFilter(lookup_expr="icontains", label="Engagement name contains") - engagement__version = CharFilter(field_name="engagement__version", lookup_expr="icontains", label="Engagement version") - engagement__test__version = CharFilter(field_name="engagement__test__version", lookup_expr="icontains", label="Test version") - engagement__product__lifecycle = MultipleChoiceFilter( - choices=Product.LIFECYCLE_CHOICES, - label=labels.ASSET_LIFECYCLE_LABEL, - null_label="Empty") - engagement__status = MultipleChoiceFilter( - choices=ENGAGEMENT_STATUS_CHOICES, - label="Status") - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ("prod_type__name", "prod_type__name"), - ), - field_labels={ - "name": labels.ASSET_FILTERS_NAME_LABEL, - "prod_type__name": labels.ORG_FILTERS_LABEL, - }, - ) - - -class EngagementFilter(EngagementFilterHelper, DojoFilter): - engagement__lead = ModelChoiceFilter( - queryset=Dojo_User.objects.none(), - label="Lead") - prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["prod_type"].queryset = get_authorized_product_types("view") - self.form.fields["engagement__lead"].queryset = get_authorized_users("view") \ - .filter(engagement__lead__isnull=False).distinct() - self.form.fields["tags"].help_text = labels.ASSET_FILTERS_TAGS_HELP - self.form.fields["not_tags"].help_text = labels.ASSET_FILTERS_NOT_TAGS_HELP - - class Meta: - model = Product - fields = ["name", "prod_type"] - - -class ProductEngagementsFilter(DojoFilter): - engagement__name = CharFilter(field_name="name", lookup_expr="icontains", label="Engagement name contains") - engagement__lead = ModelChoiceFilter(field_name="lead", queryset=Dojo_User.objects.none(), label="Lead") - engagement__version = CharFilter(field_name="version", lookup_expr="icontains", label="Engagement version") - engagement__test__version = CharFilter(field_name="test__version", lookup_expr="icontains", label="Test version") - engagement__status = MultipleChoiceFilter(field_name="status", choices=ENGAGEMENT_STATUS_CHOICES, - label="Status") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["engagement__lead"].queryset = get_authorized_users("view") \ - .filter(engagement__lead__isnull=False).distinct() - - class Meta: - model = Engagement - fields = [] - - -class ProductEngagementsFilterWithoutObjectLookups(ProductEngagementsFilter): - engagement__lead = CharFilter( - field_name="lead__username", - lookup_expr="iexact", - label="Lead Username", - help_text="Search for Lead username that are an exact match") - - -class EngagementFilterWithoutObjectLookups(EngagementFilterHelper): - engagement__lead = CharFilter( - field_name="engagement__lead__username", - lookup_expr="iexact", - label="Lead Username", - help_text="Search for Lead username that are an exact match") - engagement__lead_contains = CharFilter( - field_name="engagement__lead__username", - lookup_expr="icontains", - label="Lead Username Contains", - help_text="Search for Lead username that contain a given pattern") - prod_type__name = CharFilter( - field_name="prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_LABEL, - help_text=labels.ORG_FILTERS_LABEL_HELP) - prod_type__name_contains = CharFilter( - field_name="prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - - class Meta: - model = Product - fields = ["name"] - - -class ProductEngagementFilterHelper(FilterSet): - version = CharFilter(lookup_expr="icontains", label="Engagement version") - test__version = CharFilter(field_name="test__version", lookup_expr="icontains", label="Test version") - name = CharFilter(lookup_expr="icontains") - status = MultipleChoiceFilter(choices=ENGAGEMENT_STATUS_CHOICES, label="Status") - target_start = DateRangeFilter() - target_end = DateRangeFilter() - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ("version", "version"), - ("target_start", "target_start"), - ("target_end", "target_end"), - ("status", "status"), - ("lead", "lead"), - ), - field_labels={ - "name": "Engagement Name", - }, - ) - - class Meta: - model = Product - fields = ["name"] - - -class ProductEngagementFilter(ProductEngagementFilterHelper, DojoFilter): - lead = ModelChoiceFilter(queryset=Dojo_User.objects.none(), label="Lead") - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["lead"].queryset = get_authorized_users( - "view").filter(engagement__lead__isnull=False).distinct() - - -class ProductEngagementFilterWithoutObjectLookups(ProductEngagementFilterHelper, DojoFilter): - lead = CharFilter( - field_name="lead__username", - lookup_expr="iexact", - label="Lead Username", - help_text="Search for Lead username that are an exact match") - lead_contains = CharFilter( - field_name="lead__username", - lookup_expr="icontains", - label="Lead Username Contains", - help_text="Search for Lead username that contain a given pattern") - - class ApiEngagementFilter(DojoFilter): product__prod_type = NumberInFilter(field_name="product__prod_type", lookup_expr="in") tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") @@ -3168,120 +2908,6 @@ class Meta: } -class EngagementTestFilterHelper(FilterSet): - version = CharFilter(lookup_expr="icontains", label="Version") - if settings.TRACK_IMPORT_HISTORY: - test_import__version = CharFilter(field_name="test_import__version", lookup_expr="icontains", label="Reimported Version") - target_start = DateRangeFilter() - target_end = DateRangeFilter() - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("title", "title"), - ("version", "version"), - ("target_start", "target_start"), - ("target_end", "target_end"), - ("lead", "lead"), - ("api_scan_configuration", "api_scan_configuration"), - ), - field_labels={ - "name": "Test Name", - }, - ) - - -class EngagementTestFilter(EngagementTestFilterHelper, DojoFilter): - lead = ModelChoiceFilter(queryset=Dojo_User.objects.none(), label="Lead") - api_scan_configuration = ModelChoiceFilter( - queryset=Product_API_Scan_Configuration.objects.none(), - label="API Scan Configuration") - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - queryset=Test.tags.tag_model.objects.all().order_by("name")) - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Test.tags.tag_model.objects.all().order_by("name")) - - class Meta: - model = Test - fields = [ - "title", "test_type", "target_start", - "target_end", "percent_complete", - "version", "api_scan_configuration", - ] - - def __init__(self, *args, **kwargs): - self.engagement = kwargs.pop("engagement") - super(DojoFilter, self).__init__(*args, **kwargs) - self.form.fields["test_type"].queryset = Test_Type.objects.filter(test__engagement=self.engagement).distinct().order_by("name") - self.form.fields["api_scan_configuration"].queryset = Product_API_Scan_Configuration.objects.filter(product=self.engagement.product).distinct() - self.form.fields["lead"].queryset = get_authorized_users("view") \ - .filter(test__lead__isnull=False).distinct() - - -class EngagementTestFilterWithoutObjectLookups(EngagementTestFilterHelper): - lead = CharFilter( - field_name="lead__username", - lookup_expr="iexact", - label="Lead Username", - help_text="Search for Lead username that are an exact match") - lead_contains = CharFilter( - field_name="lead__username", - lookup_expr="icontains", - label="Lead Username Contains", - help_text="Search for Lead username that contain a given pattern") - api_scan_configuration__tool_configuration__name = CharFilter( - field_name="api_scan_configuration__tool_configuration__name", - lookup_expr="iexact", - label="API Scan Configuration Name", - help_text="Search for Lead username that are an exact match") - api_scan_configuration__tool_configuration__name_contains = CharFilter( - field_name="api_scan_configuration__tool_configuration__name", - lookup_expr="icontains", - label="API Scan Configuration Name Contains", - help_text="Search for Lead username that contain a given pattern") - tags_contains = CharFilter( - label="Test Tag Contains", - field_name="tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Test that contain a given pattern") - tags = CharFilter( - label="Test Tag", - field_name="tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Test that are an exact match") - not_tags_contains = CharFilter( - label="Test Tag Does Not Contain", - field_name="tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Test that contain a given pattern, and exclude them", - exclude=True) - not_tags = CharFilter( - label="Not Test Tag", - field_name="tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Test that are an exact match, and exclude them", - exclude=True) - - class Meta: - model = Test - fields = [ - "title", "test_type", "target_start", - "target_end", "percent_complete", "version", - ] - - def __init__(self, *args, **kwargs): - self.engagement = kwargs.pop("engagement") - super().__init__(*args, **kwargs) - self.form.fields["test_type"].queryset = Test_Type.objects.filter(test__engagement=self.engagement).distinct().order_by("name") - - class ApiAppAnalysisFilter(DojoFilter): tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") tags = CharFieldInFilter( diff --git a/dojo/forms.py b/dojo/forms.py index b90ab7ab4b1..5bbf64306fe 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -29,7 +29,6 @@ from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add -from dojo.engagement.queries import get_authorized_engagements from dojo.finding.queries import get_authorized_findings from dojo.github.ui.forms import ( # noqa: F401 -- backward compat DeleteGITHUBConfForm, @@ -73,8 +72,6 @@ Dojo_User, DojoMeta, Endpoint, - Engagement, - Engagement_Presets, Engagement_Survey, FileUpload, Finding, @@ -971,95 +968,16 @@ class Meta: "sensitive_data", "sensitive_issues", "other", "other_issues"] -class EngForm(forms.ModelForm): - name = forms.CharField( - max_length=300, required=False, - help_text=( - "Add a descriptive name to identify this engagement. " - "Without a name the target start date will be set." - )) - description = forms.CharField(widget=forms.Textarea(attrs={}), - required=False, help_text="Description of the engagement and details regarding the engagement.") - product = forms.ModelChoiceField(label=labels.ASSET_LABEL, - queryset=Product.objects.none(), - required=True) - target_start = forms.DateField(widget=forms.TextInput( - attrs={"class": "datepicker", "autocomplete": "off"})) - target_end = forms.DateField(widget=forms.TextInput( - attrs={"class": "datepicker", "autocomplete": "off"})) - lead = forms.ModelChoiceField( - queryset=None, - required=True, label="Testing Lead") - test_strategy = forms.URLField(required=False, label="Test Strategy URL") - - def __init__(self, *args, **kwargs): - cicd = False - product = None - if "cicd" in kwargs: - cicd = kwargs.pop("cicd") - - if "product" in kwargs: - product = kwargs.pop("product") - - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - - super().__init__(*args, **kwargs) - - if product: - self.fields["preset"] = forms.ModelChoiceField(help_text="Settings and notes for performing this engagement.", required=False, queryset=Engagement_Presets.objects.filter(product=product)) - self.fields["lead"].queryset = get_authorized_users_for_product_and_product_type(None, product, "view").filter(is_active=True) - else: - self.fields["lead"].queryset = get_authorized_users("view").filter(is_active=True) - - self.fields["product"].queryset = get_authorized_products("add") - - # Don't show CICD fields on a interactive engagement - if cicd is False: - del self.fields["build_id"] - del self.fields["commit_hash"] - del self.fields["branch_tag"] - del self.fields["build_server"] - del self.fields["source_code_management_server"] - # del self.fields['source_code_management_uri'] - del self.fields["orchestration_engine"] - else: - del self.fields["test_strategy"] - del self.fields["status"] - - def is_valid(self): - valid = super().is_valid() - - # we're done now if not valid - if not valid: - return valid - if self.cleaned_data["target_start"] > self.cleaned_data["target_end"]: - self.add_error("target_start", "Your target start date exceeds your target end date") - self.add_error("target_end", "Your target start date exceeds your target end date") - return False - return True - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Engagement - exclude = ("first_contacted", "real_start", "engagement_type", "inherited_tags", - "real_end", "requester", "reason", "updated", "report_type", - "product", "threat_model", "api_test", "pen_test", "check_list") - - -class DeleteEngagementForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Engagement - fields = ["id"] - - +# Engagement forms live in dojo/engagement/ui/forms.py. Re-exported here for +# backward compat. DeleteEngagementForm has no external consumers, so it is not +# re-exported (imported directly from dojo.engagement.ui.forms by its only user). +from dojo.engagement.ui.forms import ( # noqa: E402, F401 -- backward compat + AddEngagementForm, + DeleteEngagementPresetsForm, + EngagementPresetsForm, + EngForm, + ExistingEngagementForm, +) from dojo.test.ui.forms import TestForm # noqa: E402, F401 -- backward compat @@ -2632,33 +2550,6 @@ def clean(self): return self.cleaned_data -class EngagementPresetsForm(forms.ModelForm): - - notes = forms.CharField(widget=forms.Textarea(attrs={}), - required=False, help_text="Description of what needs to be tested or setting up environment for testing") - - scope = forms.CharField(widget=forms.Textarea(attrs={}), - required=False, help_text="Scope of Engagement testing, IP's/Resources/URL's)") - - class Meta: - model = Engagement_Presets - exclude = ["product"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - -class DeleteEngagementPresetsForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Engagement_Presets - fields = ["id"] - - class SystemSettingsForm(forms.ModelForm): jira_webhook_secret = forms.CharField(required=False) @@ -3137,30 +3028,6 @@ class Meta: exclude = ["engagement", "survey", "responder", "completed", "answered_on"] -class AddEngagementForm(forms.Form): - product = forms.ModelChoiceField( - queryset=Product.objects.none(), - required=True, - widget=forms.widgets.Select(), - help_text="Select which product to attach Engagement") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["product"].queryset = get_authorized_products("add") - - -class ExistingEngagementForm(forms.Form): - engagement = forms.ModelChoiceField( - queryset=Engagement.objects.none(), - required=True, - widget=forms.widgets.Select(), - help_text="Select which Engagement to link the Questionnaire to") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["engagement"].queryset = get_authorized_engagements("edit").order_by("-target_start") - - class ConfigurationPermissionsForm(forms.Form): def __init__(self, *args, **kwargs): diff --git a/dojo/product/views.py b/dojo/product/views.py index 6f5afb4e9fa..f9dab849576 100644 --- a/dojo/product/views.py +++ b/dojo/product/views.py @@ -29,16 +29,18 @@ from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.roles_permissions import Permissions from dojo.components.sql_group_concat import Sql_GroupConcat -from dojo.filters import ( +from dojo.engagement.ui.filters import ( EngagementFilter, EngagementFilterWithoutObjectLookups, + ProductEngagementFilter, + ProductEngagementFilterWithoutObjectLookups, +) +from dojo.filters import ( MetricsEndpointFilter, MetricsEndpointFilterWithoutObjectLookups, MetricsFindingFilter, MetricsFindingFilterWithoutObjectLookups, ProductComponentFilter, - ProductEngagementFilter, - ProductEngagementFilterWithoutObjectLookups, ProductFilter, ProductFilterWithoutObjectLookups, ) From 4d5455c7a5b797058731a8ba1330b5b1d88c4e1e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 21:15:46 +0200 Subject: [PATCH 18/40] refactor(engagement): move views + urls into dojo/engagement/ui/ [engagement Phase 5] --- dojo/asset/urls.py | 2 +- dojo/authorization/url_permissions.py | 2 +- dojo/engagement/{ => ui}/urls.py | 2 +- dojo/engagement/{ => ui}/views.py | 0 dojo/urls.py | 2 +- unittests/test_query_utils.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename dojo/engagement/{ => ui}/urls.py (98%) rename dojo/engagement/{ => ui}/views.py (100%) diff --git a/dojo/asset/urls.py b/dojo/asset/urls.py index 1b71a03dddf..93be5e217e0 100644 --- a/dojo/asset/urls.py +++ b/dojo/asset/urls.py @@ -1,7 +1,7 @@ from django.conf import settings from django.urls import re_path -from dojo.engagement import views as dojo_engagement_views +from dojo.engagement.ui import views as dojo_engagement_views from dojo.product import views from dojo.utils import redirect_view diff --git a/dojo/authorization/url_permissions.py b/dojo/authorization/url_permissions.py index 70ac4ab20bb..e59aea15ebd 100644 --- a/dojo/authorization/url_permissions.py +++ b/dojo/authorization/url_permissions.py @@ -62,7 +62,7 @@ "delete_api_scan_configuration": [("object", Product_API_Scan_Configuration, "delete", "pascid")], # ----------------------------------------------------------------------- - # Engagement (dojo/engagement/views.py -> dojo/engagement/urls.py) + # Engagement (dojo/engagement/ui/views.py -> dojo/engagement/ui/urls.py) # ----------------------------------------------------------------------- "edit_engagement": [("object", Engagement, "edit", "eid")], "delete_engagement": [("object", Engagement, "delete", "eid")], diff --git a/dojo/engagement/urls.py b/dojo/engagement/ui/urls.py similarity index 98% rename from dojo/engagement/urls.py rename to dojo/engagement/ui/urls.py index 0f33c3aa697..0af9f481a87 100644 --- a/dojo/engagement/urls.py +++ b/dojo/engagement/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.engagement import views +from dojo.engagement.ui import views urlpatterns = [ # engagements and calendar diff --git a/dojo/engagement/views.py b/dojo/engagement/ui/views.py similarity index 100% rename from dojo/engagement/views.py rename to dojo/engagement/ui/views.py diff --git a/dojo/urls.py b/dojo/urls.py index 2930b601b11..6e3a60d2426 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -60,7 +60,7 @@ from dojo.components.urls import urlpatterns as component_urls from dojo.development_environment.urls import urlpatterns as dev_env_urls from dojo.endpoint.urls import urlpatterns as endpoint_urls -from dojo.engagement.urls import urlpatterns as eng_urls +from dojo.engagement.ui.urls import urlpatterns as eng_urls from dojo.finding.urls import urlpatterns as finding_urls from dojo.finding_group.urls import urlpatterns as finding_group_urls from dojo.github.ui.urls import urlpatterns as github_urls diff --git a/unittests/test_query_utils.py b/unittests/test_query_utils.py index e953efd1df9..5e98b507fac 100644 --- a/unittests/test_query_utils.py +++ b/unittests/test_query_utils.py @@ -1,6 +1,6 @@ from django.db.models import Count -from dojo.engagement.views import prefetch_for_view_tests +from dojo.engagement.ui.views import prefetch_for_view_tests from dojo.models import Finding, Test from unittests.dojo_test_case import DojoTestCase, versioned_fixtures From 46350a6f1d175e652c39b4ab6ca25fb49ad2118e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 21:30:31 +0200 Subject: [PATCH 19/40] refactor(engagement): extract API layer into dojo/engagement/api/ [engagement Phase 6,7,8,9] --- dojo/api_v2/serializers.py | 70 +----- dojo/api_v2/views.py | 337 --------------------------- dojo/engagement/api/__init__.py | 1 + dojo/engagement/api/filters.py | 69 ++++++ dojo/engagement/api/serializer.py | 87 +++++++ dojo/engagement/api/urls.py | 7 + dojo/engagement/api/views.py | 371 ++++++++++++++++++++++++++++++ dojo/filters.py | 53 ----- dojo/urls.py | 6 +- unittests/test_rest_framework.py | 2 +- 10 files changed, 543 insertions(+), 460 deletions(-) create mode 100644 dojo/engagement/api/__init__.py create mode 100644 dojo/engagement/api/filters.py create mode 100644 dojo/engagement/api/serializer.py create mode 100644 dojo/engagement/api/urls.py create mode 100644 dojo/engagement/api/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 85371da3fca..ddee30d976a 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -48,7 +48,6 @@ Announcement, App_Analysis, BurpRawRequestResponse, - Check_List, Development_Environment, Dojo_User, DojoMeta, @@ -56,7 +55,6 @@ Endpoint_Params, Endpoint_Status, Engagement, - Engagement_Presets, FileUpload, Finding, Finding_Group, @@ -725,38 +723,14 @@ class Meta: fields = ["path"] +# Engagement serializers live in dojo/engagement/api/serializer.py. +# EngagementSerializer is re-exported here because ReportGenerateSerializer and +# RiskAcceptanceSerializer (below) still reference it. The other engagement +# serializers are imported directly from dojo.engagement.api by their consumers. +from dojo.engagement.api.serializer import EngagementSerializer # noqa: E402 -- backward compat from dojo.product_type.api.serializer import ProductTypeSerializer # noqa: E402 -class EngagementSerializer(serializers.ModelSerializer): - tags = TagListSerializerField(required=False) - - class Meta: - model = Engagement - exclude = ("inherited_tags",) - - def validate(self, data): - if self.context["request"].method == "POST": - if data.get("target_start") > data.get("target_end"): - msg = "Your target start date exceeds your target end date" - raise serializers.ValidationError(msg) - return data - - def build_relational_field(self, field_name, relation_info): - if field_name == "notes": - return NoteSerializer, {"many": True, "read_only": True} - if field_name == "files": - return FileSerializer, {"many": True, "read_only": True} - return super().build_relational_field(field_name, relation_info) - - -class EngagementToNotesSerializer(serializers.Serializer): - engagement_id = serializers.PrimaryKeyRelatedField( - queryset=Engagement.objects.all(), many=False, allow_null=True, - ) - notes = NoteSerializer(many=True) - - class RiskAcceptanceToNotesSerializer(serializers.Serializer): risk_acceptance_id = serializers.PrimaryKeyRelatedField( queryset=Risk_Acceptance.objects.all(), many=False, allow_null=True, @@ -764,34 +738,6 @@ class RiskAcceptanceToNotesSerializer(serializers.Serializer): notes = NoteSerializer(many=True) -class EngagementToFilesSerializer(serializers.Serializer): - engagement_id = serializers.PrimaryKeyRelatedField( - queryset=Engagement.objects.all(), many=False, allow_null=True, - ) - files = FileSerializer(many=True) - - def to_representation(self, data): - engagement = data.get("engagement_id") - files = data.get("files") - new_files = [{ - "id": file.id, - "file": "{site_url}/{file_access_url}".format( - site_url=settings.SITE_URL, - file_access_url=file.get_accessible_url( - engagement, engagement.id, - ), - ), - "title": file.title, - } for file in files] - return {"engagement_id": engagement.id, "files": new_files} - - -class EngagementCheckListSerializer(serializers.ModelSerializer): - class Meta: - model = Check_List - fields = "__all__" - - class AppAnalysisSerializer(serializers.ModelSerializer): tags = TagListSerializerField(required=False) @@ -2531,12 +2477,6 @@ class FindingNoteSerializer(serializers.Serializer): from dojo.notifications.api.serializer import NotificationsSerializer # noqa: E402, F401 -- backward compat -class EngagementPresetsSerializer(serializers.ModelSerializer): - class Meta: - model = Engagement_Presets - fields = "__all__" - - class NetworkLocationsSerializer(serializers.ModelSerializer): class Meta: model = Network_Locations diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 78a7e87761a..51f06c4b029 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -44,22 +44,17 @@ prefetch, serializers, ) -from dojo.api_v2.prefetch.prefetcher import _Prefetcher from dojo.authorization import api_permissions as permissions from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.queries import ( get_authorized_endpoint_status, get_authorized_endpoints, ) from dojo.endpoint.views import get_endpoint_ids -from dojo.engagement.queries import get_authorized_engagements -from dojo.engagement.services import close_engagement, reopen_engagement from dojo.filters import ( ApiAppAnalysisFilter, ApiDojoMetaFilter, ApiEndpointFilter, - ApiEngagementFilter, ApiFindingFilter, ApiProductFilter, ApiRiskAcceptanceFilter, @@ -83,14 +78,11 @@ Announcement, App_Analysis, BurpRawRequestResponse, - Check_List, Development_Environment, Dojo_User, DojoMeta, Endpoint, Endpoint_Status, - Engagement, - Engagement_Presets, FileUpload, Finding, Finding_Template, @@ -118,7 +110,6 @@ from dojo.product.queries import ( get_authorized_app_analysis, get_authorized_dojo_meta, - get_authorized_engagement_presets, get_authorized_languages, get_authorized_product_api_scan_configurations, get_authorized_products, @@ -318,317 +309,6 @@ def get_queryset(self): ).distinct() -# Authorization: object-based -# @extend_schema_view(**schema_with_prefetch()) -# Nested models with prefetch make the response schema too long for Swagger UI -class EngagementViewSet( - # PrefetchDojoModelViewSet, - DojoModelViewSet, - ra_api.AcceptedRisksMixin, -): - serializer_class = serializers.EngagementSerializer - queryset = Engagement.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiEngagementFilter - - permission_classes = ( - IsAuthenticated, - permissions.UserHasEngagementPermission, - ) - - @property - def risk_application_model_class(self): - return Engagement - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if get_setting("ASYNC_OBJECT_DELETE"): - async_del = async_delete() - async_del.delete(instance) - else: - instance.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - def get_queryset(self): - return ( - get_authorized_engagements("view") - .prefetch_related("notes", "risk_acceptance", "files") - .distinct() - ) - - @extend_schema( - request=OpenApiTypes.NONE, responses={status.HTTP_200_OK: ""}, - ) - @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission)) - def close(self, request, pk=None): - eng = self.get_object() - close_engagement(eng) - return Response({}, status=status.HTTP_200_OK) - - @extend_schema( - request=OpenApiTypes.NONE, responses={status.HTTP_200_OK: ""}, - ) - @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission)) - def reopen(self, request, pk=None): - eng = self.get_object() - reopen_engagement(eng) - return Response({}, status=status.HTTP_200_OK) - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=True, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request, pk=None): - engagement = self.get_object() - - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, engagement, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.EngagementToNotesSerializer, - }, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewNoteOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.NoteSerializer}, - ) - @action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, permissions.UserHasEngagementNotePermission]) - def notes(self, request, pk=None): - engagement = self.get_object() - if request.method == "POST": - new_note = serializers.AddNewNoteOptionSerializer( - data=request.data, - ) - if new_note.is_valid(): - entry = new_note.validated_data["entry"] - private = new_note.validated_data.get("private", False) - note_type = new_note.validated_data.get("note_type", None) - else: - return Response( - new_note.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - notes = engagement.notes.filter(note_type=note_type).first() - if notes and note_type and note_type.is_single: - return Response("Only one instance of this note_type allowed on an engagement.", status=status.HTTP_400_BAD_REQUEST) - - author = request.user - note = Notes( - entry=entry, - author=author, - private=private, - note_type=note_type, - ) - note.save() - # Add an entry to the note history - history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) - note.history.add(history) - # Now add the note to the object - engagement.notes.add(note) - # Determine if we need to send any notifications for user mentioned - process_tag_notifications( - request=request, - note=note, - parent_url=request.build_absolute_uri( - reverse("view_engagement", args=(engagement.id,)), - ), - parent_title=f"Engagement: {engagement.name}", - ) - - serialized_note = serializers.NoteSerializer( - {"author": author, "entry": entry, "private": private}, - ) - return Response( - serialized_note.data, status=status.HTTP_201_CREATED, - ) - notes = engagement.notes.all() - - serialized_notes = serializers.EngagementToNotesSerializer( - {"engagement_id": engagement, "notes": notes}, - ) - return Response(serialized_notes.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.EngagementToFilesSerializer, - }, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewFileOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.FileSerializer}, - ) - @action( - detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=[IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission], - ) - def files(self, request, pk=None): - engagement = self.get_object() - if request.method == "POST": - new_file = serializers.FileSerializer(data=request.data) - if new_file.is_valid(): - title = new_file.validated_data["title"] - file = new_file.validated_data["file"] - else: - return Response( - new_file.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - file = FileUpload(title=title, file=file) - file.save() - engagement.files.add(file) - - serialized_file = serializers.FileSerializer(file) - return Response( - serialized_file.data, status=status.HTTP_201_CREATED, - ) - - files = engagement.files.all() - serialized_files = serializers.EngagementToFilesSerializer( - {"engagement_id": engagement, "files": files}, - ) - return Response(serialized_files.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["POST"], - request=serializers.EngagementCheckListSerializer, - responses={ - status.HTTP_201_CREATED: serializers.EngagementCheckListSerializer, - }, - ) - @action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission]) - def complete_checklist(self, request, pk=None): - engagement = self.get_object() - check_lists = Check_List.objects.filter(engagement=engagement) - if request.method == "POST": - if check_lists.count() > 0: - return Response( - { - "message": "A completed checklist for this engagement already exists.", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - check_list = serializers.EngagementCheckListSerializer( - data=request.data, - ) - if not check_list.is_valid(): - return Response( - check_list.errors, status=status.HTTP_400_BAD_REQUEST, - ) - check_list = Check_List(**check_list.data) - check_list.engagement = engagement - check_list.save() - serialized_check_list = serializers.EngagementCheckListSerializer( - check_list, - ) - return Response( - serialized_check_list.data, status=status.HTTP_201_CREATED, - ) - prefetch_params = request.GET.get("prefetch", "").split(",") - prefetcher = _Prefetcher() - entry = check_lists.first() - # Get the queried object representation - result = serializers.EngagementCheckListSerializer(entry).data - prefetcher._prefetch(entry, prefetch_params) - result["prefetch"] = prefetcher.prefetched_data - return Response(result, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.RawFileSerializer, - }, - ) - @action( - detail=True, - methods=["get"], - url_path=r"files/download/(?P\d+)", - permission_classes=[IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission], - ) - def download_file(self, request, file_id, pk=None): - engagement = self.get_object() - # Get the file object - file_object_qs = engagement.files.filter(id=file_id) - file_object = ( - file_object_qs.first() if len(file_object_qs) > 0 else None - ) - if file_object is None: - return Response( - {"error": "File ID not associated with Engagement"}, - status=status.HTTP_404_NOT_FOUND, - ) - # send file - return generate_file_response(file_object) - - @extend_schema( - request=serializers.EngagementUpdateJiraEpicSerializer, - responses={status.HTTP_200_OK: serializers.EngagementUpdateJiraEpicSerializer}, - ) - @action( - detail=True, methods=["post"], - permission_classes=(IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission), - ) - def update_jira_epic(self, request, pk=None): - engagement = self.get_object() - try: - if engagement.has_jira_issue: - task = jira_services.get_epic_task("update_epic") - if task: - dojo_dispatch_task(task, engagement.id, **request.data) - response = Response( - {"info": "Jira Epic update query sent"}, - status=status.HTTP_200_OK, - ) - else: - task = jira_services.get_epic_task("add_epic") - if task: - dojo_dispatch_task(task, engagement.id, **request.data) - response = Response( - {"info": "Jira Epic create query sent"}, - status=status.HTTP_200_OK, - ) - except ValidationError: - return Response( - {"error": "Bad Request!"}, - status=status.HTTP_400_BAD_REQUEST, - ) - return response - - # @extend_schema_view(**schema_with_prefetch()) # Nested models with prefetch make the response schema too long for Swagger UI class RiskAcceptanceViewSet( @@ -2594,23 +2274,6 @@ def queue_task_purge(self, request): return Response({"purged": purged}) -@extend_schema_view(**schema_with_prefetch()) -class EngagementPresetsViewset( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.EngagementPresetsSerializer - queryset = Engagement_Presets.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["id", "title", "product"] - permission_classes = ( - IsAuthenticated, - permissions.UserHasEngagementPresetPermission, - ) - - def get_queryset(self): - return get_authorized_engagement_presets("view") - - class NetworkLocationsViewset( DojoModelViewSet, ): diff --git a/dojo/engagement/api/__init__.py b/dojo/engagement/api/__init__.py new file mode 100644 index 00000000000..60e35ed2e10 --- /dev/null +++ b/dojo/engagement/api/__init__.py @@ -0,0 +1 @@ +path = "engagements" # noqa: RUF067 diff --git a/dojo/engagement/api/filters.py b/dojo/engagement/api/filters.py new file mode 100644 index 00000000000..1015b019fd0 --- /dev/null +++ b/dojo/engagement/api/filters.py @@ -0,0 +1,69 @@ +from django_filters import ( + BooleanFilter, + CharFilter, + OrderingFilter, +) + +from dojo.filters import ( + CharFieldFilterANDExpression, + CharFieldInFilter, + DojoFilter, + NumberInFilter, +) +from dojo.labels import get_labels +from dojo.models import Engagement + +labels = get_labels() + + +class ApiEngagementFilter(DojoFilter): + product__prod_type = NumberInFilter(field_name="product__prod_type", lookup_expr="in") + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + product__tags = CharFieldInFilter( + field_name="product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) + product__tags__and = CharFieldFilterANDExpression( + field_name="product__tags__name", + help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on model", exclude="True") + not_product__tags = CharFieldInFilter(field_name="product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, + exclude="True") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ("version", "version"), + ("target_start", "target_start"), + ("target_end", "target_end"), + ("status", "status"), + ("lead", "lead"), + ("created", "created"), + ("updated", "updated"), + ), + field_labels={ + "name": "Engagement Name", + }, + + ) + + class Meta: + model = Engagement + fields = ["id", "active", "target_start", + "target_end", "requester", "report_type", + "updated", "threat_model", "api_test", + "pen_test", "status", "product", "name", "version", "tags"] diff --git a/dojo/engagement/api/serializer.py b/dojo/engagement/api/serializer.py new file mode 100644 index 00000000000..7cae1b39485 --- /dev/null +++ b/dojo/engagement/api/serializer.py @@ -0,0 +1,87 @@ +from django.conf import settings +from rest_framework import serializers + +from dojo.models import Check_List, Engagement, Engagement_Presets + + +class EngagementSerializer(serializers.ModelSerializer): + class Meta: + model = Engagement + exclude = ("inherited_tags",) + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + def validate(self, data): + if self.context["request"].method == "POST": + if data.get("target_start") > data.get("target_end"): + msg = "Your target start date exceeds your target end date" + raise serializers.ValidationError(msg) + return data + + def build_relational_field(self, field_name, relation_info): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + FileSerializer, + NoteSerializer, + ) + if field_name == "notes": + return NoteSerializer, {"many": True, "read_only": True} + if field_name == "files": + return FileSerializer, {"many": True, "read_only": True} + return super().build_relational_field(field_name, relation_info) + + +class EngagementToNotesSerializer(serializers.Serializer): + engagement_id = serializers.PrimaryKeyRelatedField( + queryset=Engagement.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import NoteSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["notes"] = NoteSerializer(many=True) + return fields + + +class EngagementToFilesSerializer(serializers.Serializer): + engagement_id = serializers.PrimaryKeyRelatedField( + queryset=Engagement.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import FileSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["files"] = FileSerializer(many=True) + return fields + + def to_representation(self, data): + engagement = data.get("engagement_id") + files = data.get("files") + new_files = [{ + "id": file.id, + "file": "{site_url}/{file_access_url}".format( + site_url=settings.SITE_URL, + file_access_url=file.get_accessible_url( + engagement, engagement.id, + ), + ), + "title": file.title, + } for file in files] + return {"engagement_id": engagement.id, "files": new_files} + + +class EngagementCheckListSerializer(serializers.ModelSerializer): + class Meta: + model = Check_List + fields = "__all__" + + +class EngagementPresetsSerializer(serializers.ModelSerializer): + class Meta: + model = Engagement_Presets + fields = "__all__" diff --git a/dojo/engagement/api/urls.py b/dojo/engagement/api/urls.py new file mode 100644 index 00000000000..7c5ba0c2758 --- /dev/null +++ b/dojo/engagement/api/urls.py @@ -0,0 +1,7 @@ +from dojo.engagement.api.views import EngagementPresetsViewset, EngagementViewSet + + +def add_engagement_urls(router): + router.register("engagements", EngagementViewSet, basename="engagement") + router.register("engagement_presets", EngagementPresetsViewset, basename="engagement_presets") + return router diff --git a/dojo/engagement/api/views.py b/dojo/engagement/api/views.py new file mode 100644 index 00000000000..34ed278a358 --- /dev/null +++ b/dojo/engagement/api/views.py @@ -0,0 +1,371 @@ +from django.core.exceptions import ValidationError +from django.urls import reverse +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2 import serializers as api_v2_serializers +from dojo.api_v2.prefetch.prefetcher import _Prefetcher +from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, report_generate, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.celery_dispatch import dojo_dispatch_task +from dojo.engagement.api.filters import ApiEngagementFilter +from dojo.engagement.api.serializer import ( + EngagementCheckListSerializer, + EngagementPresetsSerializer, + EngagementSerializer, + EngagementToFilesSerializer, + EngagementToNotesSerializer, +) +from dojo.engagement.queries import get_authorized_engagements +from dojo.engagement.services import close_engagement, reopen_engagement +from dojo.jira import services as jira_services +from dojo.models import ( + Check_List, + Engagement, + Engagement_Presets, + FileUpload, + NoteHistory, + Notes, +) +from dojo.product.queries import get_authorized_engagement_presets +from dojo.risk_acceptance import api as ra_api +from dojo.utils import ( + async_delete, + generate_file_response, + get_setting, + process_tag_notifications, +) + + +# Authorization: object-based +# @extend_schema_view(**schema_with_prefetch()) +# Nested models with prefetch make the response schema too long for Swagger UI +class EngagementViewSet( + # PrefetchDojoModelViewSet, + DojoModelViewSet, + ra_api.AcceptedRisksMixin, +): + serializer_class = EngagementSerializer + queryset = Engagement.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiEngagementFilter + + permission_classes = ( + IsAuthenticated, + permissions.UserHasEngagementPermission, + ) + + @property + def risk_application_model_class(self): + return Engagement + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if get_setting("ASYNC_OBJECT_DELETE"): + async_del = async_delete() + async_del.delete(instance) + else: + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_queryset(self): + return ( + get_authorized_engagements("view") + .prefetch_related("notes", "risk_acceptance", "files") + .distinct() + ) + + @extend_schema( + request=OpenApiTypes.NONE, responses={status.HTTP_200_OK: ""}, + ) + @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission)) + def close(self, request, pk=None): + eng = self.get_object() + close_engagement(eng) + return Response({}, status=status.HTTP_200_OK) + + @extend_schema( + request=OpenApiTypes.NONE, responses={status.HTTP_200_OK: ""}, + ) + @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission)) + def reopen(self, request, pk=None): + eng = self.get_object() + reopen_engagement(eng) + return Response({}, status=status.HTTP_200_OK) + + @extend_schema( + request=api_v2_serializers.ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: api_v2_serializers.ReportGenerateSerializer}, + ) + @action( + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request, pk=None): + engagement = self.get_object() + + options = {} + # prepare post data + report_options = api_v2_serializers.ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, engagement, options) + report = api_v2_serializers.ReportGenerateSerializer(data) + return Response(report.data) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: EngagementToNotesSerializer, + }, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewNoteOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.NoteSerializer}, + ) + @action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, permissions.UserHasEngagementNotePermission]) + def notes(self, request, pk=None): + engagement = self.get_object() + if request.method == "POST": + new_note = api_v2_serializers.AddNewNoteOptionSerializer( + data=request.data, + ) + if new_note.is_valid(): + entry = new_note.validated_data["entry"] + private = new_note.validated_data.get("private", False) + note_type = new_note.validated_data.get("note_type", None) + else: + return Response( + new_note.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + notes = engagement.notes.filter(note_type=note_type).first() + if notes and note_type and note_type.is_single: + return Response("Only one instance of this note_type allowed on an engagement.", status=status.HTTP_400_BAD_REQUEST) + + author = request.user + note = Notes( + entry=entry, + author=author, + private=private, + note_type=note_type, + ) + note.save() + # Add an entry to the note history + history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) + note.history.add(history) + # Now add the note to the object + engagement.notes.add(note) + # Determine if we need to send any notifications for user mentioned + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri( + reverse("view_engagement", args=(engagement.id,)), + ), + parent_title=f"Engagement: {engagement.name}", + ) + + serialized_note = api_v2_serializers.NoteSerializer( + {"author": author, "entry": entry, "private": private}, + ) + return Response( + serialized_note.data, status=status.HTTP_201_CREATED, + ) + notes = engagement.notes.all() + + serialized_notes = EngagementToNotesSerializer( + {"engagement_id": engagement, "notes": notes}, + ) + return Response(serialized_notes.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: EngagementToFilesSerializer, + }, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewFileOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.FileSerializer}, + ) + @action( + detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=[IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission], + ) + def files(self, request, pk=None): + engagement = self.get_object() + if request.method == "POST": + new_file = api_v2_serializers.FileSerializer(data=request.data) + if new_file.is_valid(): + title = new_file.validated_data["title"] + file = new_file.validated_data["file"] + else: + return Response( + new_file.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + file = FileUpload(title=title, file=file) + file.save() + engagement.files.add(file) + + serialized_file = api_v2_serializers.FileSerializer(file) + return Response( + serialized_file.data, status=status.HTTP_201_CREATED, + ) + + files = engagement.files.all() + serialized_files = EngagementToFilesSerializer( + {"engagement_id": engagement, "files": files}, + ) + return Response(serialized_files.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["POST"], + request=EngagementCheckListSerializer, + responses={ + status.HTTP_201_CREATED: EngagementCheckListSerializer, + }, + ) + @action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission]) + def complete_checklist(self, request, pk=None): + engagement = self.get_object() + check_lists = Check_List.objects.filter(engagement=engagement) + if request.method == "POST": + if check_lists.count() > 0: + return Response( + { + "message": "A completed checklist for this engagement already exists.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + check_list = EngagementCheckListSerializer( + data=request.data, + ) + if not check_list.is_valid(): + return Response( + check_list.errors, status=status.HTTP_400_BAD_REQUEST, + ) + check_list = Check_List(**check_list.data) + check_list.engagement = engagement + check_list.save() + serialized_check_list = EngagementCheckListSerializer( + check_list, + ) + return Response( + serialized_check_list.data, status=status.HTTP_201_CREATED, + ) + prefetch_params = request.GET.get("prefetch", "").split(",") + prefetcher = _Prefetcher() + entry = check_lists.first() + # Get the queried object representation + result = EngagementCheckListSerializer(entry).data + prefetcher._prefetch(entry, prefetch_params) + result["prefetch"] = prefetcher.prefetched_data + return Response(result, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: api_v2_serializers.RawFileSerializer, + }, + ) + @action( + detail=True, + methods=["get"], + url_path=r"files/download/(?P\d+)", + permission_classes=[IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission], + ) + def download_file(self, request, file_id, pk=None): + engagement = self.get_object() + # Get the file object + file_object_qs = engagement.files.filter(id=file_id) + file_object = ( + file_object_qs.first() if len(file_object_qs) > 0 else None + ) + if file_object is None: + return Response( + {"error": "File ID not associated with Engagement"}, + status=status.HTTP_404_NOT_FOUND, + ) + # send file + return generate_file_response(file_object) + + @extend_schema( + request=api_v2_serializers.EngagementUpdateJiraEpicSerializer, + responses={status.HTTP_200_OK: api_v2_serializers.EngagementUpdateJiraEpicSerializer}, + ) + @action( + detail=True, methods=["post"], + permission_classes=(IsAuthenticated, permissions.UserHasEngagementRelatedObjectPermission), + ) + def update_jira_epic(self, request, pk=None): + engagement = self.get_object() + try: + if engagement.has_jira_issue: + task = jira_services.get_epic_task("update_epic") + if task: + dojo_dispatch_task(task, engagement.id, **request.data) + response = Response( + {"info": "Jira Epic update query sent"}, + status=status.HTTP_200_OK, + ) + else: + task = jira_services.get_epic_task("add_epic") + if task: + dojo_dispatch_task(task, engagement.id, **request.data) + response = Response( + {"info": "Jira Epic create query sent"}, + status=status.HTTP_200_OK, + ) + except ValidationError: + return Response( + {"error": "Bad Request!"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return response + + +@extend_schema_view(**schema_with_prefetch()) +class EngagementPresetsViewset( + PrefetchDojoModelViewSet, +): + serializer_class = EngagementPresetsSerializer + queryset = Engagement_Presets.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["id", "title", "product"] + permission_classes = ( + IsAuthenticated, + permissions.UserHasEngagementPresetPermission, + ) + + def get_queryset(self): + return get_authorized_engagement_presets("view") diff --git a/dojo/filters.py b/dojo/filters.py index 21357ca5b9e..46ca7e0927b 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -1056,59 +1056,6 @@ def __init__(self, *args, **kwargs): "test__engagement__product"].queryset = get_authorized_products("view") -class ApiEngagementFilter(DojoFilter): - product__prod_type = NumberInFilter(field_name="product__prod_type", lookup_expr="in") - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - product__tags = CharFieldInFilter( - field_name="product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) - product__tags__and = CharFieldFilterANDExpression( - field_name="product__tags__name", - help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on model", exclude="True") - not_product__tags = CharFieldInFilter(field_name="product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, - exclude="True") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ("version", "version"), - ("target_start", "target_start"), - ("target_end", "target_end"), - ("status", "status"), - ("lead", "lead"), - ("created", "created"), - ("updated", "updated"), - ), - field_labels={ - "name": "Engagement Name", - }, - - ) - - class Meta: - model = Engagement - fields = ["id", "active", "target_start", - "target_end", "requester", "report_type", - "updated", "threat_model", "api_test", - "pen_test", "status", "product", "name", "version", "tags"] - - class ProductFilterHelper(FilterSet): name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_LABEL) name_exact = CharFilter(field_name="name", lookup_expr="iexact", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) diff --git a/dojo/urls.py b/dojo/urls.py index 6e3a60d2426..cfccb1152d1 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -22,8 +22,6 @@ EndpointMetaImporterView, EndpointStatusViewSet, EndPointViewSet, - EngagementPresetsViewset, - EngagementViewSet, FindingTemplatesViewSet, FindingViewSet, ImportLanguagesView, @@ -60,6 +58,7 @@ from dojo.components.urls import urlpatterns as component_urls from dojo.development_environment.urls import urlpatterns as dev_env_urls from dojo.endpoint.urls import urlpatterns as endpoint_urls +from dojo.engagement.api.urls import add_engagement_urls from dojo.engagement.ui.urls import urlpatterns as eng_urls from dojo.finding.urls import urlpatterns as finding_urls from dojo.finding_group.urls import urlpatterns as finding_group_urls @@ -111,8 +110,6 @@ # RBAC endpoints moved to Pro under legacy authorization: # dojo_groups, dojo_group_members → pro/groups, pro/group_members v2_api.register(r"endpoint_meta_import", EndpointMetaImporterView, basename="endpointmetaimport") -v2_api.register(r"engagements", EngagementViewSet, basename="engagement") -v2_api.register(r"engagement_presets", EngagementPresetsViewset, basename="engagement_presets") v2_api.register(r"finding_templates", FindingTemplatesViewSet, basename="finding_template") v2_api.register(r"findings", FindingViewSet, basename="finding") # RBAC endpoint moved to Pro under legacy authorization: global_roles → pro/global_roles @@ -135,6 +132,7 @@ # RBAC endpoints moved to Pro under legacy authorization: # product_groups, product_members → pro/product_groups, pro/product_members v2_api = add_product_type_urls(v2_api) +v2_api = add_engagement_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 4b16e26d358..b195500889e 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -44,7 +44,6 @@ DevelopmentEnvironmentViewSet, EndpointStatusViewSet, EndPointViewSet, - EngagementViewSet, FindingTemplatesViewSet, FindingViewSet, ImportLanguagesView, @@ -71,6 +70,7 @@ AssetViewSet, ) from dojo.authorization.roles_permissions import Permissions, permission_to_action +from dojo.engagement.api.views import EngagementViewSet from dojo.location.api.endpoint_compat import V3EndpointCompatibleViewSet, V3EndpointStatusCompatibleViewSet from dojo.location.api.views import LocationFindingReferenceViewSet, LocationProductReferenceViewSet, LocationViewSet from dojo.location.models import Location, LocationFindingReference, LocationProductReference From b87221f2ce20968dae4c651da0d9d5dd10cdbba3 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 17:22:38 +0200 Subject: [PATCH 20/40] refactor(product): extract Product/Product_Line/Product_API_Scan_Configuration into dojo/product/ Phase 1 of module reorg per AGENTS.md. Move Product, Product_Line, Product_API_Scan_Configuration + admin registrations into dojo/product/{models,admin}.py. Cross-module FKs use string refs to avoid circular imports. Product_Type re-export now pure backward-compat (F401). No migration change. --- dojo/models.py | 295 +------------------------------------ dojo/product/__init__.py | 1 + dojo/product/admin.py | 21 +++ dojo/product/models.py | 303 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 289 deletions(-) create mode 100644 dojo/product/admin.py create mode 100644 dojo/product/models.py diff --git a/dojo/models.py b/dojo/models.py index 00d53ad3ba5..5f709f4b543 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -7,7 +7,6 @@ import warnings from contextlib import suppress from datetime import datetime, timedelta -from decimal import Decimal from pathlib import Path from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -746,7 +745,12 @@ def clean(self): raise ValidationError(msg) -from dojo.product_type.models import Product_Type # noqa: E402 -- re-export; mid-file as Product FK uses it below +from dojo.product.models import ( # noqa: E402 -- re-export; class-body FKs below reference these + Product, + Product_API_Scan_Configuration, # noqa: F401 -- re-export + Product_Line, # noqa: F401 -- re-export +) +from dojo.product_type.models import Product_Type # noqa: E402, F401 -- re-export from dojo.test.models import ( # noqa: E402 -- re-export; class-body FKs below reference these IMPORT_ACTIONS, # noqa: F401 -- re-export IMPORT_CLOSED_FINDING, # noqa: F401 -- re-export @@ -760,14 +764,6 @@ def clean(self): ) -class Product_Line(models.Model): - name = models.CharField(max_length=300) - description = models.CharField(max_length=2000) - - def __str__(self): - return self.name - - class Report_Type(models.Model): name = models.CharField(max_length=255) @@ -957,257 +953,6 @@ def get_summary(self): return f"{self.name} - Critical: {self.critical}, High: {self.high}, Medium: {self.medium}, Low: {self.low}" -class Product(BaseModel): - WEB_PLATFORM = "web" - IOT = "iot" - DESKTOP_PLATFORM = "desktop" - MOBILE_PLATFORM = "mobile" - WEB_SERVICE_PLATFORM = "web service" - PLATFORM_CHOICES = ( - (WEB_SERVICE_PLATFORM, _("API")), - (DESKTOP_PLATFORM, _("Desktop")), - (IOT, _("Internet of Things")), - (MOBILE_PLATFORM, _("Mobile")), - (WEB_PLATFORM, _("Web")), - ) - - CONSTRUCTION = "construction" - PRODUCTION = "production" - RETIREMENT = "retirement" - LIFECYCLE_CHOICES = ( - (CONSTRUCTION, _("Construction")), - (PRODUCTION, _("Production")), - (RETIREMENT, _("Retirement")), - ) - - THIRD_PARTY_LIBRARY_ORIGIN = "third party library" - PURCHASED_ORIGIN = "purchased" - CONTRACTOR_ORIGIN = "contractor" - INTERNALLY_DEVELOPED_ORIGIN = "internal" - OPEN_SOURCE_ORIGIN = "open source" - OUTSOURCED_ORIGIN = "outsourced" - ORIGIN_CHOICES = ( - (THIRD_PARTY_LIBRARY_ORIGIN, _("Third Party Library")), - (PURCHASED_ORIGIN, _("Purchased")), - (CONTRACTOR_ORIGIN, _("Contractor Developed")), - (INTERNALLY_DEVELOPED_ORIGIN, _("Internally Developed")), - (OPEN_SOURCE_ORIGIN, _("Open Source")), - (OUTSOURCED_ORIGIN, _("Outsourced")), - ) - - VERY_HIGH_CRITICALITY = "very high" - HIGH_CRITICALITY = "high" - MEDIUM_CRITICALITY = "medium" - LOW_CRITICALITY = "low" - VERY_LOW_CRITICALITY = "very low" - NONE_CRITICALITY = "none" - BUSINESS_CRITICALITY_CHOICES = ( - (VERY_HIGH_CRITICALITY, _("Very High")), - (HIGH_CRITICALITY, _("High")), - (MEDIUM_CRITICALITY, _("Medium")), - (LOW_CRITICALITY, _("Low")), - (VERY_LOW_CRITICALITY, _("Very Low")), - (NONE_CRITICALITY, _("None")), - ) - - name = models.CharField(max_length=255, unique=True) - description = models.CharField(max_length=4000) - - product_manager = models.ForeignKey(Dojo_User, null=True, blank=True, - related_name="product_manager", on_delete=models.RESTRICT) - technical_contact = models.ForeignKey(Dojo_User, null=True, blank=True, - related_name="technical_contact", on_delete=models.RESTRICT) - team_manager = models.ForeignKey(Dojo_User, null=True, blank=True, - related_name="team_manager", on_delete=models.RESTRICT) - - prod_type = models.ForeignKey(Product_Type, related_name="prod_type", - null=False, blank=False, on_delete=models.CASCADE) - sla_configuration = models.ForeignKey(SLA_Configuration, - related_name="sla_config", - null=False, - blank=False, - default=1, - on_delete=models.RESTRICT) - tid = models.IntegerField(default=0, editable=False) - authorized_users = models.ManyToManyField(Dojo_User, related_name="authorized_products", blank=True) - prod_numeric_grade = models.IntegerField(null=True, blank=True) - - # Metadata - business_criticality = models.CharField(max_length=9, choices=BUSINESS_CRITICALITY_CHOICES, blank=True, null=True) - platform = models.CharField(max_length=11, choices=PLATFORM_CHOICES, blank=True, null=True) - lifecycle = models.CharField(max_length=12, choices=LIFECYCLE_CHOICES, blank=True, null=True) - origin = models.CharField(max_length=19, choices=ORIGIN_CHOICES, blank=True, null=True) - user_records = models.PositiveIntegerField(blank=True, null=True, help_text=_("Estimate the number of user records within the application.")) - revenue = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True, validators=[MinValueValidator(Decimal("0.00"))], help_text=_("Estimate the application's revenue.")) - external_audience = models.BooleanField(default=False, help_text=_("Specify if the application is used by people outside the organization.")) - internet_accessible = models.BooleanField(default=False, help_text=_("Specify if the application is accessible from the public internet.")) - regulations = models.ManyToManyField(Regulation, blank=True) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this product. Choose from the list or add new tags. Press Enter key to add.")) - enable_product_tag_inheritance = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Product Tag Inheritance"), - help_text=_("Enables product tag inheritance. Any tags added on a product will automatically be added to all Engagements, Tests, and Findings")) - enable_simple_risk_acceptance = models.BooleanField(default=False, help_text=_("Allows simple risk acceptance by checking/unchecking a checkbox.")) - enable_full_risk_acceptance = models.BooleanField(default=True, help_text=_("Allows full risk acceptance using a risk acceptance form, expiration date, uploaded proof, etc.")) - - disable_sla_breach_notifications = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Disable SLA breach notifications"), - help_text=_("Disable SLA breach notifications if configured in the global settings")) - async_updating = models.BooleanField(default=False, - help_text=_("Findings under this Product or SLA configuration are asynchronously being updated")) - - class Meta: - ordering = ("name",) - - def __str__(self): - return self.name - - def save(self, *args, **kwargs): - # get the product's sla config before saving (if this is an existing product) - initial_sla_config = None - if self.pk is not None: - initial_sla_config = getattr(Product.objects.get(pk=self.pk), "sla_configuration", None) - # if initial sla config exists and async finding update is already running, revert sla config before saving - if initial_sla_config and self.async_updating: - self.sla_configuration = initial_sla_config - - super().save(*args, **kwargs) - - # if the initial sla config exists and async finding update is not running - if initial_sla_config is not None and not self.async_updating: - # get the new sla config from the saved product - new_sla_config = getattr(self, "sla_configuration", None) - # if the sla config has changed, update finding sla expiration dates within this product - if new_sla_config and (initial_sla_config != new_sla_config): - # set the async updating flag to true for this product - self.async_updating = True - super().save(*args, **kwargs) - # set the async updating flag to true for the sla config assigned to this product - sla_config = getattr(self, "sla_configuration", None) - if sla_config: - sla_config.async_updating = True - super(SLA_Configuration, sla_config).save() - # launch the async task to update all finding sla expiration dates - from dojo.sla_config.helpers import async_update_sla_expiration_dates_sla_config_sync # noqa: I001, PLC0415 circular import - from dojo.celery_dispatch import dojo_dispatch_task # noqa: PLC0415 circular import - - dojo_dispatch_task( - async_update_sla_expiration_dates_sla_config_sync, - sla_config.id, - [self.id], - ) - # The async task refetches and resets async_updating on its own copies. - # Mirror that on this in-memory product and the in-memory sla_config so a - # subsequent save() on either does not trigger their lock-revert paths. - self.async_updating = False - if sla_config: - sla_config.async_updating = False - - def get_absolute_url(self): - return reverse("view_product", args=[str(self.id)]) - - @cached_property - def findings_count(self): - try: - # if prefetched, it's already there - return self.active_finding_count - except AttributeError: - # ideally it's always prefetched and we can remove this code in the future - self.active_finding_count = Finding.objects.filter(active=True, - test__engagement__product=self).count() - return self.active_finding_count - - @cached_property - def findings_active_verified_count(self): - try: - # if prefetched, it's already there - return self.active_verified_finding_count - except AttributeError: - # ideally it's always prefetched and we can remove this code in the future - self.active_verified_finding_count = Finding.objects.filter(active=True, - verified=True, - test__engagement__product=self).count() - return self.active_verified_finding_count - - # TODO: Delete this after the move to Locations - @cached_property - def endpoint_host_count(self): - # active_endpoints is (should be) prefetched - endpoints = getattr(self, "active_endpoints", None) - - hosts = [] - for e in endpoints: - if e.host in hosts: - continue - hosts.append(e.host) - - return len(hosts) - - # TODO: Delete this after the move to Locations - @cached_property - def endpoint_count(self): - # active_endpoints is (should be) prefetched - endpoints = getattr(self, "active_endpoints", None) - if endpoints: - return len(self.active_endpoints) - return 0 - - def open_findings(self, start_date=None, end_date=None): - if start_date is None or end_date is None: - return {} - - from dojo.utils import get_system_setting # noqa: PLC0415 circular import - findings = Finding.objects.filter(test__engagement__product=self, - mitigated__isnull=True, - false_p=False, - duplicate=False, - out_of_scope=False, - date__range=[start_date, - end_date]) - - if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): - findings = findings.filter(verified=True) - - critical = findings.filter(severity="Critical").count() - high = findings.filter(severity="High").count() - medium = findings.filter(severity="Medium").count() - low = findings.filter(severity="Low").count() - - return {"Critical": critical, - "High": high, - "Medium": medium, - "Low": low, - "Total": (critical + high + medium + low)} - - def get_breadcrumbs(self): - return [{"title": str(self), - "url": reverse("view_product", args=(self.id,))}] - - @property - def get_product_type(self): - return self.prod_type if self.prod_type is not None else "unknown" - - # only used in APIv2 serializers.py, should be deprecated or at least prefetched - def open_findings_list(self): - findings = Finding.objects.filter(test__engagement__product=self, active=True).values_list("id", flat=True) - return list(findings) - - @property - def has_jira_configured(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_configured(self) - - def violates_sla(self): - findings = Finding.objects.filter(test__engagement__product=self, - active=True, - sla_expiration_date__lt=timezone.now().date()) - return findings.count() > 0 - - class Tool_Type(models.Model): name = models.CharField(max_length=200) description = models.CharField(max_length=2000, null=True, blank=True) @@ -1248,31 +993,6 @@ def __str__(self): return self.name -class Product_API_Scan_Configuration(models.Model): - product = models.ForeignKey(Product, null=False, blank=False, on_delete=models.CASCADE) - tool_configuration = models.ForeignKey(Tool_Configuration, null=False, blank=False, on_delete=models.CASCADE) - service_key_1 = models.CharField(max_length=200, null=True, blank=True) - service_key_2 = models.CharField(max_length=200, null=True, blank=True) - service_key_3 = models.CharField(max_length=200, null=True, blank=True) - - def __str__(self): - name = self.tool_configuration.name - if self.service_key_1 or self.service_key_2 or self.service_key_3: - name += f" ({self.details})" - return name - - @property - def details(self): - details = "" - if self.service_key_1: - details += f"{self.service_key_1}" - if self.service_key_2: - details += f" | {self.service_key_2}" - if self.service_key_3: - details += f" | {self.service_key_3}" - return details - - # declare form here as we can't import forms.py due to circular imports not even locally class ToolConfigForm_Admin(forms.ModelForm): password = forms.CharField(widget=forms.PasswordInput, required=False) @@ -3948,7 +3668,6 @@ def __str__(self): admin.site.register(Endpoint_Params) admin.site.register(Endpoint_Status) admin.site.register(Endpoint) -admin.site.register(Product) admin.site.register(UserContactInfo) admin.site.register(Notes) admin.site.register(Note_Type) @@ -3986,10 +3705,8 @@ def __str__(self): admin.site.register(Contact) admin.site.register(NoteHistory) -admin.site.register(Product_Line) admin.site.register(Report_Type) admin.site.register(DojoMeta) -admin.site.register(Product_API_Scan_Configuration) admin.site.register(Development_Environment) admin.site.register(Finding_Template) admin.site.register(Vulnerability_Id) diff --git a/dojo/product/__init__.py b/dojo/product/__init__.py index e69de29bb2d..df5e047d856 100644 --- a/dojo/product/__init__.py +++ b/dojo/product/__init__.py @@ -0,0 +1 @@ +import dojo.product.admin # noqa: F401 diff --git a/dojo/product/admin.py b/dojo/product/admin.py new file mode 100644 index 00000000000..e6a32855567 --- /dev/null +++ b/dojo/product/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin + +from dojo.product.models import Product, Product_API_Scan_Configuration, Product_Line + + +@admin.register(Product_Line) +class ProductLineAdmin(admin.ModelAdmin): + + """Admin support for the Product_Line model.""" + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + + """Admin support for the Product model.""" + + +@admin.register(Product_API_Scan_Configuration) +class ProductAPIScanConfigurationAdmin(admin.ModelAdmin): + + """Admin support for the Product_API_Scan_Configuration model.""" diff --git a/dojo/product/models.py b/dojo/product/models.py new file mode 100644 index 00000000000..cd49e157ac4 --- /dev/null +++ b/dojo/product/models.py @@ -0,0 +1,303 @@ +from decimal import Decimal + +from django.core.validators import MinValueValidator +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.translation import gettext as _ +from tagulous.models import TagField + +from dojo.base_models.base import BaseModel + + +class Product_Line(models.Model): + name = models.CharField(max_length=300) + description = models.CharField(max_length=2000) + + def __str__(self): + return self.name + + +class Product(BaseModel): + WEB_PLATFORM = "web" + IOT = "iot" + DESKTOP_PLATFORM = "desktop" + MOBILE_PLATFORM = "mobile" + WEB_SERVICE_PLATFORM = "web service" + PLATFORM_CHOICES = ( + (WEB_SERVICE_PLATFORM, _("API")), + (DESKTOP_PLATFORM, _("Desktop")), + (IOT, _("Internet of Things")), + (MOBILE_PLATFORM, _("Mobile")), + (WEB_PLATFORM, _("Web")), + ) + + CONSTRUCTION = "construction" + PRODUCTION = "production" + RETIREMENT = "retirement" + LIFECYCLE_CHOICES = ( + (CONSTRUCTION, _("Construction")), + (PRODUCTION, _("Production")), + (RETIREMENT, _("Retirement")), + ) + + THIRD_PARTY_LIBRARY_ORIGIN = "third party library" + PURCHASED_ORIGIN = "purchased" + CONTRACTOR_ORIGIN = "contractor" + INTERNALLY_DEVELOPED_ORIGIN = "internal" + OPEN_SOURCE_ORIGIN = "open source" + OUTSOURCED_ORIGIN = "outsourced" + ORIGIN_CHOICES = ( + (THIRD_PARTY_LIBRARY_ORIGIN, _("Third Party Library")), + (PURCHASED_ORIGIN, _("Purchased")), + (CONTRACTOR_ORIGIN, _("Contractor Developed")), + (INTERNALLY_DEVELOPED_ORIGIN, _("Internally Developed")), + (OPEN_SOURCE_ORIGIN, _("Open Source")), + (OUTSOURCED_ORIGIN, _("Outsourced")), + ) + + VERY_HIGH_CRITICALITY = "very high" + HIGH_CRITICALITY = "high" + MEDIUM_CRITICALITY = "medium" + LOW_CRITICALITY = "low" + VERY_LOW_CRITICALITY = "very low" + NONE_CRITICALITY = "none" + BUSINESS_CRITICALITY_CHOICES = ( + (VERY_HIGH_CRITICALITY, _("Very High")), + (HIGH_CRITICALITY, _("High")), + (MEDIUM_CRITICALITY, _("Medium")), + (LOW_CRITICALITY, _("Low")), + (VERY_LOW_CRITICALITY, _("Very Low")), + (NONE_CRITICALITY, _("None")), + ) + + name = models.CharField(max_length=255, unique=True) + description = models.CharField(max_length=4000) + + product_manager = models.ForeignKey("dojo.Dojo_User", null=True, blank=True, + related_name="product_manager", on_delete=models.RESTRICT) + technical_contact = models.ForeignKey("dojo.Dojo_User", null=True, blank=True, + related_name="technical_contact", on_delete=models.RESTRICT) + team_manager = models.ForeignKey("dojo.Dojo_User", null=True, blank=True, + related_name="team_manager", on_delete=models.RESTRICT) + + prod_type = models.ForeignKey("dojo.Product_Type", related_name="prod_type", + null=False, blank=False, on_delete=models.CASCADE) + sla_configuration = models.ForeignKey("dojo.SLA_Configuration", + related_name="sla_config", + null=False, + blank=False, + default=1, + on_delete=models.RESTRICT) + tid = models.IntegerField(default=0, editable=False) + authorized_users = models.ManyToManyField("dojo.Dojo_User", related_name="authorized_products", blank=True) + prod_numeric_grade = models.IntegerField(null=True, blank=True) + + # Metadata + business_criticality = models.CharField(max_length=9, choices=BUSINESS_CRITICALITY_CHOICES, blank=True, null=True) + platform = models.CharField(max_length=11, choices=PLATFORM_CHOICES, blank=True, null=True) + lifecycle = models.CharField(max_length=12, choices=LIFECYCLE_CHOICES, blank=True, null=True) + origin = models.CharField(max_length=19, choices=ORIGIN_CHOICES, blank=True, null=True) + user_records = models.PositiveIntegerField(blank=True, null=True, help_text=_("Estimate the number of user records within the application.")) + revenue = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True, validators=[MinValueValidator(Decimal("0.00"))], help_text=_("Estimate the application's revenue.")) + external_audience = models.BooleanField(default=False, help_text=_("Specify if the application is used by people outside the organization.")) + internet_accessible = models.BooleanField(default=False, help_text=_("Specify if the application is accessible from the public internet.")) + regulations = models.ManyToManyField("dojo.Regulation", blank=True) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this product. Choose from the list or add new tags. Press Enter key to add.")) + enable_product_tag_inheritance = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Product Tag Inheritance"), + help_text=_("Enables product tag inheritance. Any tags added on a product will automatically be added to all Engagements, Tests, and Findings")) + enable_simple_risk_acceptance = models.BooleanField(default=False, help_text=_("Allows simple risk acceptance by checking/unchecking a checkbox.")) + enable_full_risk_acceptance = models.BooleanField(default=True, help_text=_("Allows full risk acceptance using a risk acceptance form, expiration date, uploaded proof, etc.")) + + disable_sla_breach_notifications = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Disable SLA breach notifications"), + help_text=_("Disable SLA breach notifications if configured in the global settings")) + async_updating = models.BooleanField(default=False, + help_text=_("Findings under this Product or SLA configuration are asynchronously being updated")) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + # get the product's sla config before saving (if this is an existing product) + initial_sla_config = None + if self.pk is not None: + initial_sla_config = getattr(Product.objects.get(pk=self.pk), "sla_configuration", None) + # if initial sla config exists and async finding update is already running, revert sla config before saving + if initial_sla_config and self.async_updating: + self.sla_configuration = initial_sla_config + + super().save(*args, **kwargs) + + # if the initial sla config exists and async finding update is not running + if initial_sla_config is not None and not self.async_updating: + # get the new sla config from the saved product + new_sla_config = getattr(self, "sla_configuration", None) + # if the sla config has changed, update finding sla expiration dates within this product + if new_sla_config and (initial_sla_config != new_sla_config): + # set the async updating flag to true for this product + self.async_updating = True + super().save(*args, **kwargs) + # set the async updating flag to true for the sla config assigned to this product + sla_config = getattr(self, "sla_configuration", None) + if sla_config: + from dojo.models import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + SLA_Configuration, + ) + sla_config.async_updating = True + super(SLA_Configuration, sla_config).save() + # launch the async task to update all finding sla expiration dates + from dojo.sla_config.helpers import async_update_sla_expiration_dates_sla_config_sync # noqa: I001, PLC0415 circular import + from dojo.celery_dispatch import dojo_dispatch_task # noqa: PLC0415 circular import + + dojo_dispatch_task( + async_update_sla_expiration_dates_sla_config_sync, + sla_config.id, + [self.id], + ) + # The async task refetches and resets async_updating on its own copies. + # Mirror that on this in-memory product and the in-memory sla_config so a + # subsequent save() on either does not trigger their lock-revert paths. + self.async_updating = False + if sla_config: + sla_config.async_updating = False + + def get_absolute_url(self): + return reverse("view_product", args=[str(self.id)]) + + @cached_property + def findings_count(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + # if prefetched, it's already there + return self.active_finding_count + except AttributeError: + # ideally it's always prefetched and we can remove this code in the future + self.active_finding_count = Finding.objects.filter(active=True, + test__engagement__product=self).count() + return self.active_finding_count + + @cached_property + def findings_active_verified_count(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + # if prefetched, it's already there + return self.active_verified_finding_count + except AttributeError: + # ideally it's always prefetched and we can remove this code in the future + self.active_verified_finding_count = Finding.objects.filter(active=True, + verified=True, + test__engagement__product=self).count() + return self.active_verified_finding_count + + # TODO: Delete this after the move to Locations + @cached_property + def endpoint_host_count(self): + # active_endpoints is (should be) prefetched + endpoints = getattr(self, "active_endpoints", None) + + hosts = [] + for e in endpoints: + if e.host in hosts: + continue + hosts.append(e.host) + + return len(hosts) + + # TODO: Delete this after the move to Locations + @cached_property + def endpoint_count(self): + # active_endpoints is (should be) prefetched + endpoints = getattr(self, "active_endpoints", None) + if endpoints: + return len(self.active_endpoints) + return 0 + + def open_findings(self, start_date=None, end_date=None): + if start_date is None or end_date is None: + return {} + + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + from dojo.utils import get_system_setting # noqa: PLC0415 circular import + findings = Finding.objects.filter(test__engagement__product=self, + mitigated__isnull=True, + false_p=False, + duplicate=False, + out_of_scope=False, + date__range=[start_date, + end_date]) + + if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): + findings = findings.filter(verified=True) + + critical = findings.filter(severity="Critical").count() + high = findings.filter(severity="High").count() + medium = findings.filter(severity="Medium").count() + low = findings.filter(severity="Low").count() + + return {"Critical": critical, + "High": high, + "Medium": medium, + "Low": low, + "Total": (critical + high + medium + low)} + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": reverse("view_product", args=(self.id,))}] + + @property + def get_product_type(self): + return self.prod_type if self.prod_type is not None else "unknown" + + # only used in APIv2 serializers.py, should be deprecated or at least prefetched + def open_findings_list(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + findings = Finding.objects.filter(test__engagement__product=self, active=True).values_list("id", flat=True) + return list(findings) + + @property + def has_jira_configured(self): + from dojo.jira import services as jira_services # noqa: PLC0415 circular import + return jira_services.has_configured(self) + + def violates_sla(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + findings = Finding.objects.filter(test__engagement__product=self, + active=True, + sla_expiration_date__lt=timezone.now().date()) + return findings.count() > 0 + + +class Product_API_Scan_Configuration(models.Model): + product = models.ForeignKey("dojo.Product", null=False, blank=False, on_delete=models.CASCADE) + tool_configuration = models.ForeignKey("dojo.Tool_Configuration", null=False, blank=False, on_delete=models.CASCADE) + service_key_1 = models.CharField(max_length=200, null=True, blank=True) + service_key_2 = models.CharField(max_length=200, null=True, blank=True) + service_key_3 = models.CharField(max_length=200, null=True, blank=True) + + def __str__(self): + name = self.tool_configuration.name + if self.service_key_1 or self.service_key_2 or self.service_key_3: + name += f" ({self.details})" + return name + + @property + def details(self): + details = "" + if self.service_key_1: + details += f"{self.service_key_1}" + if self.service_key_2: + details += f" | {self.service_key_2}" + if self.service_key_3: + details += f" | {self.service_key_3}" + return details From b0f15f88a640c3f4f0c65bab134effdca492942a Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 21:56:52 +0200 Subject: [PATCH 21/40] refactor(product): move forms + UI filters into dojo/product/ui/ [product Phase 3,4] --- dojo/components/views.py | 2 +- dojo/filters.py | 190 +----------------------------- dojo/forms.py | 144 ++--------------------- dojo/product/ui/__init__.py | 0 dojo/product/ui/filters.py | 213 ++++++++++++++++++++++++++++++++++ dojo/product/ui/forms.py | 154 ++++++++++++++++++++++++ dojo/product/views.py | 20 ++-- dojo/product_type/ui/views.py | 2 +- 8 files changed, 392 insertions(+), 333 deletions(-) create mode 100644 dojo/product/ui/__init__.py create mode 100644 dojo/product/ui/filters.py create mode 100644 dojo/product/ui/forms.py diff --git a/dojo/components/views.py b/dojo/components/views.py index 28e6f720ea8..96f6bcbf4a0 100644 --- a/dojo/components/views.py +++ b/dojo/components/views.py @@ -5,8 +5,8 @@ from django.shortcuts import render from dojo.components.sql_group_concat import Sql_GroupConcat -from dojo.filters import ComponentFilter, ComponentFilterWithoutObjectLookups from dojo.finding.queries import get_authorized_findings +from dojo.product.ui.filters import ComponentFilter, ComponentFilterWithoutObjectLookups from dojo.utils import add_breadcrumb, get_page_items, get_system_setting diff --git a/dojo/filters.py b/dojo/filters.py index 46ca7e0927b..38a4f673376 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -53,7 +53,7 @@ from dojo.finding.queries import get_authorized_findings_for_queryset from dojo.finding_group.queries import get_authorized_finding_groups_for_queryset from dojo.labels import get_labels -from dojo.location.status import FindingLocationStatus, ProductLocationStatus +from dojo.location.status import FindingLocationStatus from dojo.models import ( EFFORT_FOR_FIXING_CHOICES, SEVERITY_CHOICES, @@ -995,194 +995,6 @@ def filter(self, qs, value): return self.options[value][1](self, qs, self.field_name) -class ProductComponentFilter(DojoFilter): - component_name = CharFilter(lookup_expr="icontains", label="Module Name") - component_version = CharFilter(lookup_expr="icontains", label="Module Version") - - o = OrderingFilter( - fields=( - ("component_name", "component_name"), - ("component_version", "component_version"), - ("active", "active"), - ("duplicate", "duplicate"), - ("total", "total"), - ), - field_labels={ - "component_name": "Component Name", - "component_version": "Component Version", - "active": "Active", - "duplicate": "Duplicate", - "total": "Total", - }, - ) - - -class ComponentFilterWithoutObjectLookups(ProductComponentFilter): - test__engagement__product__prod_type__name = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_NAME_LABEL, - help_text=labels.ORG_FILTERS_NAME_HELP) - test__engagement__product__prod_type__name_contains = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - test__engagement__product__name = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="iexact", - label=labels.ASSET_FILTERS_NAME_LABEL, - help_text=labels.ASSET_FILTERS_NAME_HELP) - test__engagement__product__name_contains = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="icontains", - label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) - - -class ComponentFilter(ProductComponentFilter): - test__engagement__product__prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - test__engagement__product = ModelMultipleChoiceFilter( - queryset=Product.objects.none(), - label=labels.ASSET_FILTERS_LABEL) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields[ - "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") - self.form.fields[ - "test__engagement__product"].queryset = get_authorized_products("view") - - -class ProductFilterHelper(FilterSet): - name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_LABEL) - name_exact = CharFilter(field_name="name", lookup_expr="iexact", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) - business_criticality = MultipleChoiceFilter(choices=Product.BUSINESS_CRITICALITY_CHOICES, null_label="Empty") - platform = MultipleChoiceFilter(choices=Product.PLATFORM_CHOICES, null_label="Empty") - lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, null_label="Empty") - origin = MultipleChoiceFilter(choices=Product.ORIGIN_CHOICES, null_label="Empty") - external_audience = BooleanFilter(field_name="external_audience") - internet_accessible = BooleanFilter(field_name="internet_accessible") - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag contains") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - outside_of_sla = ProductSLAFilter(label="Outside of SLA") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - if settings.V3_FEATURE_LOCATIONS: - location_status = MultipleChoiceFilter( - field_name="locations__status", - choices=ProductLocationStatus.choices, - help_text="Status of the Location from the Products relationship", - ) - endpoints__host = CharFilter( - field_name="locations__location__url__host", method="filter_endpoints_host", label="Endpoint Host", - ) - endpoints = NumberFilter(field_name="locations__location", method="filter_endpoints", widget=HiddenInput()) - - def filter_endpoints_host(self, queryset, name, value): - return filter_endpoints_host_base( - queryset, - name, - value, - endpoint_id=self.data.get("endpoints"), - statuses=self.data.getlist("location_status"), - ) - - def filter_endpoints(self, queryset, name, value): - return filter_endpoints_base( - queryset, - name, - value, - statuses=self.data.getlist("location_status"), - host=self.data.get("endpoints__host"), - ) - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ("name_exact", "name_exact"), - ("prod_type__name", "prod_type__name"), - ("business_criticality", "business_criticality"), - ("platform", "platform"), - ("lifecycle", "lifecycle"), - ("origin", "origin"), - ("external_audience", "external_audience"), - ("internet_accessible", "internet_accessible"), - ("findings_count", "findings_count"), - ), - field_labels={ - "name": labels.ASSET_FILTERS_NAME_LABEL, - "name_exact": labels.ASSET_FILTERS_NAME_EXACT_LABEL, - "prod_type__name": labels.ORG_FILTERS_LABEL, - "business_criticality": "Business Criticality", - "platform": "Platform ", - "lifecycle": "Lifecycle ", - "origin": "Origin ", - "external_audience": "External Audience ", - "internet_accessible": "Internet Accessible ", - "findings_count": "Findings Count ", - }, - ) - - -class ProductFilter(ProductFilterHelper, DojoFilter): - prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - queryset=Product.tags.tag_model.objects.all().order_by("name")) - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Product.tags.tag_model.objects.all().order_by("name")) - - def __init__(self, *args, **kwargs): - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - super().__init__(*args, **kwargs) - self.form.fields["prod_type"].queryset = get_authorized_product_types("view") - self.form.fields["tags"].help_text = labels.ASSET_FILTERS_TAGS_HELP - self.form.fields["not_tags"].help_text = labels.ASSET_FILTERS_NOT_TAGS_HELP - - class Meta: - model = Product - fields = [ - "name", "name_exact", "prod_type", "business_criticality", - "platform", "lifecycle", "origin", "external_audience", - "internet_accessible", "tags", - ] - - -class ProductFilterWithoutObjectLookups(ProductFilterHelper): - prod_type__name = CharFilter( - field_name="prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_NAME_LABEL, - help_text=labels.ORG_FILTERS_NAME_HELP) - prod_type__name_contains = CharFilter( - field_name="prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - - def __init__(self, *args, **kwargs): - kwargs.pop("user", None) - super().__init__(*args, **kwargs) - - class Meta: - model = Product - fields = [ - "name", "name_exact", "business_criticality", "platform", - "lifecycle", "origin", "external_audience", "internet_accessible", - ] - - class ApiDojoMetaFilter(DojoFilter): name_case_insensitive = CharFilter(field_name="name", lookup_expr="iexact") value_case_insensitive = CharFilter(field_name="value", lookup_expr="iexact") diff --git a/dojo/forms.py b/dojo/forms.py index 5bbf64306fe..22a8fd87f31 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -271,61 +271,6 @@ class Meta: fields = ["id"] -class ProductForm(forms.ModelForm): - name = forms.CharField(max_length=255, required=True) - description = forms.CharField(widget=forms.Textarea(attrs={}), - required=True) - - prod_type = forms.ModelChoiceField(label=labels.ORG_LABEL, - queryset=Product_Type.objects.none(), - required=True) - - sla_configuration = forms.ModelChoiceField(label="SLA Configuration", - queryset=SLA_Configuration.objects.all(), - required=True, - initial="Default") - - product_manager = forms.ModelChoiceField(label=labels.ASSET_MANAGER_LABEL, - queryset=Dojo_User.objects.exclude(is_active=False).order_by("first_name", "last_name"), required=False) - technical_contact = forms.ModelChoiceField(queryset=Dojo_User.objects.exclude(is_active=False).order_by("first_name", "last_name"), required=False) - team_manager = forms.ModelChoiceField(queryset=Dojo_User.objects.exclude(is_active=False).order_by("first_name", "last_name"), required=False) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["prod_type"].queryset = get_authorized_product_types("add") - self.fields["enable_product_tag_inheritance"].label = labels.ASSET_TAG_INHERITANCE_ENABLE_LABEL - self.fields["enable_product_tag_inheritance"].help_text = labels.ASSET_TAG_INHERITANCE_ENABLE_HELP - if prod_type_id := kwargs.get("instance", Product()).prod_type_id: # we are editing existing instance - self.fields["prod_type"].queryset |= Product_Type.objects.filter(pk=prod_type_id) # even if user does not have permission for any other ProdType we need to add at least assign ProdType to make form submittable (otherwise empty list was here which generated invalid form) - - # if this product has findings being asynchronously updated, disable the sla config field - if self.instance.async_updating: - self.fields["sla_configuration"].disabled = True - self.fields["sla_configuration"].widget.attrs["message"] = ( - "Finding SLA expiration dates are currently being recalculated. " - "This field cannot be changed until the calculation is complete." - ) - - class Meta: - model = Product - fields = ["name", "description", "tags", "product_manager", "technical_contact", "team_manager", "prod_type", "sla_configuration", "regulations", - "business_criticality", "platform", "lifecycle", "origin", "user_records", "revenue", "external_audience", "enable_product_tag_inheritance", - "internet_accessible", "enable_simple_risk_acceptance", "enable_full_risk_acceptance", "disable_sla_breach_notifications"] - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - -class DeleteProductForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Product - fields = ["id"] - - class EditFindingGroupForm(forms.ModelForm): name = forms.CharField(max_length=255, required=True, label="Finding Group Name") jira_issue = forms.CharField(max_length=255, required=False, label="Linked JIRA Issue", @@ -358,37 +303,6 @@ class Meta: fields = ["id"] -class Add_Product_AuthorizedUsersForm(forms.Form): - users = forms.ModelMultipleChoiceField( - queryset=Dojo_User.objects.none(), required=True, label="Users", - ) - - def __init__(self, *args, product=None, **kwargs): - super().__init__(*args, **kwargs) - self.product = product - current = product.authorized_users.values_list("pk", flat=True) - self.fields["users"].queryset = ( - Dojo_User.objects.filter(is_active=True) - .exclude(is_superuser=True) - .exclude(pk__in=current) - .order_by("first_name", "last_name") - ) - - -class Authorize_User_For_ProductsForm(forms.Form): - products = forms.ModelMultipleChoiceField( - queryset=Product.objects.none(), required=True, label=labels.ASSET_PLURAL_LABEL, - ) - - def __init__(self, *args, user=None, **kwargs): - super().__init__(*args, **kwargs) - self.user = user - # Show products the user is not already directly authorized for. - self.fields["products"].queryset = ( - Product.objects.exclude(authorized_users=user).order_by("name") - ) - - class Authorize_User_For_ProductTypesForm(forms.Form): product_types = forms.ModelMultipleChoiceField( queryset=Product_Type.objects.none(), required=True, label=labels.ORG_PLURAL_LABEL, @@ -2231,16 +2145,16 @@ def __init__(self, *args, **kwargs): self.fields.pop("reset_api_token", None) -def get_years(): - now = timezone.now() - return [(now.year, now.year), (now.year - 1, now.year - 1), (now.year - 2, now.year - 2)] - - -class ProductCountsFormBase(forms.Form): - month = forms.ChoiceField(choices=list(MONTHS.items()), required=True, error_messages={ - "required": "*"}) - year = forms.ChoiceField(choices=get_years, required=True, error_messages={ - "required": "*"}) +# Product forms live in dojo/product/ui/forms.py. Re-exported here for backward +# compat: ProductCountsFormBase is subclassed by ProductTypeCountsForm below, +# Authorize_User_For_ProductsForm by dojo/user/views.py, ProductTagCountsForm by +# dojo/metrics/views.py. The other product forms are imported directly from +# dojo.product.ui.forms by the product module's own views. +from dojo.product.ui.forms import ( # noqa: E402, F401 -- backward compat + Authorize_User_For_ProductsForm, + ProductCountsFormBase, + ProductTagCountsForm, +) class ProductTypeCountsForm(ProductCountsFormBase): @@ -2255,20 +2169,6 @@ def __init__(self, *args, **kwargs): self.fields["product_type"].queryset = get_authorized_product_types("view") -class ProductTagCountsForm(ProductCountsFormBase): - product_tag = forms.ModelChoiceField(required=True, - queryset=Product.tags.tag_model.objects.none().order_by("name"), - label=labels.ASSET_TAG_LABEL, - error_messages={ - "required": "*"}) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - prods = get_authorized_products("view") - tags_available_to_user = Product.tags.tag_model.objects.filter(product__in=prods) - self.fields["product_tag"].queryset = tags_available_to_user - - class APIKeyForm(forms.ModelForm): id = forms.IntegerField(required=True, widget=forms.widgets.HiddenInput()) @@ -2351,30 +2251,6 @@ class Meta: fields = ["id"] -class Product_API_Scan_ConfigurationForm(forms.ModelForm): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - tool_configuration = forms.ModelChoiceField( - label="Tool Configuration", - queryset=Tool_Configuration.objects.all().order_by("name"), - required=True, - ) - - class Meta: - model = Product_API_Scan_Configuration - exclude = ["product"] - - -class DeleteProduct_API_Scan_ConfigurationForm(forms.ModelForm): - id = forms.IntegerField(required=True, widget=forms.widgets.HiddenInput()) - - class Meta: - model = Product_API_Scan_Configuration - fields = ["id"] - - class ToolTypeForm(forms.ModelForm): class Meta: model = Tool_Type diff --git a/dojo/product/ui/__init__.py b/dojo/product/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/product/ui/filters.py b/dojo/product/ui/filters.py new file mode 100644 index 00000000000..f5deccf9a02 --- /dev/null +++ b/dojo/product/ui/filters.py @@ -0,0 +1,213 @@ +from django.conf import settings +from django.forms import HiddenInput +from django_filters import ( + BooleanFilter, + CharFilter, + FilterSet, + ModelMultipleChoiceFilter, + MultipleChoiceFilter, + NumberFilter, + OrderingFilter, +) + +from dojo.filters import ( + DojoFilter, + ProductSLAFilter, + filter_endpoints_base, + filter_endpoints_host_base, +) +from dojo.labels import get_labels +from dojo.location.status import ProductLocationStatus +from dojo.models import Product, Product_Type +from dojo.product.queries import get_authorized_products +from dojo.product_type.queries import get_authorized_product_types + +labels = get_labels() + + +class ProductComponentFilter(DojoFilter): + component_name = CharFilter(lookup_expr="icontains", label="Module Name") + component_version = CharFilter(lookup_expr="icontains", label="Module Version") + + o = OrderingFilter( + fields=( + ("component_name", "component_name"), + ("component_version", "component_version"), + ("active", "active"), + ("duplicate", "duplicate"), + ("total", "total"), + ), + field_labels={ + "component_name": "Component Name", + "component_version": "Component Version", + "active": "Active", + "duplicate": "Duplicate", + "total": "Total", + }, + ) + + +class ComponentFilterWithoutObjectLookups(ProductComponentFilter): + test__engagement__product__prod_type__name = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_NAME_LABEL, + help_text=labels.ORG_FILTERS_NAME_HELP) + test__engagement__product__prod_type__name_contains = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + test__engagement__product__name = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="iexact", + label=labels.ASSET_FILTERS_NAME_LABEL, + help_text=labels.ASSET_FILTERS_NAME_HELP) + test__engagement__product__name_contains = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="icontains", + label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) + + +class ComponentFilter(ProductComponentFilter): + test__engagement__product__prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + test__engagement__product = ModelMultipleChoiceFilter( + queryset=Product.objects.none(), + label=labels.ASSET_FILTERS_LABEL) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields[ + "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") + self.form.fields[ + "test__engagement__product"].queryset = get_authorized_products("view") + + +class ProductFilterHelper(FilterSet): + name = CharFilter(lookup_expr="icontains", label=labels.ASSET_FILTERS_NAME_LABEL) + name_exact = CharFilter(field_name="name", lookup_expr="iexact", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) + business_criticality = MultipleChoiceFilter(choices=Product.BUSINESS_CRITICALITY_CHOICES, null_label="Empty") + platform = MultipleChoiceFilter(choices=Product.PLATFORM_CHOICES, null_label="Empty") + lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, null_label="Empty") + origin = MultipleChoiceFilter(choices=Product.ORIGIN_CHOICES, null_label="Empty") + external_audience = BooleanFilter(field_name="external_audience") + internet_accessible = BooleanFilter(field_name="internet_accessible") + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag contains") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + outside_of_sla = ProductSLAFilter(label="Outside of SLA") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + if settings.V3_FEATURE_LOCATIONS: + location_status = MultipleChoiceFilter( + field_name="locations__status", + choices=ProductLocationStatus.choices, + help_text="Status of the Location from the Products relationship", + ) + endpoints__host = CharFilter( + field_name="locations__location__url__host", method="filter_endpoints_host", label="Endpoint Host", + ) + endpoints = NumberFilter(field_name="locations__location", method="filter_endpoints", widget=HiddenInput()) + + def filter_endpoints_host(self, queryset, name, value): + return filter_endpoints_host_base( + queryset, + name, + value, + endpoint_id=self.data.get("endpoints"), + statuses=self.data.getlist("location_status"), + ) + + def filter_endpoints(self, queryset, name, value): + return filter_endpoints_base( + queryset, + name, + value, + statuses=self.data.getlist("location_status"), + host=self.data.get("endpoints__host"), + ) + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ("name_exact", "name_exact"), + ("prod_type__name", "prod_type__name"), + ("business_criticality", "business_criticality"), + ("platform", "platform"), + ("lifecycle", "lifecycle"), + ("origin", "origin"), + ("external_audience", "external_audience"), + ("internet_accessible", "internet_accessible"), + ("findings_count", "findings_count"), + ), + field_labels={ + "name": labels.ASSET_FILTERS_NAME_LABEL, + "name_exact": labels.ASSET_FILTERS_NAME_EXACT_LABEL, + "prod_type__name": labels.ORG_FILTERS_LABEL, + "business_criticality": "Business Criticality", + "platform": "Platform ", + "lifecycle": "Lifecycle ", + "origin": "Origin ", + "external_audience": "External Audience ", + "internet_accessible": "Internet Accessible ", + "findings_count": "Findings Count ", + }, + ) + + +class ProductFilter(ProductFilterHelper, DojoFilter): + prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + queryset=Product.tags.tag_model.objects.all().order_by("name")) + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Product.tags.tag_model.objects.all().order_by("name")) + + def __init__(self, *args, **kwargs): + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + self.form.fields["prod_type"].queryset = get_authorized_product_types("view") + self.form.fields["tags"].help_text = labels.ASSET_FILTERS_TAGS_HELP + self.form.fields["not_tags"].help_text = labels.ASSET_FILTERS_NOT_TAGS_HELP + + class Meta: + model = Product + fields = [ + "name", "name_exact", "prod_type", "business_criticality", + "platform", "lifecycle", "origin", "external_audience", + "internet_accessible", "tags", + ] + + +class ProductFilterWithoutObjectLookups(ProductFilterHelper): + prod_type__name = CharFilter( + field_name="prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_NAME_LABEL, + help_text=labels.ORG_FILTERS_NAME_HELP) + prod_type__name_contains = CharFilter( + field_name="prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + + def __init__(self, *args, **kwargs): + kwargs.pop("user", None) + super().__init__(*args, **kwargs) + + class Meta: + model = Product + fields = [ + "name", "name_exact", "business_criticality", "platform", + "lifecycle", "origin", "external_audience", "internet_accessible", + ] diff --git a/dojo/product/ui/forms.py b/dojo/product/ui/forms.py new file mode 100644 index 00000000000..9f86884bfea --- /dev/null +++ b/dojo/product/ui/forms.py @@ -0,0 +1,154 @@ +from django import forms +from django.utils import timezone +from django.utils.dates import MONTHS + +from dojo.labels import get_labels +from dojo.models import ( + Dojo_User, + Product, + Product_API_Scan_Configuration, + Product_Type, + SLA_Configuration, + Tool_Configuration, +) +from dojo.product.queries import get_authorized_products +from dojo.product_type.queries import get_authorized_product_types +from dojo.validators import tag_validator + +labels = get_labels() + + +class ProductForm(forms.ModelForm): + name = forms.CharField(max_length=255, required=True) + description = forms.CharField(widget=forms.Textarea(attrs={}), + required=True) + + prod_type = forms.ModelChoiceField(label=labels.ORG_LABEL, + queryset=Product_Type.objects.none(), + required=True) + + sla_configuration = forms.ModelChoiceField(label="SLA Configuration", + queryset=SLA_Configuration.objects.all(), + required=True, + initial="Default") + + product_manager = forms.ModelChoiceField(label=labels.ASSET_MANAGER_LABEL, + queryset=Dojo_User.objects.exclude(is_active=False).order_by("first_name", "last_name"), required=False) + technical_contact = forms.ModelChoiceField(queryset=Dojo_User.objects.exclude(is_active=False).order_by("first_name", "last_name"), required=False) + team_manager = forms.ModelChoiceField(queryset=Dojo_User.objects.exclude(is_active=False).order_by("first_name", "last_name"), required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["prod_type"].queryset = get_authorized_product_types("add") + self.fields["enable_product_tag_inheritance"].label = labels.ASSET_TAG_INHERITANCE_ENABLE_LABEL + self.fields["enable_product_tag_inheritance"].help_text = labels.ASSET_TAG_INHERITANCE_ENABLE_HELP + if prod_type_id := kwargs.get("instance", Product()).prod_type_id: # we are editing existing instance + self.fields["prod_type"].queryset |= Product_Type.objects.filter(pk=prod_type_id) # even if user does not have permission for any other ProdType we need to add at least assign ProdType to make form submittable (otherwise empty list was here which generated invalid form) + + # if this product has findings being asynchronously updated, disable the sla config field + if self.instance.async_updating: + self.fields["sla_configuration"].disabled = True + self.fields["sla_configuration"].widget.attrs["message"] = ( + "Finding SLA expiration dates are currently being recalculated. " + "This field cannot be changed until the calculation is complete." + ) + + class Meta: + model = Product + fields = ["name", "description", "tags", "product_manager", "technical_contact", "team_manager", "prod_type", "sla_configuration", "regulations", + "business_criticality", "platform", "lifecycle", "origin", "user_records", "revenue", "external_audience", "enable_product_tag_inheritance", + "internet_accessible", "enable_simple_risk_acceptance", "enable_full_risk_acceptance", "disable_sla_breach_notifications"] + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + +class DeleteProductForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Product + fields = ["id"] + + +class Add_Product_AuthorizedUsersForm(forms.Form): + users = forms.ModelMultipleChoiceField( + queryset=Dojo_User.objects.none(), required=True, label="Users", + ) + + def __init__(self, *args, product=None, **kwargs): + super().__init__(*args, **kwargs) + self.product = product + current = product.authorized_users.values_list("pk", flat=True) + self.fields["users"].queryset = ( + Dojo_User.objects.filter(is_active=True) + .exclude(is_superuser=True) + .exclude(pk__in=current) + .order_by("first_name", "last_name") + ) + + +class Authorize_User_For_ProductsForm(forms.Form): + products = forms.ModelMultipleChoiceField( + queryset=Product.objects.none(), required=True, label=labels.ASSET_PLURAL_LABEL, + ) + + def __init__(self, *args, user=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + # Show products the user is not already directly authorized for. + self.fields["products"].queryset = ( + Product.objects.exclude(authorized_users=user).order_by("name") + ) + + +def get_years(): + now = timezone.now() + return [(now.year, now.year), (now.year - 1, now.year - 1), (now.year - 2, now.year - 2)] + + +class ProductCountsFormBase(forms.Form): + month = forms.ChoiceField(choices=list(MONTHS.items()), required=True, error_messages={ + "required": "*"}) + year = forms.ChoiceField(choices=get_years, required=True, error_messages={ + "required": "*"}) + + +class ProductTagCountsForm(ProductCountsFormBase): + product_tag = forms.ModelChoiceField(required=True, + queryset=Product.tags.tag_model.objects.none().order_by("name"), + label=labels.ASSET_TAG_LABEL, + error_messages={ + "required": "*"}) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + prods = get_authorized_products("view") + tags_available_to_user = Product.tags.tag_model.objects.filter(product__in=prods) + self.fields["product_tag"].queryset = tags_available_to_user + + +class Product_API_Scan_ConfigurationForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + tool_configuration = forms.ModelChoiceField( + label="Tool Configuration", + queryset=Tool_Configuration.objects.all().order_by("name"), + required=True, + ) + + class Meta: + model = Product_API_Scan_Configuration + exclude = ["product"] + + +class DeleteProduct_API_Scan_ConfigurationForm(forms.ModelForm): + id = forms.IntegerField(required=True, widget=forms.widgets.HiddenInput()) + + class Meta: + model = Product_API_Scan_Configuration + fields = ["id"] diff --git a/dojo/product/views.py b/dojo/product/views.py index f9dab849576..a5ef6acbd62 100644 --- a/dojo/product/views.py +++ b/dojo/product/views.py @@ -40,18 +40,12 @@ MetricsEndpointFilterWithoutObjectLookups, MetricsFindingFilter, MetricsFindingFilterWithoutObjectLookups, - ProductComponentFilter, - ProductFilter, - ProductFilterWithoutObjectLookups, ) from dojo.forms import ( - Add_Product_AuthorizedUsersForm, AdHocFindingForm, AppAnalysisForm, DeleteAppAnalysisForm, DeleteEngagementPresetsForm, - DeleteProduct_API_Scan_ConfigurationForm, - DeleteProductForm, DojoMetaFormSet, EngagementPresetsForm, EngForm, @@ -60,8 +54,6 @@ JIRAEngagementForm, JIRAFindingForm, JIRAProjectForm, - Product_API_Scan_ConfigurationForm, - ProductForm, ProductNotificationsForm, SLA_Configuration, ) @@ -94,6 +86,18 @@ from dojo.product.queries import ( get_authorized_products, ) +from dojo.product.ui.filters import ( + ProductComponentFilter, + ProductFilter, + ProductFilterWithoutObjectLookups, +) +from dojo.product.ui.forms import ( + Add_Product_AuthorizedUsersForm, + DeleteProduct_API_Scan_ConfigurationForm, + DeleteProductForm, + Product_API_Scan_ConfigurationForm, + ProductForm, +) from dojo.product_type.queries import ( get_authorized_product_types, ) diff --git a/dojo/product_type/ui/views.py b/dojo/product_type/ui/views.py index 79353d5fd4b..562646e0392 100644 --- a/dojo/product_type/ui/views.py +++ b/dojo/product_type/ui/views.py @@ -15,7 +15,6 @@ from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.roles_permissions import Permissions -from dojo.filters import ProductFilter, ProductFilterWithoutObjectLookups from dojo.forms import ( Add_Product_Type_AuthorizedUsersForm, Delete_Product_TypeForm, @@ -24,6 +23,7 @@ from dojo.labels import get_labels from dojo.models import Dojo_User, Endpoint, Finding, Product, Product_Type from dojo.product.queries import get_authorized_products +from dojo.product.ui.filters import ProductFilter, ProductFilterWithoutObjectLookups from dojo.product_type.queries import ( get_authorized_product_types, ) From 8a3ddb9688f4b6dc322b6633ecef8a53bbcf7e90 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 21:57:57 +0200 Subject: [PATCH 22/40] refactor(product): move views into dojo/product/ui/ [product Phase 5] --- dojo/asset/urls.py | 2 +- dojo/organization/urls.py | 2 +- dojo/product/{ => ui}/views.py | 0 unittests/test_product_metrics_closed_count.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename dojo/product/{ => ui}/views.py (100%) diff --git a/dojo/asset/urls.py b/dojo/asset/urls.py index 93be5e217e0..d072060fb6e 100644 --- a/dojo/asset/urls.py +++ b/dojo/asset/urls.py @@ -2,7 +2,7 @@ from django.urls import re_path from dojo.engagement.ui import views as dojo_engagement_views -from dojo.product import views +from dojo.product.ui import views from dojo.utils import redirect_view # TODO: remove the else: branch once v3 migration is complete diff --git a/dojo/organization/urls.py b/dojo/organization/urls.py index 3487cee1bf2..e3801572bd8 100644 --- a/dojo/organization/urls.py +++ b/dojo/organization/urls.py @@ -1,7 +1,7 @@ from django.conf import settings from django.urls import re_path -from dojo.product import views as product_views +from dojo.product.ui import views as product_views from dojo.product_type.ui import views from dojo.utils import redirect_view diff --git a/dojo/product/views.py b/dojo/product/ui/views.py similarity index 100% rename from dojo/product/views.py rename to dojo/product/ui/views.py diff --git a/unittests/test_product_metrics_closed_count.py b/unittests/test_product_metrics_closed_count.py index 80b9c4dbd62..31b978b5b6a 100644 --- a/unittests/test_product_metrics_closed_count.py +++ b/unittests/test_product_metrics_closed_count.py @@ -18,7 +18,7 @@ from django.test import RequestFactory from dojo.models import Engagement, Finding, Product, Product_Type, Test, Test_Type, User -from dojo.product.views import finding_queries +from dojo.product.ui.views import finding_queries from .dojo_test_case import DojoTestCase From d6329f75b0a1b98504fc2ecce7216e71398d7c17 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 22:06:08 +0200 Subject: [PATCH 23/40] refactor(product): extract API layer into dojo/product/api/ [product Phase 6,7,8,9] --- dojo/api_v2/serializers.py | 60 +++----------- dojo/api_v2/views.py | 110 -------------------------- dojo/filters.py | 75 ------------------ dojo/product/api/__init__.py | 1 + dojo/product/api/filters.py | 97 +++++++++++++++++++++++ dojo/product/api/serializer.py | 60 ++++++++++++++ dojo/product/api/urls.py | 7 ++ dojo/product/api/views.py | 129 +++++++++++++++++++++++++++++++ dojo/urls.py | 6 +- unittests/test_rest_framework.py | 3 +- 10 files changed, 307 insertions(+), 241 deletions(-) create mode 100644 dojo/product/api/__init__.py create mode 100644 dojo/product/api/filters.py create mode 100644 dojo/product/api/serializer.py create mode 100644 dojo/product/api/urls.py create mode 100644 dojo/product/api/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index ddee30d976a..fd91c874775 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -466,12 +466,6 @@ def validate(self, data): return data -class ProductMetaSerializer(serializers.ModelSerializer): - class Meta: - model = DojoMeta - fields = ("name", "value") - - class UserSerializer(serializers.ModelSerializer): date_joined = serializers.DateTimeField(read_only=True) last_login = serializers.DateTimeField(read_only=True, allow_null=True) @@ -728,6 +722,16 @@ class Meta: # RiskAcceptanceSerializer (below) still reference it. The other engagement # serializers are imported directly from dojo.engagement.api by their consumers. from dojo.engagement.api.serializer import EngagementSerializer # noqa: E402 -- backward compat + +# Product serializers live in dojo/product/api/serializer.py. ProductSerializer is +# re-exported because ReportGenerateSerializer (below) still references it; +# ProductMetaSerializer because dojo/asset/api/serializers.py imports it. +# ProductAPIScanConfigurationSerializer is imported directly from +# dojo.product.api.serializer by its only consumer (the viewset). +from dojo.product.api.serializer import ( # noqa: E402 -- backward compat + ProductMetaSerializer, # noqa: F401 -- backward compat + ProductSerializer, +) from dojo.product_type.api.serializer import ProductTypeSerializer # noqa: E402 @@ -946,12 +950,6 @@ class Meta: fields = "__all__" -class ProductAPIScanConfigurationSerializer(serializers.ModelSerializer): - class Meta: - model = Product_API_Scan_Configuration - fields = "__all__" - - class DevelopmentEnvironmentSerializer(serializers.ModelSerializer): class Meta: model = Development_Environment @@ -1622,44 +1620,6 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) -class ProductSerializer(serializers.ModelSerializer): - findings_count = serializers.SerializerMethodField() - findings_list = serializers.SerializerMethodField() - - business_criticality = serializers.ChoiceField(choices=Product.BUSINESS_CRITICALITY_CHOICES, allow_blank=True, allow_null=True, required=False) - platform = serializers.ChoiceField(choices=Product.PLATFORM_CHOICES, allow_blank=True, allow_null=True, required=False) - lifecycle = serializers.ChoiceField(choices=Product.LIFECYCLE_CHOICES, allow_blank=True, allow_null=True, required=False) - origin = serializers.ChoiceField(choices=Product.ORIGIN_CHOICES, allow_blank=True, allow_null=True, required=False) - - tags = TagListSerializerField(required=False) - product_meta = ProductMetaSerializer(read_only=True, many=True) - - class Meta: - model = Product - exclude = ( - "tid", - "updated", - "async_updating", - ) - - def validate(self, data): - async_updating = getattr(self.instance, "async_updating", None) - if async_updating: - new_sla_config = data.get("sla_configuration", None) - old_sla_config = getattr(self.instance, "sla_configuration", None) - if new_sla_config and old_sla_config and new_sla_config != old_sla_config: - msg = "Finding SLA expiration dates are currently being recalculated. The SLA configuration for this product cannot be changed until the calculation is complete." - raise serializers.ValidationError(msg) - return data - - def get_findings_count(self, obj) -> int: - return obj.findings_count - - # TODO: maybe extend_schema_field is needed here? - def get_findings_list(self, obj) -> list[int]: - return obj.open_findings_list() - - class CommonImportScanSerializer(serializers.Serializer): scan_date = serializers.DateField( required=False, diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 51f06c4b029..653dfaf2d8d 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -56,7 +56,6 @@ ApiDojoMetaFilter, ApiEndpointFilter, ApiFindingFilter, - ApiProductFilter, ApiRiskAcceptanceFilter, ApiTemplateFindingFilter, ApiUserFilter, @@ -93,7 +92,6 @@ NoteHistory, Notes, Product, - Product_API_Scan_Configuration, Regulation, Risk_Acceptance, SLA_Configuration, @@ -111,7 +109,6 @@ get_authorized_app_analysis, get_authorized_dojo_meta, get_authorized_languages, - get_authorized_product_api_scan_configurations, get_authorized_products, ) from dojo.query_utils import build_count_subquery @@ -127,12 +124,10 @@ from dojo.user.authentication import reset_token_for_user from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( - async_delete, generate_file_response, get_celery_queue_details, get_celery_queue_length, get_celery_worker_status, - get_setting, get_system_setting, process_tag_notifications, purge_celery_queue, @@ -1258,31 +1253,6 @@ def get_queryset(self): # Authorization: object-based @extend_schema_view(**schema_with_prefetch()) -class ProductAPIScanConfigurationViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.ProductAPIScanConfigurationSerializer - queryset = Product_API_Scan_Configuration.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "product", - "tool_configuration", - "service_key_1", - "service_key_2", - "service_key_3", - ] - permission_classes = ( - IsAuthenticated, - permissions.UserHasProductAPIScanConfigurationPermission, - ) - - def get_queryset(self): - return get_authorized_product_api_scan_configurations( - "view", - ) - - # Authorization: object-based # @extend_schema_view(**schema_with_prefetch()) # Nested models with prefetch make the response schema too long for Swagger UI @@ -1376,86 +1346,6 @@ def process_patch(self, request): raise ValidationError(msg) -@extend_schema_view(**schema_with_prefetch()) -class ProductViewSet( - prefetch.PrefetchListMixin, - prefetch.PrefetchRetrieveMixin, - mixins.CreateModelMixin, - mixins.DestroyModelMixin, - mixins.UpdateModelMixin, - viewsets.GenericViewSet, - dojo_mixins.DeletePreviewModelMixin, -): - serializer_class = serializers.ProductSerializer - queryset = Product.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiProductFilter - permission_classes = ( - IsAuthenticated, - permissions.UserHasProductPermission, - ) - - def get_queryset(self): - return get_authorized_products("view").distinct() - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if get_setting("ASYNC_OBJECT_DELETE"): - async_del = async_delete() - async_del.delete(instance) - else: - with Endpoint.allow_endpoint_init(): # TODO: Delete this after the move to Locations - instance.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - # def list(self, request): - # # Note the use of `get_queryset()` instead of `self.queryset` - # queryset = self.get_queryset() - # serializer = self.serializer_class(queryset, many=True) - # return Response(serializer.data) - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=True, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request, pk=None): - product = self.get_object() - - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, product, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - # Authorization: authenticated, configuration class DevelopmentEnvironmentViewSet( DojoModelViewSet, diff --git a/dojo/filters.py b/dojo/filters.py index 38a4f673376..55afc778196 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -1019,81 +1019,6 @@ class Meta: ] -class ApiProductFilter(DojoFilter): - # BooleanFilter - external_audience = BooleanFilter(field_name="external_audience") - internet_accessible = BooleanFilter(field_name="internet_accessible") - # CharFilter - name = CharFilter(lookup_expr="icontains") - name_exact = CharFilter(field_name="name", lookup_expr="iexact") - description = CharFilter(lookup_expr="icontains") - business_criticality = MultipleChoiceFilter(choices=Product.BUSINESS_CRITICALITY_CHOICES) - platform = MultipleChoiceFilter(choices=Product.PLATFORM_CHOICES) - lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES) - origin = MultipleChoiceFilter(choices=Product.ORIGIN_CHOICES) - # NumberInFilter - id = NumberInFilter(field_name="id", lookup_expr="in") - product_manager = NumberInFilter(field_name="product_manager", lookup_expr="in") - technical_contact = NumberInFilter(field_name="technical_contact", lookup_expr="in") - team_manager = NumberInFilter(field_name="team_manager", lookup_expr="in") - prod_type = NumberInFilter(field_name="prod_type", lookup_expr="in") - tid = NumberInFilter(field_name="tid", lookup_expr="in") - prod_numeric_grade = NumberInFilter(field_name="prod_numeric_grade", lookup_expr="in") - user_records = NumberInFilter(field_name="user_records", lookup_expr="in") - regulations = NumberInFilter(field_name="regulations", lookup_expr="in") - - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, exclude="True") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - outside_of_sla = extend_schema_field(OpenApiTypes.NUMBER)(ProductSLAFilter()) - - # DateRangeFilter - created = DateRangeFilter() - updated = DateRangeFilter() - # NumberFilter - revenue = NumberFilter() - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("id", "id"), - ("tid", "tid"), - ("name", "name"), - ("created", "created"), - ("prod_numeric_grade", "prod_numeric_grade"), - ("business_criticality", "business_criticality"), - ("platform", "platform"), - ("lifecycle", "lifecycle"), - ("origin", "origin"), - ("revenue", "revenue"), - ("external_audience", "external_audience"), - ("internet_accessible", "internet_accessible"), - ("product_manager", "product_manager"), - ("product_manager__first_name", "product_manager__first_name"), - ("product_manager__last_name", "product_manager__last_name"), - ("technical_contact", "technical_contact"), - ("technical_contact__first_name", "technical_contact__first_name"), - ("technical_contact__last_name", "technical_contact__last_name"), - ("team_manager", "team_manager"), - ("team_manager__first_name", "team_manager__first_name"), - ("team_manager__last_name", "team_manager__last_name"), - ("prod_type", "prod_type"), - ("prod_type__name", "prod_type__name"), - ("updated", "updated"), - ("user_records", "user_records"), - ), - ) - - class PercentageRangeFilter(RangeFilter): def filter(self, qs, value): if value is not None: diff --git a/dojo/product/api/__init__.py b/dojo/product/api/__init__.py new file mode 100644 index 00000000000..36b10db0174 --- /dev/null +++ b/dojo/product/api/__init__.py @@ -0,0 +1 @@ +path = "products" # noqa: RUF067 diff --git a/dojo/product/api/filters.py b/dojo/product/api/filters.py new file mode 100644 index 00000000000..e75b95684ad --- /dev/null +++ b/dojo/product/api/filters.py @@ -0,0 +1,97 @@ +from django_filters import ( + BooleanFilter, + CharFilter, + MultipleChoiceFilter, + NumberFilter, + OrderingFilter, +) +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field + +from dojo.filters import ( + CharFieldFilterANDExpression, + CharFieldInFilter, + DateRangeFilter, + DojoFilter, + NumberInFilter, + ProductSLAFilter, +) +from dojo.labels import get_labels +from dojo.models import Product + +labels = get_labels() + + +class ApiProductFilter(DojoFilter): + # BooleanFilter + external_audience = BooleanFilter(field_name="external_audience") + internet_accessible = BooleanFilter(field_name="internet_accessible") + # CharFilter + name = CharFilter(lookup_expr="icontains") + name_exact = CharFilter(field_name="name", lookup_expr="iexact") + description = CharFilter(lookup_expr="icontains") + business_criticality = MultipleChoiceFilter(choices=Product.BUSINESS_CRITICALITY_CHOICES) + platform = MultipleChoiceFilter(choices=Product.PLATFORM_CHOICES) + lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES) + origin = MultipleChoiceFilter(choices=Product.ORIGIN_CHOICES) + # NumberInFilter + id = NumberInFilter(field_name="id", lookup_expr="in") + product_manager = NumberInFilter(field_name="product_manager", lookup_expr="in") + technical_contact = NumberInFilter(field_name="technical_contact", lookup_expr="in") + team_manager = NumberInFilter(field_name="team_manager", lookup_expr="in") + prod_type = NumberInFilter(field_name="prod_type", lookup_expr="in") + tid = NumberInFilter(field_name="tid", lookup_expr="in") + prod_numeric_grade = NumberInFilter(field_name="prod_numeric_grade", lookup_expr="in") + user_records = NumberInFilter(field_name="user_records", lookup_expr="in") + regulations = NumberInFilter(field_name="regulations", lookup_expr="in") + + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, exclude="True") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + outside_of_sla = extend_schema_field(OpenApiTypes.NUMBER)(ProductSLAFilter()) + + # DateRangeFilter + created = DateRangeFilter() + updated = DateRangeFilter() + # NumberFilter + revenue = NumberFilter() + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("id", "id"), + ("tid", "tid"), + ("name", "name"), + ("created", "created"), + ("prod_numeric_grade", "prod_numeric_grade"), + ("business_criticality", "business_criticality"), + ("platform", "platform"), + ("lifecycle", "lifecycle"), + ("origin", "origin"), + ("revenue", "revenue"), + ("external_audience", "external_audience"), + ("internet_accessible", "internet_accessible"), + ("product_manager", "product_manager"), + ("product_manager__first_name", "product_manager__first_name"), + ("product_manager__last_name", "product_manager__last_name"), + ("technical_contact", "technical_contact"), + ("technical_contact__first_name", "technical_contact__first_name"), + ("technical_contact__last_name", "technical_contact__last_name"), + ("team_manager", "team_manager"), + ("team_manager__first_name", "team_manager__first_name"), + ("team_manager__last_name", "team_manager__last_name"), + ("prod_type", "prod_type"), + ("prod_type__name", "prod_type__name"), + ("updated", "updated"), + ("user_records", "user_records"), + ), + ) diff --git a/dojo/product/api/serializer.py b/dojo/product/api/serializer.py new file mode 100644 index 00000000000..53b89033a28 --- /dev/null +++ b/dojo/product/api/serializer.py @@ -0,0 +1,60 @@ +from rest_framework import serializers + +from dojo.models import DojoMeta, Product, Product_API_Scan_Configuration + + +class ProductMetaSerializer(serializers.ModelSerializer): + class Meta: + model = DojoMeta + fields = ("name", "value") + + +class ProductAPIScanConfigurationSerializer(serializers.ModelSerializer): + class Meta: + model = Product_API_Scan_Configuration + fields = "__all__" + + +class ProductSerializer(serializers.ModelSerializer): + findings_count = serializers.SerializerMethodField() + findings_list = serializers.SerializerMethodField() + + business_criticality = serializers.ChoiceField(choices=Product.BUSINESS_CRITICALITY_CHOICES, allow_blank=True, allow_null=True, required=False) + platform = serializers.ChoiceField(choices=Product.PLATFORM_CHOICES, allow_blank=True, allow_null=True, required=False) + lifecycle = serializers.ChoiceField(choices=Product.LIFECYCLE_CHOICES, allow_blank=True, allow_null=True, required=False) + origin = serializers.ChoiceField(choices=Product.ORIGIN_CHOICES, allow_blank=True, allow_null=True, required=False) + + product_meta = ProductMetaSerializer(read_only=True, many=True) + + class Meta: + model = Product + exclude = ( + "tid", + "updated", + "async_updating", + ) + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + def validate(self, data): + async_updating = getattr(self.instance, "async_updating", None) + if async_updating: + new_sla_config = data.get("sla_configuration", None) + old_sla_config = getattr(self.instance, "sla_configuration", None) + if new_sla_config and old_sla_config and new_sla_config != old_sla_config: + msg = "Finding SLA expiration dates are currently being recalculated. The SLA configuration for this product cannot be changed until the calculation is complete." + raise serializers.ValidationError(msg) + return data + + def get_findings_count(self, obj) -> int: + return obj.findings_count + + # TODO: maybe extend_schema_field is needed here? + def get_findings_list(self, obj) -> list[int]: + return obj.open_findings_list() diff --git a/dojo/product/api/urls.py b/dojo/product/api/urls.py new file mode 100644 index 00000000000..0e7e34974c0 --- /dev/null +++ b/dojo/product/api/urls.py @@ -0,0 +1,7 @@ +from dojo.product.api.views import ProductAPIScanConfigurationViewSet, ProductViewSet + + +def add_product_urls(router): + router.register("products", ProductViewSet, basename="product") + router.register("product_api_scan_configurations", ProductAPIScanConfigurationViewSet, basename="product_api_scan_configuration") + return router diff --git a/dojo/product/api/views.py b/dojo/product/api/views.py new file mode 100644 index 00000000000..a9ebd2dca0e --- /dev/null +++ b/dojo/product/api/views.py @@ -0,0 +1,129 @@ +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +import dojo.api_v2.mixins as dojo_mixins +from dojo.api_v2 import prefetch +from dojo.api_v2 import serializers as api_v2_serializers +from dojo.api_v2.views import PrefetchDojoModelViewSet, report_generate, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.models import Endpoint, Product, Product_API_Scan_Configuration +from dojo.product.api.filters import ApiProductFilter +from dojo.product.api.serializer import ( + ProductAPIScanConfigurationSerializer, + ProductSerializer, +) +from dojo.product.queries import ( + get_authorized_product_api_scan_configurations, + get_authorized_products, +) +from dojo.utils import async_delete, get_setting + + +# Authorization: object-based +class ProductAPIScanConfigurationViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = ProductAPIScanConfigurationSerializer + queryset = Product_API_Scan_Configuration.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "product", + "tool_configuration", + "service_key_1", + "service_key_2", + "service_key_3", + ] + permission_classes = ( + IsAuthenticated, + permissions.UserHasProductAPIScanConfigurationPermission, + ) + + def get_queryset(self): + return get_authorized_product_api_scan_configurations( + "view", + ) + + +@extend_schema_view(**schema_with_prefetch()) +class ProductViewSet( + prefetch.PrefetchListMixin, + prefetch.PrefetchRetrieveMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, + dojo_mixins.DeletePreviewModelMixin, +): + serializer_class = ProductSerializer + queryset = Product.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiProductFilter + permission_classes = ( + IsAuthenticated, + permissions.UserHasProductPermission, + ) + + def get_queryset(self): + return get_authorized_products("view").distinct() + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if get_setting("ASYNC_OBJECT_DELETE"): + async_del = async_delete() + async_del.delete(instance) + else: + with Endpoint.allow_endpoint_init(): # TODO: Delete this after the move to Locations + instance.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + # def list(self, request): + # # Note the use of `get_queryset()` instead of `self.queryset` + # queryset = self.get_queryset() + # serializer = self.serializer_class(queryset, many=True) + # return Response(serializer.data) + + @extend_schema( + request=api_v2_serializers.ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: api_v2_serializers.ReportGenerateSerializer}, + ) + @action( + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request, pk=None): + product = self.get_object() + + options = {} + # prepare post data + report_options = api_v2_serializers.ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, product, options) + report = api_v2_serializers.ReportGenerateSerializer(data) + return Response(report.data) diff --git a/dojo/urls.py b/dojo/urls.py index cfccb1152d1..c7f6ac68e52 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -34,8 +34,6 @@ NetworkLocationsViewset, NotesViewSet, NoteTypeViewSet, - ProductAPIScanConfigurationViewSet, - ProductViewSet, RegulationsViewSet, ReImportScanView, RiskAcceptanceViewSet, @@ -75,6 +73,7 @@ from dojo.object.urls import urlpatterns as object_urls from dojo.organization.api.urls import add_organization_urls from dojo.organization.urls import urlpatterns as organization_urls +from dojo.product.api.urls import add_product_urls from dojo.product_type.api.urls import add_product_type_urls from dojo.regulations.urls import urlpatterns as regulations from dojo.reports.urls import urlpatterns as reports_urls @@ -127,8 +126,7 @@ v2_api.register(r"notes", NotesViewSet, basename="notes") v2_api.register(r"note_type", NoteTypeViewSet, basename="note_type") add_notifications_urls(v2_api) -v2_api.register(r"products", ProductViewSet, basename="product") -v2_api.register(r"product_api_scan_configurations", ProductAPIScanConfigurationViewSet, basename="product_api_scan_configuration") +v2_api = add_product_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # product_groups, product_members → pro/product_groups, pro/product_members v2_api = add_product_type_urls(v2_api) diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index b195500889e..222f478c933 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -55,8 +55,6 @@ LanguageViewSet, NotesViewSet, NoteTypeViewSet, - ProductAPIScanConfigurationViewSet, - ProductViewSet, RiskAcceptanceViewSet, SonarqubeIssueViewSet, ToolConfigurationsViewSet, @@ -113,6 +111,7 @@ from dojo.organization.api.views import ( OrganizationViewSet, ) +from dojo.product.api.views import ProductAPIScanConfigurationViewSet, ProductViewSet from dojo.product_type.api.views import ProductTypeViewSet from dojo.test.api.views import TestsViewSet, TestTypesViewSet from dojo.url.api.views import URLViewSet From b2437c217ae0ee081fb2dfc1f8e7e64dd9ec136f Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 17:47:18 +0200 Subject: [PATCH 24/40] refactor(finding): extract Finding/Vulnerability_Id/Finding_Group/Finding_Template into dojo/finding/ Phase 1 of module reorg per AGENTS.md. Move Finding (+ custom FindingAdmin), Vulnerability_Id, Finding_Group, Finding_Template + admin registrations into dojo/finding/{models,admin}.py. Cross-module FKs use string refs; date/util field defaults imported from dojo.models to preserve migration serialization path; restore load-bearing parse_cvss_data re-export for dojo.location side-effect registration. No migration change. --- dojo/finding/__init__.py | 1 + dojo/finding/admin.py | 31 + dojo/finding/models.py | 1497 ++++++++++++++++++++++++++++++++++++++ dojo/models.py | 1485 +------------------------------------ 4 files changed, 1539 insertions(+), 1475 deletions(-) create mode 100644 dojo/finding/admin.py create mode 100644 dojo/finding/models.py diff --git a/dojo/finding/__init__.py b/dojo/finding/__init__.py index e69de29bb2d..83045d6089c 100644 --- a/dojo/finding/__init__.py +++ b/dojo/finding/__init__.py @@ -0,0 +1 @@ +import dojo.finding.admin # noqa: F401 diff --git a/dojo/finding/admin.py b/dojo/finding/admin.py new file mode 100644 index 00000000000..61d6098a002 --- /dev/null +++ b/dojo/finding/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin + +from dojo.finding.models import Finding, Finding_Group, Finding_Template, Vulnerability_Id + + +@admin.register(Finding) +class FindingAdmin(admin.ModelAdmin): + # TODO: Delete this after the move to Locations + # For efficiency with large databases, display many-to-many fields with raw + # IDs rather than multi-select + raw_id_fields = ( + "endpoints", + ) + + +@admin.register(Finding_Template) +class FindingTemplateAdmin(admin.ModelAdmin): + + """Admin support for the Finding_Template model.""" + + +@admin.register(Vulnerability_Id) +class VulnerabilityIdAdmin(admin.ModelAdmin): + + """Admin support for the Vulnerability_Id model.""" + + +@admin.register(Finding_Group) +class FindingGroupAdmin(admin.ModelAdmin): + + """Admin support for the Finding_Group model.""" diff --git a/dojo/finding/models.py b/dojo/finding/models.py new file mode 100644 index 00000000000..19772f95519 --- /dev/null +++ b/dojo/finding/models.py @@ -0,0 +1,1497 @@ +import base64 +import hashlib +import logging +import re +from contextlib import suppress +from datetime import datetime +from typing import TYPE_CHECKING + +import dateutil +from dateutil.parser import parse as datetutilsparse +from dateutil.relativedelta import relativedelta +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.html import escape +from django.utils.translation import gettext as _ +from django_extensions.db.models import TimeStampedModel +from tagulous.models import TagField +from titlecase import titlecase + +from dojo.base_models.base import BaseModel + +# get_current_date/tomorrow/copy_model_util are defined early in dojo.models, before the +# re-export that loads this module — so this resolves despite the partial circular load, and +# keeps their dojo.models.* path for Django migration serialization (used as field defaults). +from dojo.models import copy_model_util, get_current_date, tomorrow +from dojo.validators import cvss3_validator, cvss4_validator + +if TYPE_CHECKING: + from dojo.importers.location_manager import UnsavedLocation + +logger = logging.getLogger(__name__) +deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") + + +class Finding(BaseModel): + # Fields loaded when performing deduplication (used by get_finding_models_for_deduplication + # and build_candidate_scope_queryset to restrict the SELECT to only what is needed). + # Covers the union of all deduplication algorithms so that a single queryset works + # regardless of which algorithm is in use. Large text fields (description, mitigation, + # impact, references, …) are intentionally excluded. + DEDUPLICATION_FIELDS = [ + "id", + # FK required for select_related("test") — must not be deferred + "test", + # Fields written by set_duplicate + "duplicate", + "active", + "verified", + "duplicate_finding", + # Guard checks in set_duplicate + "is_mitigated", + "mitigated", + "out_of_scope", + "false_p", + # Accessed by status() (debug logging only) + "under_review", + "risk_accepted", + # Used by hash-code and legacy algorithms for endpoint/location matching + "dynamic_finding", + "static_finding", + # Algorithm-specific matching fields + "hash_code", # hash_code, uid_or_hash, legacy + "unique_id_from_tool", # unique_id, uid_or_hash + "title", # legacy + "cwe", # legacy + "file_path", # legacy + "line", # legacy + ] + + # Large text fields deferred in build_candidate_scope_queryset. These are + # never accessed during deduplication or reimport candidate matching, so + # excluding them reduces the data loaded for every candidate finding. + DEDUPLICATION_DEFERRED_FIELDS = [ + "description", + "mitigation", + "impact", + "steps_to_reproduce", + "severity_justification", + "references", + "url", + "cvssv3", + "cvssv4", + ] + + title = models.CharField(max_length=511, + verbose_name=_("Title"), + help_text=_("A short description of the flaw.")) + date = models.DateField(default=get_current_date, + verbose_name=_("Date"), + help_text=_("The date the flaw was discovered.")) + sla_start_date = models.DateField( + blank=True, + null=True, + verbose_name=_("SLA Start Date"), + help_text=_("(readonly)The date used as start date for SLA calculation. Set by expiring risk acceptances. Empty by default, causing a fallback to 'date'.")) + sla_expiration_date = models.DateField( + blank=True, + null=True, + verbose_name=_("SLA Expiration Date"), + help_text=_("(readonly)The date SLA expires for this finding. Empty by default, causing a fallback to 'date'.")) + cwe = models.IntegerField(default=0, null=True, blank=True, + verbose_name=_("CWE"), + help_text=_("The CWE number associated with this flaw.")) + cve = models.CharField(max_length=50, + null=True, + blank=False, + verbose_name=_("Vulnerability Id"), + help_text=_("An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.")) + epss_score = models.FloatField(default=None, null=True, blank=True, + verbose_name=_("EPSS Score"), + help_text=_("EPSS score for the CVE. Describes how likely it is the vulnerability will be exploited in the next 30 days."), + validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) + epss_percentile = models.FloatField(default=None, null=True, blank=True, + verbose_name=_("EPSS percentile"), + help_text=_("EPSS percentile for the CVE. Describes how many CVEs are scored at or below this one."), + validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) + known_exploited = models.BooleanField(default=False, + verbose_name=_("Known Exploited"), + help_text=_("Whether this vulnerability is known to have been exploited in the wild.")) + ransomware_used = models.BooleanField(default=False, + verbose_name=_("Used in Ransomware"), + help_text=_("Whether this vulnerability is known to have been leveraged as part of a ransomware campaign.")) + kev_date = models.DateField(null=True, blank=True, + verbose_name=_("KEV Date Added"), + help_text=_("The date the vulnerability was added to the KEV catalog."), + validators=[MaxValueValidator(tomorrow)]) + cvssv3 = models.TextField(validators=[cvss3_validator], + max_length=117, + null=True, + verbose_name=_("CVSS3 Vector"), + help_text=_("Common Vulnerability Scoring System version 3 (CVSS3) score associated with this finding.")) + cvssv3_score = models.FloatField(null=True, + blank=True, + verbose_name=_("CVSS3 Score"), + help_text=_("Numerical CVSSv3 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), + validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) + + cvssv4 = models.TextField(validators=[cvss4_validator], + max_length=255, + null=True, + verbose_name=_("CVSS4 vector"), + help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding.")) + cvssv4_score = models.FloatField(null=True, + blank=True, + verbose_name=_("CVSSv4 Score"), + help_text=_("Numerical CVSSv4 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), + validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) + + url = models.TextField(null=True, + blank=True, + editable=False, + verbose_name=_("URL"), + help_text=_("External reference that provides more information about this flaw.")) # not displayed and pretty much the same as references. To remove? + severity = models.CharField(max_length=200, + verbose_name=_("Severity"), + help_text=_("The severity level of this flaw (Critical, High, Medium, Low, Info).")) + description = models.TextField(verbose_name=_("Description"), + help_text=_("Longer more descriptive information about the flaw.")) + mitigation = models.TextField(verbose_name=_("Mitigation"), + null=True, + blank=True, + help_text=_("Text describing how to best fix the flaw.")) + fix_available = models.BooleanField(null=True, + default=None, + verbose_name=_("Fix Available"), + help_text=_("Denotes if there is a fix available for this flaw.")) + fix_version = models.CharField(null=True, + blank=True, + max_length=100, + verbose_name=_("Fix version"), + help_text=_("Version of the affected component in which the flaw is fixed.")) + impact = models.TextField(verbose_name=_("Impact"), + null=True, + blank=True, + help_text=_("Text describing the impact this flaw has on systems, products, enterprise, etc.")) + steps_to_reproduce = models.TextField(null=True, + blank=True, + verbose_name=_("Steps to Reproduce"), + help_text=_("Text describing the steps that must be followed in order to reproduce the flaw / bug.")) + severity_justification = models.TextField(null=True, + blank=True, + verbose_name=_("Severity Justification"), + help_text=_("Text describing why a certain severity was associated with this flaw.")) + # TODO: Delete this after the move to Locations + endpoints = models.ManyToManyField("dojo.Endpoint", + blank=True, + verbose_name=_("Endpoints"), + help_text=_("The hosts within the product that are susceptible to this flaw. + The status of the endpoint associated with this flaw (Vulnerable, Mitigated, ...)."), + through="dojo.Endpoint_Status") + references = models.TextField(null=True, + blank=True, + db_column="refs", + verbose_name=_("References"), + help_text=_("The external documentation available for this flaw.")) + test = models.ForeignKey("dojo.Test", + editable=False, + on_delete=models.CASCADE, + verbose_name=_("Test"), + help_text=_("The test that is associated with this flaw.")) + active = models.BooleanField(default=True, + verbose_name=_("Active"), + help_text=_("Denotes if this flaw is active or not.")) + # note that false positive findings cannot be verified + # in defectdojo verified means: "we have verified the finding and it turns out that it's not a false positive" + verified = models.BooleanField(default=False, + verbose_name=_("Verified"), + help_text=_("Denotes if this flaw has been manually verified by the tester.")) + false_p = models.BooleanField(default=False, + verbose_name=_("False Positive"), + help_text=_("Denotes if this flaw has been deemed a false positive by the tester.")) + duplicate = models.BooleanField(default=False, + verbose_name=_("Duplicate"), + help_text=_("Denotes if this flaw is a duplicate of other flaws reported.")) + duplicate_finding = models.ForeignKey("self", + editable=False, + null=True, + related_name="original_finding", + blank=True, on_delete=models.DO_NOTHING, + verbose_name=_("Duplicate Finding"), + help_text=_("Link to the original finding if this finding is a duplicate.")) + out_of_scope = models.BooleanField(default=False, + verbose_name=_("Out Of Scope"), + help_text=_("Denotes if this flaw falls outside the scope of the test and/or engagement.")) + risk_accepted = models.BooleanField(default=False, + verbose_name=_("Risk Accepted"), + help_text=_("Denotes if this finding has been marked as an accepted risk.")) + under_review = models.BooleanField(default=False, + verbose_name=_("Under Review"), + help_text=_("Denotes is this flaw is currently being reviewed.")) + + last_status_update = models.DateTimeField(editable=False, + null=True, + blank=True, + auto_now_add=True, + verbose_name=_("Last Status Update"), + help_text=_("Timestamp of latest status update (change in status related fields).")) + + review_requested_by = models.ForeignKey("dojo.Dojo_User", + null=True, + blank=True, + related_name="review_requested_by", + on_delete=models.RESTRICT, + verbose_name=_("Review Requested By"), + help_text=_("Documents who requested a review for this finding.")) + reviewers = models.ManyToManyField("dojo.Dojo_User", + blank=True, + verbose_name=_("Reviewers"), + help_text=_("Documents who reviewed the flaw.")) + + # Defect Tracking Review + under_defect_review = models.BooleanField(default=False, + verbose_name=_("Under Defect Review"), + help_text=_("Denotes if this finding is under defect review.")) + defect_review_requested_by = models.ForeignKey("dojo.Dojo_User", + null=True, + blank=True, + related_name="defect_review_requested_by", + on_delete=models.RESTRICT, + verbose_name=_("Defect Review Requested By"), + help_text=_("Documents who requested a defect review for this flaw.")) + is_mitigated = models.BooleanField(default=False, + verbose_name=_("Is Mitigated"), + help_text=_("Denotes if this flaw has been fixed.")) + thread_id = models.IntegerField(default=0, + editable=False, + verbose_name=_("Thread ID")) + mitigated = models.DateTimeField(editable=False, + null=True, + blank=True, + verbose_name=_("Mitigated"), + help_text=_("Denotes if this flaw has been fixed by storing the date it was fixed.")) + mitigated_by = models.ForeignKey("dojo.Dojo_User", + null=True, + editable=False, + related_name="mitigated_by", + on_delete=models.RESTRICT, + verbose_name=_("Mitigated By"), + help_text=_("Documents who has marked this flaw as fixed.")) + reporter = models.ForeignKey("dojo.Dojo_User", + editable=False, + default=1, + related_name="reporter", + on_delete=models.RESTRICT, + verbose_name=_("Reporter"), + help_text=_("Documents who reported the flaw.")) + notes = models.ManyToManyField("dojo.Notes", + blank=True, + editable=False, + verbose_name=_("Notes"), + help_text=_("Stores information pertinent to the flaw or the mitigation.")) + numerical_severity = models.CharField(max_length=4, + verbose_name=_("Numerical Severity"), + help_text=_("The numerical representation of the severity (S0, S1, S2, S3, S4).")) + last_reviewed = models.DateTimeField(null=True, + editable=False, + verbose_name=_("Last Reviewed"), + help_text=_("Provides the date the flaw was last 'touched' by a tester.")) + last_reviewed_by = models.ForeignKey("dojo.Dojo_User", + null=True, + editable=False, + related_name="last_reviewed_by", + on_delete=models.RESTRICT, + verbose_name=_("Last Reviewed By"), + help_text=_("Provides the person who last reviewed the flaw.")) + files = models.ManyToManyField("dojo.FileUpload", + blank=True, + editable=False, + verbose_name=_("Files"), + help_text=_("Files(s) related to the flaw.")) + param = models.TextField(null=True, + blank=True, + editable=False, + verbose_name=_("Parameter"), + help_text=_("Parameter used to trigger the issue (DAST).")) + payload = models.TextField(null=True, + blank=True, + editable=False, + verbose_name=_("Payload"), + help_text=_("Payload used to attack the service / application and trigger the bug / problem.")) + hash_code = models.CharField(null=True, + blank=True, + editable=False, + max_length=64, + verbose_name=_("Hash Code"), + help_text=_("A hash over a configurable set of fields that is used for findings deduplication.")) + line = models.IntegerField(null=True, + blank=True, + verbose_name=_("Line number"), + help_text=_("Source line number of the attack vector.")) + file_path = models.CharField(null=True, + blank=True, + max_length=4000, + verbose_name=_("File path"), + help_text=_("Identified file(s) containing the flaw.")) + component_name = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("Component name"), + help_text=_("Name of the affected component (library name, part of a system, ...).")) + component_version = models.CharField(null=True, + blank=True, + max_length=100, + verbose_name=_("Component version"), + help_text=_("Version of the affected component.")) + found_by = models.ManyToManyField("dojo.Test_Type", + editable=False, + verbose_name=_("Found by"), + help_text=_("The name of the scanner that identified the flaw.")) + static_finding = models.BooleanField(default=False, + verbose_name=_("Static finding (SAST)"), + help_text=_("Flaw has been detected from a Static Application Security Testing tool (SAST).")) + dynamic_finding = models.BooleanField(default=True, + verbose_name=_("Dynamic finding (DAST)"), + help_text=_("Flaw has been detected from a Dynamic Application Security Testing tool (DAST).")) + scanner_confidence = models.IntegerField(null=True, + blank=True, + default=None, + editable=False, + verbose_name=_("Scanner confidence"), + help_text=_("Confidence level of vulnerability which is supplied by the scanner.")) + sonarqube_issue = models.ForeignKey("dojo.Sonarqube_Issue", + null=True, + blank=True, + help_text=_("The SonarQube issue associated with this finding."), + verbose_name=_("SonarQube issue"), + on_delete=models.CASCADE) + unique_id_from_tool = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("Unique ID from tool"), + help_text=_("Vulnerability technical id from the source tool. Allows to track unique vulnerabilities over time across subsequent scans.")) + vuln_id_from_tool = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("Vulnerability ID from tool"), + help_text=_("Non-unique technical id from the source tool associated with the vulnerability type.")) + sast_source_object = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("SAST Source Object"), + help_text=_("Source object (variable, function...) of the attack vector.")) + sast_sink_object = models.CharField(null=True, + blank=True, + max_length=500, + verbose_name=_("SAST Sink Object"), + help_text=_("Sink object (variable, function...) of the attack vector.")) + sast_source_line = models.IntegerField(null=True, + blank=True, + verbose_name=_("SAST Source Line number"), + help_text=_("Source line number of the attack vector.")) + sast_source_file_path = models.CharField(null=True, + blank=True, + max_length=4000, + verbose_name=_("SAST Source File Path"), + help_text=_("Source file path of the attack vector.")) + nb_occurences = models.IntegerField(null=True, + blank=True, + verbose_name=_("Number of occurences"), + help_text=_("Number of occurences in the source tool when several vulnerabilites were found and aggregated by the scanner.")) + + # this is useful for vulnerabilities on dependencies : helps answer the question "Did I add this vulnerability or was it discovered recently?" + publish_date = models.DateField(null=True, + blank=True, + verbose_name=_("Publish date"), + help_text=_("Date when this vulnerability was made publicly available.")) + + # The service is used to generate the hash_code, so that it gets part of the deduplication of findings. + service = models.CharField(null=True, + blank=True, + max_length=200, + verbose_name=_("Service"), + help_text=_("A service is a self-contained piece of functionality within a Product. This is an optional field which is used in deduplication of findings when set.")) + + planned_remediation_date = models.DateField(null=True, + editable=True, + verbose_name=_("Planned Remediation Date"), + help_text=_("The date the flaw is expected to be remediated.")) + + planned_remediation_version = models.CharField(null=True, + blank=True, + max_length=99, + verbose_name=_("Planned remediation version"), + help_text=_("The target version when the vulnerability should be fixed / remediated")) + + effort_for_fixing = models.CharField(null=True, + blank=True, + max_length=99, + verbose_name=_("Effort for fixing"), + help_text=_("Effort for fixing / remediating the vulnerability (Low, Medium, High)")) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding. Choose from the list or add new tags. Press Enter key to add.")) + inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) + + SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, + "High": 1, "Critical": 0} + + class Meta: + ordering = ("numerical_severity", "-date", "title", "epss_score", "epss_percentile") + indexes = [ + models.Index(fields=["test", "active", "verified"]), + + models.Index(fields=["test", "is_mitigated"]), + models.Index(fields=["test", "duplicate"]), + models.Index(fields=["test", "out_of_scope"]), + models.Index(fields=["test", "false_p"]), + + models.Index(fields=["test", "unique_id_from_tool", "duplicate"]), + models.Index(fields=["test", "hash_code", "duplicate"]), + + models.Index(fields=["test", "component_name"]), + + models.Index(fields=["cve"]), + models.Index(fields=["epss_score"]), + models.Index(fields=["epss_percentile"]), + models.Index(fields=["cwe"]), + models.Index(fields=["out_of_scope"]), + models.Index(fields=["false_p"]), + models.Index(fields=["verified"]), + models.Index(fields=["mitigated"]), + models.Index(fields=["active"]), + models.Index(fields=["numerical_severity"]), + models.Index(fields=["date"]), + models.Index(fields=["title"]), + models.Index(fields=["hash_code"]), + models.Index(fields=["unique_id_from_tool"]), + # models.Index(fields=['file_path']), # can't add index because the field has max length 4000. + models.Index(fields=["line"]), + models.Index(fields=["component_name"]), + models.Index(fields=["duplicate"]), + models.Index(fields=["is_mitigated"]), + models.Index(fields=["duplicate_finding", "id"]), + models.Index(fields=["known_exploited"]), + models.Index(fields=["ransomware_used"]), + models.Index(fields=["kev_date"]), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if settings.V3_FEATURE_LOCATIONS: + self.unsaved_locations: list[UnsavedLocation] = [] + else: + # TODO: Delete this after the move to Locations + self.unsaved_endpoints = [] + self.unsaved_request = None + self.unsaved_response = None + self.unsaved_tags = None + self.unsaved_files = None + self.unsaved_vulnerability_ids = None + + def __str__(self): + return self.title + + def save(self, dedupe_option=True, rules_option=True, product_grading_option=True, # noqa: FBT002 + issue_updater_option=True, push_to_jira=False, user=None, *args, **kwargs): # noqa: FBT002 - this is bit hard to fix nice have this universally fixed + logger.debug("Start saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") + from dojo.finding import helper as finding_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + + is_new_finding = self.pk is None + + # if not isinstance(self.date, (datetime, date)): + # raise ValidationError(_("The 'date' field must be a valid date or datetime object.")) + + if not user: + from dojo.utils import get_current_user # noqa: PLC0415 -- lazy import, avoids circular dependency + user = get_current_user() + # Title Casing + self.title = titlecase(self.title[:511]) + # Set the date of the finding if nothing is supplied + if self.date is None: + self.date = timezone.now() + # Assign the numerical severity for correct sorting order + self.numerical_severity = Finding.get_numerical_severity(self.severity) + + # Synchronize cvssv3 score using cvssv3 vector + + if self.cvssv3: + try: + from dojo.utils import parse_cvss_data # noqa: PLC0415 -- lazy import, avoids circular dependency + cvss_data = parse_cvss_data(self.cvssv3) + if cvss_data: + self.cvssv3 = cvss_data.get("cvssv3") + self.cvssv3_score = cvss_data.get("cvssv3_score") + + except Exception as ex: + logger.warning("Can't compute cvssv3 score for finding id %i. Invalid cvssv3 vector found: '%s'. Exception: %s.", self.id, self.cvssv3, ex) + # remove invalid cvssv3 vector for new findings, or should we just throw a ValidationError? + if self.pk is None: + self.cvssv3 = None + + # behaviour for CVVS4 is slightly different. Extracting this into a method would lead to probably hard to read code + if self.cvssv4: + try: + from dojo.utils import parse_cvss_data # noqa: PLC0415 -- lazy import, avoids circular dependency + cvss_data = parse_cvss_data(self.cvssv4) + if cvss_data: + self.cvssv4 = cvss_data.get("cvssv4") + self.cvssv4_score = cvss_data.get("cvssv4_score") + + except Exception as ex: + logger.warning("Can't compute cvssv4 score for finding id %i. Invalid cvssv4 vector found: '%s'. Exception: %s.", self.id, self.cvssv4, ex) + self.cvssv4 = None + + self.set_hash_code(dedupe_option) + + if is_new_finding: + if settings.V3_FEATURE_LOCATIONS: + if (self.file_path is not None) and (len(self.unsaved_locations) == 0): + self.static_finding = True + self.dynamic_finding = False + elif (self.file_path is not None): + self.static_finding = True + # TODO: Delete this after the move to Locations + elif (self.file_path is not None) and (len(self.unsaved_endpoints) == 0): + self.static_finding = True + self.dynamic_finding = False + elif (self.file_path is not None): + self.static_finding = True + + # because we have reduced the number of (super()).save() calls, the helper is no longer called for new findings + # so we call it manually + finding_helper.update_finding_status(self, user, changed_fields={"id": (None, None)}) + + # logger.debug('setting static / dynamic in save') + # need to have an id/pk before we can access locations/endpoints + elif self.file_path is not None: + if settings.V3_FEATURE_LOCATIONS: + if not self.locations.exists(): + self.static_finding = True + self.dynamic_finding = False + else: + self.static_finding = True + # TODO: Delete this after the move to Locations + elif not self.endpoints.exists(): + self.static_finding = True + self.dynamic_finding = False + else: + self.static_finding = True + + # update the SLA expiration date last, after all other finding fields have been updated + self.set_sla_expiration_date() + + logger.debug("Saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") + # We cannot run the full_clean method here without issue, so we specify skip_validation + super().save(*args, **kwargs, skip_validation=True) + + # Only add to found_by for newly-created findings (avoid doing this on every update) + if is_new_finding: + self.found_by.add(self.test.test_type) + + # only perform post processing (in celery task) if needed. this check avoids submitting 1000s of tasks to celery that will do nothing + from dojo.models import System_Settings # noqa: PLC0415 -- lazy import, avoids circular dependency + system_settings = System_Settings.objects.get() + if dedupe_option or issue_updater_option or (product_grading_option and system_settings.enable_product_grade) or push_to_jira: + finding_helper.post_process_finding_save(self.id, dedupe_option=dedupe_option, rules_option=rules_option, product_grading_option=product_grading_option, + issue_updater_option=issue_updater_option, push_to_jira=push_to_jira, user=user, *args, **kwargs) + else: + logger.debug("no options selected that require finding post processing") + + def get_absolute_url(self): + return reverse("view_finding", args=[str(self.id)]) + + def copy(self, test=None): + copy = copy_model_util(self) + # Save the necessary ManyToMany relationships + old_notes = list(self.notes.all()) + old_files = list(self.files.all()) + old_reviewers = list(self.reviewers.all()) + old_found_by = list(self.found_by.all()) + old_tags = list(self.tags.all()) + # Wipe the IDs of the new object + if test: + copy.test = test + # Save the object before setting any ManyToMany relationships + copy.save() + # Copy the notes + for notes in old_notes: + copy.notes.add(notes.copy()) + # Copy the files + for files in old_files: + copy.files.add(files.copy()) + if settings.V3_FEATURE_LOCATIONS: + old_location_refs = self.locations.all() + for location_ref in old_location_refs: + location_ref.copy(copy) + else: + # TODO: Delete this after the move to Locations + # Copy the endpoint_status + old_status_findings = list(self.status_finding.all()) + for endpoint_status in old_status_findings: + endpoint_status.copy(finding=copy) # adding or setting is not necessary, link is created by Endpoint_Status.copy() + # Assign any reviewers + copy.reviewers.set(old_reviewers) + # Assign any found_by + copy.found_by.set(old_found_by) + # Assign any tags + copy.tags.set(old_tags) + + return copy + + def delete(self, *args, product_grading_option=True, **kwargs): + logger.debug("%d finding delete", self.id) + from dojo.finding import helper as finding_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + finding_helper.finding_delete(self) + super().delete(*args, **kwargs) + if product_grading_option: + from dojo.models import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + Engagement, + Product, + Test, + ) + with suppress(Finding.DoesNotExist, Test.DoesNotExist, Engagement.DoesNotExist, Product.DoesNotExist): + # Suppressing a potential issue created from async delete removing + # related objects in a separate task + from dojo.utils import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + perform_product_grading, + ) + perform_product_grading(self.test.engagement.product) + + # only used by bulk risk acceptance api + @classmethod + def unaccepted_open_findings(cls): + from dojo.utils import get_system_setting # noqa: PLC0415 -- lazy import, avoids circular dependency + results = cls.objects.filter(active=True, duplicate=False, risk_accepted=False) + if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): + results = results.filter(verified=True) + + return results + + @property + def risk_acceptance(self): + ras = self.risk_acceptance_set.all() + if ras: + return ras[0] + + return None + + def compute_hash_code(self): + # Allow Pro to overwrite compute hash_code which gets dedupe settings from a database instead of django.settings + from dojo.utils import get_custom_method # noqa: PLC0415 -- lazy import, avoids circular dependency + if compute_hash_code_method := get_custom_method("FINDING_COMPUTE_HASH_METHOD"): + deduplicationLogger.debug("using custom FINDING_COMPUTE_HASH_METHOD method") + return compute_hash_code_method(self) + + # Check if all needed settings are defined + if not hasattr(settings, "HASHCODE_FIELDS_PER_SCANNER") or not hasattr(settings, "HASHCODE_ALLOWS_NULL_CWE") or not hasattr(settings, "HASHCODE_ALLOWED_FIELDS"): + deduplicationLogger.debug("no or incomplete configuration per hash_code found; using legacy algorithm") + return self.compute_hash_code_legacy() + + hash_code_fields = self.test.hash_code_fields + + # Check if hash_code fields are found in the settings + if not hash_code_fields: + deduplicationLogger.debug( + "No configuration for hash_code computation found; using default fields for " + ("dynamic" if self.dynamic_finding else "static") + " scanners") + return self.compute_hash_code_legacy() + + # Check if all elements of HASHCODE_FIELDS_PER_SCANNER are in HASHCODE_ALLOWED_FIELDS + if not (all(elem in settings.HASHCODE_ALLOWED_FIELDS for elem in hash_code_fields)): + deduplicationLogger.debug( + "compute_hash_code - configuration error: some elements of HASHCODE_FIELDS_PER_SCANNER are not in the allowed list HASHCODE_ALLOWED_FIELDS. " + "Using default fields") + return self.compute_hash_code_legacy() + + # Make sure that we have a cwe if we need one + if self.cwe == 0 and not self.test.hash_code_allows_null_cwe: + deduplicationLogger.debug( + "Cannot compute hash_code based on configured fields because cwe is 0 for finding of title '" + self.title + "' found in file '" + str(self.file_path) + + "'. Fallback to legacy mode for this finding.") + return self.compute_hash_code_legacy() + + deduplicationLogger.debug("computing hash_code for finding id " + str(self.id) + " based on: " + ", ".join(hash_code_fields)) + + fields_to_hash = "" + for hashcodeField in hash_code_fields: + # Note: preserve this field label ("endpoints") for settings purposes through the Locations migration + if hashcodeField == "endpoints": + # For locations/endpoints, need to compute the field + locations = self.get_locations() + fields_to_hash += locations + deduplicationLogger.debug(hashcodeField + " : " + locations) + elif hashcodeField == "vulnerability_ids": + # For vulnerability_ids, need to compute the field + my_vulnerability_ids = self.get_vulnerability_ids() + fields_to_hash += my_vulnerability_ids + deduplicationLogger.debug(hashcodeField + " : " + my_vulnerability_ids) + else: + # Generically use the finding attribute having the same name, converts to str in case it's integer + fields_to_hash += str(getattr(self, hashcodeField)) + deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) + + # Log the hash_code fields that are always included (but are not part of the hash_code_fields list as they are inserted downtstream in self.hash_fields) + hash_code_fields_always = getattr(settings, "HASH_CODE_FIELDS_ALWAYS", []) + for hashcodeField in hash_code_fields_always: + if getattr(self, hashcodeField): + deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) + + deduplicationLogger.debug("compute_hash_code - fields_to_hash = " + fields_to_hash) + return self.hash_fields(fields_to_hash) + + def compute_hash_code_legacy(self): + fields_to_hash = self.title + str(self.cwe) + str(self.line) + str(self.file_path) + self.description + deduplicationLogger.debug("compute_hash_code_legacy - fields_to_hash = " + fields_to_hash) + return self.hash_fields(fields_to_hash) + + # Get vulnerability_ids to use for hash_code computation + def get_vulnerability_ids(self): + + def _get_unsaved_vulnerability_ids(finding) -> str: + if finding.unsaved_vulnerability_ids: + deduplicationLogger.debug("get_vulnerability_ids before the finding was saved") + # convert list of unsaved vulnerability_ids to the list of their canonical representation + vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in finding.unsaved_vulnerability_ids] + # deduplicate (usually done upon saving finding) and sort endpoints + return "".join(sorted(dict.fromkeys(vulnerability_id_str_list))) + deduplicationLogger.debug("finding has no unsaved vulnerability references") + return "" + + def _get_saved_vulnerability_ids(finding) -> str: + if finding.id is not None: + vulnerability_ids = Vulnerability_Id.objects.filter(finding=finding) + deduplicationLogger.debug("get_vulnerability_ids after the finding was saved. Vulnerability references count: " + str(vulnerability_ids.count())) + # convert list of vulnerability_ids to the list of their canonical representation + vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in vulnerability_ids.all()] + # sort vulnerability_ids strings + return "".join(sorted(vulnerability_id_str_list)) + return "" + + return _get_saved_vulnerability_ids(self) or _get_unsaved_vulnerability_ids(self) + + # Get locations/endpoints to use for hash_code computation + def get_locations(self): + # TODO: Delete this after the move to Locations + if not settings.V3_FEATURE_LOCATIONS: + # Get endpoints to use for hash_code computation + # (This sometimes reports "None") + def _get_unsaved_endpoints(finding) -> str: + if len(finding.unsaved_endpoints) > 0: + deduplicationLogger.debug("get_endpoints before the finding was saved") + # convert list of unsaved endpoints to the list of their canonical representation + endpoint_str_list = [str(endpoint) for endpoint in finding.unsaved_endpoints] + # deduplicate (usually done upon saving finding) and sort endpoints + return "".join(dict.fromkeys(endpoint_str_list)) + # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted + # In this case, before saving the finding, both static_finding and dynamic_finding are True + # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) + deduplicationLogger.debug("trying to get endpoints on a finding before it was saved but no endpoints found (static parser wrongly identified as dynamic?") + return "" + + def _get_saved_endpoints(finding) -> str: + if finding.id is not None: + deduplicationLogger.debug("get_endpoints: after the finding was saved. Endpoints count: " + str(finding.endpoints.count())) + # convert list of endpoints to the list of their canonical representation + endpoint_str_list = [str(endpoint) for endpoint in finding.endpoints.all()] + # sort endpoints strings + return "".join(sorted(endpoint_str_list)) + return "" + + return _get_saved_endpoints(self) or _get_unsaved_endpoints(self) + + def _get_unsaved_locations(finding) -> str: + if len(finding.unsaved_locations) > 0: + deduplicationLogger.debug("get_locations before the finding was saved") + # convert list of unsaved locations to the list of their canonical representation + from dojo.importers.location_manager import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + LocationManager, + ) + unsaved_locations = LocationManager.clean_unsaved_locations(finding.unsaved_locations) + # deduplicate (usually done upon saving finding) and sort locations + locations = sorted({location.get_location_value() for location in unsaved_locations}) + return "".join(locations) + # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted + # In this case, before saving the finding, both static_finding and dynamic_finding are True + # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) + deduplicationLogger.debug("trying to get locations on a finding before it was saved but no locations found (static parser wrongly identified as dynamic?") + return "" + + def _get_saved_locations(finding) -> str: + if finding.id is not None: + from dojo.url.models import URL # noqa: PLC0415 -- lazy import, avoids circular dependency + url_locations = finding.locations.filter(location__location_type=URL.get_location_type()) + deduplicationLogger.debug("get_locations: after the finding was saved. Locations count: " + str(url_locations.count())) + # convert list of locations to the list of their canonical representation + locations = sorted({location_ref.location.get_location_value() for location_ref in url_locations.all()}) + # sort locations strings + return "".join(sorted(locations)) + return "" + + return _get_saved_locations(self) or _get_unsaved_locations(self) + + # Compute the hash_code from the fields to hash + def hash_fields(self, fields_to_hash): + if hasattr(settings, "HASH_CODE_FIELDS_ALWAYS"): + for field in settings.HASH_CODE_FIELDS_ALWAYS: + if getattr(self, field): + deduplicationLogger.debug("adding HASH_CODE_FIELDS_ALWAYSfield %s to hash_fields: %s", field, getattr(self, field)) + fields_to_hash += str(getattr(self, field)) + + logger.debug("fields_to_hash : %s", fields_to_hash) + logger.debug("fields_to_hash lower: %s", fields_to_hash.lower()) + return hashlib.sha256(fields_to_hash.casefold().encode("utf-8").strip()).hexdigest() + + def duplicate_finding_set(self): + if self.duplicate: + if self.duplicate_finding is not None: + return Finding.objects.get( + id=self.duplicate_finding.id).original_finding.all().order_by("title") + return [] + return self.original_finding.all().order_by("title") + + def get_scanner_confidence_text(self): + if self.scanner_confidence and isinstance(self.scanner_confidence, int): + if self.scanner_confidence <= 2: + return "Certain" + if self.scanner_confidence >= 3 and self.scanner_confidence <= 5: + return "Firm" + return "Tentative" + return "" + + @staticmethod + def get_numerical_severity(severity): + if severity == "Critical": + return "S0" + if severity == "High": + return "S1" + if severity == "Medium": + return "S2" + if severity == "Low": + return "S3" + if severity == "Info": + return "S4" + return "S5" + + @staticmethod + def get_number_severity(severity): + if severity == "Critical": + return 4 + if severity == "High": + return 3 + if severity == "Medium": + return 2 + if severity == "Low": + return 1 + if severity == "Info": + return 0 + return 5 + + @staticmethod + def get_severity(num_severity): + severities = {0: "Info", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"} + if num_severity in severities: + return severities[num_severity] + + return None + + def status(self): + status = [] + if self.under_review: + status += ["Under Review"] + if self.active: + status += ["Active"] + else: + status += ["Inactive"] + if self.verified: + status += ["Verified"] + if self.mitigated or self.is_mitigated: + status += ["Mitigated"] + if self.false_p: + status += ["False Positive"] + if self.out_of_scope: + status += ["Out Of Scope"] + if self.duplicate: + status += ["Duplicate"] + if self.risk_accepted: + status += ["Risk Accepted"] + if not len(status): + status += ["Initial"] + + return ", ".join([str(s) for s in status]) + + def _age(self, start_date): + if start_date and isinstance(start_date, str): + start_date = datetutilsparse(start_date).date() + + if isinstance(start_date, datetime): + start_date = start_date.date() + + if self.mitigated: + mitigated_date = self.mitigated + if isinstance(mitigated_date, datetime): + mitigated_date = self.mitigated.date() + diff = mitigated_date - start_date + else: + diff = get_current_date() - start_date + days = diff.days + return max(0, days) + + @property + def age(self): + return self._age(self.date) + + @property + def sla_age(self): + return self._age(self.get_sla_start_date()) + + def get_sla_start_date(self): + if self.sla_start_date: + return self.sla_start_date + return self.date + + def get_sla_configuration(self): + return self.test.engagement.product.sla_configuration + + def get_sla_period(self): + # Determine which method to use to calculate the SLA + from dojo.utils import get_custom_method # noqa: PLC0415 -- lazy import, avoids circular dependency + if method := get_custom_method("FINDING_SLA_PERIOD_METHOD"): + return method(self) + # Run the default method + sla_configuration = self.get_sla_configuration() + sla_period = getattr(sla_configuration, self.severity.lower(), None) + enforce_period = getattr(sla_configuration, str("enforce_" + self.severity.lower()), None) + return sla_period, enforce_period + + def set_sla_expiration_date(self): + # First check if SLA is enabled globally + from dojo.models import System_Settings # noqa: PLC0415 -- lazy import, avoids circular dependency + system_settings = System_Settings.objects.get() + if not system_settings.enable_finding_sla: + return + # Call the internal method to set the sla expiration date + self._set_sla_expiration_date() + + def _set_sla_expiration_date(self): + # some parsers provide date as a `str` instead of a `date` in which case we need to parse it #12299 on GitHub + sla_start_date = self.get_sla_start_date() + if sla_start_date and isinstance(sla_start_date, str): + sla_start_date = dateutil.parser.parse(sla_start_date).date() + + sla_period, enforce_period = self.get_sla_period() + if sla_period is not None and enforce_period: + self.sla_expiration_date = sla_start_date + relativedelta(days=sla_period) + else: + self.sla_expiration_date = None + + def sla_days_remaining(self): + if self.sla_expiration_date: + if self.mitigated: + mitigated_date = self.mitigated + if isinstance(mitigated_date, datetime): + mitigated_date = self.mitigated.date() + return (self.sla_expiration_date - mitigated_date).days + return (self.sla_expiration_date - get_current_date()).days + return None + + def sla_deadline(self): + return self.sla_expiration_date + + def github(self): + from dojo.github.models import GITHUB_Issue # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + return self.github_issue + except GITHUB_Issue.DoesNotExist: + return None + + def has_github_issue(self): + from dojo.github.models import GITHUB_Issue # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + # Attempt to access the github issue if it exists. If not, an exception will be caught + _ = self.github_issue + except GITHUB_Issue.DoesNotExist: + return False + return True + + def github_conf(self): + from dojo.github.models import GITHUB_PKey # noqa: PLC0415 -- lazy import, avoids circular dependency + try: + github_product_key = GITHUB_PKey.objects.get(product=self.test.engagement.product) + github_conf = github_product_key.conf + except: + github_conf = None + return github_conf + + # newer version that can work with prefetching + def github_conf_new(self): + try: + return self.test.engagement.product.github_pkey_set.all()[0].git_conf + except: + return None + + @property + def has_jira_issue(self): + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_issue(self) + + @cached_property + def finding_group(self): + return self.finding_group_set.all().first() + # logger.debug('finding.finding_group: %s', group) + + @cached_property + def has_jira_group_issue(self): + if not self.has_finding_group: + return False + + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_issue(self.finding_group) + + @property + def has_jira_configured(self): + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_configured(self) + + @cached_property + def has_finding_group(self): + return self.finding_group is not None + + def save_no_options(self, *args, **kwargs): + logger.debug("save_no_options") + return self.save(dedupe_option=False, rules_option=False, product_grading_option=False, + issue_updater_option=False, push_to_jira=False, user=None, *args, **kwargs) + + # Check if a mandatory field is empty. If it's the case, fill it with "no given" + def clean(self): + no_check = ["test", "reporter"] + bigfields = ["description"] + for field_obj in self._meta.fields: + field = field_obj.name + if field not in no_check: + val = getattr(self, field) + if not val and field == "title": + setattr(self, field, "No title given") + if not val and field in bigfields: + setattr(self, field, f"No {field} given") + + def severity_display(self): + return self.severity + + def get_breadcrumbs(self): + bc = self.test.get_breadcrumbs() + bc += [{"title": str(self), + "url": reverse("view_finding", args=(self.id,))}] + return bc + + def get_valid_request_response_pairs(self): + empty_value = base64.b64encode(b"") + # Get a list of all req/resp pairs + all_req_resps = self.burprawrequestresponse_set.all() + # Filter away those that do not have any contents + return all_req_resps.exclude( + burpRequestBase64__exact=empty_value, + burpResponseBase64__exact=empty_value, + ) + + def get_report_requests(self): + # Get the list of request response pairs that are non empty + request_response_pairs = self.get_valid_request_response_pairs() + # Determine how many to return + if request_response_pairs.count() >= 3: + return request_response_pairs[0:3] + if request_response_pairs.count() > 0: + return request_response_pairs + return None + + def get_request(self): + # Get the list of request response pairs that are non empty + request_response_pairs = self.get_valid_request_response_pairs() + # Determine what to return + if request_response_pairs.count() > 0: + reqres = request_response_pairs.first() + return base64.b64decode(reqres.burpRequestBase64) + + def get_response(self): + # Get the list of request response pairs that are non empty + request_response_pairs = self.get_valid_request_response_pairs() + # Determine what to return + if request_response_pairs.count() > 0: + reqres = request_response_pairs.first() + res = base64.b64decode(reqres.burpResponseBase64) + # Removes all blank lines + return re.sub(r"\n\s*\n", "\n", res) + + def latest_note(self): + if self.notes.all(): + note = self.notes.all()[0] + return note.date.strftime("%Y-%m-%d %H:%M:%S") + ": " + note.author.get_full_name() + " : " + note.entry + + return "" + + def get_sast_source_file_path_with_link(self): + from dojo.utils import create_bleached_link # noqa: PLC0415 -- lazy import, avoids circular dependency + if self.sast_source_file_path is None: + return None + if self.test.engagement.source_code_management_uri is None: + return escape(self.sast_source_file_path) + link = self.test.engagement.source_code_management_uri + "/" + self.sast_source_file_path + if self.sast_source_line: + link = link + "#L" + str(self.sast_source_line) + return create_bleached_link(link, self.sast_source_file_path) + + def get_file_path_with_link(self): + from dojo.utils import create_bleached_link # noqa: PLC0415 -- lazy import, avoids circular dependency + if self.file_path is None: + return None + if self.test.engagement.source_code_management_uri is None: + return escape(self.file_path) + link = self.get_file_path_with_raw_link() + return create_bleached_link(link, self.file_path) + + def get_scm_type(self): + # extract scm type from product custom field 'scm-type' + + from dojo.models import DojoMeta # noqa: PLC0415 -- lazy import, avoids circular dependency + if hasattr(self.test.engagement, "product"): + dojo_meta = DojoMeta.objects.filter(product=self.test.engagement.product, name="scm-type").first() + if dojo_meta: + st = dojo_meta.value.strip() + if st: + return st.lower() + return "" + + def scm_public_prepare_base_link(self, uri): + # scm public (https://scm-domain.org) url template for browse is: + # https://scm-domain.org// + # but when you get repo url for git, its template is: + # https://scm-domain.org//.git + # so to create browser url - git url should be recomposed like below: + + parts_uri = uri.split(".git") + return parts_uri[0] + + def git_public_prepare_scm_link(self, uri, scm_type): + # if commit hash or branch/tag is set for engagement/test - + # hash or branch/tag should be appended to base browser link + intermediate_path = "/blob/" if scm_type in {"github", "gitlab"} else "/src/" + + link = self.scm_public_prepare_base_link(uri) + if self.test.commit_hash: + link += intermediate_path + self.test.commit_hash + "/" + self.file_path + elif self.test.engagement.commit_hash: + link += intermediate_path + self.test.engagement.commit_hash + "/" + self.file_path + elif self.test.branch_tag: + link += intermediate_path + self.test.branch_tag + "/" + self.file_path + elif self.test.engagement.branch_tag: + link += intermediate_path + self.test.engagement.branch_tag + "/" + self.file_path + else: + link += intermediate_path + "master/" + self.file_path + + return link + + def bitbucket_standalone_prepare_scm_base_link(self, uri): + # bitbucket onpremise/standalone url template for browse is: + # https://bb.example.com/projects//repos/ + # but when you get repo url for git, its template is: + # https://bb.example.com/scm//.git + # or for user public repo^ + # https://bb.example.com/users//repos/ + # but when you get repo url for git, its template is: + # https://bb.example.com/scm//.git (username often could be prefixed with ~) + # so to create borwser url - git url should be recomposed like below: + + parts_uri = uri.split(".git") + parts_scm = parts_uri[0].split("/scm/") + parts_project = parts_scm[1].split("/") + project = parts_project[0] + if project.startswith("~"): + return parts_scm[0] + "/users/" + parts_project[0][1:] + "/repos/" + parts_project[1] + "/browse" + return parts_scm[0] + "/projects/" + parts_project[0] + "/repos/" + parts_project[1] + "/browse" + + def bitbucket_standalone_prepare_scm_link(self, uri): + # if commit hash or branch/tag is set for engagement/test - + # hash or barnch/tag should be appended to base browser link + + link = self.bitbucket_standalone_prepare_scm_base_link(uri) + if self.test.commit_hash: + link += "/" + self.file_path + "?at=" + self.test.commit_hash + elif self.test.engagement.commit_hash: + link += "/" + self.file_path + "?at=" + self.test.engagement.commit_hash + elif self.test.branch_tag: + link += "/" + self.file_path + "?at=" + self.test.branch_tag + elif self.test.engagement.branch_tag: + link += "/" + self.file_path + "?at=" + self.test.engagement.branch_tag + else: + link += "/" + self.file_path + + return link + + def get_file_path_with_raw_link(self): + if self.file_path is None: + return None + + link = self.test.engagement.source_code_management_uri + scm_type = self.get_scm_type() + if (self.test.engagement.source_code_management_uri is not None): + if scm_type == "bitbucket-standalone": + link = self.bitbucket_standalone_prepare_scm_link(link) + elif scm_type in {"github", "gitlab", "gitea", "codeberg", "bitbucket"}: + link = self.git_public_prepare_scm_link(link, scm_type) + elif "https://github.com/" in self.test.engagement.source_code_management_uri: + link = self.git_public_prepare_scm_link(link, "github") + else: + link += "/" + self.file_path + else: + link += "/" + self.file_path + + # than - add line part to browser url + if self.line: + if scm_type in {"github", "gitlab", "gitea", "codeberg"} or "https://github.com/" in self.test.engagement.source_code_management_uri: + link = link + "#L" + str(self.line) + elif scm_type == "bitbucket-standalone": + link = link + "#" + str(self.line) + elif scm_type == "bitbucket": + link = link + "#lines-" + str(self.line) + return link + + def get_references_with_links(self): + from dojo.utils import create_bleached_link # noqa: PLC0415 -- lazy import, avoids circular dependency + if self.references is None: + return None + matches = re.findall(r"([\(|\[]?(https?):((//)|(\\\\))+([\w\d:#@%/;$~_?\+-=\\\.&](#!)?)*[\)|\]]?)", self.references) + + processed_matches = [] + for match in matches: + # Check if match isn't already a markdown link + # Only replace the same matches one time, otherwise the links will be corrupted + if not (match[0].startswith("[") or match[0].startswith("(")) and match[0] not in processed_matches: + self.references = self.references.replace(match[0], create_bleached_link(match[0], match[0]), 1) + processed_matches.append(match[0]) + + return self.references + + @cached_property + def vulnerability_ids(self): + # Get vulnerability ids from database and convert to list of strings + vulnerability_ids_model = self.vulnerability_id_set.all() + vulnerability_ids = [vulnerability_id.vulnerability_id for vulnerability_id in vulnerability_ids_model] + + # Synchronize the cve field with the unsaved_vulnerability_ids + # We do this to be as flexible as possible to handle the fields until + # the cve field is not needed anymore and can be removed. + if vulnerability_ids and self.cve: + # Make sure the first entry of the list is the value of the cve field + vulnerability_ids.insert(0, self.cve) + elif not vulnerability_ids and self.cve: + # If there is no list, make one with the value of the cve field + vulnerability_ids = [self.cve] + + # Remove duplicates + return list(dict.fromkeys(vulnerability_ids)) + + @property + def violates_sla(self): + return (self.sla_expiration_date and self.sla_expiration_date < timezone.now().date()) + + def set_hash_code(self, dedupe_option): + from dojo.utils import get_custom_method # noqa: PLC0415 -- lazy import, avoids circular dependency + if hash_method := get_custom_method("FINDING_HASH_METHOD"): + deduplicationLogger.debug("Using custom hash method") + hash_method(self, dedupe_option) + # Finding.save is called once from serializers.py with dedupe_option=False because the finding is not ready yet, for example the endpoints are not built + # It is then called a second time with dedupe_option defaulted to true; now we can compute the hash_code and run the deduplication + elif dedupe_option: + finding_id = self.id if self.id is not None else "unsaved" + if self.hash_code is not None: + deduplicationLogger.debug("Hash_code already computed for finding: %s", finding_id) + else: + self.hash_code = self.compute_hash_code() + deduplicationLogger.debug("Hash_code computed for finding: %s: %s", finding_id, self.hash_code) + + +class Vulnerability_Id(models.Model): + finding = models.ForeignKey("dojo.Finding", editable=False, on_delete=models.CASCADE) + vulnerability_id = models.TextField(max_length=50, blank=False, null=False) + + def __str__(self): + return self.vulnerability_id + + def get_absolute_url(self): + return reverse("view_finding", args=[str(self.finding.id)]) + + +class Finding_Group(TimeStampedModel): + + GROUP_BY_OPTIONS = [("component_name", "Component Name"), + ("component_name+component_version", "Component Name + Version"), + ("file_path", "File path"), + ("finding_title", "Finding Title"), + ("vuln_id_from_tool", "Vulnerability ID from Tool")] + + name = models.CharField(max_length=255, blank=False, null=False) + test = models.ForeignKey("dojo.Test", on_delete=models.CASCADE) + findings = models.ManyToManyField("dojo.Finding") + creator = models.ForeignKey("dojo.Dojo_User", on_delete=models.RESTRICT) + + def __str__(self): + return self.name + + @property + def has_jira_issue(self): + from dojo.jira import services as jira_services # noqa: PLC0415 -- lazy import, avoids circular dependency + return jira_services.has_issue(self) + + @cached_property + def severity(self): + if not self.findings.all(): + return None + max_number_severity = max(Finding.get_number_severity(find.severity) for find in self.findings.all()) + return Finding.get_severity(max_number_severity) + + @cached_property + def components(self): + components: dict[str, set[str | None]] = {} + for finding in self.findings.all(): + if finding.component_name is not None: + components.setdefault(finding.component_name, set()).add(finding.component_version) + return "; ".join(f"""{name}: {", ".join(map(str, versions))}""" for name, versions in components.items()) + + @property + def age(self): + if not self.findings.all(): + return None + + return max(find.age for find in self.findings.all()) + + @cached_property + def sla_days_remaining_internal(self): + if not self.findings.all(): + return None + + return min([find.sla_days_remaining() for find in self.findings.all() if find.sla_days_remaining()], default=None) + + def sla_days_remaining(self): + return self.sla_days_remaining_internal + + def sla_deadline(self): + if not self.findings.all(): + return None + + return min([find.sla_deadline() for find in self.findings.all() if find.sla_deadline()], default=None) + + def status(self): + if not self.findings.all(): + return None + + if any(find.active for find in self.findings.all()): + return "Active" + + if all(find.is_mitigated for find in self.findings.all()): + return "Mitigated" + + return "Inactive" + + @cached_property + def mitigated(self): + return all(find.mitigated is not None for find in self.findings.all()) + + def get_sla_start_date(self): + return min(find.get_sla_start_date() for find in self.findings.all()) + + def get_absolute_url(self): + return reverse("view_test", args=[str(self.test.id)]) + + class Meta: + ordering = ["id"] + + +class Finding_Template(models.Model): + title = models.TextField(max_length=1000) + cwe = models.IntegerField(default=None, null=True, blank=True) + cve = models.CharField(max_length=50, + null=True, + blank=False, + verbose_name="Vulnerability Id", + help_text="An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.") + cvssv3 = models.TextField(help_text=_("Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding."), validators=[cvss3_validator], max_length=117, null=True, verbose_name=_("CVSS v3 vector")) + cvssv3_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv3 score")) + cvssv4 = models.TextField(help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding."), validators=[cvss4_validator], max_length=255, null=True, verbose_name=_("CVSS4 vector")) + cvssv4_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv4 score")) + + severity = models.CharField(max_length=200, null=True, blank=True) + description = models.TextField(null=True, blank=True) + mitigation = models.TextField(null=True, blank=True) + impact = models.TextField(null=True, blank=True) + references = models.TextField(null=True, blank=True, db_column="refs") + last_used = models.DateTimeField(null=True, editable=False) + numerical_severity = models.CharField(max_length=4, null=True, blank=True, editable=False) + + # Remediation planning fields + fix_available = models.BooleanField(null=True, blank=True, help_text=_("Indicates if a fix is available for this vulnerability type")) + fix_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Version where fix is available")) + planned_remediation_version = models.CharField(max_length=99, null=True, blank=True, help_text=_("Target version for remediation")) + effort_for_fixing = models.CharField(max_length=99, null=True, blank=True, help_text=_("Effort estimate for fixing (e.g., Low/Medium/High)")) + + # Technical details fields + steps_to_reproduce = models.TextField(null=True, blank=True, help_text=_("Standard reproduction steps for this vulnerability type")) + severity_justification = models.TextField(null=True, blank=True, help_text=_("Explanation of why this severity level is appropriate")) + component_name = models.CharField(max_length=500, null=True, blank=True, help_text=_("Affected component name (e.g., library name)")) + component_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Affected component version")) + + # Notes field (single note content, not a list) + notes = models.TextField(null=True, blank=True, help_text=_("Note content to add when applying this template")) + + # String-based list fields (newline-separated) + vulnerability_ids_text = models.TextField(null=True, blank=True, help_text=_("Vulnerability IDs (one per line)")) + endpoints_text = models.TextField(null=True, blank=True, help_text=_("Endpoint URLs (one per line)")) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.")) + + SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, + "High": 1, "Critical": 0} + + class Meta: + ordering = ["-cwe"] + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("edit_template", args=[str(self.id)]) + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": reverse("view_template", args=(self.id,))}] + + @property + def vulnerability_ids(self): + """Parse vulnerability IDs from TextField string (newline-separated).""" + vulnerability_ids = [] + + # Get from the TextField + if self.vulnerability_ids_text: + # Parse newline-separated string, remove empty lines + vulnerability_ids = [line.strip() for line in self.vulnerability_ids_text.split("\n") if line.strip()] + + # Synchronize the cve field with the vulnerability_ids + # We do this to be as flexible as possible to handle the fields until + # the cve field is not needed anymore and can be removed. + if vulnerability_ids and self.cve and self.cve not in vulnerability_ids: + # Make sure the first entry of the list is the value of the cve field + vulnerability_ids.insert(0, self.cve) + elif not vulnerability_ids and self.cve: + # If there is no list, make one with the value of the cve field + vulnerability_ids = [self.cve] + + # Remove duplicates + return list(dict.fromkeys(vulnerability_ids)) + + @property + def endpoints(self): + """Parse endpoint URLs from TextField string (newline-separated).""" + if not self.endpoints_text: + return [] + # Parse newline-separated string, remove empty lines + return [line.strip() for line in self.endpoints_text.split("\n") if line.strip()] diff --git a/dojo/models.py b/dojo/models.py index 5f709f4b543..3cd1041caec 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1,22 +1,16 @@ import base64 import contextlib import copy -import hashlib import logging import re import warnings -from contextlib import suppress -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path -from typing import TYPE_CHECKING from urllib.parse import urlparse from uuid import uuid4 -import dateutil import hyperlink import tagulous.admin -from dateutil.parser import parse as datetutilsparse -from dateutil.relativedelta import relativedelta from django import forms from django.conf import settings from django.contrib import admin @@ -31,8 +25,6 @@ from django.urls import reverse from django.utils import timezone from django.utils.deconstruct import deconstructible -from django.utils.functional import cached_property -from django.utils.html import escape from django.utils.timezone import now from django.utils.translation import gettext as _ from django_extensions.db.models import TimeStampedModel @@ -41,14 +33,6 @@ from polymorphic.models import PolymorphicModel from tagulous.models import TagField from tagulous.models.managers import FakeTagRelatedManager # noqa: F401 -- backward compat re-export -from titlecase import titlecase - -from dojo.base_models.base import BaseModel -from dojo.validators import cvss3_validator, cvss4_validator - -if TYPE_CHECKING: - from dojo.importers.location_manager import UnsavedLocation - logger = logging.getLogger(__name__) deduplicationLogger = logging.getLogger("dojo.specific-loggers.deduplication") @@ -760,7 +744,7 @@ def clean(self): Test, Test_Import, # noqa: F401 -- re-export Test_Import_Finding_Action, # noqa: F401 -- re-export - Test_Type, + Test_Type, # noqa: F401 -- re-export ) @@ -1516,1457 +1500,12 @@ class Meta: ordering = ("-created", ) -class Finding(BaseModel): - # Fields loaded when performing deduplication (used by get_finding_models_for_deduplication - # and build_candidate_scope_queryset to restrict the SELECT to only what is needed). - # Covers the union of all deduplication algorithms so that a single queryset works - # regardless of which algorithm is in use. Large text fields (description, mitigation, - # impact, references, …) are intentionally excluded. - DEDUPLICATION_FIELDS = [ - "id", - # FK required for select_related("test") — must not be deferred - "test", - # Fields written by set_duplicate - "duplicate", - "active", - "verified", - "duplicate_finding", - # Guard checks in set_duplicate - "is_mitigated", - "mitigated", - "out_of_scope", - "false_p", - # Accessed by status() (debug logging only) - "under_review", - "risk_accepted", - # Used by hash-code and legacy algorithms for endpoint/location matching - "dynamic_finding", - "static_finding", - # Algorithm-specific matching fields - "hash_code", # hash_code, uid_or_hash, legacy - "unique_id_from_tool", # unique_id, uid_or_hash - "title", # legacy - "cwe", # legacy - "file_path", # legacy - "line", # legacy - ] - - # Large text fields deferred in build_candidate_scope_queryset. These are - # never accessed during deduplication or reimport candidate matching, so - # excluding them reduces the data loaded for every candidate finding. - DEDUPLICATION_DEFERRED_FIELDS = [ - "description", - "mitigation", - "impact", - "steps_to_reproduce", - "severity_justification", - "references", - "url", - "cvssv3", - "cvssv4", - ] - - title = models.CharField(max_length=511, - verbose_name=_("Title"), - help_text=_("A short description of the flaw.")) - date = models.DateField(default=get_current_date, - verbose_name=_("Date"), - help_text=_("The date the flaw was discovered.")) - sla_start_date = models.DateField( - blank=True, - null=True, - verbose_name=_("SLA Start Date"), - help_text=_("(readonly)The date used as start date for SLA calculation. Set by expiring risk acceptances. Empty by default, causing a fallback to 'date'.")) - sla_expiration_date = models.DateField( - blank=True, - null=True, - verbose_name=_("SLA Expiration Date"), - help_text=_("(readonly)The date SLA expires for this finding. Empty by default, causing a fallback to 'date'.")) - cwe = models.IntegerField(default=0, null=True, blank=True, - verbose_name=_("CWE"), - help_text=_("The CWE number associated with this flaw.")) - cve = models.CharField(max_length=50, - null=True, - blank=False, - verbose_name=_("Vulnerability Id"), - help_text=_("An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.")) - epss_score = models.FloatField(default=None, null=True, blank=True, - verbose_name=_("EPSS Score"), - help_text=_("EPSS score for the CVE. Describes how likely it is the vulnerability will be exploited in the next 30 days."), - validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - epss_percentile = models.FloatField(default=None, null=True, blank=True, - verbose_name=_("EPSS percentile"), - help_text=_("EPSS percentile for the CVE. Describes how many CVEs are scored at or below this one."), - validators=[MinValueValidator(0.0), MaxValueValidator(1.0)]) - known_exploited = models.BooleanField(default=False, - verbose_name=_("Known Exploited"), - help_text=_("Whether this vulnerability is known to have been exploited in the wild.")) - ransomware_used = models.BooleanField(default=False, - verbose_name=_("Used in Ransomware"), - help_text=_("Whether this vulnerability is known to have been leveraged as part of a ransomware campaign.")) - kev_date = models.DateField(null=True, blank=True, - verbose_name=_("KEV Date Added"), - help_text=_("The date the vulnerability was added to the KEV catalog."), - validators=[MaxValueValidator(tomorrow)]) - cvssv3 = models.TextField(validators=[cvss3_validator], - max_length=117, - null=True, - verbose_name=_("CVSS3 Vector"), - help_text=_("Common Vulnerability Scoring System version 3 (CVSS3) score associated with this finding.")) - cvssv3_score = models.FloatField(null=True, - blank=True, - verbose_name=_("CVSS3 Score"), - help_text=_("Numerical CVSSv3 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), - validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) - - cvssv4 = models.TextField(validators=[cvss4_validator], - max_length=255, - null=True, - verbose_name=_("CVSS4 vector"), - help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding.")) - cvssv4_score = models.FloatField(null=True, - blank=True, - verbose_name=_("CVSSv4 Score"), - help_text=_("Numerical CVSSv4 score for the vulnerability. If the vector is given, the score is updated while saving the finding. The value must be between 0-10."), - validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]) - - url = models.TextField(null=True, - blank=True, - editable=False, - verbose_name=_("URL"), - help_text=_("External reference that provides more information about this flaw.")) # not displayed and pretty much the same as references. To remove? - severity = models.CharField(max_length=200, - verbose_name=_("Severity"), - help_text=_("The severity level of this flaw (Critical, High, Medium, Low, Info).")) - description = models.TextField(verbose_name=_("Description"), - help_text=_("Longer more descriptive information about the flaw.")) - mitigation = models.TextField(verbose_name=_("Mitigation"), - null=True, - blank=True, - help_text=_("Text describing how to best fix the flaw.")) - fix_available = models.BooleanField(null=True, - default=None, - verbose_name=_("Fix Available"), - help_text=_("Denotes if there is a fix available for this flaw.")) - fix_version = models.CharField(null=True, - blank=True, - max_length=100, - verbose_name=_("Fix version"), - help_text=_("Version of the affected component in which the flaw is fixed.")) - impact = models.TextField(verbose_name=_("Impact"), - null=True, - blank=True, - help_text=_("Text describing the impact this flaw has on systems, products, enterprise, etc.")) - steps_to_reproduce = models.TextField(null=True, - blank=True, - verbose_name=_("Steps to Reproduce"), - help_text=_("Text describing the steps that must be followed in order to reproduce the flaw / bug.")) - severity_justification = models.TextField(null=True, - blank=True, - verbose_name=_("Severity Justification"), - help_text=_("Text describing why a certain severity was associated with this flaw.")) - # TODO: Delete this after the move to Locations - endpoints = models.ManyToManyField(Endpoint, - blank=True, - verbose_name=_("Endpoints"), - help_text=_("The hosts within the product that are susceptible to this flaw. + The status of the endpoint associated with this flaw (Vulnerable, Mitigated, ...)."), - through=Endpoint_Status) - references = models.TextField(null=True, - blank=True, - db_column="refs", - verbose_name=_("References"), - help_text=_("The external documentation available for this flaw.")) - test = models.ForeignKey(Test, - editable=False, - on_delete=models.CASCADE, - verbose_name=_("Test"), - help_text=_("The test that is associated with this flaw.")) - active = models.BooleanField(default=True, - verbose_name=_("Active"), - help_text=_("Denotes if this flaw is active or not.")) - # note that false positive findings cannot be verified - # in defectdojo verified means: "we have verified the finding and it turns out that it's not a false positive" - verified = models.BooleanField(default=False, - verbose_name=_("Verified"), - help_text=_("Denotes if this flaw has been manually verified by the tester.")) - false_p = models.BooleanField(default=False, - verbose_name=_("False Positive"), - help_text=_("Denotes if this flaw has been deemed a false positive by the tester.")) - duplicate = models.BooleanField(default=False, - verbose_name=_("Duplicate"), - help_text=_("Denotes if this flaw is a duplicate of other flaws reported.")) - duplicate_finding = models.ForeignKey("self", - editable=False, - null=True, - related_name="original_finding", - blank=True, on_delete=models.DO_NOTHING, - verbose_name=_("Duplicate Finding"), - help_text=_("Link to the original finding if this finding is a duplicate.")) - out_of_scope = models.BooleanField(default=False, - verbose_name=_("Out Of Scope"), - help_text=_("Denotes if this flaw falls outside the scope of the test and/or engagement.")) - risk_accepted = models.BooleanField(default=False, - verbose_name=_("Risk Accepted"), - help_text=_("Denotes if this finding has been marked as an accepted risk.")) - under_review = models.BooleanField(default=False, - verbose_name=_("Under Review"), - help_text=_("Denotes is this flaw is currently being reviewed.")) - - last_status_update = models.DateTimeField(editable=False, - null=True, - blank=True, - auto_now_add=True, - verbose_name=_("Last Status Update"), - help_text=_("Timestamp of latest status update (change in status related fields).")) - - review_requested_by = models.ForeignKey(Dojo_User, - null=True, - blank=True, - related_name="review_requested_by", - on_delete=models.RESTRICT, - verbose_name=_("Review Requested By"), - help_text=_("Documents who requested a review for this finding.")) - reviewers = models.ManyToManyField(Dojo_User, - blank=True, - verbose_name=_("Reviewers"), - help_text=_("Documents who reviewed the flaw.")) - - # Defect Tracking Review - under_defect_review = models.BooleanField(default=False, - verbose_name=_("Under Defect Review"), - help_text=_("Denotes if this finding is under defect review.")) - defect_review_requested_by = models.ForeignKey(Dojo_User, - null=True, - blank=True, - related_name="defect_review_requested_by", - on_delete=models.RESTRICT, - verbose_name=_("Defect Review Requested By"), - help_text=_("Documents who requested a defect review for this flaw.")) - is_mitigated = models.BooleanField(default=False, - verbose_name=_("Is Mitigated"), - help_text=_("Denotes if this flaw has been fixed.")) - thread_id = models.IntegerField(default=0, - editable=False, - verbose_name=_("Thread ID")) - mitigated = models.DateTimeField(editable=False, - null=True, - blank=True, - verbose_name=_("Mitigated"), - help_text=_("Denotes if this flaw has been fixed by storing the date it was fixed.")) - mitigated_by = models.ForeignKey(Dojo_User, - null=True, - editable=False, - related_name="mitigated_by", - on_delete=models.RESTRICT, - verbose_name=_("Mitigated By"), - help_text=_("Documents who has marked this flaw as fixed.")) - reporter = models.ForeignKey(Dojo_User, - editable=False, - default=1, - related_name="reporter", - on_delete=models.RESTRICT, - verbose_name=_("Reporter"), - help_text=_("Documents who reported the flaw.")) - notes = models.ManyToManyField(Notes, - blank=True, - editable=False, - verbose_name=_("Notes"), - help_text=_("Stores information pertinent to the flaw or the mitigation.")) - numerical_severity = models.CharField(max_length=4, - verbose_name=_("Numerical Severity"), - help_text=_("The numerical representation of the severity (S0, S1, S2, S3, S4).")) - last_reviewed = models.DateTimeField(null=True, - editable=False, - verbose_name=_("Last Reviewed"), - help_text=_("Provides the date the flaw was last 'touched' by a tester.")) - last_reviewed_by = models.ForeignKey(Dojo_User, - null=True, - editable=False, - related_name="last_reviewed_by", - on_delete=models.RESTRICT, - verbose_name=_("Last Reviewed By"), - help_text=_("Provides the person who last reviewed the flaw.")) - files = models.ManyToManyField(FileUpload, - blank=True, - editable=False, - verbose_name=_("Files"), - help_text=_("Files(s) related to the flaw.")) - param = models.TextField(null=True, - blank=True, - editable=False, - verbose_name=_("Parameter"), - help_text=_("Parameter used to trigger the issue (DAST).")) - payload = models.TextField(null=True, - blank=True, - editable=False, - verbose_name=_("Payload"), - help_text=_("Payload used to attack the service / application and trigger the bug / problem.")) - hash_code = models.CharField(null=True, - blank=True, - editable=False, - max_length=64, - verbose_name=_("Hash Code"), - help_text=_("A hash over a configurable set of fields that is used for findings deduplication.")) - line = models.IntegerField(null=True, - blank=True, - verbose_name=_("Line number"), - help_text=_("Source line number of the attack vector.")) - file_path = models.CharField(null=True, - blank=True, - max_length=4000, - verbose_name=_("File path"), - help_text=_("Identified file(s) containing the flaw.")) - component_name = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("Component name"), - help_text=_("Name of the affected component (library name, part of a system, ...).")) - component_version = models.CharField(null=True, - blank=True, - max_length=100, - verbose_name=_("Component version"), - help_text=_("Version of the affected component.")) - found_by = models.ManyToManyField(Test_Type, - editable=False, - verbose_name=_("Found by"), - help_text=_("The name of the scanner that identified the flaw.")) - static_finding = models.BooleanField(default=False, - verbose_name=_("Static finding (SAST)"), - help_text=_("Flaw has been detected from a Static Application Security Testing tool (SAST).")) - dynamic_finding = models.BooleanField(default=True, - verbose_name=_("Dynamic finding (DAST)"), - help_text=_("Flaw has been detected from a Dynamic Application Security Testing tool (DAST).")) - scanner_confidence = models.IntegerField(null=True, - blank=True, - default=None, - editable=False, - verbose_name=_("Scanner confidence"), - help_text=_("Confidence level of vulnerability which is supplied by the scanner.")) - sonarqube_issue = models.ForeignKey(Sonarqube_Issue, - null=True, - blank=True, - help_text=_("The SonarQube issue associated with this finding."), - verbose_name=_("SonarQube issue"), - on_delete=models.CASCADE) - unique_id_from_tool = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("Unique ID from tool"), - help_text=_("Vulnerability technical id from the source tool. Allows to track unique vulnerabilities over time across subsequent scans.")) - vuln_id_from_tool = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("Vulnerability ID from tool"), - help_text=_("Non-unique technical id from the source tool associated with the vulnerability type.")) - sast_source_object = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("SAST Source Object"), - help_text=_("Source object (variable, function...) of the attack vector.")) - sast_sink_object = models.CharField(null=True, - blank=True, - max_length=500, - verbose_name=_("SAST Sink Object"), - help_text=_("Sink object (variable, function...) of the attack vector.")) - sast_source_line = models.IntegerField(null=True, - blank=True, - verbose_name=_("SAST Source Line number"), - help_text=_("Source line number of the attack vector.")) - sast_source_file_path = models.CharField(null=True, - blank=True, - max_length=4000, - verbose_name=_("SAST Source File Path"), - help_text=_("Source file path of the attack vector.")) - nb_occurences = models.IntegerField(null=True, - blank=True, - verbose_name=_("Number of occurences"), - help_text=_("Number of occurences in the source tool when several vulnerabilites were found and aggregated by the scanner.")) - - # this is useful for vulnerabilities on dependencies : helps answer the question "Did I add this vulnerability or was it discovered recently?" - publish_date = models.DateField(null=True, - blank=True, - verbose_name=_("Publish date"), - help_text=_("Date when this vulnerability was made publicly available.")) - - # The service is used to generate the hash_code, so that it gets part of the deduplication of findings. - service = models.CharField(null=True, - blank=True, - max_length=200, - verbose_name=_("Service"), - help_text=_("A service is a self-contained piece of functionality within a Product. This is an optional field which is used in deduplication of findings when set.")) - - planned_remediation_date = models.DateField(null=True, - editable=True, - verbose_name=_("Planned Remediation Date"), - help_text=_("The date the flaw is expected to be remediated.")) - - planned_remediation_version = models.CharField(null=True, - blank=True, - max_length=99, - verbose_name=_("Planned remediation version"), - help_text=_("The target version when the vulnerability should be fixed / remediated")) - - effort_for_fixing = models.CharField(null=True, - blank=True, - max_length=99, - verbose_name=_("Effort for fixing"), - help_text=_("Effort for fixing / remediating the vulnerability (Low, Medium, High)")) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding. Choose from the list or add new tags. Press Enter key to add.")) - inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) - - SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, - "High": 1, "Critical": 0} - - class Meta: - ordering = ("numerical_severity", "-date", "title", "epss_score", "epss_percentile") - indexes = [ - models.Index(fields=["test", "active", "verified"]), - - models.Index(fields=["test", "is_mitigated"]), - models.Index(fields=["test", "duplicate"]), - models.Index(fields=["test", "out_of_scope"]), - models.Index(fields=["test", "false_p"]), - - models.Index(fields=["test", "unique_id_from_tool", "duplicate"]), - models.Index(fields=["test", "hash_code", "duplicate"]), - - models.Index(fields=["test", "component_name"]), - - models.Index(fields=["cve"]), - models.Index(fields=["epss_score"]), - models.Index(fields=["epss_percentile"]), - models.Index(fields=["cwe"]), - models.Index(fields=["out_of_scope"]), - models.Index(fields=["false_p"]), - models.Index(fields=["verified"]), - models.Index(fields=["mitigated"]), - models.Index(fields=["active"]), - models.Index(fields=["numerical_severity"]), - models.Index(fields=["date"]), - models.Index(fields=["title"]), - models.Index(fields=["hash_code"]), - models.Index(fields=["unique_id_from_tool"]), - # models.Index(fields=['file_path']), # can't add index because the field has max length 4000. - models.Index(fields=["line"]), - models.Index(fields=["component_name"]), - models.Index(fields=["duplicate"]), - models.Index(fields=["is_mitigated"]), - models.Index(fields=["duplicate_finding", "id"]), - models.Index(fields=["known_exploited"]), - models.Index(fields=["ransomware_used"]), - models.Index(fields=["kev_date"]), - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if settings.V3_FEATURE_LOCATIONS: - self.unsaved_locations: list[UnsavedLocation] = [] - else: - # TODO: Delete this after the move to Locations - self.unsaved_endpoints = [] - self.unsaved_request = None - self.unsaved_response = None - self.unsaved_tags = None - self.unsaved_files = None - self.unsaved_vulnerability_ids = None - - def __str__(self): - return self.title - - def save(self, dedupe_option=True, rules_option=True, product_grading_option=True, # noqa: FBT002 - issue_updater_option=True, push_to_jira=False, user=None, *args, **kwargs): # noqa: FBT002 - this is bit hard to fix nice have this universally fixed - logger.debug("Start saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") - from dojo.finding import helper as finding_helper # noqa: PLC0415 circular import - - is_new_finding = self.pk is None - - # if not isinstance(self.date, (datetime, date)): - # raise ValidationError(_("The 'date' field must be a valid date or datetime object.")) - - if not user: - from dojo.utils import get_current_user # noqa: PLC0415 circular import - user = get_current_user() - # Title Casing - self.title = titlecase(self.title[:511]) - # Set the date of the finding if nothing is supplied - if self.date is None: - self.date = timezone.now() - # Assign the numerical severity for correct sorting order - self.numerical_severity = Finding.get_numerical_severity(self.severity) - - # Synchronize cvssv3 score using cvssv3 vector - - if self.cvssv3: - try: - cvss_data = parse_cvss_data(self.cvssv3) - if cvss_data: - self.cvssv3 = cvss_data.get("cvssv3") - self.cvssv3_score = cvss_data.get("cvssv3_score") - - except Exception as ex: - logger.warning("Can't compute cvssv3 score for finding id %i. Invalid cvssv3 vector found: '%s'. Exception: %s.", self.id, self.cvssv3, ex) - # remove invalid cvssv3 vector for new findings, or should we just throw a ValidationError? - if self.pk is None: - self.cvssv3 = None - - # behaviour for CVVS4 is slightly different. Extracting this into a method would lead to probably hard to read code - if self.cvssv4: - try: - cvss_data = parse_cvss_data(self.cvssv4) - if cvss_data: - self.cvssv4 = cvss_data.get("cvssv4") - self.cvssv4_score = cvss_data.get("cvssv4_score") - - except Exception as ex: - logger.warning("Can't compute cvssv4 score for finding id %i. Invalid cvssv4 vector found: '%s'. Exception: %s.", self.id, self.cvssv4, ex) - self.cvssv4 = None - - self.set_hash_code(dedupe_option) - - if is_new_finding: - if settings.V3_FEATURE_LOCATIONS: - if (self.file_path is not None) and (len(self.unsaved_locations) == 0): - self.static_finding = True - self.dynamic_finding = False - elif (self.file_path is not None): - self.static_finding = True - # TODO: Delete this after the move to Locations - elif (self.file_path is not None) and (len(self.unsaved_endpoints) == 0): - self.static_finding = True - self.dynamic_finding = False - elif (self.file_path is not None): - self.static_finding = True - - # because we have reduced the number of (super()).save() calls, the helper is no longer called for new findings - # so we call it manually - finding_helper.update_finding_status(self, user, changed_fields={"id": (None, None)}) - - # logger.debug('setting static / dynamic in save') - # need to have an id/pk before we can access locations/endpoints - elif self.file_path is not None: - if settings.V3_FEATURE_LOCATIONS: - if not self.locations.exists(): - self.static_finding = True - self.dynamic_finding = False - else: - self.static_finding = True - # TODO: Delete this after the move to Locations - elif not self.endpoints.exists(): - self.static_finding = True - self.dynamic_finding = False - else: - self.static_finding = True - - # update the SLA expiration date last, after all other finding fields have been updated - self.set_sla_expiration_date() - - logger.debug("Saving finding of id " + str(self.id) + " dedupe_option:" + str(dedupe_option) + " (self.pk is %s)", "None" if self.pk is None else "not None") - # We cannot run the full_clean method here without issue, so we specify skip_validation - super().save(*args, **kwargs, skip_validation=True) - - # Only add to found_by for newly-created findings (avoid doing this on every update) - if is_new_finding: - self.found_by.add(self.test.test_type) - - # only perform post processing (in celery task) if needed. this check avoids submitting 1000s of tasks to celery that will do nothing - system_settings = System_Settings.objects.get() - if dedupe_option or issue_updater_option or (product_grading_option and system_settings.enable_product_grade) or push_to_jira: - finding_helper.post_process_finding_save(self.id, dedupe_option=dedupe_option, rules_option=rules_option, product_grading_option=product_grading_option, - issue_updater_option=issue_updater_option, push_to_jira=push_to_jira, user=user, *args, **kwargs) - else: - logger.debug("no options selected that require finding post processing") - - def get_absolute_url(self): - return reverse("view_finding", args=[str(self.id)]) - - def copy(self, test=None): - copy = copy_model_util(self) - # Save the necessary ManyToMany relationships - old_notes = list(self.notes.all()) - old_files = list(self.files.all()) - old_reviewers = list(self.reviewers.all()) - old_found_by = list(self.found_by.all()) - old_tags = list(self.tags.all()) - # Wipe the IDs of the new object - if test: - copy.test = test - # Save the object before setting any ManyToMany relationships - copy.save() - # Copy the notes - for notes in old_notes: - copy.notes.add(notes.copy()) - # Copy the files - for files in old_files: - copy.files.add(files.copy()) - if settings.V3_FEATURE_LOCATIONS: - old_location_refs = self.locations.all() - for location_ref in old_location_refs: - location_ref.copy(copy) - else: - # TODO: Delete this after the move to Locations - # Copy the endpoint_status - old_status_findings = list(self.status_finding.all()) - for endpoint_status in old_status_findings: - endpoint_status.copy(finding=copy) # adding or setting is not necessary, link is created by Endpoint_Status.copy() - # Assign any reviewers - copy.reviewers.set(old_reviewers) - # Assign any found_by - copy.found_by.set(old_found_by) - # Assign any tags - copy.tags.set(old_tags) - - return copy - - def delete(self, *args, product_grading_option=True, **kwargs): - logger.debug("%d finding delete", self.id) - from dojo.finding import helper as finding_helper # noqa: PLC0415 circular import - finding_helper.finding_delete(self) - super().delete(*args, **kwargs) - if product_grading_option: - with suppress(Finding.DoesNotExist, Test.DoesNotExist, Engagement.DoesNotExist, Product.DoesNotExist): - # Suppressing a potential issue created from async delete removing - # related objects in a separate task - from dojo.utils import perform_product_grading # noqa: PLC0415 circular import - perform_product_grading(self.test.engagement.product) - - # only used by bulk risk acceptance api - @classmethod - def unaccepted_open_findings(cls): - from dojo.utils import get_system_setting # noqa: PLC0415 circular import - results = cls.objects.filter(active=True, duplicate=False, risk_accepted=False) - if get_system_setting("enforce_verified_status", True) or get_system_setting("enforce_verified_status_metrics", True): - results = results.filter(verified=True) - - return results - - @property - def risk_acceptance(self): - ras = self.risk_acceptance_set.all() - if ras: - return ras[0] - - return None - - def compute_hash_code(self): - # Allow Pro to overwrite compute hash_code which gets dedupe settings from a database instead of django.settings - from dojo.utils import get_custom_method # noqa: PLC0415 circular import - if compute_hash_code_method := get_custom_method("FINDING_COMPUTE_HASH_METHOD"): - deduplicationLogger.debug("using custom FINDING_COMPUTE_HASH_METHOD method") - return compute_hash_code_method(self) - - # Check if all needed settings are defined - if not hasattr(settings, "HASHCODE_FIELDS_PER_SCANNER") or not hasattr(settings, "HASHCODE_ALLOWS_NULL_CWE") or not hasattr(settings, "HASHCODE_ALLOWED_FIELDS"): - deduplicationLogger.debug("no or incomplete configuration per hash_code found; using legacy algorithm") - return self.compute_hash_code_legacy() - - hash_code_fields = self.test.hash_code_fields - - # Check if hash_code fields are found in the settings - if not hash_code_fields: - deduplicationLogger.debug( - "No configuration for hash_code computation found; using default fields for " + ("dynamic" if self.dynamic_finding else "static") + " scanners") - return self.compute_hash_code_legacy() - - # Check if all elements of HASHCODE_FIELDS_PER_SCANNER are in HASHCODE_ALLOWED_FIELDS - if not (all(elem in settings.HASHCODE_ALLOWED_FIELDS for elem in hash_code_fields)): - deduplicationLogger.debug( - "compute_hash_code - configuration error: some elements of HASHCODE_FIELDS_PER_SCANNER are not in the allowed list HASHCODE_ALLOWED_FIELDS. " - "Using default fields") - return self.compute_hash_code_legacy() - - # Make sure that we have a cwe if we need one - if self.cwe == 0 and not self.test.hash_code_allows_null_cwe: - deduplicationLogger.debug( - "Cannot compute hash_code based on configured fields because cwe is 0 for finding of title '" + self.title + "' found in file '" + str(self.file_path) - + "'. Fallback to legacy mode for this finding.") - return self.compute_hash_code_legacy() - - deduplicationLogger.debug("computing hash_code for finding id " + str(self.id) + " based on: " + ", ".join(hash_code_fields)) - - fields_to_hash = "" - for hashcodeField in hash_code_fields: - # Note: preserve this field label ("endpoints") for settings purposes through the Locations migration - if hashcodeField == "endpoints": - # For locations/endpoints, need to compute the field - locations = self.get_locations() - fields_to_hash += locations - deduplicationLogger.debug(hashcodeField + " : " + locations) - elif hashcodeField == "vulnerability_ids": - # For vulnerability_ids, need to compute the field - my_vulnerability_ids = self.get_vulnerability_ids() - fields_to_hash += my_vulnerability_ids - deduplicationLogger.debug(hashcodeField + " : " + my_vulnerability_ids) - else: - # Generically use the finding attribute having the same name, converts to str in case it's integer - fields_to_hash += str(getattr(self, hashcodeField)) - deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) - - # Log the hash_code fields that are always included (but are not part of the hash_code_fields list as they are inserted downtstream in self.hash_fields) - hash_code_fields_always = getattr(settings, "HASH_CODE_FIELDS_ALWAYS", []) - for hashcodeField in hash_code_fields_always: - if getattr(self, hashcodeField): - deduplicationLogger.debug(hashcodeField + " : " + str(getattr(self, hashcodeField))) - - deduplicationLogger.debug("compute_hash_code - fields_to_hash = " + fields_to_hash) - return self.hash_fields(fields_to_hash) - - def compute_hash_code_legacy(self): - fields_to_hash = self.title + str(self.cwe) + str(self.line) + str(self.file_path) + self.description - deduplicationLogger.debug("compute_hash_code_legacy - fields_to_hash = " + fields_to_hash) - return self.hash_fields(fields_to_hash) - - # Get vulnerability_ids to use for hash_code computation - def get_vulnerability_ids(self): - - def _get_unsaved_vulnerability_ids(finding) -> str: - if finding.unsaved_vulnerability_ids: - deduplicationLogger.debug("get_vulnerability_ids before the finding was saved") - # convert list of unsaved vulnerability_ids to the list of their canonical representation - vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in finding.unsaved_vulnerability_ids] - # deduplicate (usually done upon saving finding) and sort endpoints - return "".join(sorted(dict.fromkeys(vulnerability_id_str_list))) - deduplicationLogger.debug("finding has no unsaved vulnerability references") - return "" - - def _get_saved_vulnerability_ids(finding) -> str: - if finding.id is not None: - vulnerability_ids = Vulnerability_Id.objects.filter(finding=finding) - deduplicationLogger.debug("get_vulnerability_ids after the finding was saved. Vulnerability references count: " + str(vulnerability_ids.count())) - # convert list of vulnerability_ids to the list of their canonical representation - vulnerability_id_str_list = [str(vulnerability_id) for vulnerability_id in vulnerability_ids.all()] - # sort vulnerability_ids strings - return "".join(sorted(vulnerability_id_str_list)) - return "" - - return _get_saved_vulnerability_ids(self) or _get_unsaved_vulnerability_ids(self) - - # Get locations/endpoints to use for hash_code computation - def get_locations(self): - # TODO: Delete this after the move to Locations - if not settings.V3_FEATURE_LOCATIONS: - # Get endpoints to use for hash_code computation - # (This sometimes reports "None") - def _get_unsaved_endpoints(finding) -> str: - if len(finding.unsaved_endpoints) > 0: - deduplicationLogger.debug("get_endpoints before the finding was saved") - # convert list of unsaved endpoints to the list of their canonical representation - endpoint_str_list = [str(endpoint) for endpoint in finding.unsaved_endpoints] - # deduplicate (usually done upon saving finding) and sort endpoints - return "".join(dict.fromkeys(endpoint_str_list)) - # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted - # In this case, before saving the finding, both static_finding and dynamic_finding are True - # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) - deduplicationLogger.debug("trying to get endpoints on a finding before it was saved but no endpoints found (static parser wrongly identified as dynamic?") - return "" - - def _get_saved_endpoints(finding) -> str: - if finding.id is not None: - deduplicationLogger.debug("get_endpoints: after the finding was saved. Endpoints count: " + str(finding.endpoints.count())) - # convert list of endpoints to the list of their canonical representation - endpoint_str_list = [str(endpoint) for endpoint in finding.endpoints.all()] - # sort endpoints strings - return "".join(sorted(endpoint_str_list)) - return "" - - return _get_saved_endpoints(self) or _get_unsaved_endpoints(self) - - def _get_unsaved_locations(finding) -> str: - if len(finding.unsaved_locations) > 0: - deduplicationLogger.debug("get_locations before the finding was saved") - # convert list of unsaved locations to the list of their canonical representation - from dojo.importers.location_manager import LocationManager # noqa: PLC0415 - unsaved_locations = LocationManager.clean_unsaved_locations(finding.unsaved_locations) - # deduplicate (usually done upon saving finding) and sort locations - locations = sorted({location.get_location_value() for location in unsaved_locations}) - return "".join(locations) - # we can get here when the parser defines static_finding=True but leaves dynamic_finding defaulted - # In this case, before saving the finding, both static_finding and dynamic_finding are True - # After saving dynamic_finding may be set to False probably during the saving process (observed on Bandit scan before forcing dynamic_finding=False at parser level) - deduplicationLogger.debug("trying to get locations on a finding before it was saved but no locations found (static parser wrongly identified as dynamic?") - return "" - - def _get_saved_locations(finding) -> str: - if finding.id is not None: - from dojo.url.models import URL # noqa: PLC0415 - url_locations = finding.locations.filter(location__location_type=URL.get_location_type()) - deduplicationLogger.debug("get_locations: after the finding was saved. Locations count: " + str(url_locations.count())) - # convert list of locations to the list of their canonical representation - locations = sorted({location_ref.location.get_location_value() for location_ref in url_locations.all()}) - # sort locations strings - return "".join(sorted(locations)) - return "" - - return _get_saved_locations(self) or _get_unsaved_locations(self) - - # Compute the hash_code from the fields to hash - def hash_fields(self, fields_to_hash): - if hasattr(settings, "HASH_CODE_FIELDS_ALWAYS"): - for field in settings.HASH_CODE_FIELDS_ALWAYS: - if getattr(self, field): - deduplicationLogger.debug("adding HASH_CODE_FIELDS_ALWAYSfield %s to hash_fields: %s", field, getattr(self, field)) - fields_to_hash += str(getattr(self, field)) - - logger.debug("fields_to_hash : %s", fields_to_hash) - logger.debug("fields_to_hash lower: %s", fields_to_hash.lower()) - return hashlib.sha256(fields_to_hash.casefold().encode("utf-8").strip()).hexdigest() - - def duplicate_finding_set(self): - if self.duplicate: - if self.duplicate_finding is not None: - return Finding.objects.get( - id=self.duplicate_finding.id).original_finding.all().order_by("title") - return [] - return self.original_finding.all().order_by("title") - - def get_scanner_confidence_text(self): - if self.scanner_confidence and isinstance(self.scanner_confidence, int): - if self.scanner_confidence <= 2: - return "Certain" - if self.scanner_confidence >= 3 and self.scanner_confidence <= 5: - return "Firm" - return "Tentative" - return "" - - @staticmethod - def get_numerical_severity(severity): - if severity == "Critical": - return "S0" - if severity == "High": - return "S1" - if severity == "Medium": - return "S2" - if severity == "Low": - return "S3" - if severity == "Info": - return "S4" - return "S5" - - @staticmethod - def get_number_severity(severity): - if severity == "Critical": - return 4 - if severity == "High": - return 3 - if severity == "Medium": - return 2 - if severity == "Low": - return 1 - if severity == "Info": - return 0 - return 5 - - @staticmethod - def get_severity(num_severity): - severities = {0: "Info", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"} - if num_severity in severities: - return severities[num_severity] - - return None - - def status(self): - status = [] - if self.under_review: - status += ["Under Review"] - if self.active: - status += ["Active"] - else: - status += ["Inactive"] - if self.verified: - status += ["Verified"] - if self.mitigated or self.is_mitigated: - status += ["Mitigated"] - if self.false_p: - status += ["False Positive"] - if self.out_of_scope: - status += ["Out Of Scope"] - if self.duplicate: - status += ["Duplicate"] - if self.risk_accepted: - status += ["Risk Accepted"] - if not len(status): - status += ["Initial"] - - return ", ".join([str(s) for s in status]) - - def _age(self, start_date): - if start_date and isinstance(start_date, str): - start_date = datetutilsparse(start_date).date() - - if isinstance(start_date, datetime): - start_date = start_date.date() - - if self.mitigated: - mitigated_date = self.mitigated - if isinstance(mitigated_date, datetime): - mitigated_date = self.mitigated.date() - diff = mitigated_date - start_date - else: - diff = get_current_date() - start_date - days = diff.days - return max(0, days) - - @property - def age(self): - return self._age(self.date) - - @property - def sla_age(self): - return self._age(self.get_sla_start_date()) - - def get_sla_start_date(self): - if self.sla_start_date: - return self.sla_start_date - return self.date - - def get_sla_configuration(self): - return self.test.engagement.product.sla_configuration - - def get_sla_period(self): - # Determine which method to use to calculate the SLA - from dojo.utils import get_custom_method # noqa: PLC0415 circular import - if method := get_custom_method("FINDING_SLA_PERIOD_METHOD"): - return method(self) - # Run the default method - sla_configuration = self.get_sla_configuration() - sla_period = getattr(sla_configuration, self.severity.lower(), None) - enforce_period = getattr(sla_configuration, str("enforce_" + self.severity.lower()), None) - return sla_period, enforce_period - - def set_sla_expiration_date(self): - # First check if SLA is enabled globally - system_settings = System_Settings.objects.get() - if not system_settings.enable_finding_sla: - return - # Call the internal method to set the sla expiration date - self._set_sla_expiration_date() - - def _set_sla_expiration_date(self): - # some parsers provide date as a `str` instead of a `date` in which case we need to parse it #12299 on GitHub - sla_start_date = self.get_sla_start_date() - if sla_start_date and isinstance(sla_start_date, str): - sla_start_date = dateutil.parser.parse(sla_start_date).date() - - sla_period, enforce_period = self.get_sla_period() - if sla_period is not None and enforce_period: - self.sla_expiration_date = sla_start_date + relativedelta(days=sla_period) - else: - self.sla_expiration_date = None - - def sla_days_remaining(self): - if self.sla_expiration_date: - if self.mitigated: - mitigated_date = self.mitigated - if isinstance(mitigated_date, datetime): - mitigated_date = self.mitigated.date() - return (self.sla_expiration_date - mitigated_date).days - return (self.sla_expiration_date - get_current_date()).days - return None - - def sla_deadline(self): - return self.sla_expiration_date - - def github(self): - try: - return self.github_issue - except GITHUB_Issue.DoesNotExist: - return None - - def has_github_issue(self): - try: - # Attempt to access the github issue if it exists. If not, an exception will be caught - _ = self.github_issue - except GITHUB_Issue.DoesNotExist: - return False - return True - - def github_conf(self): - try: - github_product_key = GITHUB_PKey.objects.get(product=self.test.engagement.product) - github_conf = github_product_key.conf - except: - github_conf = None - return github_conf - - # newer version that can work with prefetching - def github_conf_new(self): - try: - return self.test.engagement.product.github_pkey_set.all()[0].git_conf - except: - return None - - @property - def has_jira_issue(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_issue(self) - - @cached_property - def finding_group(self): - return self.finding_group_set.all().first() - # logger.debug('finding.finding_group: %s', group) - - @cached_property - def has_jira_group_issue(self): - if not self.has_finding_group: - return False - - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_issue(self.finding_group) - - @property - def has_jira_configured(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_configured(self) - - @cached_property - def has_finding_group(self): - return self.finding_group is not None - - def save_no_options(self, *args, **kwargs): - logger.debug("save_no_options") - return self.save(dedupe_option=False, rules_option=False, product_grading_option=False, - issue_updater_option=False, push_to_jira=False, user=None, *args, **kwargs) - - # Check if a mandatory field is empty. If it's the case, fill it with "no given" - def clean(self): - no_check = ["test", "reporter"] - bigfields = ["description"] - for field_obj in self._meta.fields: - field = field_obj.name - if field not in no_check: - val = getattr(self, field) - if not val and field == "title": - setattr(self, field, "No title given") - if not val and field in bigfields: - setattr(self, field, f"No {field} given") - - def severity_display(self): - return self.severity - - def get_breadcrumbs(self): - bc = self.test.get_breadcrumbs() - bc += [{"title": str(self), - "url": reverse("view_finding", args=(self.id,))}] - return bc - - def get_valid_request_response_pairs(self): - empty_value = base64.b64encode(b"") - # Get a list of all req/resp pairs - all_req_resps = self.burprawrequestresponse_set.all() - # Filter away those that do not have any contents - return all_req_resps.exclude( - burpRequestBase64__exact=empty_value, - burpResponseBase64__exact=empty_value, - ) - - def get_report_requests(self): - # Get the list of request response pairs that are non empty - request_response_pairs = self.get_valid_request_response_pairs() - # Determine how many to return - if request_response_pairs.count() >= 3: - return request_response_pairs[0:3] - if request_response_pairs.count() > 0: - return request_response_pairs - return None - - def get_request(self): - # Get the list of request response pairs that are non empty - request_response_pairs = self.get_valid_request_response_pairs() - # Determine what to return - if request_response_pairs.count() > 0: - reqres = request_response_pairs.first() - return base64.b64decode(reqres.burpRequestBase64) - - def get_response(self): - # Get the list of request response pairs that are non empty - request_response_pairs = self.get_valid_request_response_pairs() - # Determine what to return - if request_response_pairs.count() > 0: - reqres = request_response_pairs.first() - res = base64.b64decode(reqres.burpResponseBase64) - # Removes all blank lines - return re.sub(r"\n\s*\n", "\n", res) - - def latest_note(self): - if self.notes.all(): - note = self.notes.all()[0] - return note.date.strftime("%Y-%m-%d %H:%M:%S") + ": " + note.author.get_full_name() + " : " + note.entry - - return "" - - def get_sast_source_file_path_with_link(self): - from dojo.utils import create_bleached_link # noqa: PLC0415 circular import - if self.sast_source_file_path is None: - return None - if self.test.engagement.source_code_management_uri is None: - return escape(self.sast_source_file_path) - link = self.test.engagement.source_code_management_uri + "/" + self.sast_source_file_path - if self.sast_source_line: - link = link + "#L" + str(self.sast_source_line) - return create_bleached_link(link, self.sast_source_file_path) - - def get_file_path_with_link(self): - from dojo.utils import create_bleached_link # noqa: PLC0415 circular import - if self.file_path is None: - return None - if self.test.engagement.source_code_management_uri is None: - return escape(self.file_path) - link = self.get_file_path_with_raw_link() - return create_bleached_link(link, self.file_path) - - def get_scm_type(self): - # extract scm type from product custom field 'scm-type' - - if hasattr(self.test.engagement, "product"): - dojo_meta = DojoMeta.objects.filter(product=self.test.engagement.product, name="scm-type").first() - if dojo_meta: - st = dojo_meta.value.strip() - if st: - return st.lower() - return "" - - def scm_public_prepare_base_link(self, uri): - # scm public (https://scm-domain.org) url template for browse is: - # https://scm-domain.org// - # but when you get repo url for git, its template is: - # https://scm-domain.org//.git - # so to create browser url - git url should be recomposed like below: - - parts_uri = uri.split(".git") - return parts_uri[0] - - def git_public_prepare_scm_link(self, uri, scm_type): - # if commit hash or branch/tag is set for engagement/test - - # hash or branch/tag should be appended to base browser link - intermediate_path = "/blob/" if scm_type in {"github", "gitlab"} else "/src/" - - link = self.scm_public_prepare_base_link(uri) - if self.test.commit_hash: - link += intermediate_path + self.test.commit_hash + "/" + self.file_path - elif self.test.engagement.commit_hash: - link += intermediate_path + self.test.engagement.commit_hash + "/" + self.file_path - elif self.test.branch_tag: - link += intermediate_path + self.test.branch_tag + "/" + self.file_path - elif self.test.engagement.branch_tag: - link += intermediate_path + self.test.engagement.branch_tag + "/" + self.file_path - else: - link += intermediate_path + "master/" + self.file_path - - return link - - def bitbucket_standalone_prepare_scm_base_link(self, uri): - # bitbucket onpremise/standalone url template for browse is: - # https://bb.example.com/projects//repos/ - # but when you get repo url for git, its template is: - # https://bb.example.com/scm//.git - # or for user public repo^ - # https://bb.example.com/users//repos/ - # but when you get repo url for git, its template is: - # https://bb.example.com/scm//.git (username often could be prefixed with ~) - # so to create borwser url - git url should be recomposed like below: - - parts_uri = uri.split(".git") - parts_scm = parts_uri[0].split("/scm/") - parts_project = parts_scm[1].split("/") - project = parts_project[0] - if project.startswith("~"): - return parts_scm[0] + "/users/" + parts_project[0][1:] + "/repos/" + parts_project[1] + "/browse" - return parts_scm[0] + "/projects/" + parts_project[0] + "/repos/" + parts_project[1] + "/browse" - - def bitbucket_standalone_prepare_scm_link(self, uri): - # if commit hash or branch/tag is set for engagement/test - - # hash or barnch/tag should be appended to base browser link - - link = self.bitbucket_standalone_prepare_scm_base_link(uri) - if self.test.commit_hash: - link += "/" + self.file_path + "?at=" + self.test.commit_hash - elif self.test.engagement.commit_hash: - link += "/" + self.file_path + "?at=" + self.test.engagement.commit_hash - elif self.test.branch_tag: - link += "/" + self.file_path + "?at=" + self.test.branch_tag - elif self.test.engagement.branch_tag: - link += "/" + self.file_path + "?at=" + self.test.engagement.branch_tag - else: - link += "/" + self.file_path - - return link - - def get_file_path_with_raw_link(self): - if self.file_path is None: - return None - - link = self.test.engagement.source_code_management_uri - scm_type = self.get_scm_type() - if (self.test.engagement.source_code_management_uri is not None): - if scm_type == "bitbucket-standalone": - link = self.bitbucket_standalone_prepare_scm_link(link) - elif scm_type in {"github", "gitlab", "gitea", "codeberg", "bitbucket"}: - link = self.git_public_prepare_scm_link(link, scm_type) - elif "https://github.com/" in self.test.engagement.source_code_management_uri: - link = self.git_public_prepare_scm_link(link, "github") - else: - link += "/" + self.file_path - else: - link += "/" + self.file_path - - # than - add line part to browser url - if self.line: - if scm_type in {"github", "gitlab", "gitea", "codeberg"} or "https://github.com/" in self.test.engagement.source_code_management_uri: - link = link + "#L" + str(self.line) - elif scm_type == "bitbucket-standalone": - link = link + "#" + str(self.line) - elif scm_type == "bitbucket": - link = link + "#lines-" + str(self.line) - return link - - def get_references_with_links(self): - from dojo.utils import create_bleached_link # noqa: PLC0415 circular import - if self.references is None: - return None - matches = re.findall(r"([\(|\[]?(https?):((//)|(\\\\))+([\w\d:#@%/;$~_?\+-=\\\.&](#!)?)*[\)|\]]?)", self.references) - - processed_matches = [] - for match in matches: - # Check if match isn't already a markdown link - # Only replace the same matches one time, otherwise the links will be corrupted - if not (match[0].startswith("[") or match[0].startswith("(")) and match[0] not in processed_matches: - self.references = self.references.replace(match[0], create_bleached_link(match[0], match[0]), 1) - processed_matches.append(match[0]) - - return self.references - - @cached_property - def vulnerability_ids(self): - # Get vulnerability ids from database and convert to list of strings - vulnerability_ids_model = self.vulnerability_id_set.all() - vulnerability_ids = [vulnerability_id.vulnerability_id for vulnerability_id in vulnerability_ids_model] - - # Synchronize the cve field with the unsaved_vulnerability_ids - # We do this to be as flexible as possible to handle the fields until - # the cve field is not needed anymore and can be removed. - if vulnerability_ids and self.cve: - # Make sure the first entry of the list is the value of the cve field - vulnerability_ids.insert(0, self.cve) - elif not vulnerability_ids and self.cve: - # If there is no list, make one with the value of the cve field - vulnerability_ids = [self.cve] - - # Remove duplicates - return list(dict.fromkeys(vulnerability_ids)) - - @property - def violates_sla(self): - return (self.sla_expiration_date and self.sla_expiration_date < timezone.now().date()) - - def set_hash_code(self, dedupe_option): - from dojo.utils import get_custom_method # noqa: PLC0415 circular import - if hash_method := get_custom_method("FINDING_HASH_METHOD"): - deduplicationLogger.debug("Using custom hash method") - hash_method(self, dedupe_option) - # Finding.save is called once from serializers.py with dedupe_option=False because the finding is not ready yet, for example the endpoints are not built - # It is then called a second time with dedupe_option defaulted to true; now we can compute the hash_code and run the deduplication - elif dedupe_option: - finding_id = self.id if self.id is not None else "unsaved" - if self.hash_code is not None: - deduplicationLogger.debug("Hash_code already computed for finding: %s", finding_id) - else: - self.hash_code = self.compute_hash_code() - deduplicationLogger.debug("Hash_code computed for finding: %s: %s", finding_id, self.hash_code) - - -class FindingAdmin(admin.ModelAdmin): - # TODO: Delete this after the move to Locations - # For efficiency with large databases, display many-to-many fields with raw - # IDs rather than multi-select - raw_id_fields = ( - "endpoints", - ) - - -class Vulnerability_Id(models.Model): - finding = models.ForeignKey(Finding, editable=False, on_delete=models.CASCADE) - vulnerability_id = models.TextField(max_length=50, blank=False, null=False) - - def __str__(self): - return self.vulnerability_id - - def get_absolute_url(self): - return reverse("view_finding", args=[str(self.finding.id)]) - - -class Finding_Group(TimeStampedModel): - - GROUP_BY_OPTIONS = [("component_name", "Component Name"), - ("component_name+component_version", "Component Name + Version"), - ("file_path", "File path"), - ("finding_title", "Finding Title"), - ("vuln_id_from_tool", "Vulnerability ID from Tool")] - - name = models.CharField(max_length=255, blank=False, null=False) - test = models.ForeignKey(Test, on_delete=models.CASCADE) - findings = models.ManyToManyField(Finding) - creator = models.ForeignKey(Dojo_User, on_delete=models.RESTRICT) - - def __str__(self): - return self.name - - @property - def has_jira_issue(self): - from dojo.jira import services as jira_services # noqa: PLC0415 circular import - return jira_services.has_issue(self) - - @cached_property - def severity(self): - if not self.findings.all(): - return None - max_number_severity = max(Finding.get_number_severity(find.severity) for find in self.findings.all()) - return Finding.get_severity(max_number_severity) - - @cached_property - def components(self): - components: dict[str, set[str | None]] = {} - for finding in self.findings.all(): - if finding.component_name is not None: - components.setdefault(finding.component_name, set()).add(finding.component_version) - return "; ".join(f"""{name}: {", ".join(map(str, versions))}""" for name, versions in components.items()) - - @property - def age(self): - if not self.findings.all(): - return None - - return max(find.age for find in self.findings.all()) - - @cached_property - def sla_days_remaining_internal(self): - if not self.findings.all(): - return None - - return min([find.sla_days_remaining() for find in self.findings.all() if find.sla_days_remaining()], default=None) - - def sla_days_remaining(self): - return self.sla_days_remaining_internal - - def sla_deadline(self): - if not self.findings.all(): - return None - - return min([find.sla_deadline() for find in self.findings.all() if find.sla_deadline()], default=None) - - def status(self): - if not self.findings.all(): - return None - - if any(find.active for find in self.findings.all()): - return "Active" - - if all(find.is_mitigated for find in self.findings.all()): - return "Mitigated" - - return "Inactive" - - @cached_property - def mitigated(self): - return all(find.mitigated is not None for find in self.findings.all()) - - def get_sla_start_date(self): - return min(find.get_sla_start_date() for find in self.findings.all()) - - def get_absolute_url(self): - return reverse("view_test", args=[str(self.test.id)]) - - class Meta: - ordering = ["id"] - - -class Finding_Template(models.Model): - title = models.TextField(max_length=1000) - cwe = models.IntegerField(default=None, null=True, blank=True) - cve = models.CharField(max_length=50, - null=True, - blank=False, - verbose_name="Vulnerability Id", - help_text="An id of a vulnerability in a security advisory associated with this finding. Can be a Common Vulnerabilities and Exposures (CVE) or from other sources.") - cvssv3 = models.TextField(help_text=_("Common Vulnerability Scoring System version 3 (CVSSv3) score associated with this finding."), validators=[cvss3_validator], max_length=117, null=True, verbose_name=_("CVSS v3 vector")) - cvssv3_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv3 score")) - cvssv4 = models.TextField(help_text=_("Common Vulnerability Scoring System version 4 (CVSS4) score associated with this finding."), validators=[cvss4_validator], max_length=255, null=True, verbose_name=_("CVSS4 vector")) - cvssv4_score = models.FloatField(null=True, blank=True, help_text=_("CVSSv4 score")) - - severity = models.CharField(max_length=200, null=True, blank=True) - description = models.TextField(null=True, blank=True) - mitigation = models.TextField(null=True, blank=True) - impact = models.TextField(null=True, blank=True) - references = models.TextField(null=True, blank=True, db_column="refs") - last_used = models.DateTimeField(null=True, editable=False) - numerical_severity = models.CharField(max_length=4, null=True, blank=True, editable=False) - - # Remediation planning fields - fix_available = models.BooleanField(null=True, blank=True, help_text=_("Indicates if a fix is available for this vulnerability type")) - fix_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Version where fix is available")) - planned_remediation_version = models.CharField(max_length=99, null=True, blank=True, help_text=_("Target version for remediation")) - effort_for_fixing = models.CharField(max_length=99, null=True, blank=True, help_text=_("Effort estimate for fixing (e.g., Low/Medium/High)")) - - # Technical details fields - steps_to_reproduce = models.TextField(null=True, blank=True, help_text=_("Standard reproduction steps for this vulnerability type")) - severity_justification = models.TextField(null=True, blank=True, help_text=_("Explanation of why this severity level is appropriate")) - component_name = models.CharField(max_length=500, null=True, blank=True, help_text=_("Affected component name (e.g., library name)")) - component_version = models.CharField(max_length=100, null=True, blank=True, help_text=_("Affected component version")) - - # Notes field (single note content, not a list) - notes = models.TextField(null=True, blank=True, help_text=_("Note content to add when applying this template")) - - # String-based list fields (newline-separated) - vulnerability_ids_text = models.TextField(null=True, blank=True, help_text=_("Vulnerability IDs (one per line)")) - endpoints_text = models.TextField(null=True, blank=True, help_text=_("Endpoint URLs (one per line)")) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.")) - - SEVERITIES = {"Info": 4, "Low": 3, "Medium": 2, - "High": 1, "Critical": 0} - - class Meta: - ordering = ["-cwe"] - - def __str__(self): - return self.title - - def get_absolute_url(self): - return reverse("edit_template", args=[str(self.id)]) - - def get_breadcrumbs(self): - return [{"title": str(self), - "url": reverse("view_template", args=(self.id,))}] - - @property - def vulnerability_ids(self): - """Parse vulnerability IDs from TextField string (newline-separated).""" - vulnerability_ids = [] - - # Get from the TextField - if self.vulnerability_ids_text: - # Parse newline-separated string, remove empty lines - vulnerability_ids = [line.strip() for line in self.vulnerability_ids_text.split("\n") if line.strip()] - - # Synchronize the cve field with the vulnerability_ids - # We do this to be as flexible as possible to handle the fields until - # the cve field is not needed anymore and can be removed. - if vulnerability_ids and self.cve and self.cve not in vulnerability_ids: - # Make sure the first entry of the list is the value of the cve field - vulnerability_ids.insert(0, self.cve) - elif not vulnerability_ids and self.cve: - # If there is no list, make one with the value of the cve field - vulnerability_ids = [self.cve] - - # Remove duplicates - return list(dict.fromkeys(vulnerability_ids)) - - @property - def endpoints(self): - """Parse endpoint URLs from TextField string (newline-separated).""" - if not self.endpoints_text: - return [] - # Parse newline-separated string, remove empty lines - return [line.strip() for line in self.endpoints_text.split("\n") if line.strip()] +from dojo.finding.models import ( # noqa: E402 -- re-export; class-body FKs below reference these + Finding, + Finding_Group, # noqa: F401 -- re-export + Finding_Template, + Vulnerability_Id, # noqa: F401 -- re-export +) class Check_List(models.Model): @@ -3626,8 +2165,8 @@ def __str__(self): # The audit system is configured in DojoAppConfig.ready() to ensure all models are loaded -from dojo.utils import ( # noqa: E402 # there is issue due to a circular import - parse_cvss_data, +from dojo.utils import ( # noqa: E402 + parse_cvss_data, # noqa: F401 -- backward compat re-export; side-effect loads dojo.utils → dojo.location models ) tagulous.admin.register(Product.tags) @@ -3660,7 +2199,6 @@ def __str__(self): admin.site.register(Languages) admin.site.register(Language_Type) admin.site.register(App_Analysis) -admin.site.register(Finding, FindingAdmin) admin.site.register(FileUpload) admin.site.register(FileAccessToken) admin.site.register(Risk_Acceptance) @@ -3708,12 +2246,9 @@ def __str__(self): admin.site.register(Report_Type) admin.site.register(DojoMeta) admin.site.register(Development_Environment) -admin.site.register(Finding_Template) -admin.site.register(Vulnerability_Id) admin.site.register(BurpRawRequestResponse) admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) admin.site.register(Tool_Product_History) admin.site.register(General_Survey) -admin.site.register(Finding_Group) From 473ec819147f2fa1cca781ae774bee3995defe8f Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 22:32:20 +0200 Subject: [PATCH 25/40] refactor(finding): move forms + UI filters into dojo/finding/ui/ [finding Phase 3,4] --- dojo/api_v2/views.py | 6 +- dojo/filters.py | 1041 +--------------- dojo/finding/ui/__init__.py | 0 dojo/finding/ui/filters.py | 1100 +++++++++++++++++ dojo/finding/ui/forms.py | 1083 ++++++++++++++++ dojo/finding/views.py | 22 +- dojo/finding_group/views.py | 4 +- dojo/forms.py | 1072 +--------------- dojo/metrics/utils.py | 6 +- dojo/product/ui/views.py | 2 + dojo/reports/views.py | 4 +- dojo/reports/widgets.py | 2 + dojo/search/views.py | 2 +- dojo/test/ui/views.py | 2 +- unittests/test_filter_finding_mitigation.py | 3 +- .../test_finding_group_filter_context.py | 2 +- unittests/test_test_type_active_toggle.py | 2 +- 17 files changed, 2231 insertions(+), 2122 deletions(-) create mode 100644 dojo/finding/ui/__init__.py create mode 100644 dojo/finding/ui/filters.py create mode 100644 dojo/finding/ui/forms.py diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 653dfaf2d8d..bdcaa185f5d 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -59,12 +59,14 @@ ApiRiskAcceptanceFilter, ApiTemplateFindingFilter, ApiUserFilter, - ReportFindingFilter, - ReportFindingFilterWithoutObjectLookups, ) from dojo.finding.queries import ( get_authorized_findings, ) +from dojo.finding.ui.filters import ( + ReportFindingFilter, + ReportFindingFilterWithoutObjectLookups, +) from dojo.finding.views import ( duplicate_cluster, reset_finding_duplicate_status_internal, diff --git a/dojo/filters.py b/dojo/filters.py index 55afc778196..ba68d7a180a 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -6,7 +6,6 @@ import six import tagulous -from django import forms from django.apps import apps from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -18,10 +17,8 @@ BooleanFilter, CharFilter, DateFilter, - DateFromToRangeFilter, DateTimeFilter, FilterSet, - ModelChoiceFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, NumberFilter, @@ -50,12 +47,8 @@ VERIFIED_FINDINGS_QUERY, WAS_ACCEPTED_FINDINGS_QUERY, ) -from dojo.finding.queries import get_authorized_findings_for_queryset -from dojo.finding_group.queries import get_authorized_finding_groups_for_queryset from dojo.labels import get_labels -from dojo.location.status import FindingLocationStatus from dojo.models import ( - EFFORT_FOR_FIXING_CHOICES, SEVERITY_CHOICES, App_Analysis, ChoiceQuestion, @@ -67,7 +60,6 @@ Engagement, Engagement_Survey, Finding, - Finding_Group, Finding_Template, Note_Type, Product, @@ -75,17 +67,13 @@ Question, Risk_Acceptance, Test, - Test_Type, TextQuestion, User, Vulnerability_Id, ) from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types -from dojo.risk_acceptance.queries import get_authorized_risk_acceptances -from dojo.test.queries import get_authorized_tests -from dojo.user.queries import get_authorized_users -from dojo.utils import get_system_setting, get_visible_scan_types, is_finding_groups_enabled, truncate_timezone_aware +from dojo.utils import get_system_setting, is_finding_groups_enabled, truncate_timezone_aware logger = logging.getLogger(__name__) @@ -1240,706 +1228,6 @@ def filter_percentage(self, queryset, name, value): return queryset.filter(**lookup_kwargs) -class FindingFilterHelper(FilterSet): - title = CharFilter(lookup_expr="icontains") - date = DateRangeFilter(field_name="date", label="Date Discovered") - on = DateFilter(field_name="date", lookup_expr="exact", label="Discovered On") - before = DateFilter(field_name="date", lookup_expr="lt", label="Discovered Before") - after = DateFilter(field_name="date", lookup_expr="gt", label="Discovered After") - last_reviewed = DateRangeFilter() - last_status_update = DateRangeFilter() - cwe = MultipleChoiceFilter(choices=[]) - vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") - severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) - duplicate = ReportBooleanFilter() - is_mitigated = ReportBooleanFilter() - fix_available = ReportBooleanFilter() - mitigation = CharFilter(lookup_expr="icontains") - mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") - mitigated = DateRangeFilter(field_name="mitigated", label="Mitigated Date") - mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", label="Mitigated On", method="filter_mitigated_on") - mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt", label="Mitigated Before") - mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") - planned_remediation_date = DateRangeOmniFilter() - planned_remediation_version = CharFilter(lookup_expr="icontains", label=_("Planned remediation version")) - file_path = CharFilter(lookup_expr="icontains") - param = CharFilter(lookup_expr="icontains") - payload = CharFilter(lookup_expr="icontains") - test__test_type = ModelMultipleChoiceFilter(queryset=Test_Type.objects.all(), label="Test Type") - service = CharFilter(lookup_expr="icontains") - test__engagement__version = CharFilter(lookup_expr="icontains", label="Engagement Version") - test__version = CharFilter(lookup_expr="icontains", label="Test Version") - risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") - effort_for_fixing = MultipleChoiceFilter(choices=EFFORT_FOR_FIXING_CHOICES) - test_import_finding_action__test_import = NumberFilter(widget=HiddenInput()) - status = FindingStatusFilter(label="Status") - test__engagement__product__lifecycle = MultipleChoiceFilter( - choices=Product.LIFECYCLE_CHOICES, - label=labels.ASSET_LIFECYCLE_LABEL) - if settings.V3_FEATURE_LOCATIONS: - location_status = MultipleChoiceFilter( - field_name="locations__status", - choices=FindingLocationStatus.choices, - help_text="Status of the Location from the Findings relationship", - ) - endpoints__host = CharFilter( - field_name="locations__location__url__host", method="filter_endpoints_host", label="Endpoint Host", - ) - endpoints = NumberFilter(field_name="locations__location", method="filter_endpoints", widget=HiddenInput()) - - def filter_endpoints_host(self, queryset, name, value): - return filter_endpoints_host_base( - queryset, - name, - value, - endpoint_id=self.data.get("endpoints"), - statuses=self.data.getlist("location_status"), - ) - - def filter_endpoints(self, queryset, name, value): - return filter_endpoints_base( - queryset, - name, - value, - statuses=self.data.getlist("location_status"), - host=self.data.get("endpoints__host"), - ) - else: - # TODO: Delete this after the move to Locations - endpoints__host = CharFilter(lookup_expr="icontains", label="Endpoint Host") - endpoints = NumberFilter(widget=HiddenInput()) - - has_component = BooleanFilter( - field_name="component_name", - lookup_expr="isnull", - exclude=True, - label="Has Component") - has_notes = BooleanFilter( - field_name="notes", - lookup_expr="isnull", - exclude=True, - label="Has notes") - - if is_finding_groups_enabled(): - has_finding_group = BooleanFilter( - field_name="finding_group", - lookup_expr="isnull", - exclude=True, - label="Is Grouped") - - if get_system_setting("enable_jira"): - has_jira_issue = BooleanFilter( - field_name="jira_issue", - lookup_expr="isnull", - exclude=True, - label="Has JIRA") - jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation", label="JIRA Creation") - jira_change = DateRangeFilter(field_name="jira_issue__jira_change", label="JIRA Updated") - jira_issue__jira_key = CharFilter(field_name="jira_issue__jira_key", lookup_expr="icontains", label="JIRA issue") - - if is_finding_groups_enabled(): - has_jira_group_issue = BooleanFilter( - field_name="finding_group__jira_issue", - lookup_expr="isnull", - exclude=True, - label="Has Group JIRA") - has_any_jira_issue = FindingHasJIRAFilter( - label="Has Any JIRA Issue", - help_text="Matches JIRA issues linked to the finding itself or to the finding's group.", - ) - - outside_of_sla = FindingSLAFilter(label="Outside of SLA") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - epss_score = PercentageFilter(field_name="epss_score", label="EPSS score") - epss_score_range = PercentageRangeFilter( - field_name="epss_score", - label="EPSS score range", - help_text=( - "The range of EPSS score percentages to filter on; the left input is a lower bound, " - "the right is an upper bound. Leaving one empty will skip that bound (e.g., leaving " - "the lower bound input empty will filter only on the upper bound -- filtering on " - '"less than or equal").' - )) - epss_percentile = PercentageFilter(field_name="epss_percentile", label="EPSS percentile") - epss_percentile_range = PercentageRangeFilter( - field_name="epss_percentile", - label="EPSS percentile range", - help_text=( - "The range of EPSS percentiles to filter on; the left input is a lower bound, the right " - "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the lower bound " - 'input empty will filter only on the upper bound -- filtering on "less than or equal").' - )) - kev_date = DateFilter(field_name="kev_date", lookup_expr="exact", label="Added to KEV On") - kev_before = DateFilter(field_name="kev_date", lookup_expr="lt", label="Added to KEV Before") - kev_after = DateFilter(field_name="kev_date", lookup_expr="gt", label="Added to KEV After") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("numerical_severity", "numerical_severity"), - ("date", "date"), - ("mitigated", "mitigated"), - ("fix_available", "fix_available"), - ("risk_acceptance__created__date", - "risk_acceptance__created__date"), - ("last_reviewed", "last_reviewed"), - ("planned_remediation_date", "planned_remediation_date"), - ("planned_remediation_version", "planned_remediation_version"), - ("title", "title"), - ("test__engagement__product__name", - "test__engagement__product__name"), - ("service", "service"), - ("sla_age_days", "sla_age_days"), - ("epss_score", "epss_score"), - ("epss_percentile", "epss_percentile"), - ("known_exploited", "known_exploited"), - ("ransomware_used", "ransomware_used"), - ("kev_date", "kev_date"), - ), - field_labels={ - "numerical_severity": "Severity", - "date": "Date", - "risk_acceptance__created__date": "Acceptance Date", - "mitigated": "Mitigated Date", - "fix_available": "Fix Available", - "title": "Finding Name", - "test__engagement__product__name": labels.ASSET_FILTERS_NAME_LABEL, - "epss_score": "EPSS Score", - "epss_percentile": "EPSS Percentile", - "known_exploited": "Known Exploited", - "ransomware_used": "Ransomware Used", - "kev_date": "Date added to KEV", - "sla_age_days": "SLA age (days)", - "planned_remediation_date": "Planned Remediation", - "planned_remediation_version": "Planned remediation version", - }, - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if "test__test_type" in self.form.fields: - self.form.fields["test__test_type"].queryset = get_visible_scan_types() - - def set_date_fields(self, *args: list, **kwargs: dict): - date_input_widget = forms.DateInput(attrs={"class": "datepicker", "placeholder": "YYYY-MM-DD"}, format="%Y-%m-%d") - self.form.fields["on"].widget = date_input_widget - self.form.fields["before"].widget = date_input_widget - self.form.fields["after"].widget = date_input_widget - self.form.fields["kev_date"].widget = date_input_widget - self.form.fields["kev_before"].widget = date_input_widget - self.form.fields["kev_after"].widget = date_input_widget - self.form.fields["mitigated_on"].widget = date_input_widget - self.form.fields["mitigated_before"].widget = date_input_widget - self.form.fields["mitigated_after"].widget = date_input_widget - self.form.fields["cwe"].choices = cwe_options(self.queryset) - - def filter_mitigated_after(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - value = value.replace(hour=23, minute=59, second=59) - - return queryset.filter(mitigated__gt=value) - - def filter_mitigated_on(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 - nextday = value + timedelta(days=1) - return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) - - return queryset.filter(mitigated=value) - - def filter_mitigation_available(self, queryset, name, value): - if value: - return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") - return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) - - -def get_finding_group_queryset_for_context(pid=None, eid=None, tid=None): - """ - Helper function to build finding group queryset based on context hierarchy. - Context priority: test > engagement > product > global - - Args: - pid: Product ID (least specific) - eid: Engagement ID - tid: Test ID (most specific) - - Returns: - QuerySet of Finding_Group filtered by context - - """ - if tid is not None: - # Most specific: filter by test - return Finding_Group.objects.filter(test_id=tid).only("id", "name") - if eid is not None: - # Filter by engagement's tests - return Finding_Group.objects.filter(test__engagement_id=eid).only("id", "name") - if pid is not None: - # Filter by product's tests - return Finding_Group.objects.filter(test__engagement__product_id=pid).only("id", "name") - # Global: return all (authorization will be applied separately) - return Finding_Group.objects.all().only("id", "name") - - -class FindingFilterWithoutObjectLookups(FindingFilterHelper, FindingTagStringFilter): - test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) - test__engagement__product = NumberFilter(widget=HiddenInput()) - reporter = CharFilter( - field_name="reporter__username", - lookup_expr="iexact", - label="Reporter Username", - help_text="Search for Reporter names that are an exact match") - reporter_contains = CharFilter( - field_name="reporter__username", - lookup_expr="icontains", - label="Reporter Username Contains", - help_text="Search for Reporter names that contain a given pattern") - reviewers = CharFilter( - field_name="reviewers__username", - lookup_expr="iexact", - label="Reviewer Username", - help_text="Search for Reviewer names that are an exact match") - reviewers_contains = CharFilter( - field_name="reviewers__username", - lookup_expr="icontains", - label="Reviewer Username Contains", - help_text="Search for Reviewer usernames that contain a given pattern") - test__engagement__product__prod_type__name = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_NAME_LABEL, - help_text=labels.ORG_FILTERS_NAME_HELP) - test__engagement__product__prod_type__name_contains = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - test__engagement__product__name = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="iexact", - label=labels.ASSET_FILTERS_NAME_LABEL, - help_text=labels.ASSET_FILTERS_NAME_HELP) - test__engagement__product__name_contains = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="icontains", - label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) - test__engagement__name = CharFilter( - field_name="test__engagement__name", - lookup_expr="iexact", - label="Engagement Name", - help_text="Search for Engagement names that are an exact match") - test__engagement__name_contains = CharFilter( - field_name="test__engagement__name", - lookup_expr="icontains", - label="Engagement name Contains", - help_text="Search for Engagement names that contain a given pattern") - test__name = CharFilter( - field_name="test__title", - lookup_expr="iexact", - label="Test Name", - help_text="Search for Test names that are an exact match") - test__name_contains = CharFilter( - field_name="test__title", - lookup_expr="icontains", - label="Test name Contains", - help_text="Search for Test names that contain a given pattern") - - if is_finding_groups_enabled(): - finding_group__name = CharFilter( - field_name="finding_group__name", - lookup_expr="iexact", - label="Finding Group Name", - help_text="Search for Finding Group names that are an exact match") - finding_group__name_contains = CharFilter( - field_name="finding_group__name", - lookup_expr="icontains", - label="Finding Group Name Contains", - help_text="Search for Finding Group names that contain a given pattern") - - class Meta: - model = Finding - fields = get_finding_filterset_fields(filter_string_matching=True) - - exclude = ["url", "description", "mitigation", "impact", - "endpoints", "references", - "thread_id", "notes", "scanner_confidence", - "numerical_severity", "line", "duplicate_finding", - "hash_code", "reviewers", "created", "files", - "sla_start_date", "sla_expiration_date", "cvssv3", - "severity_justification", "steps_to_reproduce"] - - def __init__(self, *args, **kwargs): - self.user = None - self.pid = None - self.eid = None - self.tid = None - if "user" in kwargs: - self.user = kwargs.pop("user") - - if "pid" in kwargs: - self.pid = kwargs.pop("pid") - if "eid" in kwargs: - self.eid = kwargs.pop("eid") - if "tid" in kwargs: - self.tid = kwargs.pop("tid") - super().__init__(*args, **kwargs) - # Set some date fields - self.set_date_fields(*args, **kwargs) - # Don't show the product/engagement/test filter fields when in specific context - if self.tid or self.eid or self.pid: - if "test__engagement__product__name" in self.form.fields: - del self.form.fields["test__engagement__product__name"] - if "test__engagement__product__name_contains" in self.form.fields: - del self.form.fields["test__engagement__product__name_contains"] - if "test__engagement__product__prod_type__name" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type__name"] - if "test__engagement__product__prod_type__name_contains" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type__name_contains"] - # Also hide engagement and test fields if in test or engagement context - if self.tid: - if "test__engagement__name" in self.form.fields: - del self.form.fields["test__engagement__name"] - if "test__engagement__name_contains" in self.form.fields: - del self.form.fields["test__engagement__name_contains"] - if "test__name" in self.form.fields: - del self.form.fields["test__name"] - if "test__name_contains" in self.form.fields: - del self.form.fields["test__name_contains"] - elif self.eid: - if "test__engagement__name" in self.form.fields: - del self.form.fields["test__engagement__name"] - if "test__engagement__name_contains" in self.form.fields: - del self.form.fields["test__engagement__name_contains"] - - -class FindingFilter(FindingFilterHelper, FindingTagFilter): - reporter = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) - reviewers = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) - test__engagement__product__prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - test__engagement__product = ModelMultipleChoiceFilter( - queryset=Product.objects.none(), - label=labels.ASSET_FILTERS_LABEL) - test__engagement = ModelMultipleChoiceFilter( - queryset=Engagement.objects.none(), - label="Engagement") - test = ModelMultipleChoiceFilter( - queryset=Test.objects.none(), - label="Test") - - if is_finding_groups_enabled(): - finding_group = ModelMultipleChoiceFilter( - queryset=Finding_Group.objects.none(), - label="Finding Group") - - class Meta: - model = Finding - fields = get_finding_filterset_fields() - - exclude = ["url", "description", "mitigation", "impact", - "endpoints", "references", - "thread_id", "notes", "scanner_confidence", - "numerical_severity", "line", "duplicate_finding", - "hash_code", "reviewers", "created", "files", - "sla_start_date", "sla_expiration_date", "cvssv3", - "severity_justification", "steps_to_reproduce"] - - def __init__(self, *args, **kwargs): - self.user = None - self.pid = None - self.eid = None - self.tid = None - if "user" in kwargs: - self.user = kwargs.pop("user") - - if "pid" in kwargs: - self.pid = kwargs.pop("pid") - if "eid" in kwargs: - self.eid = kwargs.pop("eid") - if "tid" in kwargs: - self.tid = kwargs.pop("tid") - super().__init__(*args, **kwargs) - # Set some date fields - self.set_date_fields(*args, **kwargs) - # Don't show the product filter on the product finding view - self.set_related_object_fields(*args, **kwargs) - - def set_related_object_fields(self, *args: list, **kwargs: dict): - # Use helper to get contextual finding group queryset - finding_group_query = get_finding_group_queryset_for_context( - pid=self.pid, - eid=self.eid, - tid=self.tid, - ) - - # Filter by most specific context: test > engagement > product - if self.tid is not None: - # Test context: filter finding groups by test - if "test__engagement__product" in self.form.fields: - del self.form.fields["test__engagement__product"] - if "test__engagement__product__prod_type" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type"] - if "test__engagement" in self.form.fields: - del self.form.fields["test__engagement"] - if "test" in self.form.fields: - del self.form.fields["test"] - elif self.eid is not None: - # Engagement context: filter finding groups by engagement - if "test__engagement__product" in self.form.fields: - del self.form.fields["test__engagement__product"] - if "test__engagement__product__prod_type" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type"] - if "test__engagement" in self.form.fields: - del self.form.fields["test__engagement"] - # Filter tests by engagement - get_authorized_tests doesn't support engagement param - engagement = Engagement.objects.filter(id=self.eid).select_related("product").first() - if engagement: - self.form.fields["test"].queryset = get_authorized_tests("view", product=engagement.product).filter(engagement_id=self.eid).prefetch_related("test_type") - elif self.pid is not None: - # Product context: filter finding groups by product - if "test__engagement__product" in self.form.fields: - del self.form.fields["test__engagement__product"] - if "test__engagement__product__prod_type" in self.form.fields: - del self.form.fields["test__engagement__product__prod_type"] - # TODO: add authorized check to be sure - if "test__engagement" in self.form.fields: - self.form.fields["test__engagement"].queryset = Engagement.objects.filter( - product_id=self.pid, - ).all() - if "test" in self.form.fields: - self.form.fields["test"].queryset = get_authorized_tests("view", product=self.pid).prefetch_related("test_type") - else: - # Global context: show all authorized finding groups - self.form.fields[ - "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") - self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") - if "test" in self.form.fields: - del self.form.fields["test"] - - if self.form.fields.get("test__engagement__product"): - self.form.fields["test__engagement__product"].queryset = get_authorized_products("view") - if self.form.fields.get("finding_group", None): - self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset("view", finding_group_query, user=self.user) - self.form.fields["reporter"].queryset = get_authorized_users("view") - self.form.fields["reviewers"].queryset = self.form.fields["reporter"].queryset - - -class FindingGroupsFilter(FilterSet): - name = CharFilter(lookup_expr="icontains", label="Name") - severity = ChoiceFilter( - choices=[ - ("Low", "Low"), - ("Medium", "Medium"), - ("High", "High"), - ("Critical", "Critical"), - ], - label="Min Severity", - ) - engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") - product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label=labels.ASSET_LABEL) - - class Meta: - model = Finding - fields = ["name", "severity", "engagement", "product"] - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user", None) - self.pid = kwargs.pop("pid", None) - super().__init__(*args, **kwargs) - self.set_related_object_fields() - - def set_related_object_fields(self): - if self.pid is not None: - self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid) - if "product" in self.form.fields: - del self.form.fields["product"] - else: - self.form.fields["product"].queryset = get_authorized_products("view") - self.form.fields["engagement"].queryset = get_authorized_engagements("view") - - -class AcceptedFindingFilter(FindingFilter): - risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") - risk_acceptance__owner = ModelMultipleChoiceFilter( - queryset=Dojo_User.objects.none(), - label="Risk Acceptance Owner") - risk_acceptance = ModelMultipleChoiceFilter( - queryset=Risk_Acceptance.objects.none(), - label="Accepted By") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["risk_acceptance__owner"].queryset = get_authorized_users("view") - self.form.fields["risk_acceptance"].queryset = get_authorized_risk_acceptances("edit") - - -class AcceptedFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): - risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") - risk_acceptance__owner = CharFilter( - field_name="risk_acceptance__owner__username", - lookup_expr="iexact", - label="Risk Acceptance Owner Username", - help_text="Search for Risk Acceptance Owners username that are an exact match") - risk_acceptance__owner_contains = CharFilter( - field_name="risk_acceptance__owner__username", - lookup_expr="icontains", - label="Risk Acceptance Owner Username Contains", - help_text="Search for Risk Acceptance Owners username that contain a given pattern") - risk_acceptance__name = CharFilter( - field_name="risk_acceptance__name", - lookup_expr="iexact", - label="Risk Acceptance Name", - help_text="Search for Risk Acceptance name that are an exact match") - risk_acceptance__name_contains = CharFilter( - field_name="risk_acceptance__name", - lookup_expr="icontains", - label="Risk Acceptance Name", - help_text="Search for Risk Acceptance name contain a given pattern") - - -class SimilarFindingHelper(FilterSet): - hash_code = MultipleChoiceFilter() - vulnerability_ids = CharFilter(method=custom_vulnerability_id_filter, label="Vulnerability Ids") - - def update_data(self, data: dict, *args: list, **kwargs: dict): - # if filterset is bound, use initial values as defaults - # because of this, we can't rely on the self.form.has_changed - self.has_changed = True - if not data and self.finding: - # get a mutable copy of the QueryDict - data = data.copy() - - data["vulnerability_ids"] = ",".join(self.finding.vulnerability_ids) - data["cwe"] = self.finding.cwe - data["file_path"] = self.finding.file_path - data["line"] = self.finding.line - data["unique_id_from_tool"] = self.finding.unique_id_from_tool - data["test__test_type"] = self.finding.test.test_type - data["test__engagement__product"] = self.finding.test.engagement.product - data["test__engagement__product__prod_type"] = self.finding.test.engagement.product.prod_type - - self.has_changed = False - - def set_hash_codes(self, *args: list, **kwargs: dict): - if self.finding and self.finding.hash_code: - self.form.fields["hash_code"] = forms.MultipleChoiceField(choices=[(self.finding.hash_code, self.finding.hash_code[:24] + "...")], required=False, initial=[]) - - def filter_queryset(self, *args: list, **kwargs: dict): - queryset = super().filter_queryset(*args, **kwargs) - queryset = get_authorized_findings_for_queryset("view", queryset, self.user) - return queryset.exclude(pk=self.finding.pk) - - -class SimilarFindingFilter(FindingFilter, SimilarFindingHelper): - class Meta(FindingFilter.Meta): - model = Finding - # slightly different fields from FindingFilter, but keep the same ordering for UI consistency - fields = get_finding_filterset_fields(similar=True) - - def __init__(self, data=None, *args, **kwargs): - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - self.finding = None - if "finding" in kwargs: - self.finding = kwargs.pop("finding") - self.update_data(data, *args, **kwargs) - super().__init__(data, *args, **kwargs) - self.set_hash_codes(*args, **kwargs) - - -class SimilarFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups, SimilarFindingHelper): - class Meta(FindingFilterWithoutObjectLookups.Meta): - model = Finding - # slightly different fields from FindingFilter, but keep the same ordering for UI consistency - fields = get_finding_filterset_fields(similar=True, filter_string_matching=True) - - def __init__(self, data=None, *args, **kwargs): - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - self.finding = None - if "finding" in kwargs: - self.finding = kwargs.pop("finding") - self.update_data(data, *args, **kwargs) - super().__init__(data, *args, **kwargs) - self.set_hash_codes(*args, **kwargs) - - -class TemplateFindingFilter(DojoFilter): - title = CharFilter(lookup_expr="icontains") - cwe = MultipleChoiceFilter(choices=[]) - severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) - - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - queryset=Finding.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Finding.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("cwe", "cwe"), - ("title", "title"), - ("numerical_severity", "numerical_severity"), - ), - field_labels={ - "numerical_severity": "Severity", - }, - ) - - class Meta: - model = Finding_Template - exclude = ["description", "mitigation", "impact", - "references", "numerical_severity"] - - not_test__tags = ModelMultipleChoiceFilter( - field_name="test__tags__name", - to_field_name="name", - exclude=True, - label="Test without tags", - queryset=Test.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_test__engagement__tags = ModelMultipleChoiceFilter( - field_name="test__engagement__tags__name", - to_field_name="name", - exclude=True, - label="Engagement without tags", - queryset=Engagement.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_test__engagement__product__tags = ModelMultipleChoiceFilter( - field_name="test__engagement__product__tags__name", - to_field_name="name", - exclude=True, - label=labels.ASSET_FILTERS_WITHOUT_TAGS_LABEL, - queryset=Product.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.form.fields["cwe"].choices = cwe_options(self.queryset) - - class ApiTemplateFindingFilter(DojoFilter): tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") tags = CharFieldInFilter( @@ -1967,66 +1255,6 @@ class Meta: "mitigation"] -class MetricsFindingFilter(FindingFilter): - start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) - end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) - date = MetricsDateRangeFilter() - vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") - - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - - def __init__(self, *args, **kwargs): - if args[0]: - if args[0].get("start_date", "") or args[0].get("end_date", ""): - args[0]._mutable = True - args[0]["date"] = 8 - args[0]._mutable = False - - super().__init__(*args, **kwargs) - - class Meta(FindingFilter.Meta): - model = Finding - fields = get_finding_filterset_fields(metrics=True) - - -class MetricsFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): - start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) - end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) - date = MetricsDateRangeFilter() - vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") - - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - exclude=True, - queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), - # label='tags', # doesn't work with tagulous, need to set in __init__ below - ) - - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - - def __init__(self, *args, **kwargs): - if args[0]: - if args[0].get("start_date", "") or args[0].get("end_date", ""): - args[0]._mutable = True - args[0]["date"] = 8 - args[0]._mutable = False - - super().__init__(*args, **kwargs) - - class Meta(FindingFilterWithoutObjectLookups.Meta): - model = Finding - fields = get_finding_filterset_fields(metrics=True, filter_string_matching=True) - - class MetricsEndpointFilterHelper(FilterSet): start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) @@ -2645,273 +1873,6 @@ class Meta: exclude = ["product"] -class ReportFindingFilterHelper(FilterSet): - title = CharFilter(lookup_expr="icontains", label="Name") - date = DateFromToRangeFilter(field_name="date", label="Date Discovered") - date_recent = DateRangeFilter(field_name="date", label="Relative Date") - severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) - active = ReportBooleanFilter() - is_mitigated = ReportBooleanFilter() - mitigated = DateRangeFilter(label="Mitigated Date") - verified = ReportBooleanFilter() - false_p = ReportBooleanFilter(label="False Positive") - risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") - duplicate = ReportBooleanFilter() - out_of_scope = ReportBooleanFilter() - outside_of_sla = FindingSLAFilter(label="Outside of SLA") - file_path = CharFilter(lookup_expr="icontains") - mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") - - o = OrderingFilter( - fields=( - ("title", "title"), - ("date", "date"), - ("fix_available", "fix_available"), - ("numerical_severity", "numerical_severity"), - ("epss_score", "epss_score"), - ("epss_percentile", "epss_percentile"), - ("test__engagement__product__name", "test__engagement__product__name"), - ), - ) - - class Meta: - model = Finding - # exclude sonarqube issue as by default it will show all without checking permissions - exclude = ["date", "cwe", "url", "description", "mitigation", "impact", - "references", "sonarqube_issue", "duplicate_finding", - "thread_id", "notes", "inherited_tags", "endpoints", - "numerical_severity", "reporter", "last_reviewed", - "jira_creation", "jira_change", "files"] - - def filter_mitigation_available(self, queryset, name, value): - if value: - return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") - return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) - - def manage_kwargs(self, kwargs): - self.prod_type = None - self.product = None - self.engagement = None - self.test = None - if "prod_type" in kwargs: - self.prod_type = kwargs.pop("prod_type") - if "product" in kwargs: - self.product = kwargs.pop("product") - if "engagement" in kwargs: - self.engagement = kwargs.pop("engagement") - if "test" in kwargs: - self.test = kwargs.pop("test") - - @property - def qs(self): - parent = super().qs - return get_authorized_findings_for_queryset("view", parent) - - -class ReportFindingFilter(ReportFindingFilterHelper, FindingTagFilter): - test__engagement__product = ModelMultipleChoiceFilter( - queryset=Product.objects.none(), label=labels.ASSET_FILTERS_LABEL) - test__engagement__product__prod_type = ModelMultipleChoiceFilter( - queryset=Product_Type.objects.none(), - label=labels.ORG_FILTERS_LABEL) - test__engagement__product__lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, label=labels.ASSET_LIFECYCLE_LABEL) - test__engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") - duplicate_finding = ModelChoiceFilter(queryset=Finding.objects.filter(original_finding__isnull=False).distinct()) - - def __init__(self, *args, **kwargs): - self.manage_kwargs(kwargs) - super().__init__(*args, **kwargs) - - # duplicate_finding queryset needs to restricted in line with permissions - # and inline with report scope to avoid a dropdown with 100K entries - duplicate_finding_query_set = self.form.fields["duplicate_finding"].queryset - duplicate_finding_query_set = get_authorized_findings_for_queryset("view", duplicate_finding_query_set) - - if self.test: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test=self.test) - del self.form.fields["test__tags"] - del self.form.fields["test__engagement__tags"] - del self.form.fields["test__engagement__product__tags"] - if self.engagement: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement=self.engagement) - del self.form.fields["test__engagement__tags"] - del self.form.fields["test__engagement__product__tags"] - elif self.product: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product=self.product) - del self.form.fields["test__engagement__product"] - del self.form.fields["test__engagement__product__tags"] - elif self.prod_type: - duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product__prod_type=self.prod_type) - del self.form.fields["test__engagement__product__prod_type"] - - self.form.fields["duplicate_finding"].queryset = duplicate_finding_query_set - - if "test__engagement__product__prod_type" in self.form.fields: - self.form.fields[ - "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") - if "test__engagement__product" in self.form.fields: - self.form.fields[ - "test__engagement__product"].queryset = get_authorized_products("view") - if "test__engagement" in self.form.fields: - self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") - - -class ReportFindingFilterWithoutObjectLookups(ReportFindingFilterHelper, FindingTagStringFilter): - test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) - test__engagement__product = NumberFilter(widget=HiddenInput()) - test__engagement = NumberFilter(widget=HiddenInput()) - test = NumberFilter(widget=HiddenInput()) - endpoint = NumberFilter(widget=HiddenInput()) - reporter = CharFilter( - field_name="reporter__username", - lookup_expr="iexact", - label="Reporter Username", - help_text="Search for Reporter names that are an exact match") - reporter_contains = CharFilter( - field_name="reporter__username", - lookup_expr="icontains", - label="Reporter Username Contains", - help_text="Search for Reporter names that contain a given pattern") - reviewers = CharFilter( - field_name="reviewers__username", - lookup_expr="iexact", - label="Reviewer Username", - help_text="Search for Reviewer names that are an exact match") - reviewers_contains = CharFilter( - field_name="reviewers__username", - lookup_expr="icontains", - label="Reviewer Username Contains", - help_text="Search for Reviewer usernames that contain a given pattern") - last_reviewed_by = CharFilter( - field_name="last_reviewed_by__username", - lookup_expr="iexact", - label="Last Reviewed By Username", - help_text="Search for Last Reviewed By names that are an exact match") - last_reviewed_by_contains = CharFilter( - field_name="last_reviewed_by__username", - lookup_expr="icontains", - label="Last Reviewed By Username Contains", - help_text="Search for Last Reviewed By usernames that contain a given pattern") - review_requested_by = CharFilter( - field_name="review_requested_by__username", - lookup_expr="iexact", - label="Review Requested By Username", - help_text="Search for Review Requested By names that are an exact match") - review_requested_by_contains = CharFilter( - field_name="review_requested_by__username", - lookup_expr="icontains", - label="Review Requested By Username Contains", - help_text="Search for Review Requested By usernames that contain a given pattern") - mitigated_by = CharFilter( - field_name="mitigated_by__username", - lookup_expr="iexact", - label="Mitigator Username", - help_text="Search for Mitigator names that are an exact match") - mitigated_by_contains = CharFilter( - field_name="mitigated_by__username", - lookup_expr="icontains", - label="Mitigator Username Contains", - help_text="Search for Mitigator usernames that contain a given pattern") - defect_review_requested_by = CharFilter( - field_name="defect_review_requested_by__username", - lookup_expr="iexact", - label="Requester of Defect Review Username", - help_text="Search for Requester of Defect Review names that are an exact match") - defect_review_requested_by_contains = CharFilter( - field_name="defect_review_requested_by__username", - lookup_expr="icontains", - label="Requester of Defect Review Username Contains", - help_text="Search for Requester of Defect Review usernames that contain a given pattern") - test__engagement__product__prod_type__name = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="iexact", - label=labels.ORG_FILTERS_NAME_LABEL, - help_text=labels.ORG_FILTERS_NAME_HELP) - test__engagement__product__prod_type__name_contains = CharFilter( - field_name="test__engagement__product__prod_type__name", - lookup_expr="icontains", - label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) - test__engagement__product__name = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="iexact", - label=labels.ASSET_FILTERS_NAME_LABEL, - help_text=labels.ASSET_FILTERS_NAME_HELP) - test__engagement__product__name_contains = CharFilter( - field_name="test__engagement__product__name", - lookup_expr="icontains", - label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) - test__engagement__name = CharFilter( - field_name="test__engagement__name", - lookup_expr="iexact", - label="Engagement Name", - help_text="Search for Engagement names that are an exact match") - test__engagement__name_contains = CharFilter( - field_name="test__engagement__name", - lookup_expr="icontains", - label="Engagement name Contains", - help_text="Search for Engagement names that contain a given pattern") - test__name = CharFilter( - field_name="test__title", - lookup_expr="iexact", - label="Test Name", - help_text="Search for Test names that are an exact match") - test__name_contains = CharFilter( - field_name="test__title", - lookup_expr="icontains", - label="Test name Contains", - help_text="Search for Test names that contain a given pattern") - - def __init__(self, *args, **kwargs): - self.manage_kwargs(kwargs) - super().__init__(*args, **kwargs) - - product_type_refs = [ - "test__engagement__product__prod_type__name", - "test__engagement__product__prod_type__name_contains", - ] - product_refs = [ - "test__engagement__product__name", - "test__engagement__product__name_contains", - "test__engagement__product__tags", - "test__engagement__product__tags_contains", - "not_test__engagement__product__tags", - "not_test__engagement__product__tags_contains", - ] - engagement_refs = [ - "test__engagement__name", - "test__engagement__name_contains", - "test__engagement__tags", - "test__engagement__tags_contains", - "not_test__engagement__tags", - "not_test__engagement__tags_contains", - ] - test_refs = [ - "test__name", - "test__name_contains", - "test__tags", - "test__tags_contains", - "not_test__tags", - "not_test__tags_contains", - ] - - if self.test: - self.delete_tags_from_form(product_type_refs) - self.delete_tags_from_form(product_refs) - self.delete_tags_from_form(engagement_refs) - self.delete_tags_from_form(test_refs) - elif self.engagement: - self.delete_tags_from_form(product_type_refs) - self.delete_tags_from_form(product_refs) - self.delete_tags_from_form(engagement_refs) - elif self.product: - self.delete_tags_from_form(product_type_refs) - self.delete_tags_from_form(product_refs) - elif self.prod_type: - self.delete_tags_from_form(product_type_refs) - - class UserFilter(DojoFilter): first_name = CharFilter(lookup_expr="icontains") last_name = CharFilter(lookup_expr="icontains") diff --git a/dojo/finding/ui/__init__.py b/dojo/finding/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/finding/ui/filters.py b/dojo/finding/ui/filters.py new file mode 100644 index 00000000000..b886c9a5232 --- /dev/null +++ b/dojo/finding/ui/filters.py @@ -0,0 +1,1100 @@ +from datetime import timedelta + +from django import forms +from django.conf import settings +from django.db.models import Q +from django.forms import HiddenInput +from django.utils.translation import gettext_lazy as _ +from django_filters import ( + BooleanFilter, + CharFilter, + ChoiceFilter, + DateFilter, + DateFromToRangeFilter, + DateTimeFilter, + FilterSet, + ModelChoiceFilter, + ModelMultipleChoiceFilter, + MultipleChoiceFilter, + NumberFilter, + OrderingFilter, +) + +from dojo.engagement.queries import get_authorized_engagements +from dojo.filters import ( + DateRangeFilter, + DateRangeOmniFilter, + DojoFilter, + FindingHasJIRAFilter, + FindingSLAFilter, + FindingStatusFilter, + FindingTagFilter, + FindingTagStringFilter, + MetricsDateRangeFilter, + PercentageFilter, + PercentageRangeFilter, + ReportBooleanFilter, + ReportRiskAcceptanceFilter, + custom_vulnerability_id_filter, + cwe_options, + filter_endpoints_base, + filter_endpoints_host_base, + get_finding_filterset_fields, + vulnerability_id_filter, +) +from dojo.finding.queries import ( + get_authorized_findings_for_queryset, +) +from dojo.finding_group.queries import get_authorized_finding_groups_for_queryset +from dojo.labels import get_labels +from dojo.location.status import FindingLocationStatus +from dojo.models import ( + EFFORT_FOR_FIXING_CHOICES, + SEVERITY_CHOICES, + Dojo_User, + Endpoint, + Engagement, + Finding, + Finding_Group, + Finding_Template, + Product, + Product_Type, + Risk_Acceptance, + Test, + Test_Type, +) +from dojo.product.queries import get_authorized_products +from dojo.product_type.queries import get_authorized_product_types +from dojo.risk_acceptance.queries import get_authorized_risk_acceptances +from dojo.test.queries import get_authorized_tests +from dojo.user.queries import get_authorized_users +from dojo.utils import get_system_setting, get_visible_scan_types, is_finding_groups_enabled + +labels = get_labels() + + +class FindingFilterHelper(FilterSet): + title = CharFilter(lookup_expr="icontains") + date = DateRangeFilter(field_name="date", label="Date Discovered") + on = DateFilter(field_name="date", lookup_expr="exact", label="Discovered On") + before = DateFilter(field_name="date", lookup_expr="lt", label="Discovered Before") + after = DateFilter(field_name="date", lookup_expr="gt", label="Discovered After") + last_reviewed = DateRangeFilter() + last_status_update = DateRangeFilter() + cwe = MultipleChoiceFilter(choices=[]) + vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") + severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) + duplicate = ReportBooleanFilter() + is_mitigated = ReportBooleanFilter() + fix_available = ReportBooleanFilter() + mitigation = CharFilter(lookup_expr="icontains") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") + mitigated = DateRangeFilter(field_name="mitigated", label="Mitigated Date") + mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", label="Mitigated On", method="filter_mitigated_on") + mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt", label="Mitigated Before") + mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") + planned_remediation_date = DateRangeOmniFilter() + planned_remediation_version = CharFilter(lookup_expr="icontains", label=_("Planned remediation version")) + file_path = CharFilter(lookup_expr="icontains") + param = CharFilter(lookup_expr="icontains") + payload = CharFilter(lookup_expr="icontains") + test__test_type = ModelMultipleChoiceFilter(queryset=Test_Type.objects.all(), label="Test Type") + service = CharFilter(lookup_expr="icontains") + test__engagement__version = CharFilter(lookup_expr="icontains", label="Engagement Version") + test__version = CharFilter(lookup_expr="icontains", label="Test Version") + risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") + effort_for_fixing = MultipleChoiceFilter(choices=EFFORT_FOR_FIXING_CHOICES) + test_import_finding_action__test_import = NumberFilter(widget=HiddenInput()) + status = FindingStatusFilter(label="Status") + test__engagement__product__lifecycle = MultipleChoiceFilter( + choices=Product.LIFECYCLE_CHOICES, + label=labels.ASSET_LIFECYCLE_LABEL) + if settings.V3_FEATURE_LOCATIONS: + location_status = MultipleChoiceFilter( + field_name="locations__status", + choices=FindingLocationStatus.choices, + help_text="Status of the Location from the Findings relationship", + ) + endpoints__host = CharFilter( + field_name="locations__location__url__host", method="filter_endpoints_host", label="Endpoint Host", + ) + endpoints = NumberFilter(field_name="locations__location", method="filter_endpoints", widget=HiddenInput()) + + def filter_endpoints_host(self, queryset, name, value): + return filter_endpoints_host_base( + queryset, + name, + value, + endpoint_id=self.data.get("endpoints"), + statuses=self.data.getlist("location_status"), + ) + + def filter_endpoints(self, queryset, name, value): + return filter_endpoints_base( + queryset, + name, + value, + statuses=self.data.getlist("location_status"), + host=self.data.get("endpoints__host"), + ) + else: + # TODO: Delete this after the move to Locations + endpoints__host = CharFilter(lookup_expr="icontains", label="Endpoint Host") + endpoints = NumberFilter(widget=HiddenInput()) + + has_component = BooleanFilter( + field_name="component_name", + lookup_expr="isnull", + exclude=True, + label="Has Component") + has_notes = BooleanFilter( + field_name="notes", + lookup_expr="isnull", + exclude=True, + label="Has notes") + + if is_finding_groups_enabled(): + has_finding_group = BooleanFilter( + field_name="finding_group", + lookup_expr="isnull", + exclude=True, + label="Is Grouped") + + if get_system_setting("enable_jira"): + has_jira_issue = BooleanFilter( + field_name="jira_issue", + lookup_expr="isnull", + exclude=True, + label="Has JIRA") + jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation", label="JIRA Creation") + jira_change = DateRangeFilter(field_name="jira_issue__jira_change", label="JIRA Updated") + jira_issue__jira_key = CharFilter(field_name="jira_issue__jira_key", lookup_expr="icontains", label="JIRA issue") + + if is_finding_groups_enabled(): + has_jira_group_issue = BooleanFilter( + field_name="finding_group__jira_issue", + lookup_expr="isnull", + exclude=True, + label="Has Group JIRA") + has_any_jira_issue = FindingHasJIRAFilter( + label="Has Any JIRA Issue", + help_text="Matches JIRA issues linked to the finding itself or to the finding's group.", + ) + + outside_of_sla = FindingSLAFilter(label="Outside of SLA") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + epss_score = PercentageFilter(field_name="epss_score", label="EPSS score") + epss_score_range = PercentageRangeFilter( + field_name="epss_score", + label="EPSS score range", + help_text=( + "The range of EPSS score percentages to filter on; the left input is a lower bound, " + "the right is an upper bound. Leaving one empty will skip that bound (e.g., leaving " + "the lower bound input empty will filter only on the upper bound -- filtering on " + '"less than or equal").' + )) + epss_percentile = PercentageFilter(field_name="epss_percentile", label="EPSS percentile") + epss_percentile_range = PercentageRangeFilter( + field_name="epss_percentile", + label="EPSS percentile range", + help_text=( + "The range of EPSS percentiles to filter on; the left input is a lower bound, the right " + "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the lower bound " + 'input empty will filter only on the upper bound -- filtering on "less than or equal").' + )) + kev_date = DateFilter(field_name="kev_date", lookup_expr="exact", label="Added to KEV On") + kev_before = DateFilter(field_name="kev_date", lookup_expr="lt", label="Added to KEV Before") + kev_after = DateFilter(field_name="kev_date", lookup_expr="gt", label="Added to KEV After") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("numerical_severity", "numerical_severity"), + ("date", "date"), + ("mitigated", "mitigated"), + ("fix_available", "fix_available"), + ("risk_acceptance__created__date", + "risk_acceptance__created__date"), + ("last_reviewed", "last_reviewed"), + ("planned_remediation_date", "planned_remediation_date"), + ("planned_remediation_version", "planned_remediation_version"), + ("title", "title"), + ("test__engagement__product__name", + "test__engagement__product__name"), + ("service", "service"), + ("sla_age_days", "sla_age_days"), + ("epss_score", "epss_score"), + ("epss_percentile", "epss_percentile"), + ("known_exploited", "known_exploited"), + ("ransomware_used", "ransomware_used"), + ("kev_date", "kev_date"), + ), + field_labels={ + "numerical_severity": "Severity", + "date": "Date", + "risk_acceptance__created__date": "Acceptance Date", + "mitigated": "Mitigated Date", + "fix_available": "Fix Available", + "title": "Finding Name", + "test__engagement__product__name": labels.ASSET_FILTERS_NAME_LABEL, + "epss_score": "EPSS Score", + "epss_percentile": "EPSS Percentile", + "known_exploited": "Known Exploited", + "ransomware_used": "Ransomware Used", + "kev_date": "Date added to KEV", + "sla_age_days": "SLA age (days)", + "planned_remediation_date": "Planned Remediation", + "planned_remediation_version": "Planned remediation version", + }, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if "test__test_type" in self.form.fields: + self.form.fields["test__test_type"].queryset = get_visible_scan_types() + + def set_date_fields(self, *args: list, **kwargs: dict): + date_input_widget = forms.DateInput(attrs={"class": "datepicker", "placeholder": "YYYY-MM-DD"}, format="%Y-%m-%d") + self.form.fields["on"].widget = date_input_widget + self.form.fields["before"].widget = date_input_widget + self.form.fields["after"].widget = date_input_widget + self.form.fields["kev_date"].widget = date_input_widget + self.form.fields["kev_before"].widget = date_input_widget + self.form.fields["kev_after"].widget = date_input_widget + self.form.fields["mitigated_on"].widget = date_input_widget + self.form.fields["mitigated_before"].widget = date_input_widget + self.form.fields["mitigated_after"].widget = date_input_widget + self.form.fields["cwe"].choices = cwe_options(self.queryset) + + def filter_mitigated_after(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + value = value.replace(hour=23, minute=59, second=59) + + return queryset.filter(mitigated__gt=value) + + def filter_mitigated_on(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 + nextday = value + timedelta(days=1) + return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) + + return queryset.filter(mitigated=value) + + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + + +def get_finding_group_queryset_for_context(pid=None, eid=None, tid=None): + """ + Helper function to build finding group queryset based on context hierarchy. + Context priority: test > engagement > product > global + + Args: + pid: Product ID (least specific) + eid: Engagement ID + tid: Test ID (most specific) + + Returns: + QuerySet of Finding_Group filtered by context + + """ + if tid is not None: + # Most specific: filter by test + return Finding_Group.objects.filter(test_id=tid).only("id", "name") + if eid is not None: + # Filter by engagement's tests + return Finding_Group.objects.filter(test__engagement_id=eid).only("id", "name") + if pid is not None: + # Filter by product's tests + return Finding_Group.objects.filter(test__engagement__product_id=pid).only("id", "name") + # Global: return all (authorization will be applied separately) + return Finding_Group.objects.all().only("id", "name") + + +class FindingFilterWithoutObjectLookups(FindingFilterHelper, FindingTagStringFilter): + test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) + test__engagement__product = NumberFilter(widget=HiddenInput()) + reporter = CharFilter( + field_name="reporter__username", + lookup_expr="iexact", + label="Reporter Username", + help_text="Search for Reporter names that are an exact match") + reporter_contains = CharFilter( + field_name="reporter__username", + lookup_expr="icontains", + label="Reporter Username Contains", + help_text="Search for Reporter names that contain a given pattern") + reviewers = CharFilter( + field_name="reviewers__username", + lookup_expr="iexact", + label="Reviewer Username", + help_text="Search for Reviewer names that are an exact match") + reviewers_contains = CharFilter( + field_name="reviewers__username", + lookup_expr="icontains", + label="Reviewer Username Contains", + help_text="Search for Reviewer usernames that contain a given pattern") + test__engagement__product__prod_type__name = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_NAME_LABEL, + help_text=labels.ORG_FILTERS_NAME_HELP) + test__engagement__product__prod_type__name_contains = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + test__engagement__product__name = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="iexact", + label=labels.ASSET_FILTERS_NAME_LABEL, + help_text=labels.ASSET_FILTERS_NAME_HELP) + test__engagement__product__name_contains = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="icontains", + label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) + test__engagement__name = CharFilter( + field_name="test__engagement__name", + lookup_expr="iexact", + label="Engagement Name", + help_text="Search for Engagement names that are an exact match") + test__engagement__name_contains = CharFilter( + field_name="test__engagement__name", + lookup_expr="icontains", + label="Engagement name Contains", + help_text="Search for Engagement names that contain a given pattern") + test__name = CharFilter( + field_name="test__title", + lookup_expr="iexact", + label="Test Name", + help_text="Search for Test names that are an exact match") + test__name_contains = CharFilter( + field_name="test__title", + lookup_expr="icontains", + label="Test name Contains", + help_text="Search for Test names that contain a given pattern") + + if is_finding_groups_enabled(): + finding_group__name = CharFilter( + field_name="finding_group__name", + lookup_expr="iexact", + label="Finding Group Name", + help_text="Search for Finding Group names that are an exact match") + finding_group__name_contains = CharFilter( + field_name="finding_group__name", + lookup_expr="icontains", + label="Finding Group Name Contains", + help_text="Search for Finding Group names that contain a given pattern") + + class Meta: + model = Finding + fields = get_finding_filterset_fields(filter_string_matching=True) + + exclude = ["url", "description", "mitigation", "impact", + "endpoints", "references", + "thread_id", "notes", "scanner_confidence", + "numerical_severity", "line", "duplicate_finding", + "hash_code", "reviewers", "created", "files", + "sla_start_date", "sla_expiration_date", "cvssv3", + "severity_justification", "steps_to_reproduce"] + + def __init__(self, *args, **kwargs): + self.user = None + self.pid = None + self.eid = None + self.tid = None + if "user" in kwargs: + self.user = kwargs.pop("user") + + if "pid" in kwargs: + self.pid = kwargs.pop("pid") + if "eid" in kwargs: + self.eid = kwargs.pop("eid") + if "tid" in kwargs: + self.tid = kwargs.pop("tid") + super().__init__(*args, **kwargs) + # Set some date fields + self.set_date_fields(*args, **kwargs) + # Don't show the product/engagement/test filter fields when in specific context + if self.tid or self.eid or self.pid: + if "test__engagement__product__name" in self.form.fields: + del self.form.fields["test__engagement__product__name"] + if "test__engagement__product__name_contains" in self.form.fields: + del self.form.fields["test__engagement__product__name_contains"] + if "test__engagement__product__prod_type__name" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type__name"] + if "test__engagement__product__prod_type__name_contains" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type__name_contains"] + # Also hide engagement and test fields if in test or engagement context + if self.tid: + if "test__engagement__name" in self.form.fields: + del self.form.fields["test__engagement__name"] + if "test__engagement__name_contains" in self.form.fields: + del self.form.fields["test__engagement__name_contains"] + if "test__name" in self.form.fields: + del self.form.fields["test__name"] + if "test__name_contains" in self.form.fields: + del self.form.fields["test__name_contains"] + elif self.eid: + if "test__engagement__name" in self.form.fields: + del self.form.fields["test__engagement__name"] + if "test__engagement__name_contains" in self.form.fields: + del self.form.fields["test__engagement__name_contains"] + + +class FindingFilter(FindingFilterHelper, FindingTagFilter): + reporter = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) + reviewers = ModelMultipleChoiceFilter(queryset=Dojo_User.objects.none()) + test__engagement__product__prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + test__engagement__product = ModelMultipleChoiceFilter( + queryset=Product.objects.none(), + label=labels.ASSET_FILTERS_LABEL) + test__engagement = ModelMultipleChoiceFilter( + queryset=Engagement.objects.none(), + label="Engagement") + test = ModelMultipleChoiceFilter( + queryset=Test.objects.none(), + label="Test") + + if is_finding_groups_enabled(): + finding_group = ModelMultipleChoiceFilter( + queryset=Finding_Group.objects.none(), + label="Finding Group") + + class Meta: + model = Finding + fields = get_finding_filterset_fields() + + exclude = ["url", "description", "mitigation", "impact", + "endpoints", "references", + "thread_id", "notes", "scanner_confidence", + "numerical_severity", "line", "duplicate_finding", + "hash_code", "reviewers", "created", "files", + "sla_start_date", "sla_expiration_date", "cvssv3", + "severity_justification", "steps_to_reproduce"] + + def __init__(self, *args, **kwargs): + self.user = None + self.pid = None + self.eid = None + self.tid = None + if "user" in kwargs: + self.user = kwargs.pop("user") + + if "pid" in kwargs: + self.pid = kwargs.pop("pid") + if "eid" in kwargs: + self.eid = kwargs.pop("eid") + if "tid" in kwargs: + self.tid = kwargs.pop("tid") + super().__init__(*args, **kwargs) + # Set some date fields + self.set_date_fields(*args, **kwargs) + # Don't show the product filter on the product finding view + self.set_related_object_fields(*args, **kwargs) + + def set_related_object_fields(self, *args: list, **kwargs: dict): + # Use helper to get contextual finding group queryset + finding_group_query = get_finding_group_queryset_for_context( + pid=self.pid, + eid=self.eid, + tid=self.tid, + ) + + # Filter by most specific context: test > engagement > product + if self.tid is not None: + # Test context: filter finding groups by test + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + if "test__engagement" in self.form.fields: + del self.form.fields["test__engagement"] + if "test" in self.form.fields: + del self.form.fields["test"] + elif self.eid is not None: + # Engagement context: filter finding groups by engagement + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + if "test__engagement" in self.form.fields: + del self.form.fields["test__engagement"] + # Filter tests by engagement - get_authorized_tests doesn't support engagement param + engagement = Engagement.objects.filter(id=self.eid).select_related("product").first() + if engagement: + self.form.fields["test"].queryset = get_authorized_tests("view", product=engagement.product).filter(engagement_id=self.eid).prefetch_related("test_type") + elif self.pid is not None: + # Product context: filter finding groups by product + if "test__engagement__product" in self.form.fields: + del self.form.fields["test__engagement__product"] + if "test__engagement__product__prod_type" in self.form.fields: + del self.form.fields["test__engagement__product__prod_type"] + # TODO: add authorized check to be sure + if "test__engagement" in self.form.fields: + self.form.fields["test__engagement"].queryset = Engagement.objects.filter( + product_id=self.pid, + ).all() + if "test" in self.form.fields: + self.form.fields["test"].queryset = get_authorized_tests("view", product=self.pid).prefetch_related("test_type") + else: + # Global context: show all authorized finding groups + self.form.fields[ + "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") + self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") + if "test" in self.form.fields: + del self.form.fields["test"] + + if self.form.fields.get("test__engagement__product"): + self.form.fields["test__engagement__product"].queryset = get_authorized_products("view") + if self.form.fields.get("finding_group", None): + self.form.fields["finding_group"].queryset = get_authorized_finding_groups_for_queryset("view", finding_group_query, user=self.user) + self.form.fields["reporter"].queryset = get_authorized_users("view") + self.form.fields["reviewers"].queryset = self.form.fields["reporter"].queryset + + +class FindingGroupsFilter(FilterSet): + name = CharFilter(lookup_expr="icontains", label="Name") + severity = ChoiceFilter( + choices=[ + ("Low", "Low"), + ("Medium", "Medium"), + ("High", "High"), + ("Critical", "Critical"), + ], + label="Min Severity", + ) + engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") + product = ModelMultipleChoiceFilter(queryset=Product.objects.none(), label=labels.ASSET_LABEL) + + class Meta: + model = Finding + fields = ["name", "severity", "engagement", "product"] + + def __init__(self, *args, **kwargs): + self.user = kwargs.pop("user", None) + self.pid = kwargs.pop("pid", None) + super().__init__(*args, **kwargs) + self.set_related_object_fields() + + def set_related_object_fields(self): + if self.pid is not None: + self.form.fields["engagement"].queryset = Engagement.objects.filter(product_id=self.pid) + if "product" in self.form.fields: + del self.form.fields["product"] + else: + self.form.fields["product"].queryset = get_authorized_products("view") + self.form.fields["engagement"].queryset = get_authorized_engagements("view") + + +class AcceptedFindingFilter(FindingFilter): + risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") + risk_acceptance__owner = ModelMultipleChoiceFilter( + queryset=Dojo_User.objects.none(), + label="Risk Acceptance Owner") + risk_acceptance = ModelMultipleChoiceFilter( + queryset=Risk_Acceptance.objects.none(), + label="Accepted By") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["risk_acceptance__owner"].queryset = get_authorized_users("view") + self.form.fields["risk_acceptance"].queryset = get_authorized_risk_acceptances("edit") + + +class AcceptedFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): + risk_acceptance__created__date = DateRangeFilter(label="Acceptance Date") + risk_acceptance__owner = CharFilter( + field_name="risk_acceptance__owner__username", + lookup_expr="iexact", + label="Risk Acceptance Owner Username", + help_text="Search for Risk Acceptance Owners username that are an exact match") + risk_acceptance__owner_contains = CharFilter( + field_name="risk_acceptance__owner__username", + lookup_expr="icontains", + label="Risk Acceptance Owner Username Contains", + help_text="Search for Risk Acceptance Owners username that contain a given pattern") + risk_acceptance__name = CharFilter( + field_name="risk_acceptance__name", + lookup_expr="iexact", + label="Risk Acceptance Name", + help_text="Search for Risk Acceptance name that are an exact match") + risk_acceptance__name_contains = CharFilter( + field_name="risk_acceptance__name", + lookup_expr="icontains", + label="Risk Acceptance Name", + help_text="Search for Risk Acceptance name contain a given pattern") + + +class SimilarFindingHelper(FilterSet): + hash_code = MultipleChoiceFilter() + vulnerability_ids = CharFilter(method=custom_vulnerability_id_filter, label="Vulnerability Ids") + + def update_data(self, data: dict, *args: list, **kwargs: dict): + # if filterset is bound, use initial values as defaults + # because of this, we can't rely on the self.form.has_changed + self.has_changed = True + if not data and self.finding: + # get a mutable copy of the QueryDict + data = data.copy() + + data["vulnerability_ids"] = ",".join(self.finding.vulnerability_ids) + data["cwe"] = self.finding.cwe + data["file_path"] = self.finding.file_path + data["line"] = self.finding.line + data["unique_id_from_tool"] = self.finding.unique_id_from_tool + data["test__test_type"] = self.finding.test.test_type + data["test__engagement__product"] = self.finding.test.engagement.product + data["test__engagement__product__prod_type"] = self.finding.test.engagement.product.prod_type + + self.has_changed = False + + def set_hash_codes(self, *args: list, **kwargs: dict): + if self.finding and self.finding.hash_code: + self.form.fields["hash_code"] = forms.MultipleChoiceField(choices=[(self.finding.hash_code, self.finding.hash_code[:24] + "...")], required=False, initial=[]) + + def filter_queryset(self, *args: list, **kwargs: dict): + queryset = super().filter_queryset(*args, **kwargs) + queryset = get_authorized_findings_for_queryset("view", queryset, self.user) + return queryset.exclude(pk=self.finding.pk) + + +class SimilarFindingFilter(FindingFilter, SimilarFindingHelper): + class Meta(FindingFilter.Meta): + model = Finding + # slightly different fields from FindingFilter, but keep the same ordering for UI consistency + fields = get_finding_filterset_fields(similar=True) + + def __init__(self, data=None, *args, **kwargs): + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + self.finding = None + if "finding" in kwargs: + self.finding = kwargs.pop("finding") + self.update_data(data, *args, **kwargs) + super().__init__(data, *args, **kwargs) + self.set_hash_codes(*args, **kwargs) + + +class SimilarFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups, SimilarFindingHelper): + class Meta(FindingFilterWithoutObjectLookups.Meta): + model = Finding + # slightly different fields from FindingFilter, but keep the same ordering for UI consistency + fields = get_finding_filterset_fields(similar=True, filter_string_matching=True) + + def __init__(self, data=None, *args, **kwargs): + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + self.finding = None + if "finding" in kwargs: + self.finding = kwargs.pop("finding") + self.update_data(data, *args, **kwargs) + super().__init__(data, *args, **kwargs) + self.set_hash_codes(*args, **kwargs) + + +class TemplateFindingFilter(DojoFilter): + title = CharFilter(lookup_expr="icontains") + cwe = MultipleChoiceFilter(choices=[]) + severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) + + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + queryset=Finding.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Finding.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("cwe", "cwe"), + ("title", "title"), + ("numerical_severity", "numerical_severity"), + ), + field_labels={ + "numerical_severity": "Severity", + }, + ) + + class Meta: + model = Finding_Template + exclude = ["description", "mitigation", "impact", + "references", "numerical_severity"] + + not_test__tags = ModelMultipleChoiceFilter( + field_name="test__tags__name", + to_field_name="name", + exclude=True, + label="Test without tags", + queryset=Test.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_test__engagement__tags = ModelMultipleChoiceFilter( + field_name="test__engagement__tags__name", + to_field_name="name", + exclude=True, + label="Engagement without tags", + queryset=Engagement.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_test__engagement__product__tags = ModelMultipleChoiceFilter( + field_name="test__engagement__product__tags__name", + to_field_name="name", + exclude=True, + label=labels.ASSET_FILTERS_WITHOUT_TAGS_LABEL, + queryset=Product.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form.fields["cwe"].choices = cwe_options(self.queryset) + + +class MetricsFindingFilter(FindingFilter): + start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) + end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) + date = MetricsDateRangeFilter() + vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") + + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + + def __init__(self, *args, **kwargs): + if args[0]: + if args[0].get("start_date", "") or args[0].get("end_date", ""): + args[0]._mutable = True + args[0]["date"] = 8 + args[0]._mutable = False + + super().__init__(*args, **kwargs) + + class Meta(FindingFilter.Meta): + model = Finding + fields = get_finding_filterset_fields(metrics=True) + + +class MetricsFindingFilterWithoutObjectLookups(FindingFilterWithoutObjectLookups): + start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) + end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) + date = MetricsDateRangeFilter() + vulnerability_id = CharFilter(method=vulnerability_id_filter, label="Vulnerability Id") + + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + exclude=True, + queryset=Endpoint.tags.tag_model.objects.all().order_by("name"), + # label='tags', # doesn't work with tagulous, need to set in __init__ below + ) + + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + + def __init__(self, *args, **kwargs): + if args[0]: + if args[0].get("start_date", "") or args[0].get("end_date", ""): + args[0]._mutable = True + args[0]["date"] = 8 + args[0]._mutable = False + + super().__init__(*args, **kwargs) + + class Meta(FindingFilterWithoutObjectLookups.Meta): + model = Finding + fields = get_finding_filterset_fields(metrics=True, filter_string_matching=True) + + +class ReportFindingFilterHelper(FilterSet): + title = CharFilter(lookup_expr="icontains", label="Name") + date = DateFromToRangeFilter(field_name="date", label="Date Discovered") + date_recent = DateRangeFilter(field_name="date", label="Relative Date") + severity = MultipleChoiceFilter(choices=SEVERITY_CHOICES) + active = ReportBooleanFilter() + is_mitigated = ReportBooleanFilter() + mitigated = DateRangeFilter(label="Mitigated Date") + verified = ReportBooleanFilter() + false_p = ReportBooleanFilter(label="False Positive") + risk_acceptance = ReportRiskAcceptanceFilter(label="Risk Accepted") + duplicate = ReportBooleanFilter() + out_of_scope = ReportBooleanFilter() + outside_of_sla = FindingSLAFilter(label="Outside of SLA") + file_path = CharFilter(lookup_expr="icontains") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") + + o = OrderingFilter( + fields=( + ("title", "title"), + ("date", "date"), + ("fix_available", "fix_available"), + ("numerical_severity", "numerical_severity"), + ("epss_score", "epss_score"), + ("epss_percentile", "epss_percentile"), + ("test__engagement__product__name", "test__engagement__product__name"), + ), + ) + + class Meta: + model = Finding + # exclude sonarqube issue as by default it will show all without checking permissions + exclude = ["date", "cwe", "url", "description", "mitigation", "impact", + "references", "sonarqube_issue", "duplicate_finding", + "thread_id", "notes", "inherited_tags", "endpoints", + "numerical_severity", "reporter", "last_reviewed", + "jira_creation", "jira_change", "files"] + + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + + def manage_kwargs(self, kwargs): + self.prod_type = None + self.product = None + self.engagement = None + self.test = None + if "prod_type" in kwargs: + self.prod_type = kwargs.pop("prod_type") + if "product" in kwargs: + self.product = kwargs.pop("product") + if "engagement" in kwargs: + self.engagement = kwargs.pop("engagement") + if "test" in kwargs: + self.test = kwargs.pop("test") + + @property + def qs(self): + parent = super().qs + return get_authorized_findings_for_queryset("view", parent) + + +class ReportFindingFilter(ReportFindingFilterHelper, FindingTagFilter): + test__engagement__product = ModelMultipleChoiceFilter( + queryset=Product.objects.none(), label=labels.ASSET_FILTERS_LABEL) + test__engagement__product__prod_type = ModelMultipleChoiceFilter( + queryset=Product_Type.objects.none(), + label=labels.ORG_FILTERS_LABEL) + test__engagement__product__lifecycle = MultipleChoiceFilter(choices=Product.LIFECYCLE_CHOICES, label=labels.ASSET_LIFECYCLE_LABEL) + test__engagement = ModelMultipleChoiceFilter(queryset=Engagement.objects.none(), label="Engagement") + duplicate_finding = ModelChoiceFilter(queryset=Finding.objects.filter(original_finding__isnull=False).distinct()) + + def __init__(self, *args, **kwargs): + self.manage_kwargs(kwargs) + super().__init__(*args, **kwargs) + + # duplicate_finding queryset needs to restricted in line with permissions + # and inline with report scope to avoid a dropdown with 100K entries + duplicate_finding_query_set = self.form.fields["duplicate_finding"].queryset + duplicate_finding_query_set = get_authorized_findings_for_queryset("view", duplicate_finding_query_set) + + if self.test: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test=self.test) + del self.form.fields["test__tags"] + del self.form.fields["test__engagement__tags"] + del self.form.fields["test__engagement__product__tags"] + if self.engagement: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement=self.engagement) + del self.form.fields["test__engagement__tags"] + del self.form.fields["test__engagement__product__tags"] + elif self.product: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product=self.product) + del self.form.fields["test__engagement__product"] + del self.form.fields["test__engagement__product__tags"] + elif self.prod_type: + duplicate_finding_query_set = duplicate_finding_query_set.filter(test__engagement__product__prod_type=self.prod_type) + del self.form.fields["test__engagement__product__prod_type"] + + self.form.fields["duplicate_finding"].queryset = duplicate_finding_query_set + + if "test__engagement__product__prod_type" in self.form.fields: + self.form.fields[ + "test__engagement__product__prod_type"].queryset = get_authorized_product_types("view") + if "test__engagement__product" in self.form.fields: + self.form.fields[ + "test__engagement__product"].queryset = get_authorized_products("view") + if "test__engagement" in self.form.fields: + self.form.fields["test__engagement"].queryset = get_authorized_engagements("view") + + +class ReportFindingFilterWithoutObjectLookups(ReportFindingFilterHelper, FindingTagStringFilter): + test__engagement__product__prod_type = NumberFilter(widget=HiddenInput()) + test__engagement__product = NumberFilter(widget=HiddenInput()) + test__engagement = NumberFilter(widget=HiddenInput()) + test = NumberFilter(widget=HiddenInput()) + endpoint = NumberFilter(widget=HiddenInput()) + reporter = CharFilter( + field_name="reporter__username", + lookup_expr="iexact", + label="Reporter Username", + help_text="Search for Reporter names that are an exact match") + reporter_contains = CharFilter( + field_name="reporter__username", + lookup_expr="icontains", + label="Reporter Username Contains", + help_text="Search for Reporter names that contain a given pattern") + reviewers = CharFilter( + field_name="reviewers__username", + lookup_expr="iexact", + label="Reviewer Username", + help_text="Search for Reviewer names that are an exact match") + reviewers_contains = CharFilter( + field_name="reviewers__username", + lookup_expr="icontains", + label="Reviewer Username Contains", + help_text="Search for Reviewer usernames that contain a given pattern") + last_reviewed_by = CharFilter( + field_name="last_reviewed_by__username", + lookup_expr="iexact", + label="Last Reviewed By Username", + help_text="Search for Last Reviewed By names that are an exact match") + last_reviewed_by_contains = CharFilter( + field_name="last_reviewed_by__username", + lookup_expr="icontains", + label="Last Reviewed By Username Contains", + help_text="Search for Last Reviewed By usernames that contain a given pattern") + review_requested_by = CharFilter( + field_name="review_requested_by__username", + lookup_expr="iexact", + label="Review Requested By Username", + help_text="Search for Review Requested By names that are an exact match") + review_requested_by_contains = CharFilter( + field_name="review_requested_by__username", + lookup_expr="icontains", + label="Review Requested By Username Contains", + help_text="Search for Review Requested By usernames that contain a given pattern") + mitigated_by = CharFilter( + field_name="mitigated_by__username", + lookup_expr="iexact", + label="Mitigator Username", + help_text="Search for Mitigator names that are an exact match") + mitigated_by_contains = CharFilter( + field_name="mitigated_by__username", + lookup_expr="icontains", + label="Mitigator Username Contains", + help_text="Search for Mitigator usernames that contain a given pattern") + defect_review_requested_by = CharFilter( + field_name="defect_review_requested_by__username", + lookup_expr="iexact", + label="Requester of Defect Review Username", + help_text="Search for Requester of Defect Review names that are an exact match") + defect_review_requested_by_contains = CharFilter( + field_name="defect_review_requested_by__username", + lookup_expr="icontains", + label="Requester of Defect Review Username Contains", + help_text="Search for Requester of Defect Review usernames that contain a given pattern") + test__engagement__product__prod_type__name = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="iexact", + label=labels.ORG_FILTERS_NAME_LABEL, + help_text=labels.ORG_FILTERS_NAME_HELP) + test__engagement__product__prod_type__name_contains = CharFilter( + field_name="test__engagement__product__prod_type__name", + lookup_expr="icontains", + label=labels.ORG_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ORG_FILTERS_NAME_CONTAINS_HELP) + test__engagement__product__name = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="iexact", + label=labels.ASSET_FILTERS_NAME_LABEL, + help_text=labels.ASSET_FILTERS_NAME_HELP) + test__engagement__product__name_contains = CharFilter( + field_name="test__engagement__product__name", + lookup_expr="icontains", + label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) + test__engagement__name = CharFilter( + field_name="test__engagement__name", + lookup_expr="iexact", + label="Engagement Name", + help_text="Search for Engagement names that are an exact match") + test__engagement__name_contains = CharFilter( + field_name="test__engagement__name", + lookup_expr="icontains", + label="Engagement name Contains", + help_text="Search for Engagement names that contain a given pattern") + test__name = CharFilter( + field_name="test__title", + lookup_expr="iexact", + label="Test Name", + help_text="Search for Test names that are an exact match") + test__name_contains = CharFilter( + field_name="test__title", + lookup_expr="icontains", + label="Test name Contains", + help_text="Search for Test names that contain a given pattern") + + def __init__(self, *args, **kwargs): + self.manage_kwargs(kwargs) + super().__init__(*args, **kwargs) + + product_type_refs = [ + "test__engagement__product__prod_type__name", + "test__engagement__product__prod_type__name_contains", + ] + product_refs = [ + "test__engagement__product__name", + "test__engagement__product__name_contains", + "test__engagement__product__tags", + "test__engagement__product__tags_contains", + "not_test__engagement__product__tags", + "not_test__engagement__product__tags_contains", + ] + engagement_refs = [ + "test__engagement__name", + "test__engagement__name_contains", + "test__engagement__tags", + "test__engagement__tags_contains", + "not_test__engagement__tags", + "not_test__engagement__tags_contains", + ] + test_refs = [ + "test__name", + "test__name_contains", + "test__tags", + "test__tags_contains", + "not_test__tags", + "not_test__tags_contains", + ] + + if self.test: + self.delete_tags_from_form(product_type_refs) + self.delete_tags_from_form(product_refs) + self.delete_tags_from_form(engagement_refs) + self.delete_tags_from_form(test_refs) + elif self.engagement: + self.delete_tags_from_form(product_type_refs) + self.delete_tags_from_form(product_refs) + self.delete_tags_from_form(engagement_refs) + elif self.product: + self.delete_tags_from_form(product_type_refs) + self.delete_tags_from_form(product_refs) + elif self.prod_type: + self.delete_tags_from_form(product_type_refs) diff --git a/dojo/finding/ui/forms.py b/dojo/finding/ui/forms.py new file mode 100644 index 00000000000..79f3e317059 --- /dev/null +++ b/dojo/finding/ui/forms.py @@ -0,0 +1,1083 @@ +import tagulous +from django import forms +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from tagulous.forms import TagField + +from dojo.endpoint.utils import validate_endpoints_to_add +from dojo.finding.queries import get_authorized_findings +from dojo.jira import services as jira_services +from dojo.location.models import Location +from dojo.location.utils import validate_locations_to_add +from dojo.models import ( + EFFORT_FOR_FIXING_CHOICES, + SEVERITY_CHOICES, + Dojo_User, + Endpoint, + Finding, + Finding_Group, + Finding_Template, + Notes, + Risk_Acceptance, + Test, +) +from dojo.user.queries import get_authorized_users, get_authorized_users_for_product_and_product_type +from dojo.utils import get_system_setting, is_finding_groups_enabled +from dojo.validators import cvss3_validator, cvss4_validator, tag_validator +from dojo.widgets import TableCheckboxWidget + +CVSS_CALCULATOR_URLS = { + "https://www.first.org/cvss/calculator/3-0": "CVSS3 Calculator by FIRST", + "https://www.first.org/cvss/calculator/4-0": "CVSS4 Calculator by FIRST", + "https://www.metaeffekt.com/security/cvss/calculator/": "CVSS2/3/4 Calculator by Metaeffekt", + } + + +vulnerability_ids_field = forms.CharField(max_length=5000, + required=False, + label="Vulnerability Ids", + help_text="Ids of vulnerabilities in security advisories associated with this finding. Can be Common Vulnerabilities and Exposures (CVE) or from other sources." + "You may enter one vulnerability id per line.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + +EFFORT_FOR_FIXING_INVALID_CHOICE = _("Select valid choice: Low,Medium,High") + + +class BulletListDisplayWidget(forms.Widget): + def __init__(self, urls_dict=None, *args, **kwargs): + self.urls_dict = urls_dict or {} + super().__init__(*args, **kwargs) + + def render(self, name, value, attrs=None, renderer=None): + if not self.urls_dict: + return "" + + html = '
    ' + for url, text in self.urls_dict.items(): + html += f'
  • {text}
  • ' + html += "
" + return mark_safe(html) + + +def hide_cvss_fields_if_disabled(form_instance): + """Hide CVSS fields based on system settings.""" + enable_cvss3 = get_system_setting("enable_cvss3_display", True) + enable_cvss4 = get_system_setting("enable_cvss4_display", True) + + # Hide CVSS3 fields if disabled + if not enable_cvss3: + if "cvssv3" in form_instance.fields: + del form_instance.fields["cvssv3"] + if "cvssv3_score" in form_instance.fields: + del form_instance.fields["cvssv3_score"] + + # Hide CVSS4 fields if disabled + if not enable_cvss4: + if "cvssv4" in form_instance.fields: + del form_instance.fields["cvssv4"] + if "cvssv4_score" in form_instance.fields: + del form_instance.fields["cvssv4_score"] + + # If both are disabled, hide all CVSS related fields + if not enable_cvss3 and not enable_cvss4: + if "cvss_info" in form_instance.fields: + del form_instance.fields["cvss_info"] + + +class EditFindingGroupForm(forms.ModelForm): + name = forms.CharField(max_length=255, required=True, label="Finding Group Name") + jira_issue = forms.CharField(max_length=255, required=False, label="Linked JIRA Issue", + help_text="Leave empty and check push to jira to create a new JIRA issue for this finding group.") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["push_to_jira"] = forms.BooleanField() + self.fields["push_to_jira"].required = False + self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one." + + self.fields["push_to_jira"].label = "Push to JIRA" + + if hasattr(self.instance, "has_jira_issue") and self.instance.has_jira_issue: + jira_url = jira_services.get_url(self.instance) + self.fields["jira_issue"].initial = jira_url + self.fields["push_to_jira"].widget.attrs["checked"] = "checked" + + class Meta: + model = Finding_Group + fields = ["name"] + + +class DeleteFindingGroupForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding_Group + fields = ["id"] + + +class MergeFindings(forms.ModelForm): + FINDING_ACTION = (("", "Select an Action"), ("inactive", "Inactive"), ("delete", "Delete")) + + append_description = forms.BooleanField(label="Append Description", initial=True, required=False, + help_text="Description in all findings will be appended into the merged finding.") + + add_endpoints = forms.BooleanField(label="Add Endpoints", initial=True, required=False, + help_text="Endpoints in all findings will be merged into the merged finding.") + + dynamic_raw = forms.BooleanField(label="Dynamic Scanner Raw Requests", initial=True, required=False, + help_text="Dynamic scanner raw requests in all findings will be merged into the merged finding.") + + tag_finding = forms.BooleanField(label="Add Tags", initial=True, required=False, + help_text="Tags in all findings will be merged into the merged finding.") + + mark_tag_finding = forms.BooleanField(label="Tag Merged Finding", initial=True, required=False, + help_text="Creates a tag titled 'merged' for the finding that will be merged. If the 'Finding Action' is set to 'inactive' the inactive findings will be tagged with 'merged-inactive'.") + + append_reference = forms.BooleanField(label="Append Reference", initial=True, required=False, + help_text="Reference in all findings will be appended into the merged finding.") + + finding_action = forms.ChoiceField( + required=True, + choices=FINDING_ACTION, + label="Finding Action", + help_text="The action to take on the merged finding. Set the findings to inactive or delete the findings.") + + def __init__(self, *args, **kwargs): + _ = kwargs.pop("finding") + findings = kwargs.pop("findings") + super().__init__(*args, **kwargs) + + self.fields["finding_to_merge_into"] = forms.ModelChoiceField( + queryset=findings, initial=0, required="False", label="Finding to Merge Into", help_text="Findings selected below will be merged into this finding.") + + # Exclude the finding to merge into from the findings to merge into + self.fields["findings_to_merge"] = forms.ModelMultipleChoiceField( + queryset=findings, required=True, label="Findings to Merge", + widget=forms.widgets.SelectMultiple(attrs={"size": 10}), + help_text=("Select the findings to merge.")) + self.field_order = ["finding_to_merge_into", "findings_to_merge", "append_description", "add_endpoints", "append_reference"] + + class Meta: + model = Finding + fields = ["append_description", "add_endpoints", "append_reference"] + + +class AddFindingsRiskAcceptanceForm(forms.ModelForm): + + accepted_findings = forms.ModelMultipleChoiceField( + queryset=Finding.objects.none(), + required=True, + label="", + widget=TableCheckboxWidget(attrs={"size": 25}), + ) + + class Meta: + model = Risk_Acceptance + fields = ["accepted_findings"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["accepted_findings"].queryset = get_authorized_findings("edit") + + +class AddFindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + request = forms.CharField(widget=forms.Textarea, required=False) + response = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.ChoiceField( + required=False, + choices=EFFORT_FOR_FIXING_CHOICES, + error_messages={ + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + + # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", + "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "verified", "false_p", "duplicate", "out_of_scope", + "risk_accepted", "under_defect_review") + + def __init__(self, *args, **kwargs): + req_resp = kwargs.pop("req_resp") + + product = None + if "product" in kwargs: + product = kwargs.pop("product") + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS and product: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) + # TODO: Delete this after the move to Locations + elif product: + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) + else: + self.fields["endpoints"].queryset = Endpoint.objects.none() + + if req_resp: + self.fields["request"].initial = req_resp[0] + self.fields["response"].initial = req_resp[1] + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: + msg = "Active findings cannot be risk accepted." + raise forms.ValidationError(msg) + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + if errors: + raise forms.ValidationError(errors) + self.endpoints_to_add_list = endpoints_to_add_list + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date") + + +class AdHocFindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + + cvss_info = forms.CharField( + label="CVSS", + widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), + required=False, + disabled=True) + + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + request = forms.CharField(widget=forms.Textarea, required=False) + response = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.all(), required=False, + label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.ChoiceField( + required=False, + choices=EFFORT_FOR_FIXING_CHOICES, + error_messages={ + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + + # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", + "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", + "active", "verified", "false_p", "duplicate", "out_of_scope", "risk_accepted", "under_defect_review", "sla_start_date", "sla_expiration_date") + + def __init__(self, *args, **kwargs): + req_resp = kwargs.pop("req_resp") + + product = None + if "product" in kwargs: + product = kwargs.pop("product") + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS and product: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) + # TODO: Delete this after the move to Locations + elif product: + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) + else: + self.fields["endpoints"].queryset = Endpoint.objects.none() + + if req_resp: + self.fields["request"].initial = req_resp[0] + self.fields["response"].initial = req_resp[1] + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + self.endpoints_to_add_list = endpoints_to_add_list + + if errors: + raise forms.ValidationError(errors) + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date", + "sla_expiration_date") + + +class PromoteFindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + + cvss_info = forms.CharField( + label="CVSS", + widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), + required=False, + disabled=True) + + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + + # the onyl reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", + "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", + "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", + "false_p", "duplicate", "out_of_scope", "risk_accept", "under_defect_review") + + def __init__(self, *args, **kwargs): + product = None + if "product" in kwargs: + product = kwargs.pop("product") + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS and product: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) + # TODO: Delete this after the move to Locations + elif product: + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) + else: + self.fields["endpoints"].queryset = Endpoint.objects.none() + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + if errors: + raise forms.ValidationError(errors) + self.endpoints_to_add_list = endpoints_to_add_list + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "active", "false_p", "verified", "endpoint_status", "cve", "inherited_tags", + "duplicate", "out_of_scope", "under_review", "reviewers", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "planned_remediation_date", "planned_remediation_version", "effort_for_fixing") + + +class FindingForm(forms.ModelForm): + title = forms.CharField(max_length=1000) + group = forms.ModelChoiceField(required=False, queryset=Finding_Group.objects.none(), help_text="The Finding Group to which this finding belongs, leave empty to remove the finding from the group. Groups can only be created via Bulk Edit for now.") + date = forms.DateField(required=True, + widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + cwe = forms.IntegerField(required=False) + vulnerability_ids = vulnerability_ids_field + + cvss_info = forms.CharField( + label="CVSS", + widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), + required=False, + disabled=True) + + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) + + description = forms.CharField(widget=forms.Textarea) + severity = forms.ChoiceField( + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + request = forms.CharField(widget=forms.Textarea, required=False) + response = forms.CharField(widget=forms.Textarea, required=False) + endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.none(), required=False, label="Systems / Endpoints") + endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + references = forms.CharField(widget=forms.Textarea, required=False) + + mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) + + publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.ChoiceField( + required=False, + choices=EFFORT_FOR_FIXING_CHOICES, + error_messages={ + "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) + + # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit + field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", + "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", + "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", "false_p", "duplicate", + "out_of_scope", "risk_accept", "under_defect_review") + + def __init__(self, *args, **kwargs): + req_resp = None + if "req_resp" in kwargs: + req_resp = kwargs.pop("req_resp") + + self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ + else False + + super().__init__(*args, **kwargs) + + if settings.V3_FEATURE_LOCATIONS: + self.fields["endpoints"].queryset = Location.objects.filter(products__product=self.instance.test.engagement.product) + if self.instance and self.instance.pk: + self.fields["endpoints"].initial = Location.objects.filter(findings__finding=self.instance) + else: + # TODO: Delete this after the move to Locations + self.fields["endpoints"].queryset = Endpoint.objects.filter(product=self.instance.test.engagement.product) + if self.instance and self.instance.pk: + self.fields["endpoints"].initial = self.instance.endpoints.all() + + self.fields["mitigated_by"].queryset = get_authorized_users("edit") + + # do not show checkbox if finding is not accepted and simple risk acceptance is disabled + # if checked, always show to allow unaccept also with full risk acceptance enabled + # when adding from template, we don't have access to the test. quickfix for now to just hide simple risk acceptance + if not hasattr(self.instance, "test") or (not self.instance.risk_accepted and not self.instance.test.engagement.product.enable_simple_risk_acceptance): + del self.fields["risk_accepted"] + elif self.instance.risk_accepted: + self.fields["risk_accepted"].help_text = "Uncheck to unaccept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." + elif self.instance.test.engagement.product.enable_simple_risk_acceptance: + self.fields["risk_accepted"].help_text = "Check to accept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." + + # self.fields['tags'].widget.choices = t + if req_resp: + self.fields["request"].initial = req_resp[0] + self.fields["response"].initial = req_resp[1] + + if self.instance.duplicate: + self.fields["duplicate"].help_text = "Original finding that is being duplicated here (readonly). Use view finding page to manage duplicate relationships. Unchecking duplicate here will reset this findings duplicate status, but will trigger deduplication logic." + else: + self.fields["duplicate"].help_text = "You can mark findings as duplicate only from the view finding page." + + self.fields["sla_start_date"].disabled = True + self.fields["sla_expiration_date"].disabled = True + + if self.can_edit_mitigated_data: + if hasattr(self, "instance"): + self.fields["mitigated"].initial = self.instance.mitigated + self.fields["mitigated_by"].initial = self.instance.mitigated_by + else: + del self.fields["mitigated"] + del self.fields["mitigated_by"] + + if not is_finding_groups_enabled() or not hasattr(self.instance, "test"): + del self.fields["group"] + else: + self.fields["group"].queryset = self.instance.test.finding_group_set.all() + self.fields["group"].initial = self.instance.finding_group + + self.endpoints_to_add_list = [] + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + + if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: + msg = "Active findings cannot be risk accepted." + raise forms.ValidationError(msg) + + if settings.V3_FEATURE_LOCATIONS: + endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) + else: + # TODO: Delete this after the move to Locations + endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) + + self.endpoints_to_add_list = endpoints_to_add_list + + if errors: + raise forms.ValidationError(errors) + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + def _post_clean(self): + super()._post_clean() + + if self.can_edit_mitigated_data: + opts = self.instance._meta + try: + opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) + opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) + except forms.ValidationError as e: + self._update_errors(e) + + class Meta: + model = Finding + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "sonarqube_issue", + "endpoints", "endpoint_status") + + +class ApplyFindingTemplateForm(forms.Form): + + title = forms.CharField(max_length=1000, required=True) + + cwe = forms.IntegerField(label="CWE", required=False) + vulnerability_ids = vulnerability_ids_field + cvssv3 = forms.CharField(label="CVSSv3", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") + cvssv4 = forms.CharField(label="CVSSv4", max_length=255, required=False) + cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") + + severity = forms.ChoiceField(required=False, choices=SEVERITY_CHOICES, error_messages={"required": "Select valid choice: In Progress, On Hold, Completed", "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + + description = forms.CharField(widget=forms.Textarea) + mitigation = forms.CharField(widget=forms.Textarea, required=False) + impact = forms.CharField(widget=forms.Textarea, required=False) + references = forms.CharField(widget=forms.Textarea, required=False) + + # Remediation planning fields + fix_available = forms.BooleanField(required=False) + fix_version = forms.CharField(max_length=100, required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.CharField(max_length=99, required=False) + + # Technical details fields + steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) + severity_justification = forms.CharField(widget=forms.Textarea, required=False) + component_name = forms.CharField(max_length=500, required=False) + component_version = forms.CharField(max_length=100, required=False) + + # Notes field + notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") + + # Endpoints field + endpoints = forms.CharField(max_length=5000, required=False, + help_text="Endpoint URLs (one per line)", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + + tags = TagField(required=False, help_text="Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.", initial=Finding.tags.tag_model.objects.all().order_by("name")) + + def __init__(self, template=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") + self.template = template + if template: + # Populate vulnerability_ids field initial value + self.fields["vulnerability_ids"].initial = "\n".join(template.vulnerability_ids) + + # Populate CVSS fields from template + if hasattr(template, "cvssv3"): + self.fields["cvssv3"].initial = template.cvssv3 + if hasattr(template, "cvssv4"): + self.fields["cvssv4"].initial = template.cvssv4 + if hasattr(template, "cvssv3_score"): + self.fields["cvssv3_score"].initial = template.cvssv3_score + if hasattr(template, "cvssv4_score"): + self.fields["cvssv4_score"].initial = template.cvssv4_score + + # Populate all other new fields from template + for field_name in ["fix_available", "fix_version", "planned_remediation_version", + "effort_for_fixing", "steps_to_reproduce", "severity_justification", + "component_name", "component_version", "notes"]: + if hasattr(template, field_name): + value = getattr(template, field_name) + if value is not None: + self.fields[field_name].initial = value + + # Populate endpoints + if hasattr(template, "endpoints"): + endpoints_value = template.endpoints + if endpoints_value: + if isinstance(endpoints_value, list): + self.fields["endpoints"].initial = "\n".join(endpoints_value) + else: + self.fields["endpoints"].initial = endpoints_value + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + def clean(self): + cleaned_data = super().clean() + + if "title" in cleaned_data: + if len(cleaned_data["title"]) <= 0: + msg = "The title is required." + raise forms.ValidationError(msg) + else: + msg = "The title is required." + raise forms.ValidationError(msg) + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + fields = ["title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "severity", "description", "mitigation", "impact", "references", "tags", + "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", + "steps_to_reproduce", "severity_justification", "component_name", "component_version", + "notes", "endpoints"] + order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "severity", "description", "impact", "steps_to_reproduce", "severity_justification", + "mitigation", "fix_available", "fix_version", "planned_remediation_version", + "effort_for_fixing", "component_name", "component_version", "references", "notes", + "endpoints", "tags") + + +class FindingTemplateForm(forms.ModelForm): + title = forms.CharField(max_length=1000, required=True) + + cwe = forms.IntegerField(label="CWE", required=False) + vulnerability_ids = vulnerability_ids_field + cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) + cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") + cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) + cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") + severity = forms.ChoiceField( + required=False, + choices=SEVERITY_CHOICES, + error_messages={ + "required": "Select valid choice: In Progress, On Hold, Completed", + "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) + + # Remediation planning fields + fix_available = forms.BooleanField(required=False) + fix_version = forms.CharField(max_length=100, required=False) + planned_remediation_version = forms.CharField(max_length=99, required=False) + effort_for_fixing = forms.CharField(max_length=99, required=False) + + # Technical details fields + steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) + severity_justification = forms.CharField(widget=forms.Textarea, required=False) + component_name = forms.CharField(max_length=500, required=False) + component_version = forms.CharField(max_length=100, required=False) + + # Notes field + notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") + + # Endpoints field + endpoints = forms.CharField(max_length=5000, required=False, + help_text="Endpoint URLs (one per line)", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + + field_order = ["title", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", + "description", "impact", "steps_to_reproduce", "severity_justification", "mitigation", + "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", + "component_name", "component_version", "references", "notes", "endpoints", "tags"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") + + # Hide CVSS fields based on system settings + hide_cvss_fields_if_disabled(self) + + class Meta: + model = Finding_Template + order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "severity", "description", "impact", + "steps_to_reproduce", "severity_justification", "mitigation", "fix_available", "fix_version", + "planned_remediation_version", "effort_for_fixing", "component_name", "component_version", + "references", "notes", "endpoints", "tags") + exclude = ("numerical_severity", "is_mitigated", "last_used", "endpoint_status", "cve", "vulnerability_ids_text") + + def clean_cvssv3(self): + value = self.cleaned_data.get("cvssv3") + if value: + try: + cvss3_validator(value) + except ValidationError as e: + raise forms.ValidationError(e.messages) + return value + + def clean_cvssv4(self): + value = self.cleaned_data.get("cvssv4") + if value: + try: + cvss4_validator(value) + except ValidationError as e: + raise forms.ValidationError(e.messages) + return value + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + +class DeleteFindingTemplateForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding_Template + fields = ["id"] + + +class FindingBulkUpdateForm(forms.ModelForm): + status = forms.BooleanField(required=False) + risk_acceptance = forms.BooleanField(required=False) + risk_accept = forms.BooleanField(required=False) + risk_unaccept = forms.BooleanField(required=False) + + date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) + planned_remediation_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) + planned_remediation_version = forms.CharField(required=False, max_length=99, widget=forms.TextInput(attrs={"class": "form-control"})) + finding_group = forms.BooleanField(required=False) + finding_group_create = forms.BooleanField(required=False) + finding_group_create_name = forms.CharField(required=False) + finding_group_add = forms.BooleanField(required=False) + add_to_finding_group_id = forms.CharField(required=False) + finding_group_remove = forms.BooleanField(required=False) + finding_group_by = forms.BooleanField(required=False) + finding_group_by_option = forms.CharField(required=False) + + push_to_jira = forms.BooleanField(required=False) + # unlink_from_jira = forms.BooleanField(required=False) + push_to_github = forms.BooleanField(required=False) + tags = TagField(required=False, autocomplete_tags=Finding.tags.tag_model.objects.all().order_by("name")) + notes = forms.CharField(required=False, max_length=1024, widget=forms.TextInput(attrs={"class": "form-control"})) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["severity"].required = False + # we need to defer initialization to prevent multiple initializations if other forms are shown + self.fields["tags"].widget.tag_options = tagulous.models.options.TagOptions(autocomplete_settings={"width": "200px", "defer": True}) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + def clean(self): + cleaned_data = super().clean() + + if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: + msg = "Duplicate findings cannot be verified or active" + raise forms.ValidationError(msg) + if cleaned_data["false_p"] and cleaned_data["verified"]: + msg = "False positive findings cannot be verified." + raise forms.ValidationError(msg) + if cleaned_data["active"] and cleaned_data.get("risk_acceptance") and cleaned_data.get("risk_accept"): + msg = "Active findings cannot be risk accepted." + raise forms.ValidationError(msg) + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + class Meta: + model = Finding + fields = ("severity", "date", "planned_remediation_date", "active", "verified", "false_p", "duplicate", "out_of_scope", + "under_review", "is_mitigated") + + +class CloseFindingForm(forms.ModelForm): + entry = forms.CharField( + required=True, max_length=2400, + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for closing a finding is " + "required, please use the text area " + "below to provide documentation.")}) + + mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) + mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) + false_p = forms.BooleanField(initial=False, required=False, label="False Positive") + out_of_scope = forms.BooleanField(initial=False, required=False, label="Out of Scope") + duplicate = forms.BooleanField(initial=False, required=False, label="Duplicate") + + def __init__(self, *args, **kwargs): + queryset = kwargs.pop("missing_note_types") + # must pop custom kwargs before calling parent __init__ to avoid unexpected kwarg errors + self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ + else False + super().__init__(*args, **kwargs) + if len(queryset) == 0: + self.fields["note_type"].widget = forms.HiddenInput() + else: + self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True) + + if self.can_edit_mitigated_data: + self.fields["mitigated_by"].queryset = get_authorized_users("edit") + self.fields["mitigated"].initial = self.instance.mitigated + self.fields["mitigated_by"].initial = self.instance.mitigated_by + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + def _post_clean(self): + super()._post_clean() + + if self.can_edit_mitigated_data: + opts = self.instance._meta + if not self.cleaned_data.get("active"): + try: + opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) + opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) + except forms.ValidationError as e: + self._update_errors(e) + + class Meta: + model = Notes + fields = ["note_type", "entry", "mitigated", "mitigated_by", "false_p", "out_of_scope", "duplicate"] + + +class EditPlannedRemediationDateFindingForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + finding = None + if "finding" in kwargs: + finding = kwargs.pop("finding") + + super().__init__(*args, **kwargs) + + self.fields["planned_remediation_date"].required = True + self.fields["planned_remediation_date"].widget = forms.DateInput(attrs={"class": "datepicker"}) + + if finding is not None: + self.fields["planned_remediation_date"].initial = finding.planned_remediation_date + + class Meta: + model = Finding + fields = ["planned_remediation_date"] + + +class DefectFindingForm(forms.ModelForm): + CLOSE_CHOICES = (("Close Finding", "Close Finding"), ("Not Fixed", "Not Fixed")) + defect_choice = forms.ChoiceField(required=True, choices=CLOSE_CHOICES) + + entry = forms.CharField( + required=True, max_length=2400, + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for closing a finding is " + "required, please use the text area " + "below to provide documentation.")}) + + class Meta: + model = Notes + fields = ["entry"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + +class ClearFindingReviewForm(forms.ModelForm): + entry = forms.CharField( + required=True, max_length=2400, + help_text="Please provide a message.", + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for clearing a review is " + "required, please use the text area " + "below to provide documentation.")}) + + class Meta: + model = Finding + fields = ["active", "verified", "false_p", "out_of_scope", "duplicate", "is_mitigated"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + +class ReviewFindingForm(forms.Form): + reviewers = forms.MultipleChoiceField( + help_text=( + "Select all users who can review Finding. Only users with " + "at least write permission to this finding can be selected"), + required=False, + ) + entry = forms.CharField( + required=True, max_length=2400, + help_text="Please provide a message for reviewers.", + widget=forms.Textarea, label="Notes:", + error_messages={"required": ("The reason for requesting a review is " + "required, please use the text area " + "below to provide documentation.")}) + allow_all_reviewers = forms.BooleanField( + required=False, + label="Allow All Eligible Reviewers", + help_text=("Checking this box will allow any user in the drop down " + "above to provide a review for this finding")) + + def __init__(self, *args, **kwargs): + finding = kwargs.pop("finding", None) + kwargs.pop("user", None) + super().__init__(*args, **kwargs) + # Get the list of users + if finding is not None: + users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit") + else: + users = get_authorized_users("edit").filter(is_active=True) + # Save a copy of the original query to be used in the validator + self.reviewer_queryset = users + # Set the users in the form + self.fields["reviewers"].choices = self._get_choices(self.reviewer_queryset) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + @staticmethod + def _get_choices(queryset): + return [(item.pk, item.get_full_name()) for item in queryset] + + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get("allow_all_reviewers", False): + cleaned_data["reviewers"] = [user.id for user in self.reviewer_queryset] + if len(cleaned_data.get("reviewers", [])) == 0: + msg = "Please select at least one user from the reviewers list" + raise ValidationError(msg) + return cleaned_data + + class Meta: + fields = ["reviewers", "entry", "allow_all_reviewers"] + + +class DeleteFindingForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding + fields = ["id"] + + +class CopyFindingForm(forms.Form): + test = forms.ModelChoiceField( + required=True, + queryset=Test.objects.none(), + error_messages={"required": "*"}) + + def __init__(self, *args, **kwargs): + authorized_lists = kwargs.pop("tests", None) + super().__init__(*args, **kwargs) + self.fields["test"].queryset = authorized_lists + + +class FindingFormID(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Finding + fields = ("id",) diff --git a/dojo/finding/views.py b/dojo/finding/views.py index 58013751ebd..e4ed5327e1c 100644 --- a/dojo/finding/views.py +++ b/dojo/finding/views.py @@ -33,7 +33,13 @@ from dojo.authorization.authorization import user_has_global_permission_or_403, user_has_permission_or_403 from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.queries import get_authorized_endpoints -from dojo.filters import ( +from dojo.finding.deduplication import ( + _fetch_fp_candidates_for_batch, + do_false_positive_history_batch, + match_finding_to_existing_findings, +) +from dojo.finding.queries import get_authorized_findings, get_authorized_findings_for_queryset, prefetch_for_findings +from dojo.finding.ui.filters import ( AcceptedFindingFilter, AcceptedFindingFilterWithoutObjectLookups, FindingFilter, @@ -42,13 +48,7 @@ SimilarFindingFilterWithoutObjectLookups, TemplateFindingFilter, ) -from dojo.finding.deduplication import ( - _fetch_fp_candidates_for_batch, - do_false_positive_history_batch, - match_finding_to_existing_findings, -) -from dojo.finding.queries import get_authorized_findings, get_authorized_findings_for_queryset, prefetch_for_findings -from dojo.forms import ( +from dojo.finding.ui.forms import ( ApplyFindingTemplateForm, ClearFindingReviewForm, CloseFindingForm, @@ -60,11 +60,13 @@ FindingBulkUpdateForm, FindingForm, FindingTemplateForm, + MergeFindings, + ReviewFindingForm, +) +from dojo.forms import ( GITHUBFindingForm, JIRAFindingForm, - MergeFindings, NoteForm, - ReviewFindingForm, TypedNoteForm, ) from dojo.jira import services as jira_services diff --git a/dojo/finding_group/views.py b/dojo/finding_group/views.py index c77ddefccd5..6c6bb2907e9 100644 --- a/dojo/finding_group/views.py +++ b/dojo/finding_group/views.py @@ -13,12 +13,12 @@ from django.views.decorators.http import require_POST from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.filters import ( +from dojo.finding.queries import prefetch_for_findings +from dojo.finding.ui.filters import ( FindingFilter, FindingFilterWithoutObjectLookups, FindingGroupsFilter, ) -from dojo.finding.queries import prefetch_for_findings from dojo.forms import DeleteFindingGroupForm, EditFindingGroupForm, FindingBulkUpdateForm from dojo.jira import services as jira_services from dojo.models import Engagement, Finding, Finding_Group, GITHUB_PKey, Product diff --git a/dojo/forms.py b/dojo/forms.py index 22a8fd87f31..8d81d1510e4 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -5,7 +5,6 @@ from datetime import date, datetime from pathlib import Path -import tagulous from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout @@ -38,7 +37,6 @@ GITHUBFindingForm, GITHUBForm, ) -from dojo.jira import services as jira_services from dojo.jira.forms import ( # noqa: F401 backward compat JIRA_TEMPLATE_CHOICES, AdvancedJIRAForm, @@ -56,7 +54,6 @@ from dojo.location.models import Location from dojo.location.utils import validate_locations_to_add from dojo.models import ( - EFFORT_FOR_FIXING_CHOICES, SEVERITY_CHOICES, Announcement, Answered_Survey, @@ -76,7 +73,6 @@ FileUpload, Finding, Finding_Group, - Finding_Template, General_Survey, Note_Type, Notes, @@ -89,7 +85,6 @@ Risk_Acceptance, SLA_Configuration, System_Settings, - Test, Test_Type, TextAnswer, TextQuestion, @@ -102,7 +97,7 @@ from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types from dojo.tools.factory import get_choices_sorted, requires_file, requires_tool_type -from dojo.user.queries import get_authorized_users, get_authorized_users_for_product_and_product_type +from dojo.user.queries import get_authorized_users from dojo.user.utils import get_configuration_permissions_fields from dojo.utils import ( get_password_requirements_string, @@ -110,8 +105,7 @@ is_finding_groups_enabled, is_scan_file_too_large, ) -from dojo.validators import ImporterFileExtensionValidator, cvss3_validator, cvss4_validator, tag_validator -from dojo.widgets import TableCheckboxWidget +from dojo.validators import ImporterFileExtensionValidator, tag_validator logger = logging.getLogger(__name__) @@ -124,38 +118,6 @@ ("duplicate", "Duplicate"), ("out_of_scope", "Out of Scope")) -CVSS_CALCULATOR_URLS = { - "https://www.first.org/cvss/calculator/3-0": "CVSS3 Calculator by FIRST", - "https://www.first.org/cvss/calculator/4-0": "CVSS4 Calculator by FIRST", - "https://www.metaeffekt.com/security/cvss/calculator/": "CVSS2/3/4 Calculator by Metaeffekt", - } - - -vulnerability_ids_field = forms.CharField(max_length=5000, - required=False, - label="Vulnerability Ids", - help_text="Ids of vulnerabilities in security advisories associated with this finding. Can be Common Vulnerabilities and Exposures (CVE) or from other sources." - "You may enter one vulnerability id per line.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - -EFFORT_FOR_FIXING_INVALID_CHOICE = _("Select valid choice: Low,Medium,High") - - -class BulletListDisplayWidget(forms.Widget): - def __init__(self, urls_dict=None, *args, **kwargs): - self.urls_dict = urls_dict or {} - super().__init__(*args, **kwargs) - - def render(self, name, value, attrs=None, renderer=None): - if not self.urls_dict: - return "" - - html = '
    ' - for url, text in self.urls_dict.items(): - html += f'
  • {text}
  • ' - html += "
" - return mark_safe(html) - class MultipleSelectWithPop(forms.SelectMultiple): def render(self, name, *args, **kwargs): @@ -271,36 +233,16 @@ class Meta: fields = ["id"] -class EditFindingGroupForm(forms.ModelForm): - name = forms.CharField(max_length=255, required=True, label="Finding Group Name") - jira_issue = forms.CharField(max_length=255, required=False, label="Linked JIRA Issue", - help_text="Leave empty and check push to jira to create a new JIRA issue for this finding group.") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["push_to_jira"] = forms.BooleanField() - self.fields["push_to_jira"].required = False - self.fields["push_to_jira"].help_text = "Checking this will overwrite content of your JIRA issue, or create one." - - self.fields["push_to_jira"].label = "Push to JIRA" - - if hasattr(self.instance, "has_jira_issue") and self.instance.has_jira_issue: - jira_url = jira_services.get_url(self.instance) - self.fields["jira_issue"].initial = jira_url - self.fields["push_to_jira"].widget.attrs["checked"] = "checked" - - class Meta: - model = Finding_Group - fields = ["name"] - - -class DeleteFindingGroupForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding_Group - fields = ["id"] +# Re-exported for external consumers (finding_group/test/engagement/product views + unittests). +# The remaining finding forms live only in dojo.finding.ui.forms and are imported there by finding's own views. +from dojo.finding.ui.forms import ( # noqa: E402, F401 -- backward compat + AddFindingForm, + AddFindingsRiskAcceptanceForm, + AdHocFindingForm, + DeleteFindingGroupForm, + EditFindingGroupForm, + FindingBulkUpdateForm, +) class Authorize_User_For_ProductTypesForm(forms.Form): @@ -690,53 +632,6 @@ def clean(self): raise ValidationError(msg) -class MergeFindings(forms.ModelForm): - FINDING_ACTION = (("", "Select an Action"), ("inactive", "Inactive"), ("delete", "Delete")) - - append_description = forms.BooleanField(label="Append Description", initial=True, required=False, - help_text="Description in all findings will be appended into the merged finding.") - - add_endpoints = forms.BooleanField(label="Add Endpoints", initial=True, required=False, - help_text="Endpoints in all findings will be merged into the merged finding.") - - dynamic_raw = forms.BooleanField(label="Dynamic Scanner Raw Requests", initial=True, required=False, - help_text="Dynamic scanner raw requests in all findings will be merged into the merged finding.") - - tag_finding = forms.BooleanField(label="Add Tags", initial=True, required=False, - help_text="Tags in all findings will be merged into the merged finding.") - - mark_tag_finding = forms.BooleanField(label="Tag Merged Finding", initial=True, required=False, - help_text="Creates a tag titled 'merged' for the finding that will be merged. If the 'Finding Action' is set to 'inactive' the inactive findings will be tagged with 'merged-inactive'.") - - append_reference = forms.BooleanField(label="Append Reference", initial=True, required=False, - help_text="Reference in all findings will be appended into the merged finding.") - - finding_action = forms.ChoiceField( - required=True, - choices=FINDING_ACTION, - label="Finding Action", - help_text="The action to take on the merged finding. Set the findings to inactive or delete the findings.") - - def __init__(self, *args, **kwargs): - _ = kwargs.pop("finding") - findings = kwargs.pop("findings") - super().__init__(*args, **kwargs) - - self.fields["finding_to_merge_into"] = forms.ModelChoiceField( - queryset=findings, initial=0, required="False", label="Finding to Merge Into", help_text="Findings selected below will be merged into this finding.") - - # Exclude the finding to merge into from the findings to merge into - self.fields["findings_to_merge"] = forms.ModelMultipleChoiceField( - queryset=findings, required=True, label="Findings to Merge", - widget=forms.widgets.SelectMultiple(attrs={"size": 10}), - help_text=("Select the findings to merge.")) - self.field_order = ["finding_to_merge_into", "findings_to_merge", "append_description", "add_endpoints", "append_reference"] - - class Meta: - model = Finding - fields = ["append_description", "add_endpoints", "append_reference"] - - class EditRiskAcceptanceForm(forms.ModelForm): # unfortunately django forces us to repeat many things here. choices, default, required etc. recommendation = forms.ChoiceField(choices=Risk_Acceptance.TREATMENT_CHOICES, initial=Risk_Acceptance.TREATMENT_ACCEPT, widget=forms.RadioSelect, label="Security Recommendation") @@ -832,24 +727,6 @@ class Meta: fields = ["path"] -class AddFindingsRiskAcceptanceForm(forms.ModelForm): - - accepted_findings = forms.ModelMultipleChoiceField( - queryset=Finding.objects.none(), - required=True, - label="", - widget=TableCheckboxWidget(attrs={"size": 25}), - ) - - class Meta: - model = Risk_Acceptance - fields = ["accepted_findings"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["accepted_findings"].queryset = get_authorized_findings("edit") - - class CheckForm(forms.ModelForm): options = (("Pass", "Pass"), ("Fail", "Fail"), ("N/A", "N/A")) session_management = forms.ChoiceField(choices=options) @@ -895,715 +772,6 @@ class Meta: from dojo.test.ui.forms import TestForm # noqa: E402, F401 -- backward compat -class AddFindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - request = forms.CharField(widget=forms.Textarea, required=False) - response = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.ChoiceField( - required=False, - choices=EFFORT_FOR_FIXING_CHOICES, - error_messages={ - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - - # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", - "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "verified", "false_p", "duplicate", "out_of_scope", - "risk_accepted", "under_defect_review") - - def __init__(self, *args, **kwargs): - req_resp = kwargs.pop("req_resp") - - product = None - if "product" in kwargs: - product = kwargs.pop("product") - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS and product: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) - # TODO: Delete this after the move to Locations - elif product: - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) - else: - self.fields["endpoints"].queryset = Endpoint.objects.none() - - if req_resp: - self.fields["request"].initial = req_resp[0] - self.fields["response"].initial = req_resp[1] - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: - msg = "Active findings cannot be risk accepted." - raise forms.ValidationError(msg) - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - if errors: - raise forms.ValidationError(errors) - self.endpoints_to_add_list = endpoints_to_add_list - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", - "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date") - - -class AdHocFindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - - cvss_info = forms.CharField( - label="CVSS", - widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), - required=False, - disabled=True) - - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - request = forms.CharField(widget=forms.Textarea, required=False) - response = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.all(), required=False, - label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.ChoiceField( - required=False, - choices=EFFORT_FOR_FIXING_CHOICES, - error_messages={ - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - - # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", - "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", - "active", "verified", "false_p", "duplicate", "out_of_scope", "risk_accepted", "under_defect_review", "sla_start_date", "sla_expiration_date") - - def __init__(self, *args, **kwargs): - req_resp = kwargs.pop("req_resp") - - product = None - if "product" in kwargs: - product = kwargs.pop("product") - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS and product: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) - # TODO: Delete this after the move to Locations - elif product: - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) - else: - self.fields["endpoints"].queryset = Endpoint.objects.none() - - if req_resp: - self.fields["request"].initial = req_resp[0] - self.fields["response"].initial = req_resp[1] - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - if ((cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]): - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - self.endpoints_to_add_list = endpoints_to_add_list - - if errors: - raise forms.ValidationError(errors) - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", - "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date", - "sla_expiration_date") - - -class PromoteFindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - - cvss_info = forms.CharField( - label="CVSS", - widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), - required=False, - disabled=True) - - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(Location.objects.none(), required=False, label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - - # the onyl reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", - "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", - "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", - "false_p", "duplicate", "out_of_scope", "risk_accept", "under_defect_review") - - def __init__(self, *args, **kwargs): - product = None - if "product" in kwargs: - product = kwargs.pop("product") - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS and product: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=product) - # TODO: Delete this after the move to Locations - elif product: - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=product) - else: - self.fields["endpoints"].queryset = Endpoint.objects.none() - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - if errors: - raise forms.ValidationError(errors) - self.endpoints_to_add_list = endpoints_to_add_list - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "active", "false_p", "verified", "endpoint_status", "cve", "inherited_tags", - "duplicate", "out_of_scope", "under_review", "reviewers", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "planned_remediation_date", "planned_remediation_version", "effort_for_fixing") - - -class FindingForm(forms.ModelForm): - title = forms.CharField(max_length=1000) - group = forms.ModelChoiceField(required=False, queryset=Finding_Group.objects.none(), help_text="The Finding Group to which this finding belongs, leave empty to remove the finding from the group. Groups can only be created via Bulk Edit for now.") - date = forms.DateField(required=True, - widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) - vulnerability_ids = vulnerability_ids_field - - cvss_info = forms.CharField( - label="CVSS", - widget=BulletListDisplayWidget(CVSS_CALCULATOR_URLS), - required=False, - disabled=True) - - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(label="CVSS4 Score", required=False, max_value=10.0, min_value=0.0) - - description = forms.CharField(widget=forms.Textarea) - severity = forms.ChoiceField( - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - request = forms.CharField(widget=forms.Textarea, required=False) - response = forms.CharField(widget=forms.Textarea, required=False) - endpoints = forms.ModelMultipleChoiceField(queryset=Location.objects.none(), required=False, label="Systems / Endpoints") - endpoints_to_add = forms.CharField(max_length=5000, required=False, label="Endpoints to add", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - references = forms.CharField(widget=forms.Textarea, required=False) - - mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) - - publish_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_date = forms.DateField(widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"}), required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.ChoiceField( - required=False, - choices=EFFORT_FOR_FIXING_CHOICES, - error_messages={ - "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) - - # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", - "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", - "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", "false_p", "duplicate", - "out_of_scope", "risk_accept", "under_defect_review") - - def __init__(self, *args, **kwargs): - req_resp = None - if "req_resp" in kwargs: - req_resp = kwargs.pop("req_resp") - - self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ - else False - - super().__init__(*args, **kwargs) - - if settings.V3_FEATURE_LOCATIONS: - self.fields["endpoints"].queryset = Location.objects.filter(products__product=self.instance.test.engagement.product) - if self.instance and self.instance.pk: - self.fields["endpoints"].initial = Location.objects.filter(findings__finding=self.instance) - else: - # TODO: Delete this after the move to Locations - self.fields["endpoints"].queryset = Endpoint.objects.filter(product=self.instance.test.engagement.product) - if self.instance and self.instance.pk: - self.fields["endpoints"].initial = self.instance.endpoints.all() - - self.fields["mitigated_by"].queryset = get_authorized_users("edit") - - # do not show checkbox if finding is not accepted and simple risk acceptance is disabled - # if checked, always show to allow unaccept also with full risk acceptance enabled - # when adding from template, we don't have access to the test. quickfix for now to just hide simple risk acceptance - if not hasattr(self.instance, "test") or (not self.instance.risk_accepted and not self.instance.test.engagement.product.enable_simple_risk_acceptance): - del self.fields["risk_accepted"] - elif self.instance.risk_accepted: - self.fields["risk_accepted"].help_text = "Uncheck to unaccept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." - elif self.instance.test.engagement.product.enable_simple_risk_acceptance: - self.fields["risk_accepted"].help_text = "Check to accept the risk. Use full risk acceptance from the dropdown menu if you need advanced settings such as an expiry date." - - # self.fields['tags'].widget.choices = t - if req_resp: - self.fields["request"].initial = req_resp[0] - self.fields["response"].initial = req_resp[1] - - if self.instance.duplicate: - self.fields["duplicate"].help_text = "Original finding that is being duplicated here (readonly). Use view finding page to manage duplicate relationships. Unchecking duplicate here will reset this findings duplicate status, but will trigger deduplication logic." - else: - self.fields["duplicate"].help_text = "You can mark findings as duplicate only from the view finding page." - - self.fields["sla_start_date"].disabled = True - self.fields["sla_expiration_date"].disabled = True - - if self.can_edit_mitigated_data: - if hasattr(self, "instance"): - self.fields["mitigated"].initial = self.instance.mitigated - self.fields["mitigated_by"].initial = self.instance.mitigated_by - else: - del self.fields["mitigated"] - del self.fields["mitigated_by"] - - if not is_finding_groups_enabled() or not hasattr(self.instance, "test"): - del self.fields["group"] - else: - self.fields["group"].queryset = self.instance.test.finding_group_set.all() - self.fields["group"].initial = self.instance.finding_group - - self.endpoints_to_add_list = [] - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - - if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - if cleaned_data["active"] and "risk_accepted" in cleaned_data and cleaned_data["risk_accepted"]: - msg = "Active findings cannot be risk accepted." - raise forms.ValidationError(msg) - - if settings.V3_FEATURE_LOCATIONS: - endpoints_to_add_list, errors = validate_locations_to_add(cleaned_data["endpoints_to_add"]) - else: - # TODO: Delete this after the move to Locations - endpoints_to_add_list, errors = validate_endpoints_to_add(cleaned_data["endpoints_to_add"]) - - self.endpoints_to_add_list = endpoints_to_add_list - - if errors: - raise forms.ValidationError(errors) - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - def _post_clean(self): - super()._post_clean() - - if self.can_edit_mitigated_data: - opts = self.instance._meta - try: - opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) - opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) - except forms.ValidationError as e: - self._update_errors(e) - - class Meta: - model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", - "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "sonarqube_issue", - "endpoints", "endpoint_status") - - -class ApplyFindingTemplateForm(forms.Form): - - title = forms.CharField(max_length=1000, required=True) - - cwe = forms.IntegerField(label="CWE", required=False) - vulnerability_ids = vulnerability_ids_field - cvssv3 = forms.CharField(label="CVSSv3", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") - cvssv4 = forms.CharField(label="CVSSv4", max_length=255, required=False) - cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") - - severity = forms.ChoiceField(required=False, choices=SEVERITY_CHOICES, error_messages={"required": "Select valid choice: In Progress, On Hold, Completed", "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - - description = forms.CharField(widget=forms.Textarea) - mitigation = forms.CharField(widget=forms.Textarea, required=False) - impact = forms.CharField(widget=forms.Textarea, required=False) - references = forms.CharField(widget=forms.Textarea, required=False) - - # Remediation planning fields - fix_available = forms.BooleanField(required=False) - fix_version = forms.CharField(max_length=100, required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.CharField(max_length=99, required=False) - - # Technical details fields - steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) - severity_justification = forms.CharField(widget=forms.Textarea, required=False) - component_name = forms.CharField(max_length=500, required=False) - component_version = forms.CharField(max_length=100, required=False) - - # Notes field - notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") - - # Endpoints field - endpoints = forms.CharField(max_length=5000, required=False, - help_text="Endpoint URLs (one per line)", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - - tags = TagField(required=False, help_text="Add tags that help describe this finding template. Choose from the list or add new tags. Press Enter key to add.", initial=Finding.tags.tag_model.objects.all().order_by("name")) - - def __init__(self, template=None, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") - self.template = template - if template: - # Populate vulnerability_ids field initial value - self.fields["vulnerability_ids"].initial = "\n".join(template.vulnerability_ids) - - # Populate CVSS fields from template - if hasattr(template, "cvssv3"): - self.fields["cvssv3"].initial = template.cvssv3 - if hasattr(template, "cvssv4"): - self.fields["cvssv4"].initial = template.cvssv4 - if hasattr(template, "cvssv3_score"): - self.fields["cvssv3_score"].initial = template.cvssv3_score - if hasattr(template, "cvssv4_score"): - self.fields["cvssv4_score"].initial = template.cvssv4_score - - # Populate all other new fields from template - for field_name in ["fix_available", "fix_version", "planned_remediation_version", - "effort_for_fixing", "steps_to_reproduce", "severity_justification", - "component_name", "component_version", "notes"]: - if hasattr(template, field_name): - value = getattr(template, field_name) - if value is not None: - self.fields[field_name].initial = value - - # Populate endpoints - if hasattr(template, "endpoints"): - endpoints_value = template.endpoints - if endpoints_value: - if isinstance(endpoints_value, list): - self.fields["endpoints"].initial = "\n".join(endpoints_value) - else: - self.fields["endpoints"].initial = endpoints_value - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - def clean(self): - cleaned_data = super().clean() - - if "title" in cleaned_data: - if len(cleaned_data["title"]) <= 0: - msg = "The title is required." - raise forms.ValidationError(msg) - else: - msg = "The title is required." - raise forms.ValidationError(msg) - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - fields = ["title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", - "severity", "description", "mitigation", "impact", "references", "tags", - "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", - "steps_to_reproduce", "severity_justification", "component_name", "component_version", - "notes", "endpoints"] - order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", - "severity", "description", "impact", "steps_to_reproduce", "severity_justification", - "mitigation", "fix_available", "fix_version", "planned_remediation_version", - "effort_for_fixing", "component_name", "component_version", "references", "notes", - "endpoints", "tags") - - -class FindingTemplateForm(forms.ModelForm): - title = forms.CharField(max_length=1000, required=True) - - cwe = forms.IntegerField(label="CWE", required=False) - vulnerability_ids = vulnerability_ids_field - cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "btn btn-secondary dropdown-toggle", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) - cvssv3_score = forms.FloatField(required=False, label="CVSSv3 Score") - cvssv4 = forms.CharField(label="CVSS4 Vector", max_length=255, required=False) - cvssv4_score = forms.FloatField(required=False, label="CVSSv4 Score") - severity = forms.ChoiceField( - required=False, - choices=SEVERITY_CHOICES, - error_messages={ - "required": "Select valid choice: In Progress, On Hold, Completed", - "invalid_choice": "Select valid choice: Critical,High,Medium,Low"}) - - # Remediation planning fields - fix_available = forms.BooleanField(required=False) - fix_version = forms.CharField(max_length=100, required=False) - planned_remediation_version = forms.CharField(max_length=99, required=False) - effort_for_fixing = forms.CharField(max_length=99, required=False) - - # Technical details fields - steps_to_reproduce = forms.CharField(widget=forms.Textarea, required=False) - severity_justification = forms.CharField(widget=forms.Textarea, required=False) - component_name = forms.CharField(max_length=500, required=False) - component_version = forms.CharField(max_length=100, required=False) - - # Notes field - notes = forms.CharField(widget=forms.Textarea, required=False, help_text="Note content to add when applying template") - - # Endpoints field - endpoints = forms.CharField(max_length=5000, required=False, - help_text="Endpoint URLs (one per line)", - widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) - - field_order = ["title", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", - "description", "impact", "steps_to_reproduce", "severity_justification", "mitigation", - "fix_available", "fix_version", "planned_remediation_version", "effort_for_fixing", - "component_name", "component_version", "references", "notes", "endpoints", "tags"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["tags"].autocomplete_tags = Finding.tags.tag_model.objects.all().order_by("name") - - # Hide CVSS fields based on system settings - hide_cvss_fields_if_disabled(self) - - class Meta: - model = Finding_Template - order = ("title", "cwe", "vulnerability_ids", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "severity", "description", "impact", - "steps_to_reproduce", "severity_justification", "mitigation", "fix_available", "fix_version", - "planned_remediation_version", "effort_for_fixing", "component_name", "component_version", - "references", "notes", "endpoints", "tags") - exclude = ("numerical_severity", "is_mitigated", "last_used", "endpoint_status", "cve", "vulnerability_ids_text") - - def clean_cvssv3(self): - value = self.cleaned_data.get("cvssv3") - if value: - try: - cvss3_validator(value) - except ValidationError as e: - raise forms.ValidationError(e.messages) - return value - - def clean_cvssv4(self): - value = self.cleaned_data.get("cvssv4") - if value: - try: - cvss4_validator(value) - except ValidationError as e: - raise forms.ValidationError(e.messages) - return value - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - -class DeleteFindingTemplateForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding_Template - fields = ["id"] - - -class FindingBulkUpdateForm(forms.ModelForm): - status = forms.BooleanField(required=False) - risk_acceptance = forms.BooleanField(required=False) - risk_accept = forms.BooleanField(required=False) - risk_unaccept = forms.BooleanField(required=False) - - date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) - planned_remediation_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"class": "datepicker"})) - planned_remediation_version = forms.CharField(required=False, max_length=99, widget=forms.TextInput(attrs={"class": "form-control"})) - finding_group = forms.BooleanField(required=False) - finding_group_create = forms.BooleanField(required=False) - finding_group_create_name = forms.CharField(required=False) - finding_group_add = forms.BooleanField(required=False) - add_to_finding_group_id = forms.CharField(required=False) - finding_group_remove = forms.BooleanField(required=False) - finding_group_by = forms.BooleanField(required=False) - finding_group_by_option = forms.CharField(required=False) - - push_to_jira = forms.BooleanField(required=False) - # unlink_from_jira = forms.BooleanField(required=False) - push_to_github = forms.BooleanField(required=False) - tags = TagField(required=False, autocomplete_tags=Finding.tags.tag_model.objects.all().order_by("name")) - notes = forms.CharField(required=False, max_length=1024, widget=forms.TextInput(attrs={"class": "form-control"})) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["severity"].required = False - # we need to defer initialization to prevent multiple initializations if other forms are shown - self.fields["tags"].widget.tag_options = tagulous.models.options.TagOptions(autocomplete_settings={"width": "200px", "defer": True}) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - def clean(self): - cleaned_data = super().clean() - - if (cleaned_data["active"] or cleaned_data["verified"]) and cleaned_data["duplicate"]: - msg = "Duplicate findings cannot be verified or active" - raise forms.ValidationError(msg) - if cleaned_data["false_p"] and cleaned_data["verified"]: - msg = "False positive findings cannot be verified." - raise forms.ValidationError(msg) - if cleaned_data["active"] and cleaned_data.get("risk_acceptance") and cleaned_data.get("risk_accept"): - msg = "Active findings cannot be risk accepted." - raise forms.ValidationError(msg) - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - class Meta: - model = Finding - fields = ("severity", "date", "planned_remediation_date", "active", "verified", "false_p", "duplicate", "out_of_scope", - "under_review", "is_mitigated") - - class EditEndpointForm(forms.ModelForm): class Meta: model = Endpoint @@ -1767,167 +935,6 @@ class Meta: fields = ["id"] -class CloseFindingForm(forms.ModelForm): - entry = forms.CharField( - required=True, max_length=2400, - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for closing a finding is " - "required, please use the text area " - "below to provide documentation.")}) - - mitigated = forms.DateField(required=False, help_text="Date and time when the flaw has been fixed", widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - mitigated_by = forms.ModelChoiceField(required=False, queryset=Dojo_User.objects.none()) - false_p = forms.BooleanField(initial=False, required=False, label="False Positive") - out_of_scope = forms.BooleanField(initial=False, required=False, label="Out of Scope") - duplicate = forms.BooleanField(initial=False, required=False, label="Duplicate") - - def __init__(self, *args, **kwargs): - queryset = kwargs.pop("missing_note_types") - # must pop custom kwargs before calling parent __init__ to avoid unexpected kwarg errors - self.can_edit_mitigated_data = kwargs.pop("can_edit_mitigated_data") if "can_edit_mitigated_data" in kwargs \ - else False - super().__init__(*args, **kwargs) - if len(queryset) == 0: - self.fields["note_type"].widget = forms.HiddenInput() - else: - self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True) - - if self.can_edit_mitigated_data: - self.fields["mitigated_by"].queryset = get_authorized_users("edit") - self.fields["mitigated"].initial = self.instance.mitigated - self.fields["mitigated_by"].initial = self.instance.mitigated_by - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - def _post_clean(self): - super()._post_clean() - - if self.can_edit_mitigated_data: - opts = self.instance._meta - if not self.cleaned_data.get("active"): - try: - opts.get_field("mitigated").save_form_data(self.instance, self.cleaned_data.get("mitigated")) - opts.get_field("mitigated_by").save_form_data(self.instance, self.cleaned_data.get("mitigated_by")) - except forms.ValidationError as e: - self._update_errors(e) - - class Meta: - model = Notes - fields = ["note_type", "entry", "mitigated", "mitigated_by", "false_p", "out_of_scope", "duplicate"] - - -class EditPlannedRemediationDateFindingForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - finding = None - if "finding" in kwargs: - finding = kwargs.pop("finding") - - super().__init__(*args, **kwargs) - - self.fields["planned_remediation_date"].required = True - self.fields["planned_remediation_date"].widget = forms.DateInput(attrs={"class": "datepicker"}) - - if finding is not None: - self.fields["planned_remediation_date"].initial = finding.planned_remediation_date - - class Meta: - model = Finding - fields = ["planned_remediation_date"] - - -class DefectFindingForm(forms.ModelForm): - CLOSE_CHOICES = (("Close Finding", "Close Finding"), ("Not Fixed", "Not Fixed")) - defect_choice = forms.ChoiceField(required=True, choices=CLOSE_CHOICES) - - entry = forms.CharField( - required=True, max_length=2400, - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for closing a finding is " - "required, please use the text area " - "below to provide documentation.")}) - - class Meta: - model = Notes - fields = ["entry"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - -class ClearFindingReviewForm(forms.ModelForm): - entry = forms.CharField( - required=True, max_length=2400, - help_text="Please provide a message.", - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for clearing a review is " - "required, please use the text area " - "below to provide documentation.")}) - - class Meta: - model = Finding - fields = ["active", "verified", "false_p", "out_of_scope", "duplicate", "is_mitigated"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - -class ReviewFindingForm(forms.Form): - reviewers = forms.MultipleChoiceField( - help_text=( - "Select all users who can review Finding. Only users with " - "at least write permission to this finding can be selected"), - required=False, - ) - entry = forms.CharField( - required=True, max_length=2400, - help_text="Please provide a message for reviewers.", - widget=forms.Textarea, label="Notes:", - error_messages={"required": ("The reason for requesting a review is " - "required, please use the text area " - "below to provide documentation.")}) - allow_all_reviewers = forms.BooleanField( - required=False, - label="Allow All Eligible Reviewers", - help_text=("Checking this box will allow any user in the drop down " - "above to provide a review for this finding")) - - def __init__(self, *args, **kwargs): - finding = kwargs.pop("finding", None) - kwargs.pop("user", None) - super().__init__(*args, **kwargs) - # Get the list of users - if finding is not None: - users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit") - else: - users = get_authorized_users("edit").filter(is_active=True) - # Save a copy of the original query to be used in the validator - self.reviewer_queryset = users - # Set the users in the form - self.fields["reviewers"].choices = self._get_choices(self.reviewer_queryset) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - @staticmethod - def _get_choices(queryset): - return [(item.pk, item.get_full_name()) for item in queryset] - - def clean(self): - cleaned_data = super().clean() - if cleaned_data.get("allow_all_reviewers", False): - cleaned_data["reviewers"] = [user.id for user in self.reviewer_queryset] - if len(cleaned_data.get("reviewers", [])) == 0: - msg = "Please select at least one user from the reviewers list" - raise ValidationError(msg) - return cleaned_data - - class Meta: - fields = ["reviewers", "entry", "allow_all_reviewers"] - - class WeeklyMetricsForm(forms.Form): dates = forms.ChoiceField() @@ -2205,36 +1212,6 @@ class CustomReportOptionsForm(forms.Form): report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) -class DeleteFindingForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding - fields = ["id"] - - -class CopyFindingForm(forms.Form): - test = forms.ModelChoiceField( - required=True, - queryset=Test.objects.none(), - error_messages={"required": "*"}) - - def __init__(self, *args, **kwargs): - authorized_lists = kwargs.pop("tests", None) - super().__init__(*args, **kwargs) - self.fields["test"].queryset = authorized_lists - - -class FindingFormID(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Finding - fields = ("id",) - - class Benchmark_Product_SummaryForm(forms.ModelForm): class Meta: @@ -2948,28 +1925,3 @@ def set_permission(self, codename): else: msg = "Neither user or group are set" raise Exception(msg) - - -def hide_cvss_fields_if_disabled(form_instance): - """Hide CVSS fields based on system settings.""" - enable_cvss3 = get_system_setting("enable_cvss3_display", True) - enable_cvss4 = get_system_setting("enable_cvss4_display", True) - - # Hide CVSS3 fields if disabled - if not enable_cvss3: - if "cvssv3" in form_instance.fields: - del form_instance.fields["cvssv3"] - if "cvssv3_score" in form_instance.fields: - del form_instance.fields["cvssv3_score"] - - # Hide CVSS4 fields if disabled - if not enable_cvss4: - if "cvssv4" in form_instance.fields: - del form_instance.fields["cvssv4"] - if "cvssv4_score" in form_instance.fields: - del form_instance.fields["cvssv4_score"] - - # If both are disabled, hide all CVSS related fields - if not enable_cvss3 and not enable_cvss4: - if "cvss_info" in form_instance.fields: - del form_instance.fields["cvss_info"] diff --git a/dojo/metrics/utils.py b/dojo/metrics/utils.py index f72e5d71063..ff12b94f83d 100644 --- a/dojo/metrics/utils.py +++ b/dojo/metrics/utils.py @@ -20,11 +20,13 @@ from dojo.filters import ( MetricsEndpointFilter, MetricsEndpointFilterWithoutObjectLookups, - MetricsFindingFilter, - MetricsFindingFilterWithoutObjectLookups, ) from dojo.finding.helper import ACCEPTED_FINDINGS_QUERY, CLOSED_FINDINGS_QUERY, OPEN_FINDINGS_QUERY from dojo.finding.queries import get_authorized_findings +from dojo.finding.ui.filters import ( + MetricsFindingFilter, + MetricsFindingFilterWithoutObjectLookups, +) from dojo.models import Endpoint_Status, Finding, Product_Type from dojo.utils import ( get_system_setting, diff --git a/dojo/product/ui/views.py b/dojo/product/ui/views.py index a5ef6acbd62..5d6924ae033 100644 --- a/dojo/product/ui/views.py +++ b/dojo/product/ui/views.py @@ -38,6 +38,8 @@ from dojo.filters import ( MetricsEndpointFilter, MetricsEndpointFilterWithoutObjectLookups, +) +from dojo.finding.ui.filters import ( MetricsFindingFilter, MetricsFindingFilterWithoutObjectLookups, ) diff --git a/dojo/reports/views.py b/dojo/reports/views.py index f346dd89900..b2f38c70dba 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -21,10 +21,12 @@ EndpointFilter, EndpointFilterWithoutObjectLookups, EndpointReportFilter, +) +from dojo.finding.queries import get_authorized_findings +from dojo.finding.ui.filters import ( ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.queries import get_authorized_findings from dojo.finding.views import BaseListFindings from dojo.forms import ReportOptionsForm from dojo.labels import get_labels diff --git a/dojo/reports/widgets.py b/dojo/reports/widgets.py index aa88d9a4884..2620ecf23d2 100644 --- a/dojo/reports/widgets.py +++ b/dojo/reports/widgets.py @@ -15,6 +15,8 @@ from dojo.filters import ( EndpointFilter, EndpointFilterWithoutObjectLookups, +) +from dojo.finding.ui.filters import ( ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) diff --git a/dojo/search/views.py b/dojo/search/views.py index e7022ede68d..13dd70e1ed1 100644 --- a/dojo/search/views.py +++ b/dojo/search/views.py @@ -12,8 +12,8 @@ from dojo.endpoint.queries import get_authorized_endpoints from dojo.endpoint.views import prefetch_for_endpoints from dojo.engagement.queries import get_authorized_engagements -from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups from dojo.finding.queries import get_authorized_findings, get_authorized_vulnerability_ids, prefetch_for_findings +from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups from dojo.forms import FindingBulkUpdateForm, SimpleSearchForm from dojo.location.queries import get_authorized_locations, prefetch_for_locations from dojo.models import Engagement, Finding, Finding_Template, Languages, Product, Test diff --git a/dojo/test/ui/views.py b/dojo/test/ui/views.py index 7d56d4e6587..55ef772e8a0 100644 --- a/dojo/test/ui/views.py +++ b/dojo/test/ui/views.py @@ -25,8 +25,8 @@ import dojo.finding.helper as finding_helper from dojo.authorization.authorization import user_has_permission_or_403 from dojo.engagement.queries import get_authorized_engagements -from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter from dojo.finding.queries import prefetch_for_findings +from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter from dojo.finding.views import find_available_notetypes from dojo.forms import ( AddFindingForm, diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py index 31b3fd5ce95..568c6d3d4ed 100644 --- a/unittests/test_filter_finding_mitigation.py +++ b/unittests/test_filter_finding_mitigation.py @@ -3,7 +3,8 @@ from django.test import TestCase from django.utils import timezone -from dojo.filters import ApiFindingFilter, FindingFilterHelper +from dojo.filters import ApiFindingFilter +from dojo.finding.ui.filters import FindingFilterHelper from dojo.models import ( Dojo_User, Engagement, diff --git a/unittests/test_finding_group_filter_context.py b/unittests/test_finding_group_filter_context.py index f9811aa5942..6af9a7028b0 100644 --- a/unittests/test_finding_group_filter_context.py +++ b/unittests/test_finding_group_filter_context.py @@ -1,6 +1,6 @@ from django.utils.timezone import now -from dojo.filters import FindingFilter, FindingFilterWithoutObjectLookups +from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups from dojo.models import ( Dojo_User, Engagement, diff --git a/unittests/test_test_type_active_toggle.py b/unittests/test_test_type_active_toggle.py index 1d0e8a55644..dd2e98d3f04 100644 --- a/unittests/test_test_type_active_toggle.py +++ b/unittests/test_test_type_active_toggle.py @@ -1,7 +1,7 @@ from django.test import TestCase -from dojo.filters import FindingFilter +from dojo.finding.ui.filters import FindingFilter from dojo.models import Test_Type from dojo.utils import get_visible_scan_types From d188220462cc36a7d24674338a74a4285e7e2c2f Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 22:34:53 +0200 Subject: [PATCH 26/40] refactor(finding): move views + urls into dojo/finding/ui/ [finding Phase 5] --- dojo/api_v2/views.py | 2 +- dojo/engagement/ui/views.py | 2 +- dojo/finding/{ => ui}/urls.py | 2 +- dojo/finding/{ => ui}/views.py | 0 dojo/reports/views.py | 2 +- dojo/test/ui/views.py | 2 +- dojo/urls.py | 2 +- unittests/test_apply_finding_template.py | 2 +- unittests/test_false_positive_history_logic.py | 2 +- 9 files changed, 8 insertions(+), 8 deletions(-) rename dojo/finding/{ => ui}/urls.py (99%) rename dojo/finding/{ => ui}/views.py (100%) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index bdcaa185f5d..f9f6e60c019 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -67,7 +67,7 @@ ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.views import ( +from dojo.finding.ui.views import ( duplicate_cluster, reset_finding_duplicate_status_internal, set_finding_as_original_internal, diff --git a/dojo/engagement/ui/views.py b/dojo/engagement/ui/views.py index 4c0978dea56..475ff9cf5f9 100644 --- a/dojo/engagement/ui/views.py +++ b/dojo/engagement/ui/views.py @@ -54,7 +54,7 @@ ) from dojo.engagement.ui.forms import DeleteEngagementForm, EngForm from dojo.finding.helper import NOT_ACCEPTED_FINDINGS_QUERY -from dojo.finding.views import find_available_notetypes +from dojo.finding.ui.views import find_available_notetypes from dojo.forms import ( AddFindingsRiskAcceptanceForm, CheckForm, diff --git a/dojo/finding/urls.py b/dojo/finding/ui/urls.py similarity index 99% rename from dojo/finding/urls.py rename to dojo/finding/ui/urls.py index 96bceeec4e8..dd3929cf19b 100644 --- a/dojo/finding/urls.py +++ b/dojo/finding/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.finding import views +from dojo.finding.ui import views urlpatterns = [ # CRUD operations diff --git a/dojo/finding/views.py b/dojo/finding/ui/views.py similarity index 100% rename from dojo/finding/views.py rename to dojo/finding/ui/views.py diff --git a/dojo/reports/views.py b/dojo/reports/views.py index b2f38c70dba..0a78787ec5c 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -27,7 +27,7 @@ ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.views import BaseListFindings +from dojo.finding.ui.views import BaseListFindings from dojo.forms import ReportOptionsForm from dojo.labels import get_labels from dojo.location.models import Location diff --git a/dojo/test/ui/views.py b/dojo/test/ui/views.py index 55ef772e8a0..b89c53cd4e3 100644 --- a/dojo/test/ui/views.py +++ b/dojo/test/ui/views.py @@ -27,7 +27,7 @@ from dojo.engagement.queries import get_authorized_engagements from dojo.finding.queries import prefetch_for_findings from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups, TemplateFindingFilter -from dojo.finding.views import find_available_notetypes +from dojo.finding.ui.views import find_available_notetypes from dojo.forms import ( AddFindingForm, FindingBulkUpdateForm, diff --git a/dojo/urls.py b/dojo/urls.py index c7f6ac68e52..a87b31a3913 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -58,7 +58,7 @@ from dojo.endpoint.urls import urlpatterns as endpoint_urls from dojo.engagement.api.urls import add_engagement_urls from dojo.engagement.ui.urls import urlpatterns as eng_urls -from dojo.finding.urls import urlpatterns as finding_urls +from dojo.finding.ui.urls import urlpatterns as finding_urls from dojo.finding_group.urls import urlpatterns as finding_group_urls from dojo.github.ui.urls import urlpatterns as github_urls from dojo.home.urls import urlpatterns as home_urls diff --git a/unittests/test_apply_finding_template.py b/unittests/test_apply_finding_template.py index 51404069ac3..468dacbd36f 100644 --- a/unittests/test_apply_finding_template.py +++ b/unittests/test_apply_finding_template.py @@ -13,8 +13,8 @@ Product_Member, Role, ) -from dojo.finding import views from dojo.finding.helper import save_endpoints_template, save_vulnerability_ids_template +from dojo.finding.ui import views from dojo.models import ( Dojo_User, Engagement, diff --git a/unittests/test_false_positive_history_logic.py b/unittests/test_false_positive_history_logic.py index 8748239bedd..5975348e14e 100644 --- a/unittests/test_false_positive_history_logic.py +++ b/unittests/test_false_positive_history_logic.py @@ -6,7 +6,7 @@ from django.conf import settings from dojo.finding.deduplication import do_false_positive_history_batch -from dojo.finding.views import EditFinding +from dojo.finding.ui.views import EditFinding from dojo.location.models import Location, LocationFindingReference from dojo.models import ( Endpoint, From 34ba2738e6fcd02ebb6a817969560845527a2a5a Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Sun, 7 Jun 2026 23:01:24 +0200 Subject: [PATCH 27/40] refactor(finding): extract API layer into dojo/finding/api/ [finding Phase 6,7,8,9] --- dojo/api_v2/serializers.py | 677 +--------------- dojo/api_v2/views.py | 777 ------------------ dojo/filters.py | 224 ------ dojo/finding/api/__init__.py | 1 + dojo/finding/api/filters.py | 247 ++++++ dojo/finding/api/serializer.py | 737 +++++++++++++++++ dojo/finding/api/urls.py | 7 + dojo/finding/api/views.py | 833 ++++++++++++++++++++ dojo/test/api/serializer.py | 4 +- dojo/urls.py | 6 +- unittests/test_filter_finding_mitigation.py | 2 +- unittests/test_rest_framework.py | 3 +- 12 files changed, 1856 insertions(+), 1662 deletions(-) create mode 100644 dojo/finding/api/__init__.py create mode 100644 dojo/finding/api/filters.py create mode 100644 dojo/finding/api/serializer.py create mode 100644 dojo/finding/api/urls.py create mode 100644 dojo/finding/api/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index fd91c874775..f6fc378417e 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -23,23 +23,14 @@ from rest_framework.exceptions import ValidationError as RestFrameworkValidationError from rest_framework.fields import DictField -import dojo.finding.helper as finding_helper import dojo.risk_acceptance.helper as ra_helper -from dojo.authorization.authorization import user_has_permission -from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import -from dojo.finding.helper import ( - save_endpoints_template, - save_vulnerability_ids, - save_vulnerability_ids_template, -) from dojo.finding.queries import get_authorized_findings from dojo.importers.auto_create_context import AutoCreateContextManager from dojo.importers.base_importer import BaseImporter from dojo.importers.default_importer import DefaultImporter from dojo.importers.default_reimporter import DefaultReImporter -from dojo.jira import services as jira_services -from dojo.location.models import Location, LocationFindingReference +from dojo.location.models import Location from dojo.models import ( IMPORT_ACTIONS, SEVERITIES, @@ -58,7 +49,6 @@ FileUpload, Finding, Finding_Group, - Finding_Template, Language_Type, Languages, Network_Locations, @@ -67,7 +57,6 @@ Notes, Product, Product_API_Scan_Configuration, - Product_Type, Regulation, Risk_Acceptance, SLA_Configuration, @@ -75,16 +64,13 @@ Sonarqube_Issue_Transition, System_Settings, Test, - Test_Type, Tool_Configuration, Tool_Product_Settings, Tool_Type, User, UserContactInfo, - Vulnerability_Id, get_current_date, ) -from dojo.notifications.helper import async_create_notification from dojo.product_announcements import ( LargeScanSizeProductAnnouncement, ScanTypeProductAnnouncement, @@ -94,7 +80,6 @@ requires_file, requires_tool_type, ) -from dojo.user.queries import get_authorized_users from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import is_scan_file_too_large from dojo.validators import ImporterFileExtensionValidator, tag_validator @@ -956,14 +941,6 @@ class Meta: fields = "__all__" -class FindingGroupSerializer(serializers.ModelSerializer): - jira_issue = JIRAIssueSerializer(read_only=True, allow_null=True) - - class Meta: - model = Finding_Group - fields = ("id", "name", "test", "jira_issue") - - from dojo.test.api.serializer import TestSerializer # noqa: E402 -- backward compat re-export @@ -1076,550 +1053,6 @@ class Meta: fields = "__all__" -class FindingMetaSerializer(serializers.ModelSerializer): - class Meta: - model = DojoMeta - fields = ("name", "value") - - -class FindingProdTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Product_Type - fields = ["id", "name"] - - -class FindingProductSerializer(serializers.ModelSerializer): - prod_type = FindingProdTypeSerializer(required=False) - - class Meta: - model = Product - fields = ["id", "name", "prod_type"] - - -class FindingEngagementSerializer(serializers.ModelSerializer): - product = FindingProductSerializer(required=False) - - class Meta: - model = Engagement - fields = [ - "id", - "name", - "description", - "product", - "target_start", - "target_end", - "branch_tag", - "engagement_type", - "build_id", - "commit_hash", - "version", - "created", - "updated", - ] - - -class FindingEnvironmentSerializer(serializers.ModelSerializer): - class Meta: - model = Development_Environment - fields = ["id", "name"] - - -class FindingTestTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Test_Type - fields = ["id", "name"] - - -class FindingTestSerializer(serializers.ModelSerializer): - engagement = FindingEngagementSerializer(required=False) - environment = FindingEnvironmentSerializer(required=False) - test_type = FindingTestTypeSerializer(required=False) - - class Meta: - model = Test - fields = [ - "id", - "title", - "test_type", - "engagement", - "environment", - "branch_tag", - "build_id", - "commit_hash", - "version", - ] - - -class FindingRelatedFieldsSerializer(serializers.Serializer): - test = serializers.SerializerMethodField() - jira = serializers.SerializerMethodField() - - @extend_schema_field(FindingTestSerializer) - def get_test(self, obj): - return FindingTestSerializer(read_only=True).to_representation( - obj.test, - ) - - @extend_schema_field(JIRAIssueSerializer) - def get_jira(self, obj): - issue = jira_services.get_issue(obj) - if issue is None: - return None - return JIRAIssueSerializer(read_only=True).to_representation(issue) - - -class VulnerabilityIdSerializer(serializers.ModelSerializer): - class Meta: - model = Vulnerability_Id - fields = ["vulnerability_id"] - - -class FindingSerializer(serializers.ModelSerializer): - mitigated = serializers.DateTimeField(required=False, allow_null=True) - mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) - tags = TagListSerializerField(required=False) - request_response = serializers.SerializerMethodField() - accepted_risks = serializers.SerializerMethodField() - push_to_jira = serializers.BooleanField(default=False) - found_by = serializers.PrimaryKeyRelatedField( - queryset=Test_Type.objects.all(), many=True, - ) - age = serializers.IntegerField(read_only=True) - sla_days_remaining = serializers.IntegerField(read_only=True, allow_null=True) - finding_meta = FindingMetaSerializer(read_only=True, many=True) - related_fields = serializers.SerializerMethodField(allow_null=True) - # for backwards compatibility - jira_creation = serializers.SerializerMethodField(read_only=True, allow_null=True) - jira_change = serializers.SerializerMethodField(read_only=True, allow_null=True) - display_status = serializers.SerializerMethodField() - finding_groups = FindingGroupSerializer( - source="finding_group_set", many=True, read_only=True, - ) - vulnerability_ids = VulnerabilityIdSerializer( - source="vulnerability_id_set", many=True, required=False, - ) - reporter = serializers.PrimaryKeyRelatedField( - required=False, queryset=User.objects.all(), - ) - endpoints = serializers.PrimaryKeyRelatedField( - source="locations", - many=True, - required=False, - queryset=LocationFindingReference.objects.all(), - ) - - class Meta: - model = Finding - exclude = ( - "cve", - "inherited_tags", - ) - - # TODO: Delete this after the move to Locations - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not settings.V3_FEATURE_LOCATIONS: - self.fields["endpoints"] = serializers.PrimaryKeyRelatedField( - many=True, required=False, queryset=Endpoint.objects.all(), - ) - - @extend_schema_field(RiskAcceptanceSerializer(many=True)) - def get_accepted_risks(self, obj): - request = self.context.get("request") - if request is None: - return [] - if not user_has_permission(request.user, obj, "edit"): - return [] - return RiskAcceptanceSerializer( - obj.risk_acceptance_set.all(), many=True, - ).data - - @extend_schema_field(serializers.DateTimeField()) - def get_jira_creation(self, obj): - return jira_services.get_creation(obj) - - @extend_schema_field(serializers.DateTimeField()) - def get_jira_change(self, obj): - return jira_services.get_change(obj) - - @extend_schema_field(FindingRelatedFieldsSerializer) - def get_related_fields(self, obj): - request = self.context.get("request", None) - if request is None: - return None - - query_params = request.query_params - if query_params.get("related_fields", "false") == "true": - return FindingRelatedFieldsSerializer( - required=False, - ).to_representation(obj) - return None - - def get_display_status(self, obj) -> str: - return obj.status() - - def process_risk_acceptance(self, data): - is_risk_accepted = data.get("risk_accepted") - # Do not take any action if the `risk_accepted` was not passed - if not isinstance(is_risk_accepted, bool): - return - # Determine how to proceed based on the value of `risk_accepted` - if is_risk_accepted and not self.instance.risk_accepted and self.instance.test.engagement.product.enable_simple_risk_acceptance and not data.get("active", False): - ra_helper.simple_risk_accept(self.context["request"].user, self.instance) - elif not is_risk_accepted and self.instance.risk_accepted: # turning off risk_accepted - ra_helper.risk_unaccept(self.context["request"].user, self.instance) - - # Overriding this to push add Push to JIRA functionality - def update(self, instance, validated_data): - # push_all_issues already checked in api views.py - push_to_jira = validated_data.pop("push_to_jira") - - # Save vulnerability ids and pop them - parsed_vulnerability_ids = [] - if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): - logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) - parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) - logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) - validated_data["cve"] = parsed_vulnerability_ids[0] - - # Save the reporter on the finding - if reporter_id := validated_data.get("reporter"): - instance.reporter = reporter_id - - # Persist vulnerability IDs first so model save computes hash including them (if there is no hash yet) - # we can't pass unsaved_vulnerabilitiy_ids to super.update() - if parsed_vulnerability_ids: - save_vulnerability_ids(instance, parsed_vulnerability_ids) - - # Get found_by from validated_data - found_by = validated_data.pop("found_by", None) - # Handle updates to found_by data - if found_by: - instance.found_by.set(found_by) - # If there is no argument entered for found_by, the user would like to clear out the values on the Finding's found_by field - # Findings still maintain original found_by value associated with their test - # In the event the user does not supply the found_by field at all, we do not modify it - elif isinstance(found_by, list) and len(found_by) == 0: - instance.found_by.clear() - - locations = None - if settings.V3_FEATURE_LOCATIONS: - locations = validated_data.pop("locations", None) - - instance = super().update( - instance, validated_data, - ) - - if settings.V3_FEATURE_LOCATIONS and locations is not None: - for location_ref in instance.locations.all(): - location_ref.location.disassociate_from_finding(instance) - for location_ref in locations: - location_ref.location.associate_with_finding(instance) - - if push_to_jira or jira_services.is_keep_in_sync(instance): - # Push synchronously so that we can see jira errors in real time - success, message = jira_services.push(instance, force_sync=True) - if not success: - raise serializers.ValidationError(message) - - return instance - - def validate(self, data): - # Enforce mitigated metadata editability (only when non-null values are provided) - attempting_to_set_mitigated = any( - (field in data) and (data.get(field) is not None) - for field in ["mitigated", "mitigated_by"] - ) - user = getattr(self.context.get("request", None), "user", None) - if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): - errors = {} - if ("mitigated" in data) and (data.get("mitigated") is not None): - errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] - if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): - errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] - if errors: - raise serializers.ValidationError(errors) - - if self.context["request"].method == "PATCH": - is_active = data.get("active", self.instance.active) - is_verified = data.get("verified", self.instance.verified) - is_duplicate = data.get("duplicate", self.instance.duplicate) - is_false_p = data.get("false_p", self.instance.false_p) - is_risk_accepted = data.get( - "risk_accepted", self.instance.risk_accepted, - ) - else: - is_active = data.get("active", True) - is_verified = data.get("verified", False) - is_duplicate = data.get("duplicate", False) - is_false_p = data.get("false_p", False) - is_risk_accepted = data.get("risk_accepted", False) - - if (is_active or is_verified) and is_duplicate: - msg = "Duplicate findings cannot be verified or active" - raise serializers.ValidationError(msg) - if is_false_p and is_verified: - msg = "False positive findings cannot be verified." - raise serializers.ValidationError(msg) - - if is_risk_accepted and not self.instance.risk_accepted: - if ( - not self.instance.test.engagement.product.enable_simple_risk_acceptance - ): - msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." - raise serializers.ValidationError(msg) - - if is_active and is_risk_accepted: - msg = "Active findings cannot be risk accepted." - raise serializers.ValidationError(msg) - - # assuming we made it past the validations,call risk acceptance properly to make sure notes, etc get created - # doing it here instead of in update because update doesn't know if the value changed - self.process_risk_acceptance(data) - - return data - - def validate_severity(self, value: str) -> str: - if value not in SEVERITIES: - msg = f"Severity must be one of the following: {SEVERITIES}" - raise serializers.ValidationError(msg) - return value - - def build_relational_field(self, field_name, relation_info): - if field_name == "notes": - return NoteSerializer, {"many": True, "read_only": True} - return super().build_relational_field(field_name, relation_info) - - @extend_schema_field(BurpRawRequestResponseSerializer) - def get_request_response(self, obj): - # Not necessarily Burp scan specific - these are just any request/response pairs - burp_req_resp = obj.burprawrequestresponse_set.all() - var = settings.MAX_REQRESP_FROM_API - if var > -1: - burp_req_resp = burp_req_resp[:var] - burp_list = [] - for burp in burp_req_resp: - request = burp.get_request() - response = burp.get_response() - burp_list.append({"request": request, "response": response}) - serialized_burps = BurpRawRequestResponseSerializer( - {"req_resp": burp_list}, - ) - return serialized_burps.data - - -class FindingCreateSerializer(serializers.ModelSerializer): - mitigated = serializers.DateTimeField(required=False, allow_null=True) - mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) - notes = serializers.PrimaryKeyRelatedField( - read_only=True, allow_null=True, required=False, many=True, - ) - test = serializers.PrimaryKeyRelatedField(queryset=Test.objects.all()) - thread_id = serializers.IntegerField(default=0) - found_by = serializers.PrimaryKeyRelatedField( - queryset=Test_Type.objects.all(), many=True, - ) - url = serializers.CharField(allow_null=True, default=None) - tags = TagListSerializerField(required=False) - push_to_jira = serializers.BooleanField(default=False) - vulnerability_ids = VulnerabilityIdSerializer( - source="vulnerability_id_set", many=True, required=False, - ) - reporter = serializers.PrimaryKeyRelatedField( - required=False, queryset=User.objects.all(), - ) - - class Meta: - model = Finding - exclude = ( - "cve", - "inherited_tags", - ) - extra_kwargs = { - "active": {"required": True}, - "verified": {"required": True}, - } - - # Overriding this to push add Push to JIRA functionality - def create(self, validated_data): - logger.debug("Creating finding with validated data: %s", validated_data) - push_to_jira = validated_data.pop("push_to_jira", False) - notes = validated_data.pop("notes", None) - found_by = validated_data.pop("found_by", None) - reviewers = validated_data.pop("reviewers", None) - # Process the vulnerability IDs specially - parsed_vulnerability_ids = [] - if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): - logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) - parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) - logger.debug("PARSED_VULNERABILITY_IDST: %s", parsed_vulnerability_ids) - logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) - validated_data["cve"] = parsed_vulnerability_ids[0] - # validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids - - # super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly. - new_finding = Finding(**validated_data) - new_finding.unsaved_vulnerability_ids = parsed_vulnerability_ids or [] - new_finding.save() - - logger.debug(f"New finding CVE: {new_finding.cve}") - - # Deal with all of the many to many things - if notes: - new_finding.notes.set(notes) - if found_by: - new_finding.found_by.set(found_by) - if reviewers: - new_finding.reviewers.set(reviewers) - if parsed_vulnerability_ids: - save_vulnerability_ids(new_finding, parsed_vulnerability_ids) - - if push_to_jira: - jira_services.push(new_finding) - - # Create a notification - dojo_dispatch_task( - async_create_notification, - event="finding_added", - title=_("Addition of %s") % new_finding.title, - finding_id=new_finding.id, - description=_('Finding "%s" was added by %s') % (new_finding.title, new_finding.reporter), - url=reverse("view_finding", args=(new_finding.id,)), - icon="exclamation-triangle", - ) - - return new_finding - - def validate(self, data): - # Ensure mitigated fields are only set when editable is enabled (ignore nulls) - attempting_to_set_mitigated = any( - (field in data) and (data.get(field) is not None) - for field in ["mitigated", "mitigated_by"] - ) - user = getattr(getattr(self.context, "request", None), "user", None) - if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): - errors = {} - if ("mitigated" in data) and (data.get("mitigated") is not None): - errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] - if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): - errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] - if errors: - raise serializers.ValidationError(errors) - - if "reporter" not in data: - request = self.context["request"] - data["reporter"] = request.user - - if (data.get("active") or data.get("verified")) and data.get( - "duplicate", - ): - msg = "Duplicate findings cannot be verified or active" - raise serializers.ValidationError(msg) - if data.get("false_p") and data.get("verified"): - msg = "False positive findings cannot be verified." - raise serializers.ValidationError(msg) - - if "risk_accepted" in data and data.get("risk_accepted"): - test = data.get("test") - # test = Test.objects.get(id=test_id) - if not test.engagement.product.enable_simple_risk_acceptance: - msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." - raise serializers.ValidationError(msg) - - if ( - data.get("active") - and "risk_accepted" in data - and data.get("risk_accepted") - ): - msg = "Active findings cannot be risk accepted." - raise serializers.ValidationError(msg) - - return data - - def validate_severity(self, value: str) -> str: - if value not in SEVERITIES: - msg = f"Severity must be one of the following: {SEVERITIES}" - raise serializers.ValidationError(msg) - return value - - -class FindingTemplateSerializer(serializers.ModelSerializer): - tags = TagListSerializerField(required=False) - vulnerability_ids = serializers.SerializerMethodField() - endpoints = serializers.SerializerMethodField() - - class Meta: - model = Finding_Template - exclude = ("cve", "vulnerability_ids_text") - - @extend_schema_field(serializers.ListField(child=serializers.CharField())) - def get_vulnerability_ids(self, obj): - """Return vulnerability IDs as a list of strings.""" - return obj.vulnerability_ids - - @extend_schema_field(serializers.ListField(child=serializers.CharField())) - def get_endpoints(self, obj): - """Return endpoints as a list of URL strings.""" - return obj.endpoints if hasattr(obj, "endpoints") else [] - - def create(self, validated_data): - - # Handle vulnerability_ids if provided as list - vulnerability_ids = None - if "vulnerability_ids" in self.initial_data: - vulnerability_ids = self.initial_data.get("vulnerability_ids", []) - if isinstance(vulnerability_ids, str): - # If it's a string, split by newlines - vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] - elif not isinstance(vulnerability_ids, list): - vulnerability_ids = [] - - # Handle endpoints if provided as list - endpoint_urls = None - if "endpoints" in self.initial_data: - endpoint_urls = self.initial_data.get("endpoints", []) - if isinstance(endpoint_urls, str): - # If it's a string, split by newlines - endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] - elif not isinstance(endpoint_urls, list): - endpoint_urls = [] - - new_finding_template = super().create( - validated_data, - ) - - # Save vulnerability IDs using helper - if vulnerability_ids: - save_vulnerability_ids_template(new_finding_template, vulnerability_ids) - - # Save endpoints using helper - if endpoint_urls: - save_endpoints_template(new_finding_template, endpoint_urls) - - return new_finding_template - - def update(self, instance, validated_data): - # Handle vulnerability_ids if provided - if "vulnerability_ids" in self.initial_data: - vulnerability_ids = self.initial_data.get("vulnerability_ids", []) - if isinstance(vulnerability_ids, str): - vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] - elif not isinstance(vulnerability_ids, list): - vulnerability_ids = [] - save_vulnerability_ids_template(instance, vulnerability_ids) - - # Handle endpoints if provided - if "endpoints" in self.initial_data: - endpoint_urls = self.initial_data.get("endpoints", []) - if isinstance(endpoint_urls, str): - endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] - elif not isinstance(endpoint_urls, list): - endpoint_urls = [] - save_endpoints_template(instance, endpoint_urls) - - return super().update(instance, validated_data) - - class CommonImportScanSerializer(serializers.Serializer): scan_date = serializers.DateField( required=False, @@ -2272,87 +1705,6 @@ class Meta: fields = "__all__" -class FindingToNotesSerializer(serializers.Serializer): - finding_id = serializers.PrimaryKeyRelatedField( - queryset=Finding.objects.all(), many=False, allow_null=True, - ) - notes = NoteSerializer(many=True) - - -class FindingToFilesSerializer(serializers.Serializer): - finding_id = serializers.PrimaryKeyRelatedField( - queryset=Finding.objects.all(), many=False, allow_null=True, - ) - files = FileSerializer(many=True) - - def to_representation(self, data): - finding = data.get("finding_id") - files = data.get("files") - new_files = [{ - "id": file.id, - "file": "{site_url}/{file_access_url}".format( - site_url=settings.SITE_URL, - file_access_url=file.get_accessible_url( - finding, finding.id, - ), - ), - "title": file.title, - } for file in files] - return {"finding_id": finding.id, "files": new_files} - - -class FindingCloseSerializer(serializers.ModelSerializer): - is_mitigated = serializers.BooleanField(required=False) - mitigated = serializers.DateTimeField(required=False) - false_p = serializers.BooleanField(required=False) - out_of_scope = serializers.BooleanField(required=False) - duplicate = serializers.BooleanField(required=False) - mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Dojo_User.objects.all()) - note = serializers.CharField(required=False, allow_blank=True) - note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) - - class Meta: - model = Finding - fields = ( - "is_mitigated", - "mitigated", - "false_p", - "out_of_scope", - "duplicate", - "mitigated_by", - "note", - "note_type", - ) - - def validate(self, data): - request = self.context.get("request") - request_user = getattr(request, "user", None) - - mitigated_by_user = data.get("mitigated_by") - if mitigated_by_user is not None: - # Require permission to edit mitigated metadata - if not (request_user and finding_helper.can_edit_mitigated_data(request_user)): - raise serializers.ValidationError({ - "mitigated_by": ["Not allowed to set mitigated_by."], - }) - - # Ensure selected user is authorized (Finding_Edit) - authorized_users = get_authorized_users("edit", user=request_user) - if not authorized_users.filter(id=mitigated_by_user.id).exists(): - raise serializers.ValidationError({ - "mitigated_by": [ - "Selected user is not authorized to be set as mitigated_by.", - ], - }) - - return data - - -class FindingVerifySerializer(serializers.Serializer): - note = serializers.CharField(required=False, allow_blank=True) - note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) - - class ReportGenerateOptionSerializer(serializers.Serializer): include_finding_notes = serializers.BooleanField(default=False) include_finding_images = serializers.BooleanField(default=False) @@ -2374,6 +1726,29 @@ class ExecutiveSummarySerializer(serializers.Serializer): total_findings = serializers.IntegerField() +# Finding serializers live in dojo/finding/api/serializer.py. FindingSerializer and +# FindingToNotesSerializer are re-exported here because ReportGenerateSerializer +# (below) still references them. The remaining finding serializers are re-exported so +# they remain discoverable as members of this module by the prefetcher +# (dojo/api_v2/prefetch/prefetcher.py inspects this module to build its model->serializer +# map); changing that membership would silently change prefetch responses. +from dojo.finding.api.serializer import ( # noqa: E402 -- backward compat + FindingCloseSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingCreateSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingEngagementSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingEnvironmentSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingGroupSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingMetaSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingProdTypeSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingProductSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingSerializer, + FindingTemplateSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingTestTypeSerializer, # noqa: F401 -- backward compat / prefetcher discovery + FindingToNotesSerializer, + VulnerabilityIdSerializer, # noqa: F401 -- backward compat / prefetcher discovery +) + + class ReportGenerateSerializer(serializers.Serializer): executive_summary = ExecutiveSummarySerializer(many=False, allow_null=True) product_type = ProductTypeSerializer(many=False, read_only=True) @@ -2430,10 +1805,6 @@ class CeleryQueueTaskDetailSerializer(serializers.Serializer): latest_expires = serializers.CharField(allow_null=True, read_only=True) -class FindingNoteSerializer(serializers.Serializer): - note_id = serializers.IntegerField() - - from dojo.notifications.api.serializer import NotificationsSerializer # noqa: E402, F401 -- backward compat diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index f9f6e60c019..cbe7353292c 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -1,11 +1,9 @@ -import base64 import logging import mimetypes from datetime import datetime from pathlib import Path import pghistory -import tagulous from crum import get_current_user from dateutil.relativedelta import relativedelta from django.conf import settings @@ -16,7 +14,6 @@ from django.db.models.functions import Coalesce from django.db.models.query import QuerySet as DjangoQuerySet from django.http import FileResponse -from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend @@ -24,7 +21,6 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( OpenApiParameter, - OpenApiResponse, extend_schema, extend_schema_view, ) @@ -36,7 +32,6 @@ from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response -import dojo.finding.helper as finding_helper from dojo.api_v2 import ( mixins as dojo_mixins, ) @@ -55,9 +50,7 @@ ApiAppAnalysisFilter, ApiDojoMetaFilter, ApiEndpointFilter, - ApiFindingFilter, ApiRiskAcceptanceFilter, - ApiTemplateFindingFilter, ApiUserFilter, ) from dojo.finding.queries import ( @@ -67,11 +60,6 @@ ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.finding.ui.views import ( - duplicate_cluster, - reset_finding_duplicate_status_internal, - set_finding_as_original_internal, -) from dojo.importers.auto_create_context import AutoCreateContextManager from dojo.jira import services as jira_services from dojo.labels import get_labels @@ -84,9 +72,7 @@ DojoMeta, Endpoint, Endpoint_Status, - FileUpload, Finding, - Finding_Template, Language_Type, Languages, Network_Locations, @@ -118,7 +104,6 @@ prefetch_related_findings_for_report, report_url_resolver, ) -from dojo.risk_acceptance import api as ra_api from dojo.risk_acceptance.helper import remove_finding_from_risk_acceptance from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests @@ -126,7 +111,6 @@ from dojo.user.authentication import reset_token_for_user from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( - generate_file_response, get_celery_queue_details, get_celery_queue_length, get_celery_worker_status, @@ -450,767 +434,6 @@ def get_queryset(self): return get_authorized_app_analysis("view") -# Authorization: configuration -class FindingTemplatesViewSet( - DojoModelViewSet, -): - serializer_class = serializers.FindingTemplateSerializer - queryset = Finding_Template.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiTemplateFindingFilter - permission_classes = (permissions.UserHasConfigurationPermissionStaff,) - - def get_queryset(self): - return Finding_Template.objects.all().order_by("id") - - -# Authorization: object-based -@extend_schema_view( - list=extend_schema( - parameters=[ - OpenApiParameter( - "related_fields", - OpenApiTypes.BOOL, - OpenApiParameter.QUERY, - required=False, - description="Expand finding external relations (engagement, environment, product, \ - product_type, test, test_type)", - ), - OpenApiParameter( - "prefetch", - OpenApiTypes.STR, - OpenApiParameter.QUERY, - required=False, - description="List of fields for which to prefetch model instances and add those to the response", - ), - ], - ), - retrieve=extend_schema( - parameters=[ - OpenApiParameter( - "related_fields", - OpenApiTypes.BOOL, - OpenApiParameter.QUERY, - required=False, - description="Expand finding external relations (engagement, environment, product, \ - product_type, test, test_type)", - ), - OpenApiParameter( - "prefetch", - OpenApiTypes.STR, - OpenApiParameter.QUERY, - required=False, - description="List of fields for which to prefetch model instances and add those to the response", - ), - ], - ), -) -class FindingViewSet( - prefetch.PrefetchListMixin, - prefetch.PrefetchRetrieveMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.CreateModelMixin, - ra_api.AcceptedFindingsMixin, - viewsets.GenericViewSet, - dojo_mixins.DeletePreviewModelMixin, -): - serializer_class = serializers.FindingSerializer - queryset = Finding.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiFindingFilter - permission_classes = ( - IsAuthenticated, - permissions.UserHasFindingPermission, - ) - - # Overriding mixins.UpdateModeMixin perform_update() method to grab push_to_jira - # data and add that as a parameter to .save() - def perform_update(self, serializer): - # IF JIRA is enabled and this product has a JIRA configuration - push_to_jira = serializer.validated_data.get("push_to_jira") - jira_project = jira_services.get_project(serializer.instance) - if get_system_setting("enable_jira") and jira_project: - push_to_jira = push_to_jira or jira_project.push_all_issues - - serializer.save(push_to_jira=push_to_jira) - - def get_queryset(self): - if settings.V3_FEATURE_LOCATIONS: - findings = get_authorized_findings( - "view", - ).prefetch_related( - "locations__location__url", - "reviewers", - "found_by", - "notes", - "risk_acceptance_set", - "test", - "tags", - "jira_issue", - "finding_group_set", - "files", - "burprawrequestresponse_set", - "status_finding", - "finding_meta", - "test__test_type", - "test__engagement", - "test__environment", - "test__engagement__product", - "test__engagement__product__prod_type", - ) - else: - # TODO: Delete this after the move to Locations - findings = get_authorized_findings( - "view", - ).prefetch_related( - "endpoints", - "reviewers", - "found_by", - "notes", - "risk_acceptance_set", - "test", - "tags", - "jira_issue", - "finding_group_set", - "files", - "burprawrequestresponse_set", - "status_finding", - "finding_meta", - "test__test_type", - "test__engagement", - "test__environment", - "test__engagement__product", - "test__engagement__product__prod_type", - ) - - return findings.distinct() - - def get_serializer_class(self): - if self.request and self.request.method == "POST": - return serializers.FindingCreateSerializer - return serializers.FindingSerializer - - @extend_schema( - methods=["POST"], - request=serializers.FindingCloseSerializer, - responses={status.HTTP_200_OK: serializers.FindingCloseSerializer}, - ) - @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def close(self, request, pk=None): - finding = self.get_object() - - if request.method == "POST": - finding_close = serializers.FindingCloseSerializer( - data=request.data, - context={"request": request}, - ) - if finding_close.is_valid(): - # Remove the prefetched tags to avoid issues with delegating to celery - finding.tags._remove_prefetched_objects() - # Use shared helper to perform close operations - finding_helper.close_finding( - finding=finding, - user=request.user, - is_mitigated=finding_close.validated_data["is_mitigated"], - mitigated=(finding_close.validated_data.get("mitigated") if finding_helper.can_edit_mitigated_data(request.user) else timezone.now()), - mitigated_by=finding_close.validated_data.get("mitigated_by") or (request.user if not finding_helper.can_edit_mitigated_data(request.user) else None), - false_p=finding_close.validated_data.get("false_p", False), - out_of_scope=finding_close.validated_data.get("out_of_scope", False), - duplicate=finding_close.validated_data.get("duplicate", False), - note_entry=finding_close.validated_data.get("note"), - note_type=finding_close.validated_data.get("note_type"), - ) - else: - return Response( - finding_close.errors, status=status.HTTP_400_BAD_REQUEST, - ) - serialized_finding = serializers.FindingCloseSerializer(finding, context={"request": request}) - return Response(serialized_finding.data) - - @extend_schema( - methods=["POST"], - request=serializers.FindingVerifySerializer, - responses={status.HTTP_200_OK: serializers.FindingSerializer}, - ) - @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def verify(self, request, pk=None): - finding = self.get_object() - - serializer = serializers.FindingVerifySerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - # Remove prefetched tags to keep queryset state in sync - finding.tags._remove_prefetched_objects() - - finding_helper.verify_finding( - finding=finding, - user=request.user, - note_entry=serializer.validated_data.get("note"), - note_type=serializer.validated_data.get("note_type"), - ) - - serialized_finding = serializers.FindingSerializer(finding, context={"request": request}) - return Response(serialized_finding.data) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.TagSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.TagSerializer, - responses={status.HTTP_201_CREATED: serializers.TagSerializer}, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def tags(self, request, pk=None): - finding = self.get_object() - - if request.method == "POST": - new_tags = serializers.TagSerializer(data=request.data) - if new_tags.is_valid(): - all_tags = finding.tags - all_tags = serializers.TagSerializer({"tags": all_tags}).data[ - "tags" - ] - for tag in new_tags.validated_data["tags"]: - for sub_tag in tagulous.utils.parse_tags(tag): - if sub_tag not in all_tags: - all_tags.append(sub_tag) - - new_tags = tagulous.utils.render_tags(all_tags) - - finding.tags = new_tags - finding.save() - else: - return Response( - new_tags.errors, status=status.HTTP_400_BAD_REQUEST, - ) - tags = finding.tags - serialized_tags = serializers.TagSerializer({"tags": tags}) - return Response(serialized_tags.data) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.BurpRawRequestResponseSerializer, - }, - ) - @extend_schema( - methods=["POST"], - request=serializers.BurpRawRequestResponseSerializer, - responses={ - status.HTTP_201_CREATED: serializers.BurpRawRequestResponseSerializer, - }, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def request_response(self, request, pk=None): - finding = self.get_object() - - if request.method == "POST": - burps = serializers.BurpRawRequestResponseSerializer( - data=request.data, many=isinstance(request.data, list), - ) - if burps.is_valid(): - for pair in burps.validated_data["req_resp"]: - burp_rr = BurpRawRequestResponse( - finding=finding, - burpRequestBase64=base64.b64encode( - pair["request"].encode("utf-8"), - ), - burpResponseBase64=base64.b64encode( - pair["response"].encode("utf-8"), - ), - ) - burp_rr.clean() - burp_rr.save() - else: - return Response( - burps.errors, status=status.HTTP_400_BAD_REQUEST, - ) - # Not necessarily Burp scan specific - these are just any request/response pairs - burp_req_resp = BurpRawRequestResponse.objects.filter(finding=finding) - var = settings.MAX_REQRESP_FROM_API - if var > -1: - burp_req_resp = burp_req_resp[:var] - - burp_list = [] - for burp in burp_req_resp: - request = burp.get_request() - response = burp.get_response() - burp_list.append({"request": request, "response": response}) - serialized_burps = serializers.BurpRawRequestResponseSerializer( - {"req_resp": burp_list}, - ) - return Response(serialized_burps.data) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.FindingToNotesSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewNoteOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.NoteSerializer}, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) - def notes(self, request, pk=None): - finding = self.get_object() - if request.method == "POST": - new_note = serializers.AddNewNoteOptionSerializer( - data=request.data, - ) - if new_note.is_valid(): - entry = new_note.validated_data["entry"] - private = new_note.validated_data.get("private", False) - note_type = new_note.validated_data.get("note_type", None) - else: - return Response( - new_note.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - if finding.notes: - notes = finding.notes.filter(note_type=note_type).first() - if notes and note_type and note_type.is_single: - return Response("Only one instance of this note_type allowed on a finding.", status=status.HTTP_400_BAD_REQUEST) - - author = request.user - note = Notes( - entry=entry, - author=author, - private=private, - note_type=note_type, - ) - note.save() - # Add an entry to the note history - history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) - note.history.add(history) - # Now add the note to the object - finding.last_reviewed = note.date - finding.last_reviewed_by = author - finding.save(update_fields=["last_reviewed", "last_reviewed_by", "updated"]) - finding.notes.add(note) - # Determine if we need to send any notifications for user mentioned - process_tag_notifications( - request=request, - note=note, - parent_url=request.build_absolute_uri( - reverse("view_finding", args=(finding.id,)), - ), - parent_title=f"Finding: {finding.title}", - ) - - if finding.has_jira_issue: - jira_services.add_comment(finding, note) - elif finding.has_jira_group_issue: - jira_services.add_comment(finding.finding_group, note) - - serialized_note = serializers.NoteSerializer( - {"author": author, "entry": entry, "private": private}, - ) - return Response( - serialized_note.data, status=status.HTTP_201_CREATED, - ) - notes = finding.notes.all() - - serialized_notes = serializers.FindingToNotesSerializer( - {"finding_id": finding, "notes": notes}, - ) - return Response(serialized_notes.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={status.HTTP_200_OK: serializers.FindingToFilesSerializer}, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewFileOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.FileSerializer}, - ) - @action( - detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def files(self, request, pk=None): - finding = self.get_object() - if request.method == "POST": - new_file = serializers.FileSerializer(data=request.data) - if new_file.is_valid(): - title = new_file.validated_data["title"] - file = new_file.validated_data["file"] - else: - return Response( - new_file.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - file = FileUpload(title=title, file=file) - file.save() - finding.files.add(file) - - serialized_file = serializers.FileSerializer(file) - return Response( - serialized_file.data, status=status.HTTP_201_CREATED, - ) - - files = finding.files.all() - serialized_files = serializers.FindingToFilesSerializer( - {"finding_id": finding, "files": files}, - ) - return Response(serialized_files.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.RawFileSerializer, - }, - ) - @action( - detail=True, - methods=["get"], - url_path=r"files/download/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def download_file(self, request, file_id, pk=None): - finding = self.get_object() - # Get the file object - file_object_qs = finding.files.filter(id=file_id) - file_object = ( - file_object_qs.first() if len(file_object_qs) > 0 else None - ) - if file_object is None: - return Response( - {"error": "File ID not associated with Finding"}, - status=status.HTTP_404_NOT_FOUND, - ) - # send file - return generate_file_response(file_object) - - @extend_schema( - request=serializers.FindingNoteSerializer, - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action(detail=True, methods=["patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) - def remove_note(self, request, pk=None): - """Remove Note From Finding Note""" - finding = self.get_object() - notes = finding.notes.all() - if request.data["note_id"]: - note = get_object_or_404(Notes.objects, id=request.data["note_id"]) - if note not in notes: - return Response( - {"error": "Selected Note is not assigned to this Finding"}, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - return Response( - {"error": "('note_id') parameter missing"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if ( - note.author.username == request.user.username - or request.user.is_superuser - ): - finding.notes.remove(note) - note.delete() - else: - return Response( - {"error": "Delete Failed, You are not the Note's author"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - return Response( - {"Success": "Selected Note has been Removed successfully"}, - status=status.HTTP_204_NO_CONTENT, - ) - - @extend_schema( - methods=["PUT", "PATCH"], - request=serializers.TagSerializer, - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action(detail=True, methods=["put", "patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def remove_tags(self, request, pk=None): - """Remove Tag(s) from finding list of tags""" - finding = self.get_object() - delete_tags = serializers.TagSerializer(data=request.data) - if delete_tags.is_valid(): - all_tags = finding.tags - all_tags = serializers.TagSerializer({"tags": all_tags}).data[ - "tags" - ] - - # serializer turns it into a string, but we need a list - del_tags = delete_tags.validated_data["tags"] - if len(del_tags) < 1: - return Response( - {"error": "Empty Tag List Not Allowed"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - for tag in del_tags: - if tag not in all_tags: - return Response( - { - "error": f"'{tag}' is not a valid tag in list '{all_tags}'", - }, - status=status.HTTP_400_BAD_REQUEST, - ) - all_tags.remove(tag) - new_tags = tagulous.utils.render_tags(all_tags) - finding.tags = new_tags - finding.save() - return Response( - {"success": "Tag(s) Removed"}, - status=status.HTTP_204_NO_CONTENT, - ) - return Response( - delete_tags.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - @extend_schema( - responses={ - status.HTTP_200_OK: serializers.FindingSerializer(many=True), - }, - ) - @action( - detail=True, - methods=["get"], - url_path=r"duplicate", - filter_backends=[], - pagination_class=None, - permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def get_duplicate_cluster(self, request, pk): - finding = self.get_object() - result = duplicate_cluster(request, finding) - serializer = serializers.FindingSerializer( - instance=result, many=True, context={"request": request}, - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - @extend_schema( - request=OpenApiTypes.NONE, - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action(detail=True, methods=["post"], url_path=r"duplicate/reset", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) - def reset_finding_duplicate_status(self, request, pk): - self.get_object() - checked_duplicate_id = reset_finding_duplicate_status_internal( - request.user, pk, - ) - if checked_duplicate_id is None: - return Response(status=status.HTTP_400_BAD_REQUEST) - return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - request=OpenApiTypes.NONE, - parameters=[ - OpenApiParameter( - "new_fid", OpenApiTypes.INT, OpenApiParameter.PATH, - ), - ], - responses={status.HTTP_204_NO_CONTENT: ""}, - ) - @action( - detail=True, methods=["post"], url_path=r"original/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def set_finding_as_original(self, request, pk, new_fid): - self.get_object() - success = set_finding_as_original_internal(request.user, pk, new_fid) - if not success: - return Response(status=status.HTTP_400_BAD_REQUEST) - return Response(status=status.HTTP_204_NO_CONTENT) - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=False, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request): - findings = self.get_queryset() - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, findings, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - def _get_metadata(self, request, finding): - metadata = DojoMeta.objects.filter(finding=finding) - serializer = serializers.FindingMetaSerializer( - instance=metadata, many=True, - ) - return Response(serializer.data, status=status.HTTP_200_OK) - - def _edit_metadata(self, request, finding): - metadata_name = request.query_params.get("name", None) - if metadata_name is None: - return Response( - "Metadata name is required", status=status.HTTP_400_BAD_REQUEST, - ) - - try: - DojoMeta.objects.update_or_create( - name=metadata_name, - finding=finding, - defaults={ - "name": request.data.get("name"), - "value": request.data.get("value"), - }, - ) - - return Response(data=request.data, status=status.HTTP_200_OK) - except IntegrityError: - return Response( - "Update failed because the new name already exists", - status=status.HTTP_400_BAD_REQUEST, - ) - - def _add_metadata(self, request, finding): - metadata_data = serializers.FindingMetaSerializer(data=request.data) - - if metadata_data.is_valid(): - name = metadata_data.validated_data["name"] - value = metadata_data.validated_data["value"] - - metadata = DojoMeta(finding=finding, name=name, value=value) - try: - metadata.validate_unique() - metadata.save() - except ValidationError: - return Response( - "Create failed probably because the name of the metadata already exists", - status=status.HTTP_400_BAD_REQUEST, - ) - - return Response(data=metadata_data.data, status=status.HTTP_200_OK) - return Response( - metadata_data.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - def _remove_metadata(self, request, finding): - name = request.query_params.get("name", None) - if name is None: - return Response( - "A metadata name must be provided", - status=status.HTTP_400_BAD_REQUEST, - ) - - metadata = get_object_or_404( - DojoMeta.objects, finding=finding, name=name, - ) - metadata.delete() - - return Response("Metadata deleted", status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.FindingMetaSerializer(many=True), - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - }, - ) - @extend_schema( - methods=["DELETE"], - parameters=[ - OpenApiParameter( - "name", - OpenApiTypes.INT, - OpenApiParameter.QUERY, - required=True, - description="name of the metadata to retrieve. If name is empty, return all the \ - metadata associated with the finding", - ), - ], - responses={ - status.HTTP_200_OK: OpenApiResponse( - description="Returned if the metadata was correctly deleted", - ), - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description="Returned if there was a problem with the metadata information", - ), - }, - ) - @extend_schema( - methods=["PUT"], - request=serializers.FindingMetaSerializer, - responses={ - status.HTTP_200_OK: serializers.FindingMetaSerializer, - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description="Returned if there was a problem with the metadata information", - ), - }, - ) - @extend_schema( - methods=["POST"], - request=serializers.FindingMetaSerializer, - responses={ - status.HTTP_200_OK: serializers.FindingMetaSerializer, - status.HTTP_404_NOT_FOUND: OpenApiResponse( - description="Returned if finding does not exist", - ), - status.HTTP_400_BAD_REQUEST: OpenApiResponse( - description="Returned if there was a problem with the metadata information", - ), - }, - ) - @action( - detail=True, - methods=["post", "put", "delete", "get"], - filter_backends=[], - pagination_class=None, - permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), - ) - def metadata(self, request, pk=None): - finding = self.get_object() - - if request.method == "GET": - return self._get_metadata(request, finding) - if request.method == "POST": - return self._add_metadata(request, finding) - if request.method in {"PUT", "PATCH"}: - return self._edit_metadata(request, finding) - if request.method == "DELETE": - return self._remove_metadata(request, finding) - - return Response( - {"error", "unsupported method"}, status=status.HTTP_400_BAD_REQUEST, - ) - - # Authorization: configuration from dojo.jira.api.views import ( # noqa: E402, F401 backward compat JiraInstanceViewSet, diff --git a/dojo/filters.py b/dojo/filters.py index ba68d7a180a..ffd44138885 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -17,7 +17,6 @@ BooleanFilter, CharFilter, DateFilter, - DateTimeFilter, FilterSet, ModelMultipleChoiceFilter, MultipleChoiceFilter, @@ -27,8 +26,6 @@ ) from django_filters import rest_framework as filters from django_filters.filters import ChoiceFilter -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema_field from polymorphic.base import ManagerInheritanceWarning # from tagulous.forms import TagWidget @@ -60,7 +57,6 @@ Engagement, Engagement_Survey, Finding, - Finding_Template, Note_Type, Product, Product_Type, @@ -1016,199 +1012,6 @@ def filter(self, qs, value): return super().filter(qs, value) -class ApiFindingFilter(DojoFilter): - # BooleanFilter - active = BooleanFilter(field_name="active") - duplicate = BooleanFilter(field_name="duplicate") - dynamic_finding = BooleanFilter(field_name="dynamic_finding") - false_p = BooleanFilter(field_name="false_p") - is_mitigated = BooleanFilter(field_name="is_mitigated") - out_of_scope = BooleanFilter(field_name="out_of_scope") - static_finding = BooleanFilter(field_name="static_finding") - under_defect_review = BooleanFilter(field_name="under_defect_review") - under_review = BooleanFilter(field_name="under_review") - verified = BooleanFilter(field_name="verified") - has_jira = BooleanFilter(field_name="jira_issue", lookup_expr="isnull", exclude=True) - fix_available = BooleanFilter(field_name="fix_available") - mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") - # CharFilter - component_version = CharFilter(lookup_expr="icontains") - component_name = CharFilter(lookup_expr="icontains") - vulnerability_id = CharFilter(method=custom_vulnerability_id_filter) - description = CharFilter(lookup_expr="icontains") - file_path = CharFilter(lookup_expr="icontains") - hash_code = CharFilter(lookup_expr="icontains") - impact = CharFilter(lookup_expr="icontains") - mitigation = CharFilter(lookup_expr="icontains") - numerical_severity = CharFilter(method=custom_filter, field_name="numerical_severity") - param = CharFilter(lookup_expr="icontains") - payload = CharFilter(lookup_expr="icontains") - references = CharFilter(lookup_expr="icontains") - severity = CharFilter(method=custom_filter, field_name="severity") - severity_justification = CharFilter(lookup_expr="icontains") - steps_to_reproduce = CharFilter(lookup_expr="icontains") - unique_id_from_tool = CharFilter(lookup_expr="icontains") - title = CharFilter(lookup_expr="icontains") - exact_title = CharFilter(field_name="title", lookup_expr="iexact", help_text="Finding title exact match (case-insensitive)") - product_name = CharFilter(lookup_expr="engagement__product__name__iexact", field_name="test", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) - product_name_contains = CharFilter(lookup_expr="engagement__product__name__icontains", field_name="test", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) - product_lifecycle = CharFilter(method=custom_filter, lookup_expr="engagement__product__lifecycle", - field_name="test__engagement__product__lifecycle", label=labels.ASSET_FILTERS_CSV_LIFECYCLES_LABEL) - # DateRangeFilter - created = DateRangeFilter() - date = DateRangeFilter() - discovered_on = DateFilter(field_name="date", lookup_expr="exact") - discovered_before = DateFilter(field_name="date", lookup_expr="lt") - discovered_after = DateFilter(field_name="date", lookup_expr="gt") - jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation") - jira_change = DateRangeFilter(field_name="jira_issue__jira_change") - last_reviewed = DateRangeFilter() - mitigated = DateRangeFilter() - mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", method="filter_mitigated_on") - mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt") - mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") - # NumberInFilter - cwe = NumberInFilter(field_name="cwe", lookup_expr="in") - defect_review_requested_by = NumberInFilter(field_name="defect_review_requested_by", lookup_expr="in") - endpoints = NumberInFilter(field_name="endpoints", lookup_expr="in") - epss_score = PercentageRangeFilter( - field_name="epss_score", - label="EPSS score range", - help_text=( - "The range of EPSS score percentages to filter on; the min input is a lower bound, " - "the max is an upper bound. Leaving one empty will skip that bound (e.g., leaving " - "the min bound input empty will filter only on the max bound -- filtering on " - '"less than or equal"). Leading 0 required.' - )) - epss_percentile = PercentageRangeFilter( - field_name="epss_percentile", - label="EPSS percentile range", - help_text=( - "The range of EPSS percentiles to filter on; the min input is a lower bound, the max " - "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the min bound " - 'input empty will filter only on the max bound -- filtering on "less than or equal"). Leading 0 required.' - )) - found_by = NumberInFilter(field_name="found_by", lookup_expr="in") - id = NumberInFilter(field_name="id", lookup_expr="in") - last_reviewed_by = NumberInFilter(field_name="last_reviewed_by", lookup_expr="in") - mitigated_by = NumberInFilter(field_name="mitigated_by", lookup_expr="in") - nb_occurences = NumberInFilter(field_name="nb_occurences", lookup_expr="in") - reporter = NumberInFilter(field_name="reporter", lookup_expr="in") - scanner_confidence = NumberInFilter(field_name="scanner_confidence", lookup_expr="in") - review_requested_by = NumberInFilter(field_name="review_requested_by", lookup_expr="in") - reviewers = NumberInFilter(field_name="reviewers", lookup_expr="in") - sast_source_line = NumberInFilter(field_name="sast_source_line", lookup_expr="in") - sonarqube_issue = NumberInFilter(field_name="sonarqube_issue", lookup_expr="in") - test__test_type = NumberInFilter(field_name="test__test_type", lookup_expr="in", label="Test Type") - test__engagement = NumberInFilter(field_name="test__engagement", lookup_expr="in") - test__engagement__product = NumberInFilter(field_name="test__engagement__product", lookup_expr="in") - test__engagement__product__prod_type = NumberInFilter(field_name="test__engagement__product__prod_type", lookup_expr="in") - finding_group = NumberInFilter(field_name="finding_group", lookup_expr="in") - - # ReportRiskAcceptanceFilter - risk_acceptance = extend_schema_field(OpenApiTypes.NUMBER)(ReportRiskAcceptanceFilter()) - - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - test__tags = CharFieldInFilter( - field_name="test__tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags present on test (uses OR for multiple values)") - test__tags__and = CharFieldFilterANDExpression( - field_name="test__tags__name", - help_text="Comma separated list of exact tags to match with an AND expression present on test") - test__engagement__tags = CharFieldInFilter( - field_name="test__engagement__tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags present on engagement (uses OR for multiple values)") - test__engagement__tags__and = CharFieldFilterANDExpression( - field_name="test__engagement__tags__name", - help_text="Comma separated list of exact tags to match with an AND expression present on engagement") - test__engagement__product__tags = CharFieldInFilter( - field_name="test__engagement__product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) - test__engagement__product__tags__and = CharFieldFilterANDExpression( - field_name="test__engagement__product__tags__name", - help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on model", exclude="True") - not_test__tags = CharFieldInFilter(field_name="test__tags__name", lookup_expr="in", exclude="True", help_text="Comma separated list of exact tags present on test") - not_test__engagement__tags = CharFieldInFilter(field_name="test__engagement__tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on engagement", - exclude="True") - not_test__engagement__product__tags = CharFieldInFilter( - field_name="test__engagement__product__tags__name", - lookup_expr="in", - help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, - exclude="True") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - outside_of_sla = extend_schema_field(OpenApiTypes.NUMBER)(FindingSLAFilter()) - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("active", "active"), - ("component_name", "component_name"), - ("component_version", "component_version"), - ("created", "created"), - ("last_status_update", "last_status_update"), - ("last_reviewed", "last_reviewed"), - ("cwe", "cwe"), - ("date", "date"), - ("duplicate", "duplicate"), - ("dynamic_finding", "dynamic_finding"), - ("false_p", "false_p"), - ("found_by", "found_by"), - ("id", "id"), - ("is_mitigated", "is_mitigated"), - ("numerical_severity", "numerical_severity"), - ("out_of_scope", "out_of_scope"), - ("planned_remediation_date", "planned_remediation_date"), - ("severity", "severity"), - ("sla_expiration_date", "sla_expiration_date"), - ("reviewers", "reviewers"), - ("static_finding", "static_finding"), - ("test__engagement__product__name", "test__engagement__product__name"), - ("title", "title"), - ("under_defect_review", "under_defect_review"), - ("under_review", "under_review"), - ("verified", "verified"), - ), - ) - - class Meta: - model = Finding - exclude = ["url", "thread_id", "notes", "files", - "line", "cve"] - - def filter_mitigated_after(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - value = value.replace(hour=23, minute=59, second=59) - - return queryset.filter(mitigated__gt=value) - - def filter_mitigated_on(self, queryset, name, value): - if value.hour == 0 and value.minute == 0 and value.second == 0: - # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 - nextday = value + timedelta(days=1) - return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) - - return queryset.filter(mitigated=value) - - def filter_mitigation_available(self, queryset, name, value): - if value: - return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") - return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) - - class PercentageFilter(NumberFilter): def __init__(self, *args, **kwargs): kwargs["method"] = self.filter_percentage @@ -1228,33 +1031,6 @@ def filter_percentage(self, queryset, name, value): return queryset.filter(**lookup_kwargs) -class ApiTemplateFindingFilter(DojoFilter): - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on model", exclude="True") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("title", "title"), - ("cwe", "cwe"), - ), - ) - - class Meta: - model = Finding_Template - fields = ["id", "title", "cwe", "severity", "description", - "mitigation"] - - class MetricsEndpointFilterHelper(FilterSet): start_date = DateFilter(field_name="date", label="Start Date", lookup_expr=("gt")) end_date = DateFilter(field_name="date", label="End Date", lookup_expr=("lt")) diff --git a/dojo/finding/api/__init__.py b/dojo/finding/api/__init__.py new file mode 100644 index 00000000000..4d8feaaab6a --- /dev/null +++ b/dojo/finding/api/__init__.py @@ -0,0 +1 @@ +path = "findings" # noqa: RUF067 diff --git a/dojo/finding/api/filters.py b/dojo/finding/api/filters.py new file mode 100644 index 00000000000..f9450aa820f --- /dev/null +++ b/dojo/finding/api/filters.py @@ -0,0 +1,247 @@ +from datetime import timedelta + +from django.db.models import Q +from django_filters import ( + BooleanFilter, + CharFilter, + DateFilter, + DateTimeFilter, + OrderingFilter, +) +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field + +from dojo.filters import ( + CharFieldFilterANDExpression, + CharFieldInFilter, + DateRangeFilter, + DojoFilter, + FindingSLAFilter, + NumberInFilter, + PercentageRangeFilter, + ReportRiskAcceptanceFilter, + custom_filter, + custom_vulnerability_id_filter, + labels, +) +from dojo.models import Finding, Finding_Template + + +class ApiFindingFilter(DojoFilter): + # BooleanFilter + active = BooleanFilter(field_name="active") + duplicate = BooleanFilter(field_name="duplicate") + dynamic_finding = BooleanFilter(field_name="dynamic_finding") + false_p = BooleanFilter(field_name="false_p") + is_mitigated = BooleanFilter(field_name="is_mitigated") + out_of_scope = BooleanFilter(field_name="out_of_scope") + static_finding = BooleanFilter(field_name="static_finding") + under_defect_review = BooleanFilter(field_name="under_defect_review") + under_review = BooleanFilter(field_name="under_review") + verified = BooleanFilter(field_name="verified") + has_jira = BooleanFilter(field_name="jira_issue", lookup_expr="isnull", exclude=True) + fix_available = BooleanFilter(field_name="fix_available") + mitigation_available = BooleanFilter(method="filter_mitigation_available", label="Mitigation Available") + # CharFilter + component_version = CharFilter(lookup_expr="icontains") + component_name = CharFilter(lookup_expr="icontains") + vulnerability_id = CharFilter(method=custom_vulnerability_id_filter) + description = CharFilter(lookup_expr="icontains") + file_path = CharFilter(lookup_expr="icontains") + hash_code = CharFilter(lookup_expr="icontains") + impact = CharFilter(lookup_expr="icontains") + mitigation = CharFilter(lookup_expr="icontains") + numerical_severity = CharFilter(method=custom_filter, field_name="numerical_severity") + param = CharFilter(lookup_expr="icontains") + payload = CharFilter(lookup_expr="icontains") + references = CharFilter(lookup_expr="icontains") + severity = CharFilter(method=custom_filter, field_name="severity") + severity_justification = CharFilter(lookup_expr="icontains") + steps_to_reproduce = CharFilter(lookup_expr="icontains") + unique_id_from_tool = CharFilter(lookup_expr="icontains") + title = CharFilter(lookup_expr="icontains") + exact_title = CharFilter(field_name="title", lookup_expr="iexact", help_text="Finding title exact match (case-insensitive)") + product_name = CharFilter(lookup_expr="engagement__product__name__iexact", field_name="test", label=labels.ASSET_FILTERS_NAME_EXACT_LABEL) + product_name_contains = CharFilter(lookup_expr="engagement__product__name__icontains", field_name="test", label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL) + product_lifecycle = CharFilter(method=custom_filter, lookup_expr="engagement__product__lifecycle", + field_name="test__engagement__product__lifecycle", label=labels.ASSET_FILTERS_CSV_LIFECYCLES_LABEL) + # DateRangeFilter + created = DateRangeFilter() + date = DateRangeFilter() + discovered_on = DateFilter(field_name="date", lookup_expr="exact") + discovered_before = DateFilter(field_name="date", lookup_expr="lt") + discovered_after = DateFilter(field_name="date", lookup_expr="gt") + jira_creation = DateRangeFilter(field_name="jira_issue__jira_creation") + jira_change = DateRangeFilter(field_name="jira_issue__jira_change") + last_reviewed = DateRangeFilter() + mitigated = DateRangeFilter() + mitigated_on = DateTimeFilter(field_name="mitigated", lookup_expr="exact", method="filter_mitigated_on") + mitigated_before = DateTimeFilter(field_name="mitigated", lookup_expr="lt") + mitigated_after = DateTimeFilter(field_name="mitigated", lookup_expr="gt", label="Mitigated After", method="filter_mitigated_after") + # NumberInFilter + cwe = NumberInFilter(field_name="cwe", lookup_expr="in") + defect_review_requested_by = NumberInFilter(field_name="defect_review_requested_by", lookup_expr="in") + endpoints = NumberInFilter(field_name="endpoints", lookup_expr="in") + epss_score = PercentageRangeFilter( + field_name="epss_score", + label="EPSS score range", + help_text=( + "The range of EPSS score percentages to filter on; the min input is a lower bound, " + "the max is an upper bound. Leaving one empty will skip that bound (e.g., leaving " + "the min bound input empty will filter only on the max bound -- filtering on " + '"less than or equal"). Leading 0 required.' + )) + epss_percentile = PercentageRangeFilter( + field_name="epss_percentile", + label="EPSS percentile range", + help_text=( + "The range of EPSS percentiles to filter on; the min input is a lower bound, the max " + "is an upper bound. Leaving one empty will skip that bound (e.g., leaving the min bound " + 'input empty will filter only on the max bound -- filtering on "less than or equal"). Leading 0 required.' + )) + found_by = NumberInFilter(field_name="found_by", lookup_expr="in") + id = NumberInFilter(field_name="id", lookup_expr="in") + last_reviewed_by = NumberInFilter(field_name="last_reviewed_by", lookup_expr="in") + mitigated_by = NumberInFilter(field_name="mitigated_by", lookup_expr="in") + nb_occurences = NumberInFilter(field_name="nb_occurences", lookup_expr="in") + reporter = NumberInFilter(field_name="reporter", lookup_expr="in") + scanner_confidence = NumberInFilter(field_name="scanner_confidence", lookup_expr="in") + review_requested_by = NumberInFilter(field_name="review_requested_by", lookup_expr="in") + reviewers = NumberInFilter(field_name="reviewers", lookup_expr="in") + sast_source_line = NumberInFilter(field_name="sast_source_line", lookup_expr="in") + sonarqube_issue = NumberInFilter(field_name="sonarqube_issue", lookup_expr="in") + test__test_type = NumberInFilter(field_name="test__test_type", lookup_expr="in", label="Test Type") + test__engagement = NumberInFilter(field_name="test__engagement", lookup_expr="in") + test__engagement__product = NumberInFilter(field_name="test__engagement__product", lookup_expr="in") + test__engagement__product__prod_type = NumberInFilter(field_name="test__engagement__product__prod_type", lookup_expr="in") + finding_group = NumberInFilter(field_name="finding_group", lookup_expr="in") + + # ReportRiskAcceptanceFilter + risk_acceptance = extend_schema_field(OpenApiTypes.NUMBER)(ReportRiskAcceptanceFilter()) + + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + test__tags = CharFieldInFilter( + field_name="test__tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags present on test (uses OR for multiple values)") + test__tags__and = CharFieldFilterANDExpression( + field_name="test__tags__name", + help_text="Comma separated list of exact tags to match with an AND expression present on test") + test__engagement__tags = CharFieldInFilter( + field_name="test__engagement__tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags present on engagement (uses OR for multiple values)") + test__engagement__tags__and = CharFieldFilterANDExpression( + field_name="test__engagement__tags__name", + help_text="Comma separated list of exact tags to match with an AND expression present on engagement") + test__engagement__product__tags = CharFieldInFilter( + field_name="test__engagement__product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_OR_HELP) + test__engagement__product__tags__and = CharFieldFilterANDExpression( + field_name="test__engagement__product__tags__name", + help_text=labels.ASSET_FILTERS_CSV_TAGS_AND_HELP) + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on model", exclude="True") + not_test__tags = CharFieldInFilter(field_name="test__tags__name", lookup_expr="in", exclude="True", help_text="Comma separated list of exact tags present on test") + not_test__engagement__tags = CharFieldInFilter(field_name="test__engagement__tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on engagement", + exclude="True") + not_test__engagement__product__tags = CharFieldInFilter( + field_name="test__engagement__product__tags__name", + lookup_expr="in", + help_text=labels.ASSET_FILTERS_CSV_TAGS_NOT_HELP, + exclude="True") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + outside_of_sla = extend_schema_field(OpenApiTypes.NUMBER)(FindingSLAFilter()) + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("active", "active"), + ("component_name", "component_name"), + ("component_version", "component_version"), + ("created", "created"), + ("last_status_update", "last_status_update"), + ("last_reviewed", "last_reviewed"), + ("cwe", "cwe"), + ("date", "date"), + ("duplicate", "duplicate"), + ("dynamic_finding", "dynamic_finding"), + ("false_p", "false_p"), + ("found_by", "found_by"), + ("id", "id"), + ("is_mitigated", "is_mitigated"), + ("numerical_severity", "numerical_severity"), + ("out_of_scope", "out_of_scope"), + ("planned_remediation_date", "planned_remediation_date"), + ("severity", "severity"), + ("sla_expiration_date", "sla_expiration_date"), + ("reviewers", "reviewers"), + ("static_finding", "static_finding"), + ("test__engagement__product__name", "test__engagement__product__name"), + ("title", "title"), + ("under_defect_review", "under_defect_review"), + ("under_review", "under_review"), + ("verified", "verified"), + ), + ) + + class Meta: + model = Finding + exclude = ["url", "thread_id", "notes", "files", + "line", "cve"] + + def filter_mitigated_after(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + value = value.replace(hour=23, minute=59, second=59) + + return queryset.filter(mitigated__gt=value) + + def filter_mitigated_on(self, queryset, name, value): + if value.hour == 0 and value.minute == 0 and value.second == 0: + # we have a simple date without a time, lets get a range from this morning to tonight at 23:59:59:999 + nextday = value + timedelta(days=1) + return queryset.filter(mitigated__gte=value, mitigated__lt=nextday) + + return queryset.filter(mitigated=value) + + def filter_mitigation_available(self, queryset, name, value): + if value: + return queryset.exclude(mitigation__isnull=True).exclude(mitigation__exact="") + return queryset.filter(Q(mitigation__isnull=True) | Q(mitigation__exact="")) + + +class ApiTemplateFindingFilter(DojoFilter): + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on model", exclude="True") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("title", "title"), + ("cwe", "cwe"), + ), + ) + + class Meta: + model = Finding_Template + fields = ["id", "title", "cwe", "severity", "description", + "mitigation"] diff --git a/dojo/finding/api/serializer.py b/dojo/finding/api/serializer.py new file mode 100644 index 00000000000..cb17386135c --- /dev/null +++ b/dojo/finding/api/serializer.py @@ -0,0 +1,737 @@ +import logging + +from django.conf import settings +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +import dojo.finding.helper as finding_helper +from dojo.authorization.authorization import user_has_permission +from dojo.celery_dispatch import dojo_dispatch_task +from dojo.finding.helper import ( + save_endpoints_template, + save_vulnerability_ids, + save_vulnerability_ids_template, +) +from dojo.jira import services as jira_services +from dojo.jira.api.serializers import JIRAIssueSerializer +from dojo.location.models import LocationFindingReference +from dojo.models import ( + SEVERITIES, + Development_Environment, + Dojo_User, + DojoMeta, + Endpoint, + Engagement, + Finding, + Finding_Group, + Finding_Template, + Note_Type, + Product, + Product_Type, + Test, + Test_Type, + User, + Vulnerability_Id, +) +from dojo.notifications.helper import async_create_notification +from dojo.user.queries import get_authorized_users + +logger = logging.getLogger(__name__) + + +class FindingGroupSerializer(serializers.ModelSerializer): + jira_issue = JIRAIssueSerializer(read_only=True, allow_null=True) + + class Meta: + model = Finding_Group + fields = ("id", "name", "test", "jira_issue") + + +class FindingMetaSerializer(serializers.ModelSerializer): + class Meta: + model = DojoMeta + fields = ("name", "value") + + +class FindingProdTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Product_Type + fields = ["id", "name"] + + +class FindingProductSerializer(serializers.ModelSerializer): + prod_type = FindingProdTypeSerializer(required=False) + + class Meta: + model = Product + fields = ["id", "name", "prod_type"] + + +class FindingEngagementSerializer(serializers.ModelSerializer): + product = FindingProductSerializer(required=False) + + class Meta: + model = Engagement + fields = [ + "id", + "name", + "description", + "product", + "target_start", + "target_end", + "branch_tag", + "engagement_type", + "build_id", + "commit_hash", + "version", + "created", + "updated", + ] + + +class FindingEnvironmentSerializer(serializers.ModelSerializer): + class Meta: + model = Development_Environment + fields = ["id", "name"] + + +class FindingTestTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Test_Type + fields = ["id", "name"] + + +class FindingTestSerializer(serializers.ModelSerializer): + engagement = FindingEngagementSerializer(required=False) + environment = FindingEnvironmentSerializer(required=False) + test_type = FindingTestTypeSerializer(required=False) + + class Meta: + model = Test + fields = [ + "id", + "title", + "test_type", + "engagement", + "environment", + "branch_tag", + "build_id", + "commit_hash", + "version", + ] + + +class FindingRelatedFieldsSerializer(serializers.Serializer): + test = serializers.SerializerMethodField() + jira = serializers.SerializerMethodField() + + @extend_schema_field(FindingTestSerializer) + def get_test(self, obj): + return FindingTestSerializer(read_only=True).to_representation( + obj.test, + ) + + @extend_schema_field(JIRAIssueSerializer) + def get_jira(self, obj): + issue = jira_services.get_issue(obj) + if issue is None: + return None + return JIRAIssueSerializer(read_only=True).to_representation(issue) + + +class VulnerabilityIdSerializer(serializers.ModelSerializer): + class Meta: + model = Vulnerability_Id + fields = ["vulnerability_id"] + + +class FindingSerializer(serializers.ModelSerializer): + mitigated = serializers.DateTimeField(required=False, allow_null=True) + mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) + request_response = serializers.SerializerMethodField() + accepted_risks = serializers.SerializerMethodField() + push_to_jira = serializers.BooleanField(default=False) + found_by = serializers.PrimaryKeyRelatedField( + queryset=Test_Type.objects.all(), many=True, + ) + age = serializers.IntegerField(read_only=True) + sla_days_remaining = serializers.IntegerField(read_only=True, allow_null=True) + finding_meta = FindingMetaSerializer(read_only=True, many=True) + related_fields = serializers.SerializerMethodField(allow_null=True) + # for backwards compatibility + jira_creation = serializers.SerializerMethodField(read_only=True, allow_null=True) + jira_change = serializers.SerializerMethodField(read_only=True, allow_null=True) + display_status = serializers.SerializerMethodField() + finding_groups = FindingGroupSerializer( + source="finding_group_set", many=True, read_only=True, + ) + vulnerability_ids = VulnerabilityIdSerializer( + source="vulnerability_id_set", many=True, required=False, + ) + reporter = serializers.PrimaryKeyRelatedField( + required=False, queryset=User.objects.all(), + ) + endpoints = serializers.PrimaryKeyRelatedField( + source="locations", + many=True, + required=False, + queryset=LocationFindingReference.objects.all(), + ) + + class Meta: + model = Finding + exclude = ( + "cve", + "inherited_tags", + ) + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + # TODO: Delete this after the move to Locations + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not settings.V3_FEATURE_LOCATIONS: + self.fields["endpoints"] = serializers.PrimaryKeyRelatedField( + many=True, required=False, queryset=Endpoint.objects.all(), + ) + + def get_accepted_risks(self, obj): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + RiskAcceptanceSerializer, + ) + # schema annotation applied lazily at module bottom (avoids circular import) + request = self.context.get("request") + if request is None: + return [] + if not user_has_permission(request.user, obj, "edit"): + return [] + return RiskAcceptanceSerializer( + obj.risk_acceptance_set.all(), many=True, + ).data + + @extend_schema_field(serializers.DateTimeField()) + def get_jira_creation(self, obj): + return jira_services.get_creation(obj) + + @extend_schema_field(serializers.DateTimeField()) + def get_jira_change(self, obj): + return jira_services.get_change(obj) + + @extend_schema_field(FindingRelatedFieldsSerializer) + def get_related_fields(self, obj): + request = self.context.get("request", None) + if request is None: + return None + + query_params = request.query_params + if query_params.get("related_fields", "false") == "true": + return FindingRelatedFieldsSerializer( + required=False, + ).to_representation(obj) + return None + + def get_display_status(self, obj) -> str: + return obj.status() + + def process_risk_acceptance(self, data): + import dojo.risk_acceptance.helper as ra_helper # noqa: PLC0415 -- lazy import, avoids circular dependency + is_risk_accepted = data.get("risk_accepted") + # Do not take any action if the `risk_accepted` was not passed + if not isinstance(is_risk_accepted, bool): + return + # Determine how to proceed based on the value of `risk_accepted` + if is_risk_accepted and not self.instance.risk_accepted and self.instance.test.engagement.product.enable_simple_risk_acceptance and not data.get("active", False): + ra_helper.simple_risk_accept(self.context["request"].user, self.instance) + elif not is_risk_accepted and self.instance.risk_accepted: # turning off risk_accepted + ra_helper.risk_unaccept(self.context["request"].user, self.instance) + + # Overriding this to push add Push to JIRA functionality + def update(self, instance, validated_data): + # push_all_issues already checked in api views.py + push_to_jira = validated_data.pop("push_to_jira") + + # Save vulnerability ids and pop them + parsed_vulnerability_ids = [] + if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): + logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) + parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) + logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) + validated_data["cve"] = parsed_vulnerability_ids[0] + + # Save the reporter on the finding + if reporter_id := validated_data.get("reporter"): + instance.reporter = reporter_id + + # Persist vulnerability IDs first so model save computes hash including them (if there is no hash yet) + # we can't pass unsaved_vulnerabilitiy_ids to super.update() + if parsed_vulnerability_ids: + save_vulnerability_ids(instance, parsed_vulnerability_ids) + + # Get found_by from validated_data + found_by = validated_data.pop("found_by", None) + # Handle updates to found_by data + if found_by: + instance.found_by.set(found_by) + # If there is no argument entered for found_by, the user would like to clear out the values on the Finding's found_by field + # Findings still maintain original found_by value associated with their test + # In the event the user does not supply the found_by field at all, we do not modify it + elif isinstance(found_by, list) and len(found_by) == 0: + instance.found_by.clear() + + locations = None + if settings.V3_FEATURE_LOCATIONS: + locations = validated_data.pop("locations", None) + + instance = super().update( + instance, validated_data, + ) + + if settings.V3_FEATURE_LOCATIONS and locations is not None: + for location_ref in instance.locations.all(): + location_ref.location.disassociate_from_finding(instance) + for location_ref in locations: + location_ref.location.associate_with_finding(instance) + + if push_to_jira or jira_services.is_keep_in_sync(instance): + # Push synchronously so that we can see jira errors in real time + success, message = jira_services.push(instance, force_sync=True) + if not success: + raise serializers.ValidationError(message) + + return instance + + def validate(self, data): + # Enforce mitigated metadata editability (only when non-null values are provided) + attempting_to_set_mitigated = any( + (field in data) and (data.get(field) is not None) + for field in ["mitigated", "mitigated_by"] + ) + user = getattr(self.context.get("request", None), "user", None) + if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): + errors = {} + if ("mitigated" in data) and (data.get("mitigated") is not None): + errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] + if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): + errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] + if errors: + raise serializers.ValidationError(errors) + + if self.context["request"].method == "PATCH": + is_active = data.get("active", self.instance.active) + is_verified = data.get("verified", self.instance.verified) + is_duplicate = data.get("duplicate", self.instance.duplicate) + is_false_p = data.get("false_p", self.instance.false_p) + is_risk_accepted = data.get( + "risk_accepted", self.instance.risk_accepted, + ) + else: + is_active = data.get("active", True) + is_verified = data.get("verified", False) + is_duplicate = data.get("duplicate", False) + is_false_p = data.get("false_p", False) + is_risk_accepted = data.get("risk_accepted", False) + + if (is_active or is_verified) and is_duplicate: + msg = "Duplicate findings cannot be verified or active" + raise serializers.ValidationError(msg) + if is_false_p and is_verified: + msg = "False positive findings cannot be verified." + raise serializers.ValidationError(msg) + + if is_risk_accepted and not self.instance.risk_accepted: + if ( + not self.instance.test.engagement.product.enable_simple_risk_acceptance + ): + msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." + raise serializers.ValidationError(msg) + + if is_active and is_risk_accepted: + msg = "Active findings cannot be risk accepted." + raise serializers.ValidationError(msg) + + # assuming we made it past the validations,call risk acceptance properly to make sure notes, etc get created + # doing it here instead of in update because update doesn't know if the value changed + self.process_risk_acceptance(data) + + return data + + def validate_severity(self, value: str) -> str: + if value not in SEVERITIES: + msg = f"Severity must be one of the following: {SEVERITIES}" + raise serializers.ValidationError(msg) + return value + + def build_relational_field(self, field_name, relation_info): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + NoteSerializer, + ) + if field_name == "notes": + return NoteSerializer, {"many": True, "read_only": True} + return super().build_relational_field(field_name, relation_info) + + def get_request_response(self, obj): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + BurpRawRequestResponseSerializer, + ) + # Not necessarily Burp scan specific - these are just any request/response pairs + burp_req_resp = obj.burprawrequestresponse_set.all() + var = settings.MAX_REQRESP_FROM_API + if var > -1: + burp_req_resp = burp_req_resp[:var] + burp_list = [] + for burp in burp_req_resp: + request = burp.get_request() + response = burp.get_response() + burp_list.append({"request": request, "response": response}) + serialized_burps = BurpRawRequestResponseSerializer( + {"req_resp": burp_list}, + ) + return serialized_burps.data + + +class FindingCreateSerializer(serializers.ModelSerializer): + mitigated = serializers.DateTimeField(required=False, allow_null=True) + mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) + notes = serializers.PrimaryKeyRelatedField( + read_only=True, allow_null=True, required=False, many=True, + ) + test = serializers.PrimaryKeyRelatedField(queryset=Test.objects.all()) + thread_id = serializers.IntegerField(default=0) + found_by = serializers.PrimaryKeyRelatedField( + queryset=Test_Type.objects.all(), many=True, + ) + url = serializers.CharField(allow_null=True, default=None) + push_to_jira = serializers.BooleanField(default=False) + vulnerability_ids = VulnerabilityIdSerializer( + source="vulnerability_id_set", many=True, required=False, + ) + reporter = serializers.PrimaryKeyRelatedField( + required=False, queryset=User.objects.all(), + ) + + class Meta: + model = Finding + exclude = ( + "cve", + "inherited_tags", + ) + extra_kwargs = { + "active": {"required": True}, + "verified": {"required": True}, + } + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + # Overriding this to push add Push to JIRA functionality + def create(self, validated_data): + logger.debug("Creating finding with validated data: %s", validated_data) + push_to_jira = validated_data.pop("push_to_jira", False) + notes = validated_data.pop("notes", None) + found_by = validated_data.pop("found_by", None) + reviewers = validated_data.pop("reviewers", None) + # Process the vulnerability IDs specially + parsed_vulnerability_ids = [] + if (vulnerability_ids := validated_data.pop("vulnerability_id_set", None)): + logger.debug("VULNERABILITY_ID_SET: %s", vulnerability_ids) + parsed_vulnerability_ids.extend(vulnerability_id["vulnerability_id"] for vulnerability_id in vulnerability_ids) + logger.debug("PARSED_VULNERABILITY_IDST: %s", parsed_vulnerability_ids) + logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) + validated_data["cve"] = parsed_vulnerability_ids[0] + # validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids + + # super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly. + new_finding = Finding(**validated_data) + new_finding.unsaved_vulnerability_ids = parsed_vulnerability_ids or [] + new_finding.save() + + logger.debug(f"New finding CVE: {new_finding.cve}") + + # Deal with all of the many to many things + if notes: + new_finding.notes.set(notes) + if found_by: + new_finding.found_by.set(found_by) + if reviewers: + new_finding.reviewers.set(reviewers) + if parsed_vulnerability_ids: + save_vulnerability_ids(new_finding, parsed_vulnerability_ids) + + if push_to_jira: + jira_services.push(new_finding) + + # Create a notification + dojo_dispatch_task( + async_create_notification, + event="finding_added", + title=_("Addition of %s") % new_finding.title, + finding_id=new_finding.id, + description=_('Finding "%s" was added by %s') % (new_finding.title, new_finding.reporter), + url=reverse("view_finding", args=(new_finding.id,)), + icon="exclamation-triangle", + ) + + return new_finding + + def validate(self, data): + # Ensure mitigated fields are only set when editable is enabled (ignore nulls) + attempting_to_set_mitigated = any( + (field in data) and (data.get(field) is not None) + for field in ["mitigated", "mitigated_by"] + ) + user = getattr(getattr(self.context, "request", None), "user", None) + if attempting_to_set_mitigated and not finding_helper.can_edit_mitigated_data(user): + errors = {} + if ("mitigated" in data) and (data.get("mitigated") is not None): + errors["mitigated"] = ["Editing mitigated timestamp is disabled (EDITABLE_MITIGATED_DATA=false)"] + if ("mitigated_by" in data) and (data.get("mitigated_by") is not None): + errors["mitigated_by"] = ["Editing mitigated_by is disabled (EDITABLE_MITIGATED_DATA=false)"] + if errors: + raise serializers.ValidationError(errors) + + if "reporter" not in data: + request = self.context["request"] + data["reporter"] = request.user + + if (data.get("active") or data.get("verified")) and data.get( + "duplicate", + ): + msg = "Duplicate findings cannot be verified or active" + raise serializers.ValidationError(msg) + if data.get("false_p") and data.get("verified"): + msg = "False positive findings cannot be verified." + raise serializers.ValidationError(msg) + + if "risk_accepted" in data and data.get("risk_accepted"): + test = data.get("test") + # test = Test.objects.get(id=test_id) + if not test.engagement.product.enable_simple_risk_acceptance: + msg = "Simple risk acceptance is disabled for this product, use the UI to accept this finding." + raise serializers.ValidationError(msg) + + if ( + data.get("active") + and "risk_accepted" in data + and data.get("risk_accepted") + ): + msg = "Active findings cannot be risk accepted." + raise serializers.ValidationError(msg) + + return data + + def validate_severity(self, value: str) -> str: + if value not in SEVERITIES: + msg = f"Severity must be one of the following: {SEVERITIES}" + raise serializers.ValidationError(msg) + return value + + +class FindingTemplateSerializer(serializers.ModelSerializer): + vulnerability_ids = serializers.SerializerMethodField() + endpoints = serializers.SerializerMethodField() + + class Meta: + model = Finding_Template + exclude = ("cve", "vulnerability_ids_text") + + def get_fields(self): + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields = super().get_fields() + fields["tags"] = TagListSerializerField(required=False) + return fields + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_vulnerability_ids(self, obj): + """Return vulnerability IDs as a list of strings.""" + return obj.vulnerability_ids + + @extend_schema_field(serializers.ListField(child=serializers.CharField())) + def get_endpoints(self, obj): + """Return endpoints as a list of URL strings.""" + return obj.endpoints if hasattr(obj, "endpoints") else [] + + def create(self, validated_data): + + # Handle vulnerability_ids if provided as list + vulnerability_ids = None + if "vulnerability_ids" in self.initial_data: + vulnerability_ids = self.initial_data.get("vulnerability_ids", []) + if isinstance(vulnerability_ids, str): + # If it's a string, split by newlines + vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] + elif not isinstance(vulnerability_ids, list): + vulnerability_ids = [] + + # Handle endpoints if provided as list + endpoint_urls = None + if "endpoints" in self.initial_data: + endpoint_urls = self.initial_data.get("endpoints", []) + if isinstance(endpoint_urls, str): + # If it's a string, split by newlines + endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] + elif not isinstance(endpoint_urls, list): + endpoint_urls = [] + + new_finding_template = super().create( + validated_data, + ) + + # Save vulnerability IDs using helper + if vulnerability_ids: + save_vulnerability_ids_template(new_finding_template, vulnerability_ids) + + # Save endpoints using helper + if endpoint_urls: + save_endpoints_template(new_finding_template, endpoint_urls) + + return new_finding_template + + def update(self, instance, validated_data): + # Handle vulnerability_ids if provided + if "vulnerability_ids" in self.initial_data: + vulnerability_ids = self.initial_data.get("vulnerability_ids", []) + if isinstance(vulnerability_ids, str): + vulnerability_ids = [vid.strip() for vid in vulnerability_ids.split("\n") if vid.strip()] + elif not isinstance(vulnerability_ids, list): + vulnerability_ids = [] + save_vulnerability_ids_template(instance, vulnerability_ids) + + # Handle endpoints if provided + if "endpoints" in self.initial_data: + endpoint_urls = self.initial_data.get("endpoints", []) + if isinstance(endpoint_urls, str): + endpoint_urls = [url.strip() for url in endpoint_urls.split("\n") if url.strip()] + elif not isinstance(endpoint_urls, list): + endpoint_urls = [] + save_endpoints_template(instance, endpoint_urls) + + return super().update(instance, validated_data) + + +class FindingToNotesSerializer(serializers.Serializer): + finding_id = serializers.PrimaryKeyRelatedField( + queryset=Finding.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import NoteSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["notes"] = NoteSerializer(many=True) + return fields + + +class FindingToFilesSerializer(serializers.Serializer): + finding_id = serializers.PrimaryKeyRelatedField( + queryset=Finding.objects.all(), many=False, allow_null=True, + ) + + def get_fields(self): + from dojo.api_v2.serializers import FileSerializer # noqa: PLC0415 -- lazy import, avoids circular dependency + fields = super().get_fields() + fields["files"] = FileSerializer(many=True) + return fields + + def to_representation(self, data): + finding = data.get("finding_id") + files = data.get("files") + new_files = [{ + "id": file.id, + "file": "{site_url}/{file_access_url}".format( + site_url=settings.SITE_URL, + file_access_url=file.get_accessible_url( + finding, finding.id, + ), + ), + "title": file.title, + } for file in files] + return {"finding_id": finding.id, "files": new_files} + + +class FindingCloseSerializer(serializers.ModelSerializer): + is_mitigated = serializers.BooleanField(required=False) + mitigated = serializers.DateTimeField(required=False) + false_p = serializers.BooleanField(required=False) + out_of_scope = serializers.BooleanField(required=False) + duplicate = serializers.BooleanField(required=False) + mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Dojo_User.objects.all()) + note = serializers.CharField(required=False, allow_blank=True) + note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) + + class Meta: + model = Finding + fields = ( + "is_mitigated", + "mitigated", + "false_p", + "out_of_scope", + "duplicate", + "mitigated_by", + "note", + "note_type", + ) + + def validate(self, data): + request = self.context.get("request") + request_user = getattr(request, "user", None) + + mitigated_by_user = data.get("mitigated_by") + if mitigated_by_user is not None: + # Require permission to edit mitigated metadata + if not (request_user and finding_helper.can_edit_mitigated_data(request_user)): + raise serializers.ValidationError({ + "mitigated_by": ["Not allowed to set mitigated_by."], + }) + + # Ensure selected user is authorized (Finding_Edit) + authorized_users = get_authorized_users("edit", user=request_user) + if not authorized_users.filter(id=mitigated_by_user.id).exists(): + raise serializers.ValidationError({ + "mitigated_by": [ + "Selected user is not authorized to be set as mitigated_by.", + ], + }) + + return data + + +class FindingVerifySerializer(serializers.Serializer): + note = serializers.CharField(required=False, allow_blank=True) + note_type = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Note_Type.objects.all()) + + +class FindingNoteSerializer(serializers.Serializer): + note_id = serializers.IntegerField() + + +def _apply_schema_overrides(): + # Apply @extend_schema_field annotations that reference serializers which remain + # in dojo.api_v2.serializers. These are applied here (rather than as class-body + # decorators) so the module carries no top-level dojo.api_v2.serializers import, + # which would create a circular dependency. drf-spectacular only reads these + # overrides at schema generation time, so applying them lazily on import is fine. + from drf_spectacular.utils import set_override # noqa: PLC0415 -- lazy import, avoids circular dependency + + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + BurpRawRequestResponseSerializer, + RiskAcceptanceSerializer, + ) + set_override(FindingSerializer.get_accepted_risks, "field", RiskAcceptanceSerializer(many=True)) + set_override(FindingSerializer.get_request_response, "field", BurpRawRequestResponseSerializer) + + +_apply_schema_overrides() diff --git a/dojo/finding/api/urls.py b/dojo/finding/api/urls.py new file mode 100644 index 00000000000..3a2e8f3143d --- /dev/null +++ b/dojo/finding/api/urls.py @@ -0,0 +1,7 @@ +from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet + + +def add_finding_urls(router): + router.register("finding_templates", FindingTemplatesViewSet, basename="finding_template") + router.register("findings", FindingViewSet, basename="finding") + return router diff --git a/dojo/finding/api/views.py b/dojo/finding/api/views.py new file mode 100644 index 00000000000..877657165f3 --- /dev/null +++ b/dojo/finding/api/views.py @@ -0,0 +1,833 @@ +import base64 +import logging + +import tagulous +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +import dojo.finding.helper as finding_helper +from dojo.api_v2 import ( + mixins as dojo_mixins, +) +from dojo.api_v2 import ( + prefetch, +) +from dojo.api_v2 import ( + serializers as api_v2_serializers, +) +from dojo.api_v2.views import DojoModelViewSet, report_generate +from dojo.authorization import api_permissions as permissions +from dojo.finding.api.filters import ApiFindingFilter, ApiTemplateFindingFilter +from dojo.finding.api.serializer import ( + FindingCloseSerializer, + FindingCreateSerializer, + FindingMetaSerializer, + FindingNoteSerializer, + FindingSerializer, + FindingTemplateSerializer, + FindingToFilesSerializer, + FindingToNotesSerializer, + FindingVerifySerializer, +) +from dojo.finding.queries import get_authorized_findings +from dojo.finding.ui.views import ( + duplicate_cluster, + reset_finding_duplicate_status_internal, + set_finding_as_original_internal, +) +from dojo.jira import services as jira_services +from dojo.models import ( + BurpRawRequestResponse, + DojoMeta, + FileUpload, + Finding, + Finding_Template, + NoteHistory, + Notes, +) +from dojo.risk_acceptance import api as ra_api +from dojo.utils import ( + generate_file_response, + get_system_setting, + process_tag_notifications, +) + +logger = logging.getLogger(__name__) + + +# Authorization: configuration +class FindingTemplatesViewSet( + DojoModelViewSet, +): + serializer_class = FindingTemplateSerializer + queryset = Finding_Template.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiTemplateFindingFilter + permission_classes = (permissions.UserHasConfigurationPermissionStaff,) + + def get_queryset(self): + return Finding_Template.objects.all().order_by("id") + + +# Authorization: object-based +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + "related_fields", + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, + required=False, + description="Expand finding external relations (engagement, environment, product, \ + product_type, test, test_type)", + ), + OpenApiParameter( + "prefetch", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + description="List of fields for which to prefetch model instances and add those to the response", + ), + ], + ), + retrieve=extend_schema( + parameters=[ + OpenApiParameter( + "related_fields", + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, + required=False, + description="Expand finding external relations (engagement, environment, product, \ + product_type, test, test_type)", + ), + OpenApiParameter( + "prefetch", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + description="List of fields for which to prefetch model instances and add those to the response", + ), + ], + ), +) +class FindingViewSet( + prefetch.PrefetchListMixin, + prefetch.PrefetchRetrieveMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.CreateModelMixin, + ra_api.AcceptedFindingsMixin, + viewsets.GenericViewSet, + dojo_mixins.DeletePreviewModelMixin, +): + serializer_class = FindingSerializer + queryset = Finding.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiFindingFilter + permission_classes = ( + IsAuthenticated, + permissions.UserHasFindingPermission, + ) + + # Overriding mixins.UpdateModeMixin perform_update() method to grab push_to_jira + # data and add that as a parameter to .save() + def perform_update(self, serializer): + # IF JIRA is enabled and this product has a JIRA configuration + push_to_jira = serializer.validated_data.get("push_to_jira") + jira_project = jira_services.get_project(serializer.instance) + if get_system_setting("enable_jira") and jira_project: + push_to_jira = push_to_jira or jira_project.push_all_issues + + serializer.save(push_to_jira=push_to_jira) + + def get_queryset(self): + if settings.V3_FEATURE_LOCATIONS: + findings = get_authorized_findings( + "view", + ).prefetch_related( + "locations__location__url", + "reviewers", + "found_by", + "notes", + "risk_acceptance_set", + "test", + "tags", + "jira_issue", + "finding_group_set", + "files", + "burprawrequestresponse_set", + "status_finding", + "finding_meta", + "test__test_type", + "test__engagement", + "test__environment", + "test__engagement__product", + "test__engagement__product__prod_type", + ) + else: + # TODO: Delete this after the move to Locations + findings = get_authorized_findings( + "view", + ).prefetch_related( + "endpoints", + "reviewers", + "found_by", + "notes", + "risk_acceptance_set", + "test", + "tags", + "jira_issue", + "finding_group_set", + "files", + "burprawrequestresponse_set", + "status_finding", + "finding_meta", + "test__test_type", + "test__engagement", + "test__environment", + "test__engagement__product", + "test__engagement__product__prod_type", + ) + + return findings.distinct() + + def get_serializer_class(self): + if self.request and self.request.method == "POST": + return FindingCreateSerializer + return FindingSerializer + + @extend_schema( + methods=["POST"], + request=FindingCloseSerializer, + responses={status.HTTP_200_OK: FindingCloseSerializer}, + ) + @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def close(self, request, pk=None): + finding = self.get_object() + + if request.method == "POST": + finding_close = FindingCloseSerializer( + data=request.data, + context={"request": request}, + ) + if finding_close.is_valid(): + # Remove the prefetched tags to avoid issues with delegating to celery + finding.tags._remove_prefetched_objects() + # Use shared helper to perform close operations + finding_helper.close_finding( + finding=finding, + user=request.user, + is_mitigated=finding_close.validated_data["is_mitigated"], + mitigated=(finding_close.validated_data.get("mitigated") if finding_helper.can_edit_mitigated_data(request.user) else timezone.now()), + mitigated_by=finding_close.validated_data.get("mitigated_by") or (request.user if not finding_helper.can_edit_mitigated_data(request.user) else None), + false_p=finding_close.validated_data.get("false_p", False), + out_of_scope=finding_close.validated_data.get("out_of_scope", False), + duplicate=finding_close.validated_data.get("duplicate", False), + note_entry=finding_close.validated_data.get("note"), + note_type=finding_close.validated_data.get("note_type"), + ) + else: + return Response( + finding_close.errors, status=status.HTTP_400_BAD_REQUEST, + ) + serialized_finding = FindingCloseSerializer(finding, context={"request": request}) + return Response(serialized_finding.data) + + @extend_schema( + methods=["POST"], + request=FindingVerifySerializer, + responses={status.HTTP_200_OK: FindingSerializer}, + ) + @action(detail=True, methods=["post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def verify(self, request, pk=None): + finding = self.get_object() + + serializer = FindingVerifySerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Remove prefetched tags to keep queryset state in sync + finding.tags._remove_prefetched_objects() + + finding_helper.verify_finding( + finding=finding, + user=request.user, + note_entry=serializer.validated_data.get("note"), + note_type=serializer.validated_data.get("note_type"), + ) + + serialized_finding = FindingSerializer(finding, context={"request": request}) + return Response(serialized_finding.data) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: api_v2_serializers.TagSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.TagSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.TagSerializer}, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def tags(self, request, pk=None): + finding = self.get_object() + + if request.method == "POST": + new_tags = api_v2_serializers.TagSerializer(data=request.data) + if new_tags.is_valid(): + all_tags = finding.tags + all_tags = api_v2_serializers.TagSerializer({"tags": all_tags}).data[ + "tags" + ] + for tag in new_tags.validated_data["tags"]: + for sub_tag in tagulous.utils.parse_tags(tag): + if sub_tag not in all_tags: + all_tags.append(sub_tag) + + new_tags = tagulous.utils.render_tags(all_tags) + + finding.tags = new_tags + finding.save() + else: + return Response( + new_tags.errors, status=status.HTTP_400_BAD_REQUEST, + ) + tags = finding.tags + serialized_tags = api_v2_serializers.TagSerializer({"tags": tags}) + return Response(serialized_tags.data) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: api_v2_serializers.BurpRawRequestResponseSerializer, + }, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.BurpRawRequestResponseSerializer, + responses={ + status.HTTP_201_CREATED: api_v2_serializers.BurpRawRequestResponseSerializer, + }, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def request_response(self, request, pk=None): + finding = self.get_object() + + if request.method == "POST": + burps = api_v2_serializers.BurpRawRequestResponseSerializer( + data=request.data, many=isinstance(request.data, list), + ) + if burps.is_valid(): + for pair in burps.validated_data["req_resp"]: + burp_rr = BurpRawRequestResponse( + finding=finding, + burpRequestBase64=base64.b64encode( + pair["request"].encode("utf-8"), + ), + burpResponseBase64=base64.b64encode( + pair["response"].encode("utf-8"), + ), + ) + burp_rr.clean() + burp_rr.save() + else: + return Response( + burps.errors, status=status.HTTP_400_BAD_REQUEST, + ) + # Not necessarily Burp scan specific - these are just any request/response pairs + burp_req_resp = BurpRawRequestResponse.objects.filter(finding=finding) + var = settings.MAX_REQRESP_FROM_API + if var > -1: + burp_req_resp = burp_req_resp[:var] + + burp_list = [] + for burp in burp_req_resp: + request = burp.get_request() + response = burp.get_response() + burp_list.append({"request": request, "response": response}) + serialized_burps = api_v2_serializers.BurpRawRequestResponseSerializer( + {"req_resp": burp_list}, + ) + return Response(serialized_burps.data) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: FindingToNotesSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewNoteOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.NoteSerializer}, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) + def notes(self, request, pk=None): + finding = self.get_object() + if request.method == "POST": + new_note = api_v2_serializers.AddNewNoteOptionSerializer( + data=request.data, + ) + if new_note.is_valid(): + entry = new_note.validated_data["entry"] + private = new_note.validated_data.get("private", False) + note_type = new_note.validated_data.get("note_type", None) + else: + return Response( + new_note.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + if finding.notes: + notes = finding.notes.filter(note_type=note_type).first() + if notes and note_type and note_type.is_single: + return Response("Only one instance of this note_type allowed on a finding.", status=status.HTTP_400_BAD_REQUEST) + + author = request.user + note = Notes( + entry=entry, + author=author, + private=private, + note_type=note_type, + ) + note.save() + # Add an entry to the note history + history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) + note.history.add(history) + # Now add the note to the object + finding.last_reviewed = note.date + finding.last_reviewed_by = author + finding.save(update_fields=["last_reviewed", "last_reviewed_by", "updated"]) + finding.notes.add(note) + # Determine if we need to send any notifications for user mentioned + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri( + reverse("view_finding", args=(finding.id,)), + ), + parent_title=f"Finding: {finding.title}", + ) + + if finding.has_jira_issue: + jira_services.add_comment(finding, note) + elif finding.has_jira_group_issue: + jira_services.add_comment(finding.finding_group, note) + + serialized_note = api_v2_serializers.NoteSerializer( + {"author": author, "entry": entry, "private": private}, + ) + return Response( + serialized_note.data, status=status.HTTP_201_CREATED, + ) + notes = finding.notes.all() + + serialized_notes = FindingToNotesSerializer( + {"finding_id": finding, "notes": notes}, + ) + return Response(serialized_notes.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={status.HTTP_200_OK: FindingToFilesSerializer}, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewFileOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.FileSerializer}, + ) + @action( + detail=True, methods=["get", "post"], parser_classes=(MultiPartParser,), permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def files(self, request, pk=None): + finding = self.get_object() + if request.method == "POST": + new_file = api_v2_serializers.FileSerializer(data=request.data) + if new_file.is_valid(): + title = new_file.validated_data["title"] + file = new_file.validated_data["file"] + else: + return Response( + new_file.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + file = FileUpload(title=title, file=file) + file.save() + finding.files.add(file) + + serialized_file = api_v2_serializers.FileSerializer(file) + return Response( + serialized_file.data, status=status.HTTP_201_CREATED, + ) + + files = finding.files.all() + serialized_files = FindingToFilesSerializer( + {"finding_id": finding, "files": files}, + ) + return Response(serialized_files.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: api_v2_serializers.RawFileSerializer, + }, + ) + @action( + detail=True, + methods=["get"], + url_path=r"files/download/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def download_file(self, request, file_id, pk=None): + finding = self.get_object() + # Get the file object + file_object_qs = finding.files.filter(id=file_id) + file_object = ( + file_object_qs.first() if len(file_object_qs) > 0 else None + ) + if file_object is None: + return Response( + {"error": "File ID not associated with Finding"}, + status=status.HTTP_404_NOT_FOUND, + ) + # send file + return generate_file_response(file_object) + + @extend_schema( + request=FindingNoteSerializer, + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action(detail=True, methods=["patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingNotePermission)) + def remove_note(self, request, pk=None): + """Remove Note From Finding Note""" + finding = self.get_object() + notes = finding.notes.all() + if request.data["note_id"]: + note = get_object_or_404(Notes.objects, id=request.data["note_id"]) + if note not in notes: + return Response( + {"error": "Selected Note is not assigned to this Finding"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + {"error": "('note_id') parameter missing"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if ( + note.author.username == request.user.username + or request.user.is_superuser + ): + finding.notes.remove(note) + note.delete() + else: + return Response( + {"error": "Delete Failed, You are not the Note's author"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + {"Success": "Selected Note has been Removed successfully"}, + status=status.HTTP_204_NO_CONTENT, + ) + + @extend_schema( + methods=["PUT", "PATCH"], + request=api_v2_serializers.TagSerializer, + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action(detail=True, methods=["put", "patch"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def remove_tags(self, request, pk=None): + """Remove Tag(s) from finding list of tags""" + finding = self.get_object() + delete_tags = api_v2_serializers.TagSerializer(data=request.data) + if delete_tags.is_valid(): + all_tags = finding.tags + all_tags = api_v2_serializers.TagSerializer({"tags": all_tags}).data[ + "tags" + ] + + # serializer turns it into a string, but we need a list + del_tags = delete_tags.validated_data["tags"] + if len(del_tags) < 1: + return Response( + {"error": "Empty Tag List Not Allowed"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + for tag in del_tags: + if tag not in all_tags: + return Response( + { + "error": f"'{tag}' is not a valid tag in list '{all_tags}'", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + all_tags.remove(tag) + new_tags = tagulous.utils.render_tags(all_tags) + finding.tags = new_tags + finding.save() + return Response( + {"success": "Tag(s) Removed"}, + status=status.HTTP_204_NO_CONTENT, + ) + return Response( + delete_tags.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + @extend_schema( + responses={ + status.HTTP_200_OK: FindingSerializer(many=True), + }, + ) + @action( + detail=True, + methods=["get"], + url_path=r"duplicate", + filter_backends=[], + pagination_class=None, + permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def get_duplicate_cluster(self, request, pk): + finding = self.get_object() + result = duplicate_cluster(request, finding) + serializer = FindingSerializer( + instance=result, many=True, context={"request": request}, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + request=OpenApiTypes.NONE, + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action(detail=True, methods=["post"], url_path=r"duplicate/reset", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) + def reset_finding_duplicate_status(self, request, pk): + self.get_object() + checked_duplicate_id = reset_finding_duplicate_status_internal( + request.user, pk, + ) + if checked_duplicate_id is None: + return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + request=OpenApiTypes.NONE, + parameters=[ + OpenApiParameter( + "new_fid", OpenApiTypes.INT, OpenApiParameter.PATH, + ), + ], + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + @action( + detail=True, methods=["post"], url_path=r"original/(?P\d+)", permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def set_finding_as_original(self, request, pk, new_fid): + self.get_object() + success = set_finding_as_original_internal(request.user, pk, new_fid) + if not success: + return Response(status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + request=api_v2_serializers.ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: api_v2_serializers.ReportGenerateSerializer}, + ) + @action( + detail=False, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request): + findings = self.get_queryset() + options = {} + # prepare post data + report_options = api_v2_serializers.ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, findings, options) + report = api_v2_serializers.ReportGenerateSerializer(data) + return Response(report.data) + + def _get_metadata(self, request, finding): + metadata = DojoMeta.objects.filter(finding=finding) + serializer = FindingMetaSerializer( + instance=metadata, many=True, + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + def _edit_metadata(self, request, finding): + metadata_name = request.query_params.get("name", None) + if metadata_name is None: + return Response( + "Metadata name is required", status=status.HTTP_400_BAD_REQUEST, + ) + + try: + DojoMeta.objects.update_or_create( + name=metadata_name, + finding=finding, + defaults={ + "name": request.data.get("name"), + "value": request.data.get("value"), + }, + ) + + return Response(data=request.data, status=status.HTTP_200_OK) + except IntegrityError: + return Response( + "Update failed because the new name already exists", + status=status.HTTP_400_BAD_REQUEST, + ) + + def _add_metadata(self, request, finding): + metadata_data = FindingMetaSerializer(data=request.data) + + if metadata_data.is_valid(): + name = metadata_data.validated_data["name"] + value = metadata_data.validated_data["value"] + + metadata = DojoMeta(finding=finding, name=name, value=value) + try: + metadata.validate_unique() + metadata.save() + except ValidationError: + return Response( + "Create failed probably because the name of the metadata already exists", + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(data=metadata_data.data, status=status.HTTP_200_OK) + return Response( + metadata_data.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + def _remove_metadata(self, request, finding): + name = request.query_params.get("name", None) + if name is None: + return Response( + "A metadata name must be provided", + status=status.HTTP_400_BAD_REQUEST, + ) + + metadata = get_object_or_404( + DojoMeta.objects, finding=finding, name=name, + ) + metadata.delete() + + return Response("Metadata deleted", status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: FindingMetaSerializer(many=True), + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + }, + ) + @extend_schema( + methods=["DELETE"], + parameters=[ + OpenApiParameter( + "name", + OpenApiTypes.INT, + OpenApiParameter.QUERY, + required=True, + description="name of the metadata to retrieve. If name is empty, return all the \ + metadata associated with the finding", + ), + ], + responses={ + status.HTTP_200_OK: OpenApiResponse( + description="Returned if the metadata was correctly deleted", + ), + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Returned if there was a problem with the metadata information", + ), + }, + ) + @extend_schema( + methods=["PUT"], + request=FindingMetaSerializer, + responses={ + status.HTTP_200_OK: FindingMetaSerializer, + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Returned if there was a problem with the metadata information", + ), + }, + ) + @extend_schema( + methods=["POST"], + request=FindingMetaSerializer, + responses={ + status.HTTP_200_OK: FindingMetaSerializer, + status.HTTP_404_NOT_FOUND: OpenApiResponse( + description="Returned if finding does not exist", + ), + status.HTTP_400_BAD_REQUEST: OpenApiResponse( + description="Returned if there was a problem with the metadata information", + ), + }, + ) + @action( + detail=True, + methods=["post", "put", "delete", "get"], + filter_backends=[], + pagination_class=None, + permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission), + ) + def metadata(self, request, pk=None): + finding = self.get_object() + + if request.method == "GET": + return self._get_metadata(request, finding) + if request.method == "POST": + return self._add_metadata(request, finding) + if request.method in {"PUT", "PATCH"}: + return self._edit_metadata(request, finding) + if request.method == "DELETE": + return self._remove_metadata(request, finding) + + return Response( + {"error", "unsupported method"}, status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/dojo/test/api/serializer.py b/dojo/test/api/serializer.py index fcce90fdd82..c5dda0409a8 100644 --- a/dojo/test/api/serializer.py +++ b/dojo/test/api/serializer.py @@ -20,9 +20,11 @@ class Meta: def get_fields(self): from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency - FindingGroupSerializer, TagListSerializerField, ) + from dojo.finding.api.serializer import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + FindingGroupSerializer, + ) fields = super().get_fields() fields["tags"] = TagListSerializerField(required=False) fields["finding_groups"] = FindingGroupSerializer( diff --git a/dojo/urls.py b/dojo/urls.py index a87b31a3913..fa91624cee9 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -22,8 +22,6 @@ EndpointMetaImporterView, EndpointStatusViewSet, EndPointViewSet, - FindingTemplatesViewSet, - FindingViewSet, ImportLanguagesView, ImportScanView, JiraInstanceViewSet, @@ -58,6 +56,7 @@ from dojo.endpoint.urls import urlpatterns as endpoint_urls from dojo.engagement.api.urls import add_engagement_urls from dojo.engagement.ui.urls import urlpatterns as eng_urls +from dojo.finding.api.urls import add_finding_urls from dojo.finding.ui.urls import urlpatterns as finding_urls from dojo.finding_group.urls import urlpatterns as finding_group_urls from dojo.github.ui.urls import urlpatterns as github_urls @@ -109,8 +108,6 @@ # RBAC endpoints moved to Pro under legacy authorization: # dojo_groups, dojo_group_members → pro/groups, pro/group_members v2_api.register(r"endpoint_meta_import", EndpointMetaImporterView, basename="endpointmetaimport") -v2_api.register(r"finding_templates", FindingTemplatesViewSet, basename="finding_template") -v2_api.register(r"findings", FindingViewSet, basename="finding") # RBAC endpoint moved to Pro under legacy authorization: global_roles → pro/global_roles v2_api.register(r"import-languages", ImportLanguagesView, basename="importlanguages") v2_api.register(r"import-scan", ImportScanView, basename="importscan") @@ -131,6 +128,7 @@ # product_groups, product_members → pro/product_groups, pro/product_members v2_api = add_product_type_urls(v2_api) v2_api = add_engagement_urls(v2_api) +v2_api = add_finding_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") diff --git a/unittests/test_filter_finding_mitigation.py b/unittests/test_filter_finding_mitigation.py index 568c6d3d4ed..d21e7db7693 100644 --- a/unittests/test_filter_finding_mitigation.py +++ b/unittests/test_filter_finding_mitigation.py @@ -3,7 +3,7 @@ from django.test import TestCase from django.utils import timezone -from dojo.filters import ApiFindingFilter +from dojo.finding.api.filters import ApiFindingFilter from dojo.finding.ui.filters import FindingFilterHelper from dojo.models import ( Dojo_User, diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 222f478c933..d4d1bba79d2 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -44,8 +44,6 @@ DevelopmentEnvironmentViewSet, EndpointStatusViewSet, EndPointViewSet, - FindingTemplatesViewSet, - FindingViewSet, ImportLanguagesView, ImportScanView, JiraInstanceViewSet, @@ -69,6 +67,7 @@ ) from dojo.authorization.roles_permissions import Permissions, permission_to_action from dojo.engagement.api.views import EngagementViewSet +from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet from dojo.location.api.endpoint_compat import V3EndpointCompatibleViewSet, V3EndpointStatusCompatibleViewSet from dojo.location.api.views import LocationFindingReferenceViewSet, LocationProductReferenceViewSet, LocationViewSet from dojo.location.models import Location, LocationFindingReference, LocationProductReference From f01e6535ad6628532405a3f636ad1b5c409797ed Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 22:46:18 +0200 Subject: [PATCH 28/40] refactor(finding): fold CWE + BurpRawRequestResponse into dojo/finding/ [finding Phase 1,6,8,9] --- dojo/api_v2/serializers.py | 150 +----------------------------- dojo/api_v2/views.py | 31 ------- dojo/finding/admin.py | 13 ++- dojo/finding/api/serializer.py | 155 ++++++++++++++++++++++++++++++- dojo/finding/api/urls.py | 7 +- dojo/finding/api/views.py | 39 +++++++- dojo/finding/models.py | 20 ++++ dojo/models.py | 25 +---- dojo/urls.py | 2 - unittests/test_rest_framework.py | 7 +- 10 files changed, 232 insertions(+), 217 deletions(-) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index f6fc378417e..1f631da291b 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -1,5 +1,3 @@ -import base64 -import collections import json import logging import re @@ -21,7 +19,6 @@ from rest_framework import serializers from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError as RestFrameworkValidationError -from rest_framework.fields import DictField import dojo.risk_acceptance.helper as ra_helper from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import @@ -38,7 +35,6 @@ STATS_FIELDS, Announcement, App_Analysis, - BurpRawRequestResponse, Development_Environment, Dojo_User, DojoMeta, @@ -214,150 +210,6 @@ def to_representation(self, value): return value -class RequestResponseDict(collections.UserList): - def __init__(self, *args, **kwargs): - pretty_print = kwargs.pop("pretty_print", True) - collections.UserList.__init__(self, *args, **kwargs) - self.pretty_print = pretty_print - - def __add__(self, rhs): - return RequestResponseDict(list.__add__(self, rhs)) - - def __getitem__(self, item): - result = list.__getitem__(self, item) - try: - return RequestResponseDict(result) - except TypeError: - return result - - def __str__(self): - if self.pretty_print: - return json.dumps( - self, sort_keys=True, indent=4, separators=(",", ": "), - ) - return json.dumps(self) - - -class RequestResponseSerializerField(serializers.ListSerializer): - child = DictField(child=serializers.CharField()) - default_error_messages = { - "not_a_list": _( - 'Expected a list of items but got type "{input_type}".', - ), - "invalid_json": _( - "Invalid json list. A tag list submitted in string" - " form must be valid json.", - ), - "not_a_dict": _( - "All list items must be of dict type with keys 'request' and 'response'", - ), - "not_a_str": _("All values in the dict must be of string type."), - } - order_by = None - - def __init__(self, **kwargs): - pretty_print = kwargs.pop("pretty_print", True) - - style = kwargs.pop("style", {}) - kwargs["style"] = {"base_template": "textarea.html"} - kwargs["style"].update(style) - - if "data" in kwargs: - data = kwargs["data"] - - if isinstance(data, list): - kwargs["many"] = True - - super().__init__(**kwargs) - - self.pretty_print = pretty_print - - def to_internal_value(self, data): - if isinstance(data, six.string_types): - if not data: - data = [] - try: - data = json.loads(data) - except ValueError: - self.fail("invalid_json") - - if not isinstance(data, list): - self.fail("not_a_list", input_type=type(data).__name__) - for s in data: - if not isinstance(s, dict): - self.fail("not_a_dict", input_type=type(s).__name__) - - request = s.get("request", None) - response = s.get("response", None) - - if not isinstance(request, str): - self.fail("not_a_str", input_type=type(request).__name__) - if not isinstance(response, str): - self.fail("not_a_str", input_type=type(request).__name__) - - self.child.run_validation(s) - return data - - def to_representation(self, value): - if not isinstance(value, RequestResponseDict): - if not isinstance(value, list): - # this will trigger when a queryset is found... - burps = value.all().order_by(*self.order_by) if self.order_by else value.all() - value = [ - { - "request": burp.get_request(), - "response": burp.get_response(), - } - for burp in burps - ] - - return value - - -class BurpRawRequestResponseSerializer(serializers.Serializer): - req_resp = RequestResponseSerializerField(required=True) - - -class BurpRawRequestResponseMultiSerializer(serializers.ModelSerializer): - burpRequestBase64 = serializers.CharField() - burpResponseBase64 = serializers.CharField() - - def to_representation(self, data): - return { - "id": data.id, - "finding": data.finding.id, - "burpRequestBase64": data.burpRequestBase64.decode("utf-8"), - "burpResponseBase64": data.burpResponseBase64.decode("utf-8"), - } - - def validate(self, data): - b64request = data.get("burpRequestBase64", None) - b64response = data.get("burpResponseBase64", None) - finding = data.get("finding", None) - # Make sure all fields are present - if not b64request or not b64response or not finding: - msg = "burpRequestBase64, burpResponseBase64, and finding are required." - raise ValidationError(msg) - # Verify we have true base64 decoding - try: - base64.b64decode(b64request, validate=True) - base64.b64decode(b64response, validate=True) - except Exception as e: - msg = "Inputs need to be valid base64 encodings" - raise ValidationError(msg) from e - # Encode the data in utf-8 to remove any bad characters - data["burpRequestBase64"] = b64request.encode("utf-8") - data["burpResponseBase64"] = b64response.encode("utf-8") - # Run the model validation - an ValidationError will be raised if there is an issue - BurpRawRequestResponse(finding=finding, burpRequestBase64=b64request, burpResponseBase64=b64response).clean() - - return data - - class Meta: - model = BurpRawRequestResponse - fields = "__all__" - - class MetaSerializer(serializers.ModelSerializer): product = serializers.PrimaryKeyRelatedField( queryset=Product.objects.all(), @@ -1733,6 +1585,8 @@ class ExecutiveSummarySerializer(serializers.Serializer): # (dojo/api_v2/prefetch/prefetcher.py inspects this module to build its model->serializer # map); changing that membership would silently change prefetch responses. from dojo.finding.api.serializer import ( # noqa: E402 -- backward compat + BurpRawRequestResponseMultiSerializer, # noqa: F401 -- backward compat / prefetcher discovery + BurpRawRequestResponseSerializer, # noqa: F401 -- backward compat FindingCloseSerializer, # noqa: F401 -- backward compat / prefetcher discovery FindingCreateSerializer, # noqa: F401 -- backward compat / prefetcher discovery FindingEngagementSerializer, # noqa: F401 -- backward compat / prefetcher discovery diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index cbe7353292c..09bc52e7252 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -53,9 +53,6 @@ ApiRiskAcceptanceFilter, ApiUserFilter, ) -from dojo.finding.queries import ( - get_authorized_findings, -) from dojo.finding.ui.filters import ( ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, @@ -66,7 +63,6 @@ from dojo.models import ( Announcement, App_Analysis, - BurpRawRequestResponse, Development_Environment, Dojo_User, DojoMeta, @@ -987,33 +983,6 @@ def get_queryset(self): return Note_Type.objects.all().order_by("id") -class BurpRawRequestResponseViewSet( - DojoModelViewSet, -): - serializer_class = serializers.BurpRawRequestResponseMultiSerializer - queryset = BurpRawRequestResponse.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["finding"] - permission_classes = ( - IsAuthenticated, - permissions.UserHasBurpRawRequestResponsePermission, - ) - - def get_queryset(self): - return ( - BurpRawRequestResponse.objects.filter( - finding__in=get_authorized_findings( - "view", - ), - ) - .exclude( - burpRequestBase64__exact=b"", - burpResponseBase64__exact=b"", - ) - .order_by("id") - ) - - # Authorization: superuser class NotesViewSet( mixins.UpdateModelMixin, diff --git a/dojo/finding/admin.py b/dojo/finding/admin.py index 61d6098a002..f10732d25f7 100644 --- a/dojo/finding/admin.py +++ b/dojo/finding/admin.py @@ -1,6 +1,13 @@ from django.contrib import admin -from dojo.finding.models import Finding, Finding_Group, Finding_Template, Vulnerability_Id +from dojo.finding.models import ( + CWE, + BurpRawRequestResponse, + Finding, + Finding_Group, + Finding_Template, + Vulnerability_Id, +) @admin.register(Finding) @@ -29,3 +36,7 @@ class VulnerabilityIdAdmin(admin.ModelAdmin): class FindingGroupAdmin(admin.ModelAdmin): """Admin support for the Finding_Group model.""" + + +admin.site.register(CWE) +admin.site.register(BurpRawRequestResponse) diff --git a/dojo/finding/api/serializer.py b/dojo/finding/api/serializer.py index cb17386135c..360f08b7926 100644 --- a/dojo/finding/api/serializer.py +++ b/dojo/finding/api/serializer.py @@ -1,10 +1,16 @@ +import base64 +import collections +import json import logging +import six from django.conf import settings +from django.core.exceptions import ValidationError from django.urls import reverse from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from rest_framework.fields import DictField import dojo.finding.helper as finding_helper from dojo.authorization.authorization import user_has_permission @@ -14,6 +20,7 @@ save_vulnerability_ids, save_vulnerability_ids_template, ) +from dojo.finding.models import BurpRawRequestResponse from dojo.jira import services as jira_services from dojo.jira.api.serializers import JIRAIssueSerializer from dojo.location.models import LocationFindingReference @@ -41,6 +48,150 @@ logger = logging.getLogger(__name__) +class RequestResponseDict(collections.UserList): + def __init__(self, *args, **kwargs): + pretty_print = kwargs.pop("pretty_print", True) + collections.UserList.__init__(self, *args, **kwargs) + self.pretty_print = pretty_print + + def __add__(self, rhs): + return RequestResponseDict(list.__add__(self, rhs)) + + def __getitem__(self, item): + result = list.__getitem__(self, item) + try: + return RequestResponseDict(result) + except TypeError: + return result + + def __str__(self): + if self.pretty_print: + return json.dumps( + self, sort_keys=True, indent=4, separators=(",", ": "), + ) + return json.dumps(self) + + +class RequestResponseSerializerField(serializers.ListSerializer): + child = DictField(child=serializers.CharField()) + default_error_messages = { + "not_a_list": _( + 'Expected a list of items but got type "{input_type}".', + ), + "invalid_json": _( + "Invalid json list. A tag list submitted in string" + " form must be valid json.", + ), + "not_a_dict": _( + "All list items must be of dict type with keys 'request' and 'response'", + ), + "not_a_str": _("All values in the dict must be of string type."), + } + order_by = None + + def __init__(self, **kwargs): + pretty_print = kwargs.pop("pretty_print", True) + + style = kwargs.pop("style", {}) + kwargs["style"] = {"base_template": "textarea.html"} + kwargs["style"].update(style) + + if "data" in kwargs: + data = kwargs["data"] + + if isinstance(data, list): + kwargs["many"] = True + + super().__init__(**kwargs) + + self.pretty_print = pretty_print + + def to_internal_value(self, data): + if isinstance(data, six.string_types): + if not data: + data = [] + try: + data = json.loads(data) + except ValueError: + self.fail("invalid_json") + + if not isinstance(data, list): + self.fail("not_a_list", input_type=type(data).__name__) + for s in data: + if not isinstance(s, dict): + self.fail("not_a_dict", input_type=type(s).__name__) + + request = s.get("request", None) + response = s.get("response", None) + + if not isinstance(request, str): + self.fail("not_a_str", input_type=type(request).__name__) + if not isinstance(response, str): + self.fail("not_a_str", input_type=type(request).__name__) + + self.child.run_validation(s) + return data + + def to_representation(self, value): + if not isinstance(value, RequestResponseDict): + if not isinstance(value, list): + # this will trigger when a queryset is found... + burps = value.all().order_by(*self.order_by) if self.order_by else value.all() + value = [ + { + "request": burp.get_request(), + "response": burp.get_response(), + } + for burp in burps + ] + + return value + + +class BurpRawRequestResponseSerializer(serializers.Serializer): + req_resp = RequestResponseSerializerField(required=True) + + +class BurpRawRequestResponseMultiSerializer(serializers.ModelSerializer): + burpRequestBase64 = serializers.CharField() + burpResponseBase64 = serializers.CharField() + + def to_representation(self, data): + return { + "id": data.id, + "finding": data.finding.id, + "burpRequestBase64": data.burpRequestBase64.decode("utf-8"), + "burpResponseBase64": data.burpResponseBase64.decode("utf-8"), + } + + def validate(self, data): + b64request = data.get("burpRequestBase64", None) + b64response = data.get("burpResponseBase64", None) + finding = data.get("finding", None) + # Make sure all fields are present + if not b64request or not b64response or not finding: + msg = "burpRequestBase64, burpResponseBase64, and finding are required." + raise ValidationError(msg) + # Verify we have true base64 decoding + try: + base64.b64decode(b64request, validate=True) + base64.b64decode(b64response, validate=True) + except Exception as e: + msg = "Inputs need to be valid base64 encodings" + raise ValidationError(msg) from e + # Encode the data in utf-8 to remove any bad characters + data["burpRequestBase64"] = b64request.encode("utf-8") + data["burpResponseBase64"] = b64response.encode("utf-8") + # Run the model validation - an ValidationError will be raised if there is an issue + BurpRawRequestResponse(finding=finding, burpRequestBase64=b64request, burpResponseBase64=b64response).clean() + + return data + + class Meta: + model = BurpRawRequestResponse + fields = "__all__" + + class FindingGroupSerializer(serializers.ModelSerializer): jira_issue = JIRAIssueSerializer(read_only=True, allow_null=True) @@ -378,9 +529,6 @@ def build_relational_field(self, field_name, relation_info): return super().build_relational_field(field_name, relation_info) def get_request_response(self, obj): - from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency - BurpRawRequestResponseSerializer, - ) # Not necessarily Burp scan specific - these are just any request/response pairs burp_req_resp = obj.burprawrequestresponse_set.all() var = settings.MAX_REQRESP_FROM_API @@ -727,7 +875,6 @@ def _apply_schema_overrides(): from drf_spectacular.utils import set_override # noqa: PLC0415 -- lazy import, avoids circular dependency from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency - BurpRawRequestResponseSerializer, RiskAcceptanceSerializer, ) set_override(FindingSerializer.get_accepted_risks, "field", RiskAcceptanceSerializer(many=True)) diff --git a/dojo/finding/api/urls.py b/dojo/finding/api/urls.py index 3a2e8f3143d..c2d240d3a1a 100644 --- a/dojo/finding/api/urls.py +++ b/dojo/finding/api/urls.py @@ -1,7 +1,12 @@ -from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet +from dojo.finding.api.views import ( + BurpRawRequestResponseViewSet, + FindingTemplatesViewSet, + FindingViewSet, +) def add_finding_urls(router): router.register("finding_templates", FindingTemplatesViewSet, basename="finding_template") router.register("findings", FindingViewSet, basename="finding") + router.register("request_response_pairs", BurpRawRequestResponseViewSet, basename="request_response_pairs") return router diff --git a/dojo/finding/api/views.py b/dojo/finding/api/views.py index 877657165f3..d40f72fd165 100644 --- a/dojo/finding/api/views.py +++ b/dojo/finding/api/views.py @@ -36,6 +36,8 @@ from dojo.authorization import api_permissions as permissions from dojo.finding.api.filters import ApiFindingFilter, ApiTemplateFindingFilter from dojo.finding.api.serializer import ( + BurpRawRequestResponseMultiSerializer, + BurpRawRequestResponseSerializer, FindingCloseSerializer, FindingCreateSerializer, FindingMetaSerializer, @@ -316,14 +318,14 @@ def tags(self, request, pk=None): @extend_schema( methods=["GET"], responses={ - status.HTTP_200_OK: api_v2_serializers.BurpRawRequestResponseSerializer, + status.HTTP_200_OK: BurpRawRequestResponseSerializer, }, ) @extend_schema( methods=["POST"], - request=api_v2_serializers.BurpRawRequestResponseSerializer, + request=BurpRawRequestResponseSerializer, responses={ - status.HTTP_201_CREATED: api_v2_serializers.BurpRawRequestResponseSerializer, + status.HTTP_201_CREATED: BurpRawRequestResponseSerializer, }, ) @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasFindingRelatedObjectPermission)) @@ -331,7 +333,7 @@ def request_response(self, request, pk=None): finding = self.get_object() if request.method == "POST": - burps = api_v2_serializers.BurpRawRequestResponseSerializer( + burps = BurpRawRequestResponseSerializer( data=request.data, many=isinstance(request.data, list), ) if burps.is_valid(): @@ -362,7 +364,7 @@ def request_response(self, request, pk=None): request = burp.get_request() response = burp.get_response() burp_list.append({"request": request, "response": response}) - serialized_burps = api_v2_serializers.BurpRawRequestResponseSerializer( + serialized_burps = BurpRawRequestResponseSerializer( {"req_resp": burp_list}, ) return Response(serialized_burps.data) @@ -831,3 +833,30 @@ def metadata(self, request, pk=None): return Response( {"error", "unsupported method"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class BurpRawRequestResponseViewSet( + DojoModelViewSet, +): + serializer_class = BurpRawRequestResponseMultiSerializer + queryset = BurpRawRequestResponse.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["finding"] + permission_classes = ( + IsAuthenticated, + permissions.UserHasBurpRawRequestResponsePermission, + ) + + def get_queryset(self): + return ( + BurpRawRequestResponse.objects.filter( + finding__in=get_authorized_findings( + "view", + ), + ) + .exclude( + burpRequestBase64__exact=b"", + burpResponseBase64__exact=b"", + ) + .order_by("id") + ) diff --git a/dojo/finding/models.py b/dojo/finding/models.py index 19772f95519..337e2b97015 100644 --- a/dojo/finding/models.py +++ b/dojo/finding/models.py @@ -1495,3 +1495,23 @@ def endpoints(self): return [] # Parse newline-separated string, remove empty lines return [line.strip() for line in self.endpoints_text.split("\n") if line.strip()] + + +class CWE(models.Model): + url = models.CharField(max_length=1000) + description = models.CharField(max_length=2000) + number = models.IntegerField() + + +class BurpRawRequestResponse(models.Model): + finding = models.ForeignKey("dojo.Finding", blank=True, null=True, on_delete=models.CASCADE) + burpRequestBase64 = models.BinaryField() + burpResponseBase64 = models.BinaryField() + + def get_request(self): + return str(base64.b64decode(self.burpRequestBase64), errors="ignore") + + def get_response(self): + res = str(base64.b64decode(self.burpResponseBase64), errors="ignore") + # Removes all blank lines + return re.sub(r"\n\s*\n", "\n", res) diff --git a/dojo/models.py b/dojo/models.py index 3cd1041caec..4dfc2336700 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1,4 +1,3 @@ -import base64 import contextlib import copy import logging @@ -1024,12 +1023,6 @@ def __str__(self): ) -class CWE(models.Model): - url = models.CharField(max_length=1000) - description = models.CharField(max_length=2000) - number = models.IntegerField() - - class Endpoint_Params(models.Model): param = models.CharField(max_length=150) value = models.CharField(max_length=150) @@ -1501,6 +1494,8 @@ class Meta: from dojo.finding.models import ( # noqa: E402 -- re-export; class-body FKs below reference these + CWE, + BurpRawRequestResponse, # noqa: F401 -- re-export Finding, Finding_Group, # noqa: F401 -- re-export Finding_Template, @@ -1560,20 +1555,6 @@ def get_breadcrumb(self): return bc -class BurpRawRequestResponse(models.Model): - finding = models.ForeignKey(Finding, blank=True, null=True, on_delete=models.CASCADE) - burpRequestBase64 = models.BinaryField() - burpResponseBase64 = models.BinaryField() - - def get_request(self): - return str(base64.b64decode(self.burpRequestBase64), errors="ignore") - - def get_response(self): - res = str(base64.b64decode(self.burpResponseBase64), errors="ignore") - # Removes all blank lines - return re.sub(r"\n\s*\n", "\n", res) - - class Risk_Acceptance(models.Model): TREATMENT_ACCEPT = "A" TREATMENT_AVOID = "V" @@ -2214,7 +2195,6 @@ def __str__(self): admin.site.register(Tool_Type) admin.site.register(System_Settings) admin.site.register(SLA_Configuration) -admin.site.register(CWE) admin.site.register(Regulation) from dojo.authorization.models import ( # noqa: E402 Dojo_Group, @@ -2246,7 +2226,6 @@ def __str__(self): admin.site.register(Report_Type) admin.site.register(DojoMeta) admin.site.register(Development_Environment) -admin.site.register(BurpRawRequestResponse) admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) diff --git a/dojo/urls.py b/dojo/urls.py index fa91624cee9..db1955dd439 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -14,7 +14,6 @@ from dojo.api_v2.views import ( AnnouncementViewSet, AppAnalysisViewSet, - BurpRawRequestResponseViewSet, CeleryViewSet, ConfigurationPermissionViewSet, DevelopmentEnvironmentViewSet, @@ -133,7 +132,6 @@ # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") v2_api.register(r"reimport-scan", ReImportScanView, basename="reimportscan") -v2_api.register(r"request_response_pairs", BurpRawRequestResponseViewSet, basename="request_response_pairs") v2_api.register(r"risk_acceptance", RiskAcceptanceViewSet, basename="risk_acceptance") # RBAC endpoint moved to Pro under legacy authorization: roles → pro/roles v2_api.register(r"sla_configurations", SLAConfigurationViewset, basename="sla_configurations") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index d4d1bba79d2..6a455168670 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -39,7 +39,6 @@ from dojo.api_v2.views import ( AnnouncementViewSet, AppAnalysisViewSet, - BurpRawRequestResponseViewSet, ConfigurationPermissionViewSet, DevelopmentEnvironmentViewSet, EndpointStatusViewSet, @@ -67,7 +66,11 @@ ) from dojo.authorization.roles_permissions import Permissions, permission_to_action from dojo.engagement.api.views import EngagementViewSet -from dojo.finding.api.views import FindingTemplatesViewSet, FindingViewSet +from dojo.finding.api.views import ( + BurpRawRequestResponseViewSet, + FindingTemplatesViewSet, + FindingViewSet, +) from dojo.location.api.endpoint_compat import V3EndpointCompatibleViewSet, V3EndpointStatusCompatibleViewSet from dojo.location.api.views import LocationFindingReferenceViewSet, LocationProductReferenceViewSet, LocationViewSet from dojo.location.models import Location, LocationFindingReference, LocationProductReference From 0265b28fdfce8437a3c9520c56b8e77834525794 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 23:07:36 +0200 Subject: [PATCH 29/40] refactor(user): extract user module into dojo/user/ [user Phase 1,3,4,5,6,7,8,9] --- dojo/api_v2/serializers.py | 193 +------------------------- dojo/api_v2/views.py | 82 ----------- dojo/filters.py | 73 +--------- dojo/forms.py | 107 --------------- dojo/metrics/views.py | 2 +- dojo/models.py | 75 +--------- dojo/urls.py | 10 +- dojo/user/__init__.py | 1 + dojo/user/admin.py | 6 + dojo/user/api/__init__.py | 1 + dojo/user/api/filters.py | 44 ++++++ dojo/user/api/serializer.py | 196 +++++++++++++++++++++++++++ dojo/user/api/urls.py | 7 + dojo/user/api/views.py | 102 ++++++++++++++ dojo/user/models.py | 78 +++++++++++ dojo/user/ui/__init__.py | 0 dojo/user/ui/filters.py | 36 +++++ dojo/user/ui/forms.py | 114 ++++++++++++++++ dojo/user/{ => ui}/urls.py | 2 +- dojo/user/{ => ui}/views.py | 12 +- unittests/test_rest_framework.py | 3 +- unittests/test_user_ui_timestamps.py | 4 +- 22 files changed, 612 insertions(+), 536 deletions(-) create mode 100644 dojo/user/admin.py create mode 100644 dojo/user/api/__init__.py create mode 100644 dojo/user/api/filters.py create mode 100644 dojo/user/api/serializer.py create mode 100644 dojo/user/api/urls.py create mode 100644 dojo/user/api/views.py create mode 100644 dojo/user/models.py create mode 100644 dojo/user/ui/__init__.py create mode 100644 dojo/user/ui/filters.py create mode 100644 dojo/user/ui/forms.py rename dojo/user/{ => ui}/urls.py (99%) rename dojo/user/{ => ui}/views.py (99%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 1f631da291b..4f8794fcb5c 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -8,7 +8,6 @@ import tagulous from django.conf import settings from django.contrib.auth.models import Permission -from django.contrib.auth.password_validation import validate_password from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.db.utils import IntegrityError @@ -36,7 +35,6 @@ Announcement, App_Analysis, Development_Environment, - Dojo_User, DojoMeta, Endpoint, Endpoint_Params, @@ -64,7 +62,6 @@ Tool_Product_Settings, Tool_Type, User, - UserContactInfo, get_current_date, ) from dojo.product_announcements import ( @@ -76,7 +73,6 @@ requires_file, requires_tool_type, ) -from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import is_scan_file_too_large from dojo.validators import ImporterFileExtensionValidator, tag_validator @@ -303,183 +299,13 @@ def validate(self, data): return data -class UserSerializer(serializers.ModelSerializer): - date_joined = serializers.DateTimeField(read_only=True) - last_login = serializers.DateTimeField(read_only=True, allow_null=True) - email = serializers.EmailField(required=True) - token_last_reset = serializers.SerializerMethodField() - password_last_reset = serializers.SerializerMethodField() - password = serializers.CharField( - write_only=True, - style={"input_type": "password"}, - required=False, - validators=[validate_password], - ) - configuration_permissions = serializers.PrimaryKeyRelatedField( - allow_null=True, - queryset=Permission.objects.filter( - codename__in=get_configuration_permissions_codenames(), - ), - many=True, - required=False, - source="user_permissions", - ) - - class Meta: - model = Dojo_User - fields = ( - "id", - "username", - "first_name", - "last_name", - "email", - "date_joined", - "last_login", - "is_active", - "is_staff", - "is_superuser", - "token_last_reset", - "password_last_reset", - "password", - "configuration_permissions", - ) - - @extend_schema_field(serializers.DateTimeField(allow_null=True)) - def get_token_last_reset(self, instance): - uci = getattr(instance, "usercontactinfo", None) - return getattr(uci, "token_last_reset", None) - - @extend_schema_field(serializers.DateTimeField(allow_null=True)) - def get_password_last_reset(self, instance): - uci = getattr(instance, "usercontactinfo", None) - return getattr(uci, "password_last_reset", None) - - def to_representation(self, instance): - ret = super().to_representation(instance) - - # This will show only "configuration_permissions" even if user has also - # other permissions - all_permissions = set(ret["configuration_permissions"]) - allowed_configuration_permissions = set( - self.fields[ - "configuration_permissions" - ].child_relation.queryset.values_list("id", flat=True), - ) - ret["configuration_permissions"] = list( - all_permissions.intersection(allowed_configuration_permissions), - ) - - return ret - - def update(self, instance, validated_data): - permissions_in_payload = None - new_configuration_permissions = None - if ( - "user_permissions" in validated_data - ): # This field was renamed from "configuration_permissions" in the meantime - permissions_in_payload = validated_data.pop("user_permissions") - new_configuration_permissions = set(permissions_in_payload) - - instance = super().update(instance, validated_data) - - # This will update only Permissions from category - # "configuration_permissions". Others will be untouched - if new_configuration_permissions: - allowed_configuration_permissions = set( - self.fields[ - "configuration_permissions" - ].child_relation.queryset.all(), - ) - non_configuration_permissions = ( - set(instance.user_permissions.all()) - - allowed_configuration_permissions - ) - new_permissions = non_configuration_permissions.union( - new_configuration_permissions, - ) - instance.user_permissions.set(new_permissions) - - # Clear all configuration permissions if an empty list is provided - if isinstance(permissions_in_payload, list) and len(permissions_in_payload) == 0: - instance.user_permissions.clear() - - return instance - - def create(self, validated_data): - password = validated_data.pop("password", None) - - new_configuration_permissions = None - if ( - "user_permissions" in validated_data - ): # This field was renamed from "configuration_permissions" in the meantime - new_configuration_permissions = set( - validated_data.pop("user_permissions"), - ) - - user = Dojo_User.objects.create(**validated_data) - - if password: - user.set_password(password) - else: - user.set_unusable_password() - - # This will create only Permissions from category - # "configuration_permissions". There are no other Permissions. - if new_configuration_permissions: - user.user_permissions.set(new_configuration_permissions) - - user.save() - return user - - def validate(self, data): - instance_is_superuser = self.instance.is_superuser if self.instance is not None else False - data_is_superuser = data.get("is_superuser", False) - if not self.context["request"].user.is_superuser and ( - instance_is_superuser or data_is_superuser - ): - msg = "Only superusers are allowed to add or edit superusers." - raise ValidationError(msg) - - instance_is_staff = self.instance.is_staff if self.instance is not None else False - data_is_staff = data.get("is_staff", instance_is_staff) - if not self.context["request"].user.is_superuser and data_is_staff != instance_is_staff: - msg = "Only superusers are allowed to add or edit staff users." - raise ValidationError(msg) - - if self.context["request"].method in {"PATCH", "PUT"} and "password" in data: - msg = "Update of password though API is not allowed" - raise ValidationError(msg) - if self.context["request"].method == "POST" and "password" not in data and settings.REQUIRE_PASSWORD_ON_USER: - msg = "Passwords must be supplied for new users" - raise ValidationError(msg) - return super().validate(data) - - -class UserContactInfoSerializer(serializers.ModelSerializer): - user_profile = UserSerializer(many=False, source="user", read_only=True) - - class Meta: - model = UserContactInfo - fields = "__all__" - - def validate(self, data): - user = data.get("user", None) or self.instance.user - if data.get("force_password_reset", False) and not user.has_usable_password(): - msg = "Password resets are not allowed for users authorized through SSO." - raise ValidationError(msg) - return super().validate(data) - - -class UserStubSerializer(serializers.ModelSerializer): - class Meta: - model = Dojo_User - fields = ("id", "username", "first_name", "last_name") - - -class AddUserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ("id", "username") +from dojo.user.api.serializer import ( # noqa: E402, F401 -- backward compat + prefetcher discovery + AddUserSerializer, + UserContactInfoSerializer, + UserProfileSerializer, + UserSerializer, + UserStubSerializer, +) class NoteTypeSerializer(serializers.ModelSerializer): @@ -1687,11 +1513,6 @@ def validate(self, data): return data -class UserProfileSerializer(serializers.Serializer): - user = UserSerializer(many=False) - user_contact_info = UserContactInfoSerializer(many=False, required=False) - - class DeletePreviewSerializer(serializers.Serializer): model = serializers.CharField(read_only=True) id = serializers.IntegerField(read_only=True, allow_null=True) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 09bc52e7252..2e6d706ee27 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -4,7 +4,6 @@ from pathlib import Path import pghistory -from crum import get_current_user from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import Permission @@ -27,7 +26,6 @@ from drf_spectacular.views import SpectacularAPIView from rest_framework import mixins, status, viewsets from rest_framework.decorators import action -from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response @@ -51,7 +49,6 @@ ApiDojoMetaFilter, ApiEndpointFilter, ApiRiskAcceptanceFilter, - ApiUserFilter, ) from dojo.finding.ui.filters import ( ReportFindingFilter, @@ -86,8 +83,6 @@ Tool_Configuration, Tool_Product_Settings, Tool_Type, - User, - UserContactInfo, ) from dojo.product.queries import ( get_authorized_app_analysis, @@ -104,7 +99,6 @@ from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests from dojo.tool_product.queries import get_authorized_tool_product_settings -from dojo.user.authentication import reset_token_for_user from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( get_celery_queue_details, @@ -654,82 +648,6 @@ def get_queryset(self): return Regulation.objects.all().order_by("id") -# Authorization: configuration -class UsersViewSet( - DojoModelViewSet, -): - serializer_class = serializers.UserSerializer - queryset = User.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiUserFilter - permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) - - def get_queryset(self): - return User.objects.all().order_by("id") - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if request.user == instance: - return Response( - "Users may not delete themselves", - status=status.HTTP_400_BAD_REQUEST, - ) - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) - - @action( - detail=True, - methods=["post"], - url_path="reset_api_token", - permission_classes=(IsAuthenticated, permissions.IsSuperUserOrGlobalOwner), - filter_backends=[], - pagination_class=None, - ) - def reset_api_token(self, request, pk=None): - target_user = self.get_object() - reset_token_for_user(acting_user=request.user, target_user=target_user) - return Response(status=status.HTTP_204_NO_CONTENT) - - -# Authorization: superuser -@extend_schema_view(**schema_with_prefetch()) -class UserContactInfoViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.UserContactInfoSerializer - queryset = UserContactInfo.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = "__all__" - permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) - - def get_queryset(self): - return UserContactInfo.objects.all().order_by("id") - - -# Authorization: authenticated users -class UserProfileView(GenericAPIView): - permission_classes = (IsAuthenticated,) - pagination_class = None - serializer_class = serializers.UserProfileSerializer - - @action( - detail=True, methods=["get"], filter_backends=[], pagination_class=None, - ) - def get(self, request, _=None): - user = get_current_user() - user_contact_info = ( - user.usercontactinfo if hasattr(user, "usercontactinfo") else None - ) - serializer = serializers.UserProfileSerializer( - { - "user": user, - "user_contact_info": user_contact_info, - }, - many=False, - ) - return Response(serializer.data) - - # Authorization: authenticated users, DjangoModelPermissions class ImportScanView(mixins.CreateModelMixin, viewsets.GenericViewSet): diff --git a/dojo/filters.py b/dojo/filters.py index ffd44138885..43f588a4503 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -50,7 +50,6 @@ App_Analysis, ChoiceQuestion, Development_Environment, - Dojo_User, DojoMeta, Endpoint, Endpoint_Status, @@ -64,7 +63,6 @@ Risk_Acceptance, Test, TextQuestion, - User, Vulnerability_Id, ) from dojo.product.queries import get_authorized_products @@ -1649,38 +1647,7 @@ class Meta: exclude = ["product"] -class UserFilter(DojoFilter): - first_name = CharFilter(lookup_expr="icontains") - last_name = CharFilter(lookup_expr="icontains") - username = CharFilter(lookup_expr="icontains") - email = CharFilter(lookup_expr="icontains") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("username", "username"), - ("last_name", "last_name"), - ("first_name", "first_name"), - ("email", "email"), - ("is_active", "is_active"), - ("is_superuser", "is_superuser"), - ("is_staff", "is_staff"), - ("date_joined", "date_joined"), - ("last_login", "last_login"), - ), - field_labels={ - "username": "User Name", - "is_active": "Active", - "is_superuser": "Superuser", - "is_staff": "Staff", - }, - ) - - class Meta: - model = Dojo_User - fields = ["is_superuser", "is_staff", "is_active", "first_name", "last_name", "username", "email"] - - +# UserFilter lives in dojo/user/ui/filters.py — import from there directly. # TestImportFilter and TestImportFindingActionFilter live in dojo/test/ui/filters.py and are # re-exported at the bottom of this module for backward compatibility. @@ -1770,43 +1737,7 @@ def filter(self, qs, value): return self.options[value][1](self, qs, self.options[value][0]) -class ApiUserFilter(filters.FilterSet): - last_login = filters.DateFromToRangeFilter() - date_joined = filters.DateFromToRangeFilter() - is_active = filters.BooleanFilter() - is_superuser = filters.BooleanFilter() - username = filters.CharFilter(lookup_expr="icontains") - first_name = filters.CharFilter(lookup_expr="icontains") - last_name = filters.CharFilter(lookup_expr="icontains") - email = filters.CharFilter(lookup_expr="icontains") - class Meta: - model = User - fields = [ - "id", - "username", - "first_name", - "last_name", - "email", - "is_active", - "is_superuser", - "last_login", - "date_joined", - ] - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("username", "username"), - ("last_name", "last_name"), - ("first_name", "first_name"), - ("email", "email"), - ("is_active", "is_active"), - ("is_superuser", "is_superuser"), - ("date_joined", "date_joined"), - ("last_login", "last_login"), - ), - ) - +# ApiUserFilter lives in dojo/user/api/filters.py — import from there directly. with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): class QuestionFilter(FilterSet): diff --git a/dojo/forms.py b/dojo/forms.py index 8d81d1510e4..5a0a9828392 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -26,7 +26,6 @@ from polymorphic.base import ManagerInheritanceWarning from tagulous.forms import TagField -from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add from dojo.finding.queries import get_authorized_findings from dojo.github.ui.forms import ( # noqa: F401 -- backward compat @@ -92,7 +91,6 @@ Tool_Product_Settings, Tool_Type, User, - UserContactInfo, ) from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types @@ -1008,20 +1006,6 @@ def __init__(self, *args, **kwargs): del self.fields["exclude_product_types"] -class DojoUserForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not get_current_user().is_superuser and not get_system_setting("enable_user_profile_editable"): - for field in self.fields: - self.fields[field].disabled = True - - class Meta: - model = Dojo_User - exclude = ["password", "last_login", "is_superuser", "groups", - "username", "is_staff", "is_active", "date_joined", - "user_permissions"] - - class ChangePasswordForm(forms.Form): current_password = forms.CharField(widget=forms.PasswordInput, required=True) @@ -1061,97 +1045,6 @@ def clean(self): return cleaned_data -class AddDojoUserForm(forms.ModelForm): - email = forms.EmailField(required=True) - password = forms.CharField(widget=forms.PasswordInput, - required=settings.REQUIRE_PASSWORD_ON_USER, - validators=[validate_password], - help_text="") - - class Meta: - model = Dojo_User - fields = ["username", "password", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - current_user = get_current_user() - if not current_user.is_superuser: - self.fields["is_staff"].disabled = True - self.fields["is_superuser"].disabled = True - self.fields["password"].help_text = get_password_requirements_string() - - -class EditDojoUserForm(forms.ModelForm): - email = forms.EmailField(required=True) - - class Meta: - model = Dojo_User - fields = ["username", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - current_user = get_current_user() - if not current_user.is_superuser: - self.fields["is_staff"].disabled = True - self.fields["is_superuser"].disabled = True - - -class DeleteUserForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = User - fields = ["id"] - - -class UserContactInfoForm(forms.ModelForm): - reset_api_token = forms.BooleanField( - required=False, - label=_("Reset API token"), - help_text=_("Upon saving, a new token will be generated and a notification of category 'Other' is triggered."), - ) - - class Meta: - model = UserContactInfo - exclude = ["user", "slack_user_id"] - # Swap order: password_last_reset before token_last_reset - field_order = [ - "title", "phone_number", "cell_number", "twitter_username", "github_username", - "slack_username", "ui_use_tailwind", "block_execution", "force_password_reset", "reset_api_token", - "password_last_reset", "token_last_reset", - ] - - def __init__(self, *args, **kwargs): - user = kwargs.pop("user", None) - super().__init__(*args, **kwargs) - # Make timestamp fields readonly. - # NOTE: `disabled=True` is enforced server-side by Django forms: posted values for disabled fields - # are ignored during binding/cleaning, so these timestamps cannot be modified via this form. - if "password_last_reset" in self.fields: - self.fields["password_last_reset"].disabled = True - if "token_last_reset" in self.fields: - self.fields["token_last_reset"].disabled = True - # Do not expose force password reset if the current user does not have a password to reset - if user is not None: - if not user.has_usable_password(): - self.fields["force_password_reset"].disabled = True - self.fields["force_password_reset"].help_text = "This user is authorized through SSO, and does not have a password to reset" - # Determine some other settings based on the current user - current_user = get_current_user() - if not current_user.is_superuser: - if not user_has_configuration_permission(current_user, "auth.change_user") and \ - not user_has_configuration_permission(current_user, "auth.add_user"): - self.fields.pop("force_password_reset", None) - if not get_system_setting("enable_user_profile_editable"): - for field in self.fields: - self.fields[field].disabled = True - - # Only show reset_api_token to superusers or global owners, and only if API tokens are enabled - if not settings.API_TOKENS_ENABLED or not user_is_superuser_or_global_owner(current_user): - self.fields.pop("reset_api_token", None) - - # Product forms live in dojo/product/ui/forms.py. Re-exported here for backward # compat: ProductCountsFormBase is subclassed by ProductTypeCountsForm below, # Authorize_User_For_ProductsForm by dojo/user/views.py, ProductTagCountsForm by diff --git a/dojo/metrics/views.py b/dojo/metrics/views.py index 28141bc95de..c2321d5a450 100644 --- a/dojo/metrics/views.py +++ b/dojo/metrics/views.py @@ -19,7 +19,6 @@ from django.views.decorators.vary import vary_on_cookie from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.filters import UserFilter from dojo.forms import ProductTagCountsForm, ProductTypeCountsForm, SimpleMetricsForm from dojo.labels import get_labels from dojo.metrics.utils import ( @@ -35,6 +34,7 @@ from dojo.models import Dojo_User, Finding, Product_Type, Risk_Acceptance from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types +from dojo.user.ui.filters import UserFilter from dojo.utils import ( add_breadcrumb, count_findings, diff --git a/dojo/models.py b/dojo/models.py index 4dfc2336700..09f4bfd2c84 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -16,7 +16,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.files.base import ContentFile -from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator, validate_ipv46_address +from django.core.validators import MaxValueValidator, MinValueValidator, validate_ipv46_address from django.db import connection, models from django.db.models import Count, F, Q from django.db.models.expressions import Case, When @@ -169,67 +169,7 @@ def __str__(self): User = get_user_model() -# proxy class for convenience and UI -class Dojo_User(User): - class Meta: - proxy = True - ordering = ["first_name"] - - def get_full_name(self): - return Dojo_User.generate_full_name(self) - - def __str__(self): - return self.get_full_name() - - @staticmethod - def wants_block_execution(user): - # this return False if there is no user, i.e. in celery processes, unittests, etc. - return hasattr(user, "usercontactinfo") and user.usercontactinfo.block_execution - - @staticmethod - def force_password_reset(user): - return hasattr(user, "usercontactinfo") and user.usercontactinfo.force_password_reset - - def disable_force_password_reset(self): - if hasattr(self, "usercontactinfo"): - self.usercontactinfo.force_password_reset = False - self.usercontactinfo.save() - - def enable_force_password_reset(self): - if hasattr(self, "usercontactinfo"): - self.usercontactinfo.force_password_reset = True - self.usercontactinfo.save() - - @staticmethod - def generate_full_name(user): - """Returns the first_name plus the last_name, with a space in between.""" - full_name = f"{user.first_name} {user.last_name} ({user.username})" - return full_name.strip() - - -class UserContactInfo(models.Model): - user = models.OneToOneField(Dojo_User, on_delete=models.CASCADE) - title = models.CharField(blank=True, null=True, max_length=150) - phone_regex = RegexValidator(regex=r"^\+?1?\d{9,15}$", - message=_("Phone number must be entered in the format: '+999999999'. " - "Up to 15 digits allowed.")) - phone_number = models.CharField(validators=[phone_regex], blank=True, - max_length=15, - help_text=_("Phone number must be entered in the format: '+999999999'. " - "Up to 15 digits allowed.")) - cell_number = models.CharField(validators=[phone_regex], blank=True, - max_length=15, - help_text=_("Phone number must be entered in the format: '+999999999'. " - "Up to 15 digits allowed.")) - twitter_username = models.CharField(blank=True, null=True, max_length=150) - github_username = models.CharField(blank=True, null=True, max_length=150) - slack_username = models.CharField(blank=True, null=True, max_length=150, help_text=_("Email address associated with your slack account"), verbose_name=_("Slack Email Address")) - slack_user_id = models.CharField(blank=True, null=True, max_length=25) - block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion.")) - force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) - ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) - token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) - password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) +from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401 class System_Settings(models.Model): @@ -601,15 +541,6 @@ def get_current_datetime(): return timezone.now() -class Contact(models.Model): - name = models.CharField(max_length=100) - email = models.EmailField() - team = models.CharField(max_length=100) - is_admin = models.BooleanField(default=False) - is_globally_read_only = models.BooleanField(default=False) - updated = models.DateTimeField(auto_now=True) - - class Note_Type(models.Model): name = models.CharField(max_length=100, unique=True) description = models.CharField(max_length=200) @@ -2187,7 +2118,6 @@ def __str__(self): admin.site.register(Endpoint_Params) admin.site.register(Endpoint_Status) admin.site.register(Endpoint) -admin.site.register(UserContactInfo) admin.site.register(Notes) admin.site.register(Note_Type) admin.site.register(Tool_Configuration, Tool_Configuration_Admin) @@ -2221,7 +2151,6 @@ def __str__(self): admin.site.register(Product_Type_Member) admin.site.register(Product_Type_Group) -admin.site.register(Contact) admin.site.register(NoteHistory) admin.site.register(Report_Type) admin.site.register(DojoMeta) diff --git a/dojo/urls.py b/dojo/urls.py index db1955dd439..86697752349 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -41,9 +41,6 @@ ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, - UserContactInfoViewSet, - UserProfileView, - UsersViewSet, ) from dojo.api_v2.views import DojoSpectacularAPIView as SpectacularAPIView from dojo.asset.api.urls import add_asset_urls @@ -87,7 +84,9 @@ from dojo.tool_type.urls import urlpatterns as tool_type_urls from dojo.url.api.urls import add_url_urls from dojo.url.ui.urls import urlpatterns as url_patterns -from dojo.user.urls import urlpatterns as user_urls +from dojo.user.api.urls import add_user_urls +from dojo.user.api.views import UserProfileView +from dojo.user.ui.urls import urlpatterns as user_urls from dojo.utils import get_system_setting logger = logging.getLogger(__name__) @@ -143,8 +142,7 @@ v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") -v2_api.register(r"users", UsersViewSet, basename="user") -v2_api.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") +v2_api = add_user_urls(v2_api) # Add the location routes if settings.V3_FEATURE_LOCATIONS: # Endpoints -> Locations diff --git a/dojo/user/__init__.py b/dojo/user/__init__.py index e69de29bb2d..e1885283340 100644 --- a/dojo/user/__init__.py +++ b/dojo/user/__init__.py @@ -0,0 +1 @@ +import dojo.user.admin # noqa: F401 diff --git a/dojo/user/admin.py b/dojo/user/admin.py new file mode 100644 index 00000000000..c8d20a46344 --- /dev/null +++ b/dojo/user/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from dojo.user.models import Contact, UserContactInfo + +admin.site.register(UserContactInfo) +admin.site.register(Contact) diff --git a/dojo/user/api/__init__.py b/dojo/user/api/__init__.py new file mode 100644 index 00000000000..06ffb66484b --- /dev/null +++ b/dojo/user/api/__init__.py @@ -0,0 +1 @@ +path = "users" # noqa: RUF067 diff --git a/dojo/user/api/filters.py b/dojo/user/api/filters.py new file mode 100644 index 00000000000..0e4bb8b8e14 --- /dev/null +++ b/dojo/user/api/filters.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django_filters import OrderingFilter +from django_filters import rest_framework as filters + +User = get_user_model() + + +class ApiUserFilter(filters.FilterSet): + last_login = filters.DateFromToRangeFilter() + date_joined = filters.DateFromToRangeFilter() + is_active = filters.BooleanFilter() + is_superuser = filters.BooleanFilter() + username = filters.CharFilter(lookup_expr="icontains") + first_name = filters.CharFilter(lookup_expr="icontains") + last_name = filters.CharFilter(lookup_expr="icontains") + email = filters.CharFilter(lookup_expr="icontains") + + class Meta: + model = User + fields = [ + "id", + "username", + "first_name", + "last_name", + "email", + "is_active", + "is_superuser", + "last_login", + "date_joined", + ] + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("username", "username"), + ("last_name", "last_name"), + ("first_name", "first_name"), + ("email", "email"), + ("is_active", "is_active"), + ("is_superuser", "is_superuser"), + ("date_joined", "date_joined"), + ("last_login", "last_login"), + ), + ) diff --git a/dojo/user/api/serializer.py b/dojo/user/api/serializer.py new file mode 100644 index 00000000000..b74b13c8742 --- /dev/null +++ b/dojo/user/api/serializer.py @@ -0,0 +1,196 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dojo.models import Dojo_User, UserContactInfo +from dojo.user.utils import get_configuration_permissions_codenames + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + date_joined = serializers.DateTimeField(read_only=True) + last_login = serializers.DateTimeField(read_only=True, allow_null=True) + email = serializers.EmailField(required=True) + token_last_reset = serializers.SerializerMethodField() + password_last_reset = serializers.SerializerMethodField() + password = serializers.CharField( + write_only=True, + style={"input_type": "password"}, + required=False, + validators=[validate_password], + ) + configuration_permissions = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Permission.objects.filter( + codename__in=get_configuration_permissions_codenames(), + ), + many=True, + required=False, + source="user_permissions", + ) + + class Meta: + model = Dojo_User + fields = ( + "id", + "username", + "first_name", + "last_name", + "email", + "date_joined", + "last_login", + "is_active", + "is_staff", + "is_superuser", + "token_last_reset", + "password_last_reset", + "password", + "configuration_permissions", + ) + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_token_last_reset(self, instance): + uci = getattr(instance, "usercontactinfo", None) + return getattr(uci, "token_last_reset", None) + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_password_last_reset(self, instance): + uci = getattr(instance, "usercontactinfo", None) + return getattr(uci, "password_last_reset", None) + + def to_representation(self, instance): + ret = super().to_representation(instance) + + # This will show only "configuration_permissions" even if user has also + # other permissions + all_permissions = set(ret["configuration_permissions"]) + allowed_configuration_permissions = set( + self.fields[ + "configuration_permissions" + ].child_relation.queryset.values_list("id", flat=True), + ) + ret["configuration_permissions"] = list( + all_permissions.intersection(allowed_configuration_permissions), + ) + + return ret + + def update(self, instance, validated_data): + permissions_in_payload = None + new_configuration_permissions = None + if ( + "user_permissions" in validated_data + ): # This field was renamed from "configuration_permissions" in the meantime + permissions_in_payload = validated_data.pop("user_permissions") + new_configuration_permissions = set(permissions_in_payload) + + instance = super().update(instance, validated_data) + + # This will update only Permissions from category + # "configuration_permissions". Others will be untouched + if new_configuration_permissions: + allowed_configuration_permissions = set( + self.fields[ + "configuration_permissions" + ].child_relation.queryset.all(), + ) + non_configuration_permissions = ( + set(instance.user_permissions.all()) + - allowed_configuration_permissions + ) + new_permissions = non_configuration_permissions.union( + new_configuration_permissions, + ) + instance.user_permissions.set(new_permissions) + + # Clear all configuration permissions if an empty list is provided + if isinstance(permissions_in_payload, list) and len(permissions_in_payload) == 0: + instance.user_permissions.clear() + + return instance + + def create(self, validated_data): + password = validated_data.pop("password", None) + + new_configuration_permissions = None + if ( + "user_permissions" in validated_data + ): # This field was renamed from "configuration_permissions" in the meantime + new_configuration_permissions = set( + validated_data.pop("user_permissions"), + ) + + user = Dojo_User.objects.create(**validated_data) + + if password: + user.set_password(password) + else: + user.set_unusable_password() + + # This will create only Permissions from category + # "configuration_permissions". There are no other Permissions. + if new_configuration_permissions: + user.user_permissions.set(new_configuration_permissions) + + user.save() + return user + + def validate(self, data): + instance_is_superuser = self.instance.is_superuser if self.instance is not None else False + data_is_superuser = data.get("is_superuser", False) + if not self.context["request"].user.is_superuser and ( + instance_is_superuser or data_is_superuser + ): + msg = "Only superusers are allowed to add or edit superusers." + raise ValidationError(msg) + + instance_is_staff = self.instance.is_staff if self.instance is not None else False + data_is_staff = data.get("is_staff", instance_is_staff) + if not self.context["request"].user.is_superuser and data_is_staff != instance_is_staff: + msg = "Only superusers are allowed to add or edit staff users." + raise ValidationError(msg) + + if self.context["request"].method in {"PATCH", "PUT"} and "password" in data: + msg = "Update of password though API is not allowed" + raise ValidationError(msg) + if self.context["request"].method == "POST" and "password" not in data and settings.REQUIRE_PASSWORD_ON_USER: + msg = "Passwords must be supplied for new users" + raise ValidationError(msg) + return super().validate(data) + + +class UserContactInfoSerializer(serializers.ModelSerializer): + user_profile = UserSerializer(many=False, source="user", read_only=True) + + class Meta: + model = UserContactInfo + fields = "__all__" + + def validate(self, data): + user = data.get("user", None) or self.instance.user + if data.get("force_password_reset", False) and not user.has_usable_password(): + msg = "Password resets are not allowed for users authorized through SSO." + raise ValidationError(msg) + return super().validate(data) + + +class UserStubSerializer(serializers.ModelSerializer): + class Meta: + model = Dojo_User + fields = ("id", "username", "first_name", "last_name") + + +class AddUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "username") + + +class UserProfileSerializer(serializers.Serializer): + user = UserSerializer(many=False) + user_contact_info = UserContactInfoSerializer(many=False, required=False) diff --git a/dojo/user/api/urls.py b/dojo/user/api/urls.py new file mode 100644 index 00000000000..cb8e8a909b5 --- /dev/null +++ b/dojo/user/api/urls.py @@ -0,0 +1,7 @@ +from dojo.user.api.views import UserContactInfoViewSet, UsersViewSet + + +def add_user_urls(router): + router.register(r"users", UsersViewSet, basename="user") + router.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") + return router diff --git a/dojo/user/api/views.py b/dojo/user/api/views.py new file mode 100644 index 00000000000..c1eb3640442 --- /dev/null +++ b/dojo/user/api/views.py @@ -0,0 +1,102 @@ +import logging + +from crum import get_current_user +from django.contrib.auth import get_user_model +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.models import UserContactInfo +from dojo.user.api.filters import ApiUserFilter +from dojo.user.api.serializer import ( + UserContactInfoSerializer, + UserProfileSerializer, + UserSerializer, +) +from dojo.user.authentication import reset_token_for_user + +logger = logging.getLogger(__name__) + +User = get_user_model() + + +# Authorization: configuration +class UsersViewSet( + DojoModelViewSet, +): + serializer_class = UserSerializer + queryset = User.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiUserFilter + permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) + + def get_queryset(self): + return User.objects.all().order_by("id") + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if request.user == instance: + return Response( + "Users may not delete themselves", + status=status.HTTP_400_BAD_REQUEST, + ) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action( + detail=True, + methods=["post"], + url_path="reset_api_token", + permission_classes=(IsAuthenticated, permissions.IsSuperUserOrGlobalOwner), + filter_backends=[], + pagination_class=None, + ) + def reset_api_token(self, request, pk=None): + target_user = self.get_object() + reset_token_for_user(acting_user=request.user, target_user=target_user) + return Response(status=status.HTTP_204_NO_CONTENT) + + +# Authorization: superuser +@extend_schema_view(**schema_with_prefetch()) +class UserContactInfoViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = UserContactInfoSerializer + queryset = UserContactInfo.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = "__all__" + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) + + def get_queryset(self): + return UserContactInfo.objects.all().order_by("id") + + +# Authorization: authenticated users +class UserProfileView(GenericAPIView): + permission_classes = (IsAuthenticated,) + pagination_class = None + serializer_class = UserProfileSerializer + + @action( + detail=True, methods=["get"], filter_backends=[], pagination_class=None, + ) + def get(self, request, _=None): + user = get_current_user() + user_contact_info = ( + user.usercontactinfo if hasattr(user, "usercontactinfo") else None + ) + serializer = UserProfileSerializer( + { + "user": user, + "user_contact_info": user_contact_info, + }, + many=False, + ) + return Response(serializer.data) diff --git a/dojo/user/models.py b/dojo/user/models.py new file mode 100644 index 00000000000..7d88c731fce --- /dev/null +++ b/dojo/user/models.py @@ -0,0 +1,78 @@ +from django.contrib.auth import get_user_model +from django.core.validators import RegexValidator +from django.db import models +from django.utils.translation import gettext as _ + +User = get_user_model() + + +# proxy class for convenience and UI +class Dojo_User(User): + class Meta: + proxy = True + ordering = ["first_name"] + + def get_full_name(self): + return Dojo_User.generate_full_name(self) + + def __str__(self): + return self.get_full_name() + + @staticmethod + def wants_block_execution(user): + # this return False if there is no user, i.e. in celery processes, unittests, etc. + return hasattr(user, "usercontactinfo") and user.usercontactinfo.block_execution + + @staticmethod + def force_password_reset(user): + return hasattr(user, "usercontactinfo") and user.usercontactinfo.force_password_reset + + def disable_force_password_reset(self): + if hasattr(self, "usercontactinfo"): + self.usercontactinfo.force_password_reset = False + self.usercontactinfo.save() + + def enable_force_password_reset(self): + if hasattr(self, "usercontactinfo"): + self.usercontactinfo.force_password_reset = True + self.usercontactinfo.save() + + @staticmethod + def generate_full_name(user): + """Returns the first_name plus the last_name, with a space in between.""" + full_name = f"{user.first_name} {user.last_name} ({user.username})" + return full_name.strip() + + +class UserContactInfo(models.Model): + user = models.OneToOneField("dojo.Dojo_User", on_delete=models.CASCADE) + title = models.CharField(blank=True, null=True, max_length=150) + phone_regex = RegexValidator(regex=r"^\+?1?\d{9,15}$", + message=_("Phone number must be entered in the format: '+999999999'. " + "Up to 15 digits allowed.")) + phone_number = models.CharField(validators=[phone_regex], blank=True, + max_length=15, + help_text=_("Phone number must be entered in the format: '+999999999'. " + "Up to 15 digits allowed.")) + cell_number = models.CharField(validators=[phone_regex], blank=True, + max_length=15, + help_text=_("Phone number must be entered in the format: '+999999999'. " + "Up to 15 digits allowed.")) + twitter_username = models.CharField(blank=True, null=True, max_length=150) + github_username = models.CharField(blank=True, null=True, max_length=150) + slack_username = models.CharField(blank=True, null=True, max_length=150, help_text=_("Email address associated with your slack account"), verbose_name=_("Slack Email Address")) + slack_user_id = models.CharField(blank=True, null=True, max_length=25) + block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion.")) + force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) + ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) + token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) + password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) + + +class Contact(models.Model): + name = models.CharField(max_length=100) + email = models.EmailField() + team = models.CharField(max_length=100) + is_admin = models.BooleanField(default=False) + is_globally_read_only = models.BooleanField(default=False) + updated = models.DateTimeField(auto_now=True) diff --git a/dojo/user/ui/__init__.py b/dojo/user/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/user/ui/filters.py b/dojo/user/ui/filters.py new file mode 100644 index 00000000000..91ab303f69f --- /dev/null +++ b/dojo/user/ui/filters.py @@ -0,0 +1,36 @@ +from django_filters import CharFilter, OrderingFilter + +from dojo.filters import DojoFilter +from dojo.models import Dojo_User + + +class UserFilter(DojoFilter): + first_name = CharFilter(lookup_expr="icontains") + last_name = CharFilter(lookup_expr="icontains") + username = CharFilter(lookup_expr="icontains") + email = CharFilter(lookup_expr="icontains") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("username", "username"), + ("last_name", "last_name"), + ("first_name", "first_name"), + ("email", "email"), + ("is_active", "is_active"), + ("is_superuser", "is_superuser"), + ("is_staff", "is_staff"), + ("date_joined", "date_joined"), + ("last_login", "last_login"), + ), + field_labels={ + "username": "User Name", + "is_active": "Active", + "is_superuser": "Superuser", + "is_staff": "Staff", + }, + ) + + class Meta: + model = Dojo_User + fields = ["is_superuser", "is_staff", "is_active", "first_name", "last_name", "username", "email"] diff --git a/dojo/user/ui/forms.py b/dojo/user/ui/forms.py new file mode 100644 index 00000000000..d32c3da187e --- /dev/null +++ b/dojo/user/ui/forms.py @@ -0,0 +1,114 @@ +from crum import get_current_user +from django import forms +from django.conf import settings +from django.contrib.auth.password_validation import validate_password +from django.utils.translation import gettext_lazy as _ + +from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner +from dojo.models import Dojo_User, User, UserContactInfo +from dojo.utils import get_password_requirements_string, get_system_setting + + +class DojoUserForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not get_current_user().is_superuser and not get_system_setting("enable_user_profile_editable"): + for field in self.fields: + self.fields[field].disabled = True + + class Meta: + model = Dojo_User + exclude = ["password", "last_login", "is_superuser", "groups", + "username", "is_staff", "is_active", "date_joined", + "user_permissions"] + + +class AddDojoUserForm(forms.ModelForm): + email = forms.EmailField(required=True) + password = forms.CharField(widget=forms.PasswordInput, + required=settings.REQUIRE_PASSWORD_ON_USER, + validators=[validate_password], + help_text="") + + class Meta: + model = Dojo_User + fields = ["username", "password", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + current_user = get_current_user() + if not current_user.is_superuser: + self.fields["is_staff"].disabled = True + self.fields["is_superuser"].disabled = True + self.fields["password"].help_text = get_password_requirements_string() + + +class EditDojoUserForm(forms.ModelForm): + email = forms.EmailField(required=True) + + class Meta: + model = Dojo_User + fields = ["username", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + current_user = get_current_user() + if not current_user.is_superuser: + self.fields["is_staff"].disabled = True + self.fields["is_superuser"].disabled = True + + +class DeleteUserForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = User + fields = ["id"] + + +class UserContactInfoForm(forms.ModelForm): + reset_api_token = forms.BooleanField( + required=False, + label=_("Reset API token"), + help_text=_("Upon saving, a new token will be generated and a notification of category 'Other' is triggered."), + ) + + class Meta: + model = UserContactInfo + exclude = ["user", "slack_user_id"] + # Swap order: password_last_reset before token_last_reset + field_order = [ + "title", "phone_number", "cell_number", "twitter_username", "github_username", + "slack_username", "ui_use_tailwind", "block_execution", "force_password_reset", "reset_api_token", + "password_last_reset", "token_last_reset", + ] + + def __init__(self, *args, **kwargs): + user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) + # Make timestamp fields readonly. + # NOTE: `disabled=True` is enforced server-side by Django forms: posted values for disabled fields + # are ignored during binding/cleaning, so these timestamps cannot be modified via this form. + if "password_last_reset" in self.fields: + self.fields["password_last_reset"].disabled = True + if "token_last_reset" in self.fields: + self.fields["token_last_reset"].disabled = True + # Do not expose force password reset if the current user does not have a password to reset + if user is not None: + if not user.has_usable_password(): + self.fields["force_password_reset"].disabled = True + self.fields["force_password_reset"].help_text = "This user is authorized through SSO, and does not have a password to reset" + # Determine some other settings based on the current user + current_user = get_current_user() + if not current_user.is_superuser: + if not user_has_configuration_permission(current_user, "auth.change_user") and \ + not user_has_configuration_permission(current_user, "auth.add_user"): + self.fields.pop("force_password_reset", None) + if not get_system_setting("enable_user_profile_editable"): + for field in self.fields: + self.fields[field].disabled = True + + # Only show reset_api_token to superusers or global owners, and only if API tokens are enabled + if not settings.API_TOKENS_ENABLED or not user_is_superuser_or_global_owner(current_user): + self.fields.pop("reset_api_token", None) diff --git a/dojo/user/urls.py b/dojo/user/ui/urls.py similarity index 99% rename from dojo/user/urls.py rename to dojo/user/ui/urls.py index b3c97bea8ea..395954f7679 100644 --- a/dojo/user/urls.py +++ b/dojo/user/ui/urls.py @@ -2,7 +2,7 @@ from django.contrib.auth import views as auth_views from django.urls import re_path, reverse_lazy -from dojo.user import views +from dojo.user.ui import views urlpatterns = [ # user specific diff --git a/dojo/user/views.py b/dojo/user/ui/views.py similarity index 99% rename from dojo/user/views.py rename to dojo/user/ui/views.py index ce3d8449a39..9ba12c044c8 100644 --- a/dojo/user/views.py +++ b/dojo/user/ui/views.py @@ -29,22 +29,24 @@ from dojo.authorization.authorization import user_is_superuser_or_global_owner from dojo.decorators import dojo_ratelimit -from dojo.filters import UserFilter from dojo.forms import ( - AddDojoUserForm, APIKeyForm, Authorize_User_For_ProductsForm, Authorize_User_For_ProductTypesForm, ChangePasswordForm, ConfigurationPermissionsForm, +) +from dojo.labels import get_labels +from dojo.models import Alerts, Dojo_User, Product, Product_Type, UserContactInfo +from dojo.user.authentication import reset_token_for_user +from dojo.user.ui.filters import UserFilter +from dojo.user.ui.forms import ( + AddDojoUserForm, DeleteUserForm, DojoUserForm, EditDojoUserForm, UserContactInfoForm, ) -from dojo.labels import get_labels -from dojo.models import Alerts, Dojo_User, Product, Product_Type, UserContactInfo -from dojo.user.authentication import reset_token_for_user from dojo.utils import add_breadcrumb, get_page_items, get_setting, get_system_setting logger = logging.getLogger(__name__) diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 6a455168670..81cb43bac98 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -57,8 +57,6 @@ ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, - UserContactInfoViewSet, - UsersViewSet, ) from dojo.asset.api.views import ( AssetAPIScanConfigurationViewSet, @@ -118,6 +116,7 @@ from dojo.test.api.views import TestsViewSet, TestTypesViewSet from dojo.url.api.views import URLViewSet from dojo.url.models import URL +from dojo.user.api.views import UserContactInfoViewSet, UsersViewSet from .dojo_test_case import ( DojoAPITestCase, diff --git a/unittests/test_user_ui_timestamps.py b/unittests/test_user_ui_timestamps.py index e2296368d14..ad2b7ebe145 100644 --- a/unittests/test_user_ui_timestamps.py +++ b/unittests/test_user_ui_timestamps.py @@ -49,7 +49,7 @@ def test_change_password_stamps_password_last_reset(self): user.save() self.client.force_login(user) - with patch("dojo.user.views.now", return_value=fixed): + with patch("dojo.user.ui.views.now", return_value=fixed): resp = self.client.post( reverse("change_password"), data={ @@ -74,7 +74,7 @@ def test_password_reset_confirm_stamps_password_last_reset(self): token = default_token_generator.make_token(user) url = reverse("password_reset_confirm", kwargs={"uidb64": uidb64, "token": token}) - with patch("dojo.user.views.now", return_value=fixed): + with patch("dojo.user.ui.views.now", return_value=fixed): # Django's PasswordResetConfirmView typically requires a GET to the tokenized URL, # which sets a session token and redirects to the "set-password" URL. resp_get = self.client.get(url) From 902397f7a77983fad58f85c058eb3cf3bf8c86ef Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 23:20:54 +0200 Subject: [PATCH 30/40] refactor(system_settings): extract System_Settings into dojo/system_settings/ [system_settings Phase 1,3,5,6,8,9] --- dojo/api_v2/serializers.py | 6 +- dojo/api_v2/views.py | 15 - dojo/forms.py | 36 --- dojo/models.py | 368 +------------------------ dojo/system_settings/__init__.py | 1 + dojo/system_settings/admin.py | 5 + dojo/system_settings/api/__init__.py | 1 + dojo/system_settings/api/serializer.py | 9 + dojo/system_settings/api/urls.py | 7 + dojo/system_settings/api/views.py | 21 ++ dojo/system_settings/models.py | 365 ++++++++++++++++++++++++ dojo/system_settings/ui/__init__.py | 0 dojo/system_settings/ui/forms.py | 41 +++ dojo/system_settings/{ => ui}/urls.py | 2 +- dojo/system_settings/{ => ui}/views.py | 4 +- dojo/urls.py | 6 +- 16 files changed, 461 insertions(+), 426 deletions(-) create mode 100644 dojo/system_settings/admin.py create mode 100644 dojo/system_settings/api/__init__.py create mode 100644 dojo/system_settings/api/serializer.py create mode 100644 dojo/system_settings/api/urls.py create mode 100644 dojo/system_settings/api/views.py create mode 100644 dojo/system_settings/models.py create mode 100644 dojo/system_settings/ui/__init__.py create mode 100644 dojo/system_settings/ui/forms.py rename dojo/system_settings/{ => ui}/urls.py (87%) rename dojo/system_settings/{ => ui}/views.py (97%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 4f8794fcb5c..be48838afe0 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -56,7 +56,6 @@ SLA_Configuration, Sonarqube_Issue, Sonarqube_Issue_Transition, - System_Settings, Test, Tool_Configuration, Tool_Product_Settings, @@ -1459,10 +1458,7 @@ class TagSerializer(serializers.Serializer): tags = TagListSerializerField(required=True) -class SystemSettingsSerializer(serializers.ModelSerializer): - class Meta: - model = System_Settings - fields = "__all__" +from dojo.system_settings.api.serializer import SystemSettingsSerializer # noqa: E402, F401 -- backward compat class CeleryStatusSerializer(serializers.Serializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 2e6d706ee27..50ddd53a4d5 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -1188,21 +1188,6 @@ def report_generate(request, obj, options): return result -# Authorization: superuser -class SystemSettingsViewSet( - mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet, -): - - """Basic control over System Settings. Use 'id' 1 for PUT, PATCH operations""" - - permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) - serializer_class = serializers.SystemSettingsSerializer - queryset = System_Settings.objects.none() - - def get_queryset(self): - return System_Settings.objects.all().order_by("id") - - class CeleryViewSet(viewsets.ViewSet): permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) queryset = System_Settings.objects.none() diff --git a/dojo/forms.py b/dojo/forms.py index 5a0a9828392..57ce9e0f53b 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -83,7 +83,6 @@ Regulation, Risk_Acceptance, SLA_Configuration, - System_Settings, Test_Type, TextAnswer, TextQuestion, @@ -1296,41 +1295,6 @@ def clean(self): return self.cleaned_data -class SystemSettingsForm(forms.ModelForm): - jira_webhook_secret = forms.CharField(required=False) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields["enable_product_tracking_files"].label = labels.SETTINGS_TRACKED_FILES_ENABLE_LABEL - self.fields["enable_product_tracking_files"].help_text = labels.SETTINGS_TRACKED_FILES_ENABLE_HELP - - self.fields[ - "enforce_verified_status_product_grading"].label = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_LABEL - self.fields[ - "enforce_verified_status_product_grading"].help_text = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_HELP - - self.fields["enable_product_grade"].label = labels.SETTINGS_ASSET_GRADING_ENABLE_LABEL - self.fields["enable_product_grade"].help_text = labels.SETTINGS_ASSET_GRADING_ENABLE_HELP - - self.fields["enable_product_tag_inheritance"].label = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_LABEL - self.fields["enable_product_tag_inheritance"].help_text = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_HELP - - def clean(self): - cleaned_data = super().clean() - enable_jira_value = cleaned_data.get("enable_jira") - jira_webhook_secret_value = cleaned_data.get("jira_webhook_secret").strip() - - if enable_jira_value and not jira_webhook_secret_value: - self.add_error("jira_webhook_secret", "This field is required when enable Jira Integration is True") - - return cleaned_data - - class Meta: - model = System_Settings - exclude = () - - class BenchmarkForm(forms.ModelForm): class Meta: diff --git a/dojo/models.py b/dojo/models.py index 09f4bfd2c84..bec3b2ec4f9 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -16,7 +16,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.files.base import ContentFile -from django.core.validators import MaxValueValidator, MinValueValidator, validate_ipv46_address +from django.core.validators import validate_ipv46_address from django.db import connection, models from django.db.models import Count, F, Q from django.db.models.expressions import Case, When @@ -169,368 +169,8 @@ def __str__(self): User = get_user_model() -from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401 - - -class System_Settings(models.Model): - enable_deduplication = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Deduplicate findings"), - help_text=_("With this setting turned on, DefectDojo deduplicates findings by " - "comparing endpoints, cwe fields, and titles. " - "If two findings share a URL and have the same CWE or " - "title, DefectDojo marks the recent finding as a duplicate. " - "When deduplication is enabled, a list of " - "deduplicated findings is added to the engagement view.")) - delete_duplicates = models.BooleanField(default=False, blank=False, help_text=_("Requires next setting: maximum number of duplicates to retain.")) - max_dupes = models.IntegerField(blank=True, null=True, default=10, - verbose_name=_("Max Duplicates"), - help_text=_("When enabled, if a single " - "issue reaches the maximum " - "number of duplicates, the " - "oldest will be deleted. Duplicate will not be deleted when left empty. A value of 0 will remove all duplicates.")) - - email_from = models.CharField(max_length=200, default="no-reply@example.com", blank=True) - - enable_jira = models.BooleanField(default=False, - verbose_name=_("Enable JIRA integration"), - blank=False) - - enable_jira_web_hook = models.BooleanField(default=False, - verbose_name=_("Enable JIRA web hook"), - help_text=_("Please note: It is strongly recommended to use a secret below and / or IP whitelist the JIRA server using a proxy such as Nginx."), - blank=False) - - disable_jira_webhook_secret = models.BooleanField(default=False, - verbose_name=_("Disable web hook secret"), - help_text=_("Allows incoming requests without a secret (discouraged legacy behaviour)"), - blank=False) - - # will be set to random / uuid by initializer so null needs to be True - jira_webhook_secret = models.CharField(max_length=64, blank=False, null=True, verbose_name=_("JIRA Webhook URL"), - help_text=_("Secret needed in URL for incoming JIRA Webhook")) - - jira_choices = (("Critical", "Critical"), - ("High", "High"), - ("Medium", "Medium"), - ("Low", "Low"), - ("Info", "Info")) - jira_minimum_severity = models.CharField(max_length=20, blank=True, - null=True, choices=jira_choices, - default="Low") - jira_labels = models.CharField(max_length=200, blank=True, null=True, - help_text=_("JIRA issue labels space seperated")) - - add_vulnerability_id_to_jira_label = models.BooleanField(default=False, - verbose_name=_("Add vulnerability Id as a JIRA label"), - blank=False) - - enable_github = models.BooleanField(default=False, - verbose_name=_("Enable GITHUB integration"), - blank=False) - - enable_slack_notifications = \ - models.BooleanField(default=False, - verbose_name=_("Enable Slack notifications"), - blank=False) - slack_channel = models.CharField(max_length=100, default="", blank=True, - help_text=_("Optional. Needed if you want to send global notifications.")) - slack_token = models.CharField(max_length=100, default="", blank=True, - help_text=_("Token required for interacting " - "with Slack. Get one at " - "https://api.slack.com/tokens")) - slack_username = models.CharField(max_length=100, default="", blank=True, - help_text=_("Optional. Will take your bot name otherwise.")) - enable_msteams_notifications = \ - models.BooleanField(default=False, - verbose_name=_("Enable Microsoft Teams notifications"), - blank=False) - msteams_url = models.CharField(max_length=400, default="", blank=True, - help_text=_("The full URL of the " - "incoming webhook")) - enable_mail_notifications = models.BooleanField(default=False, blank=False) - mail_notifications_to = models.CharField(max_length=200, default="", - blank=True) - - enable_webhooks_notifications = \ - models.BooleanField(default=False, - verbose_name=_("Enable Webhook notifications"), - blank=False) - webhooks_notifications_timeout = models.IntegerField(default=10, - help_text=_("How many seconds will DefectDojo waits for response from webhook endpoint")) - - enforce_verified_status = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Globally"), - help_text=_( - "When enabled, features such as product grading, jira " - "integration, metrics, and reports will only interact " - "with verified findings. This setting will override " - "individually scoped verified toggles.", - ), - ) - enforce_verified_status_jira = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Jira"), - help_text=_("When enabled, findings must have a verified status to be pushed to jira."), - ) - enforce_verified_status_product_grading = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Product Grading"), - help_text=_( - "When enabled, findings must have a verified status to be considered as part of a product's grading.", - ), - ) - enforce_verified_status_metrics = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Metrics"), - help_text=_( - "When enabled, findings must have a verified status to be counted in metric calculations, " - "be included in reports, and filters.", - ), - ) - - false_positive_history = models.BooleanField( - default=False, help_text=_( - "(EXPERIMENTAL) DefectDojo will automatically mark the finding as a " - "false positive if an equal finding (according to its dedupe algorithm) " - "has been previously marked as a false positive on the same product. " - "ATTENTION: Although the deduplication algorithm is used to determine " - "if a finding should be marked as a false positive, this feature will " - "not work if deduplication is enabled since it doesn't make sense to use both.", - ), - ) - - retroactive_false_positive_history = models.BooleanField( - default=False, help_text=_( - "(EXPERIMENTAL) FP History will also retroactively mark/unmark all " - "existing equal findings in the same product as a false positives. " - "Only works if the False Positive History feature is also enabled.", - ), - ) - - url_prefix = models.CharField(max_length=300, default="", blank=True, help_text=_("URL prefix if DefectDojo is installed in it's own virtual subdirectory.")) - team_name = models.CharField(max_length=100, default="", blank=True) - enable_product_grade = models.BooleanField(default=False, verbose_name=_("Enable Product Grading"), help_text=_("Displays a grade letter next to a product to show the overall health.")) - product_grade_a = models.IntegerField(default=90, - verbose_name=_("Grade A"), - help_text=_("Percentage score for an " - "'A' >=")) - product_grade_b = models.IntegerField(default=80, - verbose_name=_("Grade B"), - help_text=_("Percentage score for a " - "'B' >=")) - product_grade_c = models.IntegerField(default=70, - verbose_name=_("Grade C"), - help_text=_("Percentage score for a " - "'C' >=")) - product_grade_d = models.IntegerField(default=60, - verbose_name=_("Grade D"), - help_text=_("Percentage score for a " - "'D' >=")) - product_grade_f = models.IntegerField(default=59, - verbose_name=_("Grade F"), - help_text=_("Percentage score for an " - "'F' <=")) - enable_product_tag_inheritance = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Product Tag Inheritance"), - help_text=_("Enables product tag inheritance globally for all products. Any tags added on a product will automatically be added to all Engagements, Tests, and Findings")) - - enable_benchmark = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Benchmarks"), - help_text=_("Enables Benchmarks such as the OWASP ASVS " - "(Application Security Verification Standard)")) - - enable_similar_findings = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Similar Findings"), - help_text=_("Enable the query of similar findings on the view finding page. This feature can involve potentially large queries and negatively impact performance")) - - engagement_auto_close = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Engagement Auto-Close"), - help_text=_("Closes an engagement after 3 days (default) past due date including last update.")) - - engagement_auto_close_days = models.IntegerField( - default=3, - blank=False, - verbose_name=_("Engagement Auto-Close Days"), - help_text=_("Closes an engagement after the specified number of days past due date including last update.")) - - enable_finding_sla = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Finding SLA's"), - help_text=_("Enables Finding SLA's for time to remediate.")) - - enable_notify_sla_active = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Notify SLA's Breach for active Findings"), - help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active Findings.")) - - enable_notify_sla_active_verified = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Notify SLA's Breach for active, verified Findings"), - help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active, verified Findings.")) - - enable_notify_sla_jira_only = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Notify SLA's Breach only for Findings linked to JIRA"), - help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for Findings that are linked to JIRA issues. Notification is disabled for Findings not linked to JIRA issues")) - - enable_notify_sla_exponential_backoff = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable an exponential backoff strategy for SLA breach notifications."), - help_text=_("Enable an exponential backoff strategy for SLA breach notifications, e.g. 1, 2, 4, 8, etc. Otherwise it alerts every day")) - - allow_anonymous_survey_repsonse = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Allow Anonymous Survey Responses"), - help_text=_("Enable anyone with a link to the survey to answer a survey"), - ) - disclaimer_notifications = models.TextField(max_length=3000, default="", blank=True, - verbose_name=_("Custom Disclaimer for Notifications"), - help_text=_("Include this custom disclaimer on all notifications")) - disclaimer_reports = models.TextField(max_length=5000, default="", blank=True, - verbose_name=_("Custom Disclaimer for Reports"), - help_text=_("Include this custom disclaimer on generated reports")) - disclaimer_reports_forced = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Force to add disclaimer reports"), - help_text=_("Disclaimer will be added to all reports even if user didn't selected 'Include disclaimer'.")) - disclaimer_notes = models.TextField(max_length=3000, default="", blank=True, - verbose_name=_("Custom Disclaimer for Notes"), - help_text=_("Include this custom disclaimer next to input form for notes")) - risk_acceptance_form_default_days = models.IntegerField(null=True, blank=True, default=180, help_text=_("Default expiry period for risk acceptance form.")) - risk_acceptance_notify_before_expiration = models.IntegerField(null=True, blank=True, default=10, - verbose_name=_("Risk acceptance expiration heads up days"), help_text=_("Notify X days before risk acceptance expires. Leave empty to disable.")) - enable_questionnaires = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable questionnaires"), - help_text=_("With this setting turned off, questionnaires will be disabled in the user interface.")) - enable_checklists = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable checklists"), - help_text=_("With this setting turned off, checklists will be disabled in the user interface.")) - enable_endpoint_metadata_import = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Endpoint Metadata Import"), - help_text=_("With this setting turned off, endpoint metadata import will be disabled in the user interface.")) - enable_user_profile_editable = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable user profile for writing"), - help_text=_("When turned on users can edit their profiles")) - enable_product_tracking_files = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Product Tracking Files"), - help_text=_("With this setting turned off, the product tracking files will be disabled in the user interface.")) - enable_finding_groups = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Finding Groups"), - help_text=_("With this setting turned off, the Finding Groups will be disabled.")) - enable_ui_table_based_searching = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable UI Table Based Filtering/Sorting"), - help_text=_("With this setting enabled, table headings will contain sort buttons for the current page of data in addition to sorting buttons that consider data from all pages.")) - enable_calendar = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Calendar"), - help_text=_("With this setting turned off, the Calendar will be disabled in the user interface.")) - enable_cvss3_display = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable CVSS3 Display"), - help_text=_("With this setting turned off, CVSS3 fields will be hidden in the user interface.")) - enable_cvss4_display = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable CVSS4 Display"), - help_text=_("With this setting turned off, CVSS4 fields will be hidden in the user interface.")) - minimum_password_length = models.IntegerField( - default=9, - verbose_name=_("Minimum password length"), - help_text=_("Requires user to set passwords greater than minimum length."), - validators=[MinValueValidator(9), MaxValueValidator(48)]) - maximum_password_length = models.IntegerField( - default=48, - verbose_name=_("Maximum password length"), - help_text=_("Requires user to set passwords less than maximum length."), - validators=[MinValueValidator(9), MaxValueValidator(48)]) - number_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one digit"), - help_text=_("Requires user passwords to contain at least one digit (0-9).")) - special_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one special character"), - help_text=_("Requires user passwords to contain at least one special character (()[]{}|\\`~!@#$%^&*_-+=;:'\",<>./?).")) - lowercase_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one lowercase letter"), - help_text=_("Requires user passwords to contain at least one lowercase letter (a-z).")) - uppercase_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one uppercase letter"), - help_text=_("Requires user passwords to contain at least one uppercase letter (A-Z).")) - non_common_password_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must not be common"), - help_text=_("Requires user passwords to not be part of list of common passwords.")) - api_expose_error_details = models.BooleanField( - default=False, - blank=False, - verbose_name=_("API expose error details"), - help_text=_("When turned on, the API will expose error details in the response.")) - filter_string_matching = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Filter String Matching Optimization"), - help_text=_( - "When turned on, all filter operations in the UI will require string matches rather than ID. " - "This is a performance enhancement to avoid fetching objects unnecessarily.", - )) - - from dojo.middleware import System_Settings_Manager # noqa: PLC0415 circular import - objects = System_Settings_Manager() - - def clean(self): - super().clean() - - if ( - self.minimum_password_length is not None - and self.maximum_password_length is not None - ): - if self.minimum_password_length > self.maximum_password_length: - msg = "Minimum required password length must be larger than the maximum required password length." - raise ValidationError({ - "minimum_password_length": msg, - }) +from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401, I001 -- must precede system_settings (middleware load-order) +from dojo.system_settings.models import System_Settings # noqa: E402, F401 -- re-export def get_current_date(): @@ -2123,7 +1763,7 @@ def __str__(self): admin.site.register(Tool_Configuration, Tool_Configuration_Admin) admin.site.register(Tool_Product_Settings) admin.site.register(Tool_Type) -admin.site.register(System_Settings) + admin.site.register(SLA_Configuration) admin.site.register(Regulation) from dojo.authorization.models import ( # noqa: E402 diff --git a/dojo/system_settings/__init__.py b/dojo/system_settings/__init__.py index e69de29bb2d..50305d372ec 100644 --- a/dojo/system_settings/__init__.py +++ b/dojo/system_settings/__init__.py @@ -0,0 +1 @@ +import dojo.system_settings.admin # noqa: F401 diff --git a/dojo/system_settings/admin.py b/dojo/system_settings/admin.py new file mode 100644 index 00000000000..6a06a94bbf1 --- /dev/null +++ b/dojo/system_settings/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.system_settings.models import System_Settings + +admin.site.register(System_Settings) diff --git a/dojo/system_settings/api/__init__.py b/dojo/system_settings/api/__init__.py new file mode 100644 index 00000000000..d8f9bbde95a --- /dev/null +++ b/dojo/system_settings/api/__init__.py @@ -0,0 +1 @@ +path = "system_settings" # noqa: RUF067 diff --git a/dojo/system_settings/api/serializer.py b/dojo/system_settings/api/serializer.py new file mode 100644 index 00000000000..c58fe7549b4 --- /dev/null +++ b/dojo/system_settings/api/serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from dojo.system_settings.models import System_Settings + + +class SystemSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = System_Settings + fields = "__all__" diff --git a/dojo/system_settings/api/urls.py b/dojo/system_settings/api/urls.py new file mode 100644 index 00000000000..d22f65e5dbe --- /dev/null +++ b/dojo/system_settings/api/urls.py @@ -0,0 +1,7 @@ +from dojo.system_settings.api import path +from dojo.system_settings.api.views import SystemSettingsViewSet + + +def add_system_settings_urls(router): + router.register(path, SystemSettingsViewSet, basename="system_settings") + return router diff --git a/dojo/system_settings/api/views.py b/dojo/system_settings/api/views.py new file mode 100644 index 00000000000..3e3f6d90ec9 --- /dev/null +++ b/dojo/system_settings/api/views.py @@ -0,0 +1,21 @@ +from rest_framework import mixins, viewsets +from rest_framework.permissions import DjangoModelPermissions + +from dojo.authorization import api_permissions as permissions +from dojo.system_settings.api.serializer import SystemSettingsSerializer +from dojo.system_settings.models import System_Settings + + +# Authorization: superuser +class SystemSettingsViewSet( + mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet, +): + + """Basic control over System Settings. Use 'id' 1 for PUT, PATCH operations""" + + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) + serializer_class = SystemSettingsSerializer + queryset = System_Settings.objects.none() + + def get_queryset(self): + return System_Settings.objects.all().order_by("id") diff --git a/dojo/system_settings/models.py b/dojo/system_settings/models.py new file mode 100644 index 00000000000..81024a58383 --- /dev/null +++ b/dojo/system_settings/models.py @@ -0,0 +1,365 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils.translation import gettext as _ + + +class System_Settings(models.Model): + enable_deduplication = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Deduplicate findings"), + help_text=_("With this setting turned on, DefectDojo deduplicates findings by " + "comparing endpoints, cwe fields, and titles. " + "If two findings share a URL and have the same CWE or " + "title, DefectDojo marks the recent finding as a duplicate. " + "When deduplication is enabled, a list of " + "deduplicated findings is added to the engagement view.")) + delete_duplicates = models.BooleanField(default=False, blank=False, help_text=_("Requires next setting: maximum number of duplicates to retain.")) + max_dupes = models.IntegerField(blank=True, null=True, default=10, + verbose_name=_("Max Duplicates"), + help_text=_("When enabled, if a single " + "issue reaches the maximum " + "number of duplicates, the " + "oldest will be deleted. Duplicate will not be deleted when left empty. A value of 0 will remove all duplicates.")) + + email_from = models.CharField(max_length=200, default="no-reply@example.com", blank=True) + + enable_jira = models.BooleanField(default=False, + verbose_name=_("Enable JIRA integration"), + blank=False) + + enable_jira_web_hook = models.BooleanField(default=False, + verbose_name=_("Enable JIRA web hook"), + help_text=_("Please note: It is strongly recommended to use a secret below and / or IP whitelist the JIRA server using a proxy such as Nginx."), + blank=False) + + disable_jira_webhook_secret = models.BooleanField(default=False, + verbose_name=_("Disable web hook secret"), + help_text=_("Allows incoming requests without a secret (discouraged legacy behaviour)"), + blank=False) + + # will be set to random / uuid by initializer so null needs to be True + jira_webhook_secret = models.CharField(max_length=64, blank=False, null=True, verbose_name=_("JIRA Webhook URL"), + help_text=_("Secret needed in URL for incoming JIRA Webhook")) + + jira_choices = (("Critical", "Critical"), + ("High", "High"), + ("Medium", "Medium"), + ("Low", "Low"), + ("Info", "Info")) + jira_minimum_severity = models.CharField(max_length=20, blank=True, + null=True, choices=jira_choices, + default="Low") + jira_labels = models.CharField(max_length=200, blank=True, null=True, + help_text=_("JIRA issue labels space seperated")) + + add_vulnerability_id_to_jira_label = models.BooleanField(default=False, + verbose_name=_("Add vulnerability Id as a JIRA label"), + blank=False) + + enable_github = models.BooleanField(default=False, + verbose_name=_("Enable GITHUB integration"), + blank=False) + + enable_slack_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Slack notifications"), + blank=False) + slack_channel = models.CharField(max_length=100, default="", blank=True, + help_text=_("Optional. Needed if you want to send global notifications.")) + slack_token = models.CharField(max_length=100, default="", blank=True, + help_text=_("Token required for interacting " + "with Slack. Get one at " + "https://api.slack.com/tokens")) + slack_username = models.CharField(max_length=100, default="", blank=True, + help_text=_("Optional. Will take your bot name otherwise.")) + enable_msteams_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Microsoft Teams notifications"), + blank=False) + msteams_url = models.CharField(max_length=400, default="", blank=True, + help_text=_("The full URL of the " + "incoming webhook")) + enable_mail_notifications = models.BooleanField(default=False, blank=False) + mail_notifications_to = models.CharField(max_length=200, default="", + blank=True) + + enable_webhooks_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Webhook notifications"), + blank=False) + webhooks_notifications_timeout = models.IntegerField(default=10, + help_text=_("How many seconds will DefectDojo waits for response from webhook endpoint")) + + enforce_verified_status = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Globally"), + help_text=_( + "When enabled, features such as product grading, jira " + "integration, metrics, and reports will only interact " + "with verified findings. This setting will override " + "individually scoped verified toggles.", + ), + ) + enforce_verified_status_jira = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Jira"), + help_text=_("When enabled, findings must have a verified status to be pushed to jira."), + ) + enforce_verified_status_product_grading = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Product Grading"), + help_text=_( + "When enabled, findings must have a verified status to be considered as part of a product's grading.", + ), + ) + enforce_verified_status_metrics = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Metrics"), + help_text=_( + "When enabled, findings must have a verified status to be counted in metric calculations, " + "be included in reports, and filters.", + ), + ) + + false_positive_history = models.BooleanField( + default=False, help_text=_( + "(EXPERIMENTAL) DefectDojo will automatically mark the finding as a " + "false positive if an equal finding (according to its dedupe algorithm) " + "has been previously marked as a false positive on the same product. " + "ATTENTION: Although the deduplication algorithm is used to determine " + "if a finding should be marked as a false positive, this feature will " + "not work if deduplication is enabled since it doesn't make sense to use both.", + ), + ) + + retroactive_false_positive_history = models.BooleanField( + default=False, help_text=_( + "(EXPERIMENTAL) FP History will also retroactively mark/unmark all " + "existing equal findings in the same product as a false positives. " + "Only works if the False Positive History feature is also enabled.", + ), + ) + + url_prefix = models.CharField(max_length=300, default="", blank=True, help_text=_("URL prefix if DefectDojo is installed in it's own virtual subdirectory.")) + team_name = models.CharField(max_length=100, default="", blank=True) + enable_product_grade = models.BooleanField(default=False, verbose_name=_("Enable Product Grading"), help_text=_("Displays a grade letter next to a product to show the overall health.")) + product_grade_a = models.IntegerField(default=90, + verbose_name=_("Grade A"), + help_text=_("Percentage score for an " + "'A' >=")) + product_grade_b = models.IntegerField(default=80, + verbose_name=_("Grade B"), + help_text=_("Percentage score for a " + "'B' >=")) + product_grade_c = models.IntegerField(default=70, + verbose_name=_("Grade C"), + help_text=_("Percentage score for a " + "'C' >=")) + product_grade_d = models.IntegerField(default=60, + verbose_name=_("Grade D"), + help_text=_("Percentage score for a " + "'D' >=")) + product_grade_f = models.IntegerField(default=59, + verbose_name=_("Grade F"), + help_text=_("Percentage score for an " + "'F' <=")) + enable_product_tag_inheritance = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Product Tag Inheritance"), + help_text=_("Enables product tag inheritance globally for all products. Any tags added on a product will automatically be added to all Engagements, Tests, and Findings")) + + enable_benchmark = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Benchmarks"), + help_text=_("Enables Benchmarks such as the OWASP ASVS " + "(Application Security Verification Standard)")) + + enable_similar_findings = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Similar Findings"), + help_text=_("Enable the query of similar findings on the view finding page. This feature can involve potentially large queries and negatively impact performance")) + + engagement_auto_close = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Engagement Auto-Close"), + help_text=_("Closes an engagement after 3 days (default) past due date including last update.")) + + engagement_auto_close_days = models.IntegerField( + default=3, + blank=False, + verbose_name=_("Engagement Auto-Close Days"), + help_text=_("Closes an engagement after the specified number of days past due date including last update.")) + + enable_finding_sla = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Finding SLA's"), + help_text=_("Enables Finding SLA's for time to remediate.")) + + enable_notify_sla_active = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Notify SLA's Breach for active Findings"), + help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active Findings.")) + + enable_notify_sla_active_verified = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Notify SLA's Breach for active, verified Findings"), + help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active, verified Findings.")) + + enable_notify_sla_jira_only = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Notify SLA's Breach only for Findings linked to JIRA"), + help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for Findings that are linked to JIRA issues. Notification is disabled for Findings not linked to JIRA issues")) + + enable_notify_sla_exponential_backoff = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable an exponential backoff strategy for SLA breach notifications."), + help_text=_("Enable an exponential backoff strategy for SLA breach notifications, e.g. 1, 2, 4, 8, etc. Otherwise it alerts every day")) + + allow_anonymous_survey_repsonse = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Allow Anonymous Survey Responses"), + help_text=_("Enable anyone with a link to the survey to answer a survey"), + ) + disclaimer_notifications = models.TextField(max_length=3000, default="", blank=True, + verbose_name=_("Custom Disclaimer for Notifications"), + help_text=_("Include this custom disclaimer on all notifications")) + disclaimer_reports = models.TextField(max_length=5000, default="", blank=True, + verbose_name=_("Custom Disclaimer for Reports"), + help_text=_("Include this custom disclaimer on generated reports")) + disclaimer_reports_forced = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Force to add disclaimer reports"), + help_text=_("Disclaimer will be added to all reports even if user didn't selected 'Include disclaimer'.")) + disclaimer_notes = models.TextField(max_length=3000, default="", blank=True, + verbose_name=_("Custom Disclaimer for Notes"), + help_text=_("Include this custom disclaimer next to input form for notes")) + risk_acceptance_form_default_days = models.IntegerField(null=True, blank=True, default=180, help_text=_("Default expiry period for risk acceptance form.")) + risk_acceptance_notify_before_expiration = models.IntegerField(null=True, blank=True, default=10, + verbose_name=_("Risk acceptance expiration heads up days"), help_text=_("Notify X days before risk acceptance expires. Leave empty to disable.")) + enable_questionnaires = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable questionnaires"), + help_text=_("With this setting turned off, questionnaires will be disabled in the user interface.")) + enable_checklists = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable checklists"), + help_text=_("With this setting turned off, checklists will be disabled in the user interface.")) + enable_endpoint_metadata_import = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Endpoint Metadata Import"), + help_text=_("With this setting turned off, endpoint metadata import will be disabled in the user interface.")) + enable_user_profile_editable = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable user profile for writing"), + help_text=_("When turned on users can edit their profiles")) + enable_product_tracking_files = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Product Tracking Files"), + help_text=_("With this setting turned off, the product tracking files will be disabled in the user interface.")) + enable_finding_groups = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Finding Groups"), + help_text=_("With this setting turned off, the Finding Groups will be disabled.")) + enable_ui_table_based_searching = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable UI Table Based Filtering/Sorting"), + help_text=_("With this setting enabled, table headings will contain sort buttons for the current page of data in addition to sorting buttons that consider data from all pages.")) + enable_calendar = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Calendar"), + help_text=_("With this setting turned off, the Calendar will be disabled in the user interface.")) + enable_cvss3_display = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable CVSS3 Display"), + help_text=_("With this setting turned off, CVSS3 fields will be hidden in the user interface.")) + enable_cvss4_display = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable CVSS4 Display"), + help_text=_("With this setting turned off, CVSS4 fields will be hidden in the user interface.")) + minimum_password_length = models.IntegerField( + default=9, + verbose_name=_("Minimum password length"), + help_text=_("Requires user to set passwords greater than minimum length."), + validators=[MinValueValidator(9), MaxValueValidator(48)]) + maximum_password_length = models.IntegerField( + default=48, + verbose_name=_("Maximum password length"), + help_text=_("Requires user to set passwords less than maximum length."), + validators=[MinValueValidator(9), MaxValueValidator(48)]) + number_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one digit"), + help_text=_("Requires user passwords to contain at least one digit (0-9).")) + special_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one special character"), + help_text=_("Requires user passwords to contain at least one special character (()[]{}|\\`~!@#$%^&*_-+=;:'\",<>./?).")) + lowercase_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one lowercase letter"), + help_text=_("Requires user passwords to contain at least one lowercase letter (a-z).")) + uppercase_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one uppercase letter"), + help_text=_("Requires user passwords to contain at least one uppercase letter (A-Z).")) + non_common_password_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must not be common"), + help_text=_("Requires user passwords to not be part of list of common passwords.")) + api_expose_error_details = models.BooleanField( + default=False, + blank=False, + verbose_name=_("API expose error details"), + help_text=_("When turned on, the API will expose error details in the response.")) + filter_string_matching = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Filter String Matching Optimization"), + help_text=_( + "When turned on, all filter operations in the UI will require string matches rather than ID. " + "This is a performance enhancement to avoid fetching objects unnecessarily.", + )) + + from dojo.middleware import System_Settings_Manager # noqa: PLC0415 circular import + objects = System_Settings_Manager() + + def clean(self): + super().clean() + + if ( + self.minimum_password_length is not None + and self.maximum_password_length is not None + ): + if self.minimum_password_length > self.maximum_password_length: + msg = "Minimum required password length must be larger than the maximum required password length." + raise ValidationError({ + "minimum_password_length": msg, + }) diff --git a/dojo/system_settings/ui/__init__.py b/dojo/system_settings/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/system_settings/ui/forms.py b/dojo/system_settings/ui/forms.py new file mode 100644 index 00000000000..699ed2add7f --- /dev/null +++ b/dojo/system_settings/ui/forms.py @@ -0,0 +1,41 @@ +from django import forms + +from dojo.labels import get_labels +from dojo.system_settings.models import System_Settings + +labels = get_labels() + + +class SystemSettingsForm(forms.ModelForm): + jira_webhook_secret = forms.CharField(required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["enable_product_tracking_files"].label = labels.SETTINGS_TRACKED_FILES_ENABLE_LABEL + self.fields["enable_product_tracking_files"].help_text = labels.SETTINGS_TRACKED_FILES_ENABLE_HELP + + self.fields[ + "enforce_verified_status_product_grading"].label = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_LABEL + self.fields[ + "enforce_verified_status_product_grading"].help_text = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_HELP + + self.fields["enable_product_grade"].label = labels.SETTINGS_ASSET_GRADING_ENABLE_LABEL + self.fields["enable_product_grade"].help_text = labels.SETTINGS_ASSET_GRADING_ENABLE_HELP + + self.fields["enable_product_tag_inheritance"].label = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_LABEL + self.fields["enable_product_tag_inheritance"].help_text = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_HELP + + def clean(self): + cleaned_data = super().clean() + enable_jira_value = cleaned_data.get("enable_jira") + jira_webhook_secret_value = cleaned_data.get("jira_webhook_secret").strip() + + if enable_jira_value and not jira_webhook_secret_value: + self.add_error("jira_webhook_secret", "This field is required when enable Jira Integration is True") + + return cleaned_data + + class Meta: + model = System_Settings + exclude = () diff --git a/dojo/system_settings/urls.py b/dojo/system_settings/ui/urls.py similarity index 87% rename from dojo/system_settings/urls.py rename to dojo/system_settings/ui/urls.py index 8268f6ee0ca..ff93931611d 100644 --- a/dojo/system_settings/urls.py +++ b/dojo/system_settings/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.system_settings import views +from dojo.system_settings.ui import views urlpatterns = [ re_path( diff --git a/dojo/system_settings/views.py b/dojo/system_settings/ui/views.py similarity index 97% rename from dojo/system_settings/views.py rename to dojo/system_settings/ui/views.py index 2b375627ae2..a2088f4b7ea 100644 --- a/dojo/system_settings/views.py +++ b/dojo/system_settings/ui/views.py @@ -6,8 +6,8 @@ from django.shortcuts import render from django.views import View -from dojo.forms import SystemSettingsForm -from dojo.models import System_Settings +from dojo.system_settings.models import System_Settings +from dojo.system_settings.ui.forms import SystemSettingsForm from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) diff --git a/dojo/urls.py b/dojo/urls.py index 86697752349..7992d87f3f3 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -37,7 +37,6 @@ SLAConfigurationViewset, SonarqubeIssueTransitionViewSet, SonarqubeIssueViewSet, - SystemSettingsViewSet, ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, @@ -75,7 +74,8 @@ from dojo.search.urls import urlpatterns as search_urls from dojo.sla_config.urls import urlpatterns as sla_urls from dojo.survey.urls import urlpatterns as survey_urls -from dojo.system_settings.urls import urlpatterns as system_settings_urls +from dojo.system_settings.api.urls import add_system_settings_urls +from dojo.system_settings.ui.urls import urlpatterns as system_settings_urls from dojo.test.api.urls import add_test_urls from dojo.test.ui.urls import urlpatterns as test_urls from dojo.test_type.urls import urlpatterns as test_type_urls @@ -136,7 +136,7 @@ v2_api.register(r"sla_configurations", SLAConfigurationViewset, basename="sla_configurations") v2_api.register(r"sonarqube_issues", SonarqubeIssueViewSet, basename="sonarqube_issue") v2_api.register(r"sonarqube_transitions", SonarqubeIssueTransitionViewSet, basename="sonarqube_issue_transition") -v2_api.register(r"system_settings", SystemSettingsViewSet, basename="system_settings") +v2_api = add_system_settings_urls(v2_api) v2_api.register(r"technologies", AppAnalysisViewSet, basename="app_analysis") v2_api = add_test_urls(v2_api) v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") From e9a79789e7964acc5b243d66a82fe93481125807 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 23:36:25 +0200 Subject: [PATCH 31/40] refactor(tools): extract tool_type, tool_config, tool_product into dojo// [Phase 1,3,5,6,8,9] --- dojo/api_v2/serializers.py | 39 +---------- dojo/api_v2/views.py | 64 ----------------- dojo/forms.py | 82 ---------------------- dojo/models.py | 102 +--------------------------- dojo/tool_config/__init__.py | 1 + dojo/tool_config/admin.py | 40 +++++++++++ dojo/tool_config/api/__init__.py | 1 + dojo/tool_config/api/serializer.py | 14 ++++ dojo/tool_config/api/urls.py | 6 ++ dojo/tool_config/api/views.py | 32 +++++++++ dojo/tool_config/models.py | 31 +++++++++ dojo/tool_config/ui/__init__.py | 0 dojo/tool_config/ui/forms.py | 27 ++++++++ dojo/tool_config/{ => ui}/urls.py | 2 +- dojo/tool_config/{ => ui}/views.py | 4 +- dojo/tool_product/__init__.py | 1 + dojo/tool_product/admin.py | 6 ++ dojo/tool_product/api/__init__.py | 1 + dojo/tool_product/api/serializer.py | 15 ++++ dojo/tool_product/api/urls.py | 6 ++ dojo/tool_product/api/views.py | 38 +++++++++++ dojo/tool_product/models.py | 25 +++++++ dojo/tool_product/queries.py | 2 +- dojo/tool_product/signals.py | 2 +- dojo/tool_product/ui/__init__.py | 0 dojo/tool_product/ui/forms.py | 37 ++++++++++ dojo/tool_product/{ => ui}/urls.py | 2 +- dojo/tool_product/{ => ui}/views.py | 5 +- dojo/tool_type/__init__.py | 1 + dojo/tool_type/admin.py | 5 ++ dojo/tool_type/api/__init__.py | 1 + dojo/tool_type/api/serializer.py | 18 +++++ dojo/tool_type/api/urls.py | 6 ++ dojo/tool_type/api/views.py | 24 +++++++ dojo/tool_type/models.py | 12 ++++ dojo/tool_type/ui/__init__.py | 0 dojo/tool_type/ui/forms.py | 27 ++++++++ dojo/tool_type/{ => ui}/urls.py | 2 +- dojo/tool_type/{ => ui}/views.py | 4 +- dojo/urls.py | 18 ++--- unittests/test_rest_framework.py | 6 +- 41 files changed, 405 insertions(+), 304 deletions(-) create mode 100644 dojo/tool_config/admin.py create mode 100644 dojo/tool_config/api/__init__.py create mode 100644 dojo/tool_config/api/serializer.py create mode 100644 dojo/tool_config/api/urls.py create mode 100644 dojo/tool_config/api/views.py create mode 100644 dojo/tool_config/models.py create mode 100644 dojo/tool_config/ui/__init__.py create mode 100644 dojo/tool_config/ui/forms.py rename dojo/tool_config/{ => ui}/urls.py (89%) rename dojo/tool_config/{ => ui}/views.py (97%) create mode 100644 dojo/tool_product/admin.py create mode 100644 dojo/tool_product/api/__init__.py create mode 100644 dojo/tool_product/api/serializer.py create mode 100644 dojo/tool_product/api/urls.py create mode 100644 dojo/tool_product/api/views.py create mode 100644 dojo/tool_product/models.py create mode 100644 dojo/tool_product/ui/__init__.py create mode 100644 dojo/tool_product/ui/forms.py rename dojo/tool_product/{ => ui}/urls.py (93%) rename dojo/tool_product/{ => ui}/views.py (95%) create mode 100644 dojo/tool_type/admin.py create mode 100644 dojo/tool_type/api/__init__.py create mode 100644 dojo/tool_type/api/serializer.py create mode 100644 dojo/tool_type/api/urls.py create mode 100644 dojo/tool_type/api/views.py create mode 100644 dojo/tool_type/models.py create mode 100644 dojo/tool_type/ui/__init__.py create mode 100644 dojo/tool_type/ui/forms.py rename dojo/tool_type/{ => ui}/urls.py (89%) rename dojo/tool_type/{ => ui}/views.py (95%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index be48838afe0..13d1a3add3a 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -57,9 +57,6 @@ Sonarqube_Issue, Sonarqube_Issue_Transition, Test, - Tool_Configuration, - Tool_Product_Settings, - Tool_Type, User, get_current_date, ) @@ -412,19 +409,7 @@ class Meta: fields = "__all__" -class ToolTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Tool_Type - fields = "__all__" - - def validate(self, data): - if self.context["request"].method == "POST": - name = data.get("name") - # Make sure this will not create a duplicate test type - if Tool_Type.objects.filter(name=name).count() > 0: - msg = "A Tool Type with the name already exists" - raise serializers.ValidationError(msg) - return data +from dojo.tool_type.api.serializer import ToolTypeSerializer # noqa: E402, F401 -- re-export class RegulationSerializer(serializers.ModelSerializer): @@ -433,26 +418,8 @@ class Meta: fields = "__all__" -class ToolConfigurationSerializer(serializers.ModelSerializer): - class Meta: - model = Tool_Configuration - fields = "__all__" - extra_kwargs = { - "password": {"write_only": True}, - "ssh": {"write_only": True}, - "api_key": {"write_only": True}, - } - - -class ToolProductSettingsSerializer(serializers.ModelSerializer): - setting_url = serializers.CharField(source="url") - product = serializers.PrimaryKeyRelatedField( - queryset=Product.objects.all(), required=True, - ) - - class Meta: - model = Tool_Product_Settings - fields = "__all__" +from dojo.tool_config.api.serializer import ToolConfigurationSerializer # noqa: E402, F401 -- re-export +from dojo.tool_product.api.serializer import ToolProductSettingsSerializer # noqa: E402, F401 -- re-export class EndpointStatusSerializer(serializers.ModelSerializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 50ddd53a4d5..7e0f8aebb19 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -80,9 +80,6 @@ Sonarqube_Issue_Transition, System_Settings, Test, - Tool_Configuration, - Tool_Product_Settings, - Tool_Type, ) from dojo.product.queries import ( get_authorized_app_analysis, @@ -98,7 +95,6 @@ from dojo.risk_acceptance.helper import remove_finding_from_risk_acceptance from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests -from dojo.tool_product.queries import get_authorized_tool_product_settings from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( get_celery_queue_details, @@ -574,66 +570,6 @@ def get_queryset(self): return Development_Environment.objects.all().order_by("id") -# Authorization: configurations -@extend_schema_view(**schema_with_prefetch()) -class ToolConfigurationsViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.ToolConfigurationSerializer - queryset = Tool_Configuration.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "name", - "tool_type", - "url", - "authentication_type", - ] - permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) - - def get_queryset(self): - return Tool_Configuration.objects.all().order_by("id") - - -# Authorization: object-based -@extend_schema_view(**schema_with_prefetch()) -class ToolProductSettingsViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.ToolProductSettingsSerializer - queryset = Tool_Product_Settings.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "name", - "product", - "tool_configuration", - "tool_project_id", - "url", - ] - permission_classes = ( - IsAuthenticated, - permissions.UserHasToolProductSettingsPermission, - ) - - def get_queryset(self): - return get_authorized_tool_product_settings("view") - - -# Authorization: configuration -class ToolTypesViewSet( - DojoModelViewSet, -): - serializer_class = serializers.ToolTypeSerializer - queryset = Tool_Type.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["id", "name", "description"] - permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) - - def get_queryset(self): - return Tool_Type.objects.all().order_by("id") - - # Authorization: authenticated, configuration class RegulationsViewSet( DojoModelViewSet, diff --git a/dojo/forms.py b/dojo/forms.py index 57ce9e0f53b..0d2ed86168b 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -15,7 +15,6 @@ from django.contrib.auth.models import Permission from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError -from django.core.validators import URLValidator from django.db.models import Count from django.forms import modelformset_factory from django.forms.widgets import Select, Widget @@ -86,9 +85,6 @@ Test_Type, TextAnswer, TextQuestion, - Tool_Configuration, - Tool_Product_Settings, - Tool_Type, User, ) from dojo.product.queries import get_authorized_products @@ -1120,30 +1116,6 @@ class Meta: fields = ["id"] -class ToolTypeForm(forms.ModelForm): - class Meta: - model = Tool_Type - exclude = ["product"] - - def __init__(self, *args, **kwargs): - instance = kwargs.get("instance") - self.newly_created = True - if instance is not None: - self.newly_created = instance.pk is None - super().__init__(*args, **kwargs) - - def clean(self): - form_data = self.cleaned_data - if self.newly_created: - name = form_data.get("name") - # Make sure this will not create a duplicate test type - if Tool_Type.objects.filter(name=name).count() > 0: - msg = "A Tool Type with the name already exists" - raise forms.ValidationError(msg) - - return form_data - - class RegulationForm(forms.ModelForm): class Meta: model = Regulation @@ -1174,28 +1146,6 @@ def __init__(self, *args, **kwargs): self.fields["website_found"].disabled = True -class ToolConfigForm(forms.ModelForm): - tool_type = forms.ModelChoiceField(queryset=Tool_Type.objects.all(), label="Tool Type") - ssh = forms.CharField(widget=forms.Textarea(attrs={}), required=False, label="SSH Key") - - class Meta: - model = Tool_Configuration - exclude = ["product"] - - def clean(self): - form_data = self.cleaned_data - - try: - if form_data["url"] is not None: - url_validator = URLValidator(schemes=["ssh", "http", "https"]) - url_validator(form_data["url"]) - except forms.ValidationError: - msg = "It does not appear as though this endpoint is a valid URL/SSH or IP address." - raise forms.ValidationError(msg, code="invalid") - - return form_data - - class SLAConfigForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1244,38 +1194,6 @@ class Meta: fields = ["id"] -class DeleteToolProductSettingsForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Tool_Product_Settings - fields = ["id"] - - -class ToolProductSettingsForm(forms.ModelForm): - tool_configuration = forms.ModelChoiceField(queryset=Tool_Configuration.objects.all(), label="Tool Configuration") - - class Meta: - model = Tool_Product_Settings - fields = ["name", "description", "url", "tool_configuration", "tool_project_id"] - exclude = ["tool_type"] - order = ["name"] - - def clean(self): - form_data = self.cleaned_data - - try: - if form_data["url"] is not None: - url_validator = URLValidator(schemes=["ssh", "http", "https"]) - url_validator(form_data["url"]) - except forms.ValidationError: - msg = "It does not appear as though this endpoint is a valid URL/SSH or IP address." - raise forms.ValidationError(msg, code="invalid") - - return form_data - - class ObjectSettingsForm(forms.ModelForm): # tags = forms.CharField(widget=forms.SelectMultiple(choices=[]), diff --git a/dojo/models.py b/dojo/models.py index bec3b2ec4f9..47487685fd2 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -10,7 +10,6 @@ import hyperlink import tagulous.admin -from django import forms from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model @@ -507,77 +506,8 @@ def get_summary(self): return f"{self.name} - Critical: {self.critical}, High: {self.high}, Medium: {self.medium}, Low: {self.low}" -class Tool_Type(models.Model): - name = models.CharField(max_length=200) - description = models.CharField(max_length=2000, null=True, blank=True) - - class Meta: - ordering = ["name"] - - def __str__(self): - return self.name - - -class Tool_Configuration(models.Model): - name = models.CharField(max_length=200, null=False) - description = models.CharField(max_length=2000, null=True, blank=True) - url = models.CharField(max_length=2000, null=True, blank=True) - tool_type = models.ForeignKey(Tool_Type, related_name="tool_type", on_delete=models.CASCADE) - authentication_type = models.CharField(max_length=15, - choices=( - ("API", "API Key"), - ("Password", - "Username/Password"), - ("SSH", "SSH")), - null=True, blank=True) - extras = models.CharField(max_length=255, null=True, blank=True, help_text=_("Additional definitions that will be " - "consumed by scanner")) - username = models.CharField(max_length=200, null=True, blank=True) - password = models.CharField(max_length=600, null=True, blank=True) - auth_title = models.CharField(max_length=200, null=True, blank=True, - verbose_name=_("Title for SSH/API Key")) - ssh = models.CharField(max_length=6000, null=True, blank=True) - api_key = models.CharField(max_length=600, null=True, blank=True, - verbose_name=_("API Key")) - - class Meta: - ordering = ["name"] - - def __str__(self): - return self.name - - -# declare form here as we can't import forms.py due to circular imports not even locally -class ToolConfigForm_Admin(forms.ModelForm): - password = forms.CharField(widget=forms.PasswordInput, required=False) - api_key = forms.CharField(widget=forms.PasswordInput, required=False) - ssh = forms.CharField(widget=forms.PasswordInput, required=False) - - # django doesn't seem to have an easy way to handle password fields as PasswordInput requires reentry of passwords - password_from_db = None - ssh_from_db = None - api_key_from_db = None - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance: - # keep password from db to use if the user entered no password - self.password_from_db = self.instance.password - self.ssh_from_db = self.instance.ssh - self.api_key = self.instance.api_key - - def clean(self): - cleaned_data = super().clean() - if not cleaned_data["password"] and not cleaned_data["ssh"] and not cleaned_data["api_key"]: - cleaned_data["password"] = self.password_from_db - cleaned_data["ssh"] = self.ssh_from_db - cleaned_data["api_key"] = self.api_key_from_db - - return cleaned_data - - -class Tool_Configuration_Admin(admin.ModelAdmin): - form = ToolConfigForm_Admin +from dojo.tool_config.models import Tool_Configuration # noqa: E402, F401 -- re-export +from dojo.tool_type.models import Tool_Type # noqa: E402, F401 -- re-export class Network_Locations(models.Model): @@ -1312,28 +1242,7 @@ class BannerConf(models.Model): Notification_Webhooks, Notifications, ) - - -class Tool_Product_Settings(models.Model): - name = models.CharField(max_length=200, null=False) - description = models.CharField(max_length=2000, null=True, blank=True) - url = models.CharField(max_length=2000, null=True, blank=True) - product = models.ForeignKey(Product, default=1, editable=False, on_delete=models.CASCADE) - tool_configuration = models.ForeignKey(Tool_Configuration, null=False, - related_name="tool_configuration", on_delete=models.CASCADE) - tool_project_id = models.CharField(max_length=200, null=True, blank=True) - notes = models.ManyToManyField(Notes, blank=True, editable=False) - - class Meta: - ordering = ["name"] - - -class Tool_Product_History(models.Model): - product = models.ForeignKey(Tool_Product_Settings, editable=False, on_delete=models.CASCADE) - last_scan = models.DateTimeField(null=False, editable=False, default=now) - succesfull = models.BooleanField(default=True, verbose_name=_("Succesfully")) - configuration_details = models.CharField(max_length=2000, null=True, - blank=True) +from dojo.tool_product.models import Tool_Product_History, Tool_Product_Settings # noqa: E402, F401 -- re-export class Language_Type(models.Model): @@ -1760,10 +1669,6 @@ def __str__(self): admin.site.register(Endpoint) admin.site.register(Notes) admin.site.register(Note_Type) -admin.site.register(Tool_Configuration, Tool_Configuration_Admin) -admin.site.register(Tool_Product_Settings) -admin.site.register(Tool_Type) - admin.site.register(SLA_Configuration) admin.site.register(Regulation) from dojo.authorization.models import ( # noqa: E402 @@ -1798,5 +1703,4 @@ def __str__(self): admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) -admin.site.register(Tool_Product_History) admin.site.register(General_Survey) diff --git a/dojo/tool_config/__init__.py b/dojo/tool_config/__init__.py index e69de29bb2d..3f63d29bec4 100644 --- a/dojo/tool_config/__init__.py +++ b/dojo/tool_config/__init__.py @@ -0,0 +1 @@ +import dojo.tool_config.admin # noqa: F401 diff --git a/dojo/tool_config/admin.py b/dojo/tool_config/admin.py new file mode 100644 index 00000000000..cb07719c546 --- /dev/null +++ b/dojo/tool_config/admin.py @@ -0,0 +1,40 @@ +from django import forms +from django.contrib import admin + +from dojo.tool_config.models import Tool_Configuration + + +# declare form here as we can't import forms.py due to circular imports not even locally +class ToolConfigForm_Admin(forms.ModelForm): + password = forms.CharField(widget=forms.PasswordInput, required=False) + api_key = forms.CharField(widget=forms.PasswordInput, required=False) + ssh = forms.CharField(widget=forms.PasswordInput, required=False) + + # django doesn't seem to have an easy way to handle password fields as PasswordInput requires reentry of passwords + password_from_db = None + ssh_from_db = None + api_key_from_db = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance: + # keep password from db to use if the user entered no password + self.password_from_db = self.instance.password + self.ssh_from_db = self.instance.ssh + self.api_key = self.instance.api_key + + def clean(self): + cleaned_data = super().clean() + if not cleaned_data["password"] and not cleaned_data["ssh"] and not cleaned_data["api_key"]: + cleaned_data["password"] = self.password_from_db + cleaned_data["ssh"] = self.ssh_from_db + cleaned_data["api_key"] = self.api_key_from_db + + return cleaned_data + + +class Tool_Configuration_Admin(admin.ModelAdmin): + form = ToolConfigForm_Admin + + +admin.site.register(Tool_Configuration, Tool_Configuration_Admin) diff --git a/dojo/tool_config/api/__init__.py b/dojo/tool_config/api/__init__.py new file mode 100644 index 00000000000..3f12ce59e94 --- /dev/null +++ b/dojo/tool_config/api/__init__.py @@ -0,0 +1 @@ +path = "tool_configurations" # noqa: RUF067 diff --git a/dojo/tool_config/api/serializer.py b/dojo/tool_config/api/serializer.py new file mode 100644 index 00000000000..f80dc1b782d --- /dev/null +++ b/dojo/tool_config/api/serializer.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from dojo.tool_config.models import Tool_Configuration + + +class ToolConfigurationSerializer(serializers.ModelSerializer): + class Meta: + model = Tool_Configuration + fields = "__all__" + extra_kwargs = { + "password": {"write_only": True}, + "ssh": {"write_only": True}, + "api_key": {"write_only": True}, + } diff --git a/dojo/tool_config/api/urls.py b/dojo/tool_config/api/urls.py new file mode 100644 index 00000000000..cc4620473a7 --- /dev/null +++ b/dojo/tool_config/api/urls.py @@ -0,0 +1,6 @@ +from dojo.tool_config.api.views import ToolConfigurationsViewSet + + +def add_tool_config_urls(router): + router.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") + return router diff --git a/dojo/tool_config/api/views.py b/dojo/tool_config/api/views.py new file mode 100644 index 00000000000..91a740610b7 --- /dev/null +++ b/dojo/tool_config/api/views.py @@ -0,0 +1,32 @@ +import logging + +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view + +from dojo.api_v2.views import PrefetchDojoModelViewSet, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.tool_config.api.serializer import ToolConfigurationSerializer +from dojo.tool_config.models import Tool_Configuration + +logger = logging.getLogger(__name__) + + +# Authorization: configurations +@extend_schema_view(**schema_with_prefetch()) +class ToolConfigurationsViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = ToolConfigurationSerializer + queryset = Tool_Configuration.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "name", + "tool_type", + "url", + "authentication_type", + ] + permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) + + def get_queryset(self): + return Tool_Configuration.objects.all().order_by("id") diff --git a/dojo/tool_config/models.py b/dojo/tool_config/models.py new file mode 100644 index 00000000000..6190fe839ce --- /dev/null +++ b/dojo/tool_config/models.py @@ -0,0 +1,31 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Tool_Configuration(models.Model): + name = models.CharField(max_length=200, null=False) + description = models.CharField(max_length=2000, null=True, blank=True) + url = models.CharField(max_length=2000, null=True, blank=True) + tool_type = models.ForeignKey("dojo.Tool_Type", related_name="tool_type", on_delete=models.CASCADE) + authentication_type = models.CharField(max_length=15, + choices=( + ("API", "API Key"), + ("Password", + "Username/Password"), + ("SSH", "SSH")), + null=True, blank=True) + extras = models.CharField(max_length=255, null=True, blank=True, help_text=_("Additional definitions that will be " + "consumed by scanner")) + username = models.CharField(max_length=200, null=True, blank=True) + password = models.CharField(max_length=600, null=True, blank=True) + auth_title = models.CharField(max_length=200, null=True, blank=True, + verbose_name=_("Title for SSH/API Key")) + ssh = models.CharField(max_length=6000, null=True, blank=True) + api_key = models.CharField(max_length=600, null=True, blank=True, + verbose_name=_("API Key")) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/dojo/tool_config/ui/__init__.py b/dojo/tool_config/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tool_config/ui/forms.py b/dojo/tool_config/ui/forms.py new file mode 100644 index 00000000000..cc769725d78 --- /dev/null +++ b/dojo/tool_config/ui/forms.py @@ -0,0 +1,27 @@ +from django import forms +from django.core.validators import URLValidator + +from dojo.tool_config.models import Tool_Configuration +from dojo.tool_type.models import Tool_Type + + +class ToolConfigForm(forms.ModelForm): + tool_type = forms.ModelChoiceField(queryset=Tool_Type.objects.all(), label="Tool Type") + ssh = forms.CharField(widget=forms.Textarea(attrs={}), required=False, label="SSH Key") + + class Meta: + model = Tool_Configuration + exclude = ["product"] + + def clean(self): + form_data = self.cleaned_data + + try: + if form_data["url"] is not None: + url_validator = URLValidator(schemes=["ssh", "http", "https"]) + url_validator(form_data["url"]) + except forms.ValidationError: + msg = "It does not appear as though this endpoint is a valid URL/SSH or IP address." + raise forms.ValidationError(msg, code="invalid") + + return form_data diff --git a/dojo/tool_config/urls.py b/dojo/tool_config/ui/urls.py similarity index 89% rename from dojo/tool_config/urls.py rename to dojo/tool_config/ui/urls.py index 263142742e6..c2d5862e460 100644 --- a/dojo/tool_config/urls.py +++ b/dojo/tool_config/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.tool_config.ui import views urlpatterns = [ re_path(r"^tool_config/add", views.new_tool_config, name="add_tool_config"), diff --git a/dojo/tool_config/views.py b/dojo/tool_config/ui/views.py similarity index 97% rename from dojo/tool_config/views.py rename to dojo/tool_config/ui/views.py index cb32d3203cf..a0807917925 100644 --- a/dojo/tool_config/views.py +++ b/dojo/tool_config/ui/views.py @@ -6,9 +6,9 @@ from django.shortcuts import render from django.urls import reverse -from dojo.forms import ToolConfigForm -from dojo.models import Tool_Configuration from dojo.tool_config.factory import create_API +from dojo.tool_config.models import Tool_Configuration +from dojo.tool_config.ui.forms import ToolConfigForm from dojo.utils import add_breadcrumb, dojo_crypto_encrypt, prepare_for_view logger = logging.getLogger(__name__) diff --git a/dojo/tool_product/__init__.py b/dojo/tool_product/__init__.py index e69de29bb2d..f145962c51d 100644 --- a/dojo/tool_product/__init__.py +++ b/dojo/tool_product/__init__.py @@ -0,0 +1 @@ +import dojo.tool_product.admin # noqa: F401 diff --git a/dojo/tool_product/admin.py b/dojo/tool_product/admin.py new file mode 100644 index 00000000000..19d8890eff1 --- /dev/null +++ b/dojo/tool_product/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from dojo.tool_product.models import Tool_Product_History, Tool_Product_Settings + +admin.site.register(Tool_Product_Settings) +admin.site.register(Tool_Product_History) diff --git a/dojo/tool_product/api/__init__.py b/dojo/tool_product/api/__init__.py new file mode 100644 index 00000000000..1a68ec15513 --- /dev/null +++ b/dojo/tool_product/api/__init__.py @@ -0,0 +1 @@ +path = "tool_product_settings" # noqa: RUF067 diff --git a/dojo/tool_product/api/serializer.py b/dojo/tool_product/api/serializer.py new file mode 100644 index 00000000000..12aafcdfec7 --- /dev/null +++ b/dojo/tool_product/api/serializer.py @@ -0,0 +1,15 @@ +from rest_framework import serializers + +from dojo.models import Product +from dojo.tool_product.models import Tool_Product_Settings + + +class ToolProductSettingsSerializer(serializers.ModelSerializer): + setting_url = serializers.CharField(source="url") + product = serializers.PrimaryKeyRelatedField( + queryset=Product.objects.all(), required=True, + ) + + class Meta: + model = Tool_Product_Settings + fields = "__all__" diff --git a/dojo/tool_product/api/urls.py b/dojo/tool_product/api/urls.py new file mode 100644 index 00000000000..f3fca7fa2f2 --- /dev/null +++ b/dojo/tool_product/api/urls.py @@ -0,0 +1,6 @@ +from dojo.tool_product.api.views import ToolProductSettingsViewSet + + +def add_tool_product_urls(router): + router.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") + return router diff --git a/dojo/tool_product/api/views.py b/dojo/tool_product/api/views.py new file mode 100644 index 00000000000..33a6278841d --- /dev/null +++ b/dojo/tool_product/api/views.py @@ -0,0 +1,38 @@ +import logging + +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view +from rest_framework.permissions import IsAuthenticated + +from dojo.api_v2.views import PrefetchDojoModelViewSet, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.tool_product.api.serializer import ToolProductSettingsSerializer +from dojo.tool_product.models import Tool_Product_Settings +from dojo.tool_product.queries import get_authorized_tool_product_settings + +logger = logging.getLogger(__name__) + + +# Authorization: object-based +@extend_schema_view(**schema_with_prefetch()) +class ToolProductSettingsViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = ToolProductSettingsSerializer + queryset = Tool_Product_Settings.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "name", + "product", + "tool_configuration", + "tool_project_id", + "url", + ] + permission_classes = ( + IsAuthenticated, + permissions.UserHasToolProductSettingsPermission, + ) + + def get_queryset(self): + return get_authorized_tool_product_settings("view") diff --git a/dojo/tool_product/models.py b/dojo/tool_product/models.py new file mode 100644 index 00000000000..2f77ea89a6e --- /dev/null +++ b/dojo/tool_product/models.py @@ -0,0 +1,25 @@ +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + + +class Tool_Product_Settings(models.Model): + name = models.CharField(max_length=200, null=False) + description = models.CharField(max_length=2000, null=True, blank=True) + url = models.CharField(max_length=2000, null=True, blank=True) + product = models.ForeignKey("dojo.Product", default=1, editable=False, on_delete=models.CASCADE) + tool_configuration = models.ForeignKey("dojo.Tool_Configuration", null=False, + related_name="tool_configuration", on_delete=models.CASCADE) + tool_project_id = models.CharField(max_length=200, null=True, blank=True) + notes = models.ManyToManyField("dojo.Notes", blank=True, editable=False) + + class Meta: + ordering = ["name"] + + +class Tool_Product_History(models.Model): + product = models.ForeignKey("dojo.Tool_Product_Settings", editable=False, on_delete=models.CASCADE) + last_scan = models.DateTimeField(null=False, editable=False, default=now) + succesfull = models.BooleanField(default=True, verbose_name=_("Succesfully")) + configuration_details = models.CharField(max_length=2000, null=True, + blank=True) diff --git a/dojo/tool_product/queries.py b/dojo/tool_product/queries.py index 6ae66429fdc..45dd338b5b3 100644 --- a/dojo/tool_product/queries.py +++ b/dojo/tool_product/queries.py @@ -3,8 +3,8 @@ except ImportError: def get_auth_filter(key): return None -from dojo.models import Tool_Product_Settings from dojo.request_cache import cache_for_request +from dojo.tool_product.models import Tool_Product_Settings # Cached: all parameters are hashable, no dynamic queryset filtering diff --git a/dojo/tool_product/signals.py b/dojo/tool_product/signals.py index 96dd881ff45..391189cfb26 100644 --- a/dojo/tool_product/signals.py +++ b/dojo/tool_product/signals.py @@ -3,8 +3,8 @@ from django.db.models.signals import pre_delete from django.dispatch import receiver -from dojo.models import Tool_Product_Settings from dojo.notes.helper import delete_related_notes +from dojo.tool_product.models import Tool_Product_Settings logger = logging.getLogger(__name__) diff --git a/dojo/tool_product/ui/__init__.py b/dojo/tool_product/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tool_product/ui/forms.py b/dojo/tool_product/ui/forms.py new file mode 100644 index 00000000000..f62dac4bb9c --- /dev/null +++ b/dojo/tool_product/ui/forms.py @@ -0,0 +1,37 @@ +from django import forms +from django.core.validators import URLValidator + +from dojo.tool_config.models import Tool_Configuration +from dojo.tool_product.models import Tool_Product_Settings + + +class DeleteToolProductSettingsForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Tool_Product_Settings + fields = ["id"] + + +class ToolProductSettingsForm(forms.ModelForm): + tool_configuration = forms.ModelChoiceField(queryset=Tool_Configuration.objects.all(), label="Tool Configuration") + + class Meta: + model = Tool_Product_Settings + fields = ["name", "description", "url", "tool_configuration", "tool_project_id"] + exclude = ["tool_type"] + order = ["name"] + + def clean(self): + form_data = self.cleaned_data + + try: + if form_data["url"] is not None: + url_validator = URLValidator(schemes=["ssh", "http", "https"]) + url_validator(form_data["url"]) + except forms.ValidationError: + msg = "It does not appear as though this endpoint is a valid URL/SSH or IP address." + raise forms.ValidationError(msg, code="invalid") + + return form_data diff --git a/dojo/tool_product/urls.py b/dojo/tool_product/ui/urls.py similarity index 93% rename from dojo/tool_product/urls.py rename to dojo/tool_product/ui/urls.py index 9acc6cdb139..943647d5d49 100644 --- a/dojo/tool_product/urls.py +++ b/dojo/tool_product/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.tool_product.ui import views urlpatterns = [ re_path(r"^product/(?P\d+)/tool_product/add$", views.new_tool_product, name="new_tool_product"), diff --git a/dojo/tool_product/views.py b/dojo/tool_product/ui/views.py similarity index 95% rename from dojo/tool_product/views.py rename to dojo/tool_product/ui/views.py index de142b1bcf8..39afab79e28 100644 --- a/dojo/tool_product/views.py +++ b/dojo/tool_product/ui/views.py @@ -8,8 +8,9 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from dojo.forms import DeleteToolProductSettingsForm, ToolProductSettingsForm -from dojo.models import Product, Tool_Product_Settings +from dojo.models import Product +from dojo.tool_product.models import Tool_Product_Settings +from dojo.tool_product.ui.forms import DeleteToolProductSettingsForm, ToolProductSettingsForm from dojo.utils import Product_Tab logger = logging.getLogger(__name__) diff --git a/dojo/tool_type/__init__.py b/dojo/tool_type/__init__.py index e69de29bb2d..0235ecd395e 100644 --- a/dojo/tool_type/__init__.py +++ b/dojo/tool_type/__init__.py @@ -0,0 +1 @@ +import dojo.tool_type.admin # noqa: F401 diff --git a/dojo/tool_type/admin.py b/dojo/tool_type/admin.py new file mode 100644 index 00000000000..2196308ff07 --- /dev/null +++ b/dojo/tool_type/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.tool_type.models import Tool_Type + +admin.site.register(Tool_Type) diff --git a/dojo/tool_type/api/__init__.py b/dojo/tool_type/api/__init__.py new file mode 100644 index 00000000000..7af3572bf6b --- /dev/null +++ b/dojo/tool_type/api/__init__.py @@ -0,0 +1 @@ +path = "tool_types" # noqa: RUF067 diff --git a/dojo/tool_type/api/serializer.py b/dojo/tool_type/api/serializer.py new file mode 100644 index 00000000000..5d056dcb8fc --- /dev/null +++ b/dojo/tool_type/api/serializer.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + +from dojo.tool_type.models import Tool_Type + + +class ToolTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Tool_Type + fields = "__all__" + + def validate(self, data): + if self.context["request"].method == "POST": + name = data.get("name") + # Make sure this will not create a duplicate test type + if Tool_Type.objects.filter(name=name).count() > 0: + msg = "A Tool Type with the name already exists" + raise serializers.ValidationError(msg) + return data diff --git a/dojo/tool_type/api/urls.py b/dojo/tool_type/api/urls.py new file mode 100644 index 00000000000..044c34fe6cc --- /dev/null +++ b/dojo/tool_type/api/urls.py @@ -0,0 +1,6 @@ +from dojo.tool_type.api.views import ToolTypesViewSet + + +def add_tool_type_urls(router): + router.register(r"tool_types", ToolTypesViewSet, basename="tool_type") + return router diff --git a/dojo/tool_type/api/views.py b/dojo/tool_type/api/views.py new file mode 100644 index 00000000000..b84af213ad9 --- /dev/null +++ b/dojo/tool_type/api/views.py @@ -0,0 +1,24 @@ +import logging + +from django_filters.rest_framework import DjangoFilterBackend + +from dojo.api_v2.views import DojoModelViewSet +from dojo.authorization import api_permissions as permissions +from dojo.tool_type.api.serializer import ToolTypeSerializer +from dojo.tool_type.models import Tool_Type + +logger = logging.getLogger(__name__) + + +# Authorization: configuration +class ToolTypesViewSet( + DojoModelViewSet, +): + serializer_class = ToolTypeSerializer + queryset = Tool_Type.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["id", "name", "description"] + permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) + + def get_queryset(self): + return Tool_Type.objects.all().order_by("id") diff --git a/dojo/tool_type/models.py b/dojo/tool_type/models.py new file mode 100644 index 00000000000..a5c55213d32 --- /dev/null +++ b/dojo/tool_type/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class Tool_Type(models.Model): + name = models.CharField(max_length=200) + description = models.CharField(max_length=2000, null=True, blank=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/dojo/tool_type/ui/__init__.py b/dojo/tool_type/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tool_type/ui/forms.py b/dojo/tool_type/ui/forms.py new file mode 100644 index 00000000000..8e7ff33f90f --- /dev/null +++ b/dojo/tool_type/ui/forms.py @@ -0,0 +1,27 @@ +from django import forms + +from dojo.tool_type.models import Tool_Type + + +class ToolTypeForm(forms.ModelForm): + class Meta: + model = Tool_Type + exclude = ["product"] + + def __init__(self, *args, **kwargs): + instance = kwargs.get("instance") + self.newly_created = True + if instance is not None: + self.newly_created = instance.pk is None + super().__init__(*args, **kwargs) + + def clean(self): + form_data = self.cleaned_data + if self.newly_created: + name = form_data.get("name") + # Make sure this will not create a duplicate test type + if Tool_Type.objects.filter(name=name).count() > 0: + msg = "A Tool Type with the name already exists" + raise forms.ValidationError(msg) + + return form_data diff --git a/dojo/tool_type/urls.py b/dojo/tool_type/ui/urls.py similarity index 89% rename from dojo/tool_type/urls.py rename to dojo/tool_type/ui/urls.py index 3b79b58d1b5..68d82c15be4 100644 --- a/dojo/tool_type/urls.py +++ b/dojo/tool_type/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.tool_type.ui import views urlpatterns = [ re_path(r"^tool_type/add", views.new_tool_type, name="add_tool_type"), diff --git a/dojo/tool_type/views.py b/dojo/tool_type/ui/views.py similarity index 95% rename from dojo/tool_type/views.py rename to dojo/tool_type/ui/views.py index 3f9e8218136..551ed7c0f4e 100644 --- a/dojo/tool_type/views.py +++ b/dojo/tool_type/ui/views.py @@ -7,8 +7,8 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from dojo.forms import ToolTypeForm -from dojo.models import Tool_Type +from dojo.tool_type.models import Tool_Type +from dojo.tool_type.ui.forms import ToolTypeForm from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) diff --git a/dojo/urls.py b/dojo/urls.py index 7992d87f3f3..bb2bc081e57 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -37,9 +37,6 @@ SLAConfigurationViewset, SonarqubeIssueTransitionViewSet, SonarqubeIssueViewSet, - ToolConfigurationsViewSet, - ToolProductSettingsViewSet, - ToolTypesViewSet, ) from dojo.api_v2.views import DojoSpectacularAPIView as SpectacularAPIView from dojo.asset.api.urls import add_asset_urls @@ -79,9 +76,12 @@ from dojo.test.api.urls import add_test_urls from dojo.test.ui.urls import urlpatterns as test_urls from dojo.test_type.urls import urlpatterns as test_type_urls -from dojo.tool_config.urls import urlpatterns as tool_config_urls -from dojo.tool_product.urls import urlpatterns as tool_product_urls -from dojo.tool_type.urls import urlpatterns as tool_type_urls +from dojo.tool_config.api.urls import add_tool_config_urls +from dojo.tool_config.ui.urls import urlpatterns as tool_config_urls +from dojo.tool_product.api.urls import add_tool_product_urls +from dojo.tool_product.ui.urls import urlpatterns as tool_product_urls +from dojo.tool_type.api.urls import add_tool_type_urls +from dojo.tool_type.ui.urls import urlpatterns as tool_type_urls from dojo.url.api.urls import add_url_urls from dojo.url.ui.urls import urlpatterns as url_patterns from dojo.user.api.urls import add_user_urls @@ -139,9 +139,9 @@ v2_api = add_system_settings_urls(v2_api) v2_api.register(r"technologies", AppAnalysisViewSet, basename="app_analysis") v2_api = add_test_urls(v2_api) -v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") -v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") -v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") +v2_api = add_tool_config_urls(v2_api) +v2_api = add_tool_product_urls(v2_api) +v2_api = add_tool_type_urls(v2_api) v2_api = add_user_urls(v2_api) # Add the location routes if settings.V3_FEATURE_LOCATIONS: diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 81cb43bac98..0b2e5f23f8f 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -54,9 +54,6 @@ NoteTypeViewSet, RiskAcceptanceViewSet, SonarqubeIssueViewSet, - ToolConfigurationsViewSet, - ToolProductSettingsViewSet, - ToolTypesViewSet, ) from dojo.asset.api.views import ( AssetAPIScanConfigurationViewSet, @@ -114,6 +111,9 @@ from dojo.product.api.views import ProductAPIScanConfigurationViewSet, ProductViewSet from dojo.product_type.api.views import ProductTypeViewSet from dojo.test.api.views import TestsViewSet, TestTypesViewSet +from dojo.tool_config.api.views import ToolConfigurationsViewSet +from dojo.tool_product.api.views import ToolProductSettingsViewSet +from dojo.tool_type.api.views import ToolTypesViewSet from dojo.url.api.views import URLViewSet from dojo.url.models import URL from dojo.user.api.views import UserContactInfoViewSet, UsersViewSet From f5877972c2e6c9fb3f9f9648c202e9c7287971a8 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 9 Jun 2026 00:04:51 +0200 Subject: [PATCH 32/40] refactor(endpoint): extract endpoint module into dojo/endpoint/ [endpoint Phase 1,3,4,5,6,7,8,9] --- dojo/api_v2/serializers.py | 221 +-------------- dojo/api_v2/views.py | 141 +--------- dojo/endpoint/__init__.py | 1 + dojo/endpoint/admin.py | 10 + dojo/endpoint/api/__init__.py | 1 + dojo/endpoint/api/filters.py | 40 +++ dojo/endpoint/api/serializer.py | 230 +++++++++++++++ dojo/endpoint/api/urls.py | 20 ++ dojo/endpoint/api/views.py | 161 +++++++++++ dojo/endpoint/models.py | 464 +++++++++++++++++++++++++++++++ dojo/endpoint/signals.py | 2 +- dojo/endpoint/ui/__init__.py | 0 dojo/endpoint/ui/filters.py | 260 +++++++++++++++++ dojo/endpoint/ui/forms.py | 164 +++++++++++ dojo/endpoint/{ => ui}/urls.py | 2 +- dojo/endpoint/{ => ui}/views.py | 2 +- dojo/filters.py | 278 ------------------ dojo/forms.py | 161 +---------- dojo/models.py | 454 +----------------------------- dojo/reports/views.py | 3 +- dojo/reports/widgets.py | 5 +- dojo/search/views.py | 2 +- dojo/urls.py | 11 +- unittests/test_rest_framework.py | 3 +- 24 files changed, 1382 insertions(+), 1254 deletions(-) create mode 100644 dojo/endpoint/admin.py create mode 100644 dojo/endpoint/api/__init__.py create mode 100644 dojo/endpoint/api/filters.py create mode 100644 dojo/endpoint/api/serializer.py create mode 100644 dojo/endpoint/api/urls.py create mode 100644 dojo/endpoint/api/views.py create mode 100644 dojo/endpoint/models.py create mode 100644 dojo/endpoint/ui/__init__.py create mode 100644 dojo/endpoint/ui/filters.py create mode 100644 dojo/endpoint/ui/forms.py rename dojo/endpoint/{ => ui}/urls.py (98%) rename dojo/endpoint/{ => ui}/views.py (99%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 13d1a3add3a..2880f738fb1 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -20,7 +20,6 @@ from rest_framework.exceptions import ValidationError as RestFrameworkValidationError import dojo.risk_acceptance.helper as ra_helper -from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import from dojo.finding.queries import get_authorized_findings from dojo.importers.auto_create_context import AutoCreateContextManager from dojo.importers.base_importer import BaseImporter @@ -37,8 +36,6 @@ Development_Environment, DojoMeta, Endpoint, - Endpoint_Params, - Endpoint_Status, Engagement, FileUpload, Finding, @@ -58,7 +55,6 @@ Sonarqube_Issue_Transition, Test, User, - get_current_date, ) from dojo.product_announcements import ( LargeScanSizeProductAnnouncement, @@ -418,153 +414,18 @@ class Meta: fields = "__all__" -from dojo.tool_config.api.serializer import ToolConfigurationSerializer # noqa: E402, F401 -- re-export -from dojo.tool_product.api.serializer import ToolProductSettingsSerializer # noqa: E402, F401 -- re-export - - -class EndpointStatusSerializer(serializers.ModelSerializer): - class Meta: - model = Endpoint_Status - fields = "__all__" - - def run_validators(self, initial_data): - try: - return super().run_validators(initial_data) - except RestFrameworkValidationError as exc: - if "finding, endpoint must make a unique set" in str(exc): - msg = "This endpoint-finding relation already exists" - raise serializers.ValidationError(msg) from exc - raise - - def create(self, validated_data): - endpoint = validated_data.get("endpoint") - finding = validated_data.get("finding") - try: - status = Endpoint_Status.objects.create( - finding=finding, endpoint=endpoint, - ) - except IntegrityError as ie: - if "finding, endpoint must make a unique set" in str(ie): - msg = "This endpoint-finding relation already exists" - raise serializers.ValidationError(msg) - raise - status.mitigated = validated_data.get("mitigated", False) - status.false_positive = validated_data.get("false_positive", False) - status.out_of_scope = validated_data.get("out_of_scope", False) - status.risk_accepted = validated_data.get("risk_accepted", False) - status.date = validated_data.get("date", get_current_date()) - status.save() - return status - - def update(self, instance, validated_data): - try: - return super().update(instance, validated_data) - except IntegrityError as ie: - if "finding, endpoint must make a unique set" in str(ie): - msg = "This endpoint-finding relation already exists" - raise serializers.ValidationError(msg) - raise - - -class EndpointSerializer(serializers.ModelSerializer): - tags = TagListSerializerField(required=False) - active_finding_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Endpoint - exclude = ("inherited_tags",) - - def validate(self, data): - - if self.context["request"].method != "PATCH": - if "product" not in data: - msg = "Product is required" - raise serializers.ValidationError(msg) - protocol = data.get("protocol") - userinfo = data.get("userinfo") - host = data.get("host") - port = data.get("port") - path = data.get("path") - query = data.get("query") - fragment = data.get("fragment") - product = data.get("product") - else: - protocol = data.get("protocol", self.instance.protocol) - userinfo = data.get("userinfo", self.instance.userinfo) - host = data.get("host", self.instance.host) - port = data.get("port", self.instance.port) - path = data.get("path", self.instance.path) - query = data.get("query", self.instance.query) - fragment = data.get("fragment", self.instance.fragment) - if "product" in data and data["product"] != self.instance.product: - msg = "Change of product is not possible" - raise serializers.ValidationError(msg) - product = self.instance.product - - endpoint_ins = Endpoint( - protocol=protocol, - userinfo=userinfo, - host=host, - port=port, - path=path, - query=query, - fragment=fragment, - product=product, - ) - endpoint_ins.clean() # Run standard validation and clean process; can raise errors - - endpoint = endpoint_filter( - protocol=endpoint_ins.protocol, - userinfo=endpoint_ins.userinfo, - host=endpoint_ins.host, - port=endpoint_ins.port, - path=endpoint_ins.path, - query=endpoint_ins.query, - fragment=endpoint_ins.fragment, - product=endpoint_ins.product, - ) - if ( - self.context["request"].method in {"PUT", "PATCH"} - and ( - (endpoint.count() > 1) - or ( - endpoint.count() == 1 - and endpoint.first().pk != self.instance.pk - ) - ) - ) or ( - self.context["request"].method == "POST" and endpoint.count() > 0 - ): - msg = ( - "It appears as though an endpoint with this data already " - "exists for this product." - ) - raise serializers.ValidationError(msg, code="invalid") - - # use clean data - data["protocol"] = endpoint_ins.protocol - data["userinfo"] = endpoint_ins.userinfo - data["host"] = endpoint_ins.host - data["port"] = endpoint_ins.port - data["path"] = endpoint_ins.path - data["query"] = endpoint_ins.query - data["fragment"] = endpoint_ins.fragment - data["product"] = endpoint_ins.product - - return data - - -class EndpointParamsSerializer(serializers.ModelSerializer): - class Meta: - model = Endpoint_Params - fields = "__all__" - - -from dojo.jira.api.serializers import ( # noqa: E402, F401 backward compat +from dojo.endpoint.api.serializer import ( # noqa: E402, F401 -- re-export; prefetcher discovery requires all moved ModelSerializers here + EndpointParamsSerializer, + EndpointSerializer, + EndpointStatusSerializer, +) +from dojo.jira.api.serializers import ( # noqa: E402, F401 -- backward compat re-export JIRAInstanceSerializer, JIRAIssueSerializer, JIRAProjectSerializer, ) +from dojo.tool_config.api.serializer import ToolConfigurationSerializer # noqa: E402, F401 -- re-export +from dojo.tool_product.api.serializer import ToolProductSettingsSerializer # noqa: E402, F401 -- re-export class SonarqubeIssueSerializer(serializers.ModelSerializer): @@ -1186,71 +1047,7 @@ def save(self, *, push_to_jira=False): self.process_scan(auto_create_manager, data, context) -class EndpointMetaImporterSerializer(serializers.Serializer): - file = serializers.FileField(required=True) - create_endpoints = serializers.BooleanField(default=True, required=False) - create_tags = serializers.BooleanField(default=True, required=False) - create_dojo_meta = serializers.BooleanField(default=False, required=False) - product_name = serializers.CharField(required=False) - product = serializers.PrimaryKeyRelatedField( - queryset=Product.objects.all(), required=False, - ) - # extra fields populated in response - # need to use the _id suffix as without the serializer framework gets - # confused - product_id = serializers.IntegerField(read_only=True) - - def validate(self, data): - file = data.get("file") - if file and is_scan_file_too_large(file): - msg = f"Report file is too large. Maximum supported size is {settings.SCAN_FILE_MAX_SIZE} MB" - raise serializers.ValidationError(msg) - - return data - - def save(self): - data = self.validated_data - file = data.get("file") - create_endpoints = data.get("create_endpoints", True) - create_tags = data.get("create_tags", True) - create_dojo_meta = data.get("create_dojo_meta", False) - auto_create = AutoCreateContextManager() - # Process the context to make an conversions needed. Catch any exceptions - # in this case and wrap them in a DRF exception - try: - auto_create.process_import_meta_data_from_dict(data) - # Get an existing product - product = auto_create.get_target_product_if_exists(**data) - if not product: - product = auto_create.get_target_product_by_id_if_exists(**data) - except (ValueError, TypeError) as e: - # Raise an explicit drf exception here - raise ValidationError(str(e)) - try: - if settings.V3_FEATURE_LOCATIONS: - endpoint_meta_import( - file, - product, - create_endpoints, - create_tags, - create_dojo_meta, - origin="API", - object_class=Location, - ) - else: - # TODO: Delete this after the move to Locations - endpoint_meta_import( - file, - product, - create_endpoints, - create_tags, - create_dojo_meta, - origin="API", - ) - except SyntaxError as se: - raise Exception(se) - except ValueError as ve: - raise Exception(ve) +from dojo.endpoint.api.serializer import EndpointMetaImporterSerializer # noqa: E402, F401 -- re-export class LanguageTypeSerializer(serializers.ModelSerializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 7e0f8aebb19..97816b34f6c 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -9,8 +9,6 @@ from django.contrib.auth.models import Permission from django.core.exceptions import ValidationError from django.db import IntegrityError -from django.db.models import OuterRef, Value -from django.db.models.functions import Coalesce from django.db.models.query import QuerySet as DjangoQuerySet from django.http import FileResponse from django.urls import reverse @@ -39,15 +37,10 @@ ) from dojo.authorization import api_permissions as permissions from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.endpoint.queries import ( - get_authorized_endpoint_status, - get_authorized_endpoints, -) -from dojo.endpoint.views import get_endpoint_ids +from dojo.endpoint.ui.views import get_endpoint_ids from dojo.filters import ( ApiAppAnalysisFilter, ApiDojoMetaFilter, - ApiEndpointFilter, ApiRiskAcceptanceFilter, ) from dojo.finding.ui.filters import ( @@ -64,7 +57,6 @@ Dojo_User, DojoMeta, Endpoint, - Endpoint_Status, Finding, Language_Type, Languages, @@ -87,7 +79,6 @@ get_authorized_languages, get_authorized_products, ) -from dojo.query_utils import build_count_subquery from dojo.reports.views import ( prefetch_related_findings_for_report, report_url_resolver, @@ -178,104 +169,6 @@ def finalize_response(self, request, response, *args, **kwargs): return super().finalize_response(request, response, *args, **kwargs) -# Authorization: authenticated users -# Authorization: object-based -# @extend_schema_view(**schema_with_prefetch()) -# Nested models with prefetch make the response schema too long for Swagger UI -class EndPointViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.EndpointSerializer - queryset = Endpoint.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiEndpointFilter - - permission_classes = ( - IsAuthenticated, - permissions.UserHasEndpointPermission, - ) - - def get_queryset(self): - active_finding_subquery = build_count_subquery( - Finding.objects.filter(endpoints=OuterRef("pk"), active=True), - group_field="endpoints", - ) - return get_authorized_endpoints("view").annotate( - active_finding_count=Coalesce(active_finding_subquery, Value(0)), - ).distinct() - - @extend_schema( - request=serializers.ReportGenerateOptionSerializer, - responses={status.HTTP_200_OK: serializers.ReportGenerateSerializer}, - ) - @action( - detail=True, methods=["post"], - # IsAuthenticated only: report generation requires View permission, - # enforced by the permission-filtered get_queryset(). The viewset's - # permission_classes would check Edit (POST), which is too restrictive. - permission_classes=[IsAuthenticated], - ) - def generate_report(self, request, pk=None): - endpoint = self.get_object() - - options = {} - # prepare post data - report_options = serializers.ReportGenerateOptionSerializer( - data=request.data, - ) - if report_options.is_valid(): - options["include_finding_notes"] = report_options.validated_data[ - "include_finding_notes" - ] - options["include_finding_images"] = report_options.validated_data[ - "include_finding_images" - ] - options[ - "include_executive_summary" - ] = report_options.validated_data["include_executive_summary"] - options[ - "include_table_of_contents" - ] = report_options.validated_data["include_table_of_contents"] - else: - return Response( - report_options.errors, status=status.HTTP_400_BAD_REQUEST, - ) - - data = report_generate(request, endpoint, options) - report = serializers.ReportGenerateSerializer(data) - return Response(report.data) - - -# Authorization: object-based -# @extend_schema_view(**schema_with_prefetch()) -# Nested models with prefetch make the response schema too long for Swagger UI -class EndpointStatusViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.EndpointStatusSerializer - queryset = Endpoint_Status.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "mitigated", - "false_positive", - "out_of_scope", - "risk_accepted", - "mitigated_by", - "finding", - "endpoint", - ] - - permission_classes = ( - IsAuthenticated, - permissions.UserHasEndpointStatusPermission, - ) - - def get_queryset(self): - return get_authorized_endpoint_status( - "view", - ).distinct() - - # @extend_schema_view(**schema_with_prefetch()) # Nested models with prefetch make the response schema too long for Swagger UI class RiskAcceptanceViewSet( @@ -656,38 +549,6 @@ def get_queryset(self): return get_authorized_tests("import") -# Authorization: authenticated users, DjangoModelPermissions -class EndpointMetaImporterView( - mixins.CreateModelMixin, viewsets.GenericViewSet, -): - - """ - Imports a CSV file into a product to propagate arbitrary meta and tags on endpoints. - - By Names: - - Provide `product_name` of existing product - - By ID: - - Provide the id of the product in the `product` parameter - - In this scenario Defect Dojo will look up the product by the provided details. - """ - - serializer_class = serializers.EndpointMetaImporterSerializer - parser_classes = [MultiPartParser] - queryset = Product.objects.none() - permission_classes = ( - IsAuthenticated, - permissions.UserHasMetaImportPermission, - ) - - def perform_create(self, serializer): - serializer.save() - - def get_queryset(self): - return get_authorized_products("edit") - - # Authorization: configuration class LanguageTypeViewSet( DojoModelViewSet, diff --git a/dojo/endpoint/__init__.py b/dojo/endpoint/__init__.py index e69de29bb2d..d774cc434b1 100644 --- a/dojo/endpoint/__init__.py +++ b/dojo/endpoint/__init__.py @@ -0,0 +1 @@ +import dojo.endpoint.admin # noqa: F401 diff --git a/dojo/endpoint/admin.py b/dojo/endpoint/admin.py new file mode 100644 index 00000000000..1a56f8d89d8 --- /dev/null +++ b/dojo/endpoint/admin.py @@ -0,0 +1,10 @@ +import tagulous.admin +from django.contrib import admin + +from dojo.endpoint.models import Endpoint, Endpoint_Params, Endpoint_Status + +admin.site.register(Endpoint_Params) +admin.site.register(Endpoint_Status) +admin.site.register(Endpoint) +tagulous.admin.register(Endpoint.tags) +tagulous.admin.register(Endpoint.inherited_tags) diff --git a/dojo/endpoint/api/__init__.py b/dojo/endpoint/api/__init__.py new file mode 100644 index 00000000000..0aa96944499 --- /dev/null +++ b/dojo/endpoint/api/__init__.py @@ -0,0 +1 @@ +path = "endpoints" # noqa: RUF067 diff --git a/dojo/endpoint/api/filters.py b/dojo/endpoint/api/filters.py new file mode 100644 index 00000000000..6dda92ab41a --- /dev/null +++ b/dojo/endpoint/api/filters.py @@ -0,0 +1,40 @@ +from django_filters import ( + BooleanFilter, + CharFilter, + OrderingFilter, +) + +from dojo.endpoint.models import Endpoint +from dojo.filters import CharFieldFilterANDExpression, CharFieldInFilter, DojoFilter + + +class ApiEndpointFilter(DojoFilter): + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") + tags = CharFieldInFilter( + field_name="tags__name", + lookup_expr="in", + help_text="Comma separated list of exact tags (uses OR for multiple values)") + tags__and = CharFieldFilterANDExpression( + field_name="tags__name", + help_text="Comma separated list of exact tags to match with an AND expression") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") + not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", + help_text="Comma separated list of exact tags not present on model", exclude="True") + has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("host", "host"), + ("product", "product"), + ("id", "id"), + ("active_finding_count", "active_finding_count"), + ), + field_labels={ + "active_finding_count": "Active Findings Count", + }, + ) + + class Meta: + model = Endpoint + fields = ["id", "protocol", "userinfo", "host", "port", "path", "query", "fragment", "product"] diff --git a/dojo/endpoint/api/serializer.py b/dojo/endpoint/api/serializer.py new file mode 100644 index 00000000000..05e140d3f65 --- /dev/null +++ b/dojo/endpoint/api/serializer.py @@ -0,0 +1,230 @@ +import logging + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from rest_framework import serializers +from rest_framework.exceptions import ValidationError as RestFrameworkValidationError + +from dojo.endpoint.models import Endpoint, Endpoint_Params, Endpoint_Status +from dojo.endpoint.utils import endpoint_filter, endpoint_meta_import +from dojo.importers.auto_create_context import AutoCreateContextManager +from dojo.location.models import Location +from dojo.models import Product, get_current_date +from dojo.utils import is_scan_file_too_large + +logger = logging.getLogger(__name__) + + +class EndpointStatusSerializer(serializers.ModelSerializer): + class Meta: + model = Endpoint_Status + fields = "__all__" + + def run_validators(self, initial_data): + try: + return super().run_validators(initial_data) + except RestFrameworkValidationError as exc: + if "finding, endpoint must make a unique set" in str(exc): + msg = "This endpoint-finding relation already exists" + raise serializers.ValidationError(msg) from exc + raise + + def create(self, validated_data): + endpoint = validated_data.get("endpoint") + finding = validated_data.get("finding") + try: + status = Endpoint_Status.objects.create( + finding=finding, endpoint=endpoint, + ) + except IntegrityError as ie: + if "finding, endpoint must make a unique set" in str(ie): + msg = "This endpoint-finding relation already exists" + raise serializers.ValidationError(msg) + raise + status.mitigated = validated_data.get("mitigated", False) + status.false_positive = validated_data.get("false_positive", False) + status.out_of_scope = validated_data.get("out_of_scope", False) + status.risk_accepted = validated_data.get("risk_accepted", False) + status.date = validated_data.get("date", get_current_date()) + status.save() + return status + + def update(self, instance, validated_data): + try: + return super().update(instance, validated_data) + except IntegrityError as ie: + if "finding, endpoint must make a unique set" in str(ie): + msg = "This endpoint-finding relation already exists" + raise serializers.ValidationError(msg) + raise + + +class EndpointSerializer(serializers.ModelSerializer): + # tags field uses lazy get_fields() to break the import cycle: + # EndpointSerializer -> TagListSerializerField -> api_v2.serializers -> EndpointSerializer + active_finding_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Endpoint + exclude = ("inherited_tags",) + + def get_fields(self): + fields = super().get_fields() + from dojo.api_v2.serializers import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + TagListSerializerField, + ) + fields["tags"] = TagListSerializerField(required=False) + return fields + + def validate(self, data): + + if self.context["request"].method != "PATCH": + if "product" not in data: + msg = "Product is required" + raise serializers.ValidationError(msg) + protocol = data.get("protocol") + userinfo = data.get("userinfo") + host = data.get("host") + port = data.get("port") + path = data.get("path") + query = data.get("query") + fragment = data.get("fragment") + product = data.get("product") + else: + protocol = data.get("protocol", self.instance.protocol) + userinfo = data.get("userinfo", self.instance.userinfo) + host = data.get("host", self.instance.host) + port = data.get("port", self.instance.port) + path = data.get("path", self.instance.path) + query = data.get("query", self.instance.query) + fragment = data.get("fragment", self.instance.fragment) + if "product" in data and data["product"] != self.instance.product: + msg = "Change of product is not possible" + raise serializers.ValidationError(msg) + product = self.instance.product + + endpoint_ins = Endpoint( + protocol=protocol, + userinfo=userinfo, + host=host, + port=port, + path=path, + query=query, + fragment=fragment, + product=product, + ) + endpoint_ins.clean() # Run standard validation and clean process; can raise errors + + endpoint = endpoint_filter( + protocol=endpoint_ins.protocol, + userinfo=endpoint_ins.userinfo, + host=endpoint_ins.host, + port=endpoint_ins.port, + path=endpoint_ins.path, + query=endpoint_ins.query, + fragment=endpoint_ins.fragment, + product=endpoint_ins.product, + ) + if ( + self.context["request"].method in {"PUT", "PATCH"} + and ( + (endpoint.count() > 1) + or ( + endpoint.count() == 1 + and endpoint.first().pk != self.instance.pk + ) + ) + ) or ( + self.context["request"].method == "POST" and endpoint.count() > 0 + ): + msg = ( + "It appears as though an endpoint with this data already " + "exists for this product." + ) + raise serializers.ValidationError(msg, code="invalid") + + # use clean data + data["protocol"] = endpoint_ins.protocol + data["userinfo"] = endpoint_ins.userinfo + data["host"] = endpoint_ins.host + data["port"] = endpoint_ins.port + data["path"] = endpoint_ins.path + data["query"] = endpoint_ins.query + data["fragment"] = endpoint_ins.fragment + data["product"] = endpoint_ins.product + + return data + + +class EndpointParamsSerializer(serializers.ModelSerializer): + class Meta: + model = Endpoint_Params + fields = "__all__" + + +class EndpointMetaImporterSerializer(serializers.Serializer): + file = serializers.FileField(required=True) + create_endpoints = serializers.BooleanField(default=True, required=False) + create_tags = serializers.BooleanField(default=True, required=False) + create_dojo_meta = serializers.BooleanField(default=False, required=False) + product_name = serializers.CharField(required=False) + product = serializers.PrimaryKeyRelatedField( + queryset=Product.objects.all(), required=False, + ) + # extra fields populated in response + # need to use the _id suffix as without the serializer framework gets + # confused + product_id = serializers.IntegerField(read_only=True) + + def validate(self, data): + file = data.get("file") + if file and is_scan_file_too_large(file): + msg = f"Report file is too large. Maximum supported size is {settings.SCAN_FILE_MAX_SIZE} MB" + raise serializers.ValidationError(msg) + + return data + + def save(self): + data = self.validated_data + file = data.get("file") + create_endpoints = data.get("create_endpoints", True) + create_tags = data.get("create_tags", True) + create_dojo_meta = data.get("create_dojo_meta", False) + auto_create = AutoCreateContextManager() + # Process the context to make an conversions needed. Catch any exceptions + # in this case and wrap them in a DRF exception + try: + auto_create.process_import_meta_data_from_dict(data) + # Get an existing product + product = auto_create.get_target_product_if_exists(**data) + if not product: + product = auto_create.get_target_product_by_id_if_exists(**data) + except (ValueError, TypeError) as e: + # Raise an explicit drf exception here + raise ValidationError(str(e)) + try: + if settings.V3_FEATURE_LOCATIONS: + endpoint_meta_import( + file, + product, + create_endpoints, + create_tags, + create_dojo_meta, + origin="API", + object_class=Location, + ) + else: + # TODO: Delete this after the move to Locations + endpoint_meta_import( + file, + product, + create_endpoints, + create_tags, + create_dojo_meta, + origin="API", + ) + except SyntaxError as se: + raise Exception(se) + except ValueError as ve: + raise Exception(ve) diff --git a/dojo/endpoint/api/urls.py b/dojo/endpoint/api/urls.py new file mode 100644 index 00000000000..4a138f3cbde --- /dev/null +++ b/dojo/endpoint/api/urls.py @@ -0,0 +1,20 @@ +from dojo.endpoint.api.views import EndpointMetaImporterView, EndpointStatusViewSet, EndPointViewSet + + +def add_endpoint_urls(router): + """ + Register endpoint/endpoint_status routes (non-V3 block only). + + endpoint_meta_import is always registered via register_endpoint_meta_import. + endpoints and endpoint_status are registered only when V3_FEATURE_LOCATIONS is OFF; + the V3 compat viewsets are registered by dojo/location/api/urls.py instead. + """ + router.register(r"endpoints", EndPointViewSet, basename="endpoint") + router.register(r"endpoint_status", EndpointStatusViewSet, basename="endpoint_status") + return router + + +def register_endpoint_meta_import(router): + """Register the unconditional endpoint_meta_import route.""" + router.register(r"endpoint_meta_import", EndpointMetaImporterView, basename="endpointmetaimport") + return router diff --git a/dojo/endpoint/api/views.py b/dojo/endpoint/api/views.py new file mode 100644 index 00000000000..02a6f65f31f --- /dev/null +++ b/dojo/endpoint/api/views.py @@ -0,0 +1,161 @@ +import logging + +from django.db.models import OuterRef, Value +from django.db.models.functions import Coalesce +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2 import serializers as api_v2_serializers +from dojo.api_v2.views import PrefetchDojoModelViewSet, report_generate +from dojo.authorization import api_permissions as permissions +from dojo.endpoint.api.filters import ApiEndpointFilter +from dojo.endpoint.api.serializer import ( + EndpointMetaImporterSerializer, + EndpointSerializer, + EndpointStatusSerializer, +) +from dojo.endpoint.models import Endpoint, Endpoint_Status +from dojo.endpoint.queries import ( + get_authorized_endpoint_status, + get_authorized_endpoints, +) +from dojo.models import Finding +from dojo.product.queries import get_authorized_products +from dojo.query_utils import build_count_subquery + +logger = logging.getLogger(__name__) + + +# Authorization: authenticated users +# Authorization: object-based +# @extend_schema_view(**schema_with_prefetch()) +# Nested models with prefetch make the response schema too long for Swagger UI +class EndPointViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = EndpointSerializer + queryset = Endpoint.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiEndpointFilter + + permission_classes = ( + IsAuthenticated, + permissions.UserHasEndpointPermission, + ) + + def get_queryset(self): + active_finding_subquery = build_count_subquery( + Finding.objects.filter(endpoints=OuterRef("pk"), active=True), + group_field="endpoints", + ) + return get_authorized_endpoints("view").annotate( + active_finding_count=Coalesce(active_finding_subquery, Value(0)), + ).distinct() + + @extend_schema( + request=api_v2_serializers.ReportGenerateOptionSerializer, + responses={status.HTTP_200_OK: api_v2_serializers.ReportGenerateSerializer}, + ) + @action( + detail=True, methods=["post"], + # IsAuthenticated only: report generation requires View permission, + # enforced by the permission-filtered get_queryset(). The viewset's + # permission_classes would check Edit (POST), which is too restrictive. + permission_classes=[IsAuthenticated], + ) + def generate_report(self, request, pk=None): + endpoint = self.get_object() + + options = {} + # prepare post data + report_options = api_v2_serializers.ReportGenerateOptionSerializer( + data=request.data, + ) + if report_options.is_valid(): + options["include_finding_notes"] = report_options.validated_data[ + "include_finding_notes" + ] + options["include_finding_images"] = report_options.validated_data[ + "include_finding_images" + ] + options[ + "include_executive_summary" + ] = report_options.validated_data["include_executive_summary"] + options[ + "include_table_of_contents" + ] = report_options.validated_data["include_table_of_contents"] + else: + return Response( + report_options.errors, status=status.HTTP_400_BAD_REQUEST, + ) + + data = report_generate(request, endpoint, options) + report = api_v2_serializers.ReportGenerateSerializer(data) + return Response(report.data) + + +# Authorization: object-based +# @extend_schema_view(**schema_with_prefetch()) +# Nested models with prefetch make the response schema too long for Swagger UI +class EndpointStatusViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = EndpointStatusSerializer + queryset = Endpoint_Status.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "mitigated", + "false_positive", + "out_of_scope", + "risk_accepted", + "mitigated_by", + "finding", + "endpoint", + ] + + permission_classes = ( + IsAuthenticated, + permissions.UserHasEndpointStatusPermission, + ) + + def get_queryset(self): + return get_authorized_endpoint_status( + "view", + ).distinct() + + +# Authorization: authenticated users, DjangoModelPermissions +class EndpointMetaImporterView( + mixins.CreateModelMixin, viewsets.GenericViewSet, +): + + """ + Imports a CSV file into a product to propagate arbitrary meta and tags on endpoints. + + By Names: + - Provide `product_name` of existing product + + By ID: + - Provide the id of the product in the `product` parameter + + In this scenario Defect Dojo will look up the product by the provided details. + """ + + serializer_class = EndpointMetaImporterSerializer + parser_classes = [MultiPartParser] + queryset = Finding.objects.none() + permission_classes = ( + IsAuthenticated, + permissions.UserHasMetaImportPermission, + ) + + def perform_create(self, serializer): + serializer.save() + + def get_queryset(self): + return get_authorized_products("edit") diff --git a/dojo/endpoint/models.py b/dojo/endpoint/models.py new file mode 100644 index 00000000000..f55e3f5f8c0 --- /dev/null +++ b/dojo/endpoint/models.py @@ -0,0 +1,464 @@ +import contextlib +import logging +import re +from urllib.parse import urlparse + +import hyperlink +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import validate_ipv46_address +from django.db import connection, models +from django.db.models import F, Q +from django.db.models.functions import Lower +from django.urls import reverse +from django.utils.translation import gettext as _ +from tagulous.models import TagField + +# get_current_date/get_current_datetime/copy_model_util are defined early in dojo.models, +# before the re-export that loads this module — resolves despite partial circular load. +# Must keep their dojo.models.* path for Django migration serialization. +from dojo.models import copy_model_util, get_current_date, get_current_datetime + +logger = logging.getLogger(__name__) + + +class Endpoint_Params(models.Model): + param = models.CharField(max_length=150) + value = models.CharField(max_length=150) + method_type = (("GET", "GET"), + ("POST", "POST")) + method = models.CharField(max_length=20, blank=False, null=True, choices=method_type) + + +class Endpoint_Status(models.Model): + date = models.DateField(default=get_current_date) + last_modified = models.DateTimeField(null=True, editable=False, default=get_current_datetime) + mitigated = models.BooleanField(default=False, blank=True) + mitigated_time = models.DateTimeField(editable=False, null=True, blank=True) + mitigated_by = models.ForeignKey("dojo.Dojo_User", editable=True, null=True, on_delete=models.RESTRICT) + false_positive = models.BooleanField(default=False, blank=True) + out_of_scope = models.BooleanField(default=False, blank=True) + risk_accepted = models.BooleanField(default=False, blank=True) + endpoint = models.ForeignKey("dojo.Endpoint", null=False, blank=False, on_delete=models.CASCADE, related_name="status_endpoint") + finding = models.ForeignKey("dojo.Finding", null=False, blank=False, on_delete=models.CASCADE, related_name="status_finding") + + class Meta: + indexes = [ + models.Index(fields=["finding", "mitigated"]), + models.Index(fields=["endpoint", "mitigated"]), + # Optimize frequent lookups of "active" statuses (mitigated/flags all False) + models.Index( + name="idx_eps_active_by_endpoint", + fields=["endpoint"], + condition=Q(mitigated=False, false_positive=False, out_of_scope=False, risk_accepted=False), + ), + models.Index( + name="idx_eps_active_by_finding", + fields=["finding"], + condition=Q(mitigated=False, false_positive=False, out_of_scope=False, risk_accepted=False), + ), + ] + constraints = [ + models.UniqueConstraint(fields=["finding", "endpoint"], name="endpoint-finding relation"), + ] + + def __str__(self): + with Endpoint.allow_endpoint_init(): # TODO: Delete this after the move to Locations + return f"'{self.finding}' on '{self.endpoint}'" + + def copy(self, finding=None): + copy = copy_model_util(self) + current_endpoint = self.endpoint + if finding: + copy.finding = finding + copy.endpoint = current_endpoint + copy.save() + + return copy + + @property + def age(self): + + diff = self.mitigated_time.date() - self.date if self.mitigated else get_current_date() - self.date + days = diff.days + return max(0, days) + + +class Endpoint(models.Model): + protocol = models.CharField(null=True, blank=True, max_length=20, + help_text=_("The communication protocol/scheme such as 'http', 'ftp', 'dns', etc.")) + userinfo = models.CharField(null=True, blank=True, max_length=500, + help_text=_("User info as 'alice', 'bob', etc.")) + host = models.CharField(null=True, blank=True, max_length=500, + help_text=_("The host name or IP address. It must not include the port number. " + "For example '127.0.0.1', 'localhost', 'yourdomain.com'.")) + port = models.IntegerField(null=True, blank=True, + help_text=_("The network port associated with the endpoint.")) + path = models.CharField(null=True, blank=True, max_length=500, + help_text=_("The location of the resource, it must not start with a '/'. For example " + "endpoint/420/edit")) + query = models.CharField(null=True, blank=True, max_length=1000, + help_text=_("The query string, the question mark should be omitted." + "For example 'group=4&team=8'")) + fragment = models.CharField(null=True, blank=True, max_length=500, + help_text=_("The fragment identifier which follows the hash mark. The hash mark should " + "be omitted. For example 'section-13', 'paragraph-2'.")) + product = models.ForeignKey("dojo.Product", null=True, blank=True, on_delete=models.CASCADE) + endpoint_params = models.ManyToManyField("dojo.Endpoint_Params", blank=True, editable=False) + findings = models.ManyToManyField("dojo.Finding", + blank=True, + verbose_name=_("Findings"), + through="dojo.Endpoint_Status") + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this endpoint. Choose from the list or add new tags. Press Enter key to add.")) + inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) + + class Meta: + ordering = ["product", "host", "protocol", "port", "userinfo", "path", "query", "fragment"] + indexes = [ + models.Index(fields=["product"]), + # Fast case-insensitive equality on host within product scope + models.Index( + F("product"), + Lower("host"), + name="idx_ep_product_lower_host", + ), + ] + + def __init__(self, *args, **kwargs): + if settings.V3_FEATURE_LOCATIONS and not getattr(self, "_allow_v3_init", False): + msg = "Endpoint model is deprecated when V3_FEATURE_LOCATIONS is enabled" + raise NotImplementedError(msg) + super().__init__(*args, **kwargs) + + def __hash__(self): + return self.__str__().__hash__() + + def __eq__(self, other): + if isinstance(other, Endpoint): + contents_match = str(self) == str(other) + # Use product_id (cached integer) instead of self.product to avoid + # triggering a FK lookup on every comparison inside NestedObjects.add_edge. + if self.product_id is not None and other.product_id is not None: + return self.product_id == other.product_id and contents_match + return contents_match + + return NotImplemented + + def __str__(self): + try: + if self.host: + dummy_scheme = "dummy-scheme" # workaround for https://github.com/python-hyper/hyperlink/blob/b8c9152cd826bbe8e6cc125648f3738235019705/src/hyperlink/_url.py#L988 + url = hyperlink.EncodedURL( + scheme=self.protocol or dummy_scheme, + userinfo=self.userinfo or "", + host=self.host, + port=self.port, + path=tuple(self.path.split("/")) if self.path else (), + query=tuple( + ( + qe.split("=", 1) + if "=" in qe + else (qe, None) + ) + for qe in self.query.split("&") + ) if self.query else (), # inspired by https://github.com/python-hyper/hyperlink/blob/b8c9152cd826bbe8e6cc125648f3738235019705/src/hyperlink/_url.py#L1427 + fragment=self.fragment or "", + ) + # Return a normalized version of the URL to avoid differences where there shouldn't be any difference. + # Example: https://google.com and https://google.com:443 + normalize_path = self.path # it used to add '/' at the end of host + clean_url = url.normalize(scheme=True, host=True, path=normalize_path, query=True, fragment=True, userinfo=True, percents=True).to_uri().to_text() + if not self.protocol: + if clean_url[:len(dummy_scheme) + 3] == (dummy_scheme + "://"): + clean_url = clean_url[len(dummy_scheme) + 3:] + else: + msg = "hyperlink lib did not create URL as was expected" + raise ValueError(msg) + return clean_url + msg = "Missing host" + raise ValueError(msg) + except: + url = "" + if self.protocol: + url += f"{self.protocol}://" + if self.userinfo: + url += f"{self.userinfo}@" + if self.host: + url += self.host + if self.port: + url += f":{self.port}" + if self.path: + url += "{}{}".format("/" if self.path[0] != "/" else "", self.path) + if self.query: + url += f"?{self.query}" + if self.fragment: + url += f"#{self.fragment}" + return url + + def get_absolute_url(self): + return reverse("view_endpoint", args=[str(self.id)]) + + @classmethod + @contextlib.contextmanager + def allow_endpoint_init(cls): + # When migrating to Locations, Endpoints are not deleted (hooray backup!). Disallowing the initialization of + # Endpoints is a good way to catch where they might still be used (oops!). However, there are some circumstances + # -- object deletes -- where Django itself attempts to instantiate an Endpoint object. This, we need to allow: + # if a user wants to delete an object, including whatever Endpoints are attached to it, they should be able to. + # This context manager allows code to initialize Endpoints at our discretion. + old = getattr(cls, "_allow_v3_init", None) + cls._allow_v3_init = True + try: + yield + finally: + cls._allow_v3_init = old + + def clean(self): + errors = [] + null_char_list = ["0x00", "\x00"] + db_type = connection.vendor + if self.protocol is not None: + if not re.match(r"^[A-Za-z][A-Za-z0-9\.\-\+]+$", self.protocol): # https://tools.ietf.org/html/rfc3986#section-3.1 + errors.append(ValidationError(f'Protocol "{self.protocol}" has invalid format')) + if not self.protocol: + self.protocol = None + + if self.userinfo is not None: + if not re.match(r"^[A-Za-z0-9\.\-_~%\!\$&\'\(\)\*\+,;=:]+$", self.userinfo): # https://tools.ietf.org/html/rfc3986#section-3.2.1 + errors.append(ValidationError(f'Userinfo "{self.userinfo}" has invalid format')) + if not self.userinfo: + self.userinfo = None + + if self.host: + if not re.match(r"^[A-Za-z0-9_\-\+][A-Za-z0-9_\.\-\+]+$", self.host): + try: + validate_ipv46_address(self.host) + except ValidationError: + errors.append(ValidationError(f'Host "{self.host}" has invalid format')) + else: + errors.append(ValidationError("Host must not be empty")) + + if self.port is not None: + try: + int_port = int(self.port) + if not (0 <= int_port < 65536): + errors.append(ValidationError(f'Port "{self.port}" has invalid format - out of range')) + self.port = int_port + except ValueError: + errors.append(ValidationError(f'Port "{self.port}" has invalid format - it is not a number')) + + if self.path is not None: + while len(self.path) > 0 and self.path[0] == "/": # Endpoint store "root-less" path + self.path = self.path[1:] + if any(null_char in self.path for null_char in null_char_list): + old_value = self.path + if "postgres" in db_type: + action_string = "Postgres does not accept NULL character. Attempting to replace with %00..." + for remove_str in null_char_list: + self.path = self.path.replace(remove_str, "%00") + logger.error('Path "%s" has invalid format - It contains the NULL character. The following action was taken: %s', old_value, action_string) + if not self.path: + self.path = None + + if self.query is not None: + if len(self.query) > 0 and self.query[0] == "?": + self.query = self.query[1:] + if any(null_char in self.query for null_char in null_char_list): + old_value = self.query + if "postgres" in db_type: + action_string = "Postgres does not accept NULL character. Attempting to replace with %00..." + for remove_str in null_char_list: + self.query = self.query.replace(remove_str, "%00") + logger.error('Query "%s" has invalid format - It contains the NULL character. The following action was taken: %s', old_value, action_string) + if not self.query: + self.query = None + + if self.fragment is not None: + if len(self.fragment) > 0 and self.fragment[0] == "#": + self.fragment = self.fragment[1:] + if any(null_char in self.fragment for null_char in null_char_list): + old_value = self.fragment + if "postgres" in db_type: + action_string = "Postgres does not accept NULL character. Attempting to replace with %00..." + for remove_str in null_char_list: + self.fragment = self.fragment.replace(remove_str, "%00") + logger.error('Fragment "%s" has invalid format - It contains the NULL character. The following action was taken: %s', old_value, action_string) + if not self.fragment: + self.fragment = None + + if errors: + raise ValidationError(errors) + + @property + def is_broken(self): + try: + self.clean() + except: + return True + else: + return not self.product + + @property + def mitigated(self): + return not self.vulnerable + + @property + def vulnerable(self): + return Endpoint_Status.objects.filter( + endpoint=self, + mitigated=False, + false_positive=False, + out_of_scope=False, + risk_accepted=False, + ).count() > 0 + + @property + def findings_count(self): + return self.findings.all().count() + + def active_findings(self): + return self.findings.filter( + active=True, + out_of_scope=False, + mitigated__isnull=True, + false_p=False, + duplicate=False, + status_finding__false_positive=False, + status_finding__out_of_scope=False, + status_finding__risk_accepted=False, + ).order_by("numerical_severity") + + def active_verified_findings(self): + return self.findings.filter( + active=True, + verified=True, + out_of_scope=False, + mitigated__isnull=True, + false_p=False, + duplicate=False, + status_finding__false_positive=False, + status_finding__out_of_scope=False, + status_finding__risk_accepted=False, + ).order_by("numerical_severity") + + @property + def active_findings_count(self): + return self.active_findings().count() + + @property + def active_verified_findings_count(self): + return self.active_verified_findings().count() + + def host_endpoints(self): + return Endpoint.objects.filter(host=self.host, + product=self.product).distinct() + + @property + def host_endpoints_count(self): + return self.host_endpoints().count() + + def host_mitigated_endpoints(self): + meps = Endpoint_Status.objects \ + .filter(endpoint__in=self.host_endpoints()) \ + .filter(Q(mitigated=True) + | Q(false_positive=True) + | Q(out_of_scope=True) + | Q(risk_accepted=True) + | Q(finding__out_of_scope=True) + | Q(finding__mitigated__isnull=False) + | Q(finding__false_p=True) + | Q(finding__duplicate=True) + | Q(finding__active=False)) + return Endpoint.objects.filter(status_endpoint__in=meps).distinct() + + @property + def host_mitigated_endpoints_count(self): + return self.host_mitigated_endpoints().count() + + def host_findings(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + return Finding.objects.filter(endpoints__in=self.host_endpoints()).distinct() + + @property + def host_findings_count(self): + return self.host_findings().count() + + def host_active_findings(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + return Finding.objects.filter( + active=True, + out_of_scope=False, + mitigated__isnull=True, + false_p=False, + duplicate=False, + status_finding__false_positive=False, + status_finding__out_of_scope=False, + status_finding__risk_accepted=False, + endpoints__in=self.host_endpoints(), + ).order_by("numerical_severity") + + def host_active_verified_findings(self): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + return Finding.objects.filter( + active=True, + verified=True, + out_of_scope=False, + mitigated__isnull=True, + false_p=False, + duplicate=False, + status_finding__false_positive=False, + status_finding__out_of_scope=False, + status_finding__risk_accepted=False, + endpoints__in=self.host_endpoints(), + ).order_by("numerical_severity") + + @property + def host_active_findings_count(self): + return self.host_active_findings().count() + + @property + def host_active_verified_findings_count(self): + return self.host_active_verified_findings().count() + + def get_breadcrumbs(self): + bc = self.product.get_breadcrumbs() + bc += [{"title": self.host, + "url": reverse("view_endpoint", args=(self.id,))}] + return bc + + @staticmethod + def from_uri(uri): + try: + url = hyperlink.parse(url=uri) + except UnicodeDecodeError: + url = hyperlink.parse(url="//" + urlparse(uri).netloc) + except hyperlink.URLParseError as e: + msg = f"Invalid URL format: {e}" + raise ValidationError(msg) + + query_parts = [] # inspired by https://github.com/python-hyper/hyperlink/blob/b8c9152cd826bbe8e6cc125648f3738235019705/src/hyperlink/_url.py#L1768 + for k, v in url.query: + if v is None: + query_parts.append(k) + else: + query_parts.append(f"{k}={v}") + query_string = "&".join(query_parts) + + protocol = url.scheme or None + userinfo = ":".join(url.userinfo) if url.userinfo not in {(), ("",)} else None + host = url.host or None + port = url.port + path = "/".join(url.path)[:500] if url.path not in {None, (), ("",)} else None + query = query_string[:1000] if query_string is not None and query_string else None + fragment = url.fragment[:500] if url.fragment is not None and url.fragment else None + + return Endpoint( + protocol=protocol, + userinfo=userinfo, + host=host, + port=port, + path=path, + query=query, + fragment=fragment, + ) diff --git a/dojo/endpoint/signals.py b/dojo/endpoint/signals.py index aebc348c003..58f5e686d15 100644 --- a/dojo/endpoint/signals.py +++ b/dojo/endpoint/signals.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from dojo.models import Endpoint +from dojo.endpoint.models import Endpoint from dojo.notifications.helper import create_notification from dojo.pghistory_models import DojoEvents diff --git a/dojo/endpoint/ui/__init__.py b/dojo/endpoint/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/endpoint/ui/filters.py b/dojo/endpoint/ui/filters.py new file mode 100644 index 00000000000..a4ccb35cce1 --- /dev/null +++ b/dojo/endpoint/ui/filters.py @@ -0,0 +1,260 @@ +from django.forms import HiddenInput +from django_filters import ( + CharFilter, + FilterSet, + ModelMultipleChoiceFilter, + NumberFilter, + OrderingFilter, +) + +from dojo.endpoint.models import Endpoint +from dojo.endpoint.queries import get_authorized_endpoints_for_queryset +from dojo.filters import DojoFilter +from dojo.labels import get_labels +from dojo.models import Engagement, Finding, Product, Test +from dojo.product.queries import get_authorized_products + +labels = get_labels() + + +class EndpointFilterHelper(FilterSet): + protocol = CharFilter(lookup_expr="icontains") + userinfo = CharFilter(lookup_expr="icontains") + host = CharFilter(lookup_expr="icontains") + port = NumberFilter() + path = CharFilter(lookup_expr="icontains") + query = CharFilter(lookup_expr="icontains") + fragment = CharFilter(lookup_expr="icontains") + tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") + not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) + has_tags = CharFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("product", "product"), + ("host", "host"), + ("id", "id"), + ("active_finding_count", "active_finding_count"), + ), + field_labels={ + "active_finding_count": "Active Findings Count", + }, + ) + + +class EndpointFilter(EndpointFilterHelper, DojoFilter): + product = ModelMultipleChoiceFilter( + queryset=Product.objects.none(), + label=labels.ASSET_FILTERS_LABEL) + tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + label="Endpoint Tags", + queryset=Endpoint.tags.tag_model.objects.all().order_by("name")) + findings__tags = ModelMultipleChoiceFilter( + field_name="findings__tags__name", + to_field_name="name", + label="Finding Tags", + queryset=Finding.tags.tag_model.objects.all().order_by("name")) + findings__test__tags = ModelMultipleChoiceFilter( + field_name="findings__test__tags__name", + to_field_name="name", + label="Test Tags", + queryset=Test.tags.tag_model.objects.all().order_by("name")) + findings__test__engagement__tags = ModelMultipleChoiceFilter( + field_name="findings__test__engagement__tags__name", + to_field_name="name", + label="Engagement Tags", + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + findings__test__engagement__product__tags = ModelMultipleChoiceFilter( + field_name="findings__test__engagement__product__tags__name", + to_field_name="name", + label=labels.ASSET_FILTERS_TAGS_ASSET_LABEL, + queryset=Product.tags.tag_model.objects.all().order_by("name")) + not_tags = ModelMultipleChoiceFilter( + field_name="tags__name", + to_field_name="name", + label="Not Endpoint Tags", + exclude=True, + queryset=Endpoint.tags.tag_model.objects.all().order_by("name")) + not_findings__tags = ModelMultipleChoiceFilter( + field_name="findings__tags__name", + to_field_name="name", + label="Not Finding Tags", + exclude=True, + queryset=Finding.tags.tag_model.objects.all().order_by("name")) + not_findings__test__tags = ModelMultipleChoiceFilter( + field_name="findings__test__tags__name", + to_field_name="name", + label="Not Test Tags", + exclude=True, + queryset=Test.tags.tag_model.objects.all().order_by("name")) + not_findings__test__engagement__tags = ModelMultipleChoiceFilter( + field_name="findings__test__engagement__tags__name", + to_field_name="name", + label="Not Engagement Tags", + exclude=True, + queryset=Engagement.tags.tag_model.objects.all().order_by("name")) + not_findings__test__engagement__product__tags = ModelMultipleChoiceFilter( + field_name="findings__test__engagement__product__tags__name", + to_field_name="name", + label=labels.ASSET_FILTERS_NOT_TAGS_ASSET_LABEL, + exclude=True, + queryset=Product.tags.tag_model.objects.all().order_by("name")) + + def __init__(self, *args, **kwargs): + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + self.form.fields["product"].queryset = get_authorized_products("view") + + @property + def qs(self): + parent = super().qs + return get_authorized_endpoints_for_queryset("view", parent) + + class Meta: + model = Endpoint + exclude = ["findings", "inherited_tags"] + + +class EndpointFilterWithoutObjectLookups(EndpointFilterHelper): + product = NumberFilter(widget=HiddenInput()) + product__name = CharFilter( + field_name="product__name", + lookup_expr="iexact", + label=labels.ASSET_FILTERS_NAME_LABEL, + help_text=labels.ASSET_FILTERS_NAME_HELP) + product__name_contains = CharFilter( + field_name="product__name", + lookup_expr="icontains", + label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, + help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) + + tags_contains = CharFilter( + label="Endpoint Tag Contains", + field_name="tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Endpoint that contain a given pattern") + tags = CharFilter( + label="Endpoint Tag", + field_name="tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Endpoint that are an exact match") + findings__tags_contains = CharFilter( + label="Finding Tag Contains", + field_name="findings__tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Finding that contain a given pattern") + findings__tags = CharFilter( + label="Finding Tag", + field_name="findings__tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Finding that are an exact match") + findings__test__tags_contains = CharFilter( + label="Test Tag Contains", + field_name="findings__test__tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Finding that contain a given pattern") + findings__test__tags = CharFilter( + label="Test Tag", + field_name="findings__test__tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Finding that are an exact match") + findings__test__engagement__tags_contains = CharFilter( + label="Engagement Tag Contains", + field_name="findings__test__engagement__tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Finding that contain a given pattern") + findings__test__engagement__tags = CharFilter( + label="Engagement Tag", + field_name="findings__test__engagement__tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Finding that are an exact match") + findings__test__engagement__product__tags_contains = CharFilter( + label=labels.ASSET_FILTERS_TAG_ASSET_CONTAINS_LABEL, + field_name="findings__test__engagement__product__tags__name", + lookup_expr="icontains", + help_text=labels.ASSET_FILTERS_TAG_ASSET_CONTAINS_HELP) + findings__test__engagement__product__tags = CharFilter( + label=labels.ASSET_FILTERS_TAG_ASSET_LABEL, + field_name="findings__test__engagement__product__tags__name", + lookup_expr="iexact", + help_text=labels.ASSET_FILTERS_TAG_ASSET_HELP) + + not_tags_contains = CharFilter( + label="Endpoint Tag Does Not Contain", + field_name="tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Endpoint that contain a given pattern, and exclude them", + exclude=True) + not_tags = CharFilter( + label="Not Endpoint Tag", + field_name="tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Endpoint that are an exact match, and exclude them", + exclude=True) + not_findings__tags_contains = CharFilter( + label="Finding Tag Does Not Contain", + field_name="findings__tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Finding that contain a given pattern, and exclude them", + exclude=True) + not_findings__tags = CharFilter( + label="Not Finding Tag", + field_name="findings__tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Finding that are an exact match, and exclude them", + exclude=True) + not_findings__test__tags_contains = CharFilter( + label="Test Tag Does Not Contain", + field_name="findings__test__tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Test that contain a given pattern, and exclude them", + exclude=True) + not_findings__test__tags = CharFilter( + label="Not Test Tag", + field_name="findings__test__tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Test that are an exact match, and exclude them", + exclude=True) + not_findings__test__engagement__tags_contains = CharFilter( + label="Engagement Tag Does Not Contain", + field_name="findings__test__engagement__tags__name", + lookup_expr="icontains", + help_text="Search for tags on a Engagement that contain a given pattern, and exclude them", + exclude=True) + not_findings__test__engagement__tags = CharFilter( + label="Not Engagement Tag", + field_name="findings__test__engagement__tags__name", + lookup_expr="iexact", + help_text="Search for tags on a Engagement that are an exact match, and exclude them", + exclude=True) + not_findings__test__engagement__product__tags_contains = CharFilter( + label=labels.ASSET_FILTERS_TAG_NOT_CONTAIN_LABEL, + field_name="findings__test__engagement__product__tags__name", + lookup_expr="icontains", + help_text=labels.ASSET_FILTERS_TAG_NOT_CONTAIN_HELP, + exclude=True) + not_findings__test__engagement__product__tags = CharFilter( + label=labels.ASSET_FILTERS_TAG_NOT_LABEL, + field_name="findings__test__engagement__product__tags__name", + lookup_expr="iexact", + help_text=labels.ASSET_FILTERS_TAG_NOT_HELP, + exclude=True) + + def __init__(self, *args, **kwargs): + self.user = None + if "user" in kwargs: + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + + @property + def qs(self): + parent = super().qs + return get_authorized_endpoints_for_queryset("view", parent) + + class Meta: + model = Endpoint + exclude = ["findings", "inherited_tags", "product"] diff --git a/dojo/endpoint/ui/forms.py b/dojo/endpoint/ui/forms.py new file mode 100644 index 00000000000..625cfc09e41 --- /dev/null +++ b/dojo/endpoint/ui/forms.py @@ -0,0 +1,164 @@ +from django import forms +from tagulous.forms import TagField + +from dojo.endpoint.models import Endpoint +from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add +from dojo.labels import get_labels +from dojo.models import Finding, Product +from dojo.product.queries import get_authorized_products +from dojo.validators import tag_validator + +labels = get_labels() + + +class ImportEndpointMetaForm(forms.Form): + file = forms.FileField(widget=forms.widgets.FileInput( + attrs={"accept": ".csv"}), + label="Choose meta file", + required=True) # Could not get required=True to actually accept the file as present + create_endpoints = forms.BooleanField( + label="Create nonexisting Endpoint", + initial=True, + required=False, + help_text="Create endpoints that do not already exist") + create_tags = forms.BooleanField( + label="Add Tags", + initial=True, + required=False, + help_text="Add meta from file as tags in the format key:value") + create_dojo_meta = forms.BooleanField( + label="Add Meta", + initial=False, + required=False, + help_text="Add data from file as Metadata. Metadata is used for displaying custom fields") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class EditEndpointForm(forms.ModelForm): + class Meta: + model = Endpoint + exclude = ["product", "inherited_tags"] + + def __init__(self, *args, **kwargs): + self.product = None + self.endpoint_instance = None + super().__init__(*args, **kwargs) + if "instance" in kwargs: + self.endpoint_instance = kwargs.pop("instance") + self.product = self.endpoint_instance.product + product_id = self.endpoint_instance.product.pk + findings = Finding.objects.filter(test__engagement__product__id=product_id) + self.fields["findings"].queryset = findings + + def clean(self): + + cleaned_data = super().clean() + + protocol = cleaned_data["protocol"] + userinfo = cleaned_data["userinfo"] + host = cleaned_data["host"] + port = cleaned_data["port"] + path = cleaned_data["path"] + query = cleaned_data["query"] + fragment = cleaned_data["fragment"] + + endpoint = endpoint_filter( + protocol=protocol, + userinfo=userinfo, + host=host, + port=port, + path=path, + query=query, + fragment=fragment, + product=self.product, + ) + if endpoint.count() > 1 or (endpoint.count() == 1 and endpoint.first().pk != self.endpoint_instance.pk): + msg = "It appears as though an endpoint with this data already exists for this product." + raise forms.ValidationError(msg, code="invalid") + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + +class AddEndpointForm(forms.Form): + endpoint = forms.CharField(max_length=5000, required=True, label="Endpoint(s)", + help_text="The IP address, host name or full URL. You may enter one endpoint per line. " + "Each must be valid.", + widget=forms.widgets.Textarea(attrs={"rows": "15", "cols": "400"})) + product = forms.CharField(required=True, + label=labels.ASSET_LABEL, help_text=labels.ASSET_ENDPOINT_HELP, + widget=forms.widgets.HiddenInput()) + tags = TagField(required=False, + help_text="Add tags that help describe this endpoint. " + "Choose from the list or add new tags. Press Enter key to add.") + + def __init__(self, *args, **kwargs): + product = None + if "product" in kwargs: + product = kwargs.pop("product") + super().__init__(*args, **kwargs) + self.fields["product"] = forms.ModelChoiceField( + queryset=get_authorized_products("add"), + label=labels.ASSET_LABEL, + help_text=labels.ASSET_ENDPOINT_HELP) + if product is not None: + self.fields["product"].initial = product.id + + self.product = product + self.endpoints_to_process = [] + + def save(self): + processed_endpoints = [] + for e in self.endpoints_to_process: + endpoint, _created = endpoint_get_or_create( + protocol=e[0], + userinfo=e[1], + host=e[2], + port=e[3], + path=e[4], + query=e[5], + fragment=e[6], + product=self.product, + ) + processed_endpoints.append(endpoint) + return processed_endpoints + + def clean(self): + + cleaned_data = super().clean() + + if "endpoint" in cleaned_data and "product" in cleaned_data: + endpoint = cleaned_data["endpoint"] + product = cleaned_data["product"] + if isinstance(product, Product): + self.product = product + else: + self.product = Product.objects.get(id=int(product)) + else: + msg = "Please enter a valid URL or IP address." + raise forms.ValidationError(msg, code="invalid") + + endpoints_to_add_list, errors = validate_endpoints_to_add(endpoint) + if errors: + raise forms.ValidationError(errors) + self.endpoints_to_process = endpoints_to_add_list + + return cleaned_data + + def clean_tags(self): + tag_validator(self.cleaned_data.get("tags")) + return self.cleaned_data.get("tags") + + +class DeleteEndpointForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Endpoint + fields = ["id"] diff --git a/dojo/endpoint/urls.py b/dojo/endpoint/ui/urls.py similarity index 98% rename from dojo/endpoint/urls.py rename to dojo/endpoint/ui/urls.py index 94f6fbdcdb7..4b92af3e7b6 100644 --- a/dojo/endpoint/urls.py +++ b/dojo/endpoint/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.endpoint import views +from dojo.endpoint.ui import views urlpatterns = [ # endpoints diff --git a/dojo/endpoint/views.py b/dojo/endpoint/ui/views.py similarity index 99% rename from dojo/endpoint/views.py rename to dojo/endpoint/ui/views.py index 74c922f9ea7..531f21cffd5 100644 --- a/dojo/endpoint/views.py +++ b/dojo/endpoint/ui/views.py @@ -18,8 +18,8 @@ from dojo.authorization.authorization import user_has_permission_or_403 from dojo.celery_dispatch import dojo_dispatch_task from dojo.endpoint.queries import get_authorized_endpoints_for_queryset +from dojo.endpoint.ui.filters import EndpointFilter, EndpointFilterWithoutObjectLookups from dojo.endpoint.utils import clean_hosts_run, endpoint_meta_import -from dojo.filters import EndpointFilter, EndpointFilterWithoutObjectLookups from dojo.forms import ( AddEndpointForm, DeleteEndpointForm, diff --git a/dojo/filters.py b/dojo/filters.py index 43f588a4503..8162da4e0c5 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -10,7 +10,6 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q -from django.forms import HiddenInput from django.utils.timezone import now, tzinfo from django.utils.translation import gettext_lazy as _ from django_filters import ( @@ -30,7 +29,6 @@ # from tagulous.forms import TagWidget # import tagulous -from dojo.endpoint.queries import get_authorized_endpoints_for_queryset from dojo.engagement.queries import get_authorized_engagements from dojo.finding.helper import ( ACCEPTED_FINDINGS_QUERY, @@ -65,7 +63,6 @@ TextQuestion, Vulnerability_Id, ) -from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types from dojo.utils import get_system_setting, is_finding_groups_enabled, truncate_timezone_aware @@ -1284,281 +1281,6 @@ class Meta: exclude = ["last_modified", "endpoint", "finding"] -class EndpointFilterHelper(FilterSet): - protocol = CharFilter(lookup_expr="icontains") - userinfo = CharFilter(lookup_expr="icontains") - host = CharFilter(lookup_expr="icontains") - port = NumberFilter() - path = CharFilter(lookup_expr="icontains") - query = CharFilter(lookup_expr="icontains") - fragment = CharFilter(lookup_expr="icontains") - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Tag name contains") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", label="Not tag name contains", exclude=True) - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("product", "product"), - ("host", "host"), - ("id", "id"), - ("active_finding_count", "active_finding_count"), - ), - field_labels={ - "active_finding_count": "Active Findings Count", - }, - ) - - -class EndpointFilter(EndpointFilterHelper, DojoFilter): - product = ModelMultipleChoiceFilter( - queryset=Product.objects.none(), - label=labels.ASSET_FILTERS_LABEL) - tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - label="Endpoint Tags", - queryset=Endpoint.tags.tag_model.objects.all().order_by("name")) - findings__tags = ModelMultipleChoiceFilter( - field_name="findings__tags__name", - to_field_name="name", - label="Finding Tags", - queryset=Finding.tags.tag_model.objects.all().order_by("name")) - findings__test__tags = ModelMultipleChoiceFilter( - field_name="findings__test__tags__name", - to_field_name="name", - label="Test Tags", - queryset=Test.tags.tag_model.objects.all().order_by("name")) - findings__test__engagement__tags = ModelMultipleChoiceFilter( - field_name="findings__test__engagement__tags__name", - to_field_name="name", - label="Engagement Tags", - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - findings__test__engagement__product__tags = ModelMultipleChoiceFilter( - field_name="findings__test__engagement__product__tags__name", - to_field_name="name", - label=labels.ASSET_FILTERS_TAGS_ASSET_LABEL, - queryset=Product.tags.tag_model.objects.all().order_by("name")) - not_tags = ModelMultipleChoiceFilter( - field_name="tags__name", - to_field_name="name", - label="Not Endpoint Tags", - exclude=True, - queryset=Endpoint.tags.tag_model.objects.all().order_by("name")) - not_findings__tags = ModelMultipleChoiceFilter( - field_name="findings__tags__name", - to_field_name="name", - label="Not Finding Tags", - exclude=True, - queryset=Finding.tags.tag_model.objects.all().order_by("name")) - not_findings__test__tags = ModelMultipleChoiceFilter( - field_name="findings__test__tags__name", - to_field_name="name", - label="Not Test Tags", - exclude=True, - queryset=Test.tags.tag_model.objects.all().order_by("name")) - not_findings__test__engagement__tags = ModelMultipleChoiceFilter( - field_name="findings__test__engagement__tags__name", - to_field_name="name", - label="Not Engagement Tags", - exclude=True, - queryset=Engagement.tags.tag_model.objects.all().order_by("name")) - not_findings__test__engagement__product__tags = ModelMultipleChoiceFilter( - field_name="findings__test__engagement__product__tags__name", - to_field_name="name", - label=labels.ASSET_FILTERS_NOT_TAGS_ASSET_LABEL, - exclude=True, - queryset=Product.tags.tag_model.objects.all().order_by("name")) - - def __init__(self, *args, **kwargs): - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - super().__init__(*args, **kwargs) - self.form.fields["product"].queryset = get_authorized_products("view") - - @property - def qs(self): - parent = super().qs - return get_authorized_endpoints_for_queryset("view", parent) - - class Meta: - model = Endpoint - exclude = ["findings", "inherited_tags"] - - -class EndpointFilterWithoutObjectLookups(EndpointFilterHelper): - product = NumberFilter(widget=HiddenInput()) - product__name = CharFilter( - field_name="product__name", - lookup_expr="iexact", - label=labels.ASSET_FILTERS_NAME_LABEL, - help_text=labels.ASSET_FILTERS_NAME_HELP) - product__name_contains = CharFilter( - field_name="product__name", - lookup_expr="icontains", - label=labels.ASSET_FILTERS_NAME_CONTAINS_LABEL, - help_text=labels.ASSET_FILTERS_NAME_CONTAINS_HELP) - - tags_contains = CharFilter( - label="Endpoint Tag Contains", - field_name="tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Endpoint that contain a given pattern") - tags = CharFilter( - label="Endpoint Tag", - field_name="tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Endpoint that are an exact match") - findings__tags_contains = CharFilter( - label="Finding Tag Contains", - field_name="findings__tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Finding that contain a given pattern") - findings__tags = CharFilter( - label="Finding Tag", - field_name="findings__tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Finding that are an exact match") - findings__test__tags_contains = CharFilter( - label="Test Tag Contains", - field_name="findings__test__tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Finding that contain a given pattern") - findings__test__tags = CharFilter( - label="Test Tag", - field_name="findings__test__tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Finding that are an exact match") - findings__test__engagement__tags_contains = CharFilter( - label="Engagement Tag Contains", - field_name="findings__test__engagement__tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Finding that contain a given pattern") - findings__test__engagement__tags = CharFilter( - label="Engagement Tag", - field_name="findings__test__engagement__tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Finding that are an exact match") - findings__test__engagement__product__tags_contains = CharFilter( - label=labels.ASSET_FILTERS_TAG_ASSET_CONTAINS_LABEL, - field_name="findings__test__engagement__product__tags__name", - lookup_expr="icontains", - help_text=labels.ASSET_FILTERS_TAG_ASSET_CONTAINS_HELP) - findings__test__engagement__product__tags = CharFilter( - label=labels.ASSET_FILTERS_TAG_ASSET_LABEL, - field_name="findings__test__engagement__product__tags__name", - lookup_expr="iexact", - help_text=labels.ASSET_FILTERS_TAG_ASSET_HELP) - - not_tags_contains = CharFilter( - label="Endpoint Tag Does Not Contain", - field_name="tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Endpoint that contain a given pattern, and exclude them", - exclude=True) - not_tags = CharFilter( - label="Not Endpoint Tag", - field_name="tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Endpoint that are an exact match, and exclude them", - exclude=True) - not_findings__tags_contains = CharFilter( - label="Finding Tag Does Not Contain", - field_name="findings__tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Finding that contain a given pattern, and exclude them", - exclude=True) - not_findings__tags = CharFilter( - label="Not Finding Tag", - field_name="findings__tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Finding that are an exact match, and exclude them", - exclude=True) - not_findings__test__tags_contains = CharFilter( - label="Test Tag Does Not Contain", - field_name="findings__test__tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Test that contain a given pattern, and exclude them", - exclude=True) - not_findings__test__tags = CharFilter( - label="Not Test Tag", - field_name="findings__test__tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Test that are an exact match, and exclude them", - exclude=True) - not_findings__test__engagement__tags_contains = CharFilter( - label="Engagement Tag Does Not Contain", - field_name="findings__test__engagement__tags__name", - lookup_expr="icontains", - help_text="Search for tags on a Engagement that contain a given pattern, and exclude them", - exclude=True) - not_findings__test__engagement__tags = CharFilter( - label="Not Engagement Tag", - field_name="findings__test__engagement__tags__name", - lookup_expr="iexact", - help_text="Search for tags on a Engagement that are an exact match, and exclude them", - exclude=True) - not_findings__test__engagement__product__tags_contains = CharFilter( - label=labels.ASSET_FILTERS_TAG_NOT_CONTAIN_LABEL, - field_name="findings__test__engagement__product__tags__name", - lookup_expr="icontains", - help_text=labels.ASSET_FILTERS_TAG_NOT_CONTAIN_HELP, - exclude=True) - not_findings__test__engagement__product__tags = CharFilter( - label=labels.ASSET_FILTERS_TAG_NOT_LABEL, - field_name="findings__test__engagement__product__tags__name", - lookup_expr="iexact", - help_text=labels.ASSET_FILTERS_TAG_NOT_HELP, - exclude=True) - - def __init__(self, *args, **kwargs): - self.user = None - if "user" in kwargs: - self.user = kwargs.pop("user") - super().__init__(*args, **kwargs) - - @property - def qs(self): - parent = super().qs - return get_authorized_endpoints_for_queryset("view", parent) - - class Meta: - model = Endpoint - exclude = ["findings", "inherited_tags", "product"] - - -class ApiEndpointFilter(DojoFilter): - tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") - tags = CharFieldInFilter( - field_name="tags__name", - lookup_expr="in", - help_text="Comma separated list of exact tags (uses OR for multiple values)") - tags__and = CharFieldFilterANDExpression( - field_name="tags__name", - help_text="Comma separated list of exact tags to match with an AND expression") - not_tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude="True") - not_tags = CharFieldInFilter(field_name="tags__name", lookup_expr="in", - help_text="Comma separated list of exact tags not present on model", exclude="True") - has_tags = BooleanFilter(field_name="tags", lookup_expr="isnull", exclude=True, label="Has tags") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("host", "host"), - ("product", "product"), - ("id", "id"), - ("active_finding_count", "active_finding_count"), - ), - field_labels={ - "active_finding_count": "Active Findings Count", - }, - ) - - class Meta: - model = Endpoint - fields = ["id", "protocol", "userinfo", "host", "port", "path", "query", "fragment", "product"] - - class ApiRiskAcceptanceFilter(DojoFilter): created = DateRangeFilter() updated = DateRangeFilter() diff --git a/dojo/forms.py b/dojo/forms.py index 0d2ed86168b..e07dfb9517c 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -25,7 +25,7 @@ from polymorphic.base import ManagerInheritanceWarning from tagulous.forms import TagField -from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add +from dojo.endpoint.utils import validate_endpoints_to_add from dojo.finding.queries import get_authorized_findings from dojo.github.ui.forms import ( # noqa: F401 -- backward compat DeleteGITHUBConfForm, @@ -75,7 +75,6 @@ Note_Type, Notes, Objects_Product, - Product, Product_API_Scan_Configuration, Product_Type, Question, @@ -87,7 +86,6 @@ TextQuestion, User, ) -from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types from dojo.tools.factory import get_choices_sorted, requires_file, requires_tool_type from dojo.user.queries import get_authorized_users @@ -572,29 +570,12 @@ def clean_scan_date(self): return date -class ImportEndpointMetaForm(forms.Form): - file = forms.FileField(widget=forms.widgets.FileInput( - attrs={"accept": ".csv"}), - label="Choose meta file", - required=True) # Could not get required=True to actually accept the file as present - create_endpoints = forms.BooleanField( - label="Create nonexisting Endpoint", - initial=True, - required=False, - help_text="Create endpoints that do not already exist") - create_tags = forms.BooleanField( - label="Add Tags", - initial=True, - required=False, - help_text="Add meta from file as tags in the format key:value") - create_dojo_meta = forms.BooleanField( - label="Add Meta", - initial=False, - required=False, - help_text="Add data from file as Metadata. Metadata is used for displaying custom fields") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) +from dojo.endpoint.ui.forms import ( # noqa: E402, F401 -- backward compat re-export + AddEndpointForm, + DeleteEndpointForm, + EditEndpointForm, + ImportEndpointMetaForm, +) class DoneForm(forms.Form): @@ -765,134 +746,6 @@ class Meta: from dojo.test.ui.forms import TestForm # noqa: E402, F401 -- backward compat -class EditEndpointForm(forms.ModelForm): - class Meta: - model = Endpoint - exclude = ["product", "inherited_tags"] - - def __init__(self, *args, **kwargs): - self.product = None - self.endpoint_instance = None - super().__init__(*args, **kwargs) - if "instance" in kwargs: - self.endpoint_instance = kwargs.pop("instance") - self.product = self.endpoint_instance.product - product_id = self.endpoint_instance.product.pk - findings = Finding.objects.filter(test__engagement__product__id=product_id) - self.fields["findings"].queryset = findings - - def clean(self): - - cleaned_data = super().clean() - - protocol = cleaned_data["protocol"] - userinfo = cleaned_data["userinfo"] - host = cleaned_data["host"] - port = cleaned_data["port"] - path = cleaned_data["path"] - query = cleaned_data["query"] - fragment = cleaned_data["fragment"] - - endpoint = endpoint_filter( - protocol=protocol, - userinfo=userinfo, - host=host, - port=port, - path=path, - query=query, - fragment=fragment, - product=self.product, - ) - if endpoint.count() > 1 or (endpoint.count() == 1 and endpoint.first().pk != self.endpoint_instance.pk): - msg = "It appears as though an endpoint with this data already exists for this product." - raise forms.ValidationError(msg, code="invalid") - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - -class AddEndpointForm(forms.Form): - endpoint = forms.CharField(max_length=5000, required=True, label="Endpoint(s)", - help_text="The IP address, host name or full URL. You may enter one endpoint per line. " - "Each must be valid.", - widget=forms.widgets.Textarea(attrs={"rows": "15", "cols": "400"})) - product = forms.CharField(required=True, - label=labels.ASSET_LABEL, help_text=labels.ASSET_ENDPOINT_HELP, - widget=forms.widgets.HiddenInput()) - tags = TagField(required=False, - help_text="Add tags that help describe this endpoint. " - "Choose from the list or add new tags. Press Enter key to add.") - - def __init__(self, *args, **kwargs): - product = None - if "product" in kwargs: - product = kwargs.pop("product") - super().__init__(*args, **kwargs) - self.fields["product"] = forms.ModelChoiceField( - queryset=get_authorized_products("add"), - label=labels.ASSET_LABEL, - help_text=labels.ASSET_ENDPOINT_HELP) - if product is not None: - self.fields["product"].initial = product.id - - self.product = product - self.endpoints_to_process = [] - - def save(self): - processed_endpoints = [] - for e in self.endpoints_to_process: - endpoint, _created = endpoint_get_or_create( - protocol=e[0], - userinfo=e[1], - host=e[2], - port=e[3], - path=e[4], - query=e[5], - fragment=e[6], - product=self.product, - ) - processed_endpoints.append(endpoint) - return processed_endpoints - - def clean(self): - - cleaned_data = super().clean() - - if "endpoint" in cleaned_data and "product" in cleaned_data: - endpoint = cleaned_data["endpoint"] - product = cleaned_data["product"] - if isinstance(product, Product): - self.product = product - else: - self.product = Product.objects.get(id=int(product)) - else: - msg = "Please enter a valid URL or IP address." - raise forms.ValidationError(msg, code="invalid") - - endpoints_to_add_list, errors = validate_endpoints_to_add(endpoint) - if errors: - raise forms.ValidationError(errors) - self.endpoints_to_process = endpoints_to_add_list - - return cleaned_data - - def clean_tags(self): - tag_validator(self.cleaned_data.get("tags")) - return self.cleaned_data.get("tags") - - -class DeleteEndpointForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Endpoint - fields = ["id"] - - class NoteForm(forms.ModelForm): entry = forms.CharField(max_length=2400, widget=forms.Textarea(attrs={"rows": 4, "cols": 15}), label="Notes:") diff --git a/dojo/models.py b/dojo/models.py index 47487685fd2..7e6052ffe0c 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1,23 +1,18 @@ -import contextlib import copy import logging -import re import warnings from datetime import timedelta from pathlib import Path -from urllib.parse import urlparse from uuid import uuid4 -import hyperlink import tagulous.admin from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.files.base import ContentFile -from django.core.validators import validate_ipv46_address -from django.db import connection, models -from django.db.models import Count, F, Q +from django.db import models +from django.db.models import Count from django.db.models.expressions import Case, When from django.db.models.functions import Lower from django.urls import reverse @@ -517,6 +512,7 @@ def __str__(self): return self.location +from dojo.endpoint.models import Endpoint, Endpoint_Params, Endpoint_Status # noqa: E402, F401 -- re-export from dojo.engagement.models import ( # noqa: E402 -- re-export; class-body FKs below reference these ENGAGEMENT_STATUS_CHOICES, # noqa: F401 -- re-export Engagement, @@ -524,445 +520,6 @@ def __str__(self): ) -class Endpoint_Params(models.Model): - param = models.CharField(max_length=150) - value = models.CharField(max_length=150) - method_type = (("GET", "GET"), - ("POST", "POST")) - method = models.CharField(max_length=20, blank=False, null=True, choices=method_type) - - -class Endpoint_Status(models.Model): - date = models.DateField(default=get_current_date) - last_modified = models.DateTimeField(null=True, editable=False, default=get_current_datetime) - mitigated = models.BooleanField(default=False, blank=True) - mitigated_time = models.DateTimeField(editable=False, null=True, blank=True) - mitigated_by = models.ForeignKey(Dojo_User, editable=True, null=True, on_delete=models.RESTRICT) - false_positive = models.BooleanField(default=False, blank=True) - out_of_scope = models.BooleanField(default=False, blank=True) - risk_accepted = models.BooleanField(default=False, blank=True) - endpoint = models.ForeignKey("Endpoint", null=False, blank=False, on_delete=models.CASCADE, related_name="status_endpoint") - finding = models.ForeignKey("Finding", null=False, blank=False, on_delete=models.CASCADE, related_name="status_finding") - - class Meta: - indexes = [ - models.Index(fields=["finding", "mitigated"]), - models.Index(fields=["endpoint", "mitigated"]), - # Optimize frequent lookups of "active" statuses (mitigated/flags all False) - models.Index( - name="idx_eps_active_by_endpoint", - fields=["endpoint"], - condition=Q(mitigated=False, false_positive=False, out_of_scope=False, risk_accepted=False), - ), - models.Index( - name="idx_eps_active_by_finding", - fields=["finding"], - condition=Q(mitigated=False, false_positive=False, out_of_scope=False, risk_accepted=False), - ), - ] - constraints = [ - models.UniqueConstraint(fields=["finding", "endpoint"], name="endpoint-finding relation"), - ] - - def __str__(self): - with Endpoint.allow_endpoint_init(): # TODO: Delete this after the move to Locations - return f"'{self.finding}' on '{self.endpoint}'" - - def copy(self, finding=None): - copy = copy_model_util(self) - current_endpoint = self.endpoint - if finding: - copy.finding = finding - copy.endpoint = current_endpoint - copy.save() - - return copy - - @property - def age(self): - - diff = self.mitigated_time.date() - self.date if self.mitigated else get_current_date() - self.date - days = diff.days - return max(0, days) - - -class Endpoint(models.Model): - protocol = models.CharField(null=True, blank=True, max_length=20, - help_text=_("The communication protocol/scheme such as 'http', 'ftp', 'dns', etc.")) - userinfo = models.CharField(null=True, blank=True, max_length=500, - help_text=_("User info as 'alice', 'bob', etc.")) - host = models.CharField(null=True, blank=True, max_length=500, - help_text=_("The host name or IP address. It must not include the port number. " - "For example '127.0.0.1', 'localhost', 'yourdomain.com'.")) - port = models.IntegerField(null=True, blank=True, - help_text=_("The network port associated with the endpoint.")) - path = models.CharField(null=True, blank=True, max_length=500, - help_text=_("The location of the resource, it must not start with a '/'. For example " - "endpoint/420/edit")) - query = models.CharField(null=True, blank=True, max_length=1000, - help_text=_("The query string, the question mark should be omitted." - "For example 'group=4&team=8'")) - fragment = models.CharField(null=True, blank=True, max_length=500, - help_text=_("The fragment identifier which follows the hash mark. The hash mark should " - "be omitted. For example 'section-13', 'paragraph-2'.")) - product = models.ForeignKey(Product, null=True, blank=True, on_delete=models.CASCADE) - endpoint_params = models.ManyToManyField(Endpoint_Params, blank=True, editable=False) - findings = models.ManyToManyField("Finding", - blank=True, - verbose_name=_("Findings"), - through=Endpoint_Status) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this endpoint. Choose from the list or add new tags. Press Enter key to add.")) - inherited_tags = TagField(blank=True, force_lowercase=True, help_text=_("Internal use tags sepcifically for maintaining parity with product. This field will be present as a subset in the tags field")) - - class Meta: - ordering = ["product", "host", "protocol", "port", "userinfo", "path", "query", "fragment"] - indexes = [ - models.Index(fields=["product"]), - # Fast case-insensitive equality on host within product scope - models.Index( - F("product"), - Lower("host"), - name="idx_ep_product_lower_host", - ), - ] - - def __init__(self, *args, **kwargs): - if settings.V3_FEATURE_LOCATIONS and not getattr(self, "_allow_v3_init", False): - msg = "Endpoint model is deprecated when V3_FEATURE_LOCATIONS is enabled" - raise NotImplementedError(msg) - super().__init__(*args, **kwargs) - - def __hash__(self): - return self.__str__().__hash__() - - def __eq__(self, other): - if isinstance(other, Endpoint): - contents_match = str(self) == str(other) - # Use product_id (cached integer) instead of self.product to avoid - # triggering a FK lookup on every comparison inside NestedObjects.add_edge. - if self.product_id is not None and other.product_id is not None: - return self.product_id == other.product_id and contents_match - return contents_match - - return NotImplemented - - def __str__(self): - try: - if self.host: - dummy_scheme = "dummy-scheme" # workaround for https://github.com/python-hyper/hyperlink/blob/b8c9152cd826bbe8e6cc125648f3738235019705/src/hyperlink/_url.py#L988 - url = hyperlink.EncodedURL( - scheme=self.protocol or dummy_scheme, - userinfo=self.userinfo or "", - host=self.host, - port=self.port, - path=tuple(self.path.split("/")) if self.path else (), - query=tuple( - ( - qe.split("=", 1) - if "=" in qe - else (qe, None) - ) - for qe in self.query.split("&") - ) if self.query else (), # inspired by https://github.com/python-hyper/hyperlink/blob/b8c9152cd826bbe8e6cc125648f3738235019705/src/hyperlink/_url.py#L1427 - fragment=self.fragment or "", - ) - # Return a normalized version of the URL to avoid differences where there shouldn't be any difference. - # Example: https://google.com and https://google.com:443 - normalize_path = self.path # it used to add '/' at the end of host - clean_url = url.normalize(scheme=True, host=True, path=normalize_path, query=True, fragment=True, userinfo=True, percents=True).to_uri().to_text() - if not self.protocol: - if clean_url[:len(dummy_scheme) + 3] == (dummy_scheme + "://"): - clean_url = clean_url[len(dummy_scheme) + 3:] - else: - msg = "hyperlink lib did not create URL as was expected" - raise ValueError(msg) - return clean_url - msg = "Missing host" - raise ValueError(msg) - except: - url = "" - if self.protocol: - url += f"{self.protocol}://" - if self.userinfo: - url += f"{self.userinfo}@" - if self.host: - url += self.host - if self.port: - url += f":{self.port}" - if self.path: - url += "{}{}".format("/" if self.path[0] != "/" else "", self.path) - if self.query: - url += f"?{self.query}" - if self.fragment: - url += f"#{self.fragment}" - return url - - def get_absolute_url(self): - return reverse("view_endpoint", args=[str(self.id)]) - - @classmethod - @contextlib.contextmanager - def allow_endpoint_init(cls): - # When migrating to Locations, Endpoints are not deleted (hooray backup!). Disallowing the initialization of - # Endpoints is a good way to catch where they might still be used (oops!). However, there are some circumstances - # -- object deletes -- where Django itself attempts to instantiate an Endpoint object. This, we need to allow: - # if a user wants to delete an object, including whatever Endpoints are attached to it, they should be able to. - # This context manager allows code to initialize Endpoints at our discretion. - old = getattr(cls, "_allow_v3_init", None) - cls._allow_v3_init = True - try: - yield - finally: - cls._allow_v3_init = old - - def clean(self): - errors = [] - null_char_list = ["0x00", "\x00"] - db_type = connection.vendor - if self.protocol is not None: - if not re.match(r"^[A-Za-z][A-Za-z0-9\.\-\+]+$", self.protocol): # https://tools.ietf.org/html/rfc3986#section-3.1 - errors.append(ValidationError(f'Protocol "{self.protocol}" has invalid format')) - if not self.protocol: - self.protocol = None - - if self.userinfo is not None: - if not re.match(r"^[A-Za-z0-9\.\-_~%\!\$&\'\(\)\*\+,;=:]+$", self.userinfo): # https://tools.ietf.org/html/rfc3986#section-3.2.1 - errors.append(ValidationError(f'Userinfo "{self.userinfo}" has invalid format')) - if not self.userinfo: - self.userinfo = None - - if self.host: - if not re.match(r"^[A-Za-z0-9_\-\+][A-Za-z0-9_\.\-\+]+$", self.host): - try: - validate_ipv46_address(self.host) - except ValidationError: - errors.append(ValidationError(f'Host "{self.host}" has invalid format')) - else: - errors.append(ValidationError("Host must not be empty")) - - if self.port is not None: - try: - int_port = int(self.port) - if not (0 <= int_port < 65536): - errors.append(ValidationError(f'Port "{self.port}" has invalid format - out of range')) - self.port = int_port - except ValueError: - errors.append(ValidationError(f'Port "{self.port}" has invalid format - it is not a number')) - - if self.path is not None: - while len(self.path) > 0 and self.path[0] == "/": # Endpoint store "root-less" path - self.path = self.path[1:] - if any(null_char in self.path for null_char in null_char_list): - old_value = self.path - if "postgres" in db_type: - action_string = "Postgres does not accept NULL character. Attempting to replace with %00..." - for remove_str in null_char_list: - self.path = self.path.replace(remove_str, "%00") - logger.error('Path "%s" has invalid format - It contains the NULL character. The following action was taken: %s', old_value, action_string) - if not self.path: - self.path = None - - if self.query is not None: - if len(self.query) > 0 and self.query[0] == "?": - self.query = self.query[1:] - if any(null_char in self.query for null_char in null_char_list): - old_value = self.query - if "postgres" in db_type: - action_string = "Postgres does not accept NULL character. Attempting to replace with %00..." - for remove_str in null_char_list: - self.query = self.query.replace(remove_str, "%00") - logger.error('Query "%s" has invalid format - It contains the NULL character. The following action was taken: %s', old_value, action_string) - if not self.query: - self.query = None - - if self.fragment is not None: - if len(self.fragment) > 0 and self.fragment[0] == "#": - self.fragment = self.fragment[1:] - if any(null_char in self.fragment for null_char in null_char_list): - old_value = self.fragment - if "postgres" in db_type: - action_string = "Postgres does not accept NULL character. Attempting to replace with %00..." - for remove_str in null_char_list: - self.fragment = self.fragment.replace(remove_str, "%00") - logger.error('Fragment "%s" has invalid format - It contains the NULL character. The following action was taken: %s', old_value, action_string) - if not self.fragment: - self.fragment = None - - if errors: - raise ValidationError(errors) - - @property - def is_broken(self): - try: - self.clean() - except: - return True - else: - return not self.product - - @property - def mitigated(self): - return not self.vulnerable - - @property - def vulnerable(self): - return Endpoint_Status.objects.filter( - endpoint=self, - mitigated=False, - false_positive=False, - out_of_scope=False, - risk_accepted=False, - ).count() > 0 - - @property - def findings_count(self): - return self.findings.all().count() - - def active_findings(self): - return self.findings.filter( - active=True, - out_of_scope=False, - mitigated__isnull=True, - false_p=False, - duplicate=False, - status_finding__false_positive=False, - status_finding__out_of_scope=False, - status_finding__risk_accepted=False, - ).order_by("numerical_severity") - - def active_verified_findings(self): - return self.findings.filter( - active=True, - verified=True, - out_of_scope=False, - mitigated__isnull=True, - false_p=False, - duplicate=False, - status_finding__false_positive=False, - status_finding__out_of_scope=False, - status_finding__risk_accepted=False, - ).order_by("numerical_severity") - - @property - def active_findings_count(self): - return self.active_findings().count() - - @property - def active_verified_findings_count(self): - return self.active_verified_findings().count() - - def host_endpoints(self): - return Endpoint.objects.filter(host=self.host, - product=self.product).distinct() - - @property - def host_endpoints_count(self): - return self.host_endpoints().count() - - def host_mitigated_endpoints(self): - meps = Endpoint_Status.objects \ - .filter(endpoint__in=self.host_endpoints()) \ - .filter(Q(mitigated=True) - | Q(false_positive=True) - | Q(out_of_scope=True) - | Q(risk_accepted=True) - | Q(finding__out_of_scope=True) - | Q(finding__mitigated__isnull=False) - | Q(finding__false_p=True) - | Q(finding__duplicate=True) - | Q(finding__active=False)) - return Endpoint.objects.filter(status_endpoint__in=meps).distinct() - - @property - def host_mitigated_endpoints_count(self): - return self.host_mitigated_endpoints().count() - - def host_findings(self): - return Finding.objects.filter(endpoints__in=self.host_endpoints()).distinct() - - @property - def host_findings_count(self): - return self.host_findings().count() - - def host_active_findings(self): - return Finding.objects.filter( - active=True, - out_of_scope=False, - mitigated__isnull=True, - false_p=False, - duplicate=False, - status_finding__false_positive=False, - status_finding__out_of_scope=False, - status_finding__risk_accepted=False, - endpoints__in=self.host_endpoints(), - ).order_by("numerical_severity") - - def host_active_verified_findings(self): - return Finding.objects.filter( - active=True, - verified=True, - out_of_scope=False, - mitigated__isnull=True, - false_p=False, - duplicate=False, - status_finding__false_positive=False, - status_finding__out_of_scope=False, - status_finding__risk_accepted=False, - endpoints__in=self.host_endpoints(), - ).order_by("numerical_severity") - - @property - def host_active_findings_count(self): - return self.host_active_findings().count() - - @property - def host_active_verified_findings_count(self): - return self.host_active_verified_findings().count() - - def get_breadcrumbs(self): - bc = self.product.get_breadcrumbs() - bc += [{"title": self.host, - "url": reverse("view_endpoint", args=(self.id,))}] - return bc - - @staticmethod - def from_uri(uri): - try: - url = hyperlink.parse(url=uri) - except UnicodeDecodeError: - url = hyperlink.parse(url="//" + urlparse(uri).netloc) - except hyperlink.URLParseError as e: - msg = f"Invalid URL format: {e}" - raise ValidationError(msg) - - query_parts = [] # inspired by https://github.com/python-hyper/hyperlink/blob/b8c9152cd826bbe8e6cc125648f3738235019705/src/hyperlink/_url.py#L1768 - for k, v in url.query: - if v is None: - query_parts.append(k) - else: - query_parts.append(f"{k}={v}") - query_string = "&".join(query_parts) - - protocol = url.scheme or None - userinfo = ":".join(url.userinfo) if url.userinfo not in {(), ("",)} else None - host = url.host or None - port = url.port - path = "/".join(url.path)[:500] if url.path not in {None, (), ("",)} else None - query = query_string[:1000] if query_string is not None and query_string else None - fragment = url.fragment[:500] if url.fragment is not None and url.fragment else None - - return Endpoint( - protocol=protocol, - userinfo=userinfo, - host=host, - port=port, - path=path, - query=query, - fragment=fragment, - ) - - class Development_Environment(models.Model): name = models.CharField(max_length=200) @@ -1637,8 +1194,6 @@ def __str__(self): tagulous.admin.register(Finding.inherited_tags) tagulous.admin.register(Engagement.tags) tagulous.admin.register(Engagement.inherited_tags) -tagulous.admin.register(Endpoint.tags) -tagulous.admin.register(Endpoint.inherited_tags) tagulous.admin.register(Finding_Template.tags) tagulous.admin.register(App_Analysis.tags) tagulous.admin.register(Objects_Product.tags) @@ -1664,9 +1219,6 @@ def __str__(self): admin.site.register(FileAccessToken) admin.site.register(Risk_Acceptance) admin.site.register(Check_List) -admin.site.register(Endpoint_Params) -admin.site.register(Endpoint_Status) -admin.site.register(Endpoint) admin.site.register(Notes) admin.site.register(Note_Type) admin.site.register(SLA_Configuration) diff --git a/dojo/reports/views.py b/dojo/reports/views.py index 0a78787ec5c..eff9b1d4825 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/views.py @@ -17,9 +17,8 @@ from dojo.authorization.authorization import user_has_permission_or_403 from dojo.authorization.roles_permissions import Permissions from dojo.endpoint.queries import get_authorized_endpoints +from dojo.endpoint.ui.filters import EndpointFilter, EndpointFilterWithoutObjectLookups from dojo.filters import ( - EndpointFilter, - EndpointFilterWithoutObjectLookups, EndpointReportFilter, ) from dojo.finding.queries import get_authorized_findings diff --git a/dojo/reports/widgets.py b/dojo/reports/widgets.py index 2620ecf23d2..07377560539 100644 --- a/dojo/reports/widgets.py +++ b/dojo/reports/widgets.py @@ -12,10 +12,7 @@ from django.utils.html import format_html from django.utils.safestring import mark_safe -from dojo.filters import ( - EndpointFilter, - EndpointFilterWithoutObjectLookups, -) +from dojo.endpoint.ui.filters import EndpointFilter, EndpointFilterWithoutObjectLookups from dojo.finding.ui.filters import ( ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, diff --git a/dojo/search/views.py b/dojo/search/views.py index 13dd70e1ed1..050286c5999 100644 --- a/dojo/search/views.py +++ b/dojo/search/views.py @@ -10,7 +10,7 @@ from watson import search as watson from dojo.endpoint.queries import get_authorized_endpoints -from dojo.endpoint.views import prefetch_for_endpoints +from dojo.endpoint.ui.views import prefetch_for_endpoints from dojo.engagement.queries import get_authorized_engagements from dojo.finding.queries import get_authorized_findings, get_authorized_vulnerability_ids, prefetch_for_findings from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups diff --git a/dojo/urls.py b/dojo/urls.py index bb2bc081e57..706fe9225f3 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -18,9 +18,6 @@ ConfigurationPermissionViewSet, DevelopmentEnvironmentViewSet, DojoMetaViewSet, - EndpointMetaImporterView, - EndpointStatusViewSet, - EndPointViewSet, ImportLanguagesView, ImportScanView, JiraInstanceViewSet, @@ -45,7 +42,8 @@ from dojo.benchmark.urls import urlpatterns as benchmark_urls from dojo.components.urls import urlpatterns as component_urls from dojo.development_environment.urls import urlpatterns as dev_env_urls -from dojo.endpoint.urls import urlpatterns as endpoint_urls +from dojo.endpoint.api.urls import add_endpoint_urls, register_endpoint_meta_import +from dojo.endpoint.ui.urls import urlpatterns as endpoint_urls from dojo.engagement.api.urls import add_engagement_urls from dojo.engagement.ui.urls import urlpatterns as eng_urls from dojo.finding.api.urls import add_finding_urls @@ -105,7 +103,7 @@ v2_api.register(r"development_environments", DevelopmentEnvironmentViewSet, basename="development_environment") # RBAC endpoints moved to Pro under legacy authorization: # dojo_groups, dojo_group_members → pro/groups, pro/group_members -v2_api.register(r"endpoint_meta_import", EndpointMetaImporterView, basename="endpointmetaimport") +v2_api = register_endpoint_meta_import(v2_api) # RBAC endpoint moved to Pro under legacy authorization: global_roles → pro/global_roles v2_api.register(r"import-languages", ImportLanguagesView, basename="importlanguages") v2_api.register(r"import-scan", ImportScanView, basename="importscan") @@ -151,8 +149,7 @@ v2_api.register(r"endpoints", V3EndpointCompatibleViewSet, basename="endpoint") v2_api.register(r"endpoint_status", V3EndpointStatusCompatibleViewSet, basename="endpoint_status") else: - v2_api.register(r"endpoints", EndPointViewSet, basename="endpoint") - v2_api.register(r"endpoint_status", EndpointStatusViewSet, basename="endpoint_status") + v2_api = add_endpoint_urls(v2_api) v2_api.register(r"celery", CeleryViewSet, basename="celery") # V3 add_asset_urls(v2_api) diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 0b2e5f23f8f..4e55f580cc5 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -41,8 +41,6 @@ AppAnalysisViewSet, ConfigurationPermissionViewSet, DevelopmentEnvironmentViewSet, - EndpointStatusViewSet, - EndPointViewSet, ImportLanguagesView, ImportScanView, JiraInstanceViewSet, @@ -60,6 +58,7 @@ AssetViewSet, ) from dojo.authorization.roles_permissions import Permissions, permission_to_action +from dojo.endpoint.api.views import EndpointStatusViewSet, EndPointViewSet from dojo.engagement.api.views import EngagementViewSet from dojo.finding.api.views import ( BurpRawRequestResponseViewSet, From 4e0254995ef92326ac1b3757d394d4bfd403cbbb Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 9 Jun 2026 00:22:25 +0200 Subject: [PATCH 33/40] refactor(survey,benchmark): extract survey + benchmark modules into dojo// [Phase 1,3,4,5] --- dojo/benchmark/__init__.py | 1 + dojo/benchmark/admin.py | 15 ++ dojo/benchmark/models.py | 100 +++++++ dojo/benchmark/signals.py | 2 +- dojo/benchmark/ui/__init__.py | 0 dojo/benchmark/ui/forms.py | 37 +++ dojo/benchmark/{ => ui}/urls.py | 2 +- dojo/benchmark/{ => ui}/views.py | 6 +- dojo/filters.py | 66 +---- dojo/forms.py | 447 +------------------------------ dojo/models.py | 307 ++------------------- dojo/survey/__init__.py | 1 + dojo/survey/admin.py | 5 + dojo/survey/models.py | 181 +++++++++++++ dojo/survey/ui/__init__.py | 0 dojo/survey/ui/filters.py | 63 +++++ dojo/survey/ui/forms.py | 417 ++++++++++++++++++++++++++++ dojo/survey/{ => ui}/urls.py | 9 +- dojo/survey/{ => ui}/views.py | 34 +-- dojo/urls.py | 4 +- unittests/test_survey_forms.py | 2 +- 21 files changed, 876 insertions(+), 823 deletions(-) create mode 100644 dojo/benchmark/admin.py create mode 100644 dojo/benchmark/models.py create mode 100644 dojo/benchmark/ui/__init__.py create mode 100644 dojo/benchmark/ui/forms.py rename dojo/benchmark/{ => ui}/urls.py (96%) rename dojo/benchmark/{ => ui}/views.py (98%) create mode 100644 dojo/survey/admin.py create mode 100644 dojo/survey/models.py create mode 100644 dojo/survey/ui/__init__.py create mode 100644 dojo/survey/ui/filters.py create mode 100644 dojo/survey/ui/forms.py rename dojo/survey/{ => ui}/urls.py (94%) rename dojo/survey/{ => ui}/views.py (99%) diff --git a/dojo/benchmark/__init__.py b/dojo/benchmark/__init__.py index e69de29bb2d..08cfc4447d9 100644 --- a/dojo/benchmark/__init__.py +++ b/dojo/benchmark/__init__.py @@ -0,0 +1 @@ +import dojo.benchmark.admin # noqa: F401 diff --git a/dojo/benchmark/admin.py b/dojo/benchmark/admin.py new file mode 100644 index 00000000000..288569dc768 --- /dev/null +++ b/dojo/benchmark/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from dojo.benchmark.models import ( + Benchmark_Category, + Benchmark_Product, + Benchmark_Product_Summary, + Benchmark_Requirement, + Benchmark_Type, +) + +admin.site.register(Benchmark_Type) +admin.site.register(Benchmark_Requirement) +admin.site.register(Benchmark_Category) +admin.site.register(Benchmark_Product) +admin.site.register(Benchmark_Product_Summary) diff --git a/dojo/benchmark/models.py b/dojo/benchmark/models.py new file mode 100644 index 00000000000..184e9dc9b2d --- /dev/null +++ b/dojo/benchmark/models.py @@ -0,0 +1,100 @@ +from django.db import models +from django.utils.translation import gettext as _ + + +class Benchmark_Type(models.Model): + name = models.CharField(max_length=300) + version = models.CharField(max_length=15) + source = (("PCI", "PCI"), + ("OWASP ASVS", "OWASP ASVS"), + ("OWASP Mobile ASVS", "OWASP Mobile ASVS")) + benchmark_source = models.CharField(max_length=20, blank=False, + null=True, choices=source, + default="OWASP ASVS") + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + enabled = models.BooleanField(default=True) + + def __str__(self): + return self.name + " " + self.version + + +class Benchmark_Category(models.Model): + type = models.ForeignKey("dojo.Benchmark_Type", verbose_name=_("Benchmark Type"), on_delete=models.CASCADE) + name = models.CharField(max_length=300) + objective = models.TextField() + references = models.TextField(blank=True, null=True) + enabled = models.BooleanField(default=True) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + ": " + self.type.name + + +class Benchmark_Requirement(models.Model): + category = models.ForeignKey("dojo.Benchmark_Category", on_delete=models.CASCADE) + objective_number = models.CharField(max_length=15, null=True, blank=True) + objective = models.TextField() + references = models.TextField(blank=True, null=True) + level_1 = models.BooleanField(default=False) + level_2 = models.BooleanField(default=False) + level_3 = models.BooleanField(default=False) + enabled = models.BooleanField(default=True) + cwe_mapping = models.ManyToManyField("dojo.CWE", blank=True) + testing_guide = models.ManyToManyField("dojo.Testing_Guide", blank=True) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return str(self.objective_number) + ": " + self.category.name + + +class Benchmark_Product(models.Model): + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE) + control = models.ForeignKey("dojo.Benchmark_Requirement", on_delete=models.CASCADE) + pass_fail = models.BooleanField(default=False, verbose_name=_("Pass"), + help_text=_("Does the product meet the requirement?")) + enabled = models.BooleanField(default=True, + help_text=_("Applicable for this specific product.")) + notes = models.ManyToManyField("dojo.Notes", blank=True, editable=False) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("product", "control")] + + def __str__(self): + return self.product.name + ": " + self.control.objective_number + ": " + self.control.category.name + + +class Benchmark_Product_Summary(models.Model): + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE) + benchmark_type = models.ForeignKey("dojo.Benchmark_Type", on_delete=models.CASCADE) + asvs_level = (("Level 1", "Level 1"), + ("Level 2", "Level 2"), + ("Level 3", "Level 3")) + desired_level = models.CharField(max_length=15, + null=False, choices=asvs_level, + default="Level 1") + current_level = models.CharField(max_length=15, blank=True, + null=True, choices=asvs_level, + default="None") + asvs_level_1_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) + asvs_level_1_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 1 Score")) + asvs_level_2_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) + asvs_level_2_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 2 Score")) + asvs_level_3_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) + asvs_level_3_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 3 Score")) + publish = models.BooleanField(default=False, help_text=_("Publish score to Product.")) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("product", "benchmark_type")] + + def __str__(self): + return self.product.name + ": " + self.benchmark_type.name diff --git a/dojo/benchmark/signals.py b/dojo/benchmark/signals.py index 6f87fa320cd..f6d997698a7 100644 --- a/dojo/benchmark/signals.py +++ b/dojo/benchmark/signals.py @@ -3,7 +3,7 @@ from django.db.models.signals import pre_delete from django.dispatch import receiver -from dojo.models import Benchmark_Product +from dojo.benchmark.models import Benchmark_Product from dojo.notes.helper import delete_related_notes logger = logging.getLogger(__name__) diff --git a/dojo/benchmark/ui/__init__.py b/dojo/benchmark/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/benchmark/ui/forms.py b/dojo/benchmark/ui/forms.py new file mode 100644 index 00000000000..c4416af53f9 --- /dev/null +++ b/dojo/benchmark/ui/forms.py @@ -0,0 +1,37 @@ +from django import forms + +from dojo.benchmark.models import ( + Benchmark_Product, + Benchmark_Product_Summary, + Benchmark_Requirement, +) + + +class Benchmark_Product_SummaryForm(forms.ModelForm): + + class Meta: + model = Benchmark_Product_Summary + exclude = ["product", "current_level", "benchmark_type", "asvs_level_1_benchmark", "asvs_level_1_score", "asvs_level_2_benchmark", "asvs_level_2_score", "asvs_level_3_benchmark", "asvs_level_3_score"] + + +class DeleteBenchmarkForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Benchmark_Product_Summary + fields = ["id"] + + +class BenchmarkForm(forms.ModelForm): + + class Meta: + model = Benchmark_Product + exclude = ["product", "control"] + + +class Benchmark_RequirementForm(forms.ModelForm): + + class Meta: + model = Benchmark_Requirement + exclude = [""] diff --git a/dojo/benchmark/urls.py b/dojo/benchmark/ui/urls.py similarity index 96% rename from dojo/benchmark/urls.py rename to dojo/benchmark/ui/urls.py index 849e83c603c..3581ce165ab 100644 --- a/dojo/benchmark/urls.py +++ b/dojo/benchmark/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.benchmark.ui import views urlpatterns = [ re_path( diff --git a/dojo/benchmark/views.py b/dojo/benchmark/ui/views.py similarity index 98% rename from dojo/benchmark/views.py rename to dojo/benchmark/ui/views.py index b1dc065692e..40bfde471d7 100644 --- a/dojo/benchmark/views.py +++ b/dojo/benchmark/ui/views.py @@ -8,15 +8,15 @@ from django.urls import reverse from django.utils.translation import gettext as _ -from dojo.forms import Benchmark_Product_SummaryForm, DeleteBenchmarkForm -from dojo.models import ( +from dojo.benchmark.models import ( Benchmark_Category, Benchmark_Product, Benchmark_Product_Summary, Benchmark_Requirement, Benchmark_Type, - Product, ) +from dojo.benchmark.ui.forms import Benchmark_Product_SummaryForm, DeleteBenchmarkForm +from dojo.models import Product from dojo.templatetags.display_tags import asvs_level from dojo.utils import ( Product_Tab, diff --git a/dojo/filters.py b/dojo/filters.py index 8162da4e0c5..b6ff6815f6f 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -1,19 +1,16 @@ import collections import decimal import logging -import warnings from datetime import datetime, timedelta import six import tagulous from django.apps import apps from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q from django.utils.timezone import now, tzinfo from django.utils.translation import gettext_lazy as _ from django_filters import ( - BooleanFilter, CharFilter, DateFilter, FilterSet, @@ -25,7 +22,6 @@ ) from django_filters import rest_framework as filters from django_filters.filters import ChoiceFilter -from polymorphic.base import ManagerInheritanceWarning # from tagulous.forms import TagWidget # import tagulous @@ -46,21 +42,17 @@ from dojo.models import ( SEVERITY_CHOICES, App_Analysis, - ChoiceQuestion, Development_Environment, DojoMeta, Endpoint, Endpoint_Status, Engagement, - Engagement_Survey, Finding, Note_Type, Product, Product_Type, - Question, Risk_Acceptance, Test, - TextQuestion, Vulnerability_Id, ) from dojo.product_type.queries import get_authorized_product_types @@ -1413,64 +1405,8 @@ class Meta: exclude = [] include = ("name", "is_single", "description") -# ============================== -# Defect Dojo Engaegment Surveys -# ============================== - - -class QuestionnaireFilter(FilterSet): - name = CharFilter(lookup_expr="icontains") - description = CharFilter(lookup_expr="icontains") - active = BooleanFilter() - - class Meta: - model = Engagement_Survey - exclude = ["questions"] - - survey_set = FilterSet - - -class QuestionTypeFilter(ChoiceFilter): - def any(self, qs, name): - return qs.all() - - def text_question(self, qs, name): - return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(TextQuestion)) - - def choice_question(self, qs, name): - return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(ChoiceQuestion)) - - options = { - None: (_("Any"), any), - 1: (_("Text Question"), text_question), - 2: (_("Choice Question"), choice_question), - } - - def __init__(self, *args, **kwargs): - kwargs["choices"] = [ - (key, value[0]) for key, value in six.iteritems(self.options)] - super().__init__(*args, **kwargs) - - def filter(self, qs, value): - try: - value = int(value) - except (ValueError, TypeError): - value = None - return self.options[value][1](self, qs, self.options[value][0]) - - # ApiUserFilter lives in dojo/user/api/filters.py — import from there directly. - -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class QuestionFilter(FilterSet): - text = CharFilter(lookup_expr="icontains") - type = QuestionTypeFilter() - - class Meta: - model = Question - exclude = ["polymorphic_ctype", "created", "modified", "order"] - - question_set = FilterSet +# QuestionnaireFilter, QuestionTypeFilter, QuestionFilter live in dojo/survey/ui/filters.py from dojo.auditlog.filters import LogEntryFilter, PgHistoryFilter # noqa: E402, F401 -- backward compat diff --git a/dojo/forms.py b/dojo/forms.py index e07dfb9517c..999c9bf16d8 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -1,13 +1,8 @@ -import json import logging import re -import warnings from datetime import date, datetime from pathlib import Path -from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout from crum import get_current_user from dateutil.relativedelta import relativedelta from django import forms @@ -15,14 +10,12 @@ from django.contrib.auth.models import Permission from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError -from django.db.models import Count from django.forms import modelformset_factory from django.forms.widgets import Select, Widget from django.utils import timezone from django.utils.dates import MONTHS from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from polymorphic.base import ManagerInheritanceWarning from tagulous.forms import TagField from dojo.endpoint.utils import validate_endpoints_to_add @@ -54,41 +47,28 @@ from dojo.models import ( SEVERITY_CHOICES, Announcement, - Answered_Survey, App_Analysis, - Benchmark_Product, - Benchmark_Product_Summary, - Benchmark_Requirement, Check_List, - Choice, - ChoiceAnswer, - ChoiceQuestion, Development_Environment, Dojo_User, DojoMeta, Endpoint, - Engagement_Survey, FileUpload, Finding, Finding_Group, - General_Survey, Note_Type, Notes, Objects_Product, Product_API_Scan_Configuration, Product_Type, - Question, Regulation, Risk_Acceptance, SLA_Configuration, Test_Type, - TextAnswer, - TextQuestion, User, ) from dojo.product_type.queries import get_authorized_product_types from dojo.tools.factory import get_choices_sorted, requires_file, requires_tool_type -from dojo.user.queries import get_authorized_users from dojo.user.utils import get_configuration_permissions_fields from dojo.utils import ( get_password_requirements_string, @@ -110,14 +90,6 @@ ("out_of_scope", "Out of Scope")) -class MultipleSelectWithPop(forms.SelectMultiple): - def render(self, name, *args, **kwargs): - html = super().render(name, *args, **kwargs) - popup_plus = '
' + html + '
' - - return mark_safe(popup_plus) - - class MonthYearWidget(Widget): """ @@ -953,20 +925,12 @@ class CustomReportOptionsForm(forms.Form): report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) -class Benchmark_Product_SummaryForm(forms.ModelForm): - - class Meta: - model = Benchmark_Product_Summary - exclude = ["product", "current_level", "benchmark_type", "asvs_level_1_benchmark", "asvs_level_1_score", "asvs_level_2_benchmark", "asvs_level_2_score", "asvs_level_3_benchmark", "asvs_level_3_score"] - - -class DeleteBenchmarkForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Benchmark_Product_Summary - fields = ["id"] +from dojo.benchmark.ui.forms import ( # noqa: E402, F401 -- backward compat + Benchmark_Product_SummaryForm, + Benchmark_RequirementForm, + BenchmarkForm, + DeleteBenchmarkForm, +) class RegulationForm(forms.ModelForm): @@ -1066,20 +1030,6 @@ def clean(self): return self.cleaned_data -class BenchmarkForm(forms.ModelForm): - - class Meta: - model = Benchmark_Product - exclude = ["product", "control"] - - -class Benchmark_RequirementForm(forms.ModelForm): - - class Meta: - model = Benchmark_Requirement - exclude = [""] - - from dojo.notifications.ui.forms import ( # noqa: E402, F401 -- backward compat DeleteNotificationsWebhookForm, NotificationsForm, @@ -1124,391 +1074,6 @@ def __init__(self, *args, **kwargs): self.fields["style"].disabled = True -# ============================== -# Defect Dojo Engaegment Surveys -# ============================== - -# List of validator_name:func_name -# Show in admin a multichoice list of validator names -# pass this to form using field_name='validator_name' ? -class QuestionForm(forms.Form): - - """Base class for a Question""" - - def __init__(self, *args, **kwargs): - self.helper = FormHelper() - self.helper.form_method = "post" - - # If true crispy-forms will render a
..
tags - self.helper.form_tag = kwargs.pop("form_tag", True) - - self.engagement_survey = kwargs.get("engagement_survey") - - self.answered_survey = kwargs.get("answered_survey") - if not self.answered_survey: - del kwargs["engagement_survey"] - else: - del kwargs["answered_survey"] - - self.helper.form_class = kwargs.get("form_class", "") - - self.question = kwargs.pop("question", None) - - if not self.question: - msg = "Need a question to render" - raise ValueError(msg) - - super().__init__(*args, **kwargs) - - -class TextQuestionForm(QuestionForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # work out initial data - - initial_answer = TextAnswer.objects.filter( - answered_survey=self.answered_survey, - question=self.question, - ) - - initial_answer = initial_answer[0].answer if initial_answer.exists() else "" - - self.fields["answer"] = forms.CharField( - label=self.question.text, - widget=forms.Textarea(attrs={"rows": 3, "cols": 10}), - required=not self.question.optional, - initial=initial_answer, - ) - - def save(self): - if not self.is_valid(): - msg = "form is not valid" - raise forms.ValidationError(msg) - - answer = self.cleaned_data.get("answer") - - if not answer: - if self.fields["answer"].required: - msg = "Required" - raise forms.ValidationError(msg) - return - - text_answer, created = TextAnswer.objects.get_or_create( - answered_survey=self.answered_survey, - question=self.question, - ) - - if created: - text_answer.answered_survey = self.answered_survey - text_answer.answer = answer - text_answer.save() - - -class ChoiceQuestionForm(QuestionForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - choices = [(c.id, c.label) for c in self.question.choices.all()] - - # initial values - - initial_choices = [] - choice_answer = ChoiceAnswer.objects.filter( - answered_survey=self.answered_survey, - question=self.question, - ).annotate(a=Count("answer")).filter(a__gt=0) - - # we have ChoiceAnswer instance - if choice_answer: - choice_answer = choice_answer[0] - initial_choices = list(choice_answer.answer.all().values_list("id", flat=True)) - if self.question.multichoice is False: - initial_choices = initial_choices[0] - - # default classes - widget = forms.RadioSelect - field_type = forms.ChoiceField - inline_type = InlineRadios - - if self.question.multichoice: - field_type = forms.MultipleChoiceField - widget = forms.CheckboxSelectMultiple - inline_type = InlineCheckboxes - - field = field_type( - label=self.question.text, - required=not self.question.optional, - choices=choices, - initial=initial_choices, - widget=widget, - ) - - self.fields["answer"] = field - - # Render choice buttons inline - self.helper.layout = Layout( - inline_type("answer"), - ) - - def clean_answer(self): - real_answer = self.cleaned_data.get("answer") - - # for single choice questions, the selected answer is a single string - if not isinstance(real_answer, list): - real_answer = [real_answer] - return real_answer - - def save(self): - if not self.is_valid(): - msg = "Form is not valid" - raise forms.ValidationError(msg) - - real_answer = self.cleaned_data.get("answer") - - if not real_answer: - if self.fields["answer"].required: - msg = "Required" - raise forms.ValidationError(msg) - return - - choices = Choice.objects.filter(id__in=real_answer) - - # find ChoiceAnswer and filter in answer ! - choice_answer = ChoiceAnswer.objects.filter( - answered_survey=self.answered_survey, - question=self.question, - ) - - # we have ChoiceAnswer instance - if choice_answer: - choice_answer = choice_answer[0] - - if not choice_answer: - # create a ChoiceAnswer - choice_answer = ChoiceAnswer.objects.create( - answered_survey=self.answered_survey, - question=self.question, - ) - - # re save out the choices - choice_answer.answered_survey = self.answered_survey - choice_answer.answer.set(choices) - choice_answer.save() - - -class Add_Questionnaire_Form(forms.ModelForm): - survey = forms.ModelChoiceField( - queryset=Engagement_Survey.objects.all(), - required=True, - widget=forms.widgets.Select(), - help_text="Select the Questionnaire to add.") - - class Meta: - model = Answered_Survey - exclude = ("responder", - "completed", - "engagement", - "answered_on", - "assignee") - - -class AddGeneralQuestionnaireForm(forms.ModelForm): - survey = forms.ModelChoiceField( - queryset=Engagement_Survey.objects.all(), - required=True, - widget=forms.widgets.Select(), - help_text="Select the Questionnaire to add.") - expiration = forms.DateField(widget=forms.TextInput( - attrs={"class": "datepicker", "autocomplete": "off"})) - - class Meta: - model = General_Survey - exclude = ("num_responses", "generated") - - # date can only be today or in the past, not the future - def clean_expiration(self): - expiration = self.cleaned_data.get("expiration", None) - if expiration: - today = datetime.today().date() - if expiration < today: - msg = "The expiration cannot be in the past" - raise forms.ValidationError(msg) - if expiration == today: - msg = "The expiration cannot be today" - raise forms.ValidationError(msg) - return timezone.make_aware( - datetime.combine(expiration, datetime.min.time()), - ) - msg = "An expiration for the survey must be supplied" - raise forms.ValidationError(msg) - - -class Delete_Questionnaire_Form(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Answered_Survey - fields = ["id"] - - -class DeleteGeneralQuestionnaireForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = General_Survey - fields = ["id"] - - -class Delete_Eng_Survey_Form(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Engagement_Survey - fields = ["id"] - - -class CreateQuestionnaireForm(forms.ModelForm): - class Meta: - model = Engagement_Survey - exclude = ["questions"] - - -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class EditQuestionnaireQuestionsForm(forms.ModelForm): - questions = forms.ModelMultipleChoiceField( - Question.polymorphic.all(), - required=True, - help_text="Select questions to include on this questionnaire. Field can be used to search available questions.", - widget=MultipleSelectWithPop(attrs={"size": "11"})) - - class Meta: - model = Engagement_Survey - exclude = ["name", "description", "active"] - - -class CreateQuestionForm(forms.Form): - type = forms.ChoiceField( - choices=(("---", "-----"), ("text", "Text"), ("choice", "Choice"))) - order = forms.IntegerField( - min_value=1, - widget=forms.TextInput(attrs={"data-type": "both"}), - help_text="The order the question will appear on the questionnaire") - optional = forms.BooleanField(help_text="If selected, user doesn't have to answer this question", - initial=False, - required=False, - widget=forms.CheckboxInput(attrs={"data-type": "both"})) - text = forms.CharField(widget=forms.Textarea(attrs={"data-type": "text"}), - label="Question Text", - help_text="The actual question.") - - -class CreateTextQuestionForm(forms.Form): - class Meta: - model = TextQuestion - exclude = ["order", "optional"] - - -class MultiWidgetBasic(forms.widgets.MultiWidget): - def __init__(self, attrs=None): - widgets = [forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"}), - forms.TextInput(attrs={"data-type": "choice"})] - super().__init__(widgets, attrs) - - def decompress(self, value): - if value: - return json.loads(value) - return [None, None, None, None, None, None] - - def format_output(self, rendered_widgets): - return "
".join(rendered_widgets) - - -class MultiExampleField(forms.fields.MultiValueField): - widget = MultiWidgetBasic - - def __init__(self, *args, **kwargs): - list_fields = [forms.fields.CharField(required=True), - forms.fields.CharField(required=True), - forms.fields.CharField(required=False), - forms.fields.CharField(required=False), - forms.fields.CharField(required=False), - forms.fields.CharField(required=False)] - super().__init__(list_fields, *args, **kwargs) - - def compress(self, values): - return json.dumps(values) - - -class CreateChoiceQuestionForm(forms.Form): - multichoice = forms.BooleanField(required=False, - initial=False, - widget=forms.CheckboxInput(attrs={"data-type": "choice"}), - help_text="Can more than one choice can be selected?") - - answer_choices = MultiExampleField(required=False, widget=MultiWidgetBasic(attrs={"data-type": "choice"})) - - class Meta: - model = ChoiceQuestion - exclude = ["order", "optional", "choices"] - - -class EditQuestionForm(forms.ModelForm): - class Meta: - model = Question - exclude = [] - - -class EditTextQuestionForm(EditQuestionForm): - class Meta: - model = TextQuestion - exclude = [] - - -class EditChoiceQuestionForm(EditQuestionForm): - choices = forms.ModelMultipleChoiceField( - Choice.objects.all(), - required=True, - help_text="Select choices to include on this question. Field can be used to search available choices.", - widget=MultipleSelectWithPop(attrs={"size": "11"})) - - class Meta: - model = ChoiceQuestion - exclude = [] - - -class AddChoicesForm(forms.ModelForm): - class Meta: - model = Choice - exclude = [] - - -class AssignUserForm(forms.ModelForm): - assignee = forms.CharField(required=False, - widget=forms.widgets.HiddenInput()) - - def __init__(self, *args, **kwargs): - assignee = None - if "assignee" in kwargs: - assignee = kwargs.pop("asignees") - super().__init__(*args, **kwargs) - if assignee is None: - self.fields["assignee"] = forms.ModelChoiceField(queryset=get_authorized_users("view"), empty_label="Not Assigned", required=False) - else: - self.fields["assignee"].initial = assignee - - class Meta: - model = Answered_Survey - exclude = ["engagement", "survey", "responder", "completed", "answered_on"] - - class ConfigurationPermissionsForm(forms.Form): def __init__(self, *args, **kwargs): diff --git a/dojo/models.py b/dojo/models.py index 7e6052ffe0c..651bebad557 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -1,6 +1,5 @@ import copy import logging -import warnings from datetime import timedelta from pathlib import Path from uuid import uuid4 @@ -20,10 +19,6 @@ from django.utils.deconstruct import deconstructible from django.utils.timezone import now from django.utils.translation import gettext as _ -from django_extensions.db.models import TimeStampedModel -from polymorphic.base import ManagerInheritanceWarning -from polymorphic.managers import PolymorphicManager -from polymorphic.models import PolymorphicModel from tagulous.models import TagField from tagulous.models.managers import FakeTagRelatedManager # noqa: F401 -- backward compat re-export @@ -552,7 +547,7 @@ class Meta: from dojo.finding.models import ( # noqa: E402 -- re-export; class-body FKs below reference these - CWE, + CWE, # noqa: F401 -- re-export BurpRawRequestResponse, # noqa: F401 -- re-export Finding, Finding_Group, # noqa: F401 -- re-export @@ -905,278 +900,26 @@ def __str__(self): return self.testing_guide_category.name + ": " + self.name -class Benchmark_Type(models.Model): - name = models.CharField(max_length=300) - version = models.CharField(max_length=15) - source = (("PCI", "PCI"), - ("OWASP ASVS", "OWASP ASVS"), - ("OWASP Mobile ASVS", "OWASP Mobile ASVS")) - benchmark_source = models.CharField(max_length=20, blank=False, - null=True, choices=source, - default="OWASP ASVS") - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - enabled = models.BooleanField(default=True) - - def __str__(self): - return self.name + " " + self.version - - -class Benchmark_Category(models.Model): - type = models.ForeignKey(Benchmark_Type, verbose_name=_("Benchmark Type"), on_delete=models.CASCADE) - name = models.CharField(max_length=300) - objective = models.TextField() - references = models.TextField(blank=True, null=True) - enabled = models.BooleanField(default=True) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ("name",) - - def __str__(self): - return self.name + ": " + self.type.name - - -class Benchmark_Requirement(models.Model): - category = models.ForeignKey(Benchmark_Category, on_delete=models.CASCADE) - objective_number = models.CharField(max_length=15, null=True, blank=True) - objective = models.TextField() - references = models.TextField(blank=True, null=True) - level_1 = models.BooleanField(default=False) - level_2 = models.BooleanField(default=False) - level_3 = models.BooleanField(default=False) - enabled = models.BooleanField(default=True) - cwe_mapping = models.ManyToManyField(CWE, blank=True) - testing_guide = models.ManyToManyField(Testing_Guide, blank=True) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - def __str__(self): - return str(self.objective_number) + ": " + self.category.name - - -class Benchmark_Product(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) - control = models.ForeignKey(Benchmark_Requirement, on_delete=models.CASCADE) - pass_fail = models.BooleanField(default=False, verbose_name=_("Pass"), - help_text=_("Does the product meet the requirement?")) - enabled = models.BooleanField(default=True, - help_text=_("Applicable for this specific product.")) - notes = models.ManyToManyField(Notes, blank=True, editable=False) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = [("product", "control")] - - def __str__(self): - return self.product.name + ": " + self.control.objective_number + ": " + self.control.category.name - - -class Benchmark_Product_Summary(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) - benchmark_type = models.ForeignKey(Benchmark_Type, on_delete=models.CASCADE) - asvs_level = (("Level 1", "Level 1"), - ("Level 2", "Level 2"), - ("Level 3", "Level 3")) - desired_level = models.CharField(max_length=15, - null=False, choices=asvs_level, - default="Level 1") - current_level = models.CharField(max_length=15, blank=True, - null=True, choices=asvs_level, - default="None") - asvs_level_1_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) - asvs_level_1_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 1 Score")) - asvs_level_2_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) - asvs_level_2_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 2 Score")) - asvs_level_3_benchmark = models.IntegerField(null=False, default=0, help_text=_("Total number of active benchmarks for this application.")) - asvs_level_3_score = models.IntegerField(null=False, default=0, help_text=_("ASVS Level 3 Score")) - publish = models.BooleanField(default=False, help_text=_("Publish score to Product.")) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True) - - class Meta: - unique_together = [("product", "benchmark_type")] - - def __str__(self): - return self.product.name + ": " + self.benchmark_type.name - - -# ========================== -# Defect Dojo Engaegment Surveys -# ============================== -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class Question(PolymorphicModel, TimeStampedModel): - - """Represents a question.""" - - class Meta: - ordering = ["order"] - - order = models.PositiveIntegerField(default=1, - help_text=_("The render order")) - - optional = models.BooleanField( - default=False, - help_text=_("If selected, user doesn't have to answer this question")) - - text = models.TextField(blank=False, help_text=_("The question text"), default="") - objects = models.Manager() - polymorphic = PolymorphicManager() - - def __str__(self): - return self.text - - -class TextQuestion(Question): - - """Question with a text answer""" - - objects = PolymorphicManager() - - def get_form(self): - """Returns the form for this model""" - from .forms import TextQuestionForm # noqa: PLC0415 - return TextQuestionForm - - -class Choice(TimeStampedModel): - - """Model to store the choices for multi choice questions""" - - order = models.PositiveIntegerField(default=1) - - label = models.TextField(default="") - - class Meta: - ordering = ["order"] - - def __str__(self): - return self.label - - -class ChoiceQuestion(Question): - - """ - Question with answers that are chosen from a list of choices defined - by the user. - """ - - multichoice = models.BooleanField(default=False, - help_text=_("Select one or more")) - choices = models.ManyToManyField(Choice) - objects = PolymorphicManager() - - def get_form(self): - """Returns the form for this model""" - from .forms import ChoiceQuestionForm # noqa: PLC0415 - return ChoiceQuestionForm - - -# meant to be a abstract survey, identified by name for purpose -class Engagement_Survey(models.Model): - name = models.CharField(max_length=200, null=False, blank=False, - editable=True, default="") - description = models.TextField(editable=True, default="") - questions = models.ManyToManyField(Question) - active = models.BooleanField(default=True) - - class Meta: - verbose_name = _("Engagement Survey") - verbose_name_plural = "Engagement Surveys" - ordering = ("-active", "name") - - def __str__(self): - return self.name - - -# meant to be an answered survey tied to an engagement - -class Answered_Survey(models.Model): - # tie this to a specific engagement - engagement = models.ForeignKey(Engagement, related_name="engagement+", - null=True, blank=False, editable=True, - on_delete=models.CASCADE) - # what surveys have been answered - survey = models.ForeignKey(Engagement_Survey, on_delete=models.CASCADE) - assignee = models.ForeignKey(Dojo_User, related_name="assignee", - null=True, blank=True, editable=True, - default=None, on_delete=models.RESTRICT) - # who answered it - responder = models.ForeignKey(Dojo_User, related_name="responder", - null=True, blank=True, editable=True, - default=None, on_delete=models.RESTRICT) - completed = models.BooleanField(default=False) - answered_on = models.DateField(null=True) - - class Meta: - verbose_name = _("Answered Engagement Survey") - verbose_name_plural = _("Answered Engagement Surveys") - - def __str__(self): - return self.survey.name - - -def default_expiration(): - return timezone.now() + timedelta(days=7) - - -class General_Survey(models.Model): - survey = models.ForeignKey(Engagement_Survey, on_delete=models.CASCADE) - num_responses = models.IntegerField(default=0) - generated = models.DateTimeField(auto_now_add=True, null=True) - expiration = models.DateTimeField(default=default_expiration) - - class Meta: - verbose_name = _("General Engagement Survey") - verbose_name_plural = _("General Engagement Surveys") - - def __str__(self): - return self.survey.name - - def clean(self): - if self.expiration and timezone.is_naive(self.expiration): - self.expiration = timezone.make_aware(self.expiration) - - -with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): - class Answer(PolymorphicModel, TimeStampedModel): - - """Base Answer model""" - - question = models.ForeignKey(Question, on_delete=models.CASCADE) - - answered_survey = models.ForeignKey(Answered_Survey, - null=False, - blank=False, - on_delete=models.CASCADE) - objects = models.Manager() - polymorphic = PolymorphicManager() - - -class TextAnswer(Answer): - answer = models.TextField( - blank=False, - help_text=_("The answer text"), - default="") - objects = PolymorphicManager() - - def __str__(self): - return self.answer - - -class ChoiceAnswer(Answer): - answer = models.ManyToManyField( - Choice, - help_text=_("The selected choices as the answer")) - objects = PolymorphicManager() - - def __str__(self): - if len(self.answer.all()): - return str(self.answer.all()[0]) - return "No Response" - +from dojo.benchmark.models import ( # noqa: E402, I001 -- re-export; backward compat + Benchmark_Category, # noqa: F401 + Benchmark_Product, # noqa: F401 + Benchmark_Product_Summary, # noqa: F401 + Benchmark_Requirement, # noqa: F401 + Benchmark_Type, # noqa: F401 +) +from dojo.survey.models import ( # noqa: E402 -- re-export; backward compat + Answer, # noqa: F401 + Answered_Survey, # noqa: F401 + Choice, # noqa: F401 + ChoiceAnswer, # noqa: F401 + ChoiceQuestion, # noqa: F401 + Engagement_Survey, # noqa: F401 + General_Survey, # noqa: F401 + Question, # noqa: F401 + TextAnswer, # noqa: F401 + TextQuestion, # noqa: F401 + default_expiration, # noqa: F401 +) # Audit logging registration is now handled in auditlog.py and configured in apps.py # This allows for conditional registration of either django-auditlog or django-pghistory @@ -1198,13 +941,6 @@ def __str__(self): tagulous.admin.register(App_Analysis.tags) tagulous.admin.register(Objects_Product.tags) -# Benchmarks -admin.site.register(Benchmark_Type) -admin.site.register(Benchmark_Requirement) -admin.site.register(Benchmark_Category) -admin.site.register(Benchmark_Product) -admin.site.register(Benchmark_Product_Summary) - # Testing admin.site.register(Testing_Guide_Category) admin.site.register(Testing_Guide) @@ -1255,4 +991,3 @@ def __str__(self): admin.site.register(Announcement) admin.site.register(UserAnnouncement) admin.site.register(BannerConf) -admin.site.register(General_Survey) diff --git a/dojo/survey/__init__.py b/dojo/survey/__init__.py index e69de29bb2d..dcf96374631 100644 --- a/dojo/survey/__init__.py +++ b/dojo/survey/__init__.py @@ -0,0 +1 @@ +import dojo.survey.admin # noqa: F401 diff --git a/dojo/survey/admin.py b/dojo/survey/admin.py new file mode 100644 index 00000000000..15b76ad2c8a --- /dev/null +++ b/dojo/survey/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.survey.models import General_Survey + +admin.site.register(General_Survey) diff --git a/dojo/survey/models.py b/dojo/survey/models.py new file mode 100644 index 00000000000..6e8b98a9cf7 --- /dev/null +++ b/dojo/survey/models.py @@ -0,0 +1,181 @@ +import warnings +from datetime import timedelta + +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext as _ +from django_extensions.db.models import TimeStampedModel +from polymorphic.base import ManagerInheritanceWarning +from polymorphic.managers import PolymorphicManager +from polymorphic.models import PolymorphicModel + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class Question(PolymorphicModel, TimeStampedModel): + + """Represents a question.""" + + class Meta: + ordering = ["order"] + + order = models.PositiveIntegerField(default=1, + help_text=_("The render order")) + + optional = models.BooleanField( + default=False, + help_text=_("If selected, user doesn't have to answer this question")) + + text = models.TextField(blank=False, help_text=_("The question text"), default="") + objects = models.Manager() + polymorphic = PolymorphicManager() + + def __str__(self): + return self.text + + +class TextQuestion(Question): + + """Question with a text answer""" + + objects = PolymorphicManager() + + def get_form(self): + """Returns the form for this model""" + from dojo.survey.ui.forms import TextQuestionForm # noqa: PLC0415 -- lazy import, avoids circular dependency + return TextQuestionForm + + +class Choice(TimeStampedModel): + + """Model to store the choices for multi choice questions""" + + order = models.PositiveIntegerField(default=1) + + label = models.TextField(default="") + + class Meta: + ordering = ["order"] + + def __str__(self): + return self.label + + +class ChoiceQuestion(Question): + + """ + Question with answers that are chosen from a list of choices defined + by the user. + """ + + multichoice = models.BooleanField(default=False, + help_text=_("Select one or more")) + choices = models.ManyToManyField("dojo.Choice") + objects = PolymorphicManager() + + def get_form(self): + """Returns the form for this model""" + from dojo.survey.ui.forms import ChoiceQuestionForm # noqa: PLC0415 -- lazy import, avoids circular dependency + return ChoiceQuestionForm + + +# meant to be a abstract survey, identified by name for purpose +class Engagement_Survey(models.Model): + name = models.CharField(max_length=200, null=False, blank=False, + editable=True, default="") + description = models.TextField(editable=True, default="") + questions = models.ManyToManyField("dojo.Question") + active = models.BooleanField(default=True) + + class Meta: + verbose_name = _("Engagement Survey") + verbose_name_plural = "Engagement Surveys" + ordering = ("-active", "name") + + def __str__(self): + return self.name + + +# meant to be an answered survey tied to an engagement + +class Answered_Survey(models.Model): + # tie this to a specific engagement + engagement = models.ForeignKey("dojo.Engagement", related_name="engagement+", + null=True, blank=False, editable=True, + on_delete=models.CASCADE) + # what surveys have been answered + survey = models.ForeignKey("dojo.Engagement_Survey", on_delete=models.CASCADE) + assignee = models.ForeignKey("dojo.Dojo_User", related_name="assignee", + null=True, blank=True, editable=True, + default=None, on_delete=models.RESTRICT) + # who answered it + responder = models.ForeignKey("dojo.Dojo_User", related_name="responder", + null=True, blank=True, editable=True, + default=None, on_delete=models.RESTRICT) + completed = models.BooleanField(default=False) + answered_on = models.DateField(null=True) + + class Meta: + verbose_name = _("Answered Engagement Survey") + verbose_name_plural = _("Answered Engagement Surveys") + + def __str__(self): + return self.survey.name + + +def default_expiration(): + return timezone.now() + timedelta(days=7) + + +class General_Survey(models.Model): + survey = models.ForeignKey("dojo.Engagement_Survey", on_delete=models.CASCADE) + num_responses = models.IntegerField(default=0) + generated = models.DateTimeField(auto_now_add=True, null=True) + expiration = models.DateTimeField(default=default_expiration) + + class Meta: + verbose_name = _("General Engagement Survey") + verbose_name_plural = _("General Engagement Surveys") + + def __str__(self): + return self.survey.name + + def clean(self): + if self.expiration and timezone.is_naive(self.expiration): + self.expiration = timezone.make_aware(self.expiration) + + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class Answer(PolymorphicModel, TimeStampedModel): + + """Base Answer model""" + + question = models.ForeignKey("dojo.Question", on_delete=models.CASCADE) + + answered_survey = models.ForeignKey("dojo.Answered_Survey", + null=False, + blank=False, + on_delete=models.CASCADE) + objects = models.Manager() + polymorphic = PolymorphicManager() + + +class TextAnswer(Answer): + answer = models.TextField( + blank=False, + help_text=_("The answer text"), + default="") + objects = PolymorphicManager() + + def __str__(self): + return self.answer + + +class ChoiceAnswer(Answer): + answer = models.ManyToManyField( + "dojo.Choice", + help_text=_("The selected choices as the answer")) + objects = PolymorphicManager() + + def __str__(self): + if len(self.answer.all()): + return str(self.answer.all()[0]) + return "No Response" diff --git a/dojo/survey/ui/__init__.py b/dojo/survey/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/survey/ui/filters.py b/dojo/survey/ui/filters.py new file mode 100644 index 00000000000..c1cac668f04 --- /dev/null +++ b/dojo/survey/ui/filters.py @@ -0,0 +1,63 @@ +import warnings + +import six +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext_lazy as _ +from django_filters import BooleanFilter, CharFilter, FilterSet +from django_filters.filters import ChoiceFilter +from polymorphic.base import ManagerInheritanceWarning + +from dojo.survey.models import ChoiceQuestion, Engagement_Survey, Question, TextQuestion + + +class QuestionnaireFilter(FilterSet): + name = CharFilter(lookup_expr="icontains") + description = CharFilter(lookup_expr="icontains") + active = BooleanFilter() + + class Meta: + model = Engagement_Survey + exclude = ["questions"] + + survey_set = FilterSet + + +class QuestionTypeFilter(ChoiceFilter): + def any(self, qs, name): + return qs.all() + + def text_question(self, qs, name): + return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(TextQuestion)) + + def choice_question(self, qs, name): + return qs.filter(polymorphic_ctype=ContentType.objects.get_for_model(ChoiceQuestion)) + + options = { + None: (_("Any"), any), # noqa: A003 -- shadows builtin; matches original dojo/filters.py pattern + 1: (_("Text Question"), text_question), + 2: (_("Choice Question"), choice_question), + } + + def __init__(self, *args, **kwargs): + kwargs["choices"] = [ + (key, value[0]) for key, value in six.iteritems(self.options)] + super().__init__(*args, **kwargs) + + def filter(self, qs, value): + try: + value = int(value) + except (ValueError, TypeError): + value = None + return self.options[value][1](self, qs, self.options[value][0]) + + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class QuestionFilter(FilterSet): + text = CharFilter(lookup_expr="icontains") + type = QuestionTypeFilter() + + class Meta: + model = Question + exclude = ["polymorphic_ctype", "created", "modified", "order"] + + question_set = FilterSet diff --git a/dojo/survey/ui/forms.py b/dojo/survey/ui/forms.py new file mode 100644 index 00000000000..72d1898f4b0 --- /dev/null +++ b/dojo/survey/ui/forms.py @@ -0,0 +1,417 @@ +import json +import warnings +from datetime import datetime + +from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout +from django import forms +from django.db.models import Count +from django.utils import timezone +from polymorphic.base import ManagerInheritanceWarning + +from dojo.survey.models import ( + Answered_Survey, + Choice, + ChoiceAnswer, + ChoiceQuestion, + Engagement_Survey, + General_Survey, + Question, + TextAnswer, + TextQuestion, +) +from dojo.user.queries import get_authorized_users + + +class MultipleSelectWithPop(forms.SelectMultiple): + def render(self, name, *args, **kwargs): + from django.utils.safestring import mark_safe # noqa: PLC0415 -- lazy import, avoids circular dependency + html = super().render(name, *args, **kwargs) + popup_plus = '
' + html + '
' + return mark_safe(popup_plus) + + +# ============================== +# Defect Dojo Engaegment Surveys +# ============================== + +# List of validator_name:func_name +# Show in admin a multichoice list of validator names +# pass this to form using field_name='validator_name' ? +class QuestionForm(forms.Form): + + """Base class for a Question""" + + def __init__(self, *args, **kwargs): + self.helper = FormHelper() + self.helper.form_method = "post" + + # If true crispy-forms will render a
..
tags + self.helper.form_tag = kwargs.pop("form_tag", True) + + self.engagement_survey = kwargs.get("engagement_survey") + + self.answered_survey = kwargs.get("answered_survey") + if not self.answered_survey: + del kwargs["engagement_survey"] + else: + del kwargs["answered_survey"] + + self.helper.form_class = kwargs.get("form_class", "") + + self.question = kwargs.pop("question", None) + + if not self.question: + msg = "Need a question to render" + raise ValueError(msg) + + super().__init__(*args, **kwargs) + + +class TextQuestionForm(QuestionForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # work out initial data + + initial_answer = TextAnswer.objects.filter( + answered_survey=self.answered_survey, + question=self.question, + ) + + initial_answer = initial_answer[0].answer if initial_answer.exists() else "" + + self.fields["answer"] = forms.CharField( + label=self.question.text, + widget=forms.Textarea(attrs={"rows": 3, "cols": 10}), + required=not self.question.optional, + initial=initial_answer, + ) + + def save(self): + if not self.is_valid(): + msg = "form is not valid" + raise forms.ValidationError(msg) + + answer = self.cleaned_data.get("answer") + + if not answer: + if self.fields["answer"].required: + msg = "Required" + raise forms.ValidationError(msg) + return + + text_answer, created = TextAnswer.objects.get_or_create( + answered_survey=self.answered_survey, + question=self.question, + ) + + if created: + text_answer.answered_survey = self.answered_survey + text_answer.answer = answer + text_answer.save() + + +class ChoiceQuestionForm(QuestionForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + choices = [(c.id, c.label) for c in self.question.choices.all()] + + # initial values + + initial_choices = [] + choice_answer = ChoiceAnswer.objects.filter( + answered_survey=self.answered_survey, + question=self.question, + ).annotate(a=Count("answer")).filter(a__gt=0) + + # we have ChoiceAnswer instance + if choice_answer: + choice_answer = choice_answer[0] + initial_choices = list(choice_answer.answer.all().values_list("id", flat=True)) + if self.question.multichoice is False: + initial_choices = initial_choices[0] + + # default classes + widget = forms.RadioSelect + field_type = forms.ChoiceField + inline_type = InlineRadios + + if self.question.multichoice: + field_type = forms.MultipleChoiceField + widget = forms.CheckboxSelectMultiple + inline_type = InlineCheckboxes + + field = field_type( + label=self.question.text, + required=not self.question.optional, + choices=choices, + initial=initial_choices, + widget=widget, + ) + + self.fields["answer"] = field + + # Render choice buttons inline + self.helper.layout = Layout( + inline_type("answer"), + ) + + def clean_answer(self): + real_answer = self.cleaned_data.get("answer") + + # for single choice questions, the selected answer is a single string + if not isinstance(real_answer, list): + real_answer = [real_answer] + return real_answer + + def save(self): + if not self.is_valid(): + msg = "Form is not valid" + raise forms.ValidationError(msg) + + real_answer = self.cleaned_data.get("answer") + + if not real_answer: + if self.fields["answer"].required: + msg = "Required" + raise forms.ValidationError(msg) + return + + choices = Choice.objects.filter(id__in=real_answer) + + # find ChoiceAnswer and filter in answer ! + choice_answer = ChoiceAnswer.objects.filter( + answered_survey=self.answered_survey, + question=self.question, + ) + + # we have ChoiceAnswer instance + if choice_answer: + choice_answer = choice_answer[0] + + if not choice_answer: + # create a ChoiceAnswer + choice_answer = ChoiceAnswer.objects.create( + answered_survey=self.answered_survey, + question=self.question, + ) + + # re save out the choices + choice_answer.answered_survey = self.answered_survey + choice_answer.answer.set(choices) + choice_answer.save() + + +class Add_Questionnaire_Form(forms.ModelForm): + survey = forms.ModelChoiceField( + queryset=Engagement_Survey.objects.all(), + required=True, + widget=forms.widgets.Select(), + help_text="Select the Questionnaire to add.") + + class Meta: + model = Answered_Survey + exclude = ("responder", + "completed", + "engagement", + "answered_on", + "assignee") + + +class AddGeneralQuestionnaireForm(forms.ModelForm): + survey = forms.ModelChoiceField( + queryset=Engagement_Survey.objects.all(), + required=True, + widget=forms.widgets.Select(), + help_text="Select the Questionnaire to add.") + expiration = forms.DateField(widget=forms.TextInput( + attrs={"class": "datepicker", "autocomplete": "off"})) + + class Meta: + model = General_Survey + exclude = ("num_responses", "generated") + + # date can only be today or in the past, not the future + def clean_expiration(self): + expiration = self.cleaned_data.get("expiration", None) + if expiration: + today = datetime.today().date() + if expiration < today: + msg = "The expiration cannot be in the past" + raise forms.ValidationError(msg) + if expiration == today: + msg = "The expiration cannot be today" + raise forms.ValidationError(msg) + return timezone.make_aware( + datetime.combine(expiration, datetime.min.time()), + ) + msg = "An expiration for the survey must be supplied" + raise forms.ValidationError(msg) + + +class Delete_Questionnaire_Form(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Answered_Survey + fields = ["id"] + + +class DeleteGeneralQuestionnaireForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = General_Survey + fields = ["id"] + + +class Delete_Eng_Survey_Form(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Engagement_Survey + fields = ["id"] + + +class CreateQuestionnaireForm(forms.ModelForm): + class Meta: + model = Engagement_Survey + exclude = ["questions"] + + +with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): + class EditQuestionnaireQuestionsForm(forms.ModelForm): + questions = forms.ModelMultipleChoiceField( + Question.polymorphic.all(), + required=True, + help_text="Select questions to include on this questionnaire. Field can be used to search available questions.", + widget=MultipleSelectWithPop(attrs={"size": "11"})) + + class Meta: + model = Engagement_Survey + exclude = ["name", "description", "active"] + + +class CreateQuestionForm(forms.Form): + type = forms.ChoiceField( + choices=(("---", "-----"), ("text", "Text"), ("choice", "Choice"))) + order = forms.IntegerField( + min_value=1, + widget=forms.TextInput(attrs={"data-type": "both"}), + help_text="The order the question will appear on the questionnaire") + optional = forms.BooleanField(help_text="If selected, user doesn't have to answer this question", + initial=False, + required=False, + widget=forms.CheckboxInput(attrs={"data-type": "both"})) + text = forms.CharField(widget=forms.Textarea(attrs={"data-type": "text"}), + label="Question Text", + help_text="The actual question.") + + +class CreateTextQuestionForm(forms.Form): + class Meta: + model = TextQuestion + exclude = ["order", "optional"] + + +class MultiWidgetBasic(forms.widgets.MultiWidget): + def __init__(self, attrs=None): + widgets = [forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"}), + forms.TextInput(attrs={"data-type": "choice"})] + super().__init__(widgets, attrs) + + def decompress(self, value): + if value: + return json.loads(value) + return [None, None, None, None, None, None] + + def format_output(self, rendered_widgets): + return "
".join(rendered_widgets) + + +class MultiExampleField(forms.fields.MultiValueField): + widget = MultiWidgetBasic + + def __init__(self, *args, **kwargs): + list_fields = [forms.fields.CharField(required=True), + forms.fields.CharField(required=True), + forms.fields.CharField(required=False), + forms.fields.CharField(required=False), + forms.fields.CharField(required=False), + forms.fields.CharField(required=False)] + super().__init__(list_fields, *args, **kwargs) + + def compress(self, values): + return json.dumps(values) + + +class CreateChoiceQuestionForm(forms.Form): + multichoice = forms.BooleanField(required=False, + initial=False, + widget=forms.CheckboxInput(attrs={"data-type": "choice"}), + help_text="Can more than one choice can be selected?") + + answer_choices = MultiExampleField(required=False, widget=MultiWidgetBasic(attrs={"data-type": "choice"})) + + class Meta: + model = ChoiceQuestion + exclude = ["order", "optional", "choices"] + + +class EditQuestionForm(forms.ModelForm): + class Meta: + model = Question + exclude = [] + + +class EditTextQuestionForm(EditQuestionForm): + class Meta: + model = TextQuestion + exclude = [] + + +class EditChoiceQuestionForm(EditQuestionForm): + choices = forms.ModelMultipleChoiceField( + Choice.objects.all(), + required=True, + help_text="Select choices to include on this question. Field can be used to search available choices.", + widget=MultipleSelectWithPop(attrs={"size": "11"})) + + class Meta: + model = ChoiceQuestion + exclude = [] + + +class AddChoicesForm(forms.ModelForm): + class Meta: + model = Choice + exclude = [] + + +class AssignUserForm(forms.ModelForm): + assignee = forms.CharField(required=False, + widget=forms.widgets.HiddenInput()) + + def __init__(self, *args, **kwargs): + assignee = None + if "assignee" in kwargs: + assignee = kwargs.pop("asignees") + super().__init__(*args, **kwargs) + if assignee is None: + self.fields["assignee"] = forms.ModelChoiceField(queryset=get_authorized_users("view"), empty_label="Not Assigned", required=False) + else: + self.fields["assignee"].initial = assignee + + class Meta: + model = Answered_Survey + exclude = ["engagement", "survey", "responder", "completed", "answered_on"] diff --git a/dojo/survey/urls.py b/dojo/survey/ui/urls.py similarity index 94% rename from dojo/survey/urls.py rename to dojo/survey/ui/urls.py index a592719aaf2..43865e055a1 100644 --- a/dojo/survey/urls.py +++ b/dojo/survey/ui/urls.py @@ -3,16 +3,9 @@ @author: jay7958 """ -from django.apps import apps -from django.contrib import admin from django.urls import re_path -from dojo.survey import views - -if not apps.ready: - apps.get_models() - -admin.autodiscover() +from dojo.survey.ui import views urlpatterns = [ re_path(r"^questionnaire$", diff --git a/dojo/survey/views.py b/dojo/survey/ui/views.py similarity index 99% rename from dojo/survey/views.py rename to dojo/survey/ui/views.py index b47e6bc3502..866184829c5 100644 --- a/dojo/survey/views.py +++ b/dojo/survey/ui/views.py @@ -18,11 +18,28 @@ user_has_permission, user_has_permission_or_403, ) -from dojo.filters import QuestionFilter, QuestionnaireFilter from dojo.forms import ( + AddEngagementForm, + ExistingEngagementForm, +) +from dojo.models import ( + Engagement, + System_Settings, +) +from dojo.survey.models import ( + Answer, + Answered_Survey, + Choice, + ChoiceQuestion, + Engagement_Survey, + General_Survey, + Question, + TextQuestion, +) +from dojo.survey.ui.filters import QuestionFilter, QuestionnaireFilter +from dojo.survey.ui.forms import ( Add_Questionnaire_Form, AddChoicesForm, - AddEngagementForm, AddGeneralQuestionnaireForm, AssignUserForm, CreateChoiceQuestionForm, @@ -35,19 +52,6 @@ EditChoiceQuestionForm, EditQuestionnaireQuestionsForm, EditTextQuestionForm, - ExistingEngagementForm, -) -from dojo.models import ( - Answer, - Answered_Survey, - Choice, - ChoiceQuestion, - Engagement, - Engagement_Survey, - General_Survey, - Question, - System_Settings, - TextQuestion, ) from dojo.utils import add_breadcrumb, get_page_items, get_setting diff --git a/dojo/urls.py b/dojo/urls.py index 706fe9225f3..28d2427cebd 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -39,7 +39,7 @@ from dojo.asset.api.urls import add_asset_urls from dojo.asset.urls import urlpatterns as asset_urls from dojo.banner.urls import urlpatterns as banner_urls -from dojo.benchmark.urls import urlpatterns as benchmark_urls +from dojo.benchmark.ui.urls import urlpatterns as benchmark_urls from dojo.components.urls import urlpatterns as component_urls from dojo.development_environment.urls import urlpatterns as dev_env_urls from dojo.endpoint.api.urls import add_endpoint_urls, register_endpoint_meta_import @@ -68,7 +68,7 @@ from dojo.reports.urls import urlpatterns as reports_urls from dojo.search.urls import urlpatterns as search_urls from dojo.sla_config.urls import urlpatterns as sla_urls -from dojo.survey.urls import urlpatterns as survey_urls +from dojo.survey.ui.urls import urlpatterns as survey_urls from dojo.system_settings.api.urls import add_system_settings_urls from dojo.system_settings.ui.urls import urlpatterns as system_settings_urls from dojo.test.api.urls import add_test_urls diff --git a/unittests/test_survey_forms.py b/unittests/test_survey_forms.py index 9526c44424a..1945f2d2245 100644 --- a/unittests/test_survey_forms.py +++ b/unittests/test_survey_forms.py @@ -1,6 +1,6 @@ import json -from dojo.forms import MultiExampleField, MultiWidgetBasic +from dojo.survey.ui.forms import MultiExampleField, MultiWidgetBasic from unittests.dojo_test_case import DojoTestCase From 1753984c45c567c485179ffa3f3fa36e4966880e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 9 Jun 2026 00:44:10 +0200 Subject: [PATCH 34/40] refactor(notes,files): extract note_type, notes, file_uploads, reports into dojo// [Phase 1,3,5,6,8,9] --- dojo/api_v2/serializers.py | 75 ++----------- dojo/api_v2/views.py | 48 +------- dojo/endpoint/ui/views.py | 2 +- dojo/file_uploads/__init__.py | 1 + dojo/file_uploads/admin.py | 6 + dojo/file_uploads/api/__init__.py | 0 dojo/file_uploads/api/serializer.py | 26 +++++ dojo/file_uploads/models.py | 103 +++++++++++++++++ dojo/forms.py | 102 ++--------------- dojo/models.py | 168 ++-------------------------- dojo/note_type/__init__.py | 1 + dojo/note_type/admin.py | 5 + dojo/note_type/api/__init__.py | 1 + dojo/note_type/api/serializer.py | 9 ++ dojo/note_type/api/urls.py | 7 ++ dojo/note_type/api/views.py | 27 +++++ dojo/note_type/models.py | 12 ++ dojo/note_type/ui/__init__.py | 0 dojo/note_type/ui/forms.py | 35 ++++++ dojo/note_type/{ => ui}/urls.py | 2 +- dojo/note_type/{ => ui}/views.py | 4 +- dojo/notes/__init__.py | 1 + dojo/notes/admin.py | 6 + dojo/notes/api/__init__.py | 1 + dojo/notes/api/serializer.py | 41 +++++++ dojo/notes/api/urls.py | 7 ++ dojo/notes/api/views.py | 31 +++++ dojo/notes/models.py | 49 ++++++++ dojo/notes/ui/__init__.py | 0 dojo/notes/ui/forms.py | 39 +++++++ dojo/notes/{ => ui}/urls.py | 2 +- dojo/notes/{ => ui}/views.py | 6 +- dojo/reports/__init__.py | 1 + dojo/reports/admin.py | 5 + dojo/reports/models.py | 5 + dojo/reports/ui/__init__.py | 0 dojo/reports/ui/forms.py | 28 +++++ dojo/reports/{ => ui}/urls.py | 2 +- dojo/reports/{ => ui}/views.py | 2 +- dojo/reports/widgets.py | 2 +- dojo/url/ui/views.py | 2 +- dojo/urls.py | 14 +-- 42 files changed, 501 insertions(+), 377 deletions(-) create mode 100644 dojo/file_uploads/admin.py create mode 100644 dojo/file_uploads/api/__init__.py create mode 100644 dojo/file_uploads/api/serializer.py create mode 100644 dojo/file_uploads/models.py create mode 100644 dojo/note_type/admin.py create mode 100644 dojo/note_type/api/__init__.py create mode 100644 dojo/note_type/api/serializer.py create mode 100644 dojo/note_type/api/urls.py create mode 100644 dojo/note_type/api/views.py create mode 100644 dojo/note_type/models.py create mode 100644 dojo/note_type/ui/__init__.py create mode 100644 dojo/note_type/ui/forms.py rename dojo/note_type/{ => ui}/urls.py (93%) rename dojo/note_type/{ => ui}/views.py (96%) create mode 100644 dojo/notes/admin.py create mode 100644 dojo/notes/api/__init__.py create mode 100644 dojo/notes/api/serializer.py create mode 100644 dojo/notes/api/urls.py create mode 100644 dojo/notes/api/views.py create mode 100644 dojo/notes/models.py create mode 100644 dojo/notes/ui/__init__.py create mode 100644 dojo/notes/ui/forms.py rename dojo/notes/{ => ui}/urls.py (92%) rename dojo/notes/{ => ui}/views.py (96%) create mode 100644 dojo/reports/admin.py create mode 100644 dojo/reports/models.py create mode 100644 dojo/reports/ui/__init__.py create mode 100644 dojo/reports/ui/forms.py rename dojo/reports/{ => ui}/urls.py (99%) rename dojo/reports/{ => ui}/views.py (99%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 2880f738fb1..30e8283590f 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -43,8 +43,6 @@ Language_Type, Languages, Network_Locations, - Note_Type, - NoteHistory, Notes, Product, Product_API_Scan_Configuration, @@ -291,6 +289,15 @@ def validate(self, data): return data +from dojo.file_uploads.api.serializer import ( # noqa: E402, F401 -- re-export; prefetcher + lazy consumers in finding/test/engagement + FileSerializer, + RawFileSerializer, +) +from dojo.note_type.api.serializer import NoteTypeSerializer # noqa: E402, F401 -- re-export for prefetcher discovery +from dojo.notes.api.serializer import ( # noqa: E402, F401 -- re-export; prefetcher + RiskAcceptanceToNotesSerializer + lazy consumers + NoteHistorySerializer, + NoteSerializer, +) from dojo.user.api.serializer import ( # noqa: E402, F401 -- backward compat + prefetcher discovery AddUserSerializer, UserContactInfoSerializer, @@ -300,70 +307,6 @@ def validate(self, data): ) -class NoteTypeSerializer(serializers.ModelSerializer): - class Meta: - model = Note_Type - fields = "__all__" - - -class NoteHistorySerializer(serializers.ModelSerializer): - current_editor = UserStubSerializer(read_only=True) - note_type = NoteTypeSerializer(read_only=True, many=False) - - class Meta: - model = NoteHistory - fields = "__all__" - - -class NoteSerializer(serializers.ModelSerializer): - author = UserStubSerializer(many=False, read_only=True) - editor = UserStubSerializer(read_only=True, many=False, allow_null=True) - history = NoteHistorySerializer(read_only=True, many=True) - note_type = NoteTypeSerializer(read_only=True, many=False) - - def update(self, instance, validated_data): - instance.entry = validated_data.get("entry") - instance.edited = True - instance.editor = self.context["request"].user - instance.edit_time = timezone.now() - history = NoteHistory( - data=instance.entry, - time=instance.edit_time, - current_editor=instance.editor, - ) - history.save() - instance.history.add(history) - instance.save() - return instance - - class Meta: - model = Notes - fields = "__all__" - - -class FileSerializer(serializers.ModelSerializer): - file = serializers.FileField(required=True) - - class Meta: - model = FileUpload - fields = "__all__" - - def validate(self, data): - if file := data.get("file"): - # the clean will validate the file extensions and raise a Validation error if the extensions are not accepted - FileUpload(title=file.name, file=file).clean() - return data - return None - - -class RawFileSerializer(serializers.ModelSerializer): - file = serializers.FileField(required=True) - - class Meta: - model = FileUpload - fields = ["file"] - - class RiskAcceptanceProofSerializer(serializers.ModelSerializer): path = serializers.FileField(required=True) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 97816b34f6c..76b2e702216 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -61,7 +61,6 @@ Language_Type, Languages, Network_Locations, - Note_Type, NoteHistory, Notes, Product, @@ -79,7 +78,7 @@ get_authorized_languages, get_authorized_products, ) -from dojo.reports.views import ( +from dojo.reports.ui.views import ( prefetch_related_findings_for_report, report_url_resolver, ) @@ -677,49 +676,8 @@ def perform_create(self, serializer): pghistory.context(test_id=test_id_from_response) -# Authorization: configuration -class NoteTypeViewSet( - DojoModelViewSet, -): - serializer_class = serializers.NoteTypeSerializer - queryset = Note_Type.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "name", - "description", - "is_single", - "is_active", - "is_mandatory", - ] - permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) - - def get_queryset(self): - return Note_Type.objects.all().order_by("id") - - -# Authorization: superuser -class NotesViewSet( - mixins.UpdateModelMixin, - viewsets.ReadOnlyModelViewSet, -): - serializer_class = serializers.NoteSerializer - queryset = Notes.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = [ - "id", - "entry", - "author", - "private", - "date", - "edited", - "edit_time", - "editor", - ] - permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) - - def get_queryset(self): - return Notes.objects.all().order_by("id") +from dojo.note_type.api.views import NoteTypeViewSet # noqa: E402, F401 -- re-export; urls.py imports by name +from dojo.notes.api.views import NotesViewSet # noqa: E402, F401 -- re-export; urls.py imports by name def report_generate(request, obj, options): diff --git a/dojo/endpoint/ui/views.py b/dojo/endpoint/ui/views.py index 531f21cffd5..9e11a855118 100644 --- a/dojo/endpoint/ui/views.py +++ b/dojo/endpoint/ui/views.py @@ -29,7 +29,7 @@ ) from dojo.models import DojoMeta, Endpoint, Endpoint_Status, Finding, Product from dojo.query_utils import build_count_subquery -from dojo.reports.views import generate_report +from dojo.reports.ui.views import generate_report from dojo.utils import ( Product_Tab, add_breadcrumb, diff --git a/dojo/file_uploads/__init__.py b/dojo/file_uploads/__init__.py index e69de29bb2d..4134e5e9d54 100644 --- a/dojo/file_uploads/__init__.py +++ b/dojo/file_uploads/__init__.py @@ -0,0 +1 @@ +import dojo.file_uploads.admin # noqa: F401 diff --git a/dojo/file_uploads/admin.py b/dojo/file_uploads/admin.py new file mode 100644 index 00000000000..0add1a26c56 --- /dev/null +++ b/dojo/file_uploads/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from dojo.file_uploads.models import FileAccessToken, FileUpload + +admin.site.register(FileUpload) +admin.site.register(FileAccessToken) diff --git a/dojo/file_uploads/api/__init__.py b/dojo/file_uploads/api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/file_uploads/api/serializer.py b/dojo/file_uploads/api/serializer.py new file mode 100644 index 00000000000..3f813dd7beb --- /dev/null +++ b/dojo/file_uploads/api/serializer.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + +from dojo.file_uploads.models import FileUpload + + +class FileSerializer(serializers.ModelSerializer): + file = serializers.FileField(required=True) + + class Meta: + model = FileUpload + fields = "__all__" + + def validate(self, data): + if file := data.get("file"): + # the clean will validate the file extensions and raise a Validation error if the extensions are not accepted + FileUpload(title=file.name, file=file).clean() + return data + return None + + +class RawFileSerializer(serializers.ModelSerializer): + file = serializers.FileField(required=True) + + class Meta: + model = FileUpload + fields = ["file"] diff --git a/dojo/file_uploads/models.py b/dojo/file_uploads/models.py new file mode 100644 index 00000000000..e5fa8e5a66f --- /dev/null +++ b/dojo/file_uploads/models.py @@ -0,0 +1,103 @@ +from pathlib import Path +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from dojo.models import ( # UniqueUploadNameProvider kept in dojo.models for migration upload_to path stability + UniqueUploadNameProvider, + copy_model_util, +) + + +class FileUpload(models.Model): + title = models.CharField(max_length=100, unique=True) + file = models.FileField(upload_to=UniqueUploadNameProvider("uploaded_files")) + + def delete(self, *args, **kwargs): + """Delete the model and remove the file from storage.""" + storage = self.file.storage + path = self.file.path + super().delete(*args, **kwargs) + if path and storage.exists(path): + storage.delete(path) + + def copy(self): + copy = copy_model_util(self) + # Add unique modifier to file name + # Truncate title to ensure it doesn't exceed max_length (100) when appending suffix + # Suffix " - clone-{8 chars}" is 17 characters, so truncate to 83 chars + clone_suffix = f" - clone-{str(uuid4())[:8]}" + max_title_length = 100 - len(clone_suffix) + truncated_title = self.title[:max_title_length] if len(self.title) > max_title_length else self.title + copy.title = f"{truncated_title}{clone_suffix}" + # Create new unique file name + current_url = self.file.url + _, current_full_filename = current_url.rsplit("/", 1) + _, extension = current_full_filename.split(".", 1) + new_file = ContentFile(self.file.read(), name=f"{uuid4()}.{extension}") + copy.file = new_file + copy.save() + + return copy + + def get_accessible_url(self, obj, obj_id): + from dojo.engagement.models import Engagement # noqa: PLC0415 -- lazy import, avoids circular dependency + from dojo.finding.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + from dojo.test.models import Test # noqa: PLC0415 -- lazy import, avoids circular dependency + if isinstance(obj, Engagement): + obj_type = "Engagement" + elif isinstance(obj, Test): + obj_type = "Test" + elif isinstance(obj, Finding): + obj_type = "Finding" + + return f"access_file/{self.id}/{obj_id}/{obj_type}" + + def clean(self): + if not self.title: + self.title = "" + + valid_extensions = settings.FILE_UPLOAD_TYPES + + # why does this not work with self.file.... + file_name = self.file.url if self.file else self.title + if Path(file_name).suffix.lower() not in valid_extensions: + if accepted_extensions := f"{', '.join(valid_extensions)}": + msg = ( + _("Unsupported extension. Supported extensions are as follows: %s") % accepted_extensions + ) + else: + msg = ( + _("File uploads are prohibited due to the list of acceptable file extensions being empty") + ) + raise ValidationError(msg) + + +class FileAccessToken(models.Model): + + """ + This will allow reports to request the images without exposing the + media root to the world without + authentication + """ + + user = models.ForeignKey("dojo.Dojo_User", null=False, blank=False, on_delete=models.CASCADE) + file = models.ForeignKey("dojo.FileUpload", null=False, blank=False, on_delete=models.CASCADE) + token = models.CharField(max_length=255) + size = models.CharField(max_length=9, + choices=( + ("small", "Small"), + ("medium", "Medium"), + ("large", "Large"), + ("thumbnail", "Thumbnail"), + ("original", "Original")), + default="medium") + + def save(self, *args, **kwargs): + if not self.token: + self.token = uuid4() + return super().save(*args, **kwargs) diff --git a/dojo/forms.py b/dojo/forms.py index 999c9bf16d8..d17743f1531 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -56,8 +56,6 @@ FileUpload, Finding, Finding_Group, - Note_Type, - Notes, Objects_Product, Product_API_Scan_Configuration, Product_Type, @@ -221,36 +219,11 @@ def __init__(self, *args, user=None, **kwargs): ) -class NoteTypeForm(forms.ModelForm): - description = forms.CharField(widget=forms.Textarea(attrs={}), - required=True) - - class Meta: - model = Note_Type - fields = ["name", "description", "is_single", "is_mandatory"] - - -class EditNoteTypeForm(NoteTypeForm): - - def __init__(self, *args, **kwargs): - is_single = kwargs.pop("is_single") - super().__init__(*args, **kwargs) - if is_single is False: - self.fields["is_single"].widget = forms.HiddenInput() - - -class DisableOrEnableNoteTypeForm(NoteTypeForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["name"].disabled = True - self.fields["description"].disabled = True - self.fields["is_single"].disabled = True - self.fields["is_mandatory"].disabled = True - self.fields["is_active"].disabled = True - - class Meta: - model = Note_Type - fields = "__all__" +from dojo.note_type.ui.forms import ( # noqa: E402, F401 -- backward compat + DisableOrEnableNoteTypeForm, + EditNoteTypeForm, + NoteTypeForm, +) class DojoMetaDataForm(forms.ModelForm): @@ -715,44 +688,14 @@ class Meta: EngForm, ExistingEngagementForm, ) +from dojo.notes.ui.forms import ( # noqa: E402, F401 -- backward compat + DeleteNoteForm, + NoteForm, + TypedNoteForm, +) from dojo.test.ui.forms import TestForm # noqa: E402, F401 -- backward compat -class NoteForm(forms.ModelForm): - entry = forms.CharField(max_length=2400, widget=forms.Textarea(attrs={"rows": 4, "cols": 15}), - label="Notes:") - - class Meta: - model = Notes - fields = ["entry", "private"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - -class TypedNoteForm(NoteForm): - - def __init__(self, *args, **kwargs): - queryset = kwargs.pop("available_note_types") - super().__init__(*args, **kwargs) - self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True) - - class Meta: - model = Notes - fields = ["note_type", "entry", "private"] - - -class DeleteNoteForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Notes - fields = ["id"] - - class WeeklyMetricsForm(forms.Form): dates = forms.ChoiceField() @@ -900,31 +843,6 @@ class Meta: "date_joined", "user_permissions"] -class ReportOptionsForm(forms.Form): - yes_no = (("0", "No"), ("1", "Yes")) - include_finding_notes = forms.ChoiceField(choices=yes_no, label="Finding Notes") - include_finding_images = forms.ChoiceField(choices=yes_no, label="Finding Images") - include_executive_summary = forms.ChoiceField(choices=yes_no, label="Executive Summary") - include_table_of_contents = forms.ChoiceField(choices=yes_no, label="Table of Contents") - include_disclaimer = forms.ChoiceField(choices=yes_no, label="Disclaimer") - report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if get_system_setting("disclaimer_reports_forced"): - self.fields["include_disclaimer"].disabled = True - self.fields["include_disclaimer"].initial = "1" # represents yes - self.fields["include_disclaimer"].help_text = "Administrator of the system enforced placement of disclaimer in all reports. You are not able exclude disclaimer from this report." - - -class CustomReportOptionsForm(forms.Form): - yes_no = (("0", "No"), ("1", "Yes")) - report_name = forms.CharField(required=False, max_length=100) - include_finding_notes = forms.ChoiceField(required=False, choices=yes_no) - include_finding_images = forms.ChoiceField(choices=yes_no, label="Finding Images") - report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) - - from dojo.benchmark.ui.forms import ( # noqa: E402, F401 -- backward compat Benchmark_Product_SummaryForm, Benchmark_RequirementForm, diff --git a/dojo/models.py b/dojo/models.py index 651bebad557..cfbeaa8e273 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -5,11 +5,9 @@ from uuid import uuid4 import tagulous.admin -from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.core.files.base import ContentFile from django.db import models from django.db.models import Count from django.db.models.expressions import Case, When @@ -170,130 +168,19 @@ def get_current_datetime(): return timezone.now() -class Note_Type(models.Model): - name = models.CharField(max_length=100, unique=True) - description = models.CharField(max_length=200) - is_single = models.BooleanField(default=False, null=False) - is_active = models.BooleanField(default=True, null=False) - is_mandatory = models.BooleanField(default=True, null=False) - - def __str__(self): - return self.name - - -class NoteHistory(models.Model): - note_type = models.ForeignKey(Note_Type, null=True, blank=True, on_delete=models.CASCADE) - data = models.TextField() - time = models.DateTimeField(null=True, editable=False, - default=get_current_datetime) - current_editor = models.ForeignKey(Dojo_User, editable=False, null=True, on_delete=models.CASCADE) - - def copy(self): - copy = copy_model_util(self) - copy.save() - return copy - - -class Notes(models.Model): - note_type = models.ForeignKey(Note_Type, related_name="note_type", null=True, blank=True, on_delete=models.CASCADE) - entry = models.TextField() - date = models.DateTimeField(null=False, editable=False, - default=get_current_datetime) - author = models.ForeignKey(Dojo_User, related_name="editor_notes_set", editable=False, on_delete=models.CASCADE) - private = models.BooleanField(default=False) - edited = models.BooleanField(default=False) - editor = models.ForeignKey(Dojo_User, related_name="author_notes_set", editable=False, null=True, on_delete=models.CASCADE) - edit_time = models.DateTimeField(null=True, editable=False, - default=get_current_datetime) - history = models.ManyToManyField(NoteHistory, blank=True, - editable=False) - - class Meta: - ordering = ["-date"] - - def __str__(self): - return self.entry - - def copy(self): - copy = copy_model_util(self) - # Save the necessary ManyToMany relationships - old_history = list(self.history.all()) - # Save the object before setting any ManyToMany relationships - copy.save() - # Copy the history - for history in old_history: - copy.history.add(history.copy()) - - return copy - - -class FileUpload(models.Model): - title = models.CharField(max_length=100, unique=True) - file = models.FileField(upload_to=UniqueUploadNameProvider("uploaded_files")) - - def delete(self, *args, **kwargs): - """Delete the model and remove the file from storage.""" - storage = self.file.storage - path = self.file.path - super().delete(*args, **kwargs) - if path and storage.exists(path): - storage.delete(path) - - def copy(self): - copy = copy_model_util(self) - # Add unique modifier to file name - # Truncate title to ensure it doesn't exceed max_length (100) when appending suffix - # Suffix " - clone-{8 chars}" is 17 characters, so truncate to 83 chars - clone_suffix = f" - clone-{str(uuid4())[:8]}" - max_title_length = 100 - len(clone_suffix) - truncated_title = self.title[:max_title_length] if len(self.title) > max_title_length else self.title - copy.title = f"{truncated_title}{clone_suffix}" - # Create new unique file name - current_url = self.file.url - _, current_full_filename = current_url.rsplit("/", 1) - _, extension = current_full_filename.split(".", 1) - new_file = ContentFile(self.file.read(), name=f"{uuid4()}.{extension}") - copy.file = new_file - copy.save() - - return copy - - def get_accessible_url(self, obj, obj_id): - if isinstance(obj, Engagement): - obj_type = "Engagement" - elif isinstance(obj, Test): - obj_type = "Test" - elif isinstance(obj, Finding): - obj_type = "Finding" - - return f"access_file/{self.id}/{obj_id}/{obj_type}" - - def clean(self): - if not self.title: - self.title = "" - - valid_extensions = settings.FILE_UPLOAD_TYPES - - # why does this not work with self.file.... - file_name = self.file.url if self.file else self.title - if Path(file_name).suffix.lower() not in valid_extensions: - if accepted_extensions := f"{', '.join(valid_extensions)}": - msg = ( - _("Unsupported extension. Supported extensions are as follows: %s") % accepted_extensions - ) - else: - msg = ( - _("File uploads are prohibited due to the list of acceptable file extensions being empty") - ) - raise ValidationError(msg) - - +from dojo.file_uploads.models import FileAccessToken, FileUpload # noqa: E402, F401 -- re-export +from dojo.note_type.models import Note_Type # noqa: E402, F401 -- re-export +from dojo.notes.models import ( # noqa: E402, F401 -- re-export; Notes used by Risk_Acceptance.notes M2M below + NoteHistory, + Notes, +) from dojo.product.models import ( # noqa: E402 -- re-export; class-body FKs below reference these Product, Product_API_Scan_Configuration, # noqa: F401 -- re-export Product_Line, # noqa: F401 -- re-export ) from dojo.product_type.models import Product_Type # noqa: E402, F401 -- re-export +from dojo.reports.models import Report_Type # noqa: E402, F401 -- re-export from dojo.test.models import ( # noqa: E402 -- re-export; class-body FKs below reference these IMPORT_ACTIONS, # noqa: F401 -- re-export IMPORT_CLOSED_FINDING, # noqa: F401 -- re-export @@ -307,10 +194,6 @@ def clean(self): ) -class Report_Type(models.Model): - name = models.CharField(max_length=255) - - class DojoMeta(models.Model): name = models.CharField(max_length=120) value = models.CharField(max_length=300) @@ -710,32 +593,6 @@ def copy(self, engagement=None): return copy -class FileAccessToken(models.Model): - - """ - This will allow reports to request the images without exposing the - media root to the world without - authentication - """ - - user = models.ForeignKey(Dojo_User, null=False, blank=False, on_delete=models.CASCADE) - file = models.ForeignKey(FileUpload, null=False, blank=False, on_delete=models.CASCADE) - token = models.CharField(max_length=255) - size = models.CharField(max_length=9, - choices=( - ("small", "Small"), - ("medium", "Medium"), - ("large", "Large"), - ("thumbnail", "Thumbnail"), - ("original", "Original")), - default="medium") - - def save(self, *args, **kwargs): - if not self.token: - self.token = uuid4() - return super().save(*args, **kwargs) - - ANNOUNCEMENT_STYLE_CHOICES = ( ("info", "Info"), ("success", "Success"), @@ -951,12 +808,11 @@ def __str__(self): admin.site.register(Languages) admin.site.register(Language_Type) admin.site.register(App_Analysis) -admin.site.register(FileUpload) -admin.site.register(FileAccessToken) +# FileUpload + FileAccessToken admin registered in dojo/file_uploads/admin.py admin.site.register(Risk_Acceptance) admin.site.register(Check_List) -admin.site.register(Notes) -admin.site.register(Note_Type) +# Notes + NoteHistory admin registered in dojo/notes/admin.py +# Note_Type admin registered in dojo/note_type/admin.py admin.site.register(SLA_Configuration) admin.site.register(Regulation) from dojo.authorization.models import ( # noqa: E402 @@ -984,8 +840,8 @@ def __str__(self): admin.site.register(Product_Type_Member) admin.site.register(Product_Type_Group) -admin.site.register(NoteHistory) -admin.site.register(Report_Type) +# NoteHistory admin registered in dojo/notes/admin.py +# Report_Type admin registered in dojo/reports/admin.py admin.site.register(DojoMeta) admin.site.register(Development_Environment) admin.site.register(Announcement) diff --git a/dojo/note_type/__init__.py b/dojo/note_type/__init__.py index e69de29bb2d..2c7095d7b93 100644 --- a/dojo/note_type/__init__.py +++ b/dojo/note_type/__init__.py @@ -0,0 +1 @@ +import dojo.note_type.admin # noqa: F401 diff --git a/dojo/note_type/admin.py b/dojo/note_type/admin.py new file mode 100644 index 00000000000..6b1baab68bc --- /dev/null +++ b/dojo/note_type/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.note_type.models import Note_Type + +admin.site.register(Note_Type) diff --git a/dojo/note_type/api/__init__.py b/dojo/note_type/api/__init__.py new file mode 100644 index 00000000000..e39da828cac --- /dev/null +++ b/dojo/note_type/api/__init__.py @@ -0,0 +1 @@ +path = "note_type" # noqa: RUF067 diff --git a/dojo/note_type/api/serializer.py b/dojo/note_type/api/serializer.py new file mode 100644 index 00000000000..459773cc38d --- /dev/null +++ b/dojo/note_type/api/serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from dojo.note_type.models import Note_Type + + +class NoteTypeSerializer(serializers.ModelSerializer): + class Meta: + model = Note_Type + fields = "__all__" diff --git a/dojo/note_type/api/urls.py b/dojo/note_type/api/urls.py new file mode 100644 index 00000000000..f7c5a878568 --- /dev/null +++ b/dojo/note_type/api/urls.py @@ -0,0 +1,7 @@ +from dojo.note_type.api import path +from dojo.note_type.api.views import NoteTypeViewSet + + +def add_note_type_urls(router): + router.register(path, NoteTypeViewSet, basename="note_type") + return router diff --git a/dojo/note_type/api/views.py b/dojo/note_type/api/views.py new file mode 100644 index 00000000000..cde7c4d862f --- /dev/null +++ b/dojo/note_type/api/views.py @@ -0,0 +1,27 @@ +from django_filters.rest_framework import DjangoFilterBackend + +from dojo.api_v2.views import DojoModelViewSet +from dojo.authorization import api_permissions as permissions +from dojo.note_type.api.serializer import NoteTypeSerializer +from dojo.note_type.models import Note_Type + + +# Authorization: configuration +class NoteTypeViewSet( + DojoModelViewSet, +): + serializer_class = NoteTypeSerializer + queryset = Note_Type.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "name", + "description", + "is_single", + "is_active", + "is_mandatory", + ] + permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) + + def get_queryset(self): + return Note_Type.objects.all().order_by("id") diff --git a/dojo/note_type/models.py b/dojo/note_type/models.py new file mode 100644 index 00000000000..6ba489a8ca4 --- /dev/null +++ b/dojo/note_type/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class Note_Type(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.CharField(max_length=200) + is_single = models.BooleanField(default=False, null=False) + is_active = models.BooleanField(default=True, null=False) + is_mandatory = models.BooleanField(default=True, null=False) + + def __str__(self): + return self.name diff --git a/dojo/note_type/ui/__init__.py b/dojo/note_type/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/note_type/ui/forms.py b/dojo/note_type/ui/forms.py new file mode 100644 index 00000000000..8cbbef09020 --- /dev/null +++ b/dojo/note_type/ui/forms.py @@ -0,0 +1,35 @@ +from django import forms + +from dojo.note_type.models import Note_Type + + +class NoteTypeForm(forms.ModelForm): + description = forms.CharField(widget=forms.Textarea(attrs={}), + required=True) + + class Meta: + model = Note_Type + fields = ["name", "description", "is_single", "is_mandatory"] + + +class EditNoteTypeForm(NoteTypeForm): + + def __init__(self, *args, **kwargs): + is_single = kwargs.pop("is_single") + super().__init__(*args, **kwargs) + if is_single is False: + self.fields["is_single"].widget = forms.HiddenInput() + + +class DisableOrEnableNoteTypeForm(NoteTypeForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["name"].disabled = True + self.fields["description"].disabled = True + self.fields["is_single"].disabled = True + self.fields["is_mandatory"].disabled = True + self.fields["is_active"].disabled = True + + class Meta: + model = Note_Type + fields = "__all__" diff --git a/dojo/note_type/urls.py b/dojo/note_type/ui/urls.py similarity index 93% rename from dojo/note_type/urls.py rename to dojo/note_type/ui/urls.py index 76e3c3a6a2c..4f422a5d502 100644 --- a/dojo/note_type/urls.py +++ b/dojo/note_type/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.note_type import views +from dojo.note_type.ui import views urlpatterns = [ re_path(r"^note_type$", diff --git a/dojo/note_type/views.py b/dojo/note_type/ui/views.py similarity index 96% rename from dojo/note_type/views.py rename to dojo/note_type/ui/views.py index 65c908c740e..27aa93f6958 100644 --- a/dojo/note_type/views.py +++ b/dojo/note_type/ui/views.py @@ -6,8 +6,8 @@ from django.urls import reverse from dojo.filters import NoteTypesFilter -from dojo.forms import DisableOrEnableNoteTypeForm, EditNoteTypeForm, NoteTypeForm -from dojo.models import Note_Type +from dojo.note_type.models import Note_Type +from dojo.note_type.ui.forms import DisableOrEnableNoteTypeForm, EditNoteTypeForm, NoteTypeForm from dojo.utils import add_breadcrumb, get_page_items logger = logging.getLogger(__name__) diff --git a/dojo/notes/__init__.py b/dojo/notes/__init__.py index e69de29bb2d..6871614a351 100644 --- a/dojo/notes/__init__.py +++ b/dojo/notes/__init__.py @@ -0,0 +1 @@ +import dojo.notes.admin # noqa: F401 diff --git a/dojo/notes/admin.py b/dojo/notes/admin.py new file mode 100644 index 00000000000..2c3ccf06f9c --- /dev/null +++ b/dojo/notes/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from dojo.notes.models import NoteHistory, Notes + +admin.site.register(Notes) +admin.site.register(NoteHistory) diff --git a/dojo/notes/api/__init__.py b/dojo/notes/api/__init__.py new file mode 100644 index 00000000000..c042966fa4f --- /dev/null +++ b/dojo/notes/api/__init__.py @@ -0,0 +1 @@ +path = "notes" # noqa: RUF067 diff --git a/dojo/notes/api/serializer.py b/dojo/notes/api/serializer.py new file mode 100644 index 00000000000..e4bd3f83e5d --- /dev/null +++ b/dojo/notes/api/serializer.py @@ -0,0 +1,41 @@ +from django.utils import timezone +from rest_framework import serializers + +from dojo.note_type.api.serializer import NoteTypeSerializer +from dojo.notes.models import NoteHistory, Notes +from dojo.user.api.serializer import UserStubSerializer + + +class NoteHistorySerializer(serializers.ModelSerializer): + current_editor = UserStubSerializer(read_only=True) + note_type = NoteTypeSerializer(read_only=True, many=False) + + class Meta: + model = NoteHistory + fields = "__all__" + + +class NoteSerializer(serializers.ModelSerializer): + author = UserStubSerializer(many=False, read_only=True) + editor = UserStubSerializer(read_only=True, many=False, allow_null=True) + history = NoteHistorySerializer(read_only=True, many=True) + note_type = NoteTypeSerializer(read_only=True, many=False) + + def update(self, instance, validated_data): + instance.entry = validated_data.get("entry") + instance.edited = True + instance.editor = self.context["request"].user + instance.edit_time = timezone.now() + history = NoteHistory( + data=instance.entry, + time=instance.edit_time, + current_editor=instance.editor, + ) + history.save() + instance.history.add(history) + instance.save() + return instance + + class Meta: + model = Notes + fields = "__all__" diff --git a/dojo/notes/api/urls.py b/dojo/notes/api/urls.py new file mode 100644 index 00000000000..2d7d582551a --- /dev/null +++ b/dojo/notes/api/urls.py @@ -0,0 +1,7 @@ +from dojo.notes.api import path +from dojo.notes.api.views import NotesViewSet + + +def add_notes_urls(router): + router.register(path, NotesViewSet, basename="notes") + return router diff --git a/dojo/notes/api/views.py b/dojo/notes/api/views.py new file mode 100644 index 00000000000..29fe0e74030 --- /dev/null +++ b/dojo/notes/api/views.py @@ -0,0 +1,31 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import mixins, viewsets +from rest_framework.permissions import DjangoModelPermissions + +from dojo.authorization import api_permissions as permissions +from dojo.notes.api.serializer import NoteSerializer +from dojo.notes.models import Notes + + +# Authorization: superuser +class NotesViewSet( + mixins.UpdateModelMixin, + viewsets.ReadOnlyModelViewSet, +): + serializer_class = NoteSerializer + queryset = Notes.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = [ + "id", + "entry", + "author", + "private", + "date", + "edited", + "edit_time", + "editor", + ] + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) + + def get_queryset(self): + return Notes.objects.all().order_by("id") diff --git a/dojo/notes/models.py b/dojo/notes/models.py new file mode 100644 index 00000000000..26df9ab7a7a --- /dev/null +++ b/dojo/notes/models.py @@ -0,0 +1,49 @@ +from django.db import models + +from dojo.models import copy_model_util, get_current_datetime + + +class NoteHistory(models.Model): + note_type = models.ForeignKey("dojo.Note_Type", null=True, blank=True, on_delete=models.CASCADE) + data = models.TextField() + time = models.DateTimeField(null=True, editable=False, + default=get_current_datetime) + current_editor = models.ForeignKey("dojo.Dojo_User", editable=False, null=True, on_delete=models.CASCADE) + + def copy(self): + copy = copy_model_util(self) + copy.save() + return copy + + +class Notes(models.Model): + note_type = models.ForeignKey("dojo.Note_Type", related_name="note_type", null=True, blank=True, on_delete=models.CASCADE) + entry = models.TextField() + date = models.DateTimeField(null=False, editable=False, + default=get_current_datetime) + author = models.ForeignKey("dojo.Dojo_User", related_name="editor_notes_set", editable=False, on_delete=models.CASCADE) + private = models.BooleanField(default=False) + edited = models.BooleanField(default=False) + editor = models.ForeignKey("dojo.Dojo_User", related_name="author_notes_set", editable=False, null=True, on_delete=models.CASCADE) + edit_time = models.DateTimeField(null=True, editable=False, + default=get_current_datetime) + history = models.ManyToManyField("dojo.NoteHistory", blank=True, + editable=False) + + class Meta: + ordering = ["-date"] + + def __str__(self): + return self.entry + + def copy(self): + copy = copy_model_util(self) + # Save the necessary ManyToMany relationships + old_history = list(self.history.all()) + # Save the object before setting any ManyToMany relationships + copy.save() + # Copy the history + for history in old_history: + copy.history.add(history.copy()) + + return copy diff --git a/dojo/notes/ui/__init__.py b/dojo/notes/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/notes/ui/forms.py b/dojo/notes/ui/forms.py new file mode 100644 index 00000000000..85e9423bb26 --- /dev/null +++ b/dojo/notes/ui/forms.py @@ -0,0 +1,39 @@ +from django import forms + +from dojo.notes.models import Notes +from dojo.utils import get_system_setting + + +class NoteForm(forms.ModelForm): + entry = forms.CharField(max_length=2400, widget=forms.Textarea(attrs={"rows": 4, "cols": 15}), + label="Notes:") + + class Meta: + model = Notes + fields = ["entry", "private"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + +class TypedNoteForm(NoteForm): + + def __init__(self, *args, **kwargs): + queryset = kwargs.pop("available_note_types") + super().__init__(*args, **kwargs) + self.fields["note_type"] = forms.ModelChoiceField(queryset=queryset, label="Note Type", required=True) + + class Meta: + model = Notes + fields = ["note_type", "entry", "private"] + + +class DeleteNoteForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Notes + fields = ["id"] diff --git a/dojo/notes/urls.py b/dojo/notes/ui/urls.py similarity index 92% rename from dojo/notes/urls.py rename to dojo/notes/ui/urls.py index 00a9f17a83a..cf95618e5eb 100644 --- a/dojo/notes/urls.py +++ b/dojo/notes/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.notes.ui import views urlpatterns = [ re_path(r"^notes/(?P\d+)/delete/(?P[\w-]+)/(?P\d+)$", views.delete_note, name="delete_note"), diff --git a/dojo/notes/views.py b/dojo/notes/ui/views.py similarity index 96% rename from dojo/notes/views.py rename to dojo/notes/ui/views.py index 66c4d0aecda..4b4f7c27457 100644 --- a/dojo/notes/views.py +++ b/dojo/notes/ui/views.py @@ -16,8 +16,10 @@ from dojo.finding.queries import get_authorized_findings # Local application/library imports -from dojo.forms import DeleteNoteForm, NoteForm, TypedNoteForm -from dojo.models import Engagement, Finding, Note_Type, NoteHistory, Notes, Test +from dojo.models import Engagement, Finding, Test +from dojo.note_type.models import Note_Type +from dojo.notes.models import NoteHistory, Notes +from dojo.notes.ui.forms import DeleteNoteForm, NoteForm, TypedNoteForm from dojo.test.queries import get_authorized_tests logger = logging.getLogger(__name__) diff --git a/dojo/reports/__init__.py b/dojo/reports/__init__.py index e69de29bb2d..54faba7100c 100644 --- a/dojo/reports/__init__.py +++ b/dojo/reports/__init__.py @@ -0,0 +1 @@ +import dojo.reports.admin # noqa: F401 diff --git a/dojo/reports/admin.py b/dojo/reports/admin.py new file mode 100644 index 00000000000..f0c7f236146 --- /dev/null +++ b/dojo/reports/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.reports.models import Report_Type + +admin.site.register(Report_Type) diff --git a/dojo/reports/models.py b/dojo/reports/models.py new file mode 100644 index 00000000000..c1201126ff1 --- /dev/null +++ b/dojo/reports/models.py @@ -0,0 +1,5 @@ +from django.db import models + + +class Report_Type(models.Model): + name = models.CharField(max_length=255) diff --git a/dojo/reports/ui/__init__.py b/dojo/reports/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/reports/ui/forms.py b/dojo/reports/ui/forms.py new file mode 100644 index 00000000000..8c324781195 --- /dev/null +++ b/dojo/reports/ui/forms.py @@ -0,0 +1,28 @@ +from django import forms + +from dojo.utils import get_system_setting + + +class ReportOptionsForm(forms.Form): + yes_no = (("0", "No"), ("1", "Yes")) + include_finding_notes = forms.ChoiceField(choices=yes_no, label="Finding Notes") + include_finding_images = forms.ChoiceField(choices=yes_no, label="Finding Images") + include_executive_summary = forms.ChoiceField(choices=yes_no, label="Executive Summary") + include_table_of_contents = forms.ChoiceField(choices=yes_no, label="Table of Contents") + include_disclaimer = forms.ChoiceField(choices=yes_no, label="Disclaimer") + report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if get_system_setting("disclaimer_reports_forced"): + self.fields["include_disclaimer"].disabled = True + self.fields["include_disclaimer"].initial = "1" # represents yes + self.fields["include_disclaimer"].help_text = "Administrator of the system enforced placement of disclaimer in all reports. You are not able exclude disclaimer from this report." + + +class CustomReportOptionsForm(forms.Form): + yes_no = (("0", "No"), ("1", "Yes")) + report_name = forms.CharField(required=False, max_length=100) + include_finding_notes = forms.ChoiceField(required=False, choices=yes_no) + include_finding_images = forms.ChoiceField(choices=yes_no, label="Finding Images") + report_type = forms.ChoiceField(choices=(("HTML", "HTML"),)) diff --git a/dojo/reports/urls.py b/dojo/reports/ui/urls.py similarity index 99% rename from dojo/reports/urls.py rename to dojo/reports/ui/urls.py index 19d4348478f..b7361b95b6f 100644 --- a/dojo/reports/urls.py +++ b/dojo/reports/ui/urls.py @@ -1,7 +1,7 @@ from django.conf import settings from django.urls import re_path -from dojo.reports import views +from dojo.reports.ui import views from dojo.utils import redirect_view # TODO: remove the else: branch once v3 migration is complete diff --git a/dojo/reports/views.py b/dojo/reports/ui/views.py similarity index 99% rename from dojo/reports/views.py rename to dojo/reports/ui/views.py index eff9b1d4825..47e67c36f50 100644 --- a/dojo/reports/views.py +++ b/dojo/reports/ui/views.py @@ -27,13 +27,13 @@ ReportFindingFilterWithoutObjectLookups, ) from dojo.finding.ui.views import BaseListFindings -from dojo.forms import ReportOptionsForm from dojo.labels import get_labels from dojo.location.models import Location from dojo.location.queries import get_authorized_locations from dojo.location.status import FindingLocationStatus from dojo.models import Dojo_User, Endpoint, Engagement, Finding, Product, Product_Type, Test from dojo.reports.queries import prefetch_related_endpoints_for_report, prefetch_related_findings_for_report +from dojo.reports.ui.forms import ReportOptionsForm from dojo.reports.widgets import ( CoverPage, CustomReportJsonForm, diff --git a/dojo/reports/widgets.py b/dojo/reports/widgets.py index 07377560539..9b5375c1574 100644 --- a/dojo/reports/widgets.py +++ b/dojo/reports/widgets.py @@ -17,12 +17,12 @@ ReportFindingFilter, ReportFindingFilterWithoutObjectLookups, ) -from dojo.forms import CustomReportOptionsForm from dojo.labels import get_labels from dojo.location.models import Location from dojo.location.status import FindingLocationStatus from dojo.models import Endpoint, Finding from dojo.reports.queries import prefetch_related_endpoints_for_report, prefetch_related_findings_for_report +from dojo.reports.ui.forms import CustomReportOptionsForm from dojo.url.filters import URLFilter from dojo.utils import get_page_items, get_system_setting, get_words_for_field diff --git a/dojo/url/ui/views.py b/dojo/url/ui/views.py index 13eb6521286..3d9845fb561 100644 --- a/dojo/url/ui/views.py +++ b/dojo/url/ui/views.py @@ -24,7 +24,7 @@ from dojo.location.queries import annotate_location_counts_and_status, get_authorized_locations from dojo.location.status import FindingLocationStatus, ProductLocationStatus from dojo.models import DojoMeta, Finding, Product -from dojo.reports.views import generate_report +from dojo.reports.ui.views import generate_report from dojo.url.filters import URLFilter from dojo.url.models import URL from dojo.url.queries import annotate_host_contents diff --git a/dojo/urls.py b/dojo/urls.py index 28d2427cebd..1c101535038 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -26,8 +26,6 @@ LanguageTypeViewSet, LanguageViewSet, NetworkLocationsViewset, - NotesViewSet, - NoteTypeViewSet, RegulationsViewSet, ReImportScanView, RiskAcceptanceViewSet, @@ -55,8 +53,10 @@ from dojo.location.api.endpoint_compat import V3EndpointCompatibleViewSet, V3EndpointStatusCompatibleViewSet from dojo.location.api.urls import add_locations_urls from dojo.metrics.urls import urlpatterns as metrics_urls -from dojo.note_type.urls import urlpatterns as note_type_urls -from dojo.notes.urls import urlpatterns as notes_urls +from dojo.note_type.api.urls import add_note_type_urls +from dojo.note_type.ui.urls import urlpatterns as note_type_urls +from dojo.notes.api.urls import add_notes_urls +from dojo.notes.ui.urls import urlpatterns as notes_urls from dojo.notifications.api.urls import add_notifications_urls from dojo.notifications.ui.urls import urlpatterns as notifications_urls from dojo.object.urls import urlpatterns as object_urls @@ -65,7 +65,7 @@ from dojo.product.api.urls import add_product_urls from dojo.product_type.api.urls import add_product_type_urls from dojo.regulations.urls import urlpatterns as regulations -from dojo.reports.urls import urlpatterns as reports_urls +from dojo.reports.ui.urls import urlpatterns as reports_urls from dojo.search.urls import urlpatterns as search_urls from dojo.sla_config.urls import urlpatterns as sla_urls from dojo.survey.ui.urls import urlpatterns as survey_urls @@ -116,8 +116,8 @@ v2_api.register(r"language_types", LanguageTypeViewSet, basename="language_type") v2_api.register(r"metadata", DojoMetaViewSet, basename="metadata") v2_api.register(r"network_locations", NetworkLocationsViewset, basename="network_locations") -v2_api.register(r"notes", NotesViewSet, basename="notes") -v2_api.register(r"note_type", NoteTypeViewSet, basename="note_type") +v2_api = add_notes_urls(v2_api) +v2_api = add_note_type_urls(v2_api) add_notifications_urls(v2_api) v2_api = add_product_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: From 34d74a2059de26f34da7df13a84df3d498614a9e Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 9 Jun 2026 10:51:22 +0200 Subject: [PATCH 35/40] refactor(risk_acceptance): extract Risk_Acceptance into dojo/risk_acceptance/ [risk_acceptance Phase 1,3,6,7,8,9] --- dojo/api_v2/serializers.py | 164 +++--------------- dojo/api_v2/views.py | 133 -------------- dojo/filters.py | 36 ---- dojo/forms.py | 75 +------- dojo/models.py | 103 +---------- dojo/risk_acceptance/__init__.py | 1 + dojo/risk_acceptance/admin.py | 5 + dojo/risk_acceptance/api/__init__.py | 12 ++ dojo/risk_acceptance/api/filters.py | 37 ++++ .../risk_acceptance/{api.py => api/mixins.py} | 2 +- dojo/risk_acceptance/api/serializer.py | 137 +++++++++++++++ dojo/risk_acceptance/api/urls.py | 7 + dojo/risk_acceptance/api/views.py | 148 ++++++++++++++++ dojo/risk_acceptance/models.py | 113 ++++++++++++ dojo/risk_acceptance/ui/__init__.py | 0 dojo/risk_acceptance/ui/forms.py | 80 +++++++++ dojo/urls.py | 4 +- unittests/test_rest_framework.py | 2 +- 18 files changed, 573 insertions(+), 486 deletions(-) create mode 100644 dojo/risk_acceptance/admin.py create mode 100644 dojo/risk_acceptance/api/__init__.py create mode 100644 dojo/risk_acceptance/api/filters.py rename dojo/risk_acceptance/{api.py => api/mixins.py} (98%) create mode 100644 dojo/risk_acceptance/api/serializer.py create mode 100644 dojo/risk_acceptance/api/urls.py create mode 100644 dojo/risk_acceptance/api/views.py create mode 100644 dojo/risk_acceptance/models.py create mode 100644 dojo/risk_acceptance/ui/__init__.py create mode 100644 dojo/risk_acceptance/ui/forms.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 30e8283590f..e52b1a3a04a 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -8,10 +8,9 @@ import tagulous from django.conf import settings from django.contrib.auth.models import Permission -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import ValidationError from django.db import transaction from django.db.utils import IntegrityError -from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema_field @@ -19,8 +18,6 @@ from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError as RestFrameworkValidationError -import dojo.risk_acceptance.helper as ra_helper -from dojo.finding.queries import get_authorized_findings from dojo.importers.auto_create_context import AutoCreateContextManager from dojo.importers.base_importer import BaseImporter from dojo.importers.default_importer import DefaultImporter @@ -47,7 +44,6 @@ Product, Product_API_Scan_Configuration, Regulation, - Risk_Acceptance, SLA_Configuration, Sonarqube_Issue, Sonarqube_Issue_Transition, @@ -289,6 +285,11 @@ def validate(self, data): return data +# Engagement serializers live in dojo/engagement/api/serializer.py. +# EngagementSerializer is re-exported here because ReportGenerateSerializer and +# RiskAcceptanceSerializer (below) still reference it. The other engagement +# serializers are imported directly from dojo.engagement.api by their consumers. +from dojo.engagement.api.serializer import EngagementSerializer # noqa: E402 -- backward compat from dojo.file_uploads.api.serializer import ( # noqa: E402, F401 -- re-export; prefetcher + lazy consumers in finding/test/engagement FileSerializer, RawFileSerializer, @@ -298,28 +299,6 @@ def validate(self, data): NoteHistorySerializer, NoteSerializer, ) -from dojo.user.api.serializer import ( # noqa: E402, F401 -- backward compat + prefetcher discovery - AddUserSerializer, - UserContactInfoSerializer, - UserProfileSerializer, - UserSerializer, - UserStubSerializer, -) - - -class RiskAcceptanceProofSerializer(serializers.ModelSerializer): - path = serializers.FileField(required=True) - - class Meta: - model = Risk_Acceptance - fields = ["path"] - - -# Engagement serializers live in dojo/engagement/api/serializer.py. -# EngagementSerializer is re-exported here because ReportGenerateSerializer and -# RiskAcceptanceSerializer (below) still reference it. The other engagement -# serializers are imported directly from dojo.engagement.api by their consumers. -from dojo.engagement.api.serializer import EngagementSerializer # noqa: E402 -- backward compat # Product serializers live in dojo/product/api/serializer.py. ProductSerializer is # re-exported because ReportGenerateSerializer (below) still references it; @@ -331,13 +310,13 @@ class Meta: ProductSerializer, ) from dojo.product_type.api.serializer import ProductTypeSerializer # noqa: E402 - - -class RiskAcceptanceToNotesSerializer(serializers.Serializer): - risk_acceptance_id = serializers.PrimaryKeyRelatedField( - queryset=Risk_Acceptance.objects.all(), many=False, allow_null=True, - ) - notes = NoteSerializer(many=True) +from dojo.user.api.serializer import ( # noqa: E402, F401 -- backward compat + prefetcher discovery + AddUserSerializer, + UserContactInfoSerializer, + UserProfileSerializer, + UserSerializer, + UserStubSerializer, +) class AppAnalysisSerializer(serializers.ModelSerializer): @@ -389,118 +368,17 @@ class Meta: fields = "__all__" +# Risk acceptance serializers live in dojo/risk_acceptance/api/serializer.py. Re-exported here +# for backward compat: RiskAcceptanceSerializer is lazy-imported by dojo/finding/api/serializer.py +# (schema overrides); the ModelSerializers must also stay discoverable by the prefetcher. +from dojo.risk_acceptance.api.serializer import ( # noqa: E402 -- backward compat / prefetcher discovery + RiskAcceptanceProofSerializer, # noqa: F401 + RiskAcceptanceSerializer, # noqa: F401 -- lazy-imported by finding schema overrides + prefetcher + RiskAcceptanceToNotesSerializer, # noqa: F401 +) from dojo.test.api.serializer import TestSerializer # noqa: E402 -- backward compat re-export -class RiskAcceptanceSerializer(serializers.ModelSerializer): - path = serializers.SerializerMethodField() - - def create(self, validated_data): - instance = super().create(validated_data) - user = getattr(self.context.get("request", None), "user", None) - ra_helper.add_findings_to_risk_acceptance(user, instance, instance.accepted_findings.all()) - - # Add risk acceptance to engagement - # This is fine as Pro has its own model + relationshop to track links with engagements. - if instance.accepted_findings.exists(): - engagement = instance.accepted_findings.first().test.engagement - engagement.risk_acceptance.add(instance) - - return instance - - def update(self, instance, validated_data): - # Determine findings to risk accept, and findings to unaccept risk - existing_findings = Finding.objects.filter(risk_acceptance=self.instance.id) - new_findings_ids = [x.id for x in validated_data.get("accepted_findings", [])] - new_findings = Finding.objects.filter(id__in=new_findings_ids) - findings_to_add = set(new_findings) - set(existing_findings) - findings_to_remove = set(existing_findings) - set(new_findings) - findings_to_add = Finding.objects.filter(id__in=[x.id for x in findings_to_add]) - findings_to_remove = Finding.objects.filter(id__in=[x.id for x in findings_to_remove]) - # Make the update in the database - instance = super().update(instance, validated_data) - user = getattr(self.context.get("request", None), "user", None) - # Add the new findings - ra_helper.add_findings_to_risk_acceptance(user, instance, findings_to_add) - # Remove the ones that were not present in the payload - for finding in findings_to_remove: - ra_helper.remove_finding_from_risk_acceptance(user, instance, finding) - - # Handle orphaned risk acceptances: link to engagement if it now has findings - # This is fine as Pro has its own model + relationshop to track links with engagements. - if instance.accepted_findings.exists() and not instance.engagement: - engagement = instance.accepted_findings.first().test.engagement - engagement.risk_acceptance.add(instance) - - return instance - - @extend_schema_field(serializers.CharField()) - def get_path(self, obj): - engagement = Engagement.objects.filter( - risk_acceptance__id__in=[obj.id], - ).first() - path = "No proof has been supplied" - if engagement and obj.filename() is not None: - path = reverse( - "download_risk_acceptance", args=(engagement.id, obj.id), - ) - request = self.context.get("request") - if request: - path = request.build_absolute_uri(path) - return path - - @extend_schema_field(serializers.IntegerField()) - def get_engagement(self, obj): - engagement = Engagement.objects.filter( - risk_acceptance__id__in=[obj.id], - ).first() - return EngagementSerializer(read_only=True).to_representation( - engagement, - ) - - def validate(self, data): - def validate_findings_have_same_engagement(finding_objects: list[Finding]): - engagements = finding_objects.values_list("test__engagement__id", flat=True).distinct().count() - if engagements > 1: - msg = "You are not permitted to add findings from multiple engagements" - raise PermissionDenied(msg) - - findings = data.get("accepted_findings", []) - findings_ids = [x.id for x in findings] - finding_objects = Finding.objects.filter(id__in=findings_ids) - authed_findings = get_authorized_findings("edit").filter(id__in=findings_ids) - if len(findings) != len(authed_findings): - msg = "You are not permitted to add one or more selected findings to this risk acceptance" - raise PermissionDenied(msg) - if self.context["request"].method == "POST": - validate_findings_have_same_engagement(finding_objects) - - # Validate product allows full risk acceptance BEFORE creating instance - if finding_objects.exists(): - engagement = finding_objects.first().test.engagement - if not engagement.product.enable_full_risk_acceptance: - msg = "Full risk acceptance is not enabled for this product" - raise PermissionDenied(msg) - elif self.context["request"].method in {"PATCH", "PUT"}: - # Use the reverse relation instead of filtering - existing_findings = self.instance.accepted_findings.all() - existing_and_new_findings = existing_findings | finding_objects - validate_findings_have_same_engagement(existing_and_new_findings) - - # Explicit check to prevent engagement switching - risk_acceptance_engagement = self.instance.engagement - if risk_acceptance_engagement and finding_objects.exists(): - new_findings_engagement = finding_objects.first().test.engagement - if risk_acceptance_engagement.id != new_findings_engagement.id: - msg = f"Risk Acceptance belongs to engagement {risk_acceptance_engagement.id}. Cannot add findings from engagement {new_findings_engagement.id}" - raise ValidationError(msg) - return data - - class Meta: - model = Risk_Acceptance - fields = "__all__" - - class CommonImportScanSerializer(serializers.Serializer): scan_date = serializers.DateField( required=False, diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 76b2e702216..d4b763ea167 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -1,7 +1,5 @@ import logging -import mimetypes from datetime import datetime -from pathlib import Path import pghistory from dateutil.relativedelta import relativedelta @@ -10,8 +8,6 @@ from django.core.exceptions import ValidationError from django.db import IntegrityError from django.db.models.query import QuerySet as DjangoQuerySet -from django.http import FileResponse -from django.urls import reverse from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.renderers import OpenApiJsonRenderer2 @@ -41,7 +37,6 @@ from dojo.filters import ( ApiAppAnalysisFilter, ApiDojoMetaFilter, - ApiRiskAcceptanceFilter, ) from dojo.finding.ui.filters import ( ReportFindingFilter, @@ -61,11 +56,8 @@ Language_Type, Languages, Network_Locations, - NoteHistory, - Notes, Product, Regulation, - Risk_Acceptance, SLA_Configuration, Sonarqube_Issue, Sonarqube_Issue_Transition, @@ -82,8 +74,6 @@ prefetch_related_findings_for_report, report_url_resolver, ) -from dojo.risk_acceptance.helper import remove_finding_from_risk_acceptance -from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( @@ -91,7 +81,6 @@ get_celery_queue_length, get_celery_worker_status, get_system_setting, - process_tag_notifications, purge_celery_queue, purge_celery_queue_by_task_name, ) @@ -170,128 +159,6 @@ def finalize_response(self, request, response, *args, **kwargs): # @extend_schema_view(**schema_with_prefetch()) # Nested models with prefetch make the response schema too long for Swagger UI -class RiskAcceptanceViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.RiskAcceptanceSerializer - queryset = Risk_Acceptance.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiRiskAcceptanceFilter - - permission_classes = ( - IsAuthenticated, - permissions.UserHasRiskAcceptancePermission, - ) - - def destroy(self, request, pk=None): - instance = self.get_object() - # Remove any findings on the risk acceptance - for finding in instance.accepted_findings.all(): - remove_finding_from_risk_acceptance(request.user, instance, finding) - # return the response of the object being deleted - return super().destroy(request, pk=pk) - - def get_queryset(self): - return ( - get_authorized_risk_acceptances("edit") - .prefetch_related( - "notes", "engagement_set", "owner", "accepted_findings", - ) - .distinct() - ) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.RiskAcceptanceToNotesSerializer, - }, - ) - @extend_schema( - methods=["POST"], - request=serializers.AddNewNoteOptionSerializer, - responses={status.HTTP_201_CREATED: serializers.NoteSerializer}, - ) - @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasRiskAcceptanceRelatedObjectPermission)) - def notes(self, request, pk=None): - risk_acceptance = self.get_object() - if request.method == "POST": - new_note = serializers.AddNewNoteOptionSerializer(data=request.data) - if new_note.is_valid(): - entry = new_note.validated_data["entry"] - private = new_note.validated_data.get("private", False) - note_type = new_note.validated_data.get("note_type", None) - else: - return Response(new_note.errors, status=status.HTTP_400_BAD_REQUEST) - - notes = risk_acceptance.notes.filter(note_type=note_type).first() - if notes and note_type and note_type.is_single: - return Response("Only one instance of this note_type allowed on a risk acceptance.", status=status.HTTP_400_BAD_REQUEST) - - author = request.user - note = Notes(entry=entry, author=author, private=private, note_type=note_type) - note.save() - history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) - note.history.add(history) - risk_acceptance.notes.add(note) - engagement = risk_acceptance.engagement - if engagement: - process_tag_notifications( - request=request, - note=note, - parent_url=request.build_absolute_uri( - reverse("view_risk_acceptance", args=(engagement.id, risk_acceptance.id)), - ), - parent_title=f"Risk Acceptance: {risk_acceptance.name}", - ) - - serialized_note = serializers.NoteSerializer( - {"author": author, "entry": entry, "private": private}, - ) - return Response(serialized_note.data, status=status.HTTP_201_CREATED) - - notes = risk_acceptance.notes.all() - serialized_notes = serializers.RiskAcceptanceToNotesSerializer( - {"risk_acceptance_id": risk_acceptance, "notes": notes}, - ) - return Response(serialized_notes.data, status=status.HTTP_200_OK) - - @extend_schema( - methods=["GET"], - responses={ - status.HTTP_200_OK: serializers.RiskAcceptanceProofSerializer, - }, - ) - @action(detail=True, methods=["get"], permission_classes=(IsAuthenticated, permissions.UserHasRiskAcceptanceRelatedObjectPermission)) - def download_proof(self, request, pk=None): - risk_acceptance = self.get_object() - # Get the file object - file_object = risk_acceptance.path - if file_object is None or risk_acceptance.filename() is None: - return Response( - {"error": "Proof has not provided to this risk acceptance..."}, - status=status.HTTP_404_NOT_FOUND, - ) - # Get the path of the file in media root - file_path = Path(settings.MEDIA_ROOT) / file_object.name - # NOTE: FileResponse takes ownership of closing the file handle when the response is closed. - # Explicitly register the closer to avoid potential resource leaks and satisfy static analyzers. - file_handle = file_path.open("rb") - # send file - response = FileResponse( - file_handle, - content_type=mimetypes.guess_type(str(file_path))[0] or "application/octet-stream", - status=status.HTTP_200_OK, - ) - if hasattr(response, "_resource_closers"): - response._resource_closers.append(file_handle.close) - response["Content-Length"] = file_object.size - response[ - "Content-Disposition" - ] = f'attachment; filename="{risk_acceptance.filename()}"' - - return response - - # These are technologies in the UI and the API! # Authorization: object-based @extend_schema_view(**schema_with_prefetch()) diff --git a/dojo/filters.py b/dojo/filters.py index b6ff6815f6f..b69f2722977 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -51,7 +51,6 @@ Note_Type, Product, Product_Type, - Risk_Acceptance, Test, Vulnerability_Id, ) @@ -1273,41 +1272,6 @@ class Meta: exclude = ["last_modified", "endpoint", "finding"] -class ApiRiskAcceptanceFilter(DojoFilter): - created = DateRangeFilter() - updated = DateRangeFilter() - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("name", "name"), - ("created", "created"), - ("updated", "updated"), - ), - ) - - class Meta: - model = Risk_Acceptance - fields = { - "name": ["exact", "icontains"], - "accepted_findings": ["exact"], - "recommendation": ["exact"], - "recommendation_details": ["exact", "icontains"], - "decision": ["exact"], - "decision_details": ["exact", "icontains"], - "accepted_by": ["exact", "icontains"], - "owner": ["exact"], - "expiration_date": ["exact", "gt", "lt", "gte", "lte"], - "expiration_date_warned": ["exact", "gt", "lt", "gte", "lte"], - "expiration_date_handled": ["exact", "gt", "lt", "gte", "lte"], - "reactivate_expired": ["exact"], - "restart_sla_expired": ["exact"], - "notes": ["exact"], - "created": ["exact", "gt", "lt", "gte", "lte"], - "updated": ["exact", "gt", "lt", "gte", "lte"], - } - - class ApiAppAnalysisFilter(DojoFilter): tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains") tags = CharFieldInFilter( diff --git a/dojo/forms.py b/dojo/forms.py index d17743f1531..0a349d0c481 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -19,7 +19,6 @@ from tagulous.forms import TagField from dojo.endpoint.utils import validate_endpoints_to_add -from dojo.finding.queries import get_authorized_findings from dojo.github.ui.forms import ( # noqa: F401 -- backward compat DeleteGITHUBConfForm, ExpressGITHUBForm, @@ -54,13 +53,11 @@ DojoMeta, Endpoint, FileUpload, - Finding, Finding_Group, Objects_Product, Product_API_Scan_Configuration, Product_Type, Regulation, - Risk_Acceptance, SLA_Configuration, Test_Type, User, @@ -70,7 +67,6 @@ from dojo.user.utils import get_configuration_permissions_fields from dojo.utils import ( get_password_requirements_string, - get_system_setting, is_finding_groups_enabled, is_scan_file_too_large, ) @@ -551,64 +547,6 @@ def clean(self): raise ValidationError(msg) -class EditRiskAcceptanceForm(forms.ModelForm): - # unfortunately django forces us to repeat many things here. choices, default, required etc. - recommendation = forms.ChoiceField(choices=Risk_Acceptance.TREATMENT_CHOICES, initial=Risk_Acceptance.TREATMENT_ACCEPT, widget=forms.RadioSelect, label="Security Recommendation") - decision = forms.ChoiceField(choices=Risk_Acceptance.TREATMENT_CHOICES, initial=Risk_Acceptance.TREATMENT_ACCEPT, widget=forms.RadioSelect) - - path = forms.FileField(label="Proof", required=False, widget=forms.widgets.FileInput(attrs={"accept": ", ".join(settings.FILE_IMPORT_TYPES)})) - expiration_date = forms.DateTimeField(required=False, widget=forms.TextInput(attrs={"class": "datepicker"})) - - class Meta: - model = Risk_Acceptance - exclude = ["accepted_findings", "notes"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["path"].help_text = f"Existing proof uploaded: {self.instance.filename()}" if self.instance.filename() else "None" - self.fields["expiration_date_warned"].disabled = True - self.fields["expiration_date_handled"].disabled = True - - def clean_path(self): - if (data := self.cleaned_data.get("path")) is not None: - ext = Path(data.name).suffix # [0] returns path+filename - valid_extensions = settings.FILE_UPLOAD_TYPES - if ext.lower() not in valid_extensions: - if accepted_extensions := f"{', '.join(valid_extensions)}": - msg = f"Unsupported extension. Supported extensions are as follows: {accepted_extensions}" - else: - msg = "File uploads are prohibited due to the list of acceptable file extensions being empty" - raise ValidationError(msg) - return data - - -class RiskAcceptanceForm(EditRiskAcceptanceForm): - accepted_findings = forms.ModelMultipleChoiceField( - queryset=Finding.objects.none(), required=True, - widget=forms.widgets.SelectMultiple(attrs={"size": 10}), - help_text=("Active, verified findings listed, please select to add findings.")) - notes = forms.CharField(required=False, max_length=2400, - widget=forms.Textarea, - label="Notes") - - class Meta: - model = Risk_Acceptance - fields = "__all__" - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - expiration_delta_days = get_system_setting("risk_acceptance_form_default_days") - logger.debug("expiration_delta_days: %i", expiration_delta_days) - if expiration_delta_days > 0: - expiration_date = timezone.now().date() + relativedelta(days=expiration_delta_days) - # logger.debug('setting default expiration_date: %s', expiration_date) - self.fields["expiration_date"].initial = expiration_date - # self.fields['path'].help_text = 'Existing proof uploaded: %s' % self.instance.filename() if self.instance.filename() else 'None' - self.fields["accepted_findings"].queryset = get_authorized_findings("edit") - if disclaimer := get_system_setting("disclaimer_notes"): - self.disclaimer = disclaimer.strip() - - class BaseManageFileFormSet(forms.BaseModelFormSet): def clean(self): """Validate the IP/Mask combo is in CIDR format""" @@ -638,12 +576,13 @@ def clean(self): ManageFileFormSet = modelformset_factory(FileUpload, extra=3, max_num=10, fields=["title", "file"], can_delete=True, formset=BaseManageFileFormSet) -class ReplaceRiskAcceptanceProofForm(forms.ModelForm): - path = forms.FileField(label="Proof", required=True, widget=forms.widgets.FileInput(attrs={"accept": ".jpg,.png,.pdf"})) - - class Meta: - model = Risk_Acceptance - fields = ["path"] +# Risk acceptance forms live in dojo/risk_acceptance/ui/forms.py. Re-exported here for +# backward compat — engagement's UI views import them from dojo.forms. +from dojo.risk_acceptance.ui.forms import ( # noqa: E402, F401 -- backward compat + EditRiskAcceptanceForm, + ReplaceRiskAcceptanceProofForm, + RiskAcceptanceForm, +) class CheckForm(forms.ModelForm): diff --git a/dojo/models.py b/dojo/models.py index cfbeaa8e273..c5eb30b0d15 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -491,107 +491,7 @@ def get_breadcrumb(self): return bc -class Risk_Acceptance(models.Model): - TREATMENT_ACCEPT = "A" - TREATMENT_AVOID = "V" - TREATMENT_MITIGATE = "M" - TREATMENT_FIX = "F" - TREATMENT_TRANSFER = "T" - - TREATMENT_TRANSLATIONS = { - TREATMENT_ACCEPT: _("Accept (The risk is acknowledged, yet remains)"), - TREATMENT_AVOID: _("Avoid (Do not engage with whatever creates the risk)"), - TREATMENT_MITIGATE: _("Mitigate (The risk still exists, yet compensating controls make it less of a threat)"), - TREATMENT_FIX: _("Fix (The risk is eradicated)"), - TREATMENT_TRANSFER: _("Transfer (The risk is transferred to a 3rd party)"), - } - - TREATMENT_CHOICES = [ - (TREATMENT_ACCEPT, TREATMENT_TRANSLATIONS[TREATMENT_ACCEPT]), - (TREATMENT_AVOID, TREATMENT_TRANSLATIONS[TREATMENT_AVOID]), - (TREATMENT_MITIGATE, TREATMENT_TRANSLATIONS[TREATMENT_MITIGATE]), - (TREATMENT_FIX, TREATMENT_TRANSLATIONS[TREATMENT_FIX]), - (TREATMENT_TRANSFER, TREATMENT_TRANSLATIONS[TREATMENT_TRANSFER]), - ] - - name = models.CharField(max_length=300, null=False, blank=False, help_text=_("Descriptive name which in the future may also be used to group risk acceptances together across engagements and products")) - - accepted_findings = models.ManyToManyField(Finding) - - recommendation = models.CharField(choices=TREATMENT_CHOICES, max_length=2, null=False, default=TREATMENT_FIX, help_text=_("Recommendation from the security team."), verbose_name=_("Security Recommendation")) - - recommendation_details = models.TextField(null=True, - blank=True, - help_text=_("Explanation of security recommendation"), verbose_name=_("Security Recommendation Details")) - - decision = models.CharField(choices=TREATMENT_CHOICES, max_length=2, null=False, default=TREATMENT_ACCEPT, help_text=_("Risk treatment decision by risk owner")) - decision_details = models.TextField(default=None, blank=True, null=True, help_text=_("If a compensating control exists to mitigate the finding or reduce risk, then list the compensating control(s).")) - - accepted_by = models.CharField(max_length=200, default=None, null=True, blank=True, verbose_name=_("Accepted By"), help_text=_("The person that accepts the risk, can be outside of DefectDojo.")) - path = models.FileField(upload_to="risk/%Y/%m/%d", - editable=True, null=True, - blank=True, verbose_name=_("Proof")) - owner = models.ForeignKey(Dojo_User, editable=True, on_delete=models.RESTRICT, help_text=_("User in DefectDojo owning this acceptance. Only the owner and staff users can edit the risk acceptance.")) - - expiration_date = models.DateTimeField(default=None, null=True, blank=True, help_text=_("When the risk acceptance expires, the findings will be reactivated (unless disabled below).")) - expiration_date_warned = models.DateTimeField(default=None, null=True, blank=True, help_text=_("(readonly) Date at which notice about the risk acceptance expiration was sent.")) - expiration_date_handled = models.DateTimeField(default=None, null=True, blank=True, help_text=_("(readonly) When the risk acceptance expiration was handled (manually or by the daily job).")) - reactivate_expired = models.BooleanField(null=False, blank=False, default=True, verbose_name=_("Reactivate findings on expiration"), help_text=_("Reactivate findings when risk acceptance expires?")) - restart_sla_expired = models.BooleanField(default=False, null=False, verbose_name=_("Restart SLA on expiration"), help_text=_("When enabled, the SLA for findings is restarted when the risk acceptance expires.")) - - notes = models.ManyToManyField(Notes, editable=False) - created = models.DateTimeField(auto_now_add=True, null=False) - updated = models.DateTimeField(auto_now=True, editable=False) - - def __str__(self): - return str(self.name) - - def filename(self): - # logger.debug('path: "%s"', self.path) - if not self.path: - return None - return Path(self.path.name).name - - @property - def name_and_expiration_info(self): - return str(self.name) + (" (expired " if self.is_expired else " (expires ") + (timezone.localtime(self.expiration_date).strftime("%b %d, %Y") if self.expiration_date else "Never") + ")" - - def get_breadcrumbs(self): - bc = self.engagement_set.first().get_breadcrumbs() - bc += [{"title": str(self), - "url": reverse("view_risk_acceptance", args=( - self.engagement_set.first().product.id, self.id))}] - return bc - - @property - def is_expired(self): - return self.expiration_date_handled is not None - - # relationship is many to many, but we use it as one-to-many - @property - def engagement(self): - engs = self.engagement_set.all() - if engs: - return engs[0] - - return None - - def copy(self, engagement=None): - copy = copy_model_util(self) - # Save the necessary ManyToMany relationships - old_notes = list(self.notes.all()) - old_accepted_findings_hash_codes = [finding.hash_code for finding in self.accepted_findings.all()] - # Save the object before setting any ManyToMany relationships - copy.save() - # Copy the notes - for notes in old_notes: - copy.notes.add(notes.copy()) - # Assign any accepted findings - if engagement: - new_accepted_findings = Finding.objects.filter(test__engagement=engagement, hash_code__in=old_accepted_findings_hash_codes, risk_accepted=True).distinct() - copy.accepted_findings.set(new_accepted_findings) - return copy - +from dojo.risk_acceptance.models import Risk_Acceptance # noqa: E402, F401 -- re-export ANNOUNCEMENT_STYLE_CHOICES = ( ("info", "Info"), @@ -809,7 +709,6 @@ def __str__(self): admin.site.register(Language_Type) admin.site.register(App_Analysis) # FileUpload + FileAccessToken admin registered in dojo/file_uploads/admin.py -admin.site.register(Risk_Acceptance) admin.site.register(Check_List) # Notes + NoteHistory admin registered in dojo/notes/admin.py # Note_Type admin registered in dojo/note_type/admin.py diff --git a/dojo/risk_acceptance/__init__.py b/dojo/risk_acceptance/__init__.py index e69de29bb2d..a5cede2cf2e 100644 --- a/dojo/risk_acceptance/__init__.py +++ b/dojo/risk_acceptance/__init__.py @@ -0,0 +1 @@ +import dojo.risk_acceptance.admin # noqa: F401 diff --git a/dojo/risk_acceptance/admin.py b/dojo/risk_acceptance/admin.py new file mode 100644 index 00000000000..cec3ae2295f --- /dev/null +++ b/dojo/risk_acceptance/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.risk_acceptance.models import Risk_Acceptance + +admin.site.register(Risk_Acceptance) diff --git a/dojo/risk_acceptance/api/__init__.py b/dojo/risk_acceptance/api/__init__.py new file mode 100644 index 00000000000..97fa34f5990 --- /dev/null +++ b/dojo/risk_acceptance/api/__init__.py @@ -0,0 +1,12 @@ +path = "risk_acceptance" # noqa: RUF067 + +# Backward-compat: the AcceptedRisks/AcceptedFindings mixins + AcceptedRiskSerializer +# were historically importable as `dojo.risk_acceptance.api.` (via the old api.py). +# finding/test/engagement api viewsets consume them as `ra_api.` — keep them resolvable. +from dojo.risk_acceptance.api.mixins import ( # noqa: E402, F401 -- backward compat + AcceptedFindingsMixin, + AcceptedRisk, + AcceptedRiskSerializer, + AcceptedRisksMixin, + _accept_risks, +) diff --git a/dojo/risk_acceptance/api/filters.py b/dojo/risk_acceptance/api/filters.py new file mode 100644 index 00000000000..2ed3f4d8c15 --- /dev/null +++ b/dojo/risk_acceptance/api/filters.py @@ -0,0 +1,37 @@ +from dojo.filters import DateRangeFilter, DojoFilter, OrderingFilter +from dojo.models import Risk_Acceptance + + +class ApiRiskAcceptanceFilter(DojoFilter): + created = DateRangeFilter() + updated = DateRangeFilter() + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("name", "name"), + ("created", "created"), + ("updated", "updated"), + ), + ) + + class Meta: + model = Risk_Acceptance + fields = { + "name": ["exact", "icontains"], + "accepted_findings": ["exact"], + "recommendation": ["exact"], + "recommendation_details": ["exact", "icontains"], + "decision": ["exact"], + "decision_details": ["exact", "icontains"], + "accepted_by": ["exact", "icontains"], + "owner": ["exact"], + "expiration_date": ["exact", "gt", "lt", "gte", "lte"], + "expiration_date_warned": ["exact", "gt", "lt", "gte", "lte"], + "expiration_date_handled": ["exact", "gt", "lt", "gte", "lte"], + "reactivate_expired": ["exact"], + "restart_sla_expired": ["exact"], + "notes": ["exact"], + "created": ["exact", "gt", "lt", "gte", "lte"], + "updated": ["exact", "gt", "lt", "gte", "lte"], + } diff --git a/dojo/risk_acceptance/api.py b/dojo/risk_acceptance/api/mixins.py similarity index 98% rename from dojo/risk_acceptance/api.py rename to dojo/risk_acceptance/api/mixins.py index 78fa27062e9..cc245943d74 100644 --- a/dojo/risk_acceptance/api.py +++ b/dojo/risk_acceptance/api/mixins.py @@ -10,10 +10,10 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from dojo.api_v2.serializers import RiskAcceptanceSerializer from dojo.authorization.api_permissions import UserHasRiskAcceptanceRelatedObjectPermission from dojo.engagement.queries import get_authorized_engagements from dojo.models import Engagement, Risk_Acceptance, User, Vulnerability_Id +from dojo.risk_acceptance.api.serializer import RiskAcceptanceSerializer AcceptedRisk = NamedTuple("AcceptedRisk", (("vulnerability_id", str), ("justification", str), ("accepted_by", str))) diff --git a/dojo/risk_acceptance/api/serializer.py b/dojo/risk_acceptance/api/serializer.py new file mode 100644 index 00000000000..39b4bdd879c --- /dev/null +++ b/dojo/risk_acceptance/api/serializer.py @@ -0,0 +1,137 @@ +from django.core.exceptions import PermissionDenied, ValidationError +from django.urls import reverse +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +import dojo.risk_acceptance.helper as ra_helper +from dojo.finding.queries import get_authorized_findings +from dojo.models import Engagement, Finding +from dojo.notes.api.serializer import NoteSerializer +from dojo.risk_acceptance.models import Risk_Acceptance + + +class RiskAcceptanceProofSerializer(serializers.ModelSerializer): + path = serializers.FileField(required=True) + + class Meta: + model = Risk_Acceptance + fields = ["path"] + + +class RiskAcceptanceToNotesSerializer(serializers.Serializer): + risk_acceptance_id = serializers.PrimaryKeyRelatedField( + queryset=Risk_Acceptance.objects.all(), many=False, allow_null=True, + ) + notes = NoteSerializer(many=True) + + +class RiskAcceptanceSerializer(serializers.ModelSerializer): + path = serializers.SerializerMethodField() + + def create(self, validated_data): + instance = super().create(validated_data) + user = getattr(self.context.get("request", None), "user", None) + ra_helper.add_findings_to_risk_acceptance(user, instance, instance.accepted_findings.all()) + + # Add risk acceptance to engagement + # This is fine as Pro has its own model + relationshop to track links with engagements. + if instance.accepted_findings.exists(): + engagement = instance.accepted_findings.first().test.engagement + engagement.risk_acceptance.add(instance) + + return instance + + def update(self, instance, validated_data): + # Determine findings to risk accept, and findings to unaccept risk + existing_findings = Finding.objects.filter(risk_acceptance=self.instance.id) + new_findings_ids = [x.id for x in validated_data.get("accepted_findings", [])] + new_findings = Finding.objects.filter(id__in=new_findings_ids) + findings_to_add = set(new_findings) - set(existing_findings) + findings_to_remove = set(existing_findings) - set(new_findings) + findings_to_add = Finding.objects.filter(id__in=[x.id for x in findings_to_add]) + findings_to_remove = Finding.objects.filter(id__in=[x.id for x in findings_to_remove]) + # Make the update in the database + instance = super().update(instance, validated_data) + user = getattr(self.context.get("request", None), "user", None) + # Add the new findings + ra_helper.add_findings_to_risk_acceptance(user, instance, findings_to_add) + # Remove the ones that were not present in the payload + for finding in findings_to_remove: + ra_helper.remove_finding_from_risk_acceptance(user, instance, finding) + + # Handle orphaned risk acceptances: link to engagement if it now has findings + # This is fine as Pro has its own model + relationshop to track links with engagements. + if instance.accepted_findings.exists() and not instance.engagement: + engagement = instance.accepted_findings.first().test.engagement + engagement.risk_acceptance.add(instance) + + return instance + + @extend_schema_field(serializers.CharField()) + def get_path(self, obj): + engagement = Engagement.objects.filter( + risk_acceptance__id__in=[obj.id], + ).first() + path = "No proof has been supplied" + if engagement and obj.filename() is not None: + path = reverse( + "download_risk_acceptance", args=(engagement.id, obj.id), + ) + request = self.context.get("request") + if request: + path = request.build_absolute_uri(path) + return path + + @extend_schema_field(serializers.IntegerField()) + def get_engagement(self, obj): + from dojo.engagement.api.serializer import ( # noqa: PLC0415 -- lazy import, avoids circular dependency + EngagementSerializer, + ) + engagement = Engagement.objects.filter( + risk_acceptance__id__in=[obj.id], + ).first() + return EngagementSerializer(read_only=True).to_representation( + engagement, + ) + + def validate(self, data): + def validate_findings_have_same_engagement(finding_objects: list[Finding]): + engagements = finding_objects.values_list("test__engagement__id", flat=True).distinct().count() + if engagements > 1: + msg = "You are not permitted to add findings from multiple engagements" + raise PermissionDenied(msg) + + findings = data.get("accepted_findings", []) + findings_ids = [x.id for x in findings] + finding_objects = Finding.objects.filter(id__in=findings_ids) + authed_findings = get_authorized_findings("edit").filter(id__in=findings_ids) + if len(findings) != len(authed_findings): + msg = "You are not permitted to add one or more selected findings to this risk acceptance" + raise PermissionDenied(msg) + if self.context["request"].method == "POST": + validate_findings_have_same_engagement(finding_objects) + + # Validate product allows full risk acceptance BEFORE creating instance + if finding_objects.exists(): + engagement = finding_objects.first().test.engagement + if not engagement.product.enable_full_risk_acceptance: + msg = "Full risk acceptance is not enabled for this product" + raise PermissionDenied(msg) + elif self.context["request"].method in {"PATCH", "PUT"}: + # Use the reverse relation instead of filtering + existing_findings = self.instance.accepted_findings.all() + existing_and_new_findings = existing_findings | finding_objects + validate_findings_have_same_engagement(existing_and_new_findings) + + # Explicit check to prevent engagement switching + risk_acceptance_engagement = self.instance.engagement + if risk_acceptance_engagement and finding_objects.exists(): + new_findings_engagement = finding_objects.first().test.engagement + if risk_acceptance_engagement.id != new_findings_engagement.id: + msg = f"Risk Acceptance belongs to engagement {risk_acceptance_engagement.id}. Cannot add findings from engagement {new_findings_engagement.id}" + raise ValidationError(msg) + return data + + class Meta: + model = Risk_Acceptance + fields = "__all__" diff --git a/dojo/risk_acceptance/api/urls.py b/dojo/risk_acceptance/api/urls.py new file mode 100644 index 00000000000..5b3387ce2ce --- /dev/null +++ b/dojo/risk_acceptance/api/urls.py @@ -0,0 +1,7 @@ +from dojo.risk_acceptance.api import path +from dojo.risk_acceptance.api.views import RiskAcceptanceViewSet + + +def add_risk_acceptance_urls(router): + router.register(path, RiskAcceptanceViewSet, basename="risk_acceptance") + return router diff --git a/dojo/risk_acceptance/api/views.py b/dojo/risk_acceptance/api/views.py new file mode 100644 index 00000000000..1b6a540cf0a --- /dev/null +++ b/dojo/risk_acceptance/api/views.py @@ -0,0 +1,148 @@ +import mimetypes +from pathlib import Path + +from django.conf import settings +from django.http import FileResponse +from django.urls import reverse +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2 import serializers as api_v2_serializers +from dojo.api_v2.views import PrefetchDojoModelViewSet +from dojo.authorization import api_permissions as permissions +from dojo.models import NoteHistory, Notes, Risk_Acceptance +from dojo.risk_acceptance.api.filters import ApiRiskAcceptanceFilter +from dojo.risk_acceptance.api.serializer import ( + RiskAcceptanceProofSerializer, + RiskAcceptanceSerializer, + RiskAcceptanceToNotesSerializer, +) +from dojo.risk_acceptance.helper import remove_finding_from_risk_acceptance +from dojo.risk_acceptance.queries import get_authorized_risk_acceptances +from dojo.utils import process_tag_notifications + + +class RiskAcceptanceViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = RiskAcceptanceSerializer + queryset = Risk_Acceptance.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiRiskAcceptanceFilter + + permission_classes = ( + IsAuthenticated, + permissions.UserHasRiskAcceptancePermission, + ) + + def destroy(self, request, pk=None): + instance = self.get_object() + # Remove any findings on the risk acceptance + for finding in instance.accepted_findings.all(): + remove_finding_from_risk_acceptance(request.user, instance, finding) + # return the response of the object being deleted + return super().destroy(request, pk=pk) + + def get_queryset(self): + return ( + get_authorized_risk_acceptances("edit") + .prefetch_related( + "notes", "engagement_set", "owner", "accepted_findings", + ) + .distinct() + ) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: RiskAcceptanceToNotesSerializer, + }, + ) + @extend_schema( + methods=["POST"], + request=api_v2_serializers.AddNewNoteOptionSerializer, + responses={status.HTTP_201_CREATED: api_v2_serializers.NoteSerializer}, + ) + @action(detail=True, methods=["get", "post"], permission_classes=(IsAuthenticated, permissions.UserHasRiskAcceptanceRelatedObjectPermission)) + def notes(self, request, pk=None): + risk_acceptance = self.get_object() + if request.method == "POST": + new_note = api_v2_serializers.AddNewNoteOptionSerializer(data=request.data) + if new_note.is_valid(): + entry = new_note.validated_data["entry"] + private = new_note.validated_data.get("private", False) + note_type = new_note.validated_data.get("note_type", None) + else: + return Response(new_note.errors, status=status.HTTP_400_BAD_REQUEST) + + notes = risk_acceptance.notes.filter(note_type=note_type).first() + if notes and note_type and note_type.is_single: + return Response("Only one instance of this note_type allowed on a risk acceptance.", status=status.HTTP_400_BAD_REQUEST) + + author = request.user + note = Notes(entry=entry, author=author, private=private, note_type=note_type) + note.save() + history = NoteHistory.objects.create(data=note.entry, time=note.date, current_editor=note.author) + note.history.add(history) + risk_acceptance.notes.add(note) + engagement = risk_acceptance.engagement + if engagement: + process_tag_notifications( + request=request, + note=note, + parent_url=request.build_absolute_uri( + reverse("view_risk_acceptance", args=(engagement.id, risk_acceptance.id)), + ), + parent_title=f"Risk Acceptance: {risk_acceptance.name}", + ) + + serialized_note = api_v2_serializers.NoteSerializer( + {"author": author, "entry": entry, "private": private}, + ) + return Response(serialized_note.data, status=status.HTTP_201_CREATED) + + notes = risk_acceptance.notes.all() + serialized_notes = RiskAcceptanceToNotesSerializer( + {"risk_acceptance_id": risk_acceptance, "notes": notes}, + ) + return Response(serialized_notes.data, status=status.HTTP_200_OK) + + @extend_schema( + methods=["GET"], + responses={ + status.HTTP_200_OK: RiskAcceptanceProofSerializer, + }, + ) + @action(detail=True, methods=["get"], permission_classes=(IsAuthenticated, permissions.UserHasRiskAcceptanceRelatedObjectPermission)) + def download_proof(self, request, pk=None): + risk_acceptance = self.get_object() + # Get the file object + file_object = risk_acceptance.path + if file_object is None or risk_acceptance.filename() is None: + return Response( + {"error": "Proof has not provided to this risk acceptance..."}, + status=status.HTTP_404_NOT_FOUND, + ) + # Get the path of the file in media root + file_path = Path(settings.MEDIA_ROOT) / file_object.name + # NOTE: FileResponse takes ownership of closing the file handle when the response is closed. + # Explicitly register the closer to avoid potential resource leaks and satisfy static analyzers. + file_handle = file_path.open("rb") + # send file + response = FileResponse( + file_handle, + content_type=mimetypes.guess_type(str(file_path))[0] or "application/octet-stream", + status=status.HTTP_200_OK, + ) + if hasattr(response, "_resource_closers"): + response._resource_closers.append(file_handle.close) + response["Content-Length"] = file_object.size + response[ + "Content-Disposition" + ] = f'attachment; filename="{risk_acceptance.filename()}"' + + return response diff --git a/dojo/risk_acceptance/models.py b/dojo/risk_acceptance/models.py new file mode 100644 index 00000000000..54354e67df2 --- /dev/null +++ b/dojo/risk_acceptance/models.py @@ -0,0 +1,113 @@ +from pathlib import Path + +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext as _ + +# copy_model_util is defined early in dojo.models, before the re-export that loads this +# module, so this resolves despite the partial circular load. +from dojo.models import copy_model_util + + +class Risk_Acceptance(models.Model): + TREATMENT_ACCEPT = "A" + TREATMENT_AVOID = "V" + TREATMENT_MITIGATE = "M" + TREATMENT_FIX = "F" + TREATMENT_TRANSFER = "T" + + TREATMENT_TRANSLATIONS = { + TREATMENT_ACCEPT: _("Accept (The risk is acknowledged, yet remains)"), + TREATMENT_AVOID: _("Avoid (Do not engage with whatever creates the risk)"), + TREATMENT_MITIGATE: _("Mitigate (The risk still exists, yet compensating controls make it less of a threat)"), + TREATMENT_FIX: _("Fix (The risk is eradicated)"), + TREATMENT_TRANSFER: _("Transfer (The risk is transferred to a 3rd party)"), + } + + TREATMENT_CHOICES = [ + (TREATMENT_ACCEPT, TREATMENT_TRANSLATIONS[TREATMENT_ACCEPT]), + (TREATMENT_AVOID, TREATMENT_TRANSLATIONS[TREATMENT_AVOID]), + (TREATMENT_MITIGATE, TREATMENT_TRANSLATIONS[TREATMENT_MITIGATE]), + (TREATMENT_FIX, TREATMENT_TRANSLATIONS[TREATMENT_FIX]), + (TREATMENT_TRANSFER, TREATMENT_TRANSLATIONS[TREATMENT_TRANSFER]), + ] + + name = models.CharField(max_length=300, null=False, blank=False, help_text=_("Descriptive name which in the future may also be used to group risk acceptances together across engagements and products")) + + accepted_findings = models.ManyToManyField("dojo.Finding") + + recommendation = models.CharField(choices=TREATMENT_CHOICES, max_length=2, null=False, default=TREATMENT_FIX, help_text=_("Recommendation from the security team."), verbose_name=_("Security Recommendation")) + + recommendation_details = models.TextField(null=True, + blank=True, + help_text=_("Explanation of security recommendation"), verbose_name=_("Security Recommendation Details")) + + decision = models.CharField(choices=TREATMENT_CHOICES, max_length=2, null=False, default=TREATMENT_ACCEPT, help_text=_("Risk treatment decision by risk owner")) + decision_details = models.TextField(default=None, blank=True, null=True, help_text=_("If a compensating control exists to mitigate the finding or reduce risk, then list the compensating control(s).")) + + accepted_by = models.CharField(max_length=200, default=None, null=True, blank=True, verbose_name=_("Accepted By"), help_text=_("The person that accepts the risk, can be outside of DefectDojo.")) + path = models.FileField(upload_to="risk/%Y/%m/%d", + editable=True, null=True, + blank=True, verbose_name=_("Proof")) + owner = models.ForeignKey("dojo.Dojo_User", editable=True, on_delete=models.RESTRICT, help_text=_("User in DefectDojo owning this acceptance. Only the owner and staff users can edit the risk acceptance.")) + + expiration_date = models.DateTimeField(default=None, null=True, blank=True, help_text=_("When the risk acceptance expires, the findings will be reactivated (unless disabled below).")) + expiration_date_warned = models.DateTimeField(default=None, null=True, blank=True, help_text=_("(readonly) Date at which notice about the risk acceptance expiration was sent.")) + expiration_date_handled = models.DateTimeField(default=None, null=True, blank=True, help_text=_("(readonly) When the risk acceptance expiration was handled (manually or by the daily job).")) + reactivate_expired = models.BooleanField(null=False, blank=False, default=True, verbose_name=_("Reactivate findings on expiration"), help_text=_("Reactivate findings when risk acceptance expires?")) + restart_sla_expired = models.BooleanField(default=False, null=False, verbose_name=_("Restart SLA on expiration"), help_text=_("When enabled, the SLA for findings is restarted when the risk acceptance expires.")) + + notes = models.ManyToManyField("dojo.Notes", editable=False) + created = models.DateTimeField(auto_now_add=True, null=False) + updated = models.DateTimeField(auto_now=True, editable=False) + + def __str__(self): + return str(self.name) + + def filename(self): + # logger.debug('path: "%s"', self.path) + if not self.path: + return None + return Path(self.path.name).name + + @property + def name_and_expiration_info(self): + return str(self.name) + (" (expired " if self.is_expired else " (expires ") + (timezone.localtime(self.expiration_date).strftime("%b %d, %Y") if self.expiration_date else "Never") + ")" + + def get_breadcrumbs(self): + bc = self.engagement_set.first().get_breadcrumbs() + bc += [{"title": str(self), + "url": reverse("view_risk_acceptance", args=( + self.engagement_set.first().product.id, self.id))}] + return bc + + @property + def is_expired(self): + return self.expiration_date_handled is not None + + # relationship is many to many, but we use it as one-to-many + @property + def engagement(self): + engs = self.engagement_set.all() + if engs: + return engs[0] + + return None + + def copy(self, engagement=None): + from dojo.models import Finding # noqa: PLC0415 -- lazy import, avoids circular dependency + copy = copy_model_util(self) + # Save the necessary ManyToMany relationships + old_notes = list(self.notes.all()) + old_accepted_findings_hash_codes = [finding.hash_code for finding in self.accepted_findings.all()] + # Save the object before setting any ManyToMany relationships + copy.save() + # Copy the notes + for notes in old_notes: + copy.notes.add(notes.copy()) + # Assign any accepted findings + if engagement: + new_accepted_findings = Finding.objects.filter(test__engagement=engagement, hash_code__in=old_accepted_findings_hash_codes, risk_accepted=True).distinct() + copy.accepted_findings.set(new_accepted_findings) + return copy diff --git a/dojo/risk_acceptance/ui/__init__.py b/dojo/risk_acceptance/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/risk_acceptance/ui/forms.py b/dojo/risk_acceptance/ui/forms.py new file mode 100644 index 00000000000..8344bc0e769 --- /dev/null +++ b/dojo/risk_acceptance/ui/forms.py @@ -0,0 +1,80 @@ +import logging +from pathlib import Path + +from dateutil.relativedelta import relativedelta +from django import forms +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils import timezone + +from dojo.finding.queries import get_authorized_findings +from dojo.models import Finding, Risk_Acceptance +from dojo.utils import get_system_setting + +logger = logging.getLogger(__name__) + + +class EditRiskAcceptanceForm(forms.ModelForm): + # unfortunately django forces us to repeat many things here. choices, default, required etc. + recommendation = forms.ChoiceField(choices=Risk_Acceptance.TREATMENT_CHOICES, initial=Risk_Acceptance.TREATMENT_ACCEPT, widget=forms.RadioSelect, label="Security Recommendation") + decision = forms.ChoiceField(choices=Risk_Acceptance.TREATMENT_CHOICES, initial=Risk_Acceptance.TREATMENT_ACCEPT, widget=forms.RadioSelect) + + path = forms.FileField(label="Proof", required=False, widget=forms.widgets.FileInput(attrs={"accept": ", ".join(settings.FILE_IMPORT_TYPES)})) + expiration_date = forms.DateTimeField(required=False, widget=forms.TextInput(attrs={"class": "datepicker"})) + + class Meta: + model = Risk_Acceptance + exclude = ["accepted_findings", "notes"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["path"].help_text = f"Existing proof uploaded: {self.instance.filename()}" if self.instance.filename() else "None" + self.fields["expiration_date_warned"].disabled = True + self.fields["expiration_date_handled"].disabled = True + + def clean_path(self): + if (data := self.cleaned_data.get("path")) is not None: + ext = Path(data.name).suffix # [0] returns path+filename + valid_extensions = settings.FILE_UPLOAD_TYPES + if ext.lower() not in valid_extensions: + if accepted_extensions := f"{', '.join(valid_extensions)}": + msg = f"Unsupported extension. Supported extensions are as follows: {accepted_extensions}" + else: + msg = "File uploads are prohibited due to the list of acceptable file extensions being empty" + raise ValidationError(msg) + return data + + +class RiskAcceptanceForm(EditRiskAcceptanceForm): + accepted_findings = forms.ModelMultipleChoiceField( + queryset=Finding.objects.none(), required=True, + widget=forms.widgets.SelectMultiple(attrs={"size": 10}), + help_text=("Active, verified findings listed, please select to add findings.")) + notes = forms.CharField(required=False, max_length=2400, + widget=forms.Textarea, + label="Notes") + + class Meta: + model = Risk_Acceptance + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + expiration_delta_days = get_system_setting("risk_acceptance_form_default_days") + logger.debug("expiration_delta_days: %i", expiration_delta_days) + if expiration_delta_days > 0: + expiration_date = timezone.now().date() + relativedelta(days=expiration_delta_days) + # logger.debug('setting default expiration_date: %s', expiration_date) + self.fields["expiration_date"].initial = expiration_date + # self.fields['path'].help_text = 'Existing proof uploaded: %s' % self.instance.filename() if self.instance.filename() else 'None' + self.fields["accepted_findings"].queryset = get_authorized_findings("edit") + if disclaimer := get_system_setting("disclaimer_notes"): + self.disclaimer = disclaimer.strip() + + +class ReplaceRiskAcceptanceProofForm(forms.ModelForm): + path = forms.FileField(label="Proof", required=True, widget=forms.widgets.FileInput(attrs={"accept": ".jpg,.png,.pdf"})) + + class Meta: + model = Risk_Acceptance + fields = ["path"] diff --git a/dojo/urls.py b/dojo/urls.py index 1c101535038..be79a7f8bd0 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -28,7 +28,6 @@ NetworkLocationsViewset, RegulationsViewSet, ReImportScanView, - RiskAcceptanceViewSet, SLAConfigurationViewset, SonarqubeIssueTransitionViewSet, SonarqubeIssueViewSet, @@ -66,6 +65,7 @@ from dojo.product_type.api.urls import add_product_type_urls from dojo.regulations.urls import urlpatterns as regulations from dojo.reports.ui.urls import urlpatterns as reports_urls +from dojo.risk_acceptance.api.urls import add_risk_acceptance_urls from dojo.search.urls import urlpatterns as search_urls from dojo.sla_config.urls import urlpatterns as sla_urls from dojo.survey.ui.urls import urlpatterns as survey_urls @@ -129,7 +129,7 @@ # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") v2_api.register(r"reimport-scan", ReImportScanView, basename="reimportscan") -v2_api.register(r"risk_acceptance", RiskAcceptanceViewSet, basename="risk_acceptance") +v2_api = add_risk_acceptance_urls(v2_api) # RBAC endpoint moved to Pro under legacy authorization: roles → pro/roles v2_api.register(r"sla_configurations", SLAConfigurationViewset, basename="sla_configurations") v2_api.register(r"sonarqube_issues", SonarqubeIssueViewSet, basename="sonarqube_issue") diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 4e55f580cc5..c661d4678b3 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -50,7 +50,6 @@ LanguageViewSet, NotesViewSet, NoteTypeViewSet, - RiskAcceptanceViewSet, SonarqubeIssueViewSet, ) from dojo.asset.api.views import ( @@ -109,6 +108,7 @@ ) from dojo.product.api.views import ProductAPIScanConfigurationViewSet, ProductViewSet from dojo.product_type.api.views import ProductTypeViewSet +from dojo.risk_acceptance.api.views import RiskAcceptanceViewSet from dojo.test.api.views import TestsViewSet, TestTypesViewSet from dojo.tool_config.api.views import ToolConfigurationsViewSet from dojo.tool_product.api.views import ToolProductSettingsViewSet From 7e97e9d5fe44fd5e3f60920eccbbc5effd6d9b33 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 9 Jun 2026 11:16:39 +0200 Subject: [PATCH 36/40] refactor(misc): extract regulations, banner, announcement, development_environment, object [Phase 1,3,4,5,6,8,9] --- dojo/announcement/__init__.py | 1 + dojo/announcement/admin.py | 6 + dojo/announcement/api/__init__.py | 1 + dojo/announcement/api/serializer.py | 21 +++ dojo/announcement/api/urls.py | 7 + dojo/announcement/api/views.py | 20 +++ dojo/announcement/models.py | 28 ++++ dojo/announcement/signals.py | 3 +- dojo/announcement/ui/__init__.py | 0 dojo/announcement/ui/forms.py | 17 +++ dojo/announcement/{ => ui}/urls.py | 2 +- dojo/announcement/{ => ui}/views.py | 4 +- dojo/api_v2/serializers.py | 41 ++---- dojo/api_v2/views.py | 43 +----- dojo/banner/__init__.py | 1 + dojo/banner/admin.py | 5 + dojo/banner/models.py | 7 + dojo/banner/ui/__init__.py | 0 dojo/banner/ui/forms.py | 18 +++ dojo/banner/{ => ui}/urls.py | 2 +- dojo/banner/{ => ui}/views.py | 4 +- dojo/development_environment/__init__.py | 1 + dojo/development_environment/admin.py | 5 + dojo/development_environment/api/__init__.py | 1 + .../development_environment/api/serializer.py | 9 ++ dojo/development_environment/api/urls.py | 7 + dojo/development_environment/api/views.py | 20 +++ dojo/development_environment/models.py | 13 ++ dojo/development_environment/ui/__init__.py | 0 dojo/development_environment/ui/forms.py | 15 ++ dojo/development_environment/{ => ui}/urls.py | 2 +- .../development_environment/{ => ui}/views.py | 4 +- dojo/forms.py | 91 ++---------- dojo/models.py | 135 +++--------------- dojo/object/__init__.py | 1 + dojo/object/admin.py | 8 ++ dojo/object/models.py | 37 +++++ dojo/object/ui/__init__.py | 0 dojo/object/ui/forms.py | 26 ++++ dojo/object/{ => ui}/urls.py | 2 +- dojo/object/{ => ui}/views.py | 8 +- dojo/regulations/__init__.py | 1 + dojo/regulations/admin.py | 5 + dojo/regulations/api/__init__.py | 1 + dojo/regulations/api/serializer.py | 9 ++ dojo/regulations/api/urls.py | 7 + dojo/regulations/api/views.py | 21 +++ dojo/regulations/models.py | 36 +++++ dojo/regulations/ui/__init__.py | 0 dojo/regulations/ui/forms.py | 9 ++ dojo/regulations/{ => ui}/urls.py | 2 +- dojo/regulations/{ => ui}/views.py | 4 +- dojo/urls.py | 22 +-- unittests/test_rest_framework.py | 4 +- 54 files changed, 439 insertions(+), 298 deletions(-) create mode 100644 dojo/announcement/admin.py create mode 100644 dojo/announcement/api/__init__.py create mode 100644 dojo/announcement/api/serializer.py create mode 100644 dojo/announcement/api/urls.py create mode 100644 dojo/announcement/api/views.py create mode 100644 dojo/announcement/models.py create mode 100644 dojo/announcement/ui/__init__.py create mode 100644 dojo/announcement/ui/forms.py rename dojo/announcement/{ => ui}/urls.py (88%) rename dojo/announcement/{ => ui}/views.py (95%) create mode 100644 dojo/banner/admin.py create mode 100644 dojo/banner/models.py create mode 100644 dojo/banner/ui/__init__.py create mode 100644 dojo/banner/ui/forms.py rename dojo/banner/{ => ui}/urls.py (82%) rename dojo/banner/{ => ui}/views.py (94%) create mode 100644 dojo/development_environment/admin.py create mode 100644 dojo/development_environment/api/__init__.py create mode 100644 dojo/development_environment/api/serializer.py create mode 100644 dojo/development_environment/api/urls.py create mode 100644 dojo/development_environment/api/views.py create mode 100644 dojo/development_environment/models.py create mode 100644 dojo/development_environment/ui/__init__.py create mode 100644 dojo/development_environment/ui/forms.py rename dojo/development_environment/{ => ui}/urls.py (85%) rename dojo/development_environment/{ => ui}/views.py (95%) create mode 100644 dojo/object/admin.py create mode 100644 dojo/object/models.py create mode 100644 dojo/object/ui/__init__.py create mode 100644 dojo/object/ui/forms.py rename dojo/object/{ => ui}/urls.py (93%) rename dojo/object/{ => ui}/views.py (88%) create mode 100644 dojo/regulations/admin.py create mode 100644 dojo/regulations/api/__init__.py create mode 100644 dojo/regulations/api/serializer.py create mode 100644 dojo/regulations/api/urls.py create mode 100644 dojo/regulations/api/views.py create mode 100644 dojo/regulations/models.py create mode 100644 dojo/regulations/ui/__init__.py create mode 100644 dojo/regulations/ui/forms.py rename dojo/regulations/{ => ui}/urls.py (88%) rename dojo/regulations/{ => ui}/views.py (96%) diff --git a/dojo/announcement/__init__.py b/dojo/announcement/__init__.py index e69de29bb2d..d22235e108d 100644 --- a/dojo/announcement/__init__.py +++ b/dojo/announcement/__init__.py @@ -0,0 +1 @@ +import dojo.announcement.admin # noqa: F401 diff --git a/dojo/announcement/admin.py b/dojo/announcement/admin.py new file mode 100644 index 00000000000..23507595678 --- /dev/null +++ b/dojo/announcement/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from dojo.announcement.models import Announcement, UserAnnouncement + +admin.site.register(Announcement) +admin.site.register(UserAnnouncement) diff --git a/dojo/announcement/api/__init__.py b/dojo/announcement/api/__init__.py new file mode 100644 index 00000000000..0e64dd504ef --- /dev/null +++ b/dojo/announcement/api/__init__.py @@ -0,0 +1 @@ +path = "announcements" # noqa: RUF067 diff --git a/dojo/announcement/api/serializer.py b/dojo/announcement/api/serializer.py new file mode 100644 index 00000000000..0022e2f22ef --- /dev/null +++ b/dojo/announcement/api/serializer.py @@ -0,0 +1,21 @@ +from django.db import IntegrityError +from rest_framework import serializers + +from dojo.announcement.models import Announcement + + +class AnnouncementSerializer(serializers.ModelSerializer): + + class Meta: + model = Announcement + fields = "__all__" + + def create(self, validated_data): + validated_data["id"] = 1 + try: + return super().create(validated_data) + except IntegrityError as e: + if 'duplicate key value violates unique constraint "dojo_announcement_pkey"' in str(e): + msg = "No more than one Announcement is allowed" + raise serializers.ValidationError(msg) + raise diff --git a/dojo/announcement/api/urls.py b/dojo/announcement/api/urls.py new file mode 100644 index 00000000000..1da04620311 --- /dev/null +++ b/dojo/announcement/api/urls.py @@ -0,0 +1,7 @@ +from dojo.announcement.api import path +from dojo.announcement.api.views import AnnouncementViewSet + + +def add_announcement_urls(router): + router.register(path, AnnouncementViewSet, basename="announcement") + return router diff --git a/dojo/announcement/api/views.py b/dojo/announcement/api/views.py new file mode 100644 index 00000000000..cf6a411adc9 --- /dev/null +++ b/dojo/announcement/api/views.py @@ -0,0 +1,20 @@ +from django_filters.rest_framework import DjangoFilterBackend + +from dojo.announcement.api.serializer import AnnouncementSerializer +from dojo.announcement.models import Announcement +from dojo.api_v2.views import DojoModelViewSet +from dojo.authorization import api_permissions as permissions + + +# Authorization: configuration +class AnnouncementViewSet( + DojoModelViewSet, +): + serializer_class = AnnouncementSerializer + queryset = Announcement.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = "__all__" + permission_classes = (permissions.UserHasConfigurationPermissionStaff,) + + def get_queryset(self): + return Announcement.objects.all().order_by("id") diff --git a/dojo/announcement/models.py b/dojo/announcement/models.py new file mode 100644 index 00000000000..9266b96c019 --- /dev/null +++ b/dojo/announcement/models.py @@ -0,0 +1,28 @@ +from django.db import models +from django.utils.translation import gettext as _ + +ANNOUNCEMENT_STYLE_CHOICES = ( + ("info", "Info"), + ("success", "Success"), + ("warning", "Warning"), + ("danger", "Danger"), +) + + +class Announcement(models.Model): + message = models.CharField(max_length=500, + help_text=_("This dismissable message will be displayed on all pages for authenticated users. It can contain basic html tags, for example https://example.com"), + default="") + style = models.CharField(max_length=64, choices=ANNOUNCEMENT_STYLE_CHOICES, default="info", + help_text=_("The style of banner to display. (info, success, warning, danger)")) + dismissable = models.BooleanField(default=False, + null=False, + blank=True, + verbose_name=_("Dismissable?"), + help_text=_("Ticking this box allows users to dismiss the current announcement"), + ) + + +class UserAnnouncement(models.Model): + announcement = models.ForeignKey("dojo.Announcement", null=True, editable=False, on_delete=models.CASCADE, related_name="user_announcement") + user = models.ForeignKey("dojo.Dojo_User", null=True, editable=False, on_delete=models.CASCADE) diff --git a/dojo/announcement/signals.py b/dojo/announcement/signals.py index c74fd0e5d50..677fafe93c2 100644 --- a/dojo/announcement/signals.py +++ b/dojo/announcement/signals.py @@ -1,7 +1,8 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from dojo.models import Announcement, Dojo_User, UserAnnouncement +from dojo.announcement.models import Announcement, UserAnnouncement +from dojo.user.models import Dojo_User @receiver(post_save, sender=Dojo_User) diff --git a/dojo/announcement/ui/__init__.py b/dojo/announcement/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/announcement/ui/forms.py b/dojo/announcement/ui/forms.py new file mode 100644 index 00000000000..47e4f97721d --- /dev/null +++ b/dojo/announcement/ui/forms.py @@ -0,0 +1,17 @@ +from django import forms + +from dojo.announcement.models import Announcement + + +class AnnouncementCreateForm(forms.ModelForm): + class Meta: + model = Announcement + fields = "__all__" + + +class AnnouncementRemoveForm(AnnouncementCreateForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["dismissable"].disabled = True + self.fields["message"].disabled = True + self.fields["style"].disabled = True diff --git a/dojo/announcement/urls.py b/dojo/announcement/ui/urls.py similarity index 88% rename from dojo/announcement/urls.py rename to dojo/announcement/ui/urls.py index 9dc91187653..2ac2f2b8af0 100644 --- a/dojo/announcement/urls.py +++ b/dojo/announcement/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.announcement import views +from dojo.announcement.ui import views urlpatterns = [ re_path( diff --git a/dojo/announcement/views.py b/dojo/announcement/ui/views.py similarity index 95% rename from dojo/announcement/views.py rename to dojo/announcement/ui/views.py index 7afe915210b..f668901e86f 100644 --- a/dojo/announcement/views.py +++ b/dojo/announcement/ui/views.py @@ -7,8 +7,8 @@ from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ -from dojo.forms import AnnouncementCreateForm, AnnouncementRemoveForm -from dojo.models import Announcement, UserAnnouncement +from dojo.announcement.models import Announcement, UserAnnouncement +from dojo.announcement.ui.forms import AnnouncementCreateForm, AnnouncementRemoveForm from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index e52b1a3a04a..7714214c49a 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -28,7 +28,6 @@ SEVERITIES, SEVERITY_CHOICES, STATS_FIELDS, - Announcement, App_Analysis, Development_Environment, DojoMeta, @@ -43,7 +42,6 @@ Notes, Product, Product_API_Scan_Configuration, - Regulation, SLA_Configuration, Sonarqube_Issue, Sonarqube_Issue_Transition, @@ -327,15 +325,6 @@ class Meta: fields = "__all__" -from dojo.tool_type.api.serializer import ToolTypeSerializer # noqa: E402, F401 -- re-export - - -class RegulationSerializer(serializers.ModelSerializer): - class Meta: - model = Regulation - fields = "__all__" - - from dojo.endpoint.api.serializer import ( # noqa: E402, F401 -- re-export; prefetcher discovery requires all moved ModelSerializers here EndpointParamsSerializer, EndpointSerializer, @@ -346,8 +335,10 @@ class Meta: JIRAIssueSerializer, JIRAProjectSerializer, ) +from dojo.regulations.api.serializer import RegulationSerializer # noqa: E402, F401 -- re-export; prefetcher discovery from dojo.tool_config.api.serializer import ToolConfigurationSerializer # noqa: E402, F401 -- re-export from dojo.tool_product.api.serializer import ToolProductSettingsSerializer # noqa: E402, F401 -- re-export +from dojo.tool_type.api.serializer import ToolTypeSerializer # noqa: E402, F401 -- re-export; prefetcher discovery class SonarqubeIssueSerializer(serializers.ModelSerializer): @@ -362,11 +353,9 @@ class Meta: fields = "__all__" -class DevelopmentEnvironmentSerializer(serializers.ModelSerializer): - class Meta: - model = Development_Environment - fields = "__all__" - +from dojo.development_environment.api.serializer import ( # noqa: E402 -- re-export; prefetcher discovery + DevelopmentEnvironmentSerializer, # noqa: F401 -- re-export; prefetcher discovery +) # Risk acceptance serializers live in dojo/risk_acceptance/api/serializer.py. Re-exported here # for backward compat: RiskAcceptanceSerializer is lazy-imported by dojo/finding/api/serializer.py @@ -1106,21 +1095,7 @@ class Meta: exclude = ("content_type",) -class AnnouncementSerializer(serializers.ModelSerializer): - - class Meta: - model = Announcement - fields = "__all__" - - def create(self, validated_data): - validated_data["id"] = 1 - try: - return super().create(validated_data) - except IntegrityError as e: - if 'duplicate key value violates unique constraint "dojo_announcement_pkey"' in str(e): - msg = "No more than one Announcement is allowed" - raise serializers.ValidationError(msg) - raise - - +from dojo.announcement.api.serializer import ( # noqa: E402 -- re-export; prefetcher discovery + AnnouncementSerializer, # noqa: F401 -- re-export; prefetcher discovery +) from dojo.notifications.api.serializer import NotificationWebhooksSerializer # noqa: E402, F401 -- backward compat diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index d4b763ea167..a90210d8b89 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -46,9 +46,7 @@ from dojo.jira import services as jira_services from dojo.labels import get_labels from dojo.models import ( - Announcement, App_Analysis, - Development_Environment, Dojo_User, DojoMeta, Endpoint, @@ -57,7 +55,6 @@ Languages, Network_Locations, Product, - Regulation, SLA_Configuration, Sonarqube_Issue, Sonarqube_Issue_Transition, @@ -316,31 +313,8 @@ def process_patch(self, request): raise ValidationError(msg) -# Authorization: authenticated, configuration -class DevelopmentEnvironmentViewSet( - DojoModelViewSet, -): - serializer_class = serializers.DevelopmentEnvironmentSerializer - queryset = Development_Environment.objects.none() - filter_backends = (DjangoFilterBackend,) - permission_classes = (IsAuthenticated, permissions.UserHasDevelopmentEnvironmentPermission) - - def get_queryset(self): - return Development_Environment.objects.all().order_by("id") - - -# Authorization: authenticated, configuration -class RegulationsViewSet( - DojoModelViewSet, -): - serializer_class = serializers.RegulationSerializer - queryset = Regulation.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = ["id", "name", "description"] - permission_classes = (IsAuthenticated, permissions.UserHasRegulationPermission) - - def get_queryset(self): - return Regulation.objects.all().order_by("id") +# DevelopmentEnvironmentViewSet moved to dojo/development_environment/api/views.py +# RegulationsViewSet moved to dojo/regulations/api/views.py # Authorization: authenticated users, DjangoModelPermissions @@ -924,15 +898,4 @@ def get_queryset(self): return SLA_Configuration.objects.all().order_by("id") -# Authorization: configuration -class AnnouncementViewSet( - DojoModelViewSet, -): - serializer_class = serializers.AnnouncementSerializer - queryset = Announcement.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = "__all__" - permission_classes = (permissions.UserHasConfigurationPermissionStaff,) - - def get_queryset(self): - return Announcement.objects.all().order_by("id") +# AnnouncementViewSet moved to dojo/announcement/api/views.py diff --git a/dojo/banner/__init__.py b/dojo/banner/__init__.py index e69de29bb2d..b0433151e3a 100644 --- a/dojo/banner/__init__.py +++ b/dojo/banner/__init__.py @@ -0,0 +1 @@ +import dojo.banner.admin # noqa: F401 diff --git a/dojo/banner/admin.py b/dojo/banner/admin.py new file mode 100644 index 00000000000..02dc5f7d321 --- /dev/null +++ b/dojo/banner/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.banner.models import BannerConf + +admin.site.register(BannerConf) diff --git a/dojo/banner/models.py b/dojo/banner/models.py new file mode 100644 index 00000000000..7c885b8ea34 --- /dev/null +++ b/dojo/banner/models.py @@ -0,0 +1,7 @@ +from django.db import models +from django.utils.translation import gettext as _ + + +class BannerConf(models.Model): + banner_enable = models.BooleanField(default=False, null=True, blank=True) + banner_message = models.CharField(max_length=500, help_text=_("This message will be displayed on the login page. It can contain basic html tags, for example https://example.com"), default="") diff --git a/dojo/banner/ui/__init__.py b/dojo/banner/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/banner/ui/forms.py b/dojo/banner/ui/forms.py new file mode 100644 index 00000000000..78a1fbbcf4b --- /dev/null +++ b/dojo/banner/ui/forms.py @@ -0,0 +1,18 @@ +from django import forms + + +class LoginBanner(forms.Form): + banner_enable = forms.BooleanField( + label="Enable login banner", + initial=False, + required=False, + help_text="Tick this box to enable a text banner on the login page", + ) + + banner_message = forms.CharField( + required=False, + label="Message to display on the login page", + ) + + def clean(self): + return super().clean() diff --git a/dojo/banner/urls.py b/dojo/banner/ui/urls.py similarity index 82% rename from dojo/banner/urls.py rename to dojo/banner/ui/urls.py index c0b75f1ff77..3751ac59d63 100644 --- a/dojo/banner/urls.py +++ b/dojo/banner/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.banner import views +from dojo.banner.ui import views urlpatterns = [ re_path( diff --git a/dojo/banner/views.py b/dojo/banner/ui/views.py similarity index 94% rename from dojo/banner/views.py rename to dojo/banner/ui/views.py index 1bdf8ce2e68..03646c7f914 100644 --- a/dojo/banner/views.py +++ b/dojo/banner/ui/views.py @@ -5,8 +5,8 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse -from dojo.forms import LoginBanner -from dojo.models import BannerConf +from dojo.banner.models import BannerConf +from dojo.banner.ui.forms import LoginBanner from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) diff --git a/dojo/development_environment/__init__.py b/dojo/development_environment/__init__.py index e69de29bb2d..adc8d51562b 100644 --- a/dojo/development_environment/__init__.py +++ b/dojo/development_environment/__init__.py @@ -0,0 +1 @@ +import dojo.development_environment.admin # noqa: F401 diff --git a/dojo/development_environment/admin.py b/dojo/development_environment/admin.py new file mode 100644 index 00000000000..a6ea8885e39 --- /dev/null +++ b/dojo/development_environment/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.development_environment.models import Development_Environment + +admin.site.register(Development_Environment) diff --git a/dojo/development_environment/api/__init__.py b/dojo/development_environment/api/__init__.py new file mode 100644 index 00000000000..d48867a443d --- /dev/null +++ b/dojo/development_environment/api/__init__.py @@ -0,0 +1 @@ +path = "development_environments" # noqa: RUF067 diff --git a/dojo/development_environment/api/serializer.py b/dojo/development_environment/api/serializer.py new file mode 100644 index 00000000000..393c6ac2e98 --- /dev/null +++ b/dojo/development_environment/api/serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from dojo.development_environment.models import Development_Environment + + +class DevelopmentEnvironmentSerializer(serializers.ModelSerializer): + class Meta: + model = Development_Environment + fields = "__all__" diff --git a/dojo/development_environment/api/urls.py b/dojo/development_environment/api/urls.py new file mode 100644 index 00000000000..6d4937f37f7 --- /dev/null +++ b/dojo/development_environment/api/urls.py @@ -0,0 +1,7 @@ +from dojo.development_environment.api import path +from dojo.development_environment.api.views import DevelopmentEnvironmentViewSet + + +def add_development_environment_urls(router): + router.register(path, DevelopmentEnvironmentViewSet, basename="development_environment") + return router diff --git a/dojo/development_environment/api/views.py b/dojo/development_environment/api/views.py new file mode 100644 index 00000000000..e79748e89be --- /dev/null +++ b/dojo/development_environment/api/views.py @@ -0,0 +1,20 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.permissions import IsAuthenticated + +from dojo.api_v2.views import DojoModelViewSet +from dojo.authorization import api_permissions as permissions +from dojo.development_environment.api.serializer import DevelopmentEnvironmentSerializer +from dojo.development_environment.models import Development_Environment + + +# Authorization: authenticated, configuration +class DevelopmentEnvironmentViewSet( + DojoModelViewSet, +): + serializer_class = DevelopmentEnvironmentSerializer + queryset = Development_Environment.objects.none() + filter_backends = (DjangoFilterBackend,) + permission_classes = (IsAuthenticated, permissions.UserHasDevelopmentEnvironmentPermission) + + def get_queryset(self): + return Development_Environment.objects.all().order_by("id") diff --git a/dojo/development_environment/models.py b/dojo/development_environment/models.py new file mode 100644 index 00000000000..d8803d6e576 --- /dev/null +++ b/dojo/development_environment/models.py @@ -0,0 +1,13 @@ +from django.db import models +from django.urls import reverse + + +class Development_Environment(models.Model): + name = models.CharField(max_length=200) + + def __str__(self): + return self.name + + def get_breadcrumbs(self): + return [{"title": str(self), + "url": reverse("edit_dev_env", args=(self.id,))}] diff --git a/dojo/development_environment/ui/__init__.py b/dojo/development_environment/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/development_environment/ui/forms.py b/dojo/development_environment/ui/forms.py new file mode 100644 index 00000000000..4df9ceca798 --- /dev/null +++ b/dojo/development_environment/ui/forms.py @@ -0,0 +1,15 @@ +from django import forms + +from dojo.development_environment.models import Development_Environment + + +class Development_EnvironmentForm(forms.ModelForm): + class Meta: + model = Development_Environment + fields = ["name"] + + +class Delete_Dev_EnvironmentForm(forms.ModelForm): + class Meta: + model = Development_Environment + fields = ["id"] diff --git a/dojo/development_environment/urls.py b/dojo/development_environment/ui/urls.py similarity index 85% rename from dojo/development_environment/urls.py rename to dojo/development_environment/ui/urls.py index 1c1c60393d7..918789fcf79 100644 --- a/dojo/development_environment/urls.py +++ b/dojo/development_environment/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.development_environment import views +from dojo.development_environment.ui import views urlpatterns = [ # dev envs diff --git a/dojo/development_environment/views.py b/dojo/development_environment/ui/views.py similarity index 95% rename from dojo/development_environment/views.py rename to dojo/development_environment/ui/views.py index 8705fdd4c7c..8429c73bff0 100644 --- a/dojo/development_environment/views.py +++ b/dojo/development_environment/ui/views.py @@ -9,9 +9,9 @@ from django.urls import reverse from dojo.authorization.authorization import user_has_configuration_permission_or_403 +from dojo.development_environment.models import Development_Environment +from dojo.development_environment.ui.forms import Delete_Dev_EnvironmentForm, Development_EnvironmentForm from dojo.filters import DevelopmentEnvironmentFilter -from dojo.forms import Delete_Dev_EnvironmentForm, Development_EnvironmentForm -from dojo.models import Development_Environment from dojo.utils import add_breadcrumb, get_page_items logger = logging.getLogger(__name__) diff --git a/dojo/forms.py b/dojo/forms.py index 0a349d0c481..0ef434a0fcc 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -45,7 +45,6 @@ from dojo.location.utils import validate_locations_to_add from dojo.models import ( SEVERITY_CHOICES, - Announcement, App_Analysis, Check_List, Development_Environment, @@ -54,10 +53,8 @@ Endpoint, FileUpload, Finding_Group, - Objects_Product, Product_API_Scan_Configuration, Product_Type, - Regulation, SLA_Configuration, Test_Type, User, @@ -178,17 +175,10 @@ def clean_name(self): return self.cleaned_data["name"] -class Development_EnvironmentForm(forms.ModelForm): - class Meta: - model = Development_Environment - fields = ["name"] - - -class Delete_Dev_EnvironmentForm(forms.ModelForm): - class Meta: - model = Development_Environment - fields = ["id"] - +from dojo.development_environment.ui.forms import ( # noqa: E402, F401 -- re-export + Delete_Dev_EnvironmentForm, + Development_EnvironmentForm, +) # Re-exported for external consumers (finding_group/test/engagement/product views + unittests). # The remaining finding forms live only in dojo.finding.ui.forms and are imported there by finding's own views. @@ -788,12 +778,7 @@ class Meta: BenchmarkForm, DeleteBenchmarkForm, ) - - -class RegulationForm(forms.ModelForm): - class Meta: - model = Regulation - exclude = ["product"] +from dojo.regulations.ui.forms import RegulationForm # noqa: E402, F401 -- re-export class AppAnalysisForm(forms.ModelForm): @@ -859,40 +844,16 @@ class Meta: fields = ["id"] -class DeleteObjectsSettingsForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = Objects_Product - fields = ["id"] - - -class ObjectSettingsForm(forms.ModelForm): - - # tags = forms.CharField(widget=forms.SelectMultiple(choices=[]), - # required=False, - # help_text="Add tags that help describe this object. " - # "Choose from the list or add new tags. Press TAB key to add.") - - class Meta: - model = Objects_Product - fields = ["path", "folder", "artifact", "name", "review_status", "tags"] - exclude = ["product"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def clean(self): - return self.cleaned_data - - from dojo.notifications.ui.forms import ( # noqa: E402, F401 -- backward compat DeleteNotificationsWebhookForm, NotificationsForm, NotificationsWebhookForm, ProductNotificationsForm, ) +from dojo.object.ui.forms import ( # noqa: E402, F401 -- re-export + DeleteObjectsSettingsForm, + ObjectSettingsForm, +) class AjaxChoiceField(forms.ChoiceField): @@ -900,35 +861,11 @@ def valid_value(self, value): return True -class LoginBanner(forms.Form): - banner_enable = forms.BooleanField( - label="Enable login banner", - initial=False, - required=False, - help_text="Tick this box to enable a text banner on the login page", - ) - - banner_message = forms.CharField( - required=False, - label="Message to display on the login page", - ) - - def clean(self): - return super().clean() - - -class AnnouncementCreateForm(forms.ModelForm): - class Meta: - model = Announcement - fields = "__all__" - - -class AnnouncementRemoveForm(AnnouncementCreateForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields["dismissable"].disabled = True - self.fields["message"].disabled = True - self.fields["style"].disabled = True +from dojo.announcement.ui.forms import ( # noqa: E402, F401 -- re-export + AnnouncementCreateForm, + AnnouncementRemoveForm, +) +from dojo.banner.ui.forms import LoginBanner # noqa: E402, F401 -- re-export class ConfigurationPermissionsForm(forms.Form): diff --git a/dojo/models.py b/dojo/models.py index c5eb30b0d15..c4d3d0aaa68 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -119,44 +119,11 @@ def __call__(self, model_instance, filename): return Path(now().strftime(self.directory)) / filename -class Regulation(models.Model): - PRIVACY_CATEGORY = "privacy" - FINANCE_CATEGORY = "finance" - EDUCATION_CATEGORY = "education" - MEDICAL_CATEGORY = "medical" - CORPORATE_CATEGORY = "corporate" - SECURITY_CATEGORY = "security" - GOVERNMENT_CATEGORY = "government" - OTHER_CATEGORY = "other" - CATEGORY_CHOICES = ( - (PRIVACY_CATEGORY, _("Privacy")), - (FINANCE_CATEGORY, _("Finance")), - (EDUCATION_CATEGORY, _("Education")), - (MEDICAL_CATEGORY, _("Medical")), - (CORPORATE_CATEGORY, _("Corporate")), - (SECURITY_CATEGORY, _("Security")), - (GOVERNMENT_CATEGORY, _("Government")), - (OTHER_CATEGORY, _("Other")), - ) - - name = models.CharField(max_length=128, unique=True, help_text=_("The name of the regulation.")) - acronym = models.CharField(max_length=20, unique=True, help_text=_("A shortened representation of the name.")) - category = models.CharField(max_length=16, choices=CATEGORY_CHOICES, help_text=_("The subject of the regulation.")) - jurisdiction = models.CharField(max_length=64, help_text=_("The territory over which the regulation applies.")) - description = models.TextField(blank=True, help_text=_("Information about the regulation's purpose.")) - reference = models.URLField(blank=True, help_text=_("An external URL for more information.")) - - class Meta: - ordering = ["name"] - - def __str__(self): - return self.acronym + " (" + self.jurisdiction + ")" - - User = get_user_model() -from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401, I001 -- must precede system_settings (middleware load-order) +from dojo.regulations.models import Regulation # noqa: E402, F401, I001 -- re-export; user/system_settings block intentionally out-of-order (load-order) +from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401 -- must precede system_settings (middleware load-order) from dojo.system_settings.models import System_Settings # noqa: E402, F401 -- re-export @@ -390,6 +357,7 @@ def __str__(self): return self.location +from dojo.development_environment.models import Development_Environment # noqa: E402, F401 -- re-export from dojo.endpoint.models import Endpoint, Endpoint_Params, Endpoint_Status # noqa: E402, F401 -- re-export from dojo.engagement.models import ( # noqa: E402 -- re-export; class-body FKs below reference these ENGAGEMENT_STATUS_CHOICES, # noqa: F401 -- re-export @@ -398,17 +366,6 @@ def __str__(self): ) -class Development_Environment(models.Model): - name = models.CharField(max_length=200) - - def __str__(self): - return self.name - - def get_breadcrumbs(self): - return [{"title": str(self), - "url": reverse("edit_dev_env", args=(self.id,))}] - - class Sonarqube_Issue(models.Model): key = models.CharField(max_length=60, unique=True, help_text=_("SonarQube issue key")) status = models.CharField(max_length=20, help_text=_("SonarQube issue status")) @@ -491,40 +448,12 @@ def get_breadcrumb(self): return bc -from dojo.risk_acceptance.models import Risk_Acceptance # noqa: E402, F401 -- re-export - -ANNOUNCEMENT_STYLE_CHOICES = ( - ("info", "Info"), - ("success", "Success"), - ("warning", "Warning"), - ("danger", "Danger"), +from dojo.announcement.models import ( # noqa: E402 -- re-export + ANNOUNCEMENT_STYLE_CHOICES, # noqa: F401 -- re-export + Announcement, # noqa: F401 -- re-export + UserAnnouncement, # noqa: F401 -- re-export ) - - -class Announcement(models.Model): - message = models.CharField(max_length=500, - help_text=_("This dismissable message will be displayed on all pages for authenticated users. It can contain basic html tags, for example https://example.com"), - default="") - style = models.CharField(max_length=64, choices=ANNOUNCEMENT_STYLE_CHOICES, default="info", - help_text=_("The style of banner to display. (info, success, warning, danger)")) - dismissable = models.BooleanField(default=False, - null=False, - blank=True, - verbose_name=_("Dismissable?"), - help_text=_("Ticking this box allows users to dismiss the current announcement"), - ) - - -class UserAnnouncement(models.Model): - announcement = models.ForeignKey(Announcement, null=True, editable=False, on_delete=models.CASCADE, related_name="user_announcement") - user = models.ForeignKey(Dojo_User, null=True, editable=False, on_delete=models.CASCADE) - - -class BannerConf(models.Model): - banner_enable = models.BooleanField(default=False, null=True, blank=True) - banner_message = models.CharField(max_length=500, help_text=_("This message will be displayed on the login page. It can contain basic html tags, for example https://example.com"), default="") - - +from dojo.banner.models import BannerConf # noqa: E402, F401 -- re-export from dojo.github.models import ( # noqa: E402, F401 -- backward compat GITHUB_Clone, GITHUB_Conf, @@ -551,6 +480,7 @@ class BannerConf(models.Model): Notification_Webhooks, Notifications, ) +from dojo.risk_acceptance.models import Risk_Acceptance # noqa: E402, F401 -- re-export from dojo.tool_product.models import Tool_Product_History, Tool_Product_Settings # noqa: E402, F401 -- re-export @@ -596,38 +526,7 @@ def __str__(self): return self.name + " | " + self.product.name -class Objects_Review(models.Model): - name = models.CharField(max_length=100, null=True, blank=True) - created = models.DateTimeField(auto_now_add=True, null=False) - - def __str__(self): - return self.name - - -class Objects_Product(models.Model): - product = models.ForeignKey(Product, on_delete=models.CASCADE) - name = models.CharField(max_length=100, null=True, blank=True) - path = models.CharField(max_length=600, verbose_name=_("Full file path"), - null=True, blank=True) - folder = models.CharField(max_length=400, verbose_name=_("Folder"), - null=True, blank=True) - artifact = models.CharField(max_length=400, verbose_name=_("Artifact"), - null=True, blank=True) - review_status = models.ForeignKey(Objects_Review, on_delete=models.CASCADE) - created = models.DateTimeField(auto_now_add=True, null=False) - - tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this object. Choose from the list or add new tags. Press Enter key to add.")) - - def __str__(self): - name = None - if self.path is not None: - name = self.path - elif self.folder is not None: - name = self.folder - elif self.artifact is not None: - name = self.artifact - - return name +from dojo.object.models import Objects_Product, Objects_Review # noqa: E402, F401 -- re-export class Testing_Guide_Category(models.Model): @@ -696,15 +595,14 @@ def __str__(self): tagulous.admin.register(Engagement.inherited_tags) tagulous.admin.register(Finding_Template.tags) tagulous.admin.register(App_Analysis.tags) -tagulous.admin.register(Objects_Product.tags) +# Objects_Product.tags registered in dojo/object/admin.py # Testing admin.site.register(Testing_Guide_Category) admin.site.register(Testing_Guide) admin.site.register(Network_Locations) -admin.site.register(Objects_Product) -admin.site.register(Objects_Review) +# Objects_Product + Objects_Review admin registered in dojo/object/admin.py admin.site.register(Languages) admin.site.register(Language_Type) admin.site.register(App_Analysis) @@ -713,7 +611,7 @@ def __str__(self): # Notes + NoteHistory admin registered in dojo/notes/admin.py # Note_Type admin registered in dojo/note_type/admin.py admin.site.register(SLA_Configuration) -admin.site.register(Regulation) +# Regulation admin registered in dojo/regulations/admin.py from dojo.authorization.models import ( # noqa: E402 Dojo_Group, Dojo_Group_Member, @@ -742,7 +640,6 @@ def __str__(self): # NoteHistory admin registered in dojo/notes/admin.py # Report_Type admin registered in dojo/reports/admin.py admin.site.register(DojoMeta) -admin.site.register(Development_Environment) -admin.site.register(Announcement) -admin.site.register(UserAnnouncement) -admin.site.register(BannerConf) +# Development_Environment admin registered in dojo/development_environment/admin.py +# Announcement + UserAnnouncement admin registered in dojo/announcement/admin.py +# BannerConf admin registered in dojo/banner/admin.py diff --git a/dojo/object/__init__.py b/dojo/object/__init__.py index e69de29bb2d..2a0769e5c0e 100644 --- a/dojo/object/__init__.py +++ b/dojo/object/__init__.py @@ -0,0 +1 @@ +import dojo.object.admin # noqa: F401 diff --git a/dojo/object/admin.py b/dojo/object/admin.py new file mode 100644 index 00000000000..5782d037789 --- /dev/null +++ b/dojo/object/admin.py @@ -0,0 +1,8 @@ +import tagulous.admin +from django.contrib import admin + +from dojo.object.models import Objects_Product, Objects_Review + +admin.site.register(Objects_Product) +admin.site.register(Objects_Review) +tagulous.admin.register(Objects_Product.tags) diff --git a/dojo/object/models.py b/dojo/object/models.py new file mode 100644 index 00000000000..37f99568d40 --- /dev/null +++ b/dojo/object/models.py @@ -0,0 +1,37 @@ +from django.db import models +from django.utils.translation import gettext as _ +from tagulous.models import TagField + + +class Objects_Review(models.Model): + name = models.CharField(max_length=100, null=True, blank=True) + created = models.DateTimeField(auto_now_add=True, null=False) + + def __str__(self): + return self.name + + +class Objects_Product(models.Model): + product = models.ForeignKey("dojo.Product", on_delete=models.CASCADE) + name = models.CharField(max_length=100, null=True, blank=True) + path = models.CharField(max_length=600, verbose_name=_("Full file path"), + null=True, blank=True) + folder = models.CharField(max_length=400, verbose_name=_("Folder"), + null=True, blank=True) + artifact = models.CharField(max_length=400, verbose_name=_("Artifact"), + null=True, blank=True) + review_status = models.ForeignKey("dojo.Objects_Review", on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True, null=False) + + tags = TagField(blank=True, force_lowercase=True, help_text=_("Add tags that help describe this object. Choose from the list or add new tags. Press Enter key to add.")) + + def __str__(self): + name = None + if self.path is not None: + name = self.path + elif self.folder is not None: + name = self.folder + elif self.artifact is not None: + name = self.artifact + + return name diff --git a/dojo/object/ui/__init__.py b/dojo/object/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/object/ui/forms.py b/dojo/object/ui/forms.py new file mode 100644 index 00000000000..1c94c6bd9e8 --- /dev/null +++ b/dojo/object/ui/forms.py @@ -0,0 +1,26 @@ +from django import forms + +from dojo.object.models import Objects_Product + + +class DeleteObjectsSettingsForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = Objects_Product + fields = ["id"] + + +class ObjectSettingsForm(forms.ModelForm): + + class Meta: + model = Objects_Product + fields = ["path", "folder", "artifact", "name", "review_status", "tags"] + exclude = ["product"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def clean(self): + return self.cleaned_data diff --git a/dojo/object/urls.py b/dojo/object/ui/urls.py similarity index 93% rename from dojo/object/urls.py rename to dojo/object/ui/urls.py index b31e9350648..9aa1d7cbbdc 100644 --- a/dojo/object/urls.py +++ b/dojo/object/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.object.ui import views urlpatterns = [ re_path(r"^product/(?P\d+)/object/add$", views.new_object, name="new_object"), diff --git a/dojo/object/views.py b/dojo/object/ui/views.py similarity index 88% rename from dojo/object/views.py rename to dojo/object/ui/views.py index 40cc57a45b2..c0f9342c6c1 100644 --- a/dojo/object/views.py +++ b/dojo/object/ui/views.py @@ -6,9 +6,9 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse -from dojo.forms import DeleteObjectsSettingsForm, ObjectSettingsForm from dojo.labels import get_labels -from dojo.models import Objects_Product, Product +from dojo.object.models import Objects_Product +from dojo.object.ui.forms import DeleteObjectsSettingsForm, ObjectSettingsForm from dojo.utils import Product_Tab logger = logging.getLogger(__name__) @@ -18,6 +18,7 @@ def new_object(request, pid): page_name = labels.ASSET_TRACKED_FILES_ADD_LABEL + from dojo.models import Product # noqa: PLC0415 -- lazy import, avoids circular dependency prod = get_object_or_404(Product, id=pid) if request.method == "POST": tform = ObjectSettingsForm(request.POST) @@ -44,6 +45,7 @@ def new_object(request, pid): def view_objects(request, pid): + from dojo.models import Product # noqa: PLC0415 -- lazy import, avoids circular dependency product = get_object_or_404(Product, id=pid) object_queryset = Objects_Product.objects.filter(product=pid).order_by("path", "folder", "artifact") @@ -59,6 +61,7 @@ def view_objects(request, pid): def edit_object(request, pid, ttid): object_prod = get_object_or_404(Objects_Product, pk=ttid) + from dojo.models import Product # noqa: PLC0415 -- lazy import, avoids circular dependency product = get_object_or_404(Product, id=pid) if object_prod.product != product: raise PermissionDenied @@ -87,6 +90,7 @@ def edit_object(request, pid, ttid): def delete_object(request, pid, ttid): object_prod = get_object_or_404(Objects_Product, pk=ttid) + from dojo.models import Product # noqa: PLC0415 -- lazy import, avoids circular dependency product = get_object_or_404(Product, id=pid) if object_prod.product != product: raise PermissionDenied diff --git a/dojo/regulations/__init__.py b/dojo/regulations/__init__.py index e69de29bb2d..a6ad8a993aa 100644 --- a/dojo/regulations/__init__.py +++ b/dojo/regulations/__init__.py @@ -0,0 +1 @@ +import dojo.regulations.admin # noqa: F401 diff --git a/dojo/regulations/admin.py b/dojo/regulations/admin.py new file mode 100644 index 00000000000..6d5961769f5 --- /dev/null +++ b/dojo/regulations/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.regulations.models import Regulation + +admin.site.register(Regulation) diff --git a/dojo/regulations/api/__init__.py b/dojo/regulations/api/__init__.py new file mode 100644 index 00000000000..de5e580ef42 --- /dev/null +++ b/dojo/regulations/api/__init__.py @@ -0,0 +1 @@ +path = "regulations" # noqa: RUF067 diff --git a/dojo/regulations/api/serializer.py b/dojo/regulations/api/serializer.py new file mode 100644 index 00000000000..519d5c0ef10 --- /dev/null +++ b/dojo/regulations/api/serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from dojo.regulations.models import Regulation + + +class RegulationSerializer(serializers.ModelSerializer): + class Meta: + model = Regulation + fields = "__all__" diff --git a/dojo/regulations/api/urls.py b/dojo/regulations/api/urls.py new file mode 100644 index 00000000000..fa88fd0086f --- /dev/null +++ b/dojo/regulations/api/urls.py @@ -0,0 +1,7 @@ +from dojo.regulations.api import path +from dojo.regulations.api.views import RegulationsViewSet + + +def add_regulations_urls(router): + router.register(path, RegulationsViewSet, basename="regulations") + return router diff --git a/dojo/regulations/api/views.py b/dojo/regulations/api/views.py new file mode 100644 index 00000000000..8d0574afc89 --- /dev/null +++ b/dojo/regulations/api/views.py @@ -0,0 +1,21 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.permissions import IsAuthenticated + +from dojo.api_v2.views import DojoModelViewSet +from dojo.authorization import api_permissions as permissions +from dojo.regulations.api.serializer import RegulationSerializer +from dojo.regulations.models import Regulation + + +# Authorization: authenticated, configuration +class RegulationsViewSet( + DojoModelViewSet, +): + serializer_class = RegulationSerializer + queryset = Regulation.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ["id", "name", "description"] + permission_classes = (IsAuthenticated, permissions.UserHasRegulationPermission) + + def get_queryset(self): + return Regulation.objects.all().order_by("id") diff --git a/dojo/regulations/models.py b/dojo/regulations/models.py new file mode 100644 index 00000000000..4910f32f481 --- /dev/null +++ b/dojo/regulations/models.py @@ -0,0 +1,36 @@ +from django.db import models +from django.utils.translation import gettext as _ + + +class Regulation(models.Model): + PRIVACY_CATEGORY = "privacy" + FINANCE_CATEGORY = "finance" + EDUCATION_CATEGORY = "education" + MEDICAL_CATEGORY = "medical" + CORPORATE_CATEGORY = "corporate" + SECURITY_CATEGORY = "security" + GOVERNMENT_CATEGORY = "government" + OTHER_CATEGORY = "other" + CATEGORY_CHOICES = ( + (PRIVACY_CATEGORY, _("Privacy")), + (FINANCE_CATEGORY, _("Finance")), + (EDUCATION_CATEGORY, _("Education")), + (MEDICAL_CATEGORY, _("Medical")), + (CORPORATE_CATEGORY, _("Corporate")), + (SECURITY_CATEGORY, _("Security")), + (GOVERNMENT_CATEGORY, _("Government")), + (OTHER_CATEGORY, _("Other")), + ) + + name = models.CharField(max_length=128, unique=True, help_text=_("The name of the regulation.")) + acronym = models.CharField(max_length=20, unique=True, help_text=_("A shortened representation of the name.")) + category = models.CharField(max_length=16, choices=CATEGORY_CHOICES, help_text=_("The subject of the regulation.")) + jurisdiction = models.CharField(max_length=64, help_text=_("The territory over which the regulation applies.")) + description = models.TextField(blank=True, help_text=_("Information about the regulation's purpose.")) + reference = models.URLField(blank=True, help_text=_("An external URL for more information.")) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.acronym + " (" + self.jurisdiction + ")" diff --git a/dojo/regulations/ui/__init__.py b/dojo/regulations/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/regulations/ui/forms.py b/dojo/regulations/ui/forms.py new file mode 100644 index 00000000000..8e3a7c5f89b --- /dev/null +++ b/dojo/regulations/ui/forms.py @@ -0,0 +1,9 @@ +from django import forms + +from dojo.regulations.models import Regulation + + +class RegulationForm(forms.ModelForm): + class Meta: + model = Regulation + exclude = ["product"] diff --git a/dojo/regulations/urls.py b/dojo/regulations/ui/urls.py similarity index 88% rename from dojo/regulations/urls.py rename to dojo/regulations/ui/urls.py index 324669f6759..21acf979edf 100644 --- a/dojo/regulations/urls.py +++ b/dojo/regulations/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from . import views +from dojo.regulations.ui import views urlpatterns = [ re_path(r"^regulations/add", views.new_regulation, name="new_regulation"), diff --git a/dojo/regulations/views.py b/dojo/regulations/ui/views.py similarity index 96% rename from dojo/regulations/views.py rename to dojo/regulations/ui/views.py index 9bbb3296190..6fd127921a0 100644 --- a/dojo/regulations/views.py +++ b/dojo/regulations/ui/views.py @@ -8,8 +8,8 @@ from django.urls import reverse from dojo.authorization.authorization import user_has_configuration_permission_or_403 -from dojo.forms import RegulationForm -from dojo.models import Regulation +from dojo.regulations.models import Regulation +from dojo.regulations.ui.forms import RegulationForm from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) diff --git a/dojo/urls.py b/dojo/urls.py index be79a7f8bd0..063d65ef220 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -10,13 +10,12 @@ from rest_framework.routers import DefaultRouter from dojo import views -from dojo.announcement.urls import urlpatterns as announcement_urls +from dojo.announcement.api.urls import add_announcement_urls +from dojo.announcement.ui.urls import urlpatterns as announcement_urls from dojo.api_v2.views import ( - AnnouncementViewSet, AppAnalysisViewSet, CeleryViewSet, ConfigurationPermissionViewSet, - DevelopmentEnvironmentViewSet, DojoMetaViewSet, ImportLanguagesView, ImportScanView, @@ -26,7 +25,6 @@ LanguageTypeViewSet, LanguageViewSet, NetworkLocationsViewset, - RegulationsViewSet, ReImportScanView, SLAConfigurationViewset, SonarqubeIssueTransitionViewSet, @@ -35,10 +33,11 @@ from dojo.api_v2.views import DojoSpectacularAPIView as SpectacularAPIView from dojo.asset.api.urls import add_asset_urls from dojo.asset.urls import urlpatterns as asset_urls -from dojo.banner.urls import urlpatterns as banner_urls +from dojo.banner.ui.urls import urlpatterns as banner_urls from dojo.benchmark.ui.urls import urlpatterns as benchmark_urls from dojo.components.urls import urlpatterns as component_urls -from dojo.development_environment.urls import urlpatterns as dev_env_urls +from dojo.development_environment.api.urls import add_development_environment_urls +from dojo.development_environment.ui.urls import urlpatterns as dev_env_urls from dojo.endpoint.api.urls import add_endpoint_urls, register_endpoint_meta_import from dojo.endpoint.ui.urls import urlpatterns as endpoint_urls from dojo.engagement.api.urls import add_engagement_urls @@ -58,12 +57,13 @@ from dojo.notes.ui.urls import urlpatterns as notes_urls from dojo.notifications.api.urls import add_notifications_urls from dojo.notifications.ui.urls import urlpatterns as notifications_urls -from dojo.object.urls import urlpatterns as object_urls +from dojo.object.ui.urls import urlpatterns as object_urls from dojo.organization.api.urls import add_organization_urls from dojo.organization.urls import urlpatterns as organization_urls from dojo.product.api.urls import add_product_urls from dojo.product_type.api.urls import add_product_type_urls -from dojo.regulations.urls import urlpatterns as regulations +from dojo.regulations.api.urls import add_regulations_urls +from dojo.regulations.ui.urls import urlpatterns as regulations from dojo.reports.ui.urls import urlpatterns as reports_urls from dojo.risk_acceptance.api.urls import add_risk_acceptance_urls from dojo.search.urls import urlpatterns as search_urls @@ -98,9 +98,9 @@ # v2 api written in django-rest-framework v2_api = DefaultRouter() -v2_api.register(r"announcements", AnnouncementViewSet, basename="announcement") +v2_api = add_announcement_urls(v2_api) v2_api.register(r"configuration_permissions", ConfigurationPermissionViewSet, basename="permission") -v2_api.register(r"development_environments", DevelopmentEnvironmentViewSet, basename="development_environment") +v2_api = add_development_environment_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # dojo_groups, dojo_group_members → pro/groups, pro/group_members v2_api = register_endpoint_meta_import(v2_api) @@ -127,7 +127,7 @@ v2_api = add_finding_urls(v2_api) # RBAC endpoints moved to Pro under legacy authorization: # product_type_members, product_type_groups → pro/product_type_members, pro/product_type_groups -v2_api.register(r"regulations", RegulationsViewSet, basename="regulations") +v2_api = add_regulations_urls(v2_api) v2_api.register(r"reimport-scan", ReImportScanView, basename="reimportscan") v2_api = add_risk_acceptance_urls(v2_api) # RBAC endpoint moved to Pro under legacy authorization: roles → pro/roles diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index c661d4678b3..f82fe0a10fc 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -33,14 +33,13 @@ ) from rest_framework.test import APIClient +from dojo.announcement.api.views import AnnouncementViewSet from dojo.api_v2.mixins import DeletePreviewModelMixin from dojo.api_v2.prefetch import PrefetchListMixin, PrefetchRetrieveMixin from dojo.api_v2.prefetch.utils import get_prefetchable_fields from dojo.api_v2.views import ( - AnnouncementViewSet, AppAnalysisViewSet, ConfigurationPermissionViewSet, - DevelopmentEnvironmentViewSet, ImportLanguagesView, ImportScanView, JiraInstanceViewSet, @@ -57,6 +56,7 @@ AssetViewSet, ) from dojo.authorization.roles_permissions import Permissions, permission_to_action +from dojo.development_environment.api.views import DevelopmentEnvironmentViewSet from dojo.endpoint.api.views import EndpointStatusViewSet, EndPointViewSet from dojo.engagement.api.views import EngagementViewSet from dojo.finding.api.views import ( From fa629bd2434dc191caa6775ed04e656d78151032 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Tue, 9 Jun 2026 12:13:56 +0200 Subject: [PATCH 37/40] docs(agents): mark Phase 10 peripheral modules Complete + refresh monolith line counts --- AGENTS.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e53dcd7cadf..ed261984c8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,18 +58,22 @@ Modules in various stages of reorganization: | **test** | In module | N/A | Done | Done | **Complete** (#14971) | | **engagement** | In module | In module | Done | Done | **Complete** (#14972) | | **product** | In module | N/A | Done | Done | **Complete** (#14973) | -| **finding** | In module | N/A (helper.py) | Done | Done | **Complete** (#14974); CWE+Burp pending | -| **peripheral (×18)** | In dojo/models.py | — | Partial/none | Partial/none | **Phase 10** (PRs #6–10, see below) | +| **finding** | In module | N/A (helper.py) | Done | Done | **Complete** (#14974, incl. CWE + BurpRawRequestResponse) | +| **user / system_settings** | In module | N/A | Done | Done | **Complete** (#14981) | +| **endpoint / tool_type / tool_config / tool_product** | In module | N/A | Done | Done | **Complete** (#14982) | +| **survey / benchmark** | In module | N/A | Done | N/A (no API) | **Complete** (#14983) | +| **notes / note_type / file_uploads / reports / risk_acceptance** | In module | N/A | Done | Done | **Complete** (#14986) | +| **regulations / banner / announcement / development_environment / object** | In module | N/A | Done | Partial (API where one exists) | **Complete** (#14987) | ### Monolithic Files Being Decomposed -These files still contain code for multiple modules. Extract code to the target module's subdirectory and leave a re-export stub. +These files still contain code for multiple modules. Extract code to the target module's subdirectory and leave a re-export stub. (Counts reflect the completed Phase 10 stack; they shrink as branches merge to `dev`.) -- `dojo/models.py` (4,973 lines) — All model definitions -- `dojo/forms.py` (4,127 lines) — All Django forms -- `dojo/filters.py` (4,016 lines) — All UI and API filter classes -- `dojo/api_v2/serializers.py` (3,387 lines) — All DRF serializers -- `dojo/api_v2/views.py` (3,519 lines) — All API viewsets +- `dojo/models.py` (~645 lines) — re-export hub + the few models intentionally left here (`DojoMeta`, `Network_Locations`, `Sonarqube_Issue`/`Sonarqube_Issue_Transition`, `Check_List`, `Testing_Guide_Category`/`Testing_Guide`, `Language_Type`/`Languages`, `App_Analysis`, `SLA_Configuration`) plus shared utilities (`copy_model_util`, `get_current_date`, `tomorrow`, `UniqueUploadNameProvider`) +- `dojo/forms.py` (~914 lines) — remaining/shared Django forms +- `dojo/filters.py` (~1,376 lines) — remaining/shared filter classes + shared bases +- `dojo/api_v2/serializers.py` (~1,101 lines) — remaining serializers + re-exports for prefetcher discovery +- `dojo/api_v2/views.py` (~901 lines) — remaining viewsets + shared base classes/helpers --- From b683ebde83968b1b035ec7edd6489c42f236110d Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Wed, 17 Jun 2026 20:42:37 +0200 Subject: [PATCH 38/40] refactor(reorg): add backward-compat re-exports for moved symbols External consumers (notably the dojo-pro plugin, a separate repo) still import several symbols from their pre-reorg locations. Add the missing backward-compat re-exports following the patterns already used on this branch: - dojo.forms: user forms (UserContactInfoForm, DojoUserForm, ...) + FindingForm - dojo.api_v2.serializers: TestType/TestCreate/EngagementCheckList serializers - dojo.api_v2.views: ViewSets moved into per-module api packages, exposed lazily via PEP 562 __getattr__ to avoid entry-order circular imports - dojo.filters: filters moved into per-module ui/api packages, exposed lazily via __getattr__ (they import dojo.filters base classes), plus BooleanFilter and Product_API_Scan_Configuration - dojo.auditlog: TAG_MODEL_MAPPING added to the lazy export table - dojo//views.py: pure re-export shims to dojo//ui/views.py (matching the existing dojo/api_v2/permissions.py / pghistory_* shims) --- dojo/api_v2/serializers.py | 12 ++++++++-- dojo/api_v2/views.py | 43 +++++++++++++++++++++++++++++++++++ dojo/auditlog/__init__.py | 1 + dojo/endpoint/views.py | 4 ++++ dojo/engagement/views.py | 4 ++++ dojo/filters.py | 40 ++++++++++++++++++++++++++++++++ dojo/finding/views.py | 4 ++++ dojo/forms.py | 8 +++++++ dojo/product/views.py | 4 ++++ dojo/reports/views.py | 4 ++++ dojo/system_settings/views.py | 4 ++++ dojo/test/views.py | 4 ++++ dojo/user/views.py | 4 ++++ 13 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 dojo/endpoint/views.py create mode 100644 dojo/engagement/views.py create mode 100644 dojo/finding/views.py create mode 100644 dojo/product/views.py create mode 100644 dojo/reports/views.py create mode 100644 dojo/system_settings/views.py create mode 100644 dojo/test/views.py create mode 100644 dojo/user/views.py diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 7714214c49a..1a239b6a447 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -287,7 +287,10 @@ def validate(self, data): # EngagementSerializer is re-exported here because ReportGenerateSerializer and # RiskAcceptanceSerializer (below) still reference it. The other engagement # serializers are imported directly from dojo.engagement.api by their consumers. -from dojo.engagement.api.serializer import EngagementSerializer # noqa: E402 -- backward compat +from dojo.engagement.api.serializer import ( # noqa: E402, F401 -- backward compat + EngagementCheckListSerializer, + EngagementSerializer, +) from dojo.file_uploads.api.serializer import ( # noqa: E402, F401 -- re-export; prefetcher + lazy consumers in finding/test/engagement FileSerializer, RawFileSerializer, @@ -365,7 +368,12 @@ class Meta: RiskAcceptanceSerializer, # noqa: F401 -- lazy-imported by finding schema overrides + prefetcher RiskAcceptanceToNotesSerializer, # noqa: F401 ) -from dojo.test.api.serializer import TestSerializer # noqa: E402 -- backward compat re-export +from dojo.test.api.serializer import ( # noqa: E402, F401 -- backward compat re-export + TestCreateSerializer, + TestSerializer, + TestTypeCreateSerializer, + TestTypeSerializer, +) class CommonImportScanSerializer(serializers.Serializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index a90210d8b89..c28b865457a 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -899,3 +899,46 @@ def get_queryset(self): # AnnouncementViewSet moved to dojo/announcement/api/views.py + +# Backward-compat re-exports for external consumers (e.g. dojo-pro) that still +# import (or attribute-access) ViewSets from dojo.api_v2.views. The viewsets +# moved into per-module api/views packages, and those modules import base +# classes back from dojo.api_v2.views at import time. Eagerly importing them +# here would create entry-order-dependent circular imports, so expose them +# lazily via PEP 562 __getattr__ instead. +import importlib # noqa: E402 + +_LAZY_VIEWSET_EXPORTS = { + "AnnouncementViewSet": "dojo.announcement.api.views", + "EndPointViewSet": "dojo.endpoint.api.views", + "EndpointMetaImporterView": "dojo.endpoint.api.views", + "EndpointStatusViewSet": "dojo.endpoint.api.views", + "EngagementViewSet": "dojo.engagement.api.views", + "EngagementPresetsViewset": "dojo.engagement.api.views", + "BurpRawRequestResponseViewSet": "dojo.finding.api.views", + "FindingViewSet": "dojo.finding.api.views", + "FindingTemplatesViewSet": "dojo.finding.api.views", + "ProductAPIScanConfigurationViewSet": "dojo.product.api.views", + "ProductTypeViewSet": "dojo.product_type.api.views", + "RiskAcceptanceViewSet": "dojo.risk_acceptance.api.views", + "SystemSettingsViewSet": "dojo.system_settings.api.views", + "TestsViewSet": "dojo.test.api.views", + "TestTypesViewSet": "dojo.test.api.views", + "TestImportViewSet": "dojo.test.api.views", + "ToolConfigurationsViewSet": "dojo.tool_config.api.views", + "ToolProductSettingsViewSet": "dojo.tool_product.api.views", + "ToolTypesViewSet": "dojo.tool_type.api.views", + "DevelopmentEnvironmentViewSet": "dojo.development_environment.api.views", + "RegulationsViewSet": "dojo.regulations.api.views", + "UserContactInfoViewSet": "dojo.user.api.views", + "UsersViewSet": "dojo.user.api.views", + "UserProfileView": "dojo.user.api.views", +} + + +def __getattr__(name): + module_path = _LAZY_VIEWSET_EXPORTS.get(name) + if module_path is None: + msg = f"module 'dojo.api_v2.views' has no attribute {name!r}" + raise AttributeError(msg) + return getattr(importlib.import_module(module_path), name) diff --git a/dojo/auditlog/__init__.py b/dojo/auditlog/__init__.py index b37e10499b4..bf33db6edac 100644 --- a/dojo/auditlog/__init__.py +++ b/dojo/auditlog/__init__.py @@ -14,6 +14,7 @@ "configure_pghistory_triggers": "dojo.auditlog.services", "register_django_pghistory_models": "dojo.auditlog.services", "process_events_for_display": "dojo.auditlog.helpers", + "TAG_MODEL_MAPPING": "dojo.auditlog.helpers", "get_tracked_models": "dojo.auditlog.backfill", "process_model_backfill": "dojo.auditlog.backfill", } diff --git a/dojo/endpoint/views.py b/dojo/endpoint/views.py new file mode 100644 index 00000000000..3458a477812 --- /dev/null +++ b/dojo/endpoint/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.endpoint.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.endpoint.views, so re-export the public names from their new location. +from dojo.endpoint.ui.views import * # noqa: F403 -- backward compat re-export diff --git a/dojo/engagement/views.py b/dojo/engagement/views.py new file mode 100644 index 00000000000..af84d6b39e1 --- /dev/null +++ b/dojo/engagement/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.engagement.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.engagement.views, so re-export the public names from their new location. +from dojo.engagement.ui.views import * # noqa: F403 -- backward compat re-export diff --git a/dojo/filters.py b/dojo/filters.py index b69f2722977..7b2e05b018b 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -1373,4 +1373,44 @@ class Meta: # QuestionnaireFilter, QuestionTypeFilter, QuestionFilter live in dojo/survey/ui/filters.py +import importlib # noqa: E402 + +# Backward-compat re-exports for external consumers (e.g. dojo-pro) that still +# import filters from dojo.filters. The filter classes themselves moved into the +# per-module ui/api filter packages. Those modules import dojo.filters base +# classes (DojoFilter, ...) at import time, so eagerly importing them back here +# creates circular imports. Instead expose them lazily via PEP 562 __getattr__, +# which only resolves the target module when the name is actually accessed. +from django_filters import BooleanFilter # noqa: E402, F401 -- backward compat re-export + from dojo.auditlog.filters import LogEntryFilter, PgHistoryFilter # noqa: E402, F401 -- backward compat +from dojo.models import Product_API_Scan_Configuration # noqa: E402, F401 -- backward compat re-export + +_LAZY_FILTER_EXPORTS = { + "ApiEndpointFilter": "dojo.endpoint.api.filters", + "EndpointFilter": "dojo.endpoint.ui.filters", + "EndpointFilterWithoutObjectLookups": "dojo.endpoint.ui.filters", + "ApiEngagementFilter": "dojo.engagement.api.filters", + "EngagementDirectFilter": "dojo.engagement.ui.filters", + "EngagementTestFilter": "dojo.engagement.ui.filters", + "EngagementTestFilterWithoutObjectLookups": "dojo.engagement.ui.filters", + "ApiFindingFilter": "dojo.finding.api.filters", + "AcceptedFindingFilter": "dojo.finding.ui.filters", + "AcceptedFindingFilterWithoutObjectLookups": "dojo.finding.ui.filters", + "FindingFilter": "dojo.finding.ui.filters", + "FindingFilterWithoutObjectLookups": "dojo.finding.ui.filters", + "ReportFindingFilter": "dojo.finding.ui.filters", + "ReportFindingFilterWithoutObjectLookups": "dojo.finding.ui.filters", + "ApiProductFilter": "dojo.product.api.filters", + "ProductFilter": "dojo.product.ui.filters", + "ApiTestFilter": "dojo.test.api.filters", + "UserFilter": "dojo.user.ui.filters", +} + + +def __getattr__(name): + module_path = _LAZY_FILTER_EXPORTS.get(name) + if module_path is None: + msg = f"module 'dojo.filters' has no attribute {name!r}" + raise AttributeError(msg) + return getattr(importlib.import_module(module_path), name) diff --git a/dojo/finding/views.py b/dojo/finding/views.py new file mode 100644 index 00000000000..41b9deba7ff --- /dev/null +++ b/dojo/finding/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.finding.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.finding.views, so re-export the public names from their new location. +from dojo.finding.ui.views import * # noqa: F403 -- backward compat re-export diff --git a/dojo/forms.py b/dojo/forms.py index 0ef434a0fcc..c9075e07b6a 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -189,6 +189,14 @@ def clean_name(self): DeleteFindingGroupForm, EditFindingGroupForm, FindingBulkUpdateForm, + FindingForm, +) +from dojo.user.ui.forms import ( # noqa: E402, F401 -- backward compat re-export + AddDojoUserForm, + DeleteUserForm, + DojoUserForm, + EditDojoUserForm, + UserContactInfoForm, ) diff --git a/dojo/product/views.py b/dojo/product/views.py new file mode 100644 index 00000000000..70ad404054a --- /dev/null +++ b/dojo/product/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.product.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.product.views, so re-export the public names from their new location. +from dojo.product.ui.views import * # noqa: F403 -- backward compat re-export diff --git a/dojo/reports/views.py b/dojo/reports/views.py new file mode 100644 index 00000000000..a4e62f95ffc --- /dev/null +++ b/dojo/reports/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.reports.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.reports.views, so re-export the public names from their new location. +from dojo.reports.ui.views import * # noqa: F403 -- backward compat re-export diff --git a/dojo/system_settings/views.py b/dojo/system_settings/views.py new file mode 100644 index 00000000000..8fce6fa45c8 --- /dev/null +++ b/dojo/system_settings/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.system_settings.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.system_settings.views, so re-export the public names from their new location. +from dojo.system_settings.ui.views import * # noqa: F403 -- backward compat re-export diff --git a/dojo/test/views.py b/dojo/test/views.py new file mode 100644 index 00000000000..c8a504b106a --- /dev/null +++ b/dojo/test/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.test.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.test.views, so re-export the public names from their new location. +from dojo.test.ui.views import * # noqa: F403 -- backward compat re-export diff --git a/dojo/user/views.py b/dojo/user/views.py new file mode 100644 index 00000000000..d193f7e5dbe --- /dev/null +++ b/dojo/user/views.py @@ -0,0 +1,4 @@ +# Backward-compat shim: the view logic moved to dojo.user.ui.views during the +# module reorg. External consumers (e.g. dojo-pro) still import from +# dojo.user.views, so re-export the public names from their new location. +from dojo.user.ui.views import * # noqa: F403 -- backward compat re-export From 973cbb5872b3273877d864e6945babeb9c462441 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Wed, 17 Jun 2026 23:28:18 +0200 Subject: [PATCH 39/40] fix(test): retarget finding_added dispatch patch to moved serializer module The reorg moved FindingCreateSerializer (and its dojo_dispatch_task call for the finding_added notification) from dojo/api_v2/serializers.py to dojo/finding/api/serializer.py. Three TestNotificationTriggersApi tests still patched dojo.api_v2.serializers.dojo_dispatch_task, which no longer has that attribute -> AttributeError at patch time. Point them at the module where the dispatch now resolves. Co-Authored-By: Claude Opus 4.8 --- unittests/test_notifications.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/unittests/test_notifications.py b/unittests/test_notifications.py index 1980f73504d..504e168e70b 100644 --- a/unittests/test_notifications.py +++ b/unittests/test_notifications.py @@ -478,7 +478,7 @@ def test_auditlog_on(self, mock): self.client.delete(reverse("product_type-detail", args=(prod_type.pk,)), format="json") self.assertEqual(mock.call_args_list[-1].kwargs["description"], 'The Organization "notif prod type API" was deleted by admin') - @patch("dojo.api_v2.serializers.dojo_dispatch_task") + @patch("dojo.finding.api.serializer.dojo_dispatch_task") def test_create_calls_notification_with_auto_assigned_reporter(self, mock_dispatch): """Dispatch of async_create_notification when creating a finding without explicit reporter.""" payload = self._minimal_create_payload("Finding with auto-assigned reporter notification") @@ -504,7 +504,7 @@ def test_create_calls_notification_with_auto_assigned_reporter(self, mock_dispat created_finding = Finding.objects.get(id=created_id) self.assertEqual(created_finding.reporter, self.admin) - @patch("dojo.api_v2.serializers.dojo_dispatch_task") + @patch("dojo.finding.api.serializer.dojo_dispatch_task") def test_create_calls_notification_with_explicit_reporter(self, mock_dispatch): """Dispatch of async_create_notification when creating a finding with explicit reporter.""" explicit_reporter = User.objects.create(username="explicit_reporter", email="reporter@test.com") @@ -533,7 +533,7 @@ def test_create_calls_notification_with_explicit_reporter(self, mock_dispatch): created_finding = Finding.objects.get(id=created_id) self.assertEqual(created_finding.reporter, explicit_reporter) - @patch("dojo.api_v2.serializers.dojo_dispatch_task") + @patch("dojo.finding.api.serializer.dojo_dispatch_task") def test_notification_parameters_are_correct(self, mock_dispatch): """All dispatch parameters for finding_added are properly formatted and passed.""" payload = self._minimal_create_payload("Test Finding for Parameter Validation") From e0a0fef85ea7ad1c46dc2468c5c42b0870bbc5cc Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Thu, 18 Jun 2026 19:55:59 +0200 Subject: [PATCH 40/40] fix(reorg): restore disabled prefetch schema on DojoMetaViewSet The DojoMetaViewSet extraction re-added an active @extend_schema_view(**schema_with_prefetch()) above the intentionally commented-out original. This re-enabled prefetch schema generation for the DojoMeta endpoint, which emits a $ref to a Location component that is not registered when V3_FEATURE_LOCATIONS=False, breaking the openapi-generator schema validation integration test (PaginatedMetaList.Location is not of type schema). --- dojo/api_v2/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index c28b865457a..3f3070f2fcb 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -218,8 +218,6 @@ def get_queryset(self): return Sonarqube_Issue_Transition.objects.all().order_by("id") -# Authorization: object-based -@extend_schema_view(**schema_with_prefetch()) # Authorization: object-based # @extend_schema_view(**schema_with_prefetch()) # Nested models with prefetch make the response schema too long for Swagger UI