Compare commits

..
Author SHA1 Message Date
Adrián Jesús Peña Rodríguez ffc16e1021 Merge branch 'master' into fix/finding-groups-aggregated-filters 2026-03-24 12:38:25 +01:00
Adrián Jesús Peña Rodríguez c99a203517 fix(api): use hybrid aggregation path for finding-group filters
- Aggregate from findings when finding-level filters are present (region, delta, etc.)
- Keep summary path for compatible filters to preserve performance
- Apply status/severity filters post-aggregation in both paths
- Fix sort by time fields mapping to aggregated annotation names
- Use DenseRank instead of RowNumber to keep all findings per latest scan
- Expose muted_reason in finding-group resource endpoints
- Add tests for combined filters, validation errors, and time-field sorting
- Deduplicate list/latest actions via shared helpers
- Consolidate check_title icontains filter in base class
2026-03-24 12:34:49 +01:00
Josema Camacho 55ed7a0663 docs(CHANGELOG): cutting for 5.22.0 (#10437) 2026-03-24 12:15:44 +01:00
Alan Buscaglia 0599040d4e feat(ui): add batch apply pattern to Findings filters (#10388) 2026-03-24 11:09:11 +01:00
lydiavilchezandAndoni A. 737d20d2c1 docs(googleworkspace): add Cloud/App documentation (#10421)
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2026-03-24 09:48:01 +01:00
Josema Camacho 844efbd046 perf(api): deduplicate nodes before ProwlerFinding lookup in Attack Paths queries (#10424) 2026-03-23 17:16:15 +01:00
Adrián Jesús Peña Rodríguez ce9e1af176 Merge branch 'master' into fix/finding-groups-aggregated-filters 2026-03-23 17:03:08 +01:00
Josema Camacho d60b4f0f52 fix(api): Update Flask and Werkzeug to address vulnerabilities (#10430) 2026-03-23 16:59:03 +01:00
Hugo Pereira Brito 49ba25ba07 feat(ui): add custom attack paths queries (#10397) 2026-03-23 15:36:37 +00:00
Adrián Jesús Peña Rodríguez cdfc52732c chore: ruff format 2026-03-23 16:27:54 +01:00
Adrián Jesús Peña Rodríguez 373f6e8ca0 chore(api): update changelog for finding-group filters 2026-03-23 16:23:23 +01:00
Daniel Barranquero 41629137ef docs: remove cookbook from k8s section (#10427) 2026-03-23 16:22:54 +01:00
Hugo Pereira Brito 114e86c0dc fix(sdk): ignore disabled users in Entra MFA check (#10426) 2026-03-23 15:21:31 +00:00
Adrián Jesús Peña Rodríguez efe14dfa7d fix(api): support finding-group aggregated filters
- Apply status and severity filters on aggregated finding-group results

- Prefilter summary groups by finding-level filter matches for advanced dimensions

- Add coverage for latest/list filters and keep check_title sorting support
2026-03-23 16:20:51 +01:00
Prowler Botandprowler-bot 1015f1379f feat(aws): Update regions for AWS services (#10413)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-23 15:28:51 +01:00
Prowler Botandprowler-bot c62ac6c71b feat(aws): Update regions for AWS services (#10076)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-23 15:26:29 +01:00
Daniel BarranqueroandAndoni A. 14356e3187 docs: add cookbooks section (#10410)
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2026-03-23 13:51:07 +01:00
Adrián Peña 591f5a8603 fix(api): align finding-group latest aggregation (#10419) 2026-03-23 12:43:45 +01:00
mintlify[bot]mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>Josema Camacho
93b8a7c74c docs(attack-paths): Lighthouse AI support and supported queries to Attack Paths (#10409)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-03-23 11:12:26 +01:00
Hugo Pereira Brito 7df73a9d4f fix(sdk): use case-insensitive comparison for Azure MySQL flexible server checks (#10396) 2026-03-23 09:59:14 +00:00
Hugo Pereira Brito 1eda94140d fix(sdk): use case-insensitive comparison for Azure VM backup checks (#10395) 2026-03-23 09:45:08 +00:00
Adrián Peña ad6368a446 chore: add defusedxml as api dependency (#10401) 2026-03-19 18:26:55 +01:00
Adrián Peña 3361393b7d chore: update changelog (#10400) 2026-03-19 17:55:18 +01:00
0b7a21a70c fix(api): [security] use defusedxml to prevent XML bomb DoS in SAML metadata parsing (#10165)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-03-19 17:44:52 +01:00
Josema Camacho 872e6e239c perf(api): replace JOINs with pre-check in threat score aggregation query (#10394) 2026-03-19 17:30:06 +01:00
Adrián Peña 2fe92cfce3 feat(api): add check title search for finding groups (#10377) 2026-03-19 16:48:26 +01:00
César Arroba cece2cb87e chore: pin Prowler version to lastest master commit on push (#10384) 2026-03-19 14:32:38 +01:00
Adrián Peña ab266080d0 perf(api): add trigram indexes for finding groups (#10378) 2026-03-19 13:54:50 +01:00
Prowler Botandprowler-bot 4638b39ed4 chore(api): Bump version to v1.23.0 (#10393)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-19 13:42:46 +01:00
Prowler Botandprowler-bot 997f9bf64a docs: Update version to v5.21.0 (#10391)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-19 13:40:33 +01:00
Prowler Botandprowler-bot aecc234f78 chore(release): Bump version to v5.22.0 (#10389)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-03-19 13:40:22 +01:00
85 changed files with 6393 additions and 1083 deletions
@@ -99,6 +99,12 @@ jobs:
with:
persist-credentials: false
- name: Pin prowler SDK to latest master commit
if: github.event_name == 'push'
run: |
LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1)
sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml
- name: Login to DockerHub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
+27 -1
View File
@@ -2,11 +2,36 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.24.0] (Prowler UNRELEASED)
### 🐞 Fixed
- Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428)
## [1.23.0] (Prowler v5.22.0)
### 🚀 Added
- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
### 🐞 Fixed
- Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
- Mute rule creation now triggers finding-group summary re-aggregation after historical muting, keeping stats in sync after mute operations [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
- Attack Paths: Deduplicate nodes before ProwlerFinding lookup in Attack Paths Cypher queries, reducing execution time [(#10424)](https://github.com/prowler-cloud/prowler/pull/10424)
### 🔐 Security
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430)
---
## [1.22.1] (Prowler v5.21.1)
### 🐞 Fixed
- ThreatScore aggregation query to eliminate unnecessary JOINs and `COUNT(DISTINCT)` overhead [(#10394)](https://github.com/prowler-cloud/prowler/pull/10394)
- Threat score aggregation query to eliminate unnecessary JOINs and `COUNT(DISTINCT)` overhead [(#10394)](https://github.com/prowler-cloud/prowler/pull/10394)
---
@@ -29,6 +54,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🔐 Security
- Use `psycopg2.sql` to safely compose DDL in `PostgresEnumMigration`, preventing SQL injection via f-string interpolation [(#10166)](https://github.com/prowler-cloud/prowler/pull/10166)
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
---
+20 -375
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -22,9 +22,10 @@ dependencies = [
"drf-nested-routers (>=0.94.1,<1.0.0)",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==5.3.2",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.21",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -49,7 +50,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.22.1"
version = "1.23.0"
[project.scripts]
celery = "src.backend.config.settings.celery"
File diff suppressed because it is too large Load Diff
+107
View File
@@ -15,6 +15,7 @@ from django_filters.rest_framework import (
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
from rest_framework_json_api.serializers import ValidationError
from api.constants import SEVERITY_ORDER
from api.db_utils import (
FindingDeltaEnumField,
InvitationStateEnumField,
@@ -264,6 +265,13 @@ class CommonFindingFilters(FilterSet):
)
return queryset.filter(overall_query).distinct()
def filter_check_title_icontains(self, queryset, name, value):
return queryset.filter(
Q(check_metadata__CheckTitle__icontains=value)
| Q(check_metadata__checktitle__icontains=value)
| Q(check_metadata__Checktitle__icontains=value)
)
class TenantFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
@@ -803,11 +811,15 @@ class FindingGroupFilter(CommonFindingFilters):
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(method="filter_check_title_icontains")
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
class Meta:
model = Finding
fields = {
"check_id": ["exact", "in", "icontains"],
"scan": ["exact", "in"],
}
def filter_queryset(self, queryset):
@@ -895,11 +907,15 @@ class LatestFindingGroupFilter(CommonFindingFilters):
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(method="filter_check_title_icontains")
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
class Meta:
model = Finding
fields = {
"check_id": ["exact", "in", "icontains"],
"scan": ["exact", "in"],
}
@@ -926,6 +942,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 +1044,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")
@@ -1042,6 +1064,91 @@ class LatestFindingGroupSummaryFilter(FilterSet):
}
class FindingGroupAggregatedComputedFilter(FilterSet):
"""Filter aggregated finding-group rows by computed status/severity."""
STATUS_CHOICES = (
("FAIL", "Fail"),
("PASS", "Pass"),
("MUTED", "Muted"),
)
status = ChoiceFilter(method="filter_status", choices=STATUS_CHOICES)
status__in = CharInFilter(method="filter_status_in", lookup_expr="in")
severity = ChoiceFilter(method="filter_severity", choices=SeverityChoices)
severity__in = CharInFilter(method="filter_severity_in", lookup_expr="in")
def filter_status(self, queryset, name, value):
return queryset.filter(aggregated_status=value)
def filter_status_in(self, queryset, name, value):
values = value
if isinstance(value, str):
values = [part.strip() for part in value.split(",") if part.strip()]
allowed = {choice[0] for choice in self.STATUS_CHOICES}
invalid = [
status_value for status_value in values if status_value not in allowed
]
if invalid:
raise ValidationError(
[
{
"detail": f"invalid status filter: {invalid[0]}",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
)
if not values:
return queryset
return queryset.filter(aggregated_status__in=values)
def filter_severity(self, queryset, name, value):
severity_order = SEVERITY_ORDER.get(value)
if severity_order is None:
raise ValidationError(
[
{
"detail": f"invalid severity filter: {value}",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
)
return queryset.filter(severity_order=severity_order)
def filter_severity_in(self, queryset, name, value):
values = value
if isinstance(value, str):
values = [part.strip() for part in value.split(",") if part.strip()]
orders = []
for severity_value in values:
severity_order = SEVERITY_ORDER.get(severity_value)
if severity_order is None:
raise ValidationError(
[
{
"detail": f"invalid severity filter: {severity_value}",
"status": "400",
"source": {"pointer": "/data"},
"code": "invalid",
}
]
)
orders.append(severity_order)
if not orders:
return queryset
return queryset.filter(severity_order__in=orders)
class ProviderSecretFilter(FilterSet):
inserted_at = DateFilter(
field_name="inserted_at",
@@ -0,0 +1,31 @@
# Generated by Django 5.1.15 on 2026-03-18
from django.contrib.postgres.indexes import GinIndex, OpClass
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
("api", "0084_googleworkspace_provider"),
]
operations = [
AddIndexConcurrently(
model_name="findinggroupdailysummary",
index=GinIndex(
OpClass(Upper("check_id"), name="gin_trgm_ops"),
name="fgds_check_id_trgm_idx",
),
),
AddIndexConcurrently(
model_name="findinggroupdailysummary",
index=GinIndex(
OpClass(Upper("check_title"), name="gin_trgm_ops"),
name="fgds_check_title_trgm_idx",
),
),
]
+13 -1
View File
@@ -1,7 +1,6 @@
import json
import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
@@ -9,6 +8,8 @@ from allauth.socialaccount.models import SocialApp
from config.custom_logging import BackendLogger
from config.settings.social_login import SOCIALACCOUNT_PROVIDERS
from cryptography.fernet import Fernet, InvalidToken
import defusedxml
from defusedxml import ElementTree as ET
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
@@ -1783,6 +1784,15 @@ class FindingGroupDailySummary(RowLevelSecurityProtectedModel):
fields=["tenant_id", "provider", "inserted_at"],
name="fgds_tenant_prov_ins_idx",
),
# Trigram indexes for case-insensitive search
GinIndex(
OpClass(Upper("check_id"), name="gin_trgm_ops"),
name="fgds_check_id_trgm_idx",
),
GinIndex(
OpClass(Upper("check_title"), name="gin_trgm_ops"),
name="fgds_check_title_trgm_idx",
),
]
class JSONAPIMeta:
@@ -2058,6 +2068,8 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel):
root = ET.fromstring(self.metadata_xml)
except ET.ParseError as e:
raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
except defusedxml.DefusedXmlException as e:
raise ValidationError({"metadata_xml": f"Unsafe XML content rejected: {e}"})
# Entity ID
entity_id = root.attrib.get("entityID")
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.22.1
version: 1.23.0
description: |-
Prowler API specification.
@@ -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()
+17 -13
View File
@@ -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()
+33
View File
@@ -243,6 +243,39 @@ class TestSAMLConfigurationModel:
assert "Invalid XML" in errors["metadata_xml"][0]
assert "not well-formed" in errors["metadata_xml"][0]
def test_xml_bomb_rejected(self, tenants_fixture):
"""
Regression test: a 'billion laughs' XML bomb in the SAML metadata field
must be rejected and not allowed to exhaust server memory / CPU.
Before the fix, xml.etree.ElementTree was used directly, which does not
protect against entity-expansion attacks. The fix switches to defusedxml
which raises an exception for any XML containing entity definitions.
"""
tenant = tenants_fixture[0]
xml_bomb = (
"<?xml version='1.0'?>"
"<!DOCTYPE bomb ["
" <!ENTITY a 'aaaaaaaaaa'>"
" <!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'>"
" <!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'>"
" <!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'>"
"]>"
"<md:EntityDescriptor entityID='&d;' "
"xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'/>"
)
config = SAMLConfiguration(
email_domain="xmlbomb.com",
metadata_xml=xml_bomb,
tenant=tenant,
)
with pytest.raises(ValidationError) as exc_info:
config._parse_metadata()
errors = exc_info.value.message_dict
assert "metadata_xml" in errors
def test_metadata_missing_sso_fails(self, tenants_fixture):
tenant = tenants_fixture[0]
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
+521 -4
View File
@@ -14689,10 +14689,16 @@ class TestMuteRuleViewSet:
assert len(data) == 2
assert data[0]["id"] == str(mute_rules_fixture[first_index].id)
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
@patch("api.v1.views.chain")
@patch("api.v1.views.aggregate_finding_group_summaries_task.si")
@patch("api.v1.views.mute_historical_findings_task.si")
@patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn())
def test_mute_rules_create_valid(
self,
mock_task,
_mock_on_commit,
mock_mute_signature,
mock_aggregate_signature,
mock_chain,
authenticated_client,
findings_fixture,
create_test_user,
@@ -14730,8 +14736,14 @@ class TestMuteRuleViewSet:
assert finding.muted_at is not None
assert finding.muted_reason == "Security exception approved"
# Verify background task was called
mock_task.assert_called_once()
# Verify background task chain was called
mock_mute_signature.assert_called_once()
mock_aggregate_signature.assert_called_once()
mock_chain.assert_called_once_with(
mock_mute_signature.return_value,
mock_aggregate_signature.return_value,
)
mock_chain.return_value.apply_async.assert_called_once()
@patch("tasks.tasks.mute_historical_findings_task.apply_async")
def test_mute_rules_create_converts_finding_ids_to_uids(
@@ -15204,6 +15216,29 @@ class TestFindingGroupViewSet:
# ec2_instance_public_ip has 1 PASS and 1 FAIL, should aggregate to FAIL
assert data[0]["attributes"]["status"] == "FAIL"
def test_finding_groups_region_filter_reaggregates_metrics(
self, authenticated_client, finding_groups_fixture
):
"""Test finding-level filters recompute group metrics from matching findings."""
response = authenticated_client.get(
reverse("finding-group-list"),
{
"filter[inserted_at]": TODAY,
"filter[check_id]": "ec2_instance_public_ip",
"filter[region]": "us-east-1",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
attrs = data[0]["attributes"]
assert attrs["status"] == "PASS"
assert attrs["pass_count"] == 1
assert attrs["fail_count"] == 0
assert attrs["resources_total"] == 1
assert attrs["resources_fail"] == 0
def test_finding_groups_status_pass_when_no_fail(
self, authenticated_client, finding_groups_fixture
):
@@ -15232,6 +15267,182 @@ class TestFindingGroupViewSet:
# rds_encryption has all muted findings
assert data[0]["attributes"]["status"] == "MUTED"
def test_finding_groups_status_filter(
self, authenticated_client, finding_groups_fixture
):
"""Test finding groups can be filtered by aggregated status."""
response = authenticated_client.get(
reverse("finding-group-list"),
{"filter[inserted_at]": TODAY, "filter[status]": "FAIL"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["status"] == "FAIL" for item in data)
def test_finding_groups_status_in_filter(
self, authenticated_client, finding_groups_fixture
):
"""Test finding groups support status__in filter on aggregated status."""
response = authenticated_client.get(
reverse("finding-group-list"),
{"filter[inserted_at]": TODAY, "filter[status__in]": "FAIL,PASS"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data)
def test_finding_groups_severity_filter(
self, authenticated_client, finding_groups_fixture
):
"""Test finding groups can be filtered by aggregated severity."""
response = authenticated_client.get(
reverse("finding-group-list"),
{"filter[inserted_at]": TODAY, "filter[severity]": "critical"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["severity"] == "critical" for item in data)
@pytest.mark.parametrize(
"endpoint_name", ["finding-group-list", "finding-group-latest"]
)
def test_finding_groups_combined_region_and_status_filters(
self, authenticated_client, finding_groups_fixture, endpoint_name
):
"""Test combined region + aggregated status filters."""
params = {"filter[region]": "us-east-1", "filter[status]": "FAIL"}
if endpoint_name == "finding-group-list":
params["filter[inserted_at]"] = TODAY
response = authenticated_client.get(reverse(endpoint_name), params)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
check_ids = {item["id"] for item in data}
assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"}
assert all(item["attributes"]["status"] == "FAIL" for item in data)
@pytest.mark.parametrize(
"endpoint_name", ["finding-group-list", "finding-group-latest"]
)
def test_finding_groups_combined_delta_and_severity_filters(
self, authenticated_client, finding_groups_fixture, endpoint_name
):
"""Test combined delta + aggregated severity filters."""
params = {"filter[delta]": "new", "filter[severity]": "critical"}
if endpoint_name == "finding-group-list":
params["filter[inserted_at]"] = TODAY
response = authenticated_client.get(reverse(endpoint_name), params)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
check_ids = {item["id"] for item in data}
assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"}
assert all(item["attributes"]["severity"] == "critical" for item in data)
@pytest.mark.parametrize(
"endpoint_name", ["finding-group-list", "finding-group-latest"]
)
@pytest.mark.parametrize(
"filter_key,filter_value",
[
("status", "INVALID_STATUS"),
("severity", "INVALID_SEVERITY"),
],
)
def test_finding_groups_invalid_status_or_severity_returns_400(
self,
authenticated_client,
finding_groups_fixture,
endpoint_name,
filter_key,
filter_value,
):
"""Test invalid aggregated status/severity values are rejected."""
params = {f"filter[{filter_key}]": filter_value}
if endpoint_name == "finding-group-list":
params["filter[inserted_at]"] = TODAY
response = authenticated_client.get(reverse(endpoint_name), params)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["code"] == "invalid"
@pytest.mark.parametrize(
"endpoint_name", ["finding-group-list", "finding-group-latest"]
)
@pytest.mark.parametrize(
"filter_key,filter_value,expected_detail",
[
("status__in", "FAIL,INVALID_STATUS", "invalid status filter"),
("severity__in", "critical,INVALID_SEVERITY", "invalid severity filter"),
],
)
def test_finding_groups_invalid_in_filters_return_400(
self,
authenticated_client,
finding_groups_fixture,
endpoint_name,
filter_key,
filter_value,
expected_detail,
):
"""Test invalid values in status__in/severity__in are rejected."""
params = {f"filter[{filter_key}]": filter_value}
if endpoint_name == "finding-group-list":
params["filter[inserted_at]"] = TODAY
response = authenticated_client.get(reverse(endpoint_name), params)
assert response.status_code == status.HTTP_400_BAD_REQUEST
errors = response.json()["errors"]
assert errors[0]["code"] == "invalid"
assert expected_detail in errors[0]["detail"]
@pytest.mark.parametrize(
"filter_name,filter_value",
[
("region", "__region_does_not_exist__"),
("service", "__service_does_not_exist__"),
("category", "__category_does_not_exist__"),
("resource_groups", "__group_does_not_exist__"),
("resource_type", "__type_does_not_exist__"),
("scan", "00000000-0000-7000-8000-000000000001"),
],
)
def test_finding_groups_finding_level_filters_are_applied(
self,
authenticated_client,
finding_groups_fixture,
filter_name,
filter_value,
):
"""Test finding-level filters are applied in /finding-groups aggregation."""
response = authenticated_client.get(
reverse("finding-group-list"),
{"filter[inserted_at]": TODAY, f"filter[{filter_name}]": filter_value},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 0
def test_finding_groups_delta_filter_is_applied(
self, authenticated_client, finding_groups_fixture
):
"""Test delta filter is applied in /finding-groups aggregation."""
response = authenticated_client.get(
reverse("finding-group-list"),
{"filter[inserted_at]": TODAY, "filter[delta]": "new"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["new_count"] > 0 for item in data)
def test_finding_groups_provider_aggregation(
self, authenticated_client, finding_groups_fixture
):
@@ -15526,6 +15737,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(
@@ -15824,6 +16051,258 @@ class TestFindingGroupViewSet:
assert len(data) == 1
assert data[0]["id"] == "cloudtrail_enabled"
def test_finding_groups_latest_status_filter(
self, authenticated_client, finding_groups_fixture
):
"""Test /latest supports status filter on aggregated status."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{"filter[status]": "FAIL"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["status"] == "FAIL" for item in data)
def test_finding_groups_latest_region_filter_reaggregates_metrics(
self, authenticated_client, finding_groups_fixture
):
"""Test /latest recomputes metrics from findings matching region filter."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{
"filter[check_id]": "ec2_instance_public_ip",
"filter[region]": "us-east-1",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
attrs = data[0]["attributes"]
assert attrs["status"] == "PASS"
assert attrs["pass_count"] == 1
assert attrs["fail_count"] == 0
assert attrs["resources_total"] == 1
assert attrs["resources_fail"] == 0
def test_finding_groups_latest_status_in_filter(
self, authenticated_client, finding_groups_fixture
):
"""Test /latest supports status__in filter on aggregated status."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{"filter[status__in]": "FAIL,PASS"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data)
def test_finding_groups_latest_severity_filter(
self, authenticated_client, finding_groups_fixture
):
"""Test /latest supports severity filter on aggregated severity."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{"filter[severity]": "critical"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["severity"] == "critical" for item in data)
@pytest.mark.parametrize(
"filter_name,filter_value",
[
("region", "__region_does_not_exist__"),
("service", "__service_does_not_exist__"),
("category", "__category_does_not_exist__"),
("resource_groups", "__group_does_not_exist__"),
("resource_type", "__type_does_not_exist__"),
("scan", "00000000-0000-7000-8000-000000000001"),
],
)
def test_finding_groups_latest_finding_level_filters_are_applied(
self,
authenticated_client,
finding_groups_fixture,
filter_name,
filter_value,
):
"""Test finding-level filters are applied in /finding-groups/latest aggregation."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{f"filter[{filter_name}]": filter_value},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 0
def test_finding_groups_check_title_filter_applies_with_delta(
self, authenticated_client, finding_groups_fixture
):
"""Test check_title filter is honored when finding-level path is used."""
response = authenticated_client.get(
reverse("finding-group-list"),
{
"filter[inserted_at]": TODAY,
"filter[delta]": "new",
"filter[check_title.icontains]": "__missing_check_title__",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 0
def test_finding_groups_latest_check_title_filter_applies_with_delta(
self, authenticated_client, finding_groups_fixture
):
"""Test /latest check_title filter is honored on finding-level path."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{
"filter[delta]": "new",
"filter[check_title.icontains]": "__missing_check_title__",
},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 0
def test_finding_groups_latest_delta_filter_is_applied(
self, authenticated_client, finding_groups_fixture
):
"""Test delta filter is applied in /finding-groups/latest aggregation."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{"filter[delta]": "new"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
assert all(item["attributes"]["new_count"] > 0 for item in data)
def test_finding_groups_latest_aggregates_latest_per_provider(
self,
authenticated_client,
providers_fixture,
resources_fixture,
):
"""Test /latest keeps all findings from the latest scan per provider.
Verifies that when the latest scan produces multiple findings for the
same check_id (e.g. one per resource), all of them are included in the
aggregation not just one.
"""
provider1 = providers_fixture[0]
provider2 = providers_fixture[1]
resource1 = resources_fixture[0]
resource2 = resources_fixture[1]
resource3 = resources_fixture[2]
check_id = "cross_provider_latest_resources_total"
latest_scan_provider1 = Scan.objects.create(
tenant_id=provider1.tenant_id,
provider=provider1,
state=StateChoices.COMPLETED,
trigger=Scan.TriggerChoices.MANUAL,
completed_at=datetime.now(timezone.utc),
)
latest_scan_provider2 = Scan.objects.create(
tenant_id=provider2.tenant_id,
provider=provider2,
state=StateChoices.COMPLETED,
trigger=Scan.TriggerChoices.MANUAL,
completed_at=datetime.now(timezone.utc),
)
older_scan_provider1 = Scan.objects.create(
tenant_id=provider1.tenant_id,
provider=provider1,
state=StateChoices.COMPLETED,
trigger=Scan.TriggerChoices.MANUAL,
completed_at=datetime.now(timezone.utc) - timedelta(days=1),
)
# Older scan — these should be excluded from /latest
Finding.objects.create(
tenant_id=provider1.tenant_id,
uid="old_cross_provider_1",
scan=older_scan_provider1,
delta="new",
status="FAIL",
severity="high",
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
first_seen_at=datetime.now(timezone.utc) - timedelta(days=2),
muted=False,
)
# Latest scan provider1: TWO findings (PASS + FAIL) for the same check
latest_p1_pass = Finding.objects.create(
tenant_id=provider1.tenant_id,
uid="latest_cross_provider_1_pass",
scan=latest_scan_provider1,
delta="new",
status="PASS",
severity="high",
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
muted=False,
)
latest_p1_pass.add_resources([resource1])
latest_p1_fail = Finding.objects.create(
tenant_id=provider1.tenant_id,
uid="latest_cross_provider_1_fail",
scan=latest_scan_provider1,
delta="new",
status="FAIL",
severity="high",
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
muted=False,
)
latest_p1_fail.add_resources([resource2])
# Latest scan provider2: one finding
latest_p2 = Finding.objects.create(
tenant_id=provider2.tenant_id,
uid="latest_cross_provider_2",
scan=latest_scan_provider2,
delta="new",
status="FAIL",
severity="high",
impact="high",
check_id=check_id,
check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"},
first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1),
muted=False,
)
latest_p2.add_resources([resource3])
response = authenticated_client.get(
reverse("finding-group-latest"),
{"filter[check_id]": check_id, "filter[delta]": "new"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) == 1
attrs = data[0]["attributes"]
# 3 findings total: 2 from provider1 latest + 1 from provider2 latest
assert attrs["pass_count"] == 1
assert attrs["fail_count"] == 2
assert attrs["resources_total"] == 3
assert attrs["resources_fail"] == 2
def test_finding_groups_latest_provider_type_filter(
self, authenticated_client, finding_groups_fixture
):
@@ -15863,6 +16342,44 @@ class TestFindingGroupViewSet:
check_ids = [item["id"] for item in data]
assert check_ids == sorted(check_ids)
def test_finding_groups_latest_sort_by_check_title(
self, authenticated_client, finding_groups_fixture
):
"""Test /latest supports sorting by check_title."""
response = authenticated_client.get(
reverse("finding-group-latest"),
{"sort": "check_title"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
check_titles = [item["attributes"]["check_title"] for item in data]
assert check_titles == sorted(check_titles)
@pytest.mark.parametrize(
"endpoint_name", ["finding-group-list", "finding-group-latest"]
)
@pytest.mark.parametrize(
"sort_field",
["first_seen_at", "-first_seen_at", "last_seen_at", "failing_since"],
)
def test_finding_groups_sort_by_time_fields(
self,
authenticated_client,
finding_groups_fixture,
endpoint_name,
sort_field,
):
"""Test sorting by aggregated time fields (first_seen_at, last_seen_at, failing_since)."""
params = {"sort": sort_field}
if endpoint_name == "finding-group-list":
params["filter[inserted_at]"] = TODAY
response = authenticated_client.get(reverse(endpoint_name), params)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
assert len(data) > 0
def test_finding_groups_latest_ignores_date_filters(
self, authenticated_client, finding_groups_fixture
):
+1
View File
@@ -4180,6 +4180,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
severity = serializers.CharField()
first_seen_at = serializers.DateTimeField(required=False, allow_null=True)
last_seen_at = serializers.DateTimeField(required=False, allow_null=True)
muted_reason = serializers.CharField(required=False, allow_null=True)
class JSONAPIMeta:
resource_name = "finding-group-resources"
+268 -116
View File
@@ -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,12 +11,12 @@ 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
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
from celery import chain
from celery.result import AsyncResult
from config.custom_logging import BackendLogger
from config.env import env
@@ -32,6 +31,7 @@ from django.contrib.postgres.search import SearchQuery
from django.db import transaction
from django.db.models import (
Case,
CharField,
Count,
DecimalField,
ExpressionWrapper,
@@ -48,7 +48,7 @@ from django.db.models import (
When,
Window,
)
from django.db.models.functions import Coalesce, RowNumber
from django.db.models.functions import Cast, Coalesce, DenseRank, RowNumber
from django.http import HttpResponse, QueryDict
from django.shortcuts import redirect
from django.urls import reverse
@@ -76,12 +76,14 @@ 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
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.export import get_s3_client
from tasks.tasks import (
aggregate_finding_group_summaries_task,
backfill_compliance_summaries_task,
backfill_scan_resource_summaries_task,
check_integration_connection_task,
@@ -100,7 +102,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,
@@ -124,6 +125,7 @@ from api.filters import (
CustomDjangoFilterBackend,
DailySeveritySummaryFilter,
FindingFilter,
FindingGroupAggregatedComputedFilter,
FindingGroupFilter,
FindingGroupSummaryFilter,
IntegrationFilter,
@@ -199,6 +201,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,
@@ -408,7 +411,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.22.1"
spectacular_settings.VERSION = "1.23.0"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -6726,10 +6729,25 @@ class MuteRuleViewSet(BaseRLSViewSet):
)
# Launch background task for historical muting
with transaction.atomic():
mute_historical_findings_task.apply_async(
kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)}
)
latest_scan_id = (
Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
.order_by("-completed_at", "-inserted_at")
.values_list("id", flat=True)
.first()
)
transaction.on_commit(
lambda: chain(
mute_historical_findings_task.si(
tenant_id=tenant_id,
mute_rule_id=str(mute_rule.id),
),
aggregate_finding_group_summaries_task.si(
tenant_id=tenant_id,
scan_id=str(latest_scan_id),
),
).apply_async()
)
# Return the created mute rule
serializer = self.get_serializer(mute_rule)
@@ -6770,21 +6788,37 @@ class FindingGroupViewSet(BaseRLSViewSet):
security analysts to see which checks are failing across their
infrastructure without scrolling through thousands of individual findings.
Uses pre-aggregated FindingGroupDailySummary table for efficient queries.
Daily summaries are re-aggregated across the requested date range.
Uses a hybrid strategy: pre-aggregated daily summaries when possible,
and raw findings when finding-level filters require precise subset metrics.
"""
queryset = FindingGroupDailySummary.objects.all()
serializer_class = FindingGroupSerializer
filterset_class = FindingGroupSummaryFilter
filterset_class = FindingGroupFilter
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
return FindingGroupSummaryFilter
return LatestFindingGroupFilter
if self.action == "resources":
return FindingGroupFilter
if self.action == "latest_resources":
return LatestFindingGroupFilter
return FindingGroupFilter
def get_queryset(self):
"""Get the base FindingGroupDailySummary queryset with RLS filtering."""
@@ -6891,20 +6925,27 @@ class FindingGroupViewSet(BaseRLSViewSet):
return filterset.qs.values("id")
def _get_finding_level_filter_keys(self, latest: bool = False) -> set[str]:
"""Derive filters that require querying raw findings."""
summary_filterset = (
LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter
)
finding_filterset = LatestFindingGroupFilter if latest else FindingGroupFilter
summary_supported = set(summary_filterset.base_filters.keys())
finding_supported = set(finding_filterset.base_filters.keys())
return finding_supported - summary_supported
def _requires_finding_level_aggregation(
self, params: QueryDict, latest: bool = False
) -> bool:
finding_level_keys = self._get_finding_level_filter_keys(latest=latest)
return any(key in finding_level_keys for key in params.keys())
def _aggregate_daily_summaries(self, queryset):
"""
Re-aggregate daily summaries across the date range.
Takes pre-computed daily summaries and aggregates them by check_id
to produce totals across the selected date range.
"""
from django.db.models import CharField
from django.db.models.functions import Cast
"""Re-aggregate summary rows by check_id."""
return queryset.values("check_id").annotate(
# Max severity across days
severity_order=Max("severity_order"),
# Sum counts across days
pass_count=Sum("pass_count"),
fail_count=Sum("fail_count"),
muted_count=Sum("muted_count"),
@@ -6912,22 +6953,99 @@ class FindingGroupViewSet(BaseRLSViewSet):
changed_count=Sum("changed_count"),
resources_total=Sum("resources_total"),
resources_fail=Sum("resources_fail"),
# Collect provider types using StringAgg (cast enum to text first)
impacted_providers_str=StringAgg(
Cast("provider__provider", CharField()),
delimiter=",",
distinct=True,
default="",
),
# Min/Max timing across days
first_seen_at=Min("first_seen_at"),
last_seen_at=Max("last_seen_at"),
failing_since=Min("failing_since"),
# Get check metadata from first row (same for all days)
agg_first_seen_at=Min("first_seen_at"),
agg_last_seen_at=Max("last_seen_at"),
agg_failing_since=Min("failing_since"),
check_title=Max("check_title"),
check_description=Max("check_description"),
)
def _aggregate_findings(self, queryset):
"""Aggregate findings by check_id for finding-group endpoints."""
severity_case = Case(
*[
When(severity=severity, then=Value(order))
for severity, order in SEVERITY_ORDER.items()
],
output_field=IntegerField(),
)
return queryset.values("check_id").annotate(
severity_order=Max(severity_case),
pass_count=Count("id", filter=Q(status="PASS", muted=False)),
fail_count=Count("id", filter=Q(status="FAIL", muted=False)),
muted_count=Count("id", filter=Q(muted=True)),
new_count=Count("id", filter=Q(delta="new", muted=False)),
changed_count=Count("id", filter=Q(delta="changed", muted=False)),
resources_total=Count("resources__id", distinct=True),
resources_fail=Count(
"resources__id",
distinct=True,
filter=Q(status="FAIL", muted=False),
),
impacted_providers_str=StringAgg(
Cast("scan__provider__provider", CharField()),
delimiter=",",
distinct=True,
default="",
),
agg_first_seen_at=Min("first_seen_at"),
agg_last_seen_at=Max("inserted_at"),
agg_failing_since=Min(
"first_seen_at", filter=Q(status="FAIL", muted=False)
),
check_title=Coalesce(
Max(Cast("check_metadata__CheckTitle", CharField())),
Max(Cast("check_metadata__checktitle", CharField())),
Max(Cast("check_metadata__Checktitle", CharField())),
),
check_description=Coalesce(
Max(Cast("check_metadata__Description", CharField())),
Max(Cast("check_metadata__description", CharField())),
),
)
def _split_computed_aggregate_filters(
self, params: QueryDict
) -> tuple[QueryDict, QueryDict]:
"""Split finding filters from computed aggregate filters."""
computed_keys = {"status", "status__in", "severity", "severity__in"}
finding_params = QueryDict(mutable=True)
computed_params = QueryDict(mutable=True)
for key, values in params.lists():
if key in computed_keys:
computed_params.setlist(key, values)
else:
finding_params.setlist(key, values)
return finding_params, computed_params
def _get_latest_findings_per_check_provider(self, filtered_queryset):
"""Keep all findings from the latest scan per (check_id, provider)."""
latest_ids = (
filtered_queryset.annotate(
scan_rank=Window(
expression=DenseRank(),
partition_by=[F("check_id"), F("scan__provider_id")],
order_by=[
F("scan__completed_at").desc(nulls_last=True),
F("scan_id").desc(),
],
)
)
.filter(scan_rank=1)
.values("id")
)
return filtered_queryset.filter(id__in=Subquery(latest_ids))
def _post_process_aggregation(self, aggregated_data):
"""
Post-process aggregation results to add computed fields.
@@ -6944,6 +7062,13 @@ class FindingGroupViewSet(BaseRLSViewSet):
severity_order, "informational"
)
if "agg_first_seen_at" in row:
row["first_seen_at"] = row.pop("agg_first_seen_at")
if "agg_last_seen_at" in row:
row["last_seen_at"] = row.pop("agg_last_seen_at")
if "agg_failing_since" in row:
row["failing_since"] = row.pop("agg_failing_since")
# Compute aggregated status
if row.get("fail_count", 0) > 0:
row["status"] = "FAIL"
@@ -6966,6 +7091,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
"""Validate and map JSON:API sort fields for aggregated finding groups."""
sort_field_map = {
"check_id": "check_id",
"check_title": "check_title",
"severity": "severity_order",
"fail_count": "fail_count",
"pass_count": "pass_count",
@@ -6974,9 +7100,9 @@ class FindingGroupViewSet(BaseRLSViewSet):
"changed_count": "changed_count",
"resources_total": "resources_total",
"resources_fail": "resources_fail",
"first_seen_at": "first_seen_at",
"last_seen_at": "last_seen_at",
"failing_since": "failing_since",
"first_seen_at": "agg_first_seen_at",
"last_seen_at": "agg_last_seen_at",
"failing_since": "agg_failing_since",
}
ordering = []
@@ -7003,6 +7129,29 @@ class FindingGroupViewSet(BaseRLSViewSet):
return ordering
def _apply_aggregated_computed_filters(self, queryset, computed_params: QueryDict):
"""Apply computed filters (status/severity) on aggregated finding-group rows."""
if not computed_params:
return queryset
if computed_params.get("status") or computed_params.getlist("status__in"):
queryset = queryset.annotate(
aggregated_status=Case(
When(fail_count__gt=0, then=Value("FAIL")),
When(pass_count__gt=0, then=Value("PASS")),
default=Value("MUTED"),
output_field=CharField(),
)
)
filterset = FindingGroupAggregatedComputedFilter(
computed_params, queryset=queryset
)
if not filterset.is_valid():
raise ValidationError(filterset.errors)
return filterset.qs
def _build_resource_mapping_queryset(
self, filtered_queryset, resource_ids=None, tenant_id: str | None = None
):
@@ -7075,6 +7224,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
),
first_seen_at=Min("finding__first_seen_at"),
last_seen_at=Max("finding__inserted_at"),
muted_reason=Max("finding__muted_reason"),
)
.filter(resource_id__isnull=False)
.order_by("resource_id")
@@ -7110,56 +7260,90 @@ class FindingGroupViewSet(BaseRLSViewSet):
),
"first_seen_at": row["first_seen_at"],
"last_seen_at": row["last_seen_at"],
"muted_reason": row.get("muted_reason"),
}
)
return results
def _build_aggregated_queryset(self, finding_params, latest=False):
"""Select the summary or findings path and return an aggregated queryset."""
finding_filterset_class = (
LatestFindingGroupFilter if latest else FindingGroupFilter
)
summary_filterset_class = (
LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter
)
if self._requires_finding_level_aggregation(finding_params, latest=latest):
finding_queryset = self._get_finding_queryset()
filterset = finding_filterset_class(
finding_params, queryset=finding_queryset
)
if not filterset.is_valid():
raise ValidationError(filterset.errors)
filtered_queryset = filterset.qs
if latest:
filtered_queryset = self._get_latest_findings_per_check_provider(
filtered_queryset
)
return self._aggregate_findings(filtered_queryset)
summary_queryset = self.get_queryset()
filterset = summary_filterset_class(finding_params, queryset=summary_queryset)
if not filterset.is_valid():
raise ValidationError(filterset.errors)
filtered_queryset = filterset.qs
if latest:
latest_per_check_ids = (
filtered_queryset.order_by("check_id", "provider_id", "-inserted_at")
.distinct("check_id", "provider_id")
.values("id")
)
filtered_queryset = filtered_queryset.filter(
id__in=Subquery(latest_per_check_ids)
)
return self._aggregate_daily_summaries(filtered_queryset)
def _sorted_paginated_response(self, request, aggregated_queryset):
"""Apply ordering, pagination, post-processing, and return the Response."""
sort_param = request.query_params.get("sort")
if sort_param:
ordering = self._validate_sort_fields(sort_param)
if ordering:
aggregated_queryset = aggregated_queryset.order_by(*ordering)
else:
aggregated_queryset = aggregated_queryset.order_by(
"-fail_count", "-severity_order", "check_id"
)
page = self.paginate_queryset(aggregated_queryset)
if page is not None:
processed_data = self._post_process_aggregation(page)
serializer = self.get_serializer(processed_data, many=True)
return self.get_paginated_response(serializer.data)
processed_data = self._post_process_aggregation(aggregated_queryset)
serializer = self.get_serializer(processed_data, many=True)
return Response(serializer.data)
def list(self, request, *args, **kwargs):
"""
List finding groups with aggregation and filtering.
Returns findings grouped by check_id with aggregated metrics.
Requires at least one date filter for performance.
Uses pre-aggregated daily summaries for efficient queries.
Uses summaries when possible and raw findings for finding-level filters.
"""
queryset = self.get_queryset()
# Apply filters
normalized_params = self._normalize_jsonapi_params(request.query_params)
filterset = self.filterset_class(normalized_params, queryset=queryset)
if not filterset.is_valid():
raise ValidationError(filterset.errors)
filtered_queryset = filterset.qs
# Re-aggregate daily summaries across the date range
aggregated_queryset = self._aggregate_daily_summaries(filtered_queryset)
# Apply ordering (respect JSON:API sort param or use default)
sort_param = request.query_params.get("sort")
if sort_param:
# Convert JSON:API sort notation (prefix '-' for descending)
ordering = self._validate_sort_fields(sort_param)
if ordering:
aggregated_queryset = aggregated_queryset.order_by(*ordering)
else:
# Default ordering: failures first, then severity, then check_id
aggregated_queryset = aggregated_queryset.order_by(
"-fail_count", "-severity_order", "check_id"
)
# Paginate
page = self.paginate_queryset(aggregated_queryset)
if page is not None:
# Post-process the page
processed_data = self._post_process_aggregation(page)
serializer = self.get_serializer(processed_data, many=True)
return self.get_paginated_response(serializer.data)
# Post-process all results (no pagination)
processed_data = self._post_process_aggregation(aggregated_queryset)
serializer = self.get_serializer(processed_data, many=True)
return Response(serializer.data)
finding_params, computed_params = self._split_computed_aggregate_filters(
normalized_params
)
aggregated_qs = self._build_aggregated_queryset(finding_params, latest=False)
aggregated_qs = self._apply_aggregated_computed_filters(
aggregated_qs, computed_params
)
return self._sorted_paginated_response(request, aggregated_qs)
@extend_schema(
summary="List latest finding groups",
@@ -7177,56 +7361,22 @@ class FindingGroupViewSet(BaseRLSViewSet):
"""
List the latest finding group state per check_id.
Returns findings grouped by check_id using the latest available
inserted_at date per check_id, without requiring date filters.
Returns findings grouped by check_id using latest data per
(check_id, provider), without requiring date filters.
"""
queryset = self.get_queryset()
# Apply other filters (provider_id, provider_type, check_id, etc.)
normalized_params = self._normalize_jsonapi_params(request.query_params)
# Remove date filters since we're using latest
for key in list(normalized_params.keys()):
if key.startswith("inserted_at"):
del normalized_params[key]
filterset_class = self.get_filterset_class()
filterset = filterset_class(normalized_params, queryset=queryset)
if not filterset.is_valid():
raise ValidationError(filterset.errors)
filtered_queryset = filterset.qs
# Keep only rows from the latest inserted_at date per check_id
latest_per_check = filtered_queryset.annotate(
latest_inserted_at=Window(
expression=Max("inserted_at"),
partition_by=[F("check_id")],
)
).filter(inserted_at=F("latest_inserted_at"))
# Re-aggregate daily summaries
aggregated_queryset = self._aggregate_daily_summaries(latest_per_check)
# Apply ordering
sort_param = request.query_params.get("sort")
if sort_param:
ordering = self._validate_sort_fields(sort_param)
if ordering:
aggregated_queryset = aggregated_queryset.order_by(*ordering)
else:
aggregated_queryset = aggregated_queryset.order_by(
"-fail_count", "-severity_order", "check_id"
)
# Paginate
page = self.paginate_queryset(aggregated_queryset)
if page is not None:
processed_data = self._post_process_aggregation(page)
serializer = self.get_serializer(processed_data, many=True)
return self.get_paginated_response(serializer.data)
processed_data = self._post_process_aggregation(aggregated_queryset)
serializer = self.get_serializer(processed_data, many=True)
return Response(serializer.data)
finding_params, computed_params = self._split_computed_aggregate_filters(
normalized_params
)
aggregated_qs = self._build_aggregated_queryset(finding_params, latest=True)
aggregated_qs = self._apply_aggregated_computed_filters(
aggregated_qs, computed_params
)
return self._sorted_paginated_response(request, aggregated_qs)
@extend_schema(
summary="List resources for a finding group",
@@ -7237,6 +7387,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 +7462,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
and timing information. No date filters required.
""",
tags=["Finding Groups"],
filters=True,
)
@action(
detail=False,
+7
View File
@@ -304,6 +304,13 @@
"pages": [
"user-guide/compliance/tutorials/threatscore"
]
},
{
"group": "Cookbooks",
"pages": [
"user-guide/cookbooks/kubernetes-in-cluster",
"user-guide/cookbooks/cicd-pipeline"
]
}
]
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

+243
View File
@@ -0,0 +1,243 @@
---
title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud'
---
This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). Examples cover GitHub Actions and GitLab CI.
## Prerequisites
* A **Prowler Cloud** account with an active subscription (see [Prowler Cloud Pricing](https://prowler.com/pricing))
* A Prowler Cloud **API key** with the **Manage Ingestions** permission (see [API Keys](/user-guide/tutorials/prowler-app-api-keys))
* Cloud provider credentials configured in the CI/CD environment (e.g., AWS credentials for scanning AWS accounts)
* Access to configure pipeline workflows and secrets in the CI/CD platform
## Key Concepts
Prowler CLI provides the `--push-to-cloud` flag, which uploads scan results directly to Prowler Cloud after a scan completes. Combined with the `PROWLER_CLOUD_API_KEY` environment variable, this enables fully automated ingestion without manual file uploads.
For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation.
<Note>
The examples in this guide use AWS as the target provider, but the same approach applies to any provider supported by Prowler (Azure, GCP, Kubernetes, and others). Replace `prowler aws` with the desired provider command (e.g., `prowler gcp`, `prowler azure`) and configure the corresponding credentials in the CI/CD environment.
</Note>
## GitHub Actions
### Store Secrets
Before creating the workflow, add the following secrets to the repository (under "Settings" > "Secrets and variables" > "Actions"):
* `PROWLER_CLOUD_API_KEY` — the Prowler Cloud API key
* Cloud provider credentials (e.g., `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or configure OIDC-based role assumption)
### Workflow: Scheduled AWS Scan
This workflow runs Prowler against an AWS account on a daily schedule and on every push to the `main` branch:
```yaml
name: Prowler Security Scan
on:
schedule:
- cron: "0 3 * * *" # Daily at 03:00 UTC
push:
branches: [main]
workflow_dispatch: # Allow manual triggers
permissions:
id-token: write # Required for OIDC
contents: read
jobs:
prowler-scan:
runs-on: ubuntu-latest
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ProwlerScanRole
aws-region: us-east-1
- name: Install Prowler
run: pip install prowler
- name: Run Prowler Scan
env:
PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
run: |
prowler aws --push-to-cloud
```
<Note>
Replace `123456789012` with the actual AWS account ID and `ProwlerScanRole` with the IAM role name. For IAM role setup, refer to the [AWS authentication guide](/user-guide/providers/aws/authentication).
</Note>
### Workflow: Scan Specific Services on Pull Request
To run targeted scans on pull requests without blocking the merge pipeline, use `continue-on-error`:
```yaml
name: Prowler PR Check
on:
pull_request:
branches: [main]
jobs:
prowler-scan:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/ProwlerScanRole
aws-region: us-east-1
- name: Install Prowler
run: pip install prowler
- name: Run Prowler Scan
env:
PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }}
run: |
prowler aws --services s3,iam,ec2 --push-to-cloud
```
<Tip>
Limiting the scan to specific services with `--services` reduces execution time, making it practical for pull request checks.
</Tip>
## GitLab CI
### Store Variables
Add the following CI/CD variables in the GitLab project (under "Settings" > "CI/CD" > "Variables"):
* `PROWLER_CLOUD_API_KEY` — mark as **masked** and **protected**
* Cloud provider credentials as needed
### Pipeline: Scheduled AWS Scan
Add the following to `.gitlab-ci.yml`:
```yaml
prowler-scan:
image: python:3.12-slim
stage: test
script:
- pip install prowler
- prowler aws --push-to-cloud
variables:
PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION: "us-east-1"
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
```
To run the scan on a schedule, create a **Pipeline Schedule** in GitLab (under "Build" > "Pipeline Schedules") with the desired cron expression.
### Pipeline: Multi-Provider Scan
To scan multiple cloud providers in parallel:
```yaml
stages:
- security
.prowler-base:
image: python:3.12-slim
stage: security
before_script:
- pip install prowler
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
prowler-aws:
extends: .prowler-base
script:
- prowler aws --push-to-cloud
variables:
PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
prowler-gcp:
extends: .prowler-base
script:
- prowler gcp --push-to-cloud
variables:
PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
GOOGLE_APPLICATION_CREDENTIALS: $GCP_SERVICE_ACCOUNT_KEY
```
## Tips and Best Practices
### When to Run Scans
* **Scheduled scans** (daily or weekly) provide continuous monitoring and are ideal for baseline security assessments
* **On-merge scans** catch configuration changes introduced by new code
* **Pull request scans** provide early feedback but should target specific services to keep execution times reasonable
### Handling Scan Failures
By default, Prowler exits with a non-zero code when it finds failing checks. This causes the CI/CD job to fail. To prevent scan results from blocking the pipeline:
* **GitHub Actions**: Add `continue-on-error: true` to the job
* **GitLab CI**: Add `allow_failure: true` to the job
<Note>
Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for details.
</Note>
### Caching Prowler Installation
For faster pipeline runs, cache the Prowler installation:
**GitHub Actions:**
```yaml
- name: Cache pip packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-prowler
restore-keys: ${{ runner.os }}-pip-
- name: Install Prowler
run: pip install prowler
```
**GitLab CI:**
```yaml
prowler-scan:
cache:
paths:
- .cache/pip
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
```
### Output Formats
To generate additional report formats alongside the cloud upload:
```bash
prowler aws --push-to-cloud -M csv,html -o /tmp/prowler-reports
```
This produces CSV and HTML files locally while also pushing OCSF findings to Prowler Cloud. The local files can be stored as CI/CD artifacts for archival purposes.
### Scanning Multiple AWS Accounts
To scan multiple accounts sequentially in a single job, use [role assumption](/user-guide/providers/aws/role-assumption):
```bash
prowler aws -R arn:aws:iam::111111111111:role/ProwlerScanRole --push-to-cloud
prowler aws -R arn:aws:iam::222222222222:role/ProwlerScanRole --push-to-cloud
```
Each scan run creates a separate ingestion job in Prowler Cloud.
@@ -0,0 +1,207 @@
---
title: 'Run Kubernetes In-Cluster and Send Findings to Prowler Cloud'
---
This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads.
## Prerequisites
* A **Prowler Cloud** account with an active subscription (see [Prowler Cloud Pricing](https://prowler.com/pricing))
* A Prowler Cloud **API key** with the **Manage Ingestions** permission (see [API Keys](/user-guide/tutorials/prowler-app-api-keys))
* Access to a Kubernetes cluster with `kubectl` configured
* Permissions to create ServiceAccounts, Roles, RoleBindings, Secrets, and CronJobs in the cluster
## Step 1: Create the ServiceAccount and RBAC Resources
Prowler needs a ServiceAccount with read access to cluster resources. Apply the manifests from the [`kubernetes` directory](https://github.com/prowler-cloud/prowler/tree/master/kubernetes) of the Prowler repository:
```console
kubectl apply -f kubernetes/prowler-sa.yaml
kubectl apply -f kubernetes/prowler-role.yaml
kubectl apply -f kubernetes/prowler-rolebinding.yaml
```
This creates:
* A `prowler-sa` ServiceAccount in the `prowler-ns` namespace
* A ClusterRole with the read permissions Prowler requires
* A ClusterRoleBinding linking the ServiceAccount to the role
For more details on these resources, refer to [Getting Started with Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s).
## Step 2: Store the Prowler Cloud API Key as a Secret
Create a Kubernetes Secret to hold the API key securely:
```console
kubectl create secret generic prowler-cloud-api-key \
--from-literal=api-key=pk_your_api_key_here \
--namespace prowler-ns
```
Replace `pk_your_api_key_here` with the actual API key from Prowler Cloud.
<Warning>
Avoid embedding the API key directly in the CronJob manifest. Using a Kubernetes Secret keeps credentials out of version control and pod specs.
</Warning>
## Step 3: Create the CronJob Manifest
The CronJob runs Prowler on a schedule, scanning the cluster and pushing findings to Prowler Cloud with the `--push-to-cloud` flag.
Create a file named `prowler-cronjob.yaml`:
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: prowler-k8s-scan
namespace: prowler-ns
spec:
schedule: "0 2 * * *" # Runs daily at 02:00 UTC
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app: prowler
spec:
serviceAccountName: prowler-sa
containers:
- name: prowler
image: prowlercloud/prowler:stable
args:
- "kubernetes"
- "--push-to-cloud"
env:
- name: PROWLER_CLOUD_API_KEY
valueFrom:
secretKeyRef:
name: prowler-cloud-api-key
key: api-key
- name: CLUSTER_NAME
value: "my-cluster"
imagePullPolicy: Always
volumeMounts:
- name: var-lib-cni
mountPath: /var/lib/cni
readOnly: true
- name: var-lib-etcd
mountPath: /var/lib/etcd
readOnly: true
- name: var-lib-kubelet
mountPath: /var/lib/kubelet
readOnly: true
- name: etc-kubernetes
mountPath: /etc/kubernetes
readOnly: true
hostPID: true
restartPolicy: Never
volumes:
- name: var-lib-cni
hostPath:
path: /var/lib/cni
- name: var-lib-etcd
hostPath:
path: /var/lib/etcd
- name: var-lib-kubelet
hostPath:
path: /var/lib/kubelet
- name: etc-kubernetes
hostPath:
path: /etc/kubernetes
```
<Note>
Replace `my-cluster` with a meaningful name for the cluster. This value appears in Prowler Cloud reports and helps identify the source of findings. See the `--cluster-name` flag documentation in [Getting Started with Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) for more details.
</Note>
### Customizing the Schedule
The `schedule` field uses standard cron syntax. Common examples:
* `"0 2 * * *"` — daily at 02:00 UTC
* `"0 */6 * * *"` — every 6 hours
* `"0 2 * * 1"` — weekly on Mondays at 02:00 UTC
### Scanning Specific Namespaces
To limit the scan to specific namespaces, add the `--namespace` flag to the `args` array:
```yaml
args:
- "kubernetes"
- "--push-to-cloud"
- "--namespace"
- "production,staging"
```
## Step 4: Deploy and Verify
Apply the CronJob to the cluster:
```console
kubectl apply -f prowler-cronjob.yaml
```
To trigger an immediate test run without waiting for the schedule:
```console
kubectl create job prowler-test-run --from=cronjob/prowler-k8s-scan -n prowler-ns
```
Monitor the job execution:
```console
kubectl get pods -n prowler-ns -l app=prowler --watch
```
Check the logs to confirm findings were pushed successfully:
```console
kubectl logs -n prowler-ns -l app=prowler --tail=50
```
A successful upload produces output similar to:
```
Pushing findings to Prowler Cloud, please wait...
Findings successfully pushed to Prowler Cloud. Ingestion job: fa8bc8c5-4925-46a0-9fe0-f6575905e094
See more details here: https://cloud.prowler.com/scans
```
## Step 5: View Findings in Prowler Cloud
Once the job completes and findings are pushed:
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/)
2. Open the "Scans" section to verify the ingestion job status
3. Browse findings under the Kubernetes provider
For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation.
## Tips and Troubleshooting
* **Resource limits**: For large clusters, consider setting `resources.requests` and `resources.limits` on the container to prevent the scan from consuming excessive cluster resources.
* **Network policies**: Ensure the Prowler pod can reach `api.prowler.com` over HTTPS (port 443). Adjust NetworkPolicies or egress rules if needed.
* **Job history**: Kubernetes retains completed and failed jobs by default. Set `successfulJobsHistoryLimit` and `failedJobsHistoryLimit` in the CronJob spec to control cleanup:
```yaml
spec:
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
```
* **API key rotation**: When rotating the API key, update the Secret and restart any running jobs:
```console
kubectl delete secret prowler-cloud-api-key -n prowler-ns
kubectl create secret generic prowler-cloud-api-key \
--from-literal=api-key=pk_new_api_key_here \
--namespace prowler-ns
```
* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for common error messages.
@@ -2,9 +2,13 @@
title: 'Google Workspace Authentication in Prowler'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="5.19.0" />
Prowler for Google Workspace uses a **Service Account with Domain-Wide Delegation** to authenticate to the Google Workspace Admin SDK. This allows Prowler to read directory data on behalf of a super administrator without requiring an interactive login.
## Required OAuth Scopes
## Required Open Authorization (OAuth) Scopes
Prowler requests the following read-only OAuth 2.0 scopes from the Google Workspace Admin SDK:
@@ -20,16 +24,16 @@ The delegated user must be a **super administrator** in your Google Workspace or
## Setup Steps
### Step 1: Create a GCP Project (if needed)
### Step 1: Create a Google Cloud Platform (GCP) Project (if Needed)
If you don't have a GCP project, create one at [https://console.cloud.google.com](https://console.cloud.google.com).
If no GCP project exists, create one at [https://console.cloud.google.com](https://console.cloud.google.com).
The project is only used to host the Service Account — it does not need to have any Google Workspace data in it.
### Step 2: Enable the Admin SDK API
1. Go to the [Google Cloud Console](https://console.cloud.google.com)
2. Select your project
1. Navigate to the [Google Cloud Console](https://console.cloud.google.com)
2. Select the target project
3. Navigate to **APIs & Services → Library**
4. Search for **Admin SDK API**
5. Click **Enable**
@@ -48,8 +52,8 @@ The Service Account does not need any GCP IAM roles. Its access to Google Worksp
### Step 4: Generate a JSON Key
1. Click on the Service Account you just created
2. Go to the **Keys** tab
1. Click the newly created Service Account
2. Navigate to the **Keys** tab
3. Click **Add Key → Create new key**
4. Select **JSON** format
5. Click **Create** — the key file will download automatically
@@ -61,7 +65,7 @@ This JSON key grants access to your Google Workspace organization. Never commit
### Step 5: Configure Domain-Wide Delegation in Google Workspace
1. Go to the [Google Workspace Admin Console](https://admin.google.com)
1. Navigate to the [Google Workspace Admin Console](https://admin.google.com)
2. Navigate to **Security → Access and data control → API controls**
3. Click **Manage Domain Wide Delegation**
4. Click **Add new**
@@ -78,23 +82,26 @@ https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.google
Domain-Wide Delegation must be configured by a Google Workspace **super administrator**. It may take a few minutes to propagate after saving.
</Note>
### Step 6: Store Credentials Securely
### Step 6: Provide Credentials to Prowler
Set your credentials as environment variables:
- **Prowler Cloud:** Paste the Service Account JSON content and enter the delegated user email in the credentials form when configuring the Google Workspace provider.
- **Prowler CLI:** Export the credentials as environment variables:
```bash
```console
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/googleworkspace-sa.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
prowler googleworkspace
```
Alternatively, if you need to pass credentials as a string (e.g., in CI/CD pipelines):
Alternatively, to pass credentials as a string (e.g., in CI/CD pipelines):
```bash
```console
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT=$(cat /path/to/googleworkspace-sa.json)
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
prowler googleworkspace
```
## Credential Lookup Order
## How Prowler Resolves Credentials
Prowler resolves credentials in the following order:
@@ -147,7 +154,7 @@ The Service Account cannot impersonate the delegated user. This usually means Do
- All three required OAuth scopes are included
- The delegated user is a super administrator
### Permission Denied on Admin SDK calls
### Permission Denied on Admin SDK Calls
If Prowler connects but returns empty results or permission errors for specific API calls:
@@ -1,100 +1,131 @@
---
title: 'Getting Started with Google Workspace'
title: 'Getting Started With Google Workspace on Prowler'
---
import { VersionBadge } from "/snippets/version-badge.mdx";
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="5.19.0" />
Prowler for Google Workspace allows you to audit your organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more.
Prowler for Google Workspace audits the organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more.
## Prerequisites
Before running Prowler with the Google Workspace provider, ensure you have:
Set up authentication for Google Workspace with the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide before starting either path:
1. A Google Workspace account with super administrator privileges
2. A Google Cloud Platform (GCP) project to host the Service Account
3. Authentication configured (see [Authentication](/user-guide/providers/googleworkspace/authentication)):
- A **Service Account JSON key** from a GCP project with Domain-Wide Delegation enabled
- **Service Account:** Create a Service Account in a GCP project with Domain-Wide Delegation enabled.
- **OAuth Scopes:** Authorize the required read-only OAuth scopes in the Google Workspace Admin Console.
- **Customer ID:** Identify the Google Workspace Customer ID to use as the provider identifier.
- **Delegated User:** Have the email of a super administrator to use as the delegated user.
## Quick Start
<CardGroup cols={2}>
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
Onboard Google Workspace using Prowler Cloud
</Card>
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
Onboard Google Workspace using Prowler CLI
</Card>
</CardGroup>
## Prowler Cloud
<VersionBadge version="5.21.0" />
### Step 1: Locate the Customer ID
1. Log into the [Google Workspace Admin Console](https://admin.google.com).
2. Navigate to "Account" > "Account Settings".
3. Find the **Customer ID** on the Account Settings page.
![Google Workspace Customer ID](/images/providers/googleworkspace-customer-id.png)
<Note>
The Customer ID starts with the letter "C" followed by alphanumeric characters (e.g., `C0xxxxxxx`). This value acts as the unique identifier for the Google Workspace account in Prowler Cloud.
</Note>
### Step 2: Open Prowler Cloud
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
2. Navigate to "Configuration" > "Cloud Providers".
![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png)
3. Click "Add Cloud Provider".
![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png)
4. Select "Google Workspace".
![Select Google Workspace](/images/providers/select-googleworkspace-prowler-cloud.png)
### Step 3: Provide Credentials
1. Enter the **Customer ID** and an optional alias, then click "Next".
![Google Workspace Customer ID Form](/images/providers/googleworkspace-customer-id-form.png)
2. Paste the **Service Account JSON** credentials content.
3. Enter the "Delegated User Email" (a super administrator in the Google Workspace organization).
![Google Workspace Credentials Form](/images/providers/googleworkspace-credentials-form.png)
<Note>
The Service Account JSON is the full content of the key file downloaded when creating the Service Account. Paste the entire JSON object, not just the file path. For setup instructions, see the [Authentication guide](/user-guide/providers/googleworkspace/authentication).
</Note>
### Step 4: Check Connection
1. Click "Check Connection" to verify that the credentials and Domain-Wide Delegation are configured correctly.
2. Prowler will test the Service Account impersonation and Admin SDK access.
![Check Connection](/images/providers/googleworkspace-check-connection.png)
<Note>
If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all three OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors.
</Note>
### Step 5: Launch the Scan
1. Review the summary.
2. Click "Launch Scan" to start auditing Google Workspace.
![Launch Scan](/images/providers/googleworkspace-launch-scan.png)
---
## Prowler CLI
<VersionBadge version="5.19.0" />
### Step 1: Set Up Authentication
Set your Service Account credentials file path and delegated user email as environment variables:
Set your Service Account credentials and delegated user email following the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide:
```bash
```console
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
```
### Step 2: Run Prowler
```bash
prowler googleworkspace
```
Prowler will authenticate as the delegated user and run all available security checks against your Google Workspace organization.
## Authentication
Prowler uses a **Service Account with Domain-Wide Delegation** to authenticate to Google Workspace. This requires:
- A Service Account created in a GCP project
- The Admin SDK API enabled in that project
- Domain-Wide Delegation configured in the Google Workspace Admin Console
- A super admin user email to impersonate
### Using Environment Variables (Recommended)
```bash
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
prowler googleworkspace
```
Alternatively, pass the credentials content directly as a JSON string:
```bash
```console
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT='{"type": "service_account", ...}'
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
```
### Step 2: Run the First Scan
Run a baseline scan after credentials are configured:
```console
prowler googleworkspace
```
<Note>
The delegated user must be a super admin email in your Google Workspace organization. The service account credentials must be provided via environment variables (`GOOGLEWORKSPACE_CREDENTIALS_FILE` or `GOOGLEWORKSPACE_CREDENTIALS_CONTENT`).
</Note>
Prowler authenticates as the delegated user and runs all available security checks against the Google Workspace organization.
## Understanding the Output
When Prowler runs successfully, it will display the credentials being used:
```
Using the Google Workspace credentials below:
┌─────────────────────────────────────────────────────────┐
│ Google Workspace Domain: yourdomain.com │
│ Customer ID: C0xxxxxxx │
│ Delegated User: admin@yourdomain.com │
│ Authentication Method: Service Account with Domain-Wide │
│ Delegation │
└─────────────────────────────────────────────────────────┘
```
Findings are reported per check. For example, the `directory_super_admin_count` check verifies the number of super administrators is within a recommended range (24):
- **PASS** — 2 to 4 super administrators found
- **FAIL** — 0 or 1 (single point of failure) or 5+ (excessive privilege exposure)
Output files are saved in the configured output directory (default: `output/`) in CSV, JSON-OCSF, and HTML formats.
## Configuration
### Step 3: Use a Custom Configuration (Optional)
Prowler uses a configuration file to customize provider behavior. To use a custom configuration:
```bash
```console
prowler googleworkspace --config-file /path/to/config.yaml
```
## Next Steps
- [Authentication](/user-guide/providers/googleworkspace/authentication) — Detailed guide on setting up a Service Account and Domain-Wide Delegation
---
@@ -164,3 +164,7 @@ env:
```
</Tip>
<Tip>
To set up a production-ready CronJob that runs Prowler on a schedule and sends findings to Prowler Cloud, see the [Run Kubernetes In-Cluster and Send Findings to Prowler Cloud](/user-guide/cookbooks/kubernetes-in-cluster) cookbook.
</Tip>
@@ -202,15 +202,135 @@ To expand the graph for detailed exploration, click the fullscreen icon in the g
width="700"
/>
## Using Attack Paths with the MCP Server
## Using Attack Paths with the MCP Server and Lighthouse AI
Attack Paths capabilities are also available through the [Prowler MCP Server](/getting-started/products/prowler-mcp), enabling interaction with Attack Paths data via AI assistants like Claude Desktop, Cursor, and other MCP clients.
[Prowler Lighthouse AI](/getting-started/products/prowler-lighthouse-ai) also supports Attack Paths queries, allowing you to analyze privilege escalation chains and security misconfigurations directly from the chat interface.
The following MCP tools are available for Attack Paths:
- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans
- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan
- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries
### Example Questions
Ask through the MCP Server or Lighthouse AI:
- "Find EC2 instances exposed to the internet with access to sensitive S3 buckets"
- "Are there any IAM roles that can escalate their own privileges?"
- "Show me all internet-facing resources with open security groups"
- "Which principals can create Lambda functions with privileged roles?"
- "List all RDS instances with storage encryption disabled"
- "Find S3 buckets that allow anonymous access"
- "Are there any CloudFormation stacks that could be hijacked for privilege escalation?"
- "Show me all roles that can be assumed for lateral movement"
### Supported Queries
Attack Paths currently supports the following built-in queries for AWS:
#### Custom Attack Path Queries
| Query | Description |
|---|---|
| **Internet-Exposed EC2 with Sensitive S3 Access** | Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets |
#### Basic Resource Queries
| Query | Description |
|---|---|
| **RDS Instances Inventory** | List all provisioned RDS database instances in the account |
| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled |
| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access |
| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) |
| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy |
| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action |
#### Network Exposure Queries
| Query | Description |
|---|---|
| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet |
| **Open Security Groups on Internet-Facing Resources** | Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0 |
| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners |
| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners |
| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address |
#### Privilege Escalation Queries
These queries are based on research from [pathfinding.cloud](https://pathfinding.cloud) by Datadog.
| Query | Description |
|---|---|
| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions |
| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role |
| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached |
| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials |
| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources |
| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role |
| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts |
| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role |
| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role |
| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec |
| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec |
| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure |
| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS |
| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions |
| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS |
| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed |
| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS |
| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code |
| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code |
| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster |
| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster |
| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code |
| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials |
| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions |
| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials |
| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions |
| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code |
| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions |
| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution |
| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges |
| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account |
| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions |
| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions |
| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges |
| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions |
| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges |
| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges |
| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges |
| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members |
| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members |
| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions |
| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group |
| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges |
| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access |
| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges |
| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access |
| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions |
| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions |
| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions |
| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions |
| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role |
| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions |
| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment |
| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions |
| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions |
| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions |
| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup |
| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS |
| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS |
| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role |
These tools enable workflows such as:
- Asking an AI assistant to identify privilege escalation paths in a specific AWS account
+10
View File
@@ -2,6 +2,16 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.22.0] (Prowler v5.22.0)
### 🐞 Fixed
- Azure MySQL flexible server checks now compare configuration values case-insensitively to avoid false negatives when Azure returns lowercase values [(#10396)](https://github.com/prowler-cloud/prowler/pull/10396)
- Azure `vm_backup_enabled` and `vm_sufficient_daily_backup_retention_period` checks now compare VM names case-insensitively to avoid false negatives when Azure stores backup item names in a different case [(#10395)](https://github.com/prowler-cloud/prowler/pull/10395)
- `entra_non_privileged_user_has_mfa` skips disabled users to avoid false positives [(#10426)](https://github.com/prowler-cloud/prowler/pull/10426)
---
## [5.21.0] (Prowler v5.21.0)
### 🚀 Added
+1 -1
View File
@@ -38,7 +38,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.21.1"
prowler_version = "5.22.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -1587,6 +1587,7 @@
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
@@ -1670,20 +1671,8 @@
"budgets": {
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ca-central-1",
"eu-central-1",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2"
],
"aws-cn": [
@@ -3439,7 +3428,6 @@
"datazone": {
"regions": {
"aws": [
"af-south-1",
"ap-east-1",
"ap-northeast-1",
"ap-northeast-2",
@@ -3452,7 +3440,6 @@
"eu-central-1",
"eu-central-2",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
@@ -6998,6 +6985,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -7022,6 +7010,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -7695,6 +7684,7 @@
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-4",
"ap-southeast-5",
"ca-central-1",
"eu-central-1",
"eu-north-1",
@@ -7932,6 +7922,7 @@
"aws": [
"ap-southeast-2",
"eu-west-1",
"eu-west-2",
"us-east-1",
"us-west-2"
],
@@ -8255,6 +8246,7 @@
"ap-east-1",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
@@ -8270,6 +8262,7 @@
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
"us-west-2"
],
"aws-cn": [],
@@ -9877,6 +9870,7 @@
"eu-west-1",
"eu-west-2",
"il-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-1",
@@ -11,7 +11,7 @@ class entra_non_privileged_user_has_mfa(Check):
for tenant_domain, users in entra_client.users.items():
for user in users.values():
if not is_privileged_user(
if user.account_enabled and not is_privileged_user(
user, entra_client.directory_roles[tenant_domain]
):
report = Check_Report_Azure(metadata=self.metadata(), resource=user)
@@ -3,7 +3,9 @@ from asyncio import gather
from typing import List, Optional
from uuid import UUID
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -65,9 +67,16 @@ class Entra(AzureService):
logger.info("Entra - Getting users...")
users = {}
try:
request_configuration = RequestConfiguration(
query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=["id", "displayName", "accountEnabled"]
)
)
for tenant, client in self.clients.items():
users.update({tenant: {}})
users_response = await client.users.get()
users_response = await client.users.get(
request_configuration=request_configuration
)
registration_details = await self._get_user_registration_details(client)
try:
@@ -81,6 +90,9 @@ class Entra(AzureService):
is_mfa_capable=registration_details.get(
user.id, False
),
account_enabled=getattr(
user, "account_enabled", True
),
)
}
)
@@ -409,6 +421,7 @@ class User(BaseModel):
id: str
name: str
is_mfa_capable: bool = False
account_enabled: bool = True
class DefaultUserRolePermissions(BaseModel):
@@ -21,9 +21,9 @@ class mysql_flexible_server_audit_log_connection_activated(Check):
"audit_log_events"
].resource_id
if "CONNECTION" in server.configurations[
if "connection" in server.configurations[
"audit_log_events"
].value.split(","):
].value.lower().split(","):
report.status = "PASS"
report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}."
@@ -21,7 +21,7 @@ class mysql_flexible_server_audit_log_enabled(Check):
"audit_log_enabled"
].resource_id
if server.configurations["audit_log_enabled"].value == "ON":
if server.configurations["audit_log_enabled"].value.lower() == "on":
report.status = "PASS"
report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}."
@@ -20,7 +20,10 @@ class mysql_flexible_server_ssl_connection_enabled(Check):
report.resource_id = server.configurations[
"require_secure_transport"
].resource_id
if server.configurations["require_secure_transport"].value == "ON":
if (
server.configurations["require_secure_transport"].value.lower()
== "on"
):
report.status = "PASS"
report.status_extended = f"SSL connection is enabled for server {server.name} in subscription {subscription_name}."
@@ -31,7 +31,8 @@ class vm_backup_enabled(Check):
for backup_item in vault.backup_protected_items.values():
if (
backup_item.workload_type == DataSourceType.VM
and backup_item.name.split(";")[-1] == vm.resource_name
and backup_item.name.split(";")[-1].lower()
== vm.resource_name.lower()
):
found = True
found_vault_name = vault.name
@@ -27,7 +27,8 @@ class vm_sufficient_daily_backup_retention_period(Check):
for backup_item in vault.backup_protected_items.values():
if (
backup_item.workload_type == DataSourceType.VM
and backup_item.name.split(";")[-1] == vm.resource_name
and backup_item.name.split(";")[-1].lower()
== vm.resource_name.lower()
):
backup_found = True
policy_id = backup_item.backup_policy_id
+1 -1
View File
@@ -94,7 +94,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
version = "5.21.1"
version = "5.22.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
+12
View File
@@ -132,6 +132,18 @@ Follow conventional commits:
4. ✅ Branch is up to date with main
5. ✅ Commits are clean and descriptive
## Before Re-Requesting Review (REQUIRED)
Resolve or respond to **every** open inline review thread before re-requesting review:
1. **Agreed + fixed**: Commit the change. Reply with the commit hash so the reviewer can verify quickly:
> Fixed in `abc1234`.
2. **Agreed but deferred**: Explain why it's out of scope for this PR and where it's tracked.
3. **Disagreed**: Reply with clear technical reasoning. Do not leave threads silently open.
4. **Re-request review** only after all threads are in a clean state — either resolved or explicitly responded to.
> **Rule of thumb**: A reviewer should never have to wonder "did they see my comment?" when they re-open the PR.
## Resources
- **Documentation**: See [references/](references/) for links to local developer guide
+112
View File
@@ -186,6 +186,109 @@ cd ui && pnpm run build
cd ui && pnpm start
```
## Batch vs Instant Component API (REQUIRED)
When a component supports both **batch** (deferred, submit-based) and **instant** (immediate callback) behavior, model the coupling with a discriminated union — never as independent optionals. Coupled props must be all-or-nothing.
```typescript
// ❌ NEVER: Independent optionals — allows invalid half-states
interface FilterProps {
onBatchApply?: (values: string[]) => void;
onInstantChange?: (value: string) => void;
isBatchMode?: boolean;
}
// ✅ ALWAYS: Discriminated union — one valid shape per mode
type BatchProps = {
mode: "batch";
onApply: (values: string[]) => void;
onCancel: () => void;
};
type InstantProps = {
mode: "instant";
onChange: (value: string) => void;
// onApply/onCancel are forbidden here via structural exclusion
onApply?: never;
onCancel?: never;
};
type FilterProps = BatchProps | InstantProps;
```
This makes invalid prop combinations a compile error, not a runtime surprise.
## Reuse Shared Display Utilities First (REQUIRED)
Before adding **local** display maps (labels, provider names, status strings, category formatters), search `ui/types/*` and `ui/lib/*` for existing helpers.
```typescript
// ✅ CHECK THESE FIRST before creating a new map:
// ui/lib/utils.ts → general formatters
// ui/types/providers.ts → provider display names, icons
// ui/types/findings.ts → severity/status display maps
// ui/types/compliance.ts → category/group formatters
// ❌ NEVER add a local map that already exists:
const SEVERITY_LABELS: Record<string, string> = {
critical: "Critical",
high: "High",
// ...duplicating an existing shared map
};
// ✅ Import and reuse instead:
import { severityLabel } from "@/types/findings";
```
If a helper doesn't exist and will be used in 2+ places, add it to `ui/lib/` or `ui/types/` and reuse it. Keep local only if used in exactly one place.
## Derived State Rule (REQUIRED)
Avoid `useState` + `useEffect` patterns that mirror props or searchParams — they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth.
```typescript
// ❌ NEVER: Mirror props into state via effect
const [localFilter, setLocalFilter] = useState(filter);
useEffect(() => { setLocalFilter(filter); }, [filter]);
// ✅ ALWAYS: Derive directly
const localFilter = filter; // or compute inline
```
If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment:
```typescript
// Local state needed: user edits are buffered until "Apply" is clicked
const [pending, setPending] = useState(initialValues);
```
## Strict Key Typing for Label Maps (REQUIRED)
Avoid `Record<string, string>` when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time.
```typescript
// ❌ Loose — typos compile silently
const STATUS_LABELS: Record<string, string> = {
actve: "Active", // typo, no error
};
// ✅ Tight — union key
type Status = "active" | "inactive" | "pending";
const STATUS_LABELS: Record<Status, string> = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
// actve: "Active" ← compile error
};
// ✅ Also fine — const satisfies
const STATUS_LABELS = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
} as const satisfies Record<Status, string>;
```
## QA Checklist Before Commit
- [ ] `pnpm run typecheck` passes
@@ -199,6 +302,15 @@ cd ui && pnpm start
- [ ] Accessibility: keyboard navigation, ARIA labels
- [ ] Mobile responsive (if applicable)
## Pre-Re-Review Checklist (Review Thread Hygiene)
Before requesting re-review from a reviewer:
- [ ] Every unresolved inline thread has been either fixed or explicitly answered with a rationale
- [ ] If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply
- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
- [ ] Re-request review only after all threads are in a clean state
## Migrations Reference
| From | To | Key Changes |
+32
View File
@@ -102,6 +102,38 @@ function isUser(value: unknown): value is User {
}
```
## Coupled Optional Props (REQUIRED)
Do not model semantically coupled props as independent optionals — this allows invalid half-states that compile but break at runtime. Use discriminated unions with `never` to make invalid combinations impossible.
```typescript
// ❌ BEFORE: Independent optionals — half-states allowed
interface PaginationProps {
onPageChange?: (page: number) => void;
pageSize?: number;
currentPage?: number;
}
// ✅ AFTER: Discriminated union — shape is all-or-nothing
type ControlledPagination = {
controlled: true;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
};
type UncontrolledPagination = {
controlled: false;
currentPage?: never;
pageSize?: never;
onPageChange?: never;
};
type PaginationProps = ControlledPagination | UncontrolledPagination;
```
**Key rule:** If two or more props are only meaningful together, they belong to the same discriminated union branch. Mixing them as independent optionals shifts correctness responsibility from the type system to runtime guards.
## Import Types
```typescript
@@ -142,6 +142,86 @@ class Test_entra_non_privileged_user_has_mfa:
assert result[0].resource_id == user_id
assert result[0].subscription == f"Tenant: {DOMAIN}"
def test_entra_disabled_user_no_privileged_no_mfa(self):
entra_client = mock.MagicMock
user_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import (
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
DirectoryRole,
User,
)
user = User(
id=user_id,
name="foo",
is_mfa_capable=False,
account_enabled=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
entra_client.directory_roles = {
DOMAIN: {
"Global Administrator": DirectoryRole(id=str(uuid4()), members=[])
}
}
check = entra_non_privileged_user_has_mfa()
result = check.execute()
assert len(result) == 0
def test_entra_disabled_user_no_privileged_mfa(self):
entra_client = mock.MagicMock
user_id = str(uuid4())
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import (
entra_non_privileged_user_has_mfa,
)
from prowler.providers.azure.services.entra.entra_service import (
DirectoryRole,
User,
)
user = User(
id=user_id,
name="foo",
is_mfa_capable=True,
account_enabled=False,
)
entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}}
entra_client.directory_roles = {
DOMAIN: {
"Global Administrator": DirectoryRole(id=str(uuid4()), members=[])
}
}
check = entra_non_privileged_user_has_mfa()
result = check.execute()
assert len(result) == 0
def test_entra_user_privileged_no_mfa(self):
entra_client = mock.MagicMock
user_id = str(uuid4())
@@ -147,6 +147,7 @@ class Test_Entra_Service:
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1"
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1"
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].account_enabled is True
def test_get_authorization_policy(self):
entra_client = Entra(set_mocked_azure_provider())
@@ -229,8 +230,8 @@ def test_azure_entra__get_users_handles_pagination():
entra_service = Entra.__new__(Entra)
users_page_one = [
SimpleNamespace(id="user-1", display_name="User 1"),
SimpleNamespace(id="user-2", display_name="User 2"),
SimpleNamespace(id="user-1", display_name="User 1", account_enabled=False),
SimpleNamespace(id="user-2", display_name="User 2", account_enabled=True),
]
users_page_two = [
SimpleNamespace(id="user-3", display_name="User 3"),
@@ -288,9 +289,18 @@ def test_azure_entra__get_users_handles_pagination():
assert len(users["tenant-1"]) == 3
assert users_builder.get.await_count == 1
request_configuration = users_builder.get.await_args.kwargs["request_configuration"]
assert request_configuration.query_parameters.select == [
"id",
"displayName",
"accountEnabled",
]
with_url_mock.assert_called_once_with("next-link")
registration_details_builder.get.assert_awaited()
registration_details_builder.with_url.assert_not_called()
assert users["tenant-1"]["user-1"].is_mfa_capable is True
assert users["tenant-1"]["user-1"].account_enabled is False
assert users["tenant-1"]["user-2"].is_mfa_capable is True
assert users["tenant-1"]["user-2"].account_enabled is True
assert users["tenant-1"]["user-3"].is_mfa_capable is False
assert users["tenant-1"]["user-3"].account_enabled is True
@@ -56,6 +56,57 @@ class Test_mysql_flexible_server_audit_log_connection_activated:
result = check.execute()
assert len(result) == 0
def test_mysql_audit_log_connection_activated_lowercase(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
mysql_client.flexible_servers = {
AZURE_SUBSCRIPTION_ID: {
"/subscriptions/resource_id": FlexibleServer(
resource_id="/subscriptions/resource_id",
name=server_name,
location="location",
version="version",
configurations={
"audit_log_events": Configuration(
resource_id=f"/subscriptions/{server_name}/configurations/audit_log_events",
description="description",
value="connection",
)
},
)
}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_connection_activated.mysql_flexible_server_audit_log_connection_activated.mysql_client",
new=mysql_client,
),
):
from prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_connection_activated.mysql_flexible_server_audit_log_connection_activated import (
mysql_flexible_server_audit_log_connection_activated,
)
check = mysql_flexible_server_audit_log_connection_activated()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == server_name
assert result[0].location == "location"
assert (
result[0].resource_id
== f"/subscriptions/{server_name}/configurations/audit_log_events"
)
assert (
result[0].status_extended
== f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
)
def test_mysql_audit_log_connection_not_connection(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
@@ -56,6 +56,57 @@ class Test_mysql_flexible_server_audit_log_enabled:
result = check.execute()
assert len(result) == 0
def test_mysql_audit_log_enabled_lowercase(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
mysql_client.flexible_servers = {
AZURE_SUBSCRIPTION_ID: {
"/subscriptions/resource_id": FlexibleServer(
resource_id="/subscriptions/resource_id",
name=server_name,
location="location",
version="version",
configurations={
"audit_log_enabled": Configuration(
resource_id=f"/subscriptions/{server_name}/configurations/audit_log_enabled",
description="description",
value="on",
)
},
)
}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_enabled.mysql_flexible_server_audit_log_enabled.mysql_client",
new=mysql_client,
),
):
from prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_enabled.mysql_flexible_server_audit_log_enabled import (
mysql_flexible_server_audit_log_enabled,
)
check = mysql_flexible_server_audit_log_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == server_name
assert result[0].location == "location"
assert (
result[0].resource_id
== f"/subscriptions/{server_name}/configurations/audit_log_enabled"
)
assert (
result[0].status_extended
== f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
)
def test_mysql_audit_log_disabled(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
@@ -107,6 +107,57 @@ class Test_mysql_flexible_server_ssl_connection_enabled:
== f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
)
def test_mysql_connection_enabled_lowercase(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
mysql_client.flexible_servers = {
AZURE_SUBSCRIPTION_ID: {
"/subscriptions/resource_id": FlexibleServer(
resource_id="/subscriptions/resource_id",
name=server_name,
location="location",
version="version",
configurations={
"require_secure_transport": Configuration(
resource_id=f"/subscriptions/{server_name}/configurations/require_secure_transport",
description="description",
value="on",
)
},
)
}
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.mysql.mysql_flexible_server_ssl_connection_enabled.mysql_flexible_server_ssl_connection_enabled.mysql_client",
new=mysql_client,
),
):
from prowler.providers.azure.services.mysql.mysql_flexible_server_ssl_connection_enabled.mysql_flexible_server_ssl_connection_enabled import (
mysql_flexible_server_ssl_connection_enabled,
)
check = mysql_flexible_server_ssl_connection_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == server_name
assert result[0].location == "location"
assert (
result[0].resource_id
== f"/subscriptions/{server_name}/configurations/require_secure_transport"
)
assert (
result[0].status_extended
== f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}."
)
def test_mysql_ssl_connection_disabled(self):
server_name = str(uuid4())
mysql_client = mock.MagicMock
@@ -221,6 +221,85 @@ class Test_vm_backup_enabled:
== f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup."
)
def test_vm_protected_by_backup_case_insensitive(self):
vm_id = str(uuid4())
vm_name = "vmtest"
vault_id = str(uuid4())
vault_name = "vault1"
mock_vm_client = mock.MagicMock()
mock_recovery_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client",
new=mock_vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client",
new=mock_recovery_client,
),
):
from azure.mgmt.recoveryservicesbackup.activestamp.models import (
DataSourceType,
)
from prowler.providers.azure.services.recovery.recovery_service import (
BackupItem,
BackupVault,
)
from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import (
vm_backup_enabled,
)
from prowler.providers.azure.services.vm.vm_service import (
ManagedDiskParameters,
OSDisk,
StorageProfile,
VirtualMachine,
)
vm = VirtualMachine(
resource_id=vm_id,
resource_name=vm_name,
location="eastus",
security_profile=None,
extensions=[],
storage_profile=StorageProfile(
os_disk=OSDisk(
name="os_disk_name",
operating_system_type="Linux",
managed_disk=ManagedDiskParameters(id="managed_disk_id"),
),
data_disks=[],
),
)
backup_item = BackupItem(
id=str(uuid4()),
name="someprefix;VMTEST",
workload_type=DataSourceType.VM,
)
vault = BackupVault(
id=vault_id,
name=vault_name,
location="eastus",
backup_protected_items={backup_item.id: backup_item},
)
mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}}
mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}}
check = vm_backup_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == vm_name
assert result[0].resource_id == vm_id
assert (
result[0].status_extended
== f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is protected by Azure Backup (vault: {vault_name})."
)
def test_vm_protected_by_backup_non_vm_workload(self):
vm_id = str(uuid4())
vm_name = "VMTest"
@@ -156,6 +156,100 @@ class Test_vm_sufficient_daily_backup_retention_period:
in result[0].status_extended
)
def test_vm_with_sufficient_retention_case_insensitive(self):
from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType
from prowler.providers.azure.services.recovery.recovery_service import (
BackupItem,
BackupPolicy,
BackupVault,
)
from prowler.providers.azure.services.vm.vm_service import (
ManagedDiskParameters,
OSDisk,
StorageProfile,
VirtualMachine,
)
vm_id = str(uuid4())
vm_name = "vmtest"
vault_id = str(uuid4())
policy_id = str(uuid4())
retention_days = 14
min_retention_days = 7
vm = VirtualMachine(
resource_id=vm_id,
resource_name=vm_name,
location="eastus",
security_profile=None,
extensions=[],
storage_profile=StorageProfile(
os_disk=OSDisk(
name="os_disk_name",
operating_system_type="Linux",
managed_disk=ManagedDiskParameters(id="managed_disk_id"),
),
data_disks=[],
),
)
backup_item = BackupItem(
id=str(uuid4()),
name="someprefix;VMTEST",
workload_type=DataSourceType.VM,
backup_policy_id=policy_id,
)
backup_policy = BackupPolicy(
id=policy_id,
name="policy1",
retention_days=retention_days,
)
vault = BackupVault(
id=vault_id,
name="vault1",
location="eastus",
backup_protected_items={backup_item.id: backup_item},
backup_policies={policy_id: backup_policy},
)
vm_client = mock.MagicMock()
recovery_client = mock.MagicMock()
vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}}
recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}}
vm_client.audit_config = {
"vm_backup_min_daily_retention_days": min_retention_days
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(
audit_config=vm_client.audit_config
),
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.vm_client",
new=vm_client,
),
mock.patch(
"prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.recovery_client",
new=recovery_client,
),
):
from prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period import (
vm_sufficient_daily_backup_retention_period,
)
check = vm_sufficient_daily_backup_retention_period()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].subscription == AZURE_SUBSCRIPTION_ID
assert result[0].resource_name == vm_name
assert result[0].resource_id == vm_id
assert (
f"has a daily backup retention period of {retention_days} days"
in result[0].status_extended
)
def test_vm_with_insufficient_retention(self):
from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType
+12
View File
@@ -2,6 +2,18 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.22.0] (Prowler v5.22.0)
### 🚀 Added
- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397)
### 🔄 Changed
- Findings filters now use a batch-apply pattern with an Apply Filters button, filter summary strip, and independent filter options instead of triggering API calls on every selection [(#10388)](https://github.com/prowler-cloud/prowler/pull/10388)
---
## [1.21.0] (Prowler v5.21.0)
### 🚀 Added
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathCartographySchemaAttributes,
type AttackPathQuery,
} from "@/types/attack-paths";
import { buildAttackPathQueries } from "./queries.adapter";
const presetQuery: AttackPathQuery = {
type: "attack-paths-scans",
id: "preset-query",
attributes: {
name: "Preset Query",
short_description: "Returns privileged attack paths",
description: "Returns privileged attack paths.",
provider: "aws",
attribution: null,
parameters: [],
},
};
describe("buildAttackPathQueries", () => {
it("prepends a custom query with a schema documentation link", () => {
// Given
const schema: AttackPathCartographySchemaAttributes = {
id: "aws-0.129.0",
provider: "aws",
cartography_version: "0.129.0",
schema_url:
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
raw_schema_url:
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
};
// When
const result = buildAttackPathQueries([presetQuery], schema);
// Then
expect(result[0]).toMatchObject({
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write and run your own read-only query",
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: schema.schema_url,
},
},
});
expect(result[1]).toEqual(presetQuery);
});
});
@@ -1,7 +1,10 @@
import { MetaDataProps } from "@/types";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathCartographySchemaAttributes,
AttackPathQueriesResponse,
AttackPathQuery,
QUERY_PARAMETER_INPUT_TYPES,
} from "@/types/attack-paths";
/**
@@ -53,3 +56,52 @@ export function adaptAttackPathQueriesResponse(
return { data: enrichedData, metadata };
}
const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n)
RETURN n
LIMIT 25`;
const formatSchemaDocumentationLinkText = (
schema: AttackPathCartographySchemaAttributes,
): string => {
return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`;
};
const createCustomQuery = (
schema?: AttackPathCartographySchemaAttributes,
): AttackPathQuery => ({
type: "attack-paths-scans",
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write and run your own read-only query",
description:
"Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.",
provider: "custom",
attribution: null,
documentation_link: schema
? {
text: formatSchemaDocumentationLinkText(schema),
link: schema.schema_url,
}
: null,
parameters: [
{
name: "query",
label: "openCypher",
data_type: "string",
description: "",
placeholder: CUSTOM_QUERY_PLACEHOLDER,
required: true,
input_type: QUERY_PARAMETER_INPUT_TYPES.TEXTAREA,
},
],
},
});
export const buildAttackPathQueries = (
queries: AttackPathQuery[],
schema?: AttackPathCartographySchemaAttributes,
): AttackPathQuery[] => {
return [createCustomQuery(schema), ...queries];
};
+141 -1
View File
@@ -17,7 +17,11 @@ vi.mock("@/lib/server-actions-helper", () => ({
handleApiResponse: handleApiResponseMock,
}));
import { executeQuery } from "./queries";
import {
executeCustomQuery,
executeQuery,
getCartographySchema,
} from "./queries";
describe("executeQuery", () => {
beforeEach(() => {
@@ -65,3 +69,139 @@ describe("executeQuery", () => {
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
});
describe("executeCustomQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
handleApiResponseMock.mockResolvedValue({
data: {
type: "attack-paths-query-run-requests",
id: null,
attributes: {
nodes: [],
relationships: [],
},
},
});
});
it("posts the custom query to the dedicated endpoint", async () => {
// Given
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
// When
await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
"MATCH (n) RETURN n LIMIT 10",
);
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/queries/custom",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
data: {
type: "attack-paths-custom-query-run-requests",
attributes: {
query: "MATCH (n) RETURN n LIMIT 10",
},
},
}),
}),
);
});
it("rejects empty custom queries before calling the API", async () => {
// When
const result = await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
" ",
);
// Then
expect(result).toEqual({
error: "Custom query cannot be empty",
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
it("rejects custom queries longer than 10000 characters before calling the API", async () => {
// When
const result = await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
"x".repeat(10001),
);
// Then
expect(result).toEqual({
error: "Custom query must be 10000 characters or fewer",
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
it("rejects custom queries with write operations before calling the API", async () => {
// When
const result = await executeCustomQuery(
"550e8400-e29b-41d4-a716-446655440000",
"MATCH (n) SET n.name = 'updated' RETURN n",
);
// Then
expect(result).toEqual({
error: "Only read-only queries are allowed",
status: 400,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(handleApiResponseMock).not.toHaveBeenCalled();
});
});
describe("getCartographySchema", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
});
it("fetches the schema metadata for the selected scan", async () => {
// Given
const apiResponse = {
data: {
type: "attack-paths-cartography-schemas",
id: "aws-0.129.0",
attributes: {
id: "aws-0.129.0",
provider: "aws",
cartography_version: "0.129.0",
schema_url:
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
raw_schema_url:
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
},
},
};
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
handleApiResponseMock.mockResolvedValue(apiResponse);
// When
const result = await getCartographySchema(
"550e8400-e29b-41d4-a716-446655440000",
);
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/schema",
expect.objectContaining({
method: "GET",
}),
);
expect(result).toEqual(apiResponse);
});
});
+94
View File
@@ -3,12 +3,16 @@
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query";
import { handleApiResponse } from "@/lib/server-actions-helper";
import {
AttackPathCartographySchema,
AttackPathCartographySchemaResponse,
AttackPathQueriesResponse,
AttackPathQuery,
AttackPathQueryError,
AttackPathQueryResult,
ExecuteCustomQueryRequest,
ExecuteQueryRequest,
} from "@/types/attack-paths";
@@ -102,3 +106,93 @@ export const executeQuery = async (
};
}
};
/**
* Execute a custom openCypher query on an attack path scan
*/
export const executeCustomQuery = async (
scanId: string,
query: string,
): Promise<AttackPathQueryResult | AttackPathQueryError | undefined> => {
const validatedScanId = UUIDSchema.safeParse(scanId);
if (!validatedScanId.success) {
console.error("Invalid scan ID format");
return undefined;
}
const validatedQuery = customAttackPathQuerySchema.safeParse(query);
if (!validatedQuery.success) {
return {
error:
validatedQuery.error.issues[0]?.message ?? "Custom query is invalid.",
status: 400,
};
}
const headers = await getAuthHeaders({ contentType: true });
const requestBody: ExecuteCustomQueryRequest = {
data: {
type: "attack-paths-custom-query-run-requests",
attributes: {
query: validatedQuery.data,
},
},
};
try {
const response = await fetch(
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/custom`,
{
headers,
method: "POST",
body: JSON.stringify(requestBody),
},
);
return (await handleApiResponse(response)) as
| AttackPathQueryResult
| AttackPathQueryError;
} catch (error) {
console.error("Error executing custom query on scan:", error);
return {
error:
"Server is temporarily unavailable. Please try again in a few minutes.",
status: 503,
};
}
};
/**
* Fetch cartography schema metadata for a specific attack path scan
*/
export const getCartographySchema = async (
scanId: string,
): Promise<{ data: AttackPathCartographySchema } | undefined> => {
const validatedScanId = UUIDSchema.safeParse(scanId);
if (!validatedScanId.success) {
console.error("Invalid scan ID format");
return undefined;
}
const headers = await getAuthHeaders({ contentType: false });
try {
const response = await fetch(
`${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/schema`,
{
headers,
method: "GET",
},
);
const apiResponse = (await handleApiResponse(
response,
)) as AttackPathCartographySchemaResponse;
return { data: apiResponse.data };
} catch (error) {
console.error("Error fetching cartography schema for scan:", error);
return undefined;
}
};
@@ -27,7 +27,11 @@ import {
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderProps, ProviderType } from "@/types/providers";
import {
getProviderDisplayName,
type ProviderProps,
type ProviderType,
} from "@/types/providers";
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
@@ -46,60 +50,73 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
openstack: <OpenStackProviderBadge width={18} height={18} />,
};
interface AccountsSelectorProps {
/** Common props shared by both batch and instant modes. */
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
/**
* Currently selected provider types (from the pending ProviderTypeSelector state).
* Used only for contextual description/empty-state messaging does NOT narrow
* the list of available accounts, which remains independent of provider selection.
*/
selectedProviderTypes?: string[];
}
export function AccountsSelector({ providers }: AccountsSelectorProps) {
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
interface AccountsSelectorBatchProps extends AccountsSelectorBaseProps {
/**
* Called instead of navigating immediately.
* Use this on pages that batch filter changes (e.g. Findings).
*
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_id__in"
* @param values - The selected values array
*/
onBatchChange: (filterKey: string, values: string[]) => void;
/**
* Pending selected values controlled by the parent.
* Reflects pending state before Apply is clicked.
*/
selectedValues: string[];
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface AccountsSelectorInstantProps extends AccountsSelectorBaseProps {
onBatchChange?: never;
selectedValues?: never;
}
type AccountsSelectorProps =
| AccountsSelectorBatchProps
| AccountsSelectorInstantProps;
export function AccountsSelector({
providers,
onBatchChange,
selectedValues,
selectedProviderTypes,
}: AccountsSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
const selectedTypes = searchParams.get("filter[provider_type__in]") || "";
const selectedTypesList = selectedTypes
? selectedTypes.split(",").filter(Boolean)
: [];
const selectedIds = current ? current.split(",").filter(Boolean) : [];
const visibleProviders = providers
// .filter((p) => p.attributes.connection?.connected)
.filter((p) =>
selectedTypesList.length > 0
? selectedTypesList.includes(p.attributes.provider)
: true,
);
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
const visibleProviders = providers;
// .filter((p) => p.attributes.connection?.connected)
const handleMultiValueChange = (ids: string[]) => {
if (onBatchChange) {
onBatchChange("provider_id__in", ids);
return;
}
navigateWithParams((params) => {
params.delete(filterKey);
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}
// Auto-deselect provider types that no longer have any selected accounts
if (selectedTypesList.length > 0) {
// Get provider types of currently selected accounts
const selectedProviders = providers.filter((p) => ids.includes(p.id));
const selectedProviderTypes = new Set(
selectedProviders.map((p) => p.attributes.provider),
);
// Keep only provider types that still have selected accounts
const remainingProviderTypes = selectedTypesList.filter((type) =>
selectedProviderTypes.has(type as ProviderType),
);
// Update provider_type__in filter
if (remainingProviderTypes.length > 0) {
params.set(
"filter[provider_type__in]",
remainingProviderTypes.join(","),
);
} else {
params.delete("filter[provider_type__in]");
}
}
});
};
@@ -115,9 +132,12 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
);
};
// Build a contextual description based on currently selected provider types.
// This is purely for user guidance (aria label + empty state) and does NOT
// narrow the list of available accounts — all providers remain selectable.
const filterDescription =
selectedTypesList.length > 0
? `Showing accounts for ${selectedTypesList.join(", ")} providers`
selectedProviderTypes && selectedProviderTypes.length > 0
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "All connected cloud provider accounts";
return (
@@ -176,8 +196,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
{selectedTypesList.length > 0
? "No accounts available for selected providers"
{selectedProviderTypes && selectedProviderTypes.length > 0
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "No connected accounts available"}
</div>
)}
@@ -152,22 +152,60 @@ const PROVIDER_DATA: Record<
},
};
type ProviderTypeSelectorProps = {
/** Common props shared by both batch and instant modes. */
interface ProviderTypeSelectorBaseProps {
providers: ProviderProps[];
};
}
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
interface ProviderTypeSelectorBatchProps extends ProviderTypeSelectorBaseProps {
/**
* Called instead of navigating immediately.
* Use this on pages that batch filter changes (e.g. Findings).
*
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_type__in"
* @param values - The selected values array
*/
onBatchChange: (filterKey: string, values: string[]) => void;
/**
* Pending selected values controlled by the parent.
* Reflects pending state before Apply is clicked.
*/
selectedValues: string[];
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface ProviderTypeSelectorInstantProps
extends ProviderTypeSelectorBaseProps {
onBatchChange?: never;
selectedValues?: never;
}
type ProviderTypeSelectorProps =
| ProviderTypeSelectorBatchProps
| ProviderTypeSelectorInstantProps;
export const ProviderTypeSelector = ({
providers,
onBatchChange,
selectedValues,
}: ProviderTypeSelectorProps) => {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
const selectedTypes = currentProviders
const urlSelectedTypes = currentProviders
? currentProviders.split(",").filter(Boolean)
: [];
// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedTypes = onBatchChange ? selectedValues : urlSelectedTypes;
const handleMultiValueChange = (values: string[]) => {
if (onBatchChange) {
onBatchChange("provider_type__in", values);
return;
}
navigateWithParams((params) => {
// Update provider_type__in
if (values.length > 0) {
@@ -175,10 +213,6 @@ export const ProviderTypeSelector = ({
} else {
params.delete("filter[provider_type__in]");
}
// Clear account selection when changing provider types
// User should manually select accounts if they want to filter by specific accounts
params.delete("filter[provider_id__in]");
});
};
@@ -1,6 +1,8 @@
export { ExecuteButton } from "./execute-button";
export * from "./graph";
export * from "./node-detail";
export { QueryDescription } from "./query-description";
export { QueryExecutionError } from "./query-execution-error";
export { QueryParametersForm } from "./query-parameters-form";
export { QuerySelector } from "./query-selector";
export { ScanListTable } from "./scan-list-table";
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { AttackPathQuery } from "@/types/attack-paths";
import { QueryDescription } from "./query-description";
const customQuery: AttackPathQuery = {
type: "attack-paths-scans",
id: "custom-query",
attributes: {
name: "Custom openCypher query",
short_description: "Write your own query",
description:
"Run a read-only openCypher query against the selected Attack Paths scan.",
provider: "aws",
attribution: null,
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: "https://example.com/schema",
},
parameters: [],
},
};
describe("QueryDescription", () => {
it("renders the schema documentation link inside an info alert", () => {
// Given
render(<QueryDescription query={customQuery} />);
// When
const alert = screen.getByRole("alert");
const link = screen.getByRole("link", {
name: /cartography schema used by prowler for aws graphs/i,
});
// Then
expect(alert).toBeInTheDocument();
expect(link).toHaveAttribute("href", "https://example.com/schema");
});
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
// Given
const queryWithUnsafeLinks: AttackPathQuery = {
...customQuery,
attributes: {
...customQuery.attributes,
documentation_link: {
text: "Cartography schema used by Prowler for AWS graphs",
link: "javascript:alert('xss')",
},
attribution: {
text: "Unsafe source",
link: "javascript:alert('xss')",
},
},
};
// When
render(<QueryDescription query={queryWithUnsafeLinks} />);
// Then
expect(
screen.queryByRole("link", {
name: /cartography schema used by prowler for aws graphs/i,
}),
).not.toBeInTheDocument();
expect(
screen.queryByRole("link", { name: /unsafe source/i }),
).not.toBeInTheDocument();
expect(
screen.getByText(/cartography schema used by prowler for aws graphs/i),
).toBeInTheDocument();
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { Info } from "lucide-react";
import { Alert, AlertDescription } from "@/components/shadcn";
import type { AttackPathQuery } from "@/types/attack-paths";
interface QueryDescriptionProps {
query: AttackPathQuery;
}
const isSafeUrl = (url: string): boolean => {
try {
const parsedUrl = new URL(url);
return parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:";
} catch {
return false;
}
};
export const QueryDescription = ({ query }: QueryDescriptionProps) => {
const documentationLink = query.attributes.documentation_link;
const attribution = query.attributes.attribution;
return (
<Alert variant="info">
<Info className="text-bg-data-info mt-0.5 size-4 shrink-0" />
<AlertDescription className="w-full gap-2">
<p className="whitespace-pre-line">{query.attributes.description}</p>
{documentationLink && (
<p className="text-xs">
{isSafeUrl(documentationLink.link) ? (
<a
href={documentationLink.link}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline"
>
{documentationLink.text}
</a>
) : (
<span className="font-medium">{documentationLink.text}</span>
)}
</p>
)}
{attribution && (
<p className="text-xs">
{isSafeUrl(attribution.link) ? (
<>
Source:{" "}
<a
href={attribution.link}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{attribution.text}
</a>
</>
) : (
<>
Source: <span>{attribution.text}</span>
</>
)}
</p>
)}
</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,46 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { QueryExecutionError } from "./query-execution-error";
describe("QueryExecutionError", () => {
it("renders the default title and the raw query error details without extra copy", () => {
// Given
const error =
"Invalid input 'WHERE': expected 'MATCH' or 'WITH' (line 1, column 1)";
// When
render(<QueryExecutionError error={error} />);
// Then
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/query execution failed/i)).toBeInTheDocument();
expect(
screen.queryByText(/the attack paths query could not be executed/i),
).not.toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
it("renders custom title and description when provided", () => {
// Given
const error = "Failed to load available queries";
// When
render(
<QueryExecutionError
title="Failed to load queries"
description="Available Attack Paths queries could not be loaded for this scan."
error={error}
/>,
);
// Then
expect(screen.getByText(/failed to load queries/i)).toBeInTheDocument();
expect(
screen.getByText(
/available attack paths queries could not be loaded for this scan/i,
),
).toBeInTheDocument();
expect(screen.getByText(error)).toBeInTheDocument();
});
});
@@ -0,0 +1,30 @@
import { CircleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn";
interface QueryExecutionErrorProps {
error: string;
title?: string;
description?: string;
}
export const QueryExecutionError = ({
error,
title = "Query execution failed",
description,
}: QueryExecutionErrorProps) => {
return (
<Alert variant="error">
<CircleAlert className="size-4" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="w-full gap-3">
{description ? <p>{description}</p> : null}
<div className="bg-bg-neutral-primary/70 border-border-neutral-secondary w-full rounded-md border px-3 py-2">
<pre className="text-text-error-primary font-mono text-xs break-words whitespace-pre-wrap">
{error}
</pre>
</div>
</AlertDescription>
</Alert>
);
};
@@ -1,8 +1,12 @@
import { render, screen } from "@testing-library/react";
import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import type { AttackPathQuery } from "@/types/attack-paths";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
} from "@/types/attack-paths";
import { QueryParametersForm } from "./query-parameters-form";
@@ -42,6 +46,64 @@ function TestForm() {
);
}
function TestCustomQueryForm() {
const customQuery: AttackPathQuery = {
type: "attack-paths-scans",
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write your own query",
description: "Run a custom query against the graph.",
provider: "aws",
attribution: null,
parameters: [
{
name: "query",
label: "openCypher",
data_type: "string",
input_type: "textarea",
placeholder: "MATCH (n) RETURN n LIMIT 25",
description: "",
required: true,
},
],
},
};
const form = useForm({
defaultValues: {
query: "",
},
});
return (
<FormProvider {...form}>
<QueryParametersForm selectedQuery={customQuery} />
</FormProvider>
);
}
function TestFormWithError() {
const form = useForm({
defaultValues: {
tag_key: "",
},
});
useEffect(() => {
form.setError("tag_key", {
type: "manual",
message: "Tag key is required",
});
}, [form]);
return (
<FormProvider {...form}>
<QueryParametersForm selectedQuery={mockQuery} />
</FormProvider>
);
}
describe("QueryParametersForm", () => {
it("uses the field description as the placeholder instead of rendering helper text below", () => {
// Given
@@ -70,4 +132,62 @@ describe("QueryParametersForm", () => {
screen.queryByText("Tag key to filter the S3 bucket."),
).not.toBeInTheDocument();
});
it("renders a textarea when the parameter input type is textarea", () => {
// Given
render(<TestCustomQueryForm />);
// When
const input = screen.getByRole("textbox", { name: /opencypher/i });
const codeEditor = screen.getByTestId("query-code-editor");
// Then
expect(input.tagName).toBe("TEXTAREA");
expect(input).toHaveAttribute("data-slot", "textarea");
expect(input).toHaveAttribute("placeholder", "MATCH (n) RETURN n LIMIT 25");
expect(input).toHaveAttribute("spellcheck", "false");
expect(input).toHaveAttribute("autocomplete", "off");
expect(input).toHaveAttribute("autocorrect", "off");
expect(input).toHaveAttribute("autocapitalize", "none");
expect(input).toHaveClass(
"minimal-scrollbar",
"min-h-[320px]",
"font-mono",
"leading-6",
);
expect(codeEditor).toHaveClass(
"rounded-xl",
"border",
"bg-bg-neutral-primary",
);
expect(screen.getByText("Read-only")).toBeInTheDocument();
});
it("uses the design-system error token for field validation messages", async () => {
// Given
render(<TestFormWithError />);
// When
const errorMessage = await screen.findByText("Tag key is required");
// Then
expect(errorMessage).toHaveClass("text-text-error-primary", "text-xs");
});
it("connects field errors to the input for accessibility", async () => {
// Given
render(<TestFormWithError />);
// When
const input = screen.getByRole("textbox", { name: /tag key/i });
const errorMessage = await screen.findByText("Tag key is required");
// Then
expect(input).toHaveAttribute("aria-invalid", "true");
expect(errorMessage).toHaveAttribute("id");
expect(input).toHaveAttribute(
"aria-describedby",
expect.stringContaining(errorMessage.getAttribute("id") ?? ""),
);
});
});
@@ -1,9 +1,21 @@
"use client";
import { Controller, useFormContext } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { Input } from "@/components/shadcn";
import type { AttackPathQuery } from "@/types/attack-paths";
import { Input, Textarea } from "@/components/shadcn";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { cn } from "@/lib/utils";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
QUERY_PARAMETER_INPUT_TYPES,
} from "@/types/attack-paths";
interface QueryParametersFormProps {
selectedQuery: AttackPathQuery | null | undefined;
@@ -16,10 +28,7 @@ interface QueryParametersFormProps {
export const QueryParametersForm = ({
selectedQuery,
}: QueryParametersFormProps) => {
const {
control,
formState: { errors },
} = useFormContext();
const { control } = useFormContext();
if (!selectedQuery || !selectedQuery.attributes.parameters.length) {
return null;
@@ -36,23 +45,26 @@ export const QueryParametersForm = ({
className="grid grid-cols-1 gap-4 md:grid-cols-2"
>
{selectedQuery.attributes.parameters.map((param) => (
<Controller
<FormField
key={param.name}
name={param.name}
control={control}
render={({ field }) => {
render={({ field, fieldState }) => {
if (param.data_type === "boolean") {
return (
<div className="flex flex-col gap-2">
<FormItem className="flex flex-col gap-2">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
id={param.name}
checked={field.value === true || field.value === "true"}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
/>
<FormControl>
<input
type="checkbox"
checked={
field.value === true || field.value === "true"
}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
/>
</FormControl>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{param.label}
@@ -64,44 +76,98 @@ export const QueryParametersForm = ({
)}
</div>
</label>
</div>
<FormMessage className="text-xs" />
</FormItem>
);
}
const errorMessage = (() => {
const error = errors[param.name];
if (error && typeof error.message === "string") {
return error.message;
}
return undefined;
})();
const placeholder =
param.description ||
param.placeholder ||
`Enter ${param.label.toLowerCase()}`;
const isTextarea =
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
const isCustomCodeEditor =
selectedQuery.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
param.name === "query" &&
isTextarea;
return (
<div className="flex flex-col gap-1.5">
<label
htmlFor={param.name}
className="text-text-neutral-tertiary text-xs font-medium"
>
{param.label}
{param.required && (
<span className="text-text-error-primary">*</span>
)}
</label>
<Input
{...field}
id={param.name}
type={param.data_type === "number" ? "number" : "text"}
placeholder={
param.description ||
param.placeholder ||
`Enter ${param.label.toLowerCase()}`
}
value={field.value ?? ""}
/>
{errorMessage && (
<span className="text-xs text-red-500">{errorMessage}</span>
<FormItem
className={cn(
"flex flex-col gap-1.5",
isTextarea && "md:col-span-2",
)}
</div>
>
{!isCustomCodeEditor && (
<FormLabel className="text-text-neutral-tertiary text-xs font-medium">
{param.label}
{param.required && (
<span className="text-text-error-primary">*</span>
)}
</FormLabel>
)}
{isCustomCodeEditor ? (
<div
data-testid="query-code-editor"
className={cn(
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
fieldState.invalid && "border-border-error-primary",
)}
>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
<span className="text-text-neutral-secondary text-xs font-medium">
{param.label}
{param.required && (
<span className="text-text-error-primary">*</span>
)}
</span>
<span className="text-text-neutral-tertiary text-[11px]">
Read-only
</span>
</div>
<FormControl>
<Textarea
{...field}
aria-label={param.label}
variant="ghost"
textareaSize="lg"
placeholder={placeholder}
value={field.value ?? ""}
rows={14}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
className="minimal-scrollbar min-h-[320px] rounded-none border-0 bg-transparent font-mono text-xs leading-6 hover:bg-transparent focus:bg-transparent focus:ring-0"
/>
</FormControl>
</div>
) : (
<FormControl>
{isTextarea ? (
<Textarea
{...field}
textareaSize="lg"
placeholder={placeholder}
value={field.value ?? ""}
className="min-h-40 font-mono text-xs"
/>
) : (
<Input
{...field}
type={
param.data_type === "number" ? "number" : "text"
}
placeholder={placeholder}
value={field.value ?? ""}
/>
)}
</FormControl>
)}
<FormMessage className="text-xs" />
</FormItem>
);
}}
/>
@@ -1,7 +1,12 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
} from "@/lib/attack-paths/custom-query";
import type { AttackPathQuery } from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
import { useQueryBuilder } from "./use-query-builder";
@@ -38,6 +43,27 @@ const mockQueries: AttackPathQuery[] = [
parameters: [],
},
},
{
type: "attack-paths-scans",
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
attributes: {
name: "Custom openCypher query",
short_description: "Write your own query",
description: "Run a custom query against the graph.",
provider: "aws",
attribution: null,
parameters: [
{
name: "query",
label: "openCypher",
data_type: "string",
description: "",
required: true,
input_type: "textarea",
},
],
},
},
];
describe("useQueryBuilder", () => {
@@ -77,4 +103,101 @@ describe("useQueryBuilder", () => {
);
expect(result.current.getQueryParameters()).toBeUndefined();
});
it("rejects whitespace-only custom queries before execution", async () => {
// Given
const { result } = renderHook(() => useQueryBuilder(mockQueries));
act(() => {
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
});
await waitFor(() => {
expect(result.current.selectedQueryData?.id).toBe(
ATTACK_PATH_QUERY_IDS.CUSTOM,
);
});
act(() => {
result.current.form.setValue("query", " ");
});
// When
let isValid = true;
await act(async () => {
isValid = await result.current.form.trigger("query");
});
// Then
expect(isValid).toBe(false);
expect(result.current.form.getFieldState("query").error?.message).toBe(
"Custom query cannot be empty",
);
});
it("rejects custom queries longer than the supported limit", async () => {
// Given
const { result } = renderHook(() => useQueryBuilder(mockQueries));
act(() => {
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
});
await waitFor(() => {
expect(result.current.selectedQueryData?.id).toBe(
ATTACK_PATH_QUERY_IDS.CUSTOM,
);
});
act(() => {
result.current.form.setValue(
"query",
"x".repeat(CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH + 1),
);
});
// When
let isValid = true;
await act(async () => {
isValid = await result.current.form.trigger("query");
});
// Then
expect(isValid).toBe(false);
expect(result.current.form.getFieldState("query").error?.message).toBe(
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
);
});
it("rejects custom queries containing write operations", async () => {
// Given
const { result } = renderHook(() => useQueryBuilder(mockQueries));
act(() => {
result.current.handleQueryChange(ATTACK_PATH_QUERY_IDS.CUSTOM);
});
await waitFor(() => {
expect(result.current.selectedQueryData?.id).toBe(
ATTACK_PATH_QUERY_IDS.CUSTOM,
);
});
act(() => {
result.current.form.setValue("query", "CREATE (n:Test) RETURN n");
});
// When
let isValid = true;
await act(async () => {
isValid = await result.current.form.trigger("query");
});
// Then
expect(isValid).toBe(false);
expect(result.current.form.getFieldState("query").error?.message).toBe(
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
);
expect(result.current.isExecutionBlocked).toBe(true);
});
});
@@ -5,15 +5,28 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { AttackPathQuery } from "@/types/attack-paths";
import {
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
customAttackPathQuerySchema,
} from "@/lib/attack-paths/custom-query";
import {
ATTACK_PATH_QUERY_IDS,
type AttackPathQuery,
QUERY_PARAMETER_INPUT_TYPES,
} from "@/types/attack-paths";
const getValidationSchema = (query?: AttackPathQuery) => {
const schemaObject: Record<string, z.ZodTypeAny> = {};
query?.attributes.parameters.forEach((param) => {
let fieldSchema: z.ZodTypeAny = z
.string()
.min(1, `${param.label} is required`);
const isCustomQueryParameter =
query.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
param.name === "query" &&
param.input_type === QUERY_PARAMETER_INPUT_TYPES.TEXTAREA;
let fieldSchema: z.ZodTypeAny = isCustomQueryParameter
? customAttackPathQuerySchema
: z.string().min(1, `${param.label} is required`);
if (param.data_type === "number") {
fieldSchema = z.coerce.number().refine((val) => val >= 0, {
@@ -93,6 +106,11 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
return form.formState.isValid;
};
const isExecutionBlocked =
selectedQueryData?.id === ATTACK_PATH_QUERY_IDS.CUSTOM &&
form.formState.errors.query?.message ===
CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE;
return {
selectedQuery,
selectedQueryData,
@@ -101,5 +119,6 @@ export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
handleQueryChange,
getQueryParameters,
isFormValid,
isExecutionBlocked,
};
};
@@ -7,9 +7,12 @@ import { Suspense, useEffect, useRef, useState } from "react";
import { FormProvider } from "react-hook-form";
import {
buildAttackPathQueries,
executeCustomQuery,
executeQuery,
getAttackPathScans,
getAvailableQueries,
getCartographySchema,
} from "@/actions/attack-paths";
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
import { AutoRefresh } from "@/components/scans";
@@ -35,6 +38,7 @@ import type {
AttackPathScan,
GraphNode,
} from "@/types/attack-paths";
import { ATTACK_PATH_QUERY_IDS } from "@/types/attack-paths";
import {
AttackPathGraph,
@@ -43,6 +47,8 @@ import {
GraphLegend,
GraphLoading,
NodeDetailContent,
QueryDescription,
QueryExecutionError,
QueryParametersForm,
QuerySelector,
ScanListTable,
@@ -138,11 +144,21 @@ export default function AttackPathsPage() {
setQueriesLoading(true);
try {
const queriesData = await getAvailableQueries(scanId);
if (queriesData?.data) {
setQueries(queriesData.data);
const [queriesData, schemaData] = await Promise.all([
getAvailableQueries(scanId),
getCartographySchema(scanId),
]);
const availableQueries = buildAttackPathQueries(
queriesData?.data ?? [],
schemaData?.data.attributes,
);
if (availableQueries.length > 0) {
setQueries(availableQueries);
setQueriesError(null);
} else {
setQueries([]);
setQueriesError("Failed to load available queries");
toast({
title: "Error",
@@ -199,15 +215,12 @@ export default function AttackPathsPage() {
graphState.setError(null);
try {
const parameters = queryBuilder.getQueryParameters() as Record<
string,
string | number | boolean
>;
const result = await executeQuery(
scanId,
queryBuilder.selectedQuery,
parameters,
);
const parameters = queryBuilder.getQueryParameters();
const isCustomQuery =
queryBuilder.selectedQuery === ATTACK_PATH_QUERY_IDS.CUSTOM;
const result = isCustomQuery
? await executeCustomQuery(scanId, String(parameters?.query ?? ""))
: await executeQuery(scanId, queryBuilder.selectedQuery, parameters);
if (result && "error" in result) {
const apiError = result as AttackPathQueryError;
@@ -371,9 +384,10 @@ export default function AttackPathsPage() {
{queriesLoading ? (
<p className="text-sm">Loading queries...</p>
) : queriesError ? (
<p className="text-text-danger dark:text-text-danger text-sm">
{queriesError}
</p>
<QueryExecutionError
title="Failed to load queries"
error={queriesError}
/>
) : (
<>
<FormProvider {...queryBuilder.form}>
@@ -384,40 +398,9 @@ export default function AttackPathsPage() {
/>
{queryBuilder.selectedQueryData && (
<div className="bg-bg-neutral-tertiary text-text-neutral-secondary dark:text-text-neutral-secondary rounded-md px-3 py-2 text-sm">
<div className="flex items-start gap-2">
<Info
className="mt-0.5 size-4 shrink-0"
style={{ color: "var(--bg-data-info)" }}
/>
<p className="whitespace-pre-line">
{
queryBuilder.selectedQueryData.attributes
.description
}
</p>
</div>
{queryBuilder.selectedQueryData.attributes
.attribution && (
<p className="mt-2 text-xs">
Source:{" "}
<a
href={
queryBuilder.selectedQueryData.attributes
.attribution.link
}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{
queryBuilder.selectedQueryData.attributes
.attribution.text
}
</a>
</p>
)}
</div>
<QueryDescription
query={queryBuilder.selectedQueryData}
/>
)}
{queryBuilder.selectedQuery && (
@@ -430,15 +413,16 @@ export default function AttackPathsPage() {
<div className="flex justify-end gap-3">
<ExecuteButton
isLoading={graphState.loading}
isDisabled={!queryBuilder.selectedQuery}
isDisabled={
!queryBuilder.selectedQuery ||
queryBuilder.isExecutionBlocked
}
onExecute={handleExecuteQuery}
/>
</div>
{graphState.error && (
<div className="bg-bg-danger-secondary text-text-danger dark:bg-bg-danger-secondary dark:text-text-danger rounded p-3 text-sm">
{graphState.error}
</div>
<QueryExecutionError error={graphState.error} />
)}
</>
)}
-13
View File
@@ -24,10 +24,6 @@ import {
extractSortAndKey,
hasDateOrScanFilter,
} from "@/lib";
import {
createProviderDetailsMappingById,
extractProviderIds,
} from "@/lib/provider-helpers";
import { ScanEntity, ScanProps } from "@/types";
import { FindingProps, SearchParamsProps } from "@/types/components";
@@ -124,12 +120,6 @@ export default async function Findings({
const uniqueCategories = metadataInfoData?.data?.attributes?.categories || [];
const uniqueGroups = metadataInfoData?.data?.attributes?.groups || [];
// Extract provider IDs and details using helper functions
const providerIds = providersData ? extractProviderIds(providersData) : [];
const providerDetails = providersData
? createProviderDetailsMappingById(providerIds, providersData)
: [];
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data?.filter(
(scan: ScanProps) =>
@@ -151,9 +141,6 @@ export default async function Findings({
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerIds={providerIds}
providerDetails={providerDetails}
completedScans={completedScans || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
@@ -0,0 +1,265 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
// Mock lucide-react to avoid SVG rendering issues in jsdom
vi.mock("lucide-react", () => ({
Check: () => <svg data-testid="check-icon" />,
X: () => <svg data-testid="x-icon" />,
}));
// Mock @/components/shadcn to avoid next-auth import chain
vi.mock("@/components/shadcn", () => ({
Button: ({
children,
disabled,
onClick,
"aria-label": ariaLabel,
variant,
size,
}: {
children?: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
"aria-label"?: string;
variant?: string;
size?: string;
}) => (
<button
disabled={disabled}
onClick={onClick}
aria-label={ariaLabel}
data-variant={variant}
data-size={size}
>
{children}
</button>
),
}));
vi.mock("@/lib/utils", () => ({
cn: (...classes: (string | undefined | false)[]) =>
classes.filter(Boolean).join(" "),
}));
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
// ── Future E2E coverage ────────────────────────────────────────────────────
// TODO (E2E): Full apply-filters button flow should be covered in Playwright tests:
// - Button appears disabled when no filters have been staged
// - Button shows correct count after staging multiple filters
// - Clicking Apply pushes all pending filters to the URL in one navigation event
// - Clicking Discard resets pending state to current URL state (staged filters disappear)
// ──────────────────────────────────────────────────────────────────────────
describe("ApplyFiltersButton", () => {
// ── No changes ───────────────────────────────────────────────────────────
describe("when hasChanges is false", () => {
it("should render the Apply Filters button as disabled", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters",
});
expect(applyButton).toBeDisabled();
});
it("should NOT render the discard (X) button when there are no changes", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.queryByRole("button", {
name: /discard/i,
}),
).not.toBeInTheDocument();
});
it("should show 'Apply Filters' label without count", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
).toBeInTheDocument();
});
});
// ── Has changes ──────────────────────────────────────────────────────────
describe("when hasChanges is true", () => {
it("should render the Apply Filters button as enabled", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={2}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
const applyButton = screen.getByRole("button", {
name: "Apply Filters (2)",
});
expect(applyButton).not.toBeDisabled();
});
it("should show the change count in the button label", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={3}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Apply Filters (3)" }),
).toBeInTheDocument();
});
it("should show 'Apply Filters' (without count) when changeCount is 0 but hasChanges is true", () => {
// Given — hasChanges=true but changeCount=0 (edge case)
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={0}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Apply Filters" }),
).toBeInTheDocument();
});
it("should render the discard (X) button", () => {
// Given / When
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={1}
onApply={vi.fn()}
onDiscard={vi.fn()}
/>,
);
// Then
expect(
screen.getByRole("button", { name: /discard pending filter changes/i }),
).toBeInTheDocument();
});
});
// ── onApply interaction ──────────────────────────────────────────────────
describe("onApply", () => {
it("should call onApply when the Apply Filters button is clicked", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
const onDiscard = vi.fn();
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={1}
onApply={onApply}
onDiscard={onDiscard}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: "Apply Filters (1)" }),
);
// Then
expect(onApply).toHaveBeenCalledTimes(1);
expect(onDiscard).not.toHaveBeenCalled();
});
it("should NOT call onApply when the button is disabled", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
render(
<ApplyFiltersButton
hasChanges={false}
changeCount={0}
onApply={onApply}
onDiscard={vi.fn()}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Apply Filters" }));
// Then — disabled button should not fire
expect(onApply).not.toHaveBeenCalled();
});
});
// ── onDiscard interaction ────────────────────────────────────────────────
describe("onDiscard", () => {
it("should call onDiscard when the Discard button is clicked", async () => {
// Given
const user = userEvent.setup();
const onApply = vi.fn();
const onDiscard = vi.fn();
render(
<ApplyFiltersButton
hasChanges={true}
changeCount={2}
onApply={onApply}
onDiscard={onDiscard}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /discard pending filter changes/i }),
);
// Then
expect(onDiscard).toHaveBeenCalledTimes(1);
expect(onApply).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,64 @@
"use client";
import { Check, X } from "lucide-react";
import { Button } from "@/components/shadcn";
import { cn } from "@/lib/utils";
export interface ApplyFiltersButtonProps {
/** Whether there are pending changes that differ from the applied (URL) state */
hasChanges: boolean;
/** Number of filter keys that have pending changes */
changeCount: number;
/** Called when the user clicks "Apply Filters" */
onApply: () => void;
/** Called when the user clicks the discard (X) action */
onDiscard: () => void;
/** Optional extra class names for the outer wrapper */
className?: string;
}
/**
* Displays an "Apply Filters" button with an optional discard action.
*
* - Shows the count of pending changes when `hasChanges` is true.
* - The apply button is disabled (and visually muted) when there are no changes.
* - The discard (X) button only appears when there are pending changes.
* - Uses Prowler's shadcn `Button` component.
*/
export const ApplyFiltersButton = ({
hasChanges,
changeCount,
onApply,
onDiscard,
className,
}: ApplyFiltersButtonProps) => {
const label =
changeCount > 0 ? `Apply Filters (${changeCount})` : "Apply Filters";
return (
<div className={cn("flex items-center gap-1", className)}>
<Button
variant="default"
size="sm"
disabled={!hasChanges}
onClick={onApply}
aria-label={label}
>
<Check className="size-4" />
{label}
</Button>
{hasChanges && (
<Button
variant="ghost"
size="icon-sm"
onClick={onDiscard}
aria-label="Discard pending filter changes"
>
<X className="size-4" />
</Button>
)}
</div>
);
};
+29 -4
View File
@@ -17,6 +17,19 @@ export interface ClearFiltersButtonProps {
showCount?: boolean;
/** Use link style (text only, no button background) */
variant?: "link" | "default";
/**
* Optional callback for batch mode. When provided, this is called INSTEAD
* of pushing URL params directly. Useful for clearing pending filter state
* without immediately navigating.
*/
onClear?: () => void;
/**
* In batch mode, the number of pending filter keys that have non-empty values.
* When provided alongside `onClear`, overrides the URL-based count shown by
* `showCount`. This ensures the displayed count reflects the pending state
* (not the last-applied URL state) while the user is editing filters.
*/
pendingCount?: number;
}
export const ClearFiltersButton = ({
@@ -24,6 +37,8 @@ export const ClearFiltersButton = ({
ariaLabel = "Reset",
showCount = false,
variant = "link",
onClear,
pendingCount,
}: ClearFiltersButtonProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -51,17 +66,27 @@ export const ClearFiltersButton = ({
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [router, searchParams, pathname]);
// Only show button if there are filters other than the excluded ones
if (filterCount === 0) {
// In batch mode: use pendingCount if provided; otherwise fall back to URL count.
// In instant mode: always use URL count.
const displayCount =
onClear && pendingCount !== undefined ? pendingCount : filterCount;
// In instant mode: hide when no URL filters exist
if (!onClear && filterCount === 0) {
return null;
}
const displayText = showCount ? `Clear Filters (${filterCount})` : text;
// In batch mode: hide when there are no pending or URL filters to clear
if (onClear && displayCount === 0) {
return null;
}
const displayText = showCount ? `Clear Filters (${displayCount})` : text;
return (
<Button
aria-label={ariaLabel}
onClick={clearFiltersPreservingExcluded}
onClick={onClear ?? clearFiltersPreservingExcluded}
variant={variant}
>
<XCircle className="mr-0.5 size-4" />
@@ -11,7 +11,35 @@ const MUTED_FILTER_VALUES = {
INCLUDE: "include",
} as const;
export const CustomCheckboxMutedFindings = () => {
/** Batch mode: caller controls both the checked state and the notification callback (all-or-nothing). */
interface CustomCheckboxMutedFindingsBatchProps {
/**
* Called instead of navigating directly.
* Receives the filter key ("muted") and the string value ("include" or "false").
*/
onBatchChange: (filterKey: string, value: string) => void;
/**
* Controlled checked state from the parent (pending state).
* `true` = include muted, `false` = exclude muted.
* `undefined` defers to URL state while pending state is not yet set.
*/
checked: boolean | undefined;
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface CustomCheckboxMutedFindingsInstantProps {
onBatchChange?: never;
checked?: never;
}
type CustomCheckboxMutedFindingsProps =
| CustomCheckboxMutedFindingsBatchProps
| CustomCheckboxMutedFindingsInstantProps;
export const CustomCheckboxMutedFindings = ({
onBatchChange,
checked: checkedProp,
}: CustomCheckboxMutedFindingsProps = {}) => {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
@@ -21,11 +49,25 @@ export const CustomCheckboxMutedFindings = () => {
// URL states:
// - filter[muted]=false → Exclude muted (checkbox UNCHECKED)
// - filter[muted]=include → Include muted (checkbox CHECKED)
const includeMuted = mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
// When a controlled `checked` prop is provided (batch mode), use it; otherwise fall back to URL.
const includeMuted =
checkedProp !== undefined
? checkedProp
: mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
const handleMutedChange = (checked: boolean | "indeterminate") => {
const isChecked = checked === true;
if (onBatchChange) {
// Batch mode: notify caller instead of navigating
onBatchChange(
"muted",
isChecked ? MUTED_FILTER_VALUES.INCLUDE : MUTED_FILTER_VALUES.EXCLUDE,
);
return;
}
// Instant mode (default): navigate immediately
navigateWithParams((params) => {
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)
+56 -26
View File
@@ -3,7 +3,7 @@
import { format } from "date-fns";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Calendar } from "@/components/shadcn/calendar";
import {
@@ -14,22 +14,67 @@ import {
import { useUrlFilters } from "@/hooks/use-url-filters";
import { cn } from "@/lib/utils";
export const CustomDatePicker = () => {
/** Batch mode: caller controls both the pending date value and the notification callback (all-or-nothing). */
interface CustomDatePickerBatchProps {
/**
* Called instead of updating the URL directly.
* Receives the filter key ("inserted_at") and the formatted date string (YYYY-MM-DD).
*/
onBatchChange: (filterKey: string, value: string) => void;
/**
* Controlled date value from the parent (pending state).
* Expected format: YYYY-MM-DD (or any value parseable by `new Date()`).
*/
value: string | undefined;
}
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface CustomDatePickerInstantProps {
onBatchChange?: never;
value?: never;
}
type CustomDatePickerProps =
| CustomDatePickerBatchProps
| CustomDatePickerInstantProps;
const parseDate = (raw: string | null | undefined): Date | undefined => {
if (!raw) return undefined;
try {
// Use T00:00:00 suffix to avoid timezone offset shifting the date
return new Date(raw + "T00:00:00");
} catch {
return undefined;
}
};
export const CustomDatePicker = ({
onBatchChange,
value: valueProp,
}: CustomDatePickerProps = {}) => {
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const [open, setOpen] = useState(false);
const [date, setDate] = useState<Date | undefined>(() => {
const dateParam = searchParams.get("filter[inserted_at]");
if (!dateParam) return undefined;
try {
return new Date(dateParam);
} catch {
return undefined;
}
});
// Derive the displayed date directly from the controlled source of truth:
// - Batch mode: `valueProp` from parent (pending state)
// - Instant mode: `searchParams` from URL (re-renders automatically on URL change)
const date =
valueProp !== undefined
? parseDate(valueProp)
: parseDate(searchParams.get("filter[inserted_at]"));
const applyDateFilter = (selectedDate: Date | undefined) => {
if (onBatchChange) {
// Batch mode: notify caller instead of updating URL
onBatchChange(
"inserted_at",
selectedDate ? format(selectedDate, "yyyy-MM-dd") : "",
);
return;
}
// Instant mode (default): push to URL immediately
if (selectedDate) {
// Format as YYYY-MM-DD for the API
updateFilter("inserted_at", format(selectedDate, "yyyy-MM-dd"));
@@ -38,22 +83,7 @@ export const CustomDatePicker = () => {
}
};
// Sync local state with URL params (e.g., when Clear Filters is clicked)
useEffect(() => {
const dateParam = searchParams.get("filter[inserted_at]");
if (!dateParam) {
setDate(undefined);
} else {
try {
setDate(new Date(dateParam));
} catch {
setDate(undefined);
}
}
}, [searchParams]);
const handleDateSelect = (newDate: Date | undefined) => {
setDate(newDate);
applyDateFilter(newDate);
setOpen(false);
};
@@ -0,0 +1,272 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
// Mock lucide-react to avoid SVG rendering issues in jsdom
vi.mock("lucide-react", () => ({
X: () => <svg data-testid="x-icon" />,
}));
// Mock @/components/shadcn to avoid next-auth import chain
vi.mock("@/components/shadcn", () => ({
Badge: ({
children,
className,
variant,
}: {
children: React.ReactNode;
className?: string;
variant?: string;
}) => (
<span data-testid="badge" data-variant={variant} className={className}>
{children}
</span>
),
}));
vi.mock("@/lib/utils", () => ({
cn: (...classes: (string | undefined | false)[]) =>
classes.filter(Boolean).join(" "),
}));
import {
FilterChip,
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
// ── Future E2E coverage ────────────────────────────────────────────────────
// TODO (E2E): Full filter strip flow should be covered in Playwright tests:
// - Filter chips appear after staging selections in the findings page
// - Removing a chip via the X button un-stages that filter value
// - "Clear all" removes all staged filter chips at once
// - Chips disappear after applying filters (pending state resets to URL state)
// ──────────────────────────────────────────────────────────────────────────
const mockChips: FilterChip[] = [
{ key: "filter[severity__in]", label: "Severity", value: "critical" },
{ key: "filter[severity__in]", label: "Severity", value: "high" },
{ key: "filter[status__in]", label: "Status", value: "FAIL" },
];
describe("FilterSummaryStrip", () => {
// ── Empty state ──────────────────────────────────────────────────────────
describe("when chips array is empty", () => {
it("should not render anything", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
const { container } = render(
<FilterSummaryStrip
chips={[]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(container.firstChild).toBeNull();
});
});
// ── Chip rendering ───────────────────────────────────────────────────────
describe("when chips are provided", () => {
it("should render a chip for each filter value", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then — 3 chips should be visible (2 severity + 1 status)
expect(screen.getAllByTestId("badge")).toHaveLength(3);
});
it("should display the label and value text for each chip", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={[
{
key: "filter[severity__in]",
label: "Severity",
value: "critical",
},
]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(screen.getByText("Severity:")).toBeInTheDocument();
expect(screen.getByText("critical")).toBeInTheDocument();
});
it("should display displayValue when provided instead of value", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={[
{
key: "filter[status__in]",
label: "Status",
value: "FAIL",
displayValue: "Failed",
},
]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then — displayValue takes precedence
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.queryByText("FAIL")).not.toBeInTheDocument();
});
it("should render a 'Clear all' button", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(
screen.getByRole("button", { name: "Clear all" }),
).toBeInTheDocument();
});
it("should render an aria-label region for accessibility", () => {
// Given
const onRemove = vi.fn();
const onClearAll = vi.fn();
// When
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// Then
expect(
screen.getByRole("region", { name: "Active filters" }),
).toBeInTheDocument();
});
});
// ── onRemove interaction ─────────────────────────────────────────────────
describe("onRemove", () => {
it("should call onRemove with correct filterKey and value when X is clicked", async () => {
// Given
const user = userEvent.setup();
const onRemove = vi.fn();
const onClearAll = vi.fn();
render(
<FilterSummaryStrip
chips={[
{
key: "filter[severity__in]",
label: "Severity",
value: "critical",
},
]}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// When
const removeButton = screen.getByRole("button", {
name: /Remove Severity filter: critical/i,
});
await user.click(removeButton);
// Then
expect(onRemove).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenCalledWith("filter[severity__in]", "critical");
});
it("should call onRemove with the correct chip when there are multiple chips", async () => {
// Given
const user = userEvent.setup();
const onRemove = vi.fn();
const onClearAll = vi.fn();
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// When — click the X button for "high" severity
const removeHighButton = screen.getByRole("button", {
name: /Remove Severity filter: high/i,
});
await user.click(removeHighButton);
// Then
expect(onRemove).toHaveBeenCalledWith("filter[severity__in]", "high");
expect(onRemove).toHaveBeenCalledTimes(1);
});
});
// ── onClearAll interaction ───────────────────────────────────────────────
describe("onClearAll", () => {
it("should call onClearAll when 'Clear all' is clicked", async () => {
// Given
const user = userEvent.setup();
const onRemove = vi.fn();
const onClearAll = vi.fn();
render(
<FilterSummaryStrip
chips={mockChips}
onRemove={onRemove}
onClearAll={onClearAll}
/>,
);
// When
await user.click(screen.getByRole("button", { name: "Clear all" }));
// Then
expect(onClearAll).toHaveBeenCalledTimes(1);
expect(onRemove).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,84 @@
"use client";
import { X } from "lucide-react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
export interface FilterChip {
/** The filter parameter key, e.g. "filter[severity__in]" */
key: string;
/** Human-readable label, e.g. "Severity" */
label: string;
/** The individual value within the filter, e.g. "critical" */
value: string;
/** Optional display text for the value (defaults to `value`) */
displayValue?: string;
}
export interface FilterSummaryStripProps {
/** List of individual chips to render */
chips: FilterChip[];
/** Called when the user clicks the X on a chip */
onRemove: (key: string, value: string) => void;
/** Called when the user clicks "Clear all" */
onClearAll: () => void;
/** Optional extra class names for the outer wrapper */
className?: string;
}
/**
* Renders a horizontal strip of removable filter chips summarising
* the current pending filter state.
*
* - Hidden when `chips` is empty.
* - Each chip carries its own X button to remove that single value.
* - A "Clear all" link removes everything at once.
* - Reusable: no Findings-specific logic, driven entirely by props.
*/
export const FilterSummaryStrip = ({
chips,
onRemove,
onClearAll,
className,
}: FilterSummaryStripProps) => {
if (chips.length === 0) return null;
return (
<div
className={cn("flex flex-wrap items-center gap-2 py-2", className)}
role="region"
aria-label="Active filters"
aria-live="polite"
>
{chips.map((chip) => (
<Badge
key={`${chip.key}-${chip.value}`}
variant="outline"
className="flex items-center gap-1 pr-1"
>
<span className="text-text-neutral-primary text-xs">
<span className="font-medium">{chip.label}:</span>{" "}
{chip.displayValue ?? chip.value}
</span>
<button
type="button"
aria-label={`Remove ${chip.label} filter: ${chip.displayValue ?? chip.value}`}
onClick={() => onRemove(chip.key, chip.value)}
className="text-text-neutral-secondary hover:text-text-neutral-primary ml-0.5 rounded-sm transition-colors focus-visible:ring-1 focus-visible:outline-none"
>
<X className="size-3" />
</button>
</Badge>
))}
<button
type="button"
onClick={onClearAll}
className="text-text-neutral-secondary hover:text-text-neutral-primary text-xs underline-offset-2 hover:underline focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-none"
>
Clear all
</button>
</div>
);
};
+2
View File
@@ -1,6 +1,8 @@
export * from "./apply-filters-button";
export * from "./clear-filters-button";
export * from "./custom-checkbox-muted-findings";
export * from "./custom-date-picker";
export * from "./custom-provider-inputs";
export * from "./data-filters";
export * from "./filter-controls";
export * from "./filter-summary-strip";
+188 -22
View File
@@ -5,24 +5,28 @@ import { useState } from "react";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ApplyFiltersButton } from "@/components/filters/apply-filters-button";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { CustomCheckboxMutedFindings } from "@/components/filters/custom-checkbox-muted-findings";
import { CustomDatePicker } from "@/components/filters/custom-date-picker";
import { filterFindings } from "@/components/filters/data-filters";
import {
FilterChip,
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { useRelatedFilters } from "@/hooks";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types";
import { ProviderProps } from "@/types/providers";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, FINDING_STATUS_DISPLAY_NAMES, ScanEntity } from "@/types";
import { DATA_TABLE_FILTER_MODE, FilterParam } from "@/types/filters";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
interface FindingsFiltersProps {
/** Provider data for ProviderTypeSelector and AccountsSelector */
providers: ProviderProps[];
providerIds: string[];
providerDetails: { [id: string]: FilterEntity }[];
completedScans: ScanProps[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
uniqueRegions: string[];
@@ -32,10 +36,73 @@ interface FindingsFiltersProps {
uniqueGroups: string[];
}
/**
* Maps raw filter param keys (e.g. "filter[severity__in]") to human-readable labels.
* Used to render chips in the FilterSummaryStrip.
* Typed as Record<FilterParam, string> so TypeScript enforces exhaustiveness any
* addition to FilterParam will cause a compile error here if the label is missing.
*/
const FILTER_KEY_LABELS: Record<FilterParam, string> = {
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[severity__in]": "Severity",
"filter[status__in]": "Status",
"filter[delta__in]": "Delta",
"filter[region__in]": "Region",
"filter[service__in]": "Service",
"filter[resource_type__in]": "Resource Type",
"filter[category__in]": "Category",
"filter[resource_groups__in]": "Resource Group",
"filter[scan__in]": "Scan ID",
"filter[inserted_at]": "Date",
"filter[muted]": "Muted",
};
/**
* Formats a raw filter value into a human-readable display string.
* - Provider types: uses shared getProviderDisplayName utility
* - Severities: uses shared SEVERITY_DISPLAY_NAMES (e.g. "critical" "Critical")
* - Status: uses shared FINDING_STATUS_DISPLAY_NAMES (e.g. "FAIL" "Fail")
* - Categories: uses getCategoryLabel (handles IAM, EC2, IMDSv1, etc.)
* - Resource groups: uses getGroupLabel (underscore-delimited)
* - Date (filter[inserted_at]): returns the ISO date string as-is (YYYY-MM-DD)
* - Other values: uses formatLabel as a generic fallback (avoids naive capitalisation)
*/
const formatFilterValue = (filterKey: string, value: string): string => {
if (!value) return value;
if (filterKey === "filter[provider_type__in]") {
return getProviderDisplayName(value);
}
if (filterKey === "filter[severity__in]") {
return (
SEVERITY_DISPLAY_NAMES[
value.toLowerCase() as keyof typeof SEVERITY_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[status__in]") {
return (
FINDING_STATUS_DISPLAY_NAMES[
value as keyof typeof FINDING_STATUS_DISPLAY_NAMES
] ?? formatLabel(value)
);
}
if (filterKey === "filter[category__in]") {
return getCategoryLabel(value);
}
if (filterKey === "filter[resource_groups__in]") {
return getGroupLabel(value);
}
// Date filter: preserve ISO date string (YYYY-MM-DD) — do not run through formatLabel
if (filterKey === "filter[inserted_at]") {
return value;
}
// Generic fallback: handles hyphen/underscore-delimited IDs with smart capitalisation
return formatLabel(value);
};
export const FindingsFilters = ({
providers,
providerIds,
providerDetails,
completedScanIds,
scanDetails,
uniqueRegions,
@@ -46,12 +113,17 @@ export const FindingsFilters = ({
}: FindingsFiltersProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const { availableScans } = useRelatedFilters({
providerIds,
providerDetails,
completedScanIds,
scanDetails,
enableScanRelation: true,
const {
pendingFilters,
setPending,
applyAll,
discardAll,
clearAll,
hasChanges,
changeCount,
getFilterValue,
} = useFilterBatch({
defaultParams: { "filter[muted]": "false" },
});
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
@@ -92,7 +164,7 @@ export const FindingsFilters = ({
{
key: FilterType.SCAN,
labelCheckboxGroup: "Scan ID",
values: availableScans,
values: completedScanIds,
valueLabelMapping: scanDetails,
index: 7,
},
@@ -100,17 +172,72 @@ export const FindingsFilters = ({
const hasCustomFilters = customFilters.length > 0;
// Build FilterChip[] from pendingFilters — one chip per individual value, not per key.
// Skip filter[muted]="false" — it is the silent default and should not appear as a chip.
const filterChips: FilterChip[] = [];
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
values.forEach((value) => {
// Do not show a chip for the default muted=false state
if (key === "filter[muted]" && value === "false") return;
filterChips.push({
key,
label,
value,
displayValue: formatFilterValue(key, value),
});
});
});
// Handler for removing a single chip: update the pending filter to remove that value.
// setPending handles both "filter[key]" and "key" formats internally.
const handleChipRemove = (filterKey: string, value: string) => {
const currentValues = pendingFilters[filterKey] ?? [];
const nextValues = currentValues.filter((v) => v !== value);
setPending(filterKey, nextValues);
};
// Derive pending muted state for the checkbox.
// Note: "filter[muted]" participates in batch mode — applyAll includes it
// when present in pending state, and the defaultParams option ensures
// filter[muted]=false is applied as a fallback when no muted value is pending.
const pendingMutedValue = pendingFilters["filter[muted]"];
const mutedChecked =
pendingMutedValue !== undefined
? pendingMutedValue[0] === "include"
: undefined;
// For the date picker, read from pendingFilters
const pendingDateValues = pendingFilters["filter[inserted_at]"];
const pendingDateValue =
pendingDateValues && pendingDateValues.length > 0
? pendingDateValues[0]
: undefined;
return (
<div className="flex flex-col">
{/* First row: Provider selectors + Muted checkbox + More Filters button + Clear Filters */}
{/* First row: Provider selectors + Muted checkbox + More Filters button + Apply/Clear */}
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector providers={providers} />
<ProviderTypeSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_type__in]")}
/>
</div>
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<AccountsSelector providers={providers} />
<AccountsSelector
providers={providers}
onBatchChange={setPending}
selectedValues={getFilterValue("filter[provider_id__in]")}
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
/>
</div>
<CustomCheckboxMutedFindings />
<CustomCheckboxMutedFindings
onBatchChange={(filterKey, value) => setPending(filterKey, [value])}
checked={mutedChecked}
/>
{hasCustomFilters && (
<Button
variant="outline"
@@ -123,16 +250,55 @@ export const FindingsFilters = ({
/>
</Button>
)}
<ClearFiltersButton showCount />
<ClearFiltersButton
showCount
onClear={clearAll}
pendingCount={
Object.entries(pendingFilters).filter(([key, values]) => {
if (!values || values.length === 0) return false;
// filter[muted]=false is the silent default — don't count it as active
if (
key === "filter[muted]" &&
values.length === 1 &&
values[0] === "false"
)
return false;
return true;
}).length
}
/>
<ApplyFiltersButton
hasChanges={hasChanges}
changeCount={changeCount}
onApply={applyAll}
onDiscard={discardAll}
/>
</div>
{/* Summary strip: shown below filter bar when there are pending changes */}
<FilterSummaryStrip
chips={filterChips}
onRemove={handleChipRemove}
onClearAll={clearAll}
/>
{/* Expandable filters section */}
{hasCustomFilters && (
<ExpandableSection isExpanded={isExpanded}>
<DataTableFilterCustom
filters={customFilters}
prependElement={<CustomDatePicker />}
prependElement={
<CustomDatePicker
onBatchChange={(filterKey, value) =>
setPending(filterKey, value ? [value] : [])
}
value={pendingDateValue}
/>
}
hideClearButton
mode={DATA_TABLE_FILTER_MODE.BATCH}
onBatchChange={setPending}
getFilterValue={getFilterValue}
/>
</ExpandableSection>
)}
+1
View File
@@ -18,4 +18,5 @@ export * from "./separator/separator";
export * from "./skeleton/skeleton";
export * from "./tabs/generic-tabs";
export * from "./tabs/tabs";
export * from "./textarea/textarea";
export * from "./tooltip";
@@ -0,0 +1,278 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FilterOption } from "@/types/filters";
// ── next/navigation mock ────────────────────────────────────────────────────
const mockPush = vi.fn();
const mockUpdateFilter = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => "/findings",
useSearchParams: () => new URLSearchParams(),
}));
// ── useUrlFilters mock — tracks whether updateFilter is called ───────────────
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({ updateFilter: mockUpdateFilter }),
}));
// ── context (optional dependency used by useUrlFilters) ────────────────────
vi.mock("@/contexts", () => ({
useFilterTransitionOptional: () => null,
}));
// ── MultiSelect mock — renders a simple <select> backed by onValuesChange ──
// This lets us trigger filter changes without needing the full Popover UI.
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({
children,
values,
onValuesChange,
}: {
children: React.ReactNode;
values?: string[];
onValuesChange?: (values: string[]) => void;
}) => (
<div data-testid="multiselect" data-values={JSON.stringify(values ?? [])}>
{children}
{/* expose a select to drive value changes in tests */}
<select
data-testid="multiselect-trigger"
multiple
defaultValue={values ?? []}
onChange={(e) => {
const selected = Array.from(e.target.selectedOptions).map(
(o) => o.value,
);
onValuesChange?.(selected);
}}
>
<option value="critical">critical</option>
<option value="high">high</option>
<option value="FAIL">FAIL</option>
</select>
</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
MultiSelectSelectAll: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
),
MultiSelectSeparator: () => <hr />,
MultiSelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <option value={value}>{children}</option>,
}));
// ── ClearFiltersButton stub ─────────────────────────────────────────────────
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <button type="button">Clear</button>,
}));
// ── Other component stubs ───────────────────────────────────────────────────
vi.mock(
"@/components/compliance/compliance-header/compliance-scan-info",
() => ({
ComplianceScanInfo: () => null,
}),
);
vi.mock("@/components/ui/entities/entity-info", () => ({
EntityInfo: () => null,
}));
vi.mock("@/lib/helper-filters", () => ({
isScanEntity: () => false,
isConnectionStatus: () => false,
}));
import { DataTableFilterCustom } from "./data-table-filter-custom";
// ── Future E2E coverage ────────────────────────────────────────────────────
// TODO (E2E): Integration tests for DataTableFilterCustom in batch mode:
// - In batch mode, selecting filters does NOT navigate the browser immediately
// - Multiple filter selections accumulate in pending state
// - Pressing Apply sends a single router.push with all staged filters
// - Pressing Discard reverts staged selections to match the current URL
// ──────────────────────────────────────────────────────────────────────────
const severityFilter: FilterOption = {
key: "filter[severity__in]",
labelCheckboxGroup: "Severity",
values: ["critical", "high"],
};
describe("DataTableFilterCustom — batch vs instant mode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ── Default / instant mode ───────────────────────────────────────────────
describe("instant mode (default)", () => {
it("should call updateFilter (URL update) when a selection changes", async () => {
// Given
const user = userEvent.setup();
render(<DataTableFilterCustom filters={[severityFilter]} />);
// When — simulate a value change on the mock select
const select = screen.getByTestId("multiselect-trigger");
await user.selectOptions(select, ["critical"]);
// Then — instant mode pushes to URL via updateFilter
expect(mockUpdateFilter).toHaveBeenCalledTimes(1);
expect(mockUpdateFilter).toHaveBeenCalledWith(
"filter[severity__in]",
expect.any(Array),
);
});
it("should NOT call onBatchChange in instant mode", async () => {
// Given
const user = userEvent.setup();
const onBatchChange = vi.fn();
render(
<DataTableFilterCustom
filters={[severityFilter]}
onBatchChange={onBatchChange}
// no mode prop → defaults to "instant"
/>,
);
// When
const select = screen.getByTestId("multiselect-trigger");
await user.selectOptions(select, ["critical"]);
// Then
expect(onBatchChange).not.toHaveBeenCalled();
});
it("should render without mode prop (backward compatibility)", () => {
// Given / When
render(<DataTableFilterCustom filters={[severityFilter]} />);
// Then — renders without crashing
expect(screen.getByText("Severity")).toBeInTheDocument();
});
});
// ── Batch mode ───────────────────────────────────────────────────────────
describe("batch mode", () => {
it("should call onBatchChange instead of updateFilter when selection changes", async () => {
// Given
const user = userEvent.setup();
const onBatchChange = vi.fn();
const getFilterValue = vi.fn().mockReturnValue([]);
render(
<DataTableFilterCustom
filters={[severityFilter]}
mode="batch"
onBatchChange={onBatchChange}
getFilterValue={getFilterValue}
/>,
);
// When
const select = screen.getByTestId("multiselect-trigger");
await user.selectOptions(select, ["critical"]);
// Then — batch mode notifies caller instead of URL
expect(onBatchChange).toHaveBeenCalledTimes(1);
expect(onBatchChange).toHaveBeenCalledWith(
"filter[severity__in]",
expect.any(Array),
);
expect(mockUpdateFilter).not.toHaveBeenCalled();
});
it("should read selected values from getFilterValue in batch mode", () => {
// Given — batch mode with pre-seeded pending state
const onBatchChange = vi.fn();
const getFilterValue = vi
.fn()
.mockImplementation((key: string) =>
key === "filter[severity__in]" ? ["critical"] : [],
);
render(
<DataTableFilterCustom
filters={[severityFilter]}
mode="batch"
onBatchChange={onBatchChange}
getFilterValue={getFilterValue}
/>,
);
// Then — the mock multiselect receives the pending values
const multiselect = screen.getByTestId("multiselect");
expect(multiselect).toHaveAttribute(
"data-values",
JSON.stringify(["critical"]),
);
// getFilterValue must have been called for the filter key
expect(getFilterValue).toHaveBeenCalledWith("filter[severity__in]");
});
it("should pass empty array to MultiSelect when getFilterValue returns empty", () => {
// Given
const getFilterValue = vi.fn().mockReturnValue([]);
render(
<DataTableFilterCustom
filters={[severityFilter]}
mode="batch"
onBatchChange={vi.fn()}
getFilterValue={getFilterValue}
/>,
);
// Then — multiselect gets empty values
const multiselect = screen.getByTestId("multiselect");
expect(multiselect).toHaveAttribute("data-values", JSON.stringify([]));
});
});
// ── hideClearButton ──────────────────────────────────────────────────────
describe("hideClearButton prop", () => {
it("should hide the ClearFiltersButton when hideClearButton is true", () => {
// Given / When
render(
<DataTableFilterCustom
filters={[severityFilter]}
hideClearButton={true}
/>,
);
// Then
expect(
screen.queryByRole("button", { name: "Clear" }),
).not.toBeInTheDocument();
});
it("should show the ClearFiltersButton by default", () => {
// Given / When
render(<DataTableFilterCustom filters={[severityFilter]} />);
// Then
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
});
});
@@ -22,6 +22,7 @@ import {
ProviderEntity,
ScanEntity,
} from "@/types";
import { DATA_TABLE_FILTER_MODE, DataTableFilterMode } from "@/types/filters";
import { ProviderConnectionStatus } from "@/types/providers";
export interface DataTableFilterCustomProps {
@@ -30,12 +31,33 @@ export interface DataTableFilterCustomProps {
prependElement?: React.ReactNode;
/** Hide the clear filters button and active badges (useful when parent manages this) */
hideClearButton?: boolean;
/**
* Controls when filter selections are pushed to the URL.
* - "instant" (default): each selection immediately updates the URL (legacy behavior, backward-compatible).
* - "batch": selections accumulate in pending state; caller manages when to push URL.
*/
mode?: DataTableFilterMode;
/**
* Called in "batch" mode when a filter value changes.
* The key is the raw filter key (e.g. "filter[severity__in]" or "severity__in").
* Only invoked when mode === "batch".
*/
onBatchChange?: (filterKey: string, values: string[]) => void;
/**
* Returns the current selected values for a filter in "batch" mode.
* Replaces reading from URL searchParams when mode === "batch".
* Only used when mode === "batch".
*/
getFilterValue?: (filterKey: string) => string[];
}
export const DataTableFilterCustom = ({
filters,
prependElement,
hideClearButton = false,
mode = DATA_TABLE_FILTER_MODE.INSTANT,
onBatchChange,
getFilterValue,
}: DataTableFilterCustomProps) => {
const { updateFilter } = useUrlFilters();
const searchParams = useSearchParams();
@@ -109,6 +131,13 @@ export const DataTableFilterCustom = ({
};
const pushDropdownFilter = (filter: FilterOption, values: string[]) => {
if (mode === DATA_TABLE_FILTER_MODE.BATCH && onBatchChange) {
// In batch mode, notify the caller instead of updating the URL
onBatchChange(filter.key, values);
return;
}
// Instant mode (default): push to URL immediately
// If this filter defaults to "all selected" and the user selected all items,
// clear the URL param to represent "no specific filter" (i.e., all).
const allSelected =
@@ -123,6 +152,12 @@ export const DataTableFilterCustom = ({
};
const getSelectedValues = (filter: FilterOption): string[] => {
if (mode === DATA_TABLE_FILTER_MODE.BATCH && getFilterValue) {
// In batch mode, read from pending state provided by the caller
return getFilterValue(filter.key);
}
// Instant mode (default): read from URL searchParams
const filterKey = filter.key.startsWith("filter[")
? filter.key
: `filter[${filter.key}]`;
+588
View File
@@ -0,0 +1,588 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
// --- Mock next/navigation ---
const mockPush = vi.fn();
let mockSearchParamsValue = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => "/findings",
useSearchParams: () => mockSearchParamsValue,
}));
import { useFilterBatch } from "./use-filter-batch";
/**
* Helper to re-assign the mocked searchParams and re-import the hook.
* Because useSearchParams() is called inside the hook on every render,
* we just update the module-level variable and force a re-render.
*/
function setSearchParams(params: Record<string, string>) {
mockSearchParamsValue = new URLSearchParams(params);
}
describe("useFilterBatch", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSearchParamsValue = new URLSearchParams();
});
// ── Initial state ──────────────────────────────────────────────────────────
describe("initial state", () => {
it("should have empty pending filters when there are no URL params", () => {
// Given
setSearchParams({});
// When
const { result } = renderHook(() => useFilterBatch());
// Then
expect(result.current.pendingFilters).toEqual({});
expect(result.current.hasChanges).toBe(false);
expect(result.current.changeCount).toBe(0);
});
it("should initialize pending filters from URL search params on mount", () => {
// Given
setSearchParams({
"filter[severity__in]": "critical,high",
"filter[status__in]": "FAIL",
});
// When
const { result } = renderHook(() => useFilterBatch());
// Then
expect(result.current.pendingFilters).toEqual({
"filter[severity__in]": ["critical", "high"],
"filter[status__in]": ["FAIL"],
});
expect(result.current.hasChanges).toBe(false);
});
});
// ── Excluded keys ──────────────────────────────────────────────────────────
describe("excluded keys", () => {
it("should exclude filter[search] from batch operations", () => {
// Given — search is excluded from batch; muted now participates in batch
setSearchParams({
"filter[search]": "some-search-term",
"filter[muted]": "false",
"filter[severity__in]": "critical",
});
// When
const { result } = renderHook(() => useFilterBatch());
// Then — severity and muted are in pendingFilters; search is excluded
expect(result.current.pendingFilters).toEqual({
"filter[muted]": ["false"],
"filter[severity__in]": ["critical"],
});
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
// muted is now part of batch (not excluded)
expect(result.current.pendingFilters["filter[muted]"]).toEqual(["false"]);
});
});
// ── setPending ─────────────────────────────────────────────────────────────
describe("setPending", () => {
it("should update pending state for a given key", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// Then
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
"high",
]);
});
it("should auto-prefix key with filter[] when not already prefixed", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When
act(() => {
result.current.setPending("severity__in", ["critical"]);
});
// Then — key is stored with filter[] prefix
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
]);
});
it("should keep the key but with empty array when values is empty", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition: set a value first
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
// When — clear the filter by passing empty array
act(() => {
result.current.setPending("filter[severity__in]", []);
});
// Then
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([]);
});
});
// ── getFilterValue ─────────────────────────────────────────────────────────
describe("getFilterValue", () => {
it("should return pending values for a key that has been set", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// When
const values = result.current.getFilterValue("filter[severity__in]");
// Then
expect(values).toEqual(["critical", "high"]);
});
it("should return an empty array for a key that has not been set", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When
const values = result.current.getFilterValue("filter[unknown_key]");
// Then
expect(values).toEqual([]);
});
it("should auto-prefix key when calling getFilterValue without filter[]", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
// When — key without prefix
const values = result.current.getFilterValue("severity__in");
// Then
expect(values).toEqual(["critical"]);
});
});
// ── hasChanges & changeCount ───────────────────────────────────────────────
describe("hasChanges", () => {
it("should be false when pending matches the URL state", () => {
// Given
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// Then — initial state = URL state, so no changes
expect(result.current.hasChanges).toBe(false);
});
it("should be true when pending differs from the URL state", () => {
// Given
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// When — change pending
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// Then
expect(result.current.hasChanges).toBe(true);
});
});
describe("changeCount", () => {
it("should be 0 when pending matches URL", () => {
// Given
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// Then
expect(result.current.changeCount).toBe(0);
});
it("should count the number of changed filter keys", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
// When — add two different pending filters
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// Then — 2 keys differ from URL (which has neither)
expect(result.current.changeCount).toBe(2);
});
it("should decrease changeCount when a pending filter is reset to match URL", () => {
// Given — URL has severity=critical, pending adds status=FAIL
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[status__in]", ["FAIL"]);
});
expect(result.current.changeCount).toBe(1);
// When — reset status back to empty (matching URL which has no status)
act(() => {
result.current.setPending("filter[status__in]", []);
});
// Then
expect(result.current.changeCount).toBe(0);
});
});
// ── applyAll ───────────────────────────────────────────────────────────────
describe("applyAll", () => {
it("should call router.push with all pending filters serialized as URL params", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
});
// When
act(() => {
result.current.applyAll();
});
// Then
expect(mockPush).toHaveBeenCalledTimes(1);
const calledUrl: string = mockPush.mock.calls[0][0];
expect(calledUrl).toContain("filter%5Bseverity__in%5D=critical%2Chigh");
});
it("should reset page number when a page param exists in the URL", () => {
// Given — simulate a URL that already has page=3
mockSearchParamsValue = new URLSearchParams({
"filter[severity__in]": "critical",
page: "3",
});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// When
act(() => {
result.current.applyAll();
});
// Then — page should be reset to 1
const calledUrl: string = mockPush.mock.calls[0][0];
expect(calledUrl).toContain("page=1");
});
it("should preserve excluded params (filter[search], filter[muted]) in the URL", () => {
// Given
mockSearchParamsValue = new URLSearchParams({
"filter[search]": "my-search",
"filter[muted]": "false",
});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
// When
act(() => {
result.current.applyAll();
});
// Then — search and muted should still be present
const calledUrl: string = mockPush.mock.calls[0][0];
expect(calledUrl).toContain("filter%5Bsearch%5D=my-search");
expect(calledUrl).toContain("filter%5Bmuted%5D=false");
});
});
// ── clearAll ───────────────────────────────────────────────────────────────
describe("clearAll", () => {
it("should clear all pending filters including provider and account keys", () => {
// Given — user has pending provider, account, severity, and status filters
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[provider_type__in]", [
"aws",
"azure",
]);
result.current.setPending("filter[provider_id__in]", [
"provider-uuid-1",
]);
result.current.setPending("filter[severity__in]", ["critical"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// Pre-condition — all filters are pending
expect(
result.current.pendingFilters["filter[provider_type__in]"],
).toEqual(["aws", "azure"]);
expect(result.current.pendingFilters["filter[provider_id__in]"]).toEqual([
"provider-uuid-1",
]);
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
]);
// When
act(() => {
result.current.clearAll();
});
// Then — pending state must be TRULY EMPTY (no keys at all, not even with empty arrays)
expect(result.current.pendingFilters).toEqual({});
// getFilterValue normalises missing keys to [] so all selectors show "all selected"
expect(
result.current.getFilterValue("filter[provider_type__in]"),
).toEqual([]);
expect(result.current.getFilterValue("filter[provider_id__in]")).toEqual(
[],
);
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
expect(result.current.getFilterValue("filter[status__in]")).toEqual([]);
});
it("should also clear provider/account keys that came from the URL (applied state)", () => {
// Given — URL has provider and account filters applied
setSearchParams({
"filter[provider_type__in]": "aws",
"filter[provider_id__in]": "provider-uuid-1",
"filter[severity__in]": "critical",
});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition — filters are loaded from URL into pending
expect(
result.current.pendingFilters["filter[provider_type__in]"],
).toEqual(["aws"]);
expect(result.current.pendingFilters["filter[provider_id__in]"]).toEqual([
"provider-uuid-1",
]);
// When
act(() => {
result.current.clearAll();
});
// Then — pending state must be truly empty (no keys, not { key: [] })
expect(result.current.pendingFilters).toEqual({});
// provider and account must be cleared even though they came from the URL
expect(
result.current.getFilterValue("filter[provider_type__in]"),
).toEqual([]);
expect(result.current.getFilterValue("filter[provider_id__in]")).toEqual(
[],
);
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
});
it("should mark hasChanges as true after clear when URL still has applied filters", () => {
// Given — URL has filters applied
setSearchParams({
"filter[provider_type__in]": "aws",
"filter[severity__in]": "critical",
});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition — no pending changes (matches URL)
expect(result.current.hasChanges).toBe(false);
// When — clear all
act(() => {
result.current.clearAll();
});
// Then — hasChanges must be true (pending is empty, URL still has filters)
expect(result.current.hasChanges).toBe(true);
});
it("should NOT clear excluded keys (filter[search]) but DOES clear filter[muted]", () => {
// Given — URL has search (excluded) plus muted and severity (both in batch)
setSearchParams({
"filter[search]": "my-search",
"filter[muted]": "false",
"filter[severity__in]": "critical",
});
const { result } = renderHook(() => useFilterBatch());
// Pre-condition — muted and severity are in pendingFilters; search is excluded
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
expect(result.current.pendingFilters["filter[muted]"]).toEqual(["false"]);
// When
act(() => {
result.current.clearAll();
});
// Then — severity and muted are cleared; search remains excluded (undefined in pending)
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
expect(result.current.pendingFilters["filter[search]"]).toBeUndefined();
// muted is a batch key, so it gets cleared by clearAll
expect(result.current.pendingFilters["filter[muted]"]).toBeUndefined();
});
it("should clear applied URL filters even if they were explicitly removed from pendingFilters", () => {
// This covers the edge case where pendingFilters diverged from URL state
// (e.g., URL has provider filter but the key was removed from pending via removePending)
setSearchParams({
"filter[provider_type__in]": "gcp",
"filter[severity__in]": "high",
});
const { result } = renderHook(() => useFilterBatch());
// Remove the provider key from pending (diverge from URL state)
act(() => {
result.current.removePending("filter[provider_type__in]");
});
// Pre-condition — provider is gone from pending but still in URL
expect(
result.current.pendingFilters["filter[provider_type__in]"],
).toBeUndefined();
// When — clearAll should clear BOTH pending keys AND applied URL keys
act(() => {
result.current.clearAll();
});
// Then — severity is cleared
expect(result.current.getFilterValue("filter[severity__in]")).toEqual([]);
// provider_type__in was in the URL (applied state), so clearAll must handle it
expect(
result.current.getFilterValue("filter[provider_type__in]"),
).toEqual([]);
});
});
// ── discardAll ─────────────────────────────────────────────────────────────
describe("discardAll", () => {
it("should reset pending to match the current URL state", () => {
// Given — URL has severity=critical
setSearchParams({ "filter[severity__in]": "critical" });
const { result } = renderHook(() => useFilterBatch());
// Add a pending change
act(() => {
result.current.setPending("filter[severity__in]", ["critical", "high"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
expect(result.current.hasChanges).toBe(true);
// When
act(() => {
result.current.discardAll();
});
// Then — pending should match URL again
expect(result.current.pendingFilters).toEqual({
"filter[severity__in]": ["critical"],
});
expect(result.current.hasChanges).toBe(false);
});
});
// ── URL sync (back/forward) ────────────────────────────────────────────────
describe("URL sync", () => {
it("should re-sync pending state when searchParams change (e.g., browser back/forward)", () => {
// Given — initial empty URL
setSearchParams({});
const { result, rerender } = renderHook(() => useFilterBatch());
// Add a pending change
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
});
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"critical",
]);
// When — simulate browser back by changing searchParams externally
act(() => {
mockSearchParamsValue = new URLSearchParams({
"filter[severity__in]": "high",
});
});
rerender();
// Then — pending should re-sync from new URL
expect(result.current.pendingFilters["filter[severity__in]"]).toEqual([
"high",
]);
});
});
// ── removePending ──────────────────────────────────────────────────────────
describe("removePending", () => {
it("should remove a single filter key from pending state", () => {
// Given
setSearchParams({});
const { result } = renderHook(() => useFilterBatch());
act(() => {
result.current.setPending("filter[severity__in]", ["critical"]);
result.current.setPending("filter[status__in]", ["FAIL"]);
});
// When
act(() => {
result.current.removePending("filter[severity__in]");
});
// Then
expect(
result.current.pendingFilters["filter[severity__in]"],
).toBeUndefined();
expect(result.current.pendingFilters["filter[status__in]"]).toEqual([
"FAIL",
]);
});
});
});
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
// Filters that are managed by the batch hook (excludes system defaults)
const EXCLUDED_FROM_BATCH = ["filter[search]"];
/**
* Snapshot of pending (un-applied) filter state.
* Keys are raw filter param names, e.g. "filter[severity__in]".
* Values are arrays of selected option strings.
*/
export interface PendingFilters {
[filterKey: string]: string[];
}
export interface UseFilterBatchReturn {
/** Current pending filter values — local state, not yet in URL */
pendingFilters: PendingFilters;
/** Update a single pending filter. Does NOT touch the URL. */
setPending: (key: string, values: string[]) => void;
/** Apply all pending filters to URL in a single router.push */
applyAll: () => void;
/** Discard all pending changes, reset pending to the current URL state */
discardAll: () => void;
/**
* Clear all pending filters to an empty state (no filters selected).
* Unlike `discardAll`, this does NOT reset to the URL state it sets
* pending to `{}` (truly empty). The user must click Apply to push
* the empty state to the URL.
* Includes provider/account keys and all batch-managed filter keys.
*/
clearAll: () => void;
/** Remove a single filter key from pending state */
removePending: (key: string) => void;
/** Whether pending state differs from the current URL */
hasChanges: boolean;
/** Number of filter keys that differ from the URL */
changeCount: number;
/** Get current value for a filter (pending if set, else from URL) */
getFilterValue: (key: string) => string[];
}
/**
* Derives the applied (URL-backed) filter state from `searchParams`.
* Returns only the filter keys that are not excluded from batch management.
*/
function deriveAppliedFromUrl(searchParams: URLSearchParams): PendingFilters {
const applied: PendingFilters = {};
Array.from(searchParams.entries()).forEach(([key, value]) => {
if (!key.startsWith("filter[")) return;
if (EXCLUDED_FROM_BATCH.includes(key)) return;
if (!value) return;
applied[key] = value.split(",").filter(Boolean);
});
return applied;
}
/**
* Compares two PendingFilters objects for shallow equality.
* Two states are equal when they contain the same keys and the same sorted values.
*/
function areFiltersEqual(a: PendingFilters, b: PendingFilters): boolean {
const keysA = Object.keys(a).filter((k) => a[k].length > 0);
const keysB = Object.keys(b).filter((k) => b[k].length > 0);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) => {
if (!b[key]) return false;
const sortedA = [...a[key]].sort();
const sortedB = [...b[key]].sort();
if (sortedA.length !== sortedB.length) return false;
return sortedA.every((v, i) => v === sortedB[i]);
});
}
/**
* Counts the number of filter keys that differ between pending and applied.
*/
function countChanges(
pending: PendingFilters,
applied: PendingFilters,
): number {
const pendingKeys = Object.keys(pending).filter((k) => pending[k].length > 0);
const appliedKeys = Object.keys(applied).filter((k) => applied[k].length > 0);
// Merge all unique keys without Set iteration
const allKeys = Array.from(new Set([...pendingKeys, ...appliedKeys]));
let count = 0;
allKeys.forEach((key) => {
const p = pending[key] ?? [];
const a = applied[key] ?? [];
const sortedP = [...p].sort();
const sortedA = [...a].sort();
if (
sortedP.length !== sortedA.length ||
!sortedP.every((v, i) => v === sortedA[i])
) {
count++;
}
});
return count;
}
export interface UseFilterBatchOptions {
/**
* Default URL params to apply when applyAll() is called and they are not
* already present in the params. Useful for page-level filter defaults
* (e.g. `{ "filter[muted]": "false" }` on the Findings page).
*/
defaultParams?: Record<string, string>;
}
/**
* Manages a two-state (pending applied) filter model for the Findings view.
*
* - Pending state lives only in this hook (React `useState`).
* - Applied state is owned by the URL (`searchParams`).
* - `applyAll()` performs a single `router.push()` with the full pending state.
* - `discardAll()` resets pending to match the current URL.
* - Browser back/forward automatically re-syncs pending state from the new URL.
*/
export const useFilterBatch = (
options?: UseFilterBatchOptions,
): UseFilterBatchReturn => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pendingFilters, setPendingFilters] = useState<PendingFilters>(() =>
deriveAppliedFromUrl(new URLSearchParams(searchParams.toString())),
);
// Sync pending state whenever the URL changes (back/forward nav or external update).
// `searchParams` from useSearchParams() is stable between renders in Next.js App Router.
useEffect(() => {
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
setPendingFilters(applied);
}, [searchParams]);
const setPending = (key: string, values: string[]) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
setPendingFilters((prev) => ({
...prev,
[filterKey]: values,
}));
};
const removePending = (key: string) => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
setPendingFilters((prev) => {
const next = { ...prev };
delete next[filterKey];
return next;
});
};
const applyAll = () => {
// Start from the current URL params to preserve non-batch params.
// Only filter[search] is excluded from batch management and preserved from the URL as-is.
const params = new URLSearchParams(searchParams.toString());
// Remove all existing batch-managed filter params
Array.from(params.keys()).forEach((key) => {
if (key.startsWith("filter[") && !EXCLUDED_FROM_BATCH.includes(key)) {
params.delete(key);
}
});
// Write the pending state
Object.entries(pendingFilters).forEach(([key, values]) => {
const nonEmpty = values.filter(Boolean);
if (nonEmpty.length > 0) {
params.set(key, nonEmpty.join(","));
}
});
// Apply caller-supplied defaults for any params not already set
if (options?.defaultParams) {
Object.entries(options.defaultParams).forEach(([key, value]) => {
if (!params.has(key)) {
params.set(key, value);
}
});
}
// Reset pagination
if (params.has("page")) {
params.set("page", "1");
}
const queryString = params.toString();
const targetUrl = queryString ? `${pathname}?${queryString}` : pathname;
router.push(targetUrl, { scroll: false });
};
const discardAll = () => {
const applied = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
setPendingFilters(applied);
};
/**
* Clears ALL pending batch filters to an empty state (no filters selected).
*
* Unlike `discardAll`, this resets pending to `{}` not to the current URL
* state. This covers both:
* - Keys that are already in `pendingFilters` (pending-only or URL-loaded)
* - Keys that are in the applied (URL) state but were removed from pending
* via `removePending` (edge case: diverged state)
*
* The user must click Apply to push the empty state to the URL.
* `applyAll()` removes all batch-managed URL params first, so even keys
* absent from `pendingFilters` will be removed from the URL on apply.
*/
const clearAll = () => {
// Return a truly empty object — no filters pending at all.
// `getFilterValue` normalises missing keys to [] so selectors will show
// their "all selected" / placeholder state immediately.
setPendingFilters({});
};
const getFilterValue = (key: string): string[] => {
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
return pendingFilters[filterKey] ?? [];
};
const appliedFilters = deriveAppliedFromUrl(
new URLSearchParams(searchParams.toString()),
);
const hasChanges = !areFiltersEqual(pendingFilters, appliedFilters);
const changeCount = hasChanges
? countChanges(pendingFilters, appliedFilters)
: 0;
return {
pendingFilters,
setPending,
applyAll,
discardAll,
clearAll,
removePending,
hasChanges,
changeCount,
getFilterValue,
};
};
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod";
export const CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH = 10000;
export const CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE =
"Only read-only queries are allowed";
const CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS =
/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
const CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS = [
/\bCREATE\b/i,
/\bMERGE\b/i,
/\bSET\b/i,
/\bREMOVE\b/i,
/\bDELETE\b/i,
/\bDETACH\s+DELETE\b/i,
/\bDROP\b/i,
/\bLOAD\s+CSV\b/i,
/\bapoc\.(?:load|import|export|cypher|systemdb|config|periodic|do|trigger|custom)\b/i,
] as const;
const containsBlockedOperation = (query: string): boolean => {
const normalizedQuery = query.replace(
CUSTOM_ATTACK_PATH_QUERY_STRING_LITERALS,
"",
);
return CUSTOM_ATTACK_PATH_BLOCKED_PATTERNS.some((pattern) =>
pattern.test(normalizedQuery),
);
};
export const customAttackPathQuerySchema = z
.string()
.max(
CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH,
`Custom query must be ${CUSTOM_ATTACK_PATH_QUERY_MAX_LENGTH} characters or fewer`,
)
.refine((value) => value.trim().length > 0, {
message: "Custom query cannot be empty",
})
.refine((value) => !containsBlockedOperation(value), {
message: CUSTOM_ATTACK_PATH_QUERY_READ_ONLY_ERROR_MESSAGE,
});
+50
View File
@@ -81,6 +81,18 @@ export const DATA_TYPES = {
type DataType = (typeof DATA_TYPES)[keyof typeof DATA_TYPES];
export const QUERY_PARAMETER_INPUT_TYPES = {
TEXT: "text",
TEXTAREA: "textarea",
} as const;
export type QueryParameterInputType =
(typeof QUERY_PARAMETER_INPUT_TYPES)[keyof typeof QUERY_PARAMETER_INPUT_TYPES];
export const ATTACK_PATH_QUERY_IDS = {
CUSTOM: "__custom-open-cypher__",
} as const;
// Query Types
export interface AttackPathQueryParameter {
name: string;
@@ -89,6 +101,7 @@ export interface AttackPathQueryParameter {
description: string;
placeholder?: string;
required?: boolean;
input_type?: QueryParameterInputType;
}
export interface AttackPathQueryAttribution {
@@ -96,6 +109,11 @@ export interface AttackPathQueryAttribution {
link: string;
}
export interface AttackPathQueryDocumentationLink {
text: string;
link: string;
}
export interface AttackPathQueryAttributes {
name: string;
short_description: string;
@@ -103,6 +121,7 @@ export interface AttackPathQueryAttributes {
provider: string;
parameters: AttackPathQueryParameter[];
attribution: AttackPathQueryAttribution | null;
documentation_link?: AttackPathQueryDocumentationLink | null;
}
export interface AttackPathQuery {
@@ -115,6 +134,24 @@ export interface AttackPathQueriesResponse {
data: AttackPathQuery[];
}
export interface AttackPathCartographySchemaAttributes {
id: string;
provider: string;
cartography_version: string;
schema_url: string;
raw_schema_url: string;
}
export interface AttackPathCartographySchema {
type: "attack-paths-cartography-schemas";
id: string;
attributes: AttackPathCartographySchemaAttributes;
}
export interface AttackPathCartographySchemaResponse {
data: AttackPathCartographySchema;
}
// Graph Data Types
// Property values from graph nodes can be any primitive type or arrays
export type GraphNodePropertyValue =
@@ -256,3 +293,16 @@ export interface ExecuteQueryRequestData {
export interface ExecuteQueryRequest {
data: ExecuteQueryRequestData;
}
export interface ExecuteCustomQueryRequestAttributes {
query: string;
}
export interface ExecuteCustomQueryRequestData {
type: "attack-paths-custom-query-run-requests";
attributes: ExecuteCustomQueryRequestAttributes;
}
export interface ExecuteCustomQueryRequest {
data: ExecuteCustomQueryRequestData;
}
+10
View File
@@ -95,6 +95,16 @@ export const FINDING_STATUS = {
export type FindingStatus =
(typeof FINDING_STATUS)[keyof typeof FINDING_STATUS];
/**
* Maps raw finding status values to human-readable display strings.
* Follows the same pattern as SEVERITY_DISPLAY_NAMES in types/severities.ts.
*/
export const FINDING_STATUS_DISPLAY_NAMES: Record<FindingStatus, string> = {
PASS: "Pass",
FAIL: "Fail",
MANUAL: "Manual",
};
export const SEVERITY = {
INFORMATIONAL: "informational",
LOW: "low",
+33
View File
@@ -42,3 +42,36 @@ export enum FilterType {
CATEGORY = "category__in",
RESOURCE_GROUPS = "resource_groups__in",
}
/**
* Controls the filter dispatch behavior of DataTableFilterCustom.
* - "instant": every selection immediately updates the URL (legacy/default behavior)
* - "batch": selections accumulate in pending state; URL only updates on explicit apply
*/
export const DATA_TABLE_FILTER_MODE = {
INSTANT: "instant",
BATCH: "batch",
} as const;
export type DataTableFilterMode =
(typeof DATA_TABLE_FILTER_MODE)[keyof typeof DATA_TABLE_FILTER_MODE];
/**
* Exhaustive union of all URL filter param keys used in Findings filters.
* Use this instead of `string` to ensure FILTER_KEY_LABELS and other
* param-keyed records stay in sync with the actual filter surface.
*/
export type FilterParam =
| "filter[provider_type__in]"
| "filter[provider_id__in]"
| "filter[severity__in]"
| "filter[status__in]"
| "filter[delta__in]"
| "filter[region__in]"
| "filter[service__in]"
| "filter[resource_type__in]"
| "filter[category__in]"
| "filter[resource_groups__in]"
| "filter[scan__in]"
| "filter[inserted_at]"
| "filter[muted]";