diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 87483581d7d..1042c3aa861 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -319,6 +319,27 @@ class FindingFilter(FilterSet): field_name="resources__type", lookup_expr="icontains" ) + resource_tag_key = CharFilter(field_name="resources__tags__key") + resource_tag_key__in = CharInFilter( + field_name="resources__tags__key", lookup_expr="in" + ) + resource_tag_key__icontains = CharFilter( + field_name="resources__tags__key", lookup_expr="icontains" + ) + resource_tag_value = CharFilter(field_name="resources__tags__value") + resource_tag_value__in = CharInFilter( + field_name="resources__tags__value", lookup_expr="in" + ) + resource_tag_value__icontains = CharFilter( + field_name="resources__tags__value", lookup_expr="icontains" + ) + resource_tags = CharInFilter( + method="filter_resource_tag", + lookup_expr="in", + help_text="Filter by resource tags `key:value` pairs.\nMultiple values may be " + "separated by commas.", + ) + scan = UUIDFilter(method="filter_scan_id") scan__in = UUIDInFilter(method="filter_scan_id_in") @@ -353,6 +374,12 @@ class Meta: }, } + @property + def qs(self): + # Force distinct results to prevent duplicates with many-to-many relationships + parent_qs = super().qs + return parent_qs.distinct() + # Convert filter values to UUIDv7 values for use with partitioning def filter_scan_id(self, queryset, name, value): try: @@ -426,6 +453,16 @@ def filter_inserted_at_lte(self, queryset, name, value): return queryset.filter(id__lte=end).filter(inserted_at__lte=value) + def filter_resource_tag(self, queryset, name, value): + overall_query = Q() + for key_value_pair in value: + tag_key, tag_value = key_value_pair.split(":", 1) + overall_query |= Q( + resources__tags__key__icontains=tag_key, + resources__tags__value__icontains=tag_value, + ) + return queryset.filter(overall_query).distinct() + @staticmethod def maybe_date_to_datetime(value): dt = value diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 966f09a6ec1..6cccee84042 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -477,6 +477,51 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_tag_key] + schema: + type: string + - in: query + name: filter[resource_tag_key__icontains] + schema: + type: string + - in: query + name: filter[resource_tag_key__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_tag_value] + schema: + type: string + - in: query + name: filter[resource_tag_value__icontains] + schema: + type: string + - in: query + name: filter[resource_tag_value__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_tags] + schema: + type: array + items: + type: string + description: |- + Filter by resource tags `key:value` pairs. + Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_type] schema: @@ -983,6 +1028,51 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_tag_key] + schema: + type: string + - in: query + name: filter[resource_tag_key__icontains] + schema: + type: string + - in: query + name: filter[resource_tag_key__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_tag_value] + schema: + type: string + - in: query + name: filter[resource_tag_value__icontains] + schema: + type: string + - in: query + name: filter[resource_tag_value__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_tags] + schema: + type: array + items: + type: string + description: |- + Filter by resource tags `key:value` pairs. + Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_type] schema: @@ -1410,6 +1500,51 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[resource_tag_key] + schema: + type: string + - in: query + name: filter[resource_tag_key__icontains] + schema: + type: string + - in: query + name: filter[resource_tag_key__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_tag_value] + schema: + type: string + - in: query + name: filter[resource_tag_value__icontains] + schema: + type: string + - in: query + name: filter[resource_tag_value__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_tags] + schema: + type: array + items: + type: string + description: |- + Filter by resource tags `key:value` pairs. + Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[resource_type] schema: diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index e6f19f92412..c175334f150 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -2444,6 +2444,15 @@ def test_findings_list_include( ("search", "ec2", 2), # full text search on finding tags ("search", "value2", 2), + ("resource_tag_key", "key", 2), + ("resource_tag_key__in", "key,key2", 2), + ("resource_tag_key__icontains", "key", 2), + ("resource_tag_value", "value", 2), + ("resource_tag_value__in", "value,value2", 2), + ("resource_tag_value__icontains", "value", 2), + ("resource_tags", "key:value", 2), + ("resource_tags", "not:exists", 0), + ("resource_tags", "not:exists,key:value", 2), ] ), ) @@ -2592,7 +2601,7 @@ def test_findings_metadata_retrieve(self, authenticated_client, findings_fixture expected_services = {"ec2", "s3"} expected_regions = {"eu-west-1", "us-east-1"} - expected_tags = {"key": "value", "key2": "value2"} + expected_tags = {"key": ["value"], "key2": ["value2"]} expected_resource_types = {"prowler-test"} assert data["data"]["type"] == "findings-metadata" @@ -2619,7 +2628,7 @@ def test_findings_metadata_severity_retrieve( expected_services = {"s3"} expected_regions = {"eu-west-1"} - expected_tags = {"key": "value", "key2": "value2"} + expected_tags = {"key": ["value"], "key2": ["value2"]} expected_resource_types = {"prowler-test"} assert data["data"]["type"] == "findings-metadata" diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index d166a8d15a7..67728a65dc5 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1414,7 +1414,14 @@ def metadata(self, request): if result["tags"] is None: result["tags"] = [] - result["tags"] = {t["key"]: t["value"] for t in result["tags"]} + tags_dict = {} + for t in result["tags"]: + key, value = t["key"], t["value"] + if key not in tags_dict: + tags_dict[key] = [] + tags_dict[key].append(value) + + result["tags"] = tags_dict serializer = self.get_serializer( data=result,