From 2fe92cfce32752a3d8a888ef1478ca6334704306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A1n=20Pe=C3=B1a?= Date: Thu, 19 Mar 2026 16:48:26 +0100 Subject: [PATCH] feat(api): add check title search for finding groups (#10377) --- api/CHANGELOG.md | 1 + api/src/backend/api/filters.py | 6 ++++ .../tests/integration/test_authentication.py | 9 +++--- api/src/backend/api/tests/test_apps.py | 30 +++++++++++-------- api/src/backend/api/tests/test_views.py | 16 ++++++++++ api/src/backend/api/v1/views.py | 25 +++++++++++++--- 6 files changed, 65 insertions(+), 22 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 7cd4fce52f..dff4da4899 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🚀 Added - `CORS_ALLOWED_ORIGINS` configurable via environment variable [(#10355)](https://github.com/prowler-cloud/prowler/pull/10355) +- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377) - Attack Paths: Tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308) ### 🔄 Changed diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index a64cc0ea13..d8b803d475 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -926,6 +926,9 @@ class FindingGroupSummaryFilter(FilterSet): check_id = CharFilter(field_name="check_id", lookup_expr="exact") check_id__in = CharInFilter(field_name="check_id", lookup_expr="in") check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains") + check_title__icontains = CharFilter( + field_name="check_title", lookup_expr="icontains" + ) # Provider filters provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact") @@ -1025,6 +1028,9 @@ class LatestFindingGroupSummaryFilter(FilterSet): check_id = CharFilter(field_name="check_id", lookup_expr="exact") check_id__in = CharInFilter(field_name="check_id", lookup_expr="in") check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains") + check_title__icontains = CharFilter( + field_name="check_title", lookup_expr="icontains" + ) # Provider filters provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact") diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 2abf725124..26d5b8e108 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -301,7 +301,7 @@ class TestTokenSwitchTenant: assert invalid_tenant_response.status_code == 400 assert invalid_tenant_response.json()["errors"][0]["code"] == "invalid" assert invalid_tenant_response.json()["errors"][0]["detail"] == ( - "Tenant does not exist or user is not a " "member." + "Tenant does not exist or user is not a member." ) @@ -912,10 +912,9 @@ class TestAPIKeyLifecycle: auth_response = client.get(reverse("provider-list"), headers=api_key_headers) # Must return 401 Unauthorized, not 500 Internal Server Error - assert auth_response.status_code == 401, ( - f"Expected 401 but got {auth_response.status_code}: " - f"{auth_response.json()}" - ) + assert ( + auth_response.status_code == 401 + ), f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}" # Verify error message is present response_json = auth_response.json() diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 712bc33882..5889b4e2cb 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -10,11 +10,11 @@ from django.conf import settings import api import api.apps as api_apps_module from api.apps import ( - ApiConfig, PRIVATE_KEY_FILE, PUBLIC_KEY_FILE, SIGNING_KEY_ENV, VERIFYING_KEY_ENV, + ApiConfig, ) @@ -187,9 +187,10 @@ def test_ready_initializes_driver_for_api_process(monkeypatch): _set_argv(monkeypatch, ["gunicorn"]) _set_testing(monkeypatch, False) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_called_once() @@ -200,9 +201,10 @@ def test_ready_skips_driver_for_celery(monkeypatch): _set_argv(monkeypatch, ["celery", "-A", "api"]) _set_testing(monkeypatch, False) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_not_called() @@ -213,9 +215,10 @@ def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch): _set_argv(monkeypatch, ["manage.py", "migrate"]) _set_testing(monkeypatch, False) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_not_called() @@ -226,9 +229,10 @@ def test_ready_skips_driver_when_testing(monkeypatch): _set_argv(monkeypatch, ["gunicorn"]) _set_testing(monkeypatch, True) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_not_called() diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index d857b2bc07..7dd14bf420 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -15526,6 +15526,22 @@ class TestFindingGroupViewSet: assert len(response.json()["data"]) == 1 assert "bucket" in response.json()["data"][0]["id"].lower() + def test_finding_groups_check_title_icontains( + self, authenticated_client, finding_groups_fixture + ): + """Test searching check titles with icontains.""" + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[check_title.icontains]": "public access", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == "s3_bucket_public_access" + def test_resources_not_found(self, authenticated_client): """Test 404 returned for nonexistent check_id.""" response = authenticated_client.get( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 7878b51351..7c2de4a41c 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -4,7 +4,6 @@ import json import logging import os import time - from collections import defaultdict from copy import deepcopy from datetime import datetime, timedelta, timezone @@ -12,7 +11,6 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from urllib.parse import urljoin import sentry_sdk - from allauth.socialaccount.models import SocialAccount, SocialApp from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter @@ -76,6 +74,7 @@ from rest_framework.exceptions import ( ) from rest_framework.generics import GenericAPIView, get_object_or_404 from rest_framework.permissions import SAFE_METHODS +from rest_framework_json_api import filters as jsonapi_filters from rest_framework_json_api.views import RelationshipView, Response from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from tasks.beat import schedule_provider_scan @@ -100,7 +99,6 @@ from api.attack_paths import database as graph_database from api.attack_paths import get_queries_for_provider, get_query_by_id from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset -from api.renderers import APIJSONRenderer, PlainTextRenderer from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, @@ -199,6 +197,7 @@ from api.models import ( ) from api.pagination import ComplianceOverviewPagination from api.rbac.permissions import Permissions, get_providers, get_role +from api.renderers import APIJSONRenderer, PlainTextRenderer from api.rls import Tenant from api.utils import ( CustomOAuth2Client, @@ -6777,13 +6776,29 @@ class FindingGroupViewSet(BaseRLSViewSet): queryset = FindingGroupDailySummary.objects.all() serializer_class = FindingGroupSerializer filterset_class = FindingGroupSummaryFilter + filter_backends = [ + jsonapi_filters.QueryParameterValidationFilter, + jsonapi_filters.OrderingFilter, + CustomDjangoFilterBackend, + ] http_method_names = ["get"] required_permissions = [] def get_filterset_class(self): - """Return appropriate filter based on action.""" + """Return the filterset class used for schema generation and the list action. + + Note: The resources and latest_resources actions do not use this method + at runtime. They manually instantiate FindingGroupFilter / + LatestFindingGroupFilter against a Finding queryset (see + _get_finding_queryset). The class returned here for those actions only + affects the OpenAPI schema generated by drf-spectacular. + """ if self.action == "latest": return LatestFindingGroupSummaryFilter + if self.action == "resources": + return FindingGroupFilter + if self.action == "latest_resources": + return LatestFindingGroupFilter return FindingGroupSummaryFilter def get_queryset(self): @@ -7237,6 +7252,7 @@ class FindingGroupViewSet(BaseRLSViewSet): and timing information including how long they have been failing. """, tags=["Finding Groups"], + filters=True, ) @action(detail=True, methods=["get"], url_path="resources") def resources(self, request, pk=None): @@ -7311,6 +7327,7 @@ class FindingGroupViewSet(BaseRLSViewSet): and timing information. No date filters required. """, tags=["Finding Groups"], + filters=True, ) @action( detail=False,