Merge remote-tracking branch 'origin/master' into feature/enable-env-vars

# Conflicts:
#	ui/CHANGELOG.md
This commit is contained in:
Pablo F.G
2026-06-25 15:49:46 +02:00
81 changed files with 3685 additions and 346 deletions
+16
View File
@@ -2,6 +2,22 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.32.2] (Prowler UNRELEASED)
### 🐞 Fixed
- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696)
---
## [1.32.1] (Prowler v5.31.1)
### 🐞 Fixed
- API key auth no longer mutates `TenantAPIKey.objects` during admin DB lookups [(#11686)](https://github.com/prowler-cloud/prowler/pull/11686)
---
## [1.32.0] (Prowler v5.31.0)
### 🚀 Added
+43 -9
View File
@@ -1,11 +1,14 @@
from math import isfinite
from uuid import UUID
from api.db_router import MainRouter
from api.models import TenantAPIKey, TenantAPIKeyManager
from cryptography.fernet import InvalidToken
from django.core.exceptions import ObjectDoesNotExist
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
from drf_simple_apikey.settings import package_settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
@@ -21,18 +24,49 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
def _authenticate_credentials(self, request, key):
"""
Override to use admin connection, bypassing RLS during authentication.
Delegates to parent after temporarily routing model queries to admin DB.
"""
# Temporarily point the model's manager to admin database
original_objects = self.model.objects
self.model.objects = self.model.objects.using(MainRouter.admin_db)
try:
payload = self.key_crypto.decrypt(key)
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
if not isinstance(payload, dict):
raise AuthenticationFailed("Invalid API Key.")
payload_pk = payload.get("_pk")
payload_exp = payload.get("_exp")
if (
not isinstance(payload_pk, str)
or isinstance(payload_exp, bool)
or not isinstance(payload_exp, (int, float))
or not isfinite(payload_exp)
):
raise AuthenticationFailed("Invalid API Key.")
try:
# Call parent method which will now use admin database
return super()._authenticate_credentials(request, key)
finally:
# Restore original manager
self.model.objects = original_objects
api_key_pk = UUID(payload_pk)
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
if payload_exp < timezone.now().timestamp():
raise AuthenticationFailed("API Key has already expired.")
try:
api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk)
except ObjectDoesNotExist:
raise AuthenticationFailed("No entity matching this api key.")
if api_key.revoked:
raise AuthenticationFailed("This API Key has been revoked.")
client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER)
if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips:
raise AuthenticationFailed("Access denied from blacklisted IP.")
if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips:
raise AuthenticationFailed("Access restricted to specific IP addresses.")
return api_key.entity, key
def authenticate(self, request: Request):
prefixed_key = self.get_key(request)
@@ -7,6 +7,7 @@ import pytest
from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication
from api.db_router import MainRouter
from api.models import TenantAPIKey
from django.db.models.query import QuerySet
from django.test import RequestFactory
from rest_framework.exceptions import AuthenticationFailed
@@ -64,6 +65,54 @@ class TestTenantAPIKeyAuthentication:
# Verify the manager was restored
assert TenantAPIKey.objects == original_manager
def test_authenticate_credentials_keeps_manager_during_lookup(
self, auth_backend, api_keys_fixture, request_factory
):
"""Authentication must not expose a QuerySet as the model manager."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
_, encrypted_key = raw_key.split(TenantAPIKey.objects.separator, 1)
original_get = QuerySet.get
manager_has_create_api_key = []
def observe_manager(queryset, *args, **kwargs):
manager_has_create_api_key.append(
hasattr(TenantAPIKey.objects, "create_api_key")
)
return original_get(queryset, *args, **kwargs)
request = request_factory.get("/")
with patch.object(QuerySet, "get", observe_manager):
auth_backend._authenticate_credentials(request, encrypted_key)
assert manager_has_create_api_key
assert all(manager_has_create_api_key)
@pytest.mark.parametrize(
"payload",
[
{"_pk": str(uuid4()), "_exp": "not-a-timestamp"},
{
"_pk": "not-a-uuid",
"_exp": (datetime.now(UTC) + timedelta(days=1)).timestamp(),
},
{"_pk": str(uuid4()), "_exp": True},
],
)
def test_authenticate_credentials_rejects_malformed_payloads(
self, auth_backend, request_factory, payload
):
"""Malformed decrypted payloads fail as authentication errors."""
request = request_factory.get("/")
encrypted_key = auth_backend.key_crypto.generate(payload)
with pytest.raises(AuthenticationFailed) as exc_info:
auth_backend._authenticate_credentials(request, encrypted_key)
assert str(exc_info.value.detail) == "Invalid API Key."
def test_authenticate_credentials_restores_manager_on_exception(
self, auth_backend, request_factory
):
+4
View File
@@ -39,3 +39,7 @@ SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405
SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405
"DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod"
)
# Tests don't need secure password hashing; PBKDF2 (~hundreds of ms per call)
# dominates fixture setup time across every create_user()/check_password().
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
+101
View File
@@ -69,6 +69,107 @@ TEST_USER = "dev@prowler.com"
TEST_PASSWORD = "testing_psswd"
def _install_compliance_catalog_test_cache() -> None:
"""Memoize the heavy SDK catalog loaders for the whole test session.
``get_bulk_compliance_frameworks_universal`` re-reads and Pydantic-validates
~100 compliance JSONs (≈20 MB) and ``CheckMetadata.get_bulk`` re-reads ~1k
check metadata files on *every* call. Production amortizes this through the
per-process lazy caches (``PROWLER_CHECKS`` / ``PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE``)
and ``warm_compliance_caches``, but the test suite parametrizes over every
provider and deliberately resets the API-level caches, so the same catalogs
were re-parsed dozens of times across the suite (≈3s/call locally, ≈19s under
coverage in CI).
The catalog files are immutable during a run and callers treat the parsed
objects as read-only, so caching the result per provider is safe. This is the
test-only equivalent of an ``lru_cache`` on the SDK functions, without
changing SDK behavior in production.
A second, lower-level cache memoizes ``load_compliance_framework_universal``
**per file path**. ``get_bulk_compliance_frameworks_universal`` parses *every*
compliance JSON and only then filters by provider, so a per-provider cache
still re-parses all ~100 files on the first load of each provider. The
per-path cache makes the first provider parse the files once and every other
provider/test reuse the already-parsed ``ComplianceFramework`` objects (only
the cheap ``listdir`` + filtering re-runs). ``_load_jsons_from_dir`` calls
``load_compliance_framework_universal`` as a module global, so patching the
attribute is picked up without touching the SDK.
Installed at conftest import time (before test modules are collected) so that
even ``from ... import get_bulk_compliance_frameworks_universal`` bindings in
the test modules resolve to the cached wrapper.
"""
import prowler.lib.check.compliance_models as compliance_models
from prowler.lib.check.models import CheckMetadata
original_bulk_frameworks = (
compliance_models.get_bulk_compliance_frameworks_universal
)
original_get_bulk = CheckMetadata.get_bulk
original_load = compliance_models.load_compliance_framework_universal
def cached_bulk_frameworks(provider):
if provider not in _COMPLIANCE_FRAMEWORK_CACHE:
_COMPLIANCE_FRAMEWORK_CACHE[provider] = original_bulk_frameworks(provider)
return _COMPLIANCE_FRAMEWORK_CACHE[provider]
def cached_get_bulk(provider):
if provider not in _COMPLIANCE_CHECKS_CACHE:
_COMPLIANCE_CHECKS_CACHE[provider] = original_get_bulk(provider)
return _COMPLIANCE_CHECKS_CACHE[provider]
def cached_load(path):
if path not in _COMPLIANCE_PATH_CACHE:
_COMPLIANCE_PATH_CACHE[path] = original_load(path)
return _COMPLIANCE_PATH_CACHE[path]
compliance_models.get_bulk_compliance_frameworks_universal = cached_bulk_frameworks
compliance_models.load_compliance_framework_universal = cached_load
CheckMetadata.get_bulk = staticmethod(cached_get_bulk)
# ``api.compliance`` does ``from ... import get_bulk_compliance_frameworks_universal``
# so it holds its own binding; patch it too in case it was imported first.
import api.compliance as api_compliance
api_compliance.get_bulk_compliance_frameworks_universal = cached_bulk_frameworks
# Module-scoped so the ``_compliance_cache_guard`` fixture below can reset them.
# Keeping them out of ``_install_compliance_catalog_test_cache``'s local scope is
# what makes the caches resettable between tests; the wrappers above close over
# these names, and the original loaders stay referenced so patched behaviour is
# still honoured.
_COMPLIANCE_FRAMEWORK_CACHE: dict[str, dict] = {}
_COMPLIANCE_CHECKS_CACHE: dict[str, dict] = {}
_COMPLIANCE_PATH_CACHE: dict[str, object] = {}
_install_compliance_catalog_test_cache()
@pytest.fixture(autouse=True)
def _compliance_cache_guard(request):
"""Reset the compliance catalog caches after any test that used ``monkeypatch``.
The session-wide caches in ``_install_compliance_catalog_test_cache`` let the
read-only, parametrized compliance tests parse the ~100 catalog JSONs once
instead of dozens of times. A test that swaps a loader (or mutates a returned
object) could otherwise leak that state into later tests through the shared
dicts. Using ``monkeypatch`` as the opt-in signal keeps the full speed-up for
catalog-reading tests while giving patching tests a clean slate afterwards;
the next test simply repopulates the caches from disk.
"""
yield
if "monkeypatch" in request.fixturenames:
_COMPLIANCE_FRAMEWORK_CACHE.clear()
_COMPLIANCE_CHECKS_CACHE.clear()
_COMPLIANCE_PATH_CACHE.clear()
import api.compliance as api_compliance
api_compliance.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
def today_after_n_days(n_days: int) -> str:
return datetime.strftime(
datetime.today().date() + timedelta(days=n_days), "%Y-%m-%d"
+55 -19
View File
@@ -19,7 +19,7 @@ from api.db_utils import (
psycopg_connection,
rls_transaction,
)
from api.exceptions import ProviderConnectionError
from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
AttackSurfaceOverview,
ComplianceOverviewSummary,
@@ -48,7 +48,7 @@ from celery.utils.log import get_task_logger
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
from config.env import env
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
from django.db import IntegrityError, OperationalError
from django.db import DatabaseError, IntegrityError, OperationalError, transaction
from django.db.models import (
Case,
Count,
@@ -117,6 +117,20 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {}
def _save_scan_instance(
scan_instance: Scan, provider_id: str, update_fields: list[str]
) -> None:
try:
with transaction.atomic(): # Savepoint for not killing the `rls_transaction`
scan_instance.save(update_fields=update_fields)
except DatabaseError:
if Scan.objects.filter(pk=scan_instance.id).exists():
raise
raise ProviderDeletedException(
f"Provider '{provider_id}' for scan '{scan_instance.id}' was deleted during the scan"
) from None
def aggregate_category_counts(
categories: list[str],
severity: str,
@@ -1029,13 +1043,18 @@ def perform_prowler_scan(
group_resources_cache: dict[str, set] = {}
start_time = time.time()
exc = None
skip_final_scan_update = False
with rls_transaction(tenant_id):
provider_instance = Provider.objects.get(pk=provider_id)
scan_instance = Scan.objects.get(pk=scan_id)
scan_instance.state = StateChoices.EXECUTING
scan_instance.started_at = datetime.now(tz=UTC)
scan_instance.save(update_fields=["state", "started_at", "updated_at"])
_save_scan_instance(
scan_instance,
provider_id,
["state", "started_at", "updated_at"],
)
# Find the mutelist processor if it exists
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
@@ -1101,7 +1120,7 @@ def perform_prowler_scan(
# Throttle scan_instance progress writes to avoid hammering the writer:
# only persist when progress moves by at least `PROGRESS_THROTTLE_DELTA`
# OR `PROGRESS_THROTTLE_SECONDS` have elapsed. The final progress (1.0)
# OR `PROGRESS_THROTTLE_SECONDS` have elapsed. The final progress (100)
# always persists in the `finally` block below.
last_persisted_progress = -1.0
last_persisted_progress_at = 0.0
@@ -1143,7 +1162,11 @@ def perform_prowler_scan(
):
with rls_transaction(tenant_id):
scan_instance.progress = progress
scan_instance.save(update_fields=["progress", "updated_at"])
_save_scan_instance(
scan_instance,
provider_id,
["progress", "updated_at"],
)
last_persisted_progress = progress
last_persisted_progress_at = now
@@ -1170,26 +1193,39 @@ def perform_prowler_scan(
batch_size=SCAN_DB_BATCH_SIZE,
)
except ProviderDeletedException as e:
logger.warning(str(e))
exception = e
skip_final_scan_update = True
except Exception as e:
logger.error(f"Error performing scan {scan_id}: {e}")
exception = e
scan_instance.state = StateChoices.FAILED
finally:
with rls_transaction(tenant_id):
scan_instance.duration = time.time() - start_time
scan_instance.completed_at = datetime.now(tz=UTC)
scan_instance.unique_resource_count = len(unique_resources)
scan_instance.save(
update_fields=[
"state",
"duration",
"completed_at",
"unique_resource_count",
"progress",
"updated_at",
]
)
if not skip_final_scan_update:
try:
with rls_transaction(tenant_id):
scan_instance.duration = time.time() - start_time
scan_instance.completed_at = datetime.now(tz=UTC)
scan_instance.unique_resource_count = len(unique_resources)
if exception is None:
scan_instance.progress = 100
_save_scan_instance(
scan_instance,
provider_id,
[
"state",
"duration",
"completed_at",
"unique_resource_count",
"progress",
"updated_at",
],
)
except ProviderDeletedException as e:
logger.warning(str(e))
exception = e
if exception is not None:
raise exception
+70 -1
View File
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
import pytest
from api.db_router import MainRouter
from api.exceptions import ProviderConnectionError
from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
Finding,
MuteRule,
@@ -262,6 +262,75 @@ class TestPerformScan:
assert provider.connected is False
assert isinstance(provider.connection_last_checked_at, datetime)
def test_perform_prowler_scan_provider_deleted_during_progress_update(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
def scan_results():
Provider.objects.filter(pk=provider_id).delete()
yield 50, []
with (
patch(
"tasks.jobs.scan.initialize_prowler_provider",
return_value=MagicMock(),
),
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch("tasks.jobs.scan.logger.error") as mock_logger_error,
):
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = scan_results()
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
with pytest.raises(ProviderDeletedException):
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
mock_logger_error.assert_not_called()
assert not Scan.objects.filter(pk=scan_id).exists()
def test_perform_prowler_scan_sets_final_progress_when_progress_updates_are_throttled(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
with (
patch(
"tasks.jobs.scan.initialize_prowler_provider",
return_value=MagicMock(),
),
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch("tasks.jobs.scan.PROGRESS_THROTTLE_DELTA", 200),
patch("tasks.jobs.scan.PROGRESS_THROTTLE_SECONDS", 3600),
):
mock_prowler_scan_instance = MagicMock()
mock_prowler_scan_instance.scan.return_value = [(99, []), (100, [])]
mock_prowler_scan_class.return_value = mock_prowler_scan_instance
perform_prowler_scan(tenant_id, scan_id, provider_id, [])
scan.refresh_from_db()
assert scan.state == StateChoices.COMPLETED
assert scan.progress == 100
@pytest.mark.parametrize(
"last_status, new_status, expected_delta",
[
+17
View File
@@ -2,6 +2,23 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.32.0] (Prowler UNRELEASED)
### 🚀 Added
- `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182)
- `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577)
---
## [5.31.1] (Prowler v5.31.1)
### 🐞 Fixed
- Alibaba Cloud `ram_password_policy_number` and `cs_kubernetes_cluster_check_weekly` checks not being loaded due to missing implementation and package files [(#11683)](https://github.com/prowler-cloud/prowler/pull/11683)
---
## [5.31.0] (Prowler v5.31.0)
### 🚀 Added
@@ -0,0 +1,34 @@
from prowler.lib.check.models import Check, CheckReportAlibabaCloud
from prowler.providers.alibabacloud.services.ram.ram_client import ram_client
class ram_password_policy_number(Check):
"""Check if RAM password policy requires at least one number."""
def execute(self) -> list[CheckReportAlibabaCloud]:
findings = []
if ram_client.password_policy:
report = CheckReportAlibabaCloud(
metadata=self.metadata(), resource=ram_client.password_policy
)
report.region = ram_client.region
report.resource_id = f"{ram_client.audited_account}-password-policy"
report.resource_arn = (
f"acs:ram::{ram_client.audited_account}:password-policy"
)
if ram_client.password_policy.require_numbers:
report.status = "PASS"
report.status_extended = (
"RAM password policy requires at least one number."
)
else:
report.status = "FAIL"
report.status_extended = (
"RAM password policy does not require at least one number."
)
findings.append(report)
return findings
@@ -0,0 +1,43 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_explicitly_targets_azure_devops",
"CheckTitle": "Conditional Access Policy explicitly targets Azure DevOps",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Microsoft Entra **Conditional Access** is verified to have at least one **enabled** policy that explicitly includes the **Azure DevOps** cloud application. Policies targeting **All** cloud apps do not satisfy this check because the goal is to verify that Azure DevOps has been deliberately considered.",
"Risk": "Without an explicit Conditional Access policy for Azure DevOps, organizations may rely on broad policies that do not account for Azure DevOps-specific access patterns such as CLI, IDE plug-ins, PAT-based workflows, source code access, build pipelines, secrets, and service connections.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessapplications?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/manage-conditional-access",
"https://learn.microsoft.com/en-us/troubleshoot/entra/entra-id/governance/verify-first-party-apps-sign-in"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com).\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Create or edit a policy for Azure DevOps.\n4. Under **Target resources**, select **Include** > **Select apps** and choose **Azure DevOps**.\n5. Configure the required grant or session controls for your organization.\n6. Set the policy to **Report-only** until validated, then enable it.",
"Terraform": ""
},
"Recommendation": {
"Text": "Create and enable a Conditional Access policy that explicitly includes the Azure DevOps cloud application, then configure the appropriate access controls for your organization.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_explicitly_targets_azure_devops"
}
},
"Categories": [
"identity-access",
"trust-boundaries",
"e3"
],
"DependsOn": [],
"RelatedTo": [
"entra_conditional_access_policy_all_apps_all_users"
],
"Notes": "Azure DevOps Services uses appId 499b84ac-1321-427f-aa17-267ca6975798."
}
@@ -0,0 +1,57 @@
"""Check if a Conditional Access policy explicitly targets Azure DevOps."""
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicyState,
)
AZURE_DEVOPS_APP_ID = "499b84ac-1321-427f-aa17-267ca6975798"
class entra_conditional_access_policy_explicitly_targets_azure_devops(Check):
"""Check that an enabled Conditional Access policy explicitly targets Azure DevOps."""
def execute(self) -> list[CheckReportM365]:
"""Execute the check for explicit Azure DevOps targeting.
Returns:
A list of reports containing the result of the check.
"""
findings = []
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = (
"No enabled Conditional Access Policy explicitly targets Azure DevOps."
)
for policy in entra_client.conditional_access_policies.values():
if policy.state != ConditionalAccessPolicyState.ENABLED:
continue
if not policy.conditions.application_conditions:
continue
if (
AZURE_DEVOPS_APP_ID
not in policy.conditions.application_conditions.included_applications
):
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "PASS"
report.status_extended = f"Conditional Access Policy {policy.display_name} explicitly targets Azure DevOps."
break
findings.append(report)
return findings
@@ -0,0 +1,42 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_no_exclusion_gaps",
"CheckTitle": "Conditional Access exclusions are covered by another policy (no exclusion gaps)",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Verifies that every object excluded from an enabled Microsoft Entra **Conditional Access** policy (users, groups, roles, or applications) is still included by at least one enabled policy, so the exclusion keeps a compensating control. The Directory Synchronization Accounts role and confirmed emergency access (break glass) accounts are treated as intentional and not reported.",
"Risk": "An object excluded from a Conditional Access policy but never included by any other enabled policy sits completely outside Conditional Access enforcement. This creates a silent **MFA bypass** and **lateral movement** path: a principal exempted as a one-off remains permanently uncontrolled if no compensating policy covers it.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/plan-conditional-access",
"https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to Protection > Conditional Access > Policies in the Microsoft Entra admin center.\n2. For each object reported as an exclusion gap, decide whether the exclusion is still required.\n3. If the exclusion must stay, add the object to the Include scope of another enabled Conditional Access policy that enforces compensating controls (for example MFA).\n4. If the exclusion is no longer required, remove it so the object falls back under the original policy.\n5. Re-run the check to confirm no exclusion gaps remain.",
"Terraform": ""
},
"Recommendation": {
"Text": "Ensure every object excluded from a Conditional Access policy is included by at least one other enabled policy that applies compensating controls. Reserve exclusions for break-glass accounts and the Directory Synchronization Accounts role, and review exclusion lists regularly so that exempted principals never drift outside Conditional Access enforcement.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_no_exclusion_gaps"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [
"entra_conditional_access_policy_directory_sync_account_excluded",
"entra_emergency_access_exclusion"
],
"Notes": "Covers user, group, role, and application exclusions. Platform and location exclusions are out of scope because they are scoping conditions rather than principals removed from enforcement. Service-principal exclusions require additional fields on the ConditionalAccessPolicy service model."
}
@@ -0,0 +1,262 @@
"""Check that Conditional Access exclusions do not create coverage gaps."""
from collections import Counter, defaultdict
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
)
# Directory Synchronization Accounts built-in role template ID. Prowler enforces
# excluding this role (see entra_conditional_access_policy_directory_sync_account_excluded);
# it is intended to have no fallback, so it never counts as a gap here.
DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32"
class entra_conditional_access_policy_no_exclusion_gaps(Check):
"""Check that objects excluded from Conditional Access policies remain covered.
Excluding a principal from a Conditional Access (CA) policy is only safe when
that principal is still covered by *some* enabled CA policy that enforces
compensating controls. An object excluded everywhere and included nowhere
sits completely outside CA enforcement, which is how MFA bypass and lateral
movement against admin accounts happen in real incidents.
For every enabled CA policy this check walks each exclusion collection and
verifies the excluded object is still in scope of another enabled policy: one
that includes it (explicitly, or via the "All" wildcard) and does not itself
exclude it. A wildcard belonging to the policy that excludes the object does
not count, so a one-off exclusion with no compensating policy is reported as
a gap.
Only principals and target apps are evaluated (users, groups, roles,
applications). Platform and location exclusions are scoping conditions rather
than principals removed from enforcement, so they are out of scope.
- PASS: Every excluded object stays in scope of another enabled policy, or no
enabled policy uses any exclusion.
- FAIL: At least one excluded object is in scope of no other enabled policy.
"""
# (label, conditions attribute, included attr, excluded attr, wildcard token).
# The wildcard token, when present in an include collection, scopes a policy
# to every object of that type. Groups and roles have no wildcard: they are
# always explicit identifiers and transitive group/role expansion is out of
# scope for v1, so an excluded group/role is only "covered" when the same
# identifier is explicitly included by another enabled policy.
_COLLECTIONS = [
("users", "user_conditions", "included_users", "excluded_users", "All"),
("groups", "user_conditions", "included_groups", "excluded_groups", None),
("roles", "user_conditions", "included_roles", "excluded_roles", None),
(
"applications",
"application_conditions",
"included_applications",
"excluded_applications",
"All",
),
]
def execute(self) -> list[CheckReportM365]:
"""Execute the Conditional Access exclusion-gap check.
Returns:
list[CheckReportM365]: A single-element list with the aggregate result.
"""
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
enabled_policies = [
policy
for policy in entra_client.conditional_access_policies.values()
if policy.state == ConditionalAccessPolicyState.ENABLED
]
if not enabled_policies:
report.status = "PASS"
report.status_extended = (
"No enabled Conditional Access policies found; "
"no exclusion coverage gaps are possible."
)
return [report]
emergency_users, emergency_groups = self._emergency_access_objects()
# gaps: type label -> set of excluded object IDs with no compensating policy
gaps = defaultdict(set)
any_exclusion = False
for policy in enabled_policies:
for (
label,
conditions_attr,
included_attr,
excluded_attr,
wildcard,
) in self._COLLECTIONS:
conditions = getattr(policy.conditions, conditions_attr)
if not conditions:
continue
for object_id in getattr(conditions, excluded_attr):
any_exclusion = True
if self._is_expected_exclusion(
label, object_id, emergency_users, emergency_groups
):
continue
if not self._is_covered(
object_id,
conditions_attr,
included_attr,
excluded_attr,
wildcard,
enabled_policies,
):
gaps[label].add(object_id)
if not any_exclusion:
report.status = "PASS"
report.status_extended = (
"No enabled Conditional Access policy uses exclusions; "
"no coverage gaps are possible."
)
return [report]
if not gaps:
report.status = "PASS"
report.status_extended = (
"Every object excluded from an enabled Conditional Access policy is "
"still in scope of another enabled policy, so a compensating control "
"remains in effect."
)
return [report]
report.status = "FAIL"
report.status_extended = (
"Conditional Access exclusion gaps found "
f"({self._format_gaps(gaps, self._build_name_index())}). These objects "
"are excluded but in scope of no other enabled policy, leaving them "
"outside CA enforcement."
)
return [report]
def _build_name_index(self) -> dict:
"""Map excluded object IDs to display names per type, for readable findings.
Users, groups, and applications resolve to their display name; roles have
no loaded name catalog, so role template IDs are shown as-is. Unresolved
IDs (for example deleted principals still referenced by a policy) fall
back to the raw identifier.
"""
users = {
uid: user.name
for uid, user in (getattr(entra_client, "users", {}) or {}).items()
if getattr(user, "name", None)
}
groups = {
group.id: group.name
for group in (getattr(entra_client, "groups", []) or [])
if getattr(group, "name", None)
}
applications = {
sp.app_id: sp.name
for sp in (getattr(entra_client, "service_principals", {}) or {}).values()
if getattr(sp, "app_id", None) and getattr(sp, "name", None)
}
return {"users": users, "groups": groups, "applications": applications}
def _is_covered(
self,
object_id,
conditions_attr,
included_attr,
excluded_attr,
wildcard,
enabled_policies,
) -> bool:
"""Return True if any enabled policy keeps ``object_id`` in scope.
A policy keeps the object in scope when it includes it explicitly or via
the type's wildcard token— and does not also exclude it. The wildcard of a
policy that itself excludes the object does not count, which is what makes
a one-off exclusion with no compensating policy a real gap.
"""
for policy in enabled_policies:
conditions = getattr(policy.conditions, conditions_attr)
if not conditions:
continue
if object_id in getattr(conditions, excluded_attr):
continue
included = getattr(conditions, included_attr)
if object_id in included or (wildcard is not None and wildcard in included):
return True
return False
def _emergency_access_objects(self) -> tuple[set, set]:
"""Return user and group IDs that act as emergency access (break-glass).
Objects excluded from *every* enabled (enforced) Conditional Access policy
with a Block grant control are intended, compensating gaps and must not be
reported here. Only ENABLED policies count: report-only policies are not
enforced, so including them would dilute the "excluded everywhere" check
and could hide a genuine break-glass account (consistent with execute()).
"""
blocking_policies = [
policy
for policy in entra_client.conditional_access_policies.values()
if policy.state == ConditionalAccessPolicyState.ENABLED
and ConditionalAccessGrantControl.BLOCK
in policy.grant_controls.built_in_controls
]
if not blocking_policies:
return set(), set()
total = len(blocking_policies)
excluded_users = Counter()
excluded_groups = Counter()
for policy in blocking_policies:
user_conditions = policy.conditions.user_conditions
if not user_conditions:
continue
for user_id in user_conditions.excluded_users:
excluded_users[user_id] += 1
for group_id in user_conditions.excluded_groups:
excluded_groups[group_id] += 1
emergency_users = {uid for uid, n in excluded_users.items() if n == total}
emergency_groups = {gid for gid, n in excluded_groups.items() if n == total}
return emergency_users, emergency_groups
def _is_expected_exclusion(
self, label, object_id, emergency_users, emergency_groups
) -> bool:
"""Exclusions that are intentional by design and must not count as gaps."""
if label == "roles" and object_id == DIRECTORY_SYNC_ROLE_TEMPLATE_ID:
return True
if label == "users" and object_id in emergency_users:
return True
if label == "groups" and object_id in emergency_groups:
return True
return False
def _format_gaps(self, gaps, name_index) -> str:
"""Render the orphaned objects grouped by type, by display name when known.
Each ID is shown as its display name when resolvable; unresolved IDs (and
all roles, which have no name catalog) fall back to the raw identifier.
"""
parts = []
for label in ("users", "groups", "roles", "applications"):
if label not in gaps:
continue
names = name_index.get(label, {})
rendered = sorted(
names.get(object_id, object_id) for object_id in gaps[label]
)
parts.append(f"{label}: {', '.join(rendered)}")
return " | ".join(parts)
@@ -0,0 +1,67 @@
from unittest import mock
from tests.providers.alibabacloud.alibabacloud_fixtures import (
set_mocked_alibabacloud_provider,
)
class TestRamPasswordPolicyNumber:
def test_numbers_not_required_fails(self):
ram_client = mock.MagicMock()
ram_client.audited_account = "1234567890"
ram_client.region = "cn-hangzhou"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_alibabacloud_provider(),
),
mock.patch(
"prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number.ram_client",
new=ram_client,
),
):
from prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number import (
ram_password_policy_number,
)
from prowler.providers.alibabacloud.services.ram.ram_service import (
PasswordPolicy,
)
ram_client.password_policy = PasswordPolicy(require_numbers=False)
check = ram_password_policy_number()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_numbers_required_passes(self):
ram_client = mock.MagicMock()
ram_client.audited_account = "1234567890"
ram_client.region = "cn-hangzhou"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_alibabacloud_provider(),
),
mock.patch(
"prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number.ram_client",
new=ram_client,
),
):
from prowler.providers.alibabacloud.services.ram.ram_password_policy_number.ram_password_policy_number import (
ram_password_policy_number,
)
from prowler.providers.alibabacloud.services.ram.ram_service import (
PasswordPolicy,
)
ram_client.password_policy = PasswordPolicy(require_numbers=True)
check = ram_password_policy_number()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
@@ -0,0 +1,191 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_explicitly_targets_azure_devops.entra_conditional_access_policy_explicitly_targets_azure_devops"
AZURE_DEVOPS_APP_ID = "499b84ac-1321-427f-aa17-267ca6975798"
def _make_session_controls():
"""Return default session controls for test policies."""
return SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
)
def _make_conditions(included_applications=None):
"""Return Conditions with the given included applications."""
return Conditions(
application_conditions=ApplicationsConditions(
included_applications=included_applications or ["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=[],
user_risk_levels=[],
)
def _make_grant_controls():
"""Return default grant controls for test policies."""
return GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
authentication_strength=None,
)
def _make_policy(state, included_applications=None, display_name="Azure DevOps Policy"):
"""Return a ConditionalAccessPolicy for tests."""
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
return ConditionalAccessPolicy(
id=str(uuid4()),
display_name=display_name,
conditions=_make_conditions(included_applications=included_applications),
grant_controls=_make_grant_controls(),
session_controls=_make_session_controls(),
state=state,
)
class Test_entra_conditional_access_policy_explicitly_targets_azure_devops:
def _run_check(self, policies):
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_explicitly_targets_azure_devops.entra_conditional_access_policy_explicitly_targets_azure_devops import (
entra_conditional_access_policy_explicitly_targets_azure_devops,
)
entra_client.conditional_access_policies = policies
check = entra_conditional_access_policy_explicitly_targets_azure_devops()
return check.execute()
def test_no_conditional_access_policies(self):
result = self._run_check({})
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No enabled Conditional Access Policy explicitly targets Azure DevOps."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_enabled_policy_targets_azure_devops(self):
policy = _make_policy(
ConditionalAccessPolicyState.ENABLED,
included_applications=[AZURE_DEVOPS_APP_ID],
)
result = self._run_check({policy.id: policy})
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Conditional Access Policy {policy.display_name} explicitly targets Azure DevOps."
)
assert result[0].resource_name == policy.display_name
assert result[0].resource_id == policy.id
def test_enabled_policy_targets_all_apps_only(self):
policy = _make_policy(
ConditionalAccessPolicyState.ENABLED, included_applications=["All"]
)
result = self._run_check({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No enabled Conditional Access Policy explicitly targets Azure DevOps."
)
def test_disabled_policy_targets_azure_devops(self):
policy = _make_policy(
ConditionalAccessPolicyState.DISABLED,
included_applications=[AZURE_DEVOPS_APP_ID],
)
result = self._run_check({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No enabled Conditional Access Policy explicitly targets Azure DevOps."
)
def test_report_only_policy_targets_azure_devops(self):
policy = _make_policy(
ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
included_applications=[AZURE_DEVOPS_APP_ID],
)
result = self._run_check({policy.id: policy})
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No enabled Conditional Access Policy explicitly targets Azure DevOps."
)
def test_multiple_policies_one_targets_azure_devops(self):
non_matching = _make_policy(
ConditionalAccessPolicyState.ENABLED,
included_applications=["All"],
display_name="All Apps Policy",
)
matching = _make_policy(
ConditionalAccessPolicyState.ENABLED,
included_applications=["some-other-app", AZURE_DEVOPS_APP_ID],
display_name="Dedicated Azure DevOps Policy",
)
result = self._run_check({non_matching.id: non_matching, matching.id: matching})
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == matching.id
assert (
result[0].status_extended
== f"Conditional Access Policy {matching.display_name} explicitly targets Azure DevOps."
)
@@ -0,0 +1,424 @@
import re
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
PlatformConditions,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps"
DIRECTORY_SYNC_ROLE_TEMPLATE_ID = "d29b2b05-8046-44ba-8758-1e26182fcf32"
def _policy(
display_name="Policy",
state=ConditionalAccessPolicyState.ENABLED,
included_users=None,
excluded_users=None,
included_groups=None,
excluded_groups=None,
included_roles=None,
excluded_roles=None,
included_applications=None,
excluded_applications=None,
include_platforms=None,
exclude_platforms=None,
block=False,
) -> ConditionalAccessPolicy:
"""Build a fully-populated ConditionalAccessPolicy for tests.
Args:
display_name: Policy display name.
state: Policy state (default ENABLED).
included_users: Included user IDs, or None.
excluded_users: Excluded user IDs, or None.
included_groups: Included group IDs, or None.
excluded_groups: Excluded group IDs, or None.
included_roles: Included role template IDs, or None.
excluded_roles: Excluded role template IDs, or None.
included_applications: Included application IDs, or None.
excluded_applications: Excluded application IDs, or None.
include_platforms: Included platform names, or None.
exclude_platforms: Excluded platform names, or None.
block: Whether the policy uses a Block grant control (default False).
Returns:
A ConditionalAccessPolicy instance with the specified conditions.
"""
return ConditionalAccessPolicy(
id=str(uuid4()),
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=included_applications or [],
excluded_applications=excluded_applications or [],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=included_groups or [],
excluded_groups=excluded_groups or [],
included_users=included_users or [],
excluded_users=excluded_users or [],
included_roles=included_roles or [],
excluded_roles=excluded_roles or [],
),
platform_conditions=PlatformConditions(
include_platforms=include_platforms or [],
exclude_platforms=exclude_platforms or [],
),
),
grant_controls=GrantControls(
built_in_controls=(
[ConditionalAccessGrantControl.BLOCK]
if block
else [ConditionalAccessGrantControl.MFA]
),
operator=GrantControlOperator.AND,
authentication_strength=None,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=state,
)
def _run(
policies: list[ConditionalAccessPolicy],
users=None,
groups=None,
service_principals=None,
) -> list:
"""Run the check with a mocked entra_client holding the given policies.
Args:
policies: ConditionalAccessPolicy objects to inject into the mocked client.
users: Optional id -> User mapping used to resolve display names.
groups: Optional list of Group objects used to resolve display names.
service_principals: Optional id -> ServicePrincipal mapping for app names.
Returns:
The list of check report objects returned by ``execute()``.
"""
entra_client = mock.MagicMock()
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_exclusion_gaps.entra_conditional_access_policy_no_exclusion_gaps import (
entra_conditional_access_policy_no_exclusion_gaps,
)
entra_client.conditional_access_policies = {p.id: p for p in policies}
entra_client.users = users or {}
entra_client.groups = groups or []
entra_client.service_principals = service_principals or {}
check = entra_conditional_access_policy_no_exclusion_gaps()
return check.execute()
class Test_entra_conditional_access_policy_no_exclusion_gaps:
"""Tests for the Conditional Access exclusion-gap check.
Verifies that objects excluded from enabled Conditional Access policies stay
in scope of another enabled policy (explicitly or via the type's wildcard),
with the directory-sync role and break-glass accounts treated as intended
exclusions.
"""
def test_no_policies(self):
result = _run([])
assert len(result) == 1
assert result[0].status == "PASS"
assert "No enabled Conditional Access policies" in result[0].status_extended
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_only_disabled_policies(self):
result = _run(
[
_policy(
state=ConditionalAccessPolicyState.DISABLED,
included_users=["All"],
excluded_users=["user-1"],
)
]
)
assert result[0].status == "PASS"
assert "No enabled Conditional Access policies" in result[0].status_extended
def test_report_only_policies_out_of_scope(self):
# An exclusion in a report-only policy must not be evaluated.
result = _run(
[
_policy(
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
included_users=["All"],
excluded_users=["orphan-user"],
)
]
)
assert result[0].status == "PASS"
assert "No enabled Conditional Access policies" in result[0].status_extended
def test_no_exclusions_used(self):
result = _run([_policy(included_users=["All"], included_applications=["All"])])
assert result[0].status == "PASS"
assert "no coverage gaps are possible" in result[0].status_extended
def test_exclusion_covered_by_another_policy(self):
# Policy A excludes user-1; Policy B includes user-1 explicitly -> covered.
result = _run(
[
_policy(
display_name="A", included_users=["All"], excluded_users=["user-1"]
),
_policy(display_name="B", included_users=["user-1"]),
]
)
assert result[0].status == "PASS"
assert "compensating control" in result[0].status_extended
def test_user_exclusion_gap(self):
# user-1 is excluded but never included anywhere -> FAIL.
result = _run(
[
_policy(
display_name="A", included_users=["All"], excluded_users=["user-1"]
)
]
)
assert result[0].status == "FAIL"
assert "users: user-1" in result[0].status_extended
def test_gap_reports_display_name_when_resolvable(self):
# A resolvable user shows its display name; an unresolved user (e.g.
# deleted but still referenced) falls back to its raw ID.
from prowler.providers.m365.services.entra.entra_service import User
result = _run(
[
_policy(
display_name="A",
included_users=["All"],
excluded_users=["user-1", "ghost-2"],
)
],
users={
"user-1": User(
id="user-1",
name="Alice Admin",
on_premises_sync_enabled=False,
)
},
)
assert result[0].status == "FAIL"
assert "Alice Admin" in result[0].status_extended
assert "user-1" not in result[0].status_extended
# Unresolved ID still surfaces as the raw identifier.
assert "ghost-2" in result[0].status_extended
def test_group_and_role_gaps_reported_by_type(self):
result = _run(
[
_policy(
display_name="P",
included_users=["All"],
excluded_groups=["group-x"],
excluded_roles=["role-y"],
)
]
)
assert result[0].status == "FAIL"
assert "groups: group-x" in result[0].status_extended
assert "roles: role-y" in result[0].status_extended
def test_application_exclusion_gap(self):
result = _run(
[
_policy(
display_name="AppPolicy",
included_applications=["All"],
excluded_applications=["app-123"],
)
]
)
assert result[0].status == "FAIL"
assert "applications: app-123" in result[0].status_extended
def test_application_exclusion_covered(self):
result = _run(
[
_policy(
display_name="A",
included_applications=["All"],
excluded_applications=["app-123"],
),
_policy(display_name="B", included_applications=["app-123"]),
]
)
assert result[0].status == "PASS"
def test_exclusion_covered_by_all_wildcard_in_another_policy(self):
# Policy A excludes user-1; Policy B targets "All" users and does NOT
# exclude user-1, so user-1 stays in scope of B -> covered -> PASS.
# The "All" wildcard of the policy that excludes the user (A) must not
# count, but the wildcard of an unrelated policy (B) does.
result = _run(
[
_policy(
display_name="A",
included_users=["All"],
excluded_users=["user-1"],
),
_policy(display_name="B", included_users=["All"]),
]
)
assert result[0].status == "PASS"
assert "compensating control" in result[0].status_extended
def test_exclusion_only_wildcard_is_self_excluding_is_gap(self):
# The only "All" users policy is the one that excludes user-1, and no
# other policy covers user-1 -> real gap -> FAIL. This is the case
# #11375's global-union "All" handling would have wrongly passed.
result = _run(
[
_policy(
display_name="A",
included_users=["All"],
excluded_users=["user-1"],
),
_policy(
display_name="B",
included_users=["All"],
excluded_users=["user-1"],
),
]
)
assert result[0].status == "FAIL"
assert "users: user-1" in result[0].status_extended
def test_platform_exclusions_are_ignored(self):
# Platform exclusions are scoping conditions, not principals removed from
# enforcement, so they are out of scope even with no covering policy.
result = _run(
[
_policy(
display_name="MDM",
included_users=["All"],
exclude_platforms=["android", "ios", "macos", "linux"],
)
]
)
assert result[0].status == "PASS"
def test_directory_sync_role_exclusion_skipped(self):
# Dir-sync role excluded with no fallback must NOT be a gap.
result = _run(
[
_policy(
display_name="P",
included_users=["All"],
excluded_roles=[DIRECTORY_SYNC_ROLE_TEMPLATE_ID],
)
]
)
assert result[0].status == "PASS"
assert "compensating control" in result[0].status_extended
def test_emergency_access_user_exclusion_skipped(self):
# A break-glass user excluded from EVERY enabled blocking policy is an
# intended gap and must not be reported.
emergency = "breakglass-user"
result = _run(
[
_policy(
display_name="Block1",
block=True,
included_users=["All"],
excluded_users=[emergency],
),
_policy(
display_name="Block2",
block=True,
included_users=["All"],
excluded_users=[emergency],
),
]
)
assert result[0].status == "PASS"
assert "compensating control" in result[0].status_extended
def test_emergency_access_ignores_report_only_blocking_policy(self):
# A break-glass user excluded from every ENABLED blocking policy is an
# intended gap, even if a report-only (non-enforced) blocking policy that
# does NOT exclude them also exists. Report-only policies must not dilute
# the emergency determination.
emergency = "breakglass-user"
result = _run(
[
_policy(
display_name="Block1",
block=True,
included_users=["All"],
excluded_users=[emergency],
),
_policy(
display_name="Block2",
block=True,
included_users=["All"],
excluded_users=[emergency],
),
_policy(
display_name="ReportOnlyBlock",
block=True,
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
included_users=["All"],
),
]
)
assert result[0].status == "PASS"
assert "compensating control" in result[0].status_extended
def test_mixed_gap_and_covered(self):
# user-1 covered, user-2 orphaned -> FAIL listing only user-2.
result = _run(
[
_policy(
display_name="A",
included_users=["All"],
excluded_users=["user-1", "user-2"],
),
_policy(display_name="B", included_users=["user-1"]),
]
)
assert result[0].status == "FAIL"
assert "user-2" in result[0].status_extended
# user-1 is covered, so it must not appear as a gap (whitespace-robust).
assert not re.search(r"\busers:\s*user-1\b", result[0].status_extended)
+12
View File
@@ -4,12 +4,24 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.32.0] (Prowler UNRELEASED)
### 🚀 Added
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
### 🔄 Changed
- Sentry, Google Tag Manager, and PostHog now load their `UI_*` config only when the matching enable flag (`UI_SENTRY_ENABLE` / `UI_GOOGLE_TAG_MANAGER_ENABLE` / `UI_POSTHOG_ENABLE`) is `"true"` (default off); the deprecated legacy names (`NEXT_PUBLIC_*`, `POSTHOG_KEY`/`POSTHOG_HOST`) still activate without the flag [(#11682)](https://github.com/prowler-cloud/prowler/pull/11682)
---
## [1.31.1] (Prowler v5.31.1)
### 🔄 Changed
- Schedule Scans provider table and launch flows now use provider schedule fields, restore OSS daily scheduling, default to the next local scan hour, and clarify provider selection in launch scan [(#11684)](https://github.com/prowler-cloud/prowler/pull/11684)
---
## [1.31.0] (Prowler v5.31.0)
### 🚀 Added
+3 -3
View File
@@ -2,6 +2,7 @@
import { redirect } from "next/navigation";
import type { FindingsFilterParam } from "@/actions/findings/findings-filters";
import {
apiBaseUrl,
composeSort,
@@ -15,7 +16,6 @@ import {
} from "@/lib";
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { FilterParam } from "@/types/filters";
/**
* Maps filter[search] to filter[check_title__icontains] for finding-groups.
@@ -39,7 +39,7 @@ function mapSearchFilter(
* finding-group resources sub-endpoint. These must be stripped before
* calling the resources API to avoid empty results.
*/
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FilterParam[] = [
const FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS: FindingsFilterParam[] = [
"filter[service__in]",
"filter[scan__in]",
"filter[scan_id]",
@@ -53,7 +53,7 @@ function normalizeFindingGroupResourceFilters(
Object.entries(filters).filter(
([key]) =>
!FINDING_GROUP_RESOURCE_UNSUPPORTED_FILTERS.includes(
key as FilterParam,
key as FindingsFilterParam,
),
),
);
+40
View File
@@ -0,0 +1,40 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/** Findings-only filter fields not shared with other views. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const FINDINGS_EXTRA_FIELD = {
DELTA_IN: "delta__in",
SCAN_EXACT: "scan",
SCAN_ID: "scan_id",
SCAN_ID_IN: "scan_id__in",
INSERTED_AT: "inserted_at",
INSERTED_AT_GTE: "inserted_at__gte",
INSERTED_AT_LTE: "inserted_at__lte",
MUTED: "muted",
} as const;
type FindingsExtraField =
(typeof FINDINGS_EXTRA_FIELD)[keyof typeof FINDINGS_EXTRA_FIELD];
/**
* URL filter param keys the findings view supports, e.g. `filter[severity__in]`.
* Composed from the shared fields it uses plus a few findings-only extras
* (alternate scan/date/delta forms not used by other views).
*/
export type FindingsFilterParam = FilterParam<
// findings uses provider_id, not provider_uid
| (typeof FILTER_FIELD)[
| "PROVIDER_TYPE"
| "PROVIDER_ID"
| "PROVIDER_GROUPS"
| "REGION"
| "SERVICE"
| "SEVERITY"
| "STATUS"
| "DELTA"
| "RESOURCE_TYPE"
| "CATEGORY"
| "RESOURCE_GROUPS"
| "SCAN"]
| FindingsExtraField
>;
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchMock,
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
getErrorMessage: vi.fn(),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: handleApiErrorMock,
handleApiResponse: handleApiResponseMock,
}));
import { getAllProviderGroups } from "./manage-groups";
const makeGroup = (id: string, name: string) => ({
type: "provider-groups" as const,
id,
attributes: { name, inserted_at: "", updated_at: "" },
relationships: {
providers: { meta: { count: 0 }, data: [] },
roles: { meta: { count: 0 }, data: [] },
},
links: { self: "" },
});
const makePage = (
data: ReturnType<typeof makeGroup>[],
page: number,
pages: number,
) => ({
links: { first: "", last: "", next: null, prev: null },
data,
meta: { pagination: { page, pages, count: data.length } },
});
describe("getAllProviderGroups", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
});
it("merges every page into a single response with collapsed pagination", async () => {
handleApiResponseMock
.mockResolvedValueOnce(
makePage(
[makeGroup("g1", "Group 1"), makeGroup("g2", "Group 2")],
1,
2,
),
)
.mockResolvedValueOnce(makePage([makeGroup("g3", "Group 3")], 2, 2));
const result = await getAllProviderGroups();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result?.data.map((group) => group.id)).toEqual(["g1", "g2", "g3"]);
expect(result?.meta.pagination).toMatchObject({
page: 1,
pages: 1,
count: 3,
});
});
it("stops after the first page when there is only one page", async () => {
handleApiResponseMock.mockResolvedValueOnce(
makePage([makeGroup("g1", "Group 1")], 1, 1),
);
const result = await getAllProviderGroups();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result?.data).toHaveLength(1);
});
it("returns undefined when the first page has no data", async () => {
handleApiResponseMock.mockResolvedValueOnce(makePage([], 1, 1));
const result = await getAllProviderGroups();
expect(result).toBeUndefined();
});
it("returns undefined when the request throws", async () => {
fetchMock.mockRejectedValueOnce(new Error("network down"));
const result = await getAllProviderGroups();
expect(result).toBeUndefined();
});
it("returns undefined when a later page resolves to an error payload", async () => {
handleApiResponseMock
.mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2))
.mockResolvedValueOnce({ error: "Forbidden", status: 403 });
const result = await getAllProviderGroups();
expect(result).toBeUndefined();
});
it("returns undefined instead of a truncated list when the max-page cap is hit", async () => {
// Given an API that always reports more pages than the 50-page safety cap
handleApiResponseMock.mockImplementation((response: Response) => {
void response;
return Promise.resolve(makePage([makeGroup("g", "Group")], 1, 9999));
});
// When fetching every page
const result = await getAllProviderGroups();
// Then it must not return a partial/truncated list; bail out instead
expect(result).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(50);
});
});
+81
View File
@@ -51,6 +51,87 @@ export const getProviderGroups = async ({
}
};
/**
* Fetches all provider groups by iterating through every page.
* Used to populate filter dropdowns (e.g. the Provider Group selector) without
* the pagination cap that `getProviderGroups` applies for the management table.
*/
export const getAllProviderGroups = async (): Promise<
ProviderGroupsResponse | undefined
> => {
const pageSize = 100; // Larger page size to minimize API calls
const maxPages = 50; // Safety limit: 50 pages × 100 = 5000 groups max
let currentPage = 1;
const allGroups: ProviderGroupsResponse["data"] = [];
let lastResponse: ProviderGroupsResponse | undefined;
let hasMorePages = true;
try {
const headers = await getAuthHeaders({ contentType: false });
while (hasMorePages && currentPage <= maxPages) {
const url = new URL(`${apiBaseUrl}/provider-groups`);
url.searchParams.append("page[number]", currentPage.toString());
url.searchParams.append("page[size]", pageSize.toString());
const response = await fetch(url.toString(), { headers });
const data = (await handleApiResponse(response)) as
| ProviderGroupsResponse
| { error: string; status?: number }
| undefined;
// A later page resolving to an API error payload must abort rather than
// be treated as "no more pages", which would silently truncate groups.
if (data && "error" in data) {
console.error("Error fetching all provider groups:", data.error);
return undefined;
}
if (!data?.data || data.data.length === 0) {
hasMorePages = false;
continue;
}
allGroups.push(...data.data);
lastResponse = data;
const totalPages = data.meta?.pagination?.pages || 1;
if (currentPage >= totalPages) {
hasMorePages = false;
} else {
currentPage++;
}
}
if (hasMorePages && currentPage > maxPages) {
console.error(
`Error fetching all provider groups: exceeded max page limit (${maxPages})`,
);
return undefined;
}
if (lastResponse) {
return {
...lastResponse,
data: allGroups,
meta: {
...lastResponse.meta,
pagination: {
...lastResponse.meta?.pagination,
page: 1,
pages: 1,
count: allGroups.length,
},
},
};
}
return undefined;
} catch (error) {
console.error("Error fetching all provider groups:", error);
return undefined;
}
};
export const getProviderGroupInfoById = async (providerGroupId: string) => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/provider-groups/${providerGroupId}`);
+16
View File
@@ -0,0 +1,16 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/**
* URL filter param keys the overview dashboard scopes its widgets by. Overview has
* no single action; its widgets read these keys from the URL filters.
*/
export type OverviewFilterParam = FilterParam<
(typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_ID" | "PROVIDER_GROUPS"]
>;
/** The `filter[...]` keys overview widgets read from the URL. */
export const OVERVIEW_FILTER_PARAM = {
PROVIDER_TYPE: `filter[${FILTER_FIELD.PROVIDER_TYPE}]`,
PROVIDER_ID: `filter[${FILTER_FIELD.PROVIDER_ID}]`,
PROVIDER_GROUPS: `filter[${FILTER_FIELD.PROVIDER_GROUPS}]`,
} as const satisfies Record<string, OverviewFilterParam>;
+18
View File
@@ -0,0 +1,18 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
import { PROVIDERS_PAGE_FILTER } from "@/types/providers-table";
/**
* URL filter param keys the providers list supports, e.g. `filter[provider__in]`.
* Provider scope plus its providers-only extras (`provider__in` API param,
* `connected` status).
*/
export type ProvidersFilterParam = FilterParam<
| (typeof FILTER_FIELD)["PROVIDER_TYPE" | "PROVIDER_GROUPS" | "PROVIDER_UID"]
| (typeof PROVIDERS_PAGE_FILTER)["PROVIDER" | "STATUS"]
>;
/** `filter[...]` keys used when mapping the provider-type filter to the API param. */
export const PROVIDERS_FILTER_PARAM = {
PROVIDER: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`,
PROVIDER_TYPE: `filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`,
} as const satisfies Record<string, ProvidersFilterParam>;
+25
View File
@@ -0,0 +1,25 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/** Resources-only filter fields not shared with other views. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const RESOURCES_EXTRA_FIELD = {
TYPE: "type__in",
GROUPS: "groups__in",
} as const;
type ResourcesExtraField =
(typeof RESOURCES_EXTRA_FIELD)[keyof typeof RESOURCES_EXTRA_FIELD];
/**
* URL filter param keys the resources view supports, e.g. `filter[type__in]`.
* The shared core plus its resources-only dimensions (`type__in`, `groups__in`).
*/
export type ResourcesFilterParam = FilterParam<
| (typeof FILTER_FIELD)[
| "PROVIDER_TYPE"
| "PROVIDER_ID"
| "PROVIDER_GROUPS"
| "REGION"
| "SERVICE"]
| ResourcesExtraField
>;
+34
View File
@@ -0,0 +1,34 @@
import { FILTER_FIELD, FilterParam } from "@/types/filters";
/**
* Provider filter fields used to match/clear synthetic pending scan rows the
* `__in` forms (shared with real scan rows) plus the exact forms, and the
* provider-group `__in` form so pending rows honor the group filter too.
*/
export const SCANS_PROVIDER_FILTER_FIELD = {
PROVIDER_IN: FILTER_FIELD.PROVIDER,
PROVIDER: "provider",
PROVIDER_TYPE_IN: FILTER_FIELD.PROVIDER_TYPE,
PROVIDER_TYPE: "provider_type",
PROVIDER_GROUPS_IN: FILTER_FIELD.PROVIDER_GROUPS,
} as const;
/** Scans-only filter fields not shared with other views. */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const SCANS_EXTRA_FIELD = {
STATE: "state__in",
TRIGGER: "trigger",
} as const;
type ScansExtraField =
(typeof SCANS_EXTRA_FIELD)[keyof typeof SCANS_EXTRA_FIELD];
/**
* URL filter param keys the scans view supports, e.g. `filter[state__in]`.
* Provider scope (scans filters accounts by provider id) including provider
* groups and the exact pending-row provider forms, plus the scans-only dimensions.
*/
export type ScansFilterParam = FilterParam<
| (typeof SCANS_PROVIDER_FILTER_FIELD)[keyof typeof SCANS_PROVIDER_FILTER_FIELD]
| ScansExtraField
>;
@@ -2,7 +2,7 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { FilterType } from "@/types/filters";
import { FILTER_FIELD } from "@/types/filters";
import { AccountsSelector } from "./accounts-selector";
@@ -147,9 +147,24 @@ describe("AccountsSelector", () => {
placeholder: "Search Providers...",
emptyMessage: "No Providers found.",
});
expect(screen.getByText("All Providers")).toBeInTheDocument();
expect(screen.getByText("Production AWS")).toBeInTheDocument();
});
it("supports contextual placeholder and empty-selection copy", () => {
render(
<AccountsSelector
providers={providers}
placeholder="Select a Provider"
emptySelectionLabel="No provider selected"
clearSelectionLabel="Clear provider selection"
/>,
);
expect(screen.getByText("Select a Provider")).toBeInTheDocument();
expect(screen.getByText("No provider selected")).toBeInTheDocument();
});
it("allows disabling search explicitly", () => {
render(<AccountsSelector providers={providers} search={false} />);
@@ -174,7 +189,7 @@ describe("AccountsSelector", () => {
render(
<AccountsSelector
providers={providers}
filterKey={FilterType.PROVIDER_UID}
filterKey={FILTER_FIELD.PROVIDER_UID}
/>,
);
@@ -17,7 +17,7 @@ import {
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type AccountFilterKey, FilterType } from "@/types/filters";
import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters";
import {
getProviderDisplayName,
type ProviderProps,
@@ -32,6 +32,9 @@ interface AccountsSelectorBaseProps {
id?: string;
disabledValues?: string[];
closeOnSelect?: boolean;
placeholder?: string;
emptySelectionLabel?: string;
clearSelectionLabel?: string;
}
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
@@ -65,7 +68,7 @@ export function AccountsSelector({
providers,
onBatchChange,
selectedValues,
filterKey = FilterType.PROVIDER_ID,
filterKey = FILTER_FIELD.PROVIDER_ID,
id = "accounts-selector",
disabledValues = [],
search = {
@@ -73,6 +76,9 @@ export function AccountsSelector({
emptyMessage: "No Providers found.",
},
closeOnSelect = false,
placeholder = "All Providers",
emptySelectionLabel = "All selected",
clearSelectionLabel = "Select All",
}: AccountsSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
@@ -85,7 +91,7 @@ export function AccountsSelector({
const visibleProviders = providers;
const getProviderValue = (provider: ProviderProps) =>
filterKey === FilterType.PROVIDER_UID
filterKey === FILTER_FIELD.PROVIDER_UID
? provider.attributes.uid
: provider.id;
const disabledValuesSet = new Set(disabledValues);
@@ -163,7 +169,7 @@ export function AccountsSelector({
onOpenChange={closeOnSelect ? setSelectorOpen : undefined}
>
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
{selectedLabel() || <MultiSelectValue placeholder="All Providers" />}
{selectedLabel() || <MultiSelectValue placeholder={placeholder} />}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{visibleProviders.length > 0 ? (
@@ -187,7 +193,9 @@ export function AccountsSelector({
}
}}
>
{selectedIds.length === 0 ? "All selected" : "Select All"}
{selectedIds.length === 0
? emptySelectionLabel
: clearSelectionLabel}
</div>
{visibleProviders.map((p) => {
const value = getProviderValue(p);
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import { ProviderProps } from "@/types/providers";
import {
filterProvidersByScope,
parseFilterIds,
scopeProvidersByGroup,
} from "./provider-scope";
const makeProvider = (
id: string,
provider: string,
groupIds: string[] = [],
): ProviderProps =>
({
id,
attributes: { provider },
relationships: {
provider_groups: {
data: groupIds.map((gid) => ({ type: "provider-groups", id: gid })),
},
},
}) as unknown as ProviderProps;
describe("parseFilterIds", () => {
it("returns an empty array for undefined", () => {
// Given / When / Then
expect(parseFilterIds(undefined)).toEqual([]);
});
it("returns an empty array for an empty string", () => {
// Given an empty param value (e.g. "filter[provider_groups__in]=")
// When / Then it must not produce a [""] match
expect(parseFilterIds("")).toEqual([]);
});
it("drops whitespace-only and empty segments", () => {
// Given a blank/whitespace value
// When / Then
expect(parseFilterIds(" ")).toEqual([]);
expect(parseFilterIds(",")).toEqual([]);
expect(parseFilterIds("a,,b")).toEqual(["a", "b"]);
});
it("splits and trims comma-separated ids", () => {
expect(parseFilterIds(" a , b ")).toEqual(["a", "b"]);
});
it("normalizes array param values", () => {
expect(parseFilterIds(["a", "", "b"])).toEqual(["a", "b"]);
});
});
describe("scopeProvidersByGroup", () => {
const providers = [
makeProvider("p1", "aws", ["g1"]),
makeProvider("p2", "gcp", ["g2"]),
makeProvider("p3", "azure", []),
];
it("returns every provider when no group is selected", () => {
expect(scopeProvidersByGroup(providers, [])).toEqual(providers);
});
it("keeps only providers that belong to a selected group", () => {
// When scoping to g1
const result = scopeProvidersByGroup(providers, ["g1"]);
// Then only the g1 member remains
expect(result.map((p) => p.id)).toEqual(["p1"]);
});
it("excludes providers with no group memberships", () => {
expect(scopeProvidersByGroup(providers, ["g2"]).map((p) => p.id)).toEqual([
"p2",
]);
});
});
describe("filterProvidersByScope", () => {
const providers = [
makeProvider("p1", "aws", ["g1"]),
makeProvider("p2", "gcp", ["g1"]),
makeProvider("p3", "aws", ["g2"]),
makeProvider("p4", "azure", []),
];
it("returns every provider when no dimension is set", () => {
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: [],
providerGroupIds: [],
});
expect(result).toEqual(providers);
});
it("filters by provider id", () => {
const result = filterProvidersByScope(providers, {
providerIds: ["p2"],
providerTypes: [],
providerGroupIds: [],
});
expect(result.map((p) => p.id)).toEqual(["p2"]);
});
it("filters by provider type case-insensitively", () => {
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: ["AWS"],
providerGroupIds: [],
});
expect(result.map((p) => p.id)).toEqual(["p1", "p3"]);
});
it("filters by provider group", () => {
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: [],
providerGroupIds: ["g1"],
});
expect(result.map((p) => p.id)).toEqual(["p1", "p2"]);
});
it("composes group AND type (the risk-plot regression)", () => {
// Given both a group and a type filter are active
// When combining group g1 with type aws
const result = filterProvidersByScope(providers, {
providerIds: [],
providerTypes: ["aws"],
providerGroupIds: ["g1"],
});
// Then only providers matching BOTH survive (p1), not all aws or all g1
expect(result.map((p) => p.id)).toEqual(["p1"]);
});
it("composes id AND group", () => {
// p3 is aws/g2; selecting it together with group g1 yields nothing
const result = filterProvidersByScope(providers, {
providerIds: ["p3"],
providerTypes: [],
providerGroupIds: ["g1"],
});
expect(result).toEqual([]);
});
it("composes all three dimensions", () => {
const result = filterProvidersByScope(providers, {
providerIds: ["p1", "p2"],
providerTypes: ["aws"],
providerGroupIds: ["g1"],
});
expect(result.map((p) => p.id)).toEqual(["p1"]);
});
});
@@ -0,0 +1,71 @@
import { ProviderProps } from "@/types/providers";
export interface ProviderScopeFilters {
providerIds: string[];
providerTypes: string[];
providerGroupIds: string[];
}
/**
* Normalize a comma-separated filter param into trimmed, non-empty ids.
* Guards against blank values (e.g. an empty "filter[...]=" param) so they are
* treated as "no filter" instead of matching against an empty-string id.
*/
export const parseFilterIds = (
value: string | string[] | undefined,
): string[] => {
if (value === undefined) return [];
const raw = Array.isArray(value) ? value.join(",") : value;
return raw
.split(",")
.map((id) => id.trim())
.filter((id) => id.length > 0);
};
const belongsToGroup = (provider: ProviderProps, groupIds: string[]): boolean =>
provider.relationships.provider_groups?.data?.some((group) =>
groupIds.includes(group.id),
) ?? false;
/**
* Keep only providers belonging to one of the selected groups. An empty group
* list means "no group filter" and returns every provider unchanged.
*/
export const scopeProvidersByGroup = (
providers: ProviderProps[],
groupIds: string[],
): ProviderProps[] =>
groupIds.length === 0
? providers
: providers.filter((p) => belongsToGroup(p, groupIds));
/**
* Filter providers by every active scope dimension (id, type, group) combined
* with AND. Each empty dimension is skipped, so a provider is kept only when it
* satisfies all the filters that are actually set.
*/
export const filterProvidersByScope = (
providers: ProviderProps[],
{ providerIds, providerTypes, providerGroupIds }: ProviderScopeFilters,
): ProviderProps[] => {
const normalizedTypes = providerTypes.map((type) => type.toLowerCase());
return providers.filter((provider) => {
if (providerIds.length > 0 && !providerIds.includes(provider.id)) {
return false;
}
if (
normalizedTypes.length > 0 &&
!normalizedTypes.includes(provider.attributes.provider.toLowerCase())
) {
return false;
}
if (
providerGroupIds.length > 0 &&
!belongsToGroup(provider, providerGroupIds)
) {
return false;
}
return true;
});
};
@@ -3,11 +3,16 @@ import {
getFindingsBySeverity,
SeverityByProviderType,
} from "@/actions/overview";
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
import { getAllProviders } from "@/actions/providers";
import { SankeyChart } from "@/components/graphs/sankey-chart";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import {
parseFilterIds,
scopeProvidersByGroup,
} from "../../_lib/provider-scope";
export async function RiskPipelineViewSSR({
searchParams,
@@ -16,27 +21,31 @@ export async function RiskPipelineViewSSR({
}) {
const filters = pickFilterParams(searchParams);
const providerTypeFilter = filters["filter[provider_type__in]"];
const providerIdFilter = filters["filter[provider_id__in]"];
const providerTypeFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE];
const providerIdFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID];
const providerGroupsFilter = filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS];
// Fetch providers list to know account types
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Scope the provider set to the selected groups so we enumerate only their
// provider types below (the per-type API calls also carry the group filter).
const selectedGroupIds = parseFilterIds(providerGroupsFilter);
const scopedProviders = scopeProvidersByGroup(allProviders, selectedGroupIds);
// Build severityByProviderType based on filters
const severityByProviderType: SeverityByProviderType = {};
let selectedProviderTypes: string[] | undefined;
if (providerIdFilter) {
// Case: Accounts are selected - group by provider type and make parallel calls
const selectedAccountIds = String(providerIdFilter)
.split(",")
.map((id) => id.trim());
const selectedAccountIds = parseFilterIds(providerIdFilter);
// Group selected accounts by provider type
const accountsByType: Record<string, string[]> = {};
for (const accountId of selectedAccountIds) {
const provider = allProviders.find((p) => p.id === accountId);
const provider = scopedProviders.find((p) => p.id === accountId);
if (provider) {
const type = provider.attributes.provider.toLowerCase();
if (!accountsByType[type]) {
@@ -70,9 +79,9 @@ export async function RiskPipelineViewSSR({
}
} else if (providerTypeFilter) {
// Case: Provider types are selected - make parallel calls for each type
selectedProviderTypes = String(providerTypeFilter)
.split(",")
.map((t) => t.trim().toLowerCase());
selectedProviderTypes = parseFilterIds(providerTypeFilter).map((type) =>
type.toLowerCase(),
);
const severityPromises = selectedProviderTypes.map(async (providerType) => {
const response = await getFindingsBySeverity({
@@ -93,9 +102,10 @@ export async function RiskPipelineViewSSR({
}
}
} else {
// Case: No filters - get all provider types and make parallel calls
// Case: No account/type filter - enumerate provider types (scoped to the
// selected groups when a group filter is active) and make parallel calls.
const allProviderTypes = Array.from(
new Set(allProviders.map((p) => p.attributes.provider.toLowerCase())),
new Set(scopedProviders.map((p) => p.attributes.provider.toLowerCase())),
);
const severityPromises = allProviderTypes.map(async (providerType) => {
@@ -1,5 +1,6 @@
import { Info } from "lucide-react";
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
import {
adaptToRiskPlotData,
getProvidersRiskData,
@@ -8,6 +9,10 @@ import { getAllProviders } from "@/actions/providers";
import { SearchParamsProps } from "@/types";
import { pickFilterParams } from "../../_lib/filter-params";
import {
filterProvidersByScope,
parseFilterIds,
} from "../../_lib/provider-scope";
import { RiskPlotClient } from "./risk-plot-client";
export async function RiskPlotSSR({
@@ -17,31 +22,19 @@ export async function RiskPlotSSR({
}) {
const filters = pickFilterParams(searchParams);
const providerTypeFilter = filters["filter[provider_type__in]"];
const providerIdFilter = filters["filter[provider_id__in]"];
// Fetch all providers
const providersListResponse = await getAllProviders();
const allProviders = providersListResponse?.data || [];
// Filter providers based on search params
let filteredProviders = allProviders;
if (providerIdFilter) {
// Filter by specific provider IDs
const selectedIds = String(providerIdFilter)
.split(",")
.map((id) => id.trim());
filteredProviders = allProviders.filter((p) => selectedIds.includes(p.id));
} else if (providerTypeFilter) {
// Filter by provider types
const selectedTypes = String(providerTypeFilter)
.split(",")
.map((t) => t.trim().toLowerCase());
filteredProviders = allProviders.filter((p) =>
selectedTypes.includes(p.attributes.provider.toLowerCase()),
);
}
// Compose every active provider-scope filter with AND so combining e.g. a
// group and a type narrows to providers matching both.
const filteredProviders = filterProvidersByScope(allProviders, {
providerIds: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID]),
providerTypes: parseFilterIds(filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE]),
providerGroupIds: parseFilterIds(
filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS],
),
});
// No providers to show
if (filteredProviders.length === 0) {
@@ -3,6 +3,7 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { OVERVIEW_FILTER_PARAM } from "@/actions/overview/overview-filters";
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { LineChart } from "@/components/graphs/line-chart";
import { LineConfig, LineDataPoint } from "@/components/graphs/types";
@@ -42,10 +43,16 @@ export const FindingSeverityOverTime = ({
const getActiveProviderFilters = (): Record<string, string> => {
const filters: Record<string, string> = {};
const providerType = searchParams.get("filter[provider_type__in]");
const providerId = searchParams.get("filter[provider_id__in]");
if (providerType) filters["filter[provider_type__in]"] = providerType;
if (providerId) filters["filter[provider_id__in]"] = providerId;
const providerType = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_TYPE);
const providerId = searchParams.get(OVERVIEW_FILTER_PARAM.PROVIDER_ID);
const providerGroups = searchParams.get(
OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS,
);
if (providerType)
filters[OVERVIEW_FILTER_PARAM.PROVIDER_TYPE] = providerType;
if (providerId) filters[OVERVIEW_FILTER_PARAM.PROVIDER_ID] = providerId;
if (providerGroups)
filters[OVERVIEW_FILTER_PARAM.PROVIDER_GROUPS] = providerGroups;
return filters;
};
+4 -1
View File
@@ -6,6 +6,7 @@ import {
getLatestFindingGroups,
} from "@/actions/finding-groups";
import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScan, getScans } from "@/actions/scans";
import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components";
@@ -36,8 +37,9 @@ export default async function Findings({
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const [providersData, scansData] = await Promise.all([
const [providersData, providerGroupsData, scansData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
getScans({ pageSize: 50 }),
]);
@@ -99,6 +101,7 @@ export default async function Findings({
<div className="mb-6">
<FindingsFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
completedScanIds={completedScanIds}
scanDetails={scanDetails}
uniqueRegions={uniqueRegions}
+7 -1
View File
@@ -1,7 +1,9 @@
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types";
@@ -38,12 +40,16 @@ export default async function Home({
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const providersData = await getAllProviders();
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
]);
return (
<ContentLayout title="Overview" icon="lucide:square-chart-gantt">
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<ProviderAccountSelectors providers={providersData?.data ?? []} />
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
</div>
<div className="flex flex-col gap-6 xl:flex-row xl:flex-wrap xl:items-stretch">
+1
View File
@@ -118,6 +118,7 @@ const ProvidersTabContent = async ({
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
filters={providersView.filters}
providers={providersView.providers}
providerGroups={providersView.providerGroups}
metadata={providersView.metadata}
rows={providersView.rows}
/>
@@ -18,6 +18,10 @@ const schedulesActionsMock = vi.hoisted(() => ({
getSchedules: vi.fn(),
}));
const manageGroupsActionsMock = vi.hoisted(() => ({
getAllProviderGroups: vi.fn(),
}));
vi.mock("@/actions/providers", () => providersActionsMock);
vi.mock(
"@/actions/organizations/organizations",
@@ -25,6 +29,7 @@ vi.mock(
);
vi.mock("@/actions/scans", () => scansActionsMock);
vi.mock("@/actions/schedules", () => schedulesActionsMock);
vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock);
import { SearchParamsProps } from "@/types";
import { ProvidersApiResponse } from "@/types/providers";
@@ -886,6 +891,103 @@ describe("loadProvidersAccountsViewData", () => {
).toBeUndefined();
});
it("uses provider schedule attributes as authoritative when scan_hour is null", async () => {
// Given — provider-1 still has a materialized scheduled scan row, but the
// provider payload says the schedule was removed.
providersActionsMock.getProviders.mockResolvedValue({
...providersResponse,
data: [
{
...providersResponse.data[0],
attributes: {
...providersResponse.data[0].attributes,
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: null,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
next_scan_at: null,
last_scan_at: null,
},
},
providersResponse.data[1],
],
});
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({
data: [
{
type: "scans",
id: "scan-1",
attributes: { trigger: "scheduled", state: "scheduled" },
relationships: {
provider: { data: { type: "providers", id: "provider-1" } },
},
},
],
});
schedulesActionsMock.getSchedules.mockResolvedValue({
data: [buildSchedule("provider-1", { scan_hour: 9 })],
});
// When
const viewData = await loadProvidersAccountsViewData({
searchParams: {} satisfies SearchParamsProps,
isCloud: false,
});
// Then
const providerRow = findProviderRow(viewData.rows, "provider-1");
expect(providerRow?.hasSchedule).toBe(false);
expect(providerRow?.scheduleSummary).toBeUndefined();
expect(providerRow?.lastScanAt).toBeNull();
});
it("builds provider schedule and last scan values from the provider payload", async () => {
// Given
providersActionsMock.getProviders.mockResolvedValue({
...providersResponse,
data: [
{
...providersResponse.data[0],
attributes: {
...providersResponse.data[0].attributes,
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.MONTHLY,
scan_hour: 8,
scan_timezone: "Europe/Madrid",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: 24,
next_scan_at: "2026-06-24T06:00:00Z",
last_scan_at: "2026-06-23T06:00:00Z",
},
},
providersResponse.data[1],
],
});
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({ data: [] });
schedulesActionsMock.getSchedules.mockResolvedValue({ error: "Not found" });
// When
const viewData = await loadProvidersAccountsViewData({
searchParams: {} satisfies SearchParamsProps,
isCloud: false,
});
// Then
const providerRow = findProviderRow(viewData.rows, "provider-1");
expect(providerRow?.hasSchedule).toBe(true);
expect(providerRow?.scheduleSummary?.cadence).toBe("Monthly on the 24th");
expect(providerRow?.scheduleSummary?.nextScanAt).toBe(
"2026-06-24T06:00:00Z",
);
expect(providerRow?.lastScanAt).toBe("2026-06-23T06:00:00Z");
});
it("ignores paused or unconfigured schedules", async () => {
// Given — provider-1 paused (disabled), provider-2 never configured.
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
@@ -913,8 +1015,10 @@ describe("loadProvidersAccountsViewData", () => {
);
});
it("falls back to scan-based detection when /schedules is unavailable (OSS)", async () => {
// Given — /schedules errors, but provider-1 has a materialized scheduled scan.
it("does not infer provider schedules from materialized scans when /schedules is unavailable", async () => {
// Given — /schedules errors, and provider-1 still has a materialized
// scheduled scan. That scan is historical execution state, not schedule
// configuration.
providersActionsMock.getProviders.mockResolvedValue(providersResponse);
providersActionsMock.getAllProviders.mockResolvedValue(providersResponse);
scansActionsMock.getScans.mockResolvedValue({
@@ -937,9 +1041,9 @@ describe("loadProvidersAccountsViewData", () => {
isCloud: false,
});
// Then — scan-based path still flags the provider; no throw from the error.
// Then — only provider scan_* fields or /schedules can mark a schedule.
expect(findProviderRow(viewData.rows, "provider-1")?.hasSchedule).toBe(
true,
false,
);
expect(findProviderRow(viewData.rows, "provider-2")?.hasSchedule).toBe(
false,
@@ -1,9 +1,10 @@
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import {
listOrganizationsSafe,
listOrganizationUnitsSafe,
} from "@/actions/organizations/organizations";
import { getAllProviders, getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters";
import { getSchedules } from "@/actions/schedules";
import {
extractFiltersAndQuery,
@@ -11,6 +12,7 @@ import {
} from "@/lib/helper-filters";
import {
buildProviderScheduleSummary,
buildScheduleAttributesFromProvider,
buildSchedulesByProviderId,
isScheduleConfigured,
} from "@/lib/schedules";
@@ -33,7 +35,7 @@ import {
ProvidersTableRow,
ProvidersTableRowsInput,
} from "@/types/providers-table";
import { SCAN_TRIGGER, ScanProps, ScanScheduleSummary } from "@/types/scans";
import { ScanScheduleSummary } from "@/types/scans";
import { ScheduleAttributes } from "@/types/schedules";
const PROVIDERS_STATUS_MAPPING = [
@@ -114,26 +116,6 @@ const createProviderGroupLookup = (
return lookup;
};
const ACTIVE_SCAN_STATES = new Set(["scheduled", "available", "executing"]);
const buildScheduledProviderIds = (scans: ScanProps[]): Set<string> => {
const scheduled = new Set<string>();
for (const scan of scans) {
if (
scan.attributes.trigger === SCAN_TRIGGER.SCHEDULED &&
ACTIVE_SCAN_STATES.has(scan.attributes.state)
) {
const providerId = scan.relationships.provider?.data?.id;
if (providerId) {
scheduled.add(providerId);
}
}
}
return scheduled;
};
// A schedule is backed by the Provider row itself, so its `/schedules` entry
// exists before the first scheduled Scan is materialized — only enabled,
// configured ones carry a displayable cadence summary.
@@ -145,17 +127,33 @@ const buildProviderScheduleSummaryFor = (
? buildProviderScheduleSummary(attributes, now)
: undefined;
const getProviderLastScanAt = (
provider: ProvidersApiResponse["data"][number],
): string | null => {
if (
Object.prototype.hasOwnProperty.call(provider.attributes, "last_scan_at")
) {
return provider.attributes.last_scan_at ?? null;
}
return provider.attributes.connection.last_checked_at ?? null;
};
const enrichProviders = (
providersResponse: ProvidersApiResponse | undefined,
scanScheduledProviderIds: Set<string>,
schedulesByProviderId: Record<string, ScheduleAttributes>,
): ProvidersProviderRow[] => {
const providerGroupLookup = createProviderGroupLookup(providersResponse);
const now = new Date();
return (providersResponse?.data ?? []).map((provider) => {
const providerScheduleAttributes = buildScheduleAttributesFromProvider(
provider.attributes,
);
const scheduleAttributes =
providerScheduleAttributes ?? schedulesByProviderId[provider.id];
const scheduleSummary = buildProviderScheduleSummaryFor(
schedulesByProviderId[provider.id],
scheduleAttributes,
now,
);
@@ -167,11 +165,11 @@ const enrichProviders = (
(providerGroup: { id: string }) =>
providerGroupLookup.get(providerGroup.id) ?? "Unknown Group",
) ?? [],
// A fired scheduled scan OR a configured schedule that hasn't fired yet.
hasSchedule:
scanScheduledProviderIds.has(provider.id) ||
scheduleSummary !== undefined,
// Provider scan_* fields are authoritative when present; otherwise we
// only fall back to the /schedules resource, never materialized scans.
hasSchedule: scheduleSummary !== undefined,
scheduleSummary,
lastScanAt: getProviderLastScanAt(provider),
};
});
};
@@ -488,13 +486,12 @@ export async function loadProvidersAccountsViewData({
// Map provider_type__in (used by ProviderTypeSelector) to provider__in (API param)
const providerTypeFilter =
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
if (providerTypeFilter) {
providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER}]`] =
providerTypeFilter;
providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER] = providerTypeFilter;
}
delete providerFilters[`filter[${PROVIDERS_PAGE_FILTER.PROVIDER_TYPE}]`];
delete providerFilters[PROVIDERS_FILTER_PARAM.PROVIDER_TYPE];
const emptyOrganizationsResponse: OrganizationListResponse = {
data: [],
@@ -506,7 +503,7 @@ export async function loadProvidersAccountsViewData({
const [
providersResponse,
allProvidersResponse,
scansResponse,
allProviderGroupsResponse,
schedulesResponse,
organizationsResponse,
organizationUnitsResponse,
@@ -523,18 +520,10 @@ export async function loadProvidersAccountsViewData({
// Unfiltered fetch for ProviderTypeSelector — only needs distinct types;
// TODO: Replace with a dedicated lightweight endpoint when available.
resolveActionResult(getAllProviders()),
// Fetch active scheduled scans to flag providers whose schedule has fired.
resolveActionResult(
getScans({
pageSize: 500,
filters: {
"filter[trigger]": SCAN_TRIGGER.SCHEDULED,
"filter[state__in]": "scheduled,available",
},
}),
),
// Fetch configured schedules to also flag providers whose schedule has not
// fired yet (best-effort: absent in OSS, where the helper yields no ids).
// Unfiltered fetch for the Provider Group selector dropdown.
resolveActionResult(getAllProviderGroups()),
// Fetch configured schedules as a fallback when provider scan_* fields are
// absent (best-effort: typically empty in OSS).
resolveActionResult(getSchedules()),
isCloud
? listOrganizationsSafe()
@@ -544,18 +533,11 @@ export async function loadProvidersAccountsViewData({
: Promise.resolve(emptyOrganizationUnitsResponse),
]);
const scanScheduledProviderIds = buildScheduledProviderIds(
scansResponse?.data ?? [],
);
const schedulesByProviderId = buildSchedulesByProviderId(schedulesResponse);
const orgs = organizationsResponse?.data ?? [];
const ous = organizationUnitsResponse?.data ?? [];
const providers = enrichProviders(
providersResponse,
scanScheduledProviderIds,
schedulesByProviderId,
);
const providers = enrichProviders(providersResponse, schedulesByProviderId);
const rows = buildProvidersTableRows({
isCloud,
@@ -568,6 +550,7 @@ export async function loadProvidersAccountsViewData({
filters: createProvidersFilters(),
metadata: providersResponse?.meta,
providers: allProvidersResponse?.data ?? [],
providerGroups: allProviderGroupsResponse?.data ?? [],
rows,
};
}
+19 -13
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import {
getLatestMetadataInfo,
@@ -37,19 +38,23 @@ export default async function Resources({
const initialResourceId = resolvedSearchParams.resourceId?.toString();
const [metadataInfoData, providersData, resourceByIdData] = await Promise.all(
[
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
query,
filters: outputFilters,
sort: encodedSort,
}),
getAllProviders(),
initialResourceId
? getResourceById(initialResourceId, { include: ["provider"] })
: Promise.resolve(undefined),
],
);
const [
metadataInfoData,
providersData,
providerGroupsData,
resourceByIdData,
] = await Promise.all([
(hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo)({
query,
filters: outputFilters,
sort: encodedSort,
}),
getAllProviders(),
getAllProviderGroups(),
initialResourceId
? getResourceById(initialResourceId, { include: ["provider"] })
: Promise.resolve(undefined),
]);
const processedResource = resourceByIdData?.data
? (() => {
@@ -80,6 +85,7 @@ export default async function Resources({
<div className="mb-6">
<ResourcesFilters
providers={providersData?.data || []}
providerGroups={providerGroupsData?.data || []}
uniqueRegions={uniqueRegions}
uniqueServices={uniqueServices}
uniqueResourceTypes={uniqueResourceTypes}
+33 -24
View File
@@ -1,8 +1,13 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { getAllProviderGroups } from "@/actions/manage-groups/manage-groups";
import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import {
SCANS_PROVIDER_FILTER_FIELD,
type ScansFilterParam,
} from "@/actions/scans/scans-filters";
import { getSchedules, getSchedulesPage } from "@/actions/schedules";
import { auth } from "@/auth.config";
import { PageReady } from "@/components/onboarding";
@@ -28,7 +33,6 @@ import {
} from "@/lib/schedules";
import { isCloud } from "@/lib/shared/env";
import {
FilterType,
ProviderProps,
SCAN_JOBS_TAB,
SCAN_TRIGGER,
@@ -41,29 +45,22 @@ import {
} from "@/types/schedules";
const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1;
// Pending schedule rows must honor the same provider filters as real scan rows.
// The `__in` keys reuse the shared FilterType; the singular variants have no
// FilterType equivalent, so they stay as literals.
const PENDING_ROW_PROVIDER_FILTER = {
PROVIDER_IN: FilterType.PROVIDER,
PROVIDER: "provider",
PROVIDER_TYPE_IN: FilterType.PROVIDER_TYPE,
PROVIDER_TYPE: "provider_type",
} as const;
type PendingRowProviderFilter =
(typeof PENDING_ROW_PROVIDER_FILTER)[keyof typeof PENDING_ROW_PROVIDER_FILTER];
type PendingRowProviderFilterParam = `filter[${PendingRowProviderFilter}]`;
// Pending schedule rows are derived from provider schedules, but must honor the
// same provider filters as real scan rows. The filter keys live with the scans
// action (SCANS_PROVIDER_FILTER_FIELD) so they stay in sync with ScansFilterParam.
const PROVIDER_ID_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_IN}]`,
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER}]`,
] as const satisfies ReadonlyArray<ScansFilterParam>;
const PROVIDER_TYPE_FILTER_KEYS = [
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE_IN}]`,
`filter[${PENDING_ROW_PROVIDER_FILTER.PROVIDER_TYPE}]`,
] as const satisfies ReadonlyArray<PendingRowProviderFilterParam>;
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE_IN}]`,
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_TYPE}]`,
] as const satisfies ReadonlyArray<ScansFilterParam>;
const PROVIDER_GROUP_FILTER_KEYS = [
`filter[${SCANS_PROVIDER_FILTER_FIELD.PROVIDER_GROUPS_IN}]`,
] as const satisfies ReadonlyArray<ScansFilterParam>;
const getFilterSearchQuery = (
filters: Record<string, string | string[]>,
@@ -86,7 +83,7 @@ const parseCsvParam = (value?: string | string[]): string[] => {
const getFirstSearchParam = (
searchParams: SearchParamsProps,
keys: ReadonlyArray<PendingRowProviderFilterParam>,
keys: ReadonlyArray<ScansFilterParam>,
): string | string[] | undefined => {
for (const key of keys) {
const value = searchParams[key];
@@ -107,11 +104,18 @@ const filterProvidersForPendingRows = (
const types = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_TYPE_FILTER_KEYS),
);
const groups = parseCsvParam(
getFirstSearchParam(searchParams, PROVIDER_GROUP_FILTER_KEYS),
);
return providers.filter(
(provider) =>
(ids.length === 0 || ids.includes(provider.id)) &&
(types.length === 0 || types.includes(provider.attributes.provider)),
(types.length === 0 || types.includes(provider.attributes.provider)) &&
(groups.length === 0 ||
(provider.relationships?.provider_groups?.data ?? []).some((group) =>
groups.includes(group.id),
)),
);
};
@@ -177,8 +181,12 @@ export default async function Scans({
const session = await auth();
const resolvedSearchParams = await searchParams;
const providersData = await getAllProviders();
const [providersData, providerGroupsData] = await Promise.all([
getAllProviders(),
getAllProviderGroups(),
]);
const providers = providersData?.data ?? [];
const providerGroups = providerGroupsData?.data ?? [];
const connectedProviders = providers.filter(
(provider: ProviderProps) =>
@@ -229,6 +237,7 @@ export default async function Scans({
) : (
<ScansPageShell
providers={providers}
providerGroups={providerGroups}
hasManageScansPermission={hasManageScansPermission}
activeScanCount={activeScanCount}
>
+4 -4
View File
@@ -1,5 +1,5 @@
import { CONNECTION_STATUS_MAPPING } from "@/lib/helper-filters";
import { FilterOption, FilterType } from "@/types/filters";
import { FILTER_FIELD, FilterOption } from "@/types/filters";
import {
PROVIDER_DISPLAY_NAMES,
PROVIDER_TYPES,
@@ -64,19 +64,19 @@ export const filterScans = [
//Static filters for findings
export const filterFindings = [
{
key: FilterType.SEVERITY,
key: FILTER_FIELD.SEVERITY,
labelCheckboxGroup: "Severity",
values: ["critical", "high", "medium", "low", "informational"],
index: 0,
},
{
key: FilterType.STATUS,
key: FILTER_FIELD.STATUS,
labelCheckboxGroup: "Status",
values: ["PASS", "FAIL", "MANUAL"],
index: 1,
},
{
key: FilterType.DELTA,
key: FILTER_FIELD.DELTA,
labelCheckboxGroup: "Delta",
values: ["new", "changed"],
index: 2,
@@ -1,7 +1,7 @@
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FilterType } from "@/types/filters";
import { FILTER_FIELD } from "@/types/filters";
import type { ProviderProps } from "@/types/providers";
import { ProviderAccountSelectors } from "./provider-account-selectors";
@@ -171,7 +171,7 @@ describe("ProviderAccountSelectors", () => {
render(
<ProviderAccountSelectors
providers={providers}
accountFilterKey={FilterType.PROVIDER_UID}
accountFilterKey={FILTER_FIELD.PROVIDER_UID}
accountValue="uid"
paramsToDeleteOnChange={["page", "scanId"]}
/>,
@@ -230,7 +230,7 @@ describe("ProviderAccountSelectors", () => {
<ProviderAccountSelectors
providers={providers}
mode="batch"
accountFilterKey={FilterType.PROVIDER_UID}
accountFilterKey={FILTER_FIELD.PROVIDER_UID}
accountValue="uid"
selectedProviderTypes={["aws"]}
selectedAccounts={["123456789012", "prowler-project"]}
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type AccountFilterKey, FilterType } from "@/types/filters";
import { type AccountFilterKey, FILTER_FIELD } from "@/types/filters";
import type { ProviderProps } from "@/types/providers";
const ACCOUNT_VALUE = {
@@ -91,7 +91,7 @@ const getCompatibleAccounts = ({
export function ProviderAccountSelectors({
providers,
accountFilterKey = FilterType.PROVIDER_ID,
accountFilterKey = FILTER_FIELD.PROVIDER_ID,
accountValue = ACCOUNT_VALUE.ID,
providerSelectorClassName,
accountSelectorClassName,
@@ -0,0 +1,236 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderGroup } from "@/types/components";
import { ProviderGroupSelector } from "./provider-group-selector";
const multiSelectContentSpy = vi.fn();
const { navigateWithParamsMock } = vi.hoisted(() => ({
navigateWithParamsMock: vi.fn(),
}));
let currentSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useSearchParams: () => currentSearchParams,
}));
vi.mock("@/hooks/use-url-filters", () => ({
useUrlFilters: () => ({
navigateWithParams: navigateWithParamsMock,
}),
}));
vi.mock("@/components/shadcn/select/multiselect", () => ({
MultiSelect: ({
children,
onValuesChange,
}: {
children: React.ReactNode;
onValuesChange: (values: string[]) => void;
}) => (
<div>
<button
data-testid="mock-select-group-2"
onClick={() => onValuesChange(["group-2"])}
/>
{children}
</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
MultiSelectContent: ({
children,
search,
}: {
children: React.ReactNode;
search?: unknown;
}) => {
multiSelectContentSpy(search);
return <div>{children}</div>;
},
MultiSelectItem: ({
children,
value,
keywords,
}: {
children: React.ReactNode;
value: string;
keywords?: string[];
}) => (
<div data-value={value} data-keywords={keywords?.join("|")}>
{children}
</div>
),
}));
const makeGroup = (id: string, name: string): ProviderGroup => ({
type: "provider-groups",
id,
attributes: { name, inserted_at: "", updated_at: "" },
relationships: {
providers: { meta: { count: 0 }, data: [] },
roles: { meta: { count: 0 }, data: [] },
},
links: { self: "" },
});
const groups = [
makeGroup("group-1", "Production"),
makeGroup("group-2", "Dev"),
];
describe("ProviderGroupSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
currentSearchParams = new URLSearchParams();
});
it("stays visible with the placeholder and empty message when there are no provider groups", () => {
render(<ProviderGroupSelector groups={[]} />);
// Control is still rendered (visible even with zero groups)...
expect(screen.getByText("All Provider Groups")).toBeInTheDocument();
// ...and the single empty state is the MultiSelect's own emptyMessage,
// not a duplicate custom message.
expect(multiSelectContentSpy).toHaveBeenCalledWith({
placeholder: "Search Provider Groups...",
emptyMessage: "No Provider Groups found.",
});
expect(
screen.queryByText("No Provider Groups available"),
).not.toBeInTheDocument();
});
it("passes searchable dropdown defaults to MultiSelectContent and lists groups", () => {
render(<ProviderGroupSelector groups={groups} />);
expect(multiSelectContentSpy).toHaveBeenCalledWith({
placeholder: "Search Provider Groups...",
emptyMessage: "No Provider Groups found.",
});
expect(screen.getByText("Production")).toBeInTheDocument();
expect(screen.getByText("Dev")).toBeInTheDocument();
});
it("allows disabling search explicitly", () => {
render(<ProviderGroupSelector groups={groups} search={false} />);
expect(multiSelectContentSpy).toHaveBeenLastCalledWith(false);
});
it("passes the group name as a search keyword", () => {
render(<ProviderGroupSelector groups={groups} />);
expect(
screen.getByText("Production").closest("[data-value]"),
).toHaveAttribute("data-keywords", expect.stringContaining("Production"));
});
it("disables select all when nothing is selected", () => {
render(<ProviderGroupSelector groups={groups} />);
expect(
screen.getByRole("option", { name: /select all Provider Groups/i }),
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
it("shows the selected count in the trigger when multiple groups are selected", () => {
render(
<ProviderGroupSelector
groups={groups}
onBatchChange={vi.fn()}
selectedValues={["group-1", "group-2"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(
within(trigger).getByText("2 Provider Groups selected"),
).toBeInTheDocument();
});
it("shows the single group name in the trigger when one group is selected", () => {
render(
<ProviderGroupSelector
groups={groups}
onBatchChange={vi.fn()}
selectedValues={["group-1"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(within(trigger).getByText("Production")).toBeInTheDocument();
});
it("instant mode: writes the selection to filter[provider_groups__in] in the URL", () => {
render(<ProviderGroupSelector groups={groups} />);
fireEvent.click(screen.getByTestId("mock-select-group-2"));
expect(navigateWithParamsMock).toHaveBeenCalledTimes(1);
const params = new URLSearchParams();
navigateWithParamsMock.mock.calls[0][0](params);
expect(params.get("filter[provider_groups__in]")).toBe("group-2");
});
it("instant mode: clearing deletes the filter key and the extra paramsToDeleteOnChange keys", () => {
currentSearchParams = new URLSearchParams(
"filter[provider_groups__in]=group-1&page=3&scanId=abc",
);
render(
<ProviderGroupSelector
groups={groups}
paramsToDeleteOnChange={["page", "scanId"]}
/>,
);
fireEvent.click(
screen.getByRole("option", { name: /select all Provider Groups/i }),
);
expect(navigateWithParamsMock).toHaveBeenCalledTimes(1);
const params = new URLSearchParams(
"filter[provider_groups__in]=group-1&page=3&scanId=abc",
);
navigateWithParamsMock.mock.calls[0][0](params);
expect(params.has("filter[provider_groups__in]")).toBe(false);
expect(params.has("page")).toBe(false);
expect(params.has("scanId")).toBe(false);
});
it("defaults the control id and links the sr-only label to it", () => {
render(<ProviderGroupSelector groups={groups} />);
const label = screen.getByText(/Filter by Provider Group/i);
expect(label).toHaveAttribute("for", "provider-group-selector");
expect(label).toHaveAttribute("id", "provider-group-selector-label");
});
it("applies a custom id so multiple instances don't collide", () => {
render(
<ProviderGroupSelector groups={groups} id="resources-provider-group" />,
);
const label = screen.getByText(/Filter by Provider Group/i);
expect(label).toHaveAttribute("for", "resources-provider-group");
expect(label).toHaveAttribute("id", "resources-provider-group-label");
});
it("does not navigate on clear when nothing is selected", () => {
render(<ProviderGroupSelector groups={groups} />);
fireEvent.click(
screen.getByRole("option", { name: /select all Provider Groups/i }),
);
expect(navigateWithParamsMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,171 @@
"use client";
import { useSearchParams } from "next/navigation";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
type MultiSelectSearchProp,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderGroup } from "@/types/components";
import { FILTER_FIELD } from "@/types/filters";
const PROVIDER_GROUP_FILTER_KEY = FILTER_FIELD.PROVIDER_GROUPS;
const URL_FILTER_KEY = `filter[${PROVIDER_GROUP_FILTER_KEY}]`;
/** Common props shared by both batch and instant modes. */
interface ProviderGroupSelectorBaseProps {
groups: ProviderGroup[];
search?: MultiSelectSearchProp;
/** DOM id for the control; pass a unique one when rendering more than one. */
id?: string;
/**
* Instant mode only: extra URL params to delete when the selection changes
* (e.g. ["page", "scanId"]), mirroring ProviderAccountSelectors. Ignored in
* batch mode, where the parent owns URL updates.
*/
paramsToDeleteOnChange?: string[];
}
/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
interface ProviderGroupSelectorBatchProps
extends ProviderGroupSelectorBaseProps {
/**
* 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_groups__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 ProviderGroupSelectorInstantProps
extends ProviderGroupSelectorBaseProps {
onBatchChange?: never;
selectedValues?: never;
}
type ProviderGroupSelectorProps =
| ProviderGroupSelectorBatchProps
| ProviderGroupSelectorInstantProps;
export function ProviderGroupSelector({
groups,
onBatchChange,
selectedValues,
id = "provider-group-selector",
search = {
placeholder: "Search Provider Groups...",
emptyMessage: "No Provider Groups found.",
},
paramsToDeleteOnChange = [],
}: ProviderGroupSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();
const labelId = `${id}-label`;
const current = searchParams.get(URL_FILTER_KEY) || "";
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 handleMultiValueChange = (ids: string[]) => {
if (onBatchChange) {
onBatchChange(PROVIDER_GROUP_FILTER_KEY, ids);
return;
}
navigateWithParams((params) => {
if (ids.length > 0) {
params.set(URL_FILTER_KEY, ids.join(","));
} else {
params.delete(URL_FILTER_KEY);
}
paramsToDeleteOnChange.forEach((key) => params.delete(key));
});
};
const selectedLabel = () => {
if (selectedIds.length === 0) return null;
if (selectedIds.length === 1) {
const group = groups.find((g) => g.id === selectedIds[0]);
return (
<span className="truncate">
{group ? group.attributes.name : selectedIds[0]}
</span>
);
}
return (
<span className="truncate">
{selectedIds.length} Provider Groups selected
</span>
);
};
return (
<div className="relative">
<label htmlFor={id} className="sr-only" id={labelId}>
Filter by Provider Group. Select one or more Provider Groups to filter
results.
</label>
<MultiSelect values={selectedIds} onValuesChange={handleMultiValueChange}>
<MultiSelectTrigger id={id} aria-labelledby={labelId}>
{selectedLabel() || (
<MultiSelectValue placeholder="All Provider Groups" />
)}
</MultiSelectTrigger>
<MultiSelectContent search={search}>
{/* No items when empty: the MultiSelect's own emptyMessage is the
single empty state (avoids a duplicate "none" message). */}
{groups.length > 0 && (
<>
<div
role="option"
aria-selected={selectedIds.length === 0}
aria-disabled={selectedIds.length === 0}
aria-label="Select all Provider Groups (clears current selection to show all)"
tabIndex={0}
className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50"
onClick={() => {
if (selectedIds.length === 0) return;
handleMultiValueChange([]);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (selectedIds.length === 0) return;
handleMultiValueChange([]);
}
}}
>
{selectedIds.length === 0 ? "All selected" : "Select All"}
</div>
{groups.map((group) => (
<MultiSelectItem
key={group.id}
value={group.id}
badgeLabel={group.attributes.name}
keywords={[group.attributes.name]}
aria-label={`${group.attributes.name} Provider Group`}
>
<span className="truncate">{group.attributes.name}</span>
</MultiSelectItem>
))}
</>
)}
</MultiSelectContent>
</MultiSelect>
</div>
);
}
+39 -18
View File
@@ -14,12 +14,14 @@ import {
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { FilterType, ScanEntity } from "@/types";
import { FILTER_FIELD, ScanEntity } from "@/types";
import { ProviderGroup } from "@/types/components";
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
@@ -31,6 +33,8 @@ import {
interface FindingsFiltersProps {
/** Provider data for provider/account filter controls. */
providers: ProviderProps[];
/** Provider groups for the provider group filter control. */
providerGroups?: ProviderGroup[];
completedScanIds: string[];
scanDetails: { [key: string]: ScanEntity }[];
uniqueRegions: string[];
@@ -70,6 +74,10 @@ const FILTER_GRID_ITEM_CLASS = "min-w-0";
export const FindingsFilterBatchControls = ({
providers,
// Undefined = caller opted out (the alert editor shares this component but
// loads no groups); an empty array still renders the control, so it stays
// visible even when a tenant has no groups yet.
providerGroups,
completedScanIds,
scanDetails,
uniqueRegions,
@@ -97,7 +105,7 @@ export const FindingsFilterBatchControls = ({
const customFilters = [
...filterFindings
.filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS)
.filter((filter) => !isAlertsEdit || filter.key !== FILTER_FIELD.STATUS)
.map((filter) => ({
...filter,
labelFormatter: (value: string) =>
@@ -107,32 +115,32 @@ export const FindingsFilterBatchControls = ({
}),
})),
{
key: FilterType.REGION,
key: FILTER_FIELD.REGION,
labelCheckboxGroup: "Regions",
values: uniqueRegions,
index: 3,
},
{
key: FilterType.SERVICE,
key: FILTER_FIELD.SERVICE,
labelCheckboxGroup: "Services",
values: uniqueServices,
index: 4,
},
{
key: FilterType.RESOURCE_TYPE,
key: FILTER_FIELD.RESOURCE_TYPE,
labelCheckboxGroup: "Resource Type",
values: uniqueResourceTypes,
index: 8,
},
{
key: FilterType.CATEGORY,
key: FILTER_FIELD.CATEGORY,
labelCheckboxGroup: "Category",
values: uniqueCategories,
labelFormatter: getCategoryLabel,
index: 5,
},
{
key: FilterType.RESOURCE_GROUPS,
key: FILTER_FIELD.RESOURCE_GROUPS,
labelCheckboxGroup: "Resource Group",
values: uniqueGroups,
labelFormatter: getGroupLabel,
@@ -142,14 +150,14 @@ export const FindingsFilterBatchControls = ({
? []
: [
{
key: FilterType.SCAN,
key: FILTER_FIELD.SCAN,
labelCheckboxGroup: "Scan ID",
values: completedScanIds,
width: "wide" as const,
valueLabelMapping: scanDetails,
labelFormatter: (value: string) =>
getFindingsFilterDisplayValue(
`filter[${FilterType.SCAN}]`,
`filter[${FILTER_FIELD.SCAN}]`,
value,
{
providers,
@@ -167,6 +175,7 @@ export const FindingsFilterBatchControls = ({
appliedFilters,
{
providers,
providerGroups,
scans: scanDetails,
},
);
@@ -174,6 +183,7 @@ export const FindingsFilterBatchControls = ({
changedFilters,
{
providers,
providerGroups,
scans: scanDetails,
},
);
@@ -199,15 +209,26 @@ export const FindingsFilterBatchControls = ({
: undefined;
const providerAccountControls = (className: string) => (
<ProviderAccountSelectors
providers={providers}
mode="batch"
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
selectedAccounts={getFilterValue("filter[provider_id__in]")}
onBatchChange={setPending}
providerSelectorClassName={className}
accountSelectorClassName={className}
/>
<>
<ProviderAccountSelectors
providers={providers}
mode="batch"
selectedProviderTypes={getFilterValue("filter[provider_type__in]")}
selectedAccounts={getFilterValue("filter[provider_id__in]")}
onBatchChange={setPending}
providerSelectorClassName={className}
accountSelectorClassName={className}
/>
{providerGroups !== undefined && (
<div className={className}>
<ProviderGroupSelector
groups={providerGroups}
selectedValues={getFilterValue("filter[provider_groups__in]")}
onBatchChange={setPending}
/>
</div>
)}
</>
);
const alertEditFilterGrid = hasCustomFilters ? (
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { ProviderGroup } from "@/types/components";
import { ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
@@ -8,6 +9,19 @@ import {
getFindingsFilterDisplayValue,
} from "./findings-filters.utils";
const providerGroups: ProviderGroup[] = [
{
type: "provider-groups",
id: "group-1",
attributes: { name: "Production", inserted_at: "", updated_at: "" },
relationships: {
providers: { meta: { count: 0 }, data: [] },
roles: { meta: { count: 0 }, data: [] },
},
links: { self: "" },
},
];
function makeProvider(
overrides: Partial<ProviderProps> & { id: string },
): ProviderProps {
@@ -98,6 +112,24 @@ describe("getFindingsFilterDisplayValue", () => {
).toBe("missing-provider");
});
it("shows the provider group name for provider_groups filters instead of the raw group id", () => {
expect(
getFindingsFilterDisplayValue("filter[provider_groups__in]", "group-1", {
providerGroups,
}),
).toBe("Production");
});
it("keeps the raw value when the provider group cannot be resolved", () => {
expect(
getFindingsFilterDisplayValue(
"filter[provider_groups__in]",
"missing-group",
{ providerGroups },
),
).toBe("missing-group");
});
it("shows the resolved scan badge label for scan filters instead of formatting the raw scan id", () => {
expect(
getFindingsFilterDisplayValue("filter[scan__in]", "scan-1", { scans }),
@@ -230,6 +262,22 @@ describe("buildFindingsFilterChips", () => {
]);
});
it("labels provider group chips and resolves their names", () => {
const chips = buildFindingsFilterChips(
{ "filter[provider_groups__in]": ["group-1"] },
{ providerGroups },
);
expect(chips).toEqual([
{
key: "filter[provider_groups__in]",
label: "Provider Group",
value: "group-1",
displayValue: "Production",
},
]);
});
it("treats filter[delta] and filter[delta__in] identically", () => {
// Given
const chipsSingular = buildFindingsFilterChips({
@@ -1,8 +1,12 @@
import type { FindingsFilterParam } from "@/actions/findings/findings-filters";
import type { FilterChip } from "@/components/filters/filter-summary-strip";
import { formatLabel, getCategoryLabel, getGroupLabel } from "@/lib/categories";
import { getScanEntityLabel } from "@/lib/helper-filters";
import {
getProviderGroupDisplayValue,
getScanEntityLabel,
} from "@/lib/helper-filters";
import { FINDING_STATUS_DISPLAY_NAMES } from "@/types";
import { FilterParam } from "@/types/filters";
import { ProviderGroup } from "@/types/components";
import { getProviderDisplayName, ProviderProps } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
@@ -10,6 +14,7 @@ import { SEVERITY_DISPLAY_NAMES } from "@/types/severities";
interface GetFindingsFilterDisplayValueOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
}
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
@@ -42,7 +47,7 @@ function getScanDisplayValue(
}
export function getFindingsFilterDisplayValue(
filterKey: string,
filterKey: FindingsFilterParam,
value: string,
options: GetFindingsFilterDisplayValueOptions = {},
): string {
@@ -53,6 +58,9 @@ export function getFindingsFilterDisplayValue(
if (filterKey === "filter[provider_id__in]") {
return getProviderAccountDisplayValue(value, options.providers || []);
}
if (filterKey === "filter[provider_groups__in]") {
return getProviderGroupDisplayValue(value, options.providerGroups || []);
}
if (filterKey === "filter[scan__in]" || filterKey === "filter[scan]") {
return getScanDisplayValue(value, options.scans || []);
}
@@ -95,12 +103,14 @@ export function getFindingsFilterDisplayValue(
/**
* 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.
* Typed as Record<FindingsFilterParam, string> so TypeScript enforces exhaustiveness
* any addition to the findings filter set will cause a compile error here if the
* label is missing.
*/
export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
export const FILTER_KEY_LABELS: Record<FindingsFilterParam, string> = {
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[provider_groups__in]": "Provider Group",
"filter[severity__in]": "Severity",
"filter[status__in]": "Status",
"filter[delta__in]": "Delta",
@@ -115,12 +125,15 @@ export const FILTER_KEY_LABELS: Record<FilterParam, string> = {
"filter[scan_id]": "Scan",
"filter[scan_id__in]": "Scan",
"filter[inserted_at]": "Date",
"filter[inserted_at__gte]": "Date",
"filter[inserted_at__lte]": "Date",
"filter[muted]": "Muted",
};
interface BuildFindingsFilterChipsOptions {
providers?: ProviderProps[];
scans?: Array<{ [scanId: string]: ScanEntity }>;
providerGroups?: ProviderGroup[];
includeMuted?: boolean;
}
@@ -142,13 +155,13 @@ export function buildFindingsFilterChips(
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
if (key === "filter[muted]" && !options.includeMuted) return;
const label = FILTER_KEY_LABELS[key as FilterParam] ?? key;
const label = FILTER_KEY_LABELS[key as FindingsFilterParam] ?? key;
const visibleValues = values;
if (visibleValues.length === 0) return;
const displayValues = visibleValues.map((value) =>
getFindingsFilterDisplayValue(key, value, options),
getFindingsFilterDisplayValue(key as FindingsFilterParam, value, options),
);
const chip: FilterChip = {
+3 -8
View File
@@ -5,13 +5,12 @@ import { formatLocalTimeWithZone } from "@/lib/date-utils";
import type { ScanScheduleSummary } from "@/types/scans";
interface LinkToScansProps {
hasSchedule: boolean;
schedule?: ScanScheduleSummary;
}
// Matches the scans table Schedule column: cadence on top, next-run local time
// underneath. Falls back to a plain label when the cadence is unknown.
export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => {
// underneath. Falls back to None when no configured schedule is present.
export const LinkToScans = ({ schedule }: LinkToScansProps) => {
if (schedule) {
return (
<StackedCell
@@ -21,9 +20,5 @@ export const LinkToScans = ({ hasSchedule, schedule }: LinkToScansProps) => {
);
}
return (
<span className="text-text-neutral-secondary text-sm">
{hasSchedule ? "Daily" : "None"}
</span>
);
return <span className="text-text-neutral-secondary text-sm">None</span>;
};
@@ -429,7 +429,7 @@ describe("OrgLaunchScan", () => {
});
// Then
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
expect(updateSchedulesBulkMock).not.toHaveBeenCalled();
expect(launchOrganizationScansMock).not.toHaveBeenCalled();
});
@@ -363,8 +363,16 @@ export function OrgLaunchScan({
{isBlocked ? (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
You have exceeded the usage limit of one provider. You can add
more providers and run unlimited scans by adding a subscription.{" "}
<Link
href="https://cloud.prowler.com/billing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Manage Billing
</Link>
</p>
) : isAdvanced ? (
<ScanScheduleFields
@@ -28,6 +28,7 @@ import {
getTourTargetSelector,
} from "@/lib/tours/use-driver-tour";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProvidersTableRow } from "@/types/providers-table";
import type { ScanScheduleCapability } from "@/types/schedules";
@@ -51,6 +52,7 @@ interface ProvidersAccountsViewProps {
filters: FilterOption[];
metadata?: MetaDataProps;
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
rows: ProvidersTableRow[];
/** Cloud overlay seam for provider-creation scan launch. */
scanScheduleCapability?: ScanScheduleCapability;
@@ -62,6 +64,7 @@ export function ProvidersAccountsView({
filters,
metadata,
providers,
providerGroups = [],
rows,
scanScheduleCapability,
isScanLimitReached,
@@ -141,6 +144,7 @@ export function ProvidersAccountsView({
<ProvidersFilters
filters={filters}
providers={providers}
providerGroups={providerGroups}
actions={
<>
<MutedFindingsConfigButton />
@@ -16,6 +16,10 @@ vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({
ProviderTypeSelector: () => <div>Provider type selector</div>,
}));
vi.mock("@/components/filters/provider-group-selector", () => ({
ProviderGroupSelector: () => <div>Provider group selector</div>,
}));
vi.mock("@/components/filters/clear-filters-button", () => ({
ClearFiltersButton: () => <button type="button">Clear</button>,
}));
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import {
MultiSelect,
MultiSelectContent,
@@ -18,6 +19,7 @@ import { EntityInfo } from "@/components/ui/entities/entity-info";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { isConnectionStatus, isGroupFilterEntity } from "@/lib/helper-filters";
import { FilterEntity, FilterOption, ProviderEntity } from "@/types";
import { ProviderGroup } from "@/types/components";
import {
GroupFilterEntity,
ProviderConnectionStatus,
@@ -31,12 +33,14 @@ function isNonEmptyString(value: string | null | undefined): value is string {
interface ProvidersFiltersProps {
filters: FilterOption[];
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
actions?: ReactNode;
}
export const ProvidersFilters = ({
filters,
providers,
providerGroups = [],
actions,
}: ProvidersFiltersProps) => {
const { updateFilter } = useUrlFilters();
@@ -153,6 +157,9 @@ export const ProvidersFilters = ({
<div className="min-w-[200px] flex-1 md:max-w-[280px]">
<ProviderTypeSelector providers={providers} />
</div>
<div className="max-w-[240px] min-w-[180px] flex-1">
<ProviderGroupSelector groups={providerGroups} />
</div>
{sortedFilters.map((filter) => {
const selectedValues = getSelectedValues(filter);
return (
@@ -0,0 +1,131 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import {
PROVIDERS_ROW_TYPE,
type ProvidersProviderRow,
type ProvidersTableRow,
} from "@/types/providers-table";
import { getColumnProviders } from "./column-providers";
vi.mock("@/components/shadcn", () => ({
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}));
vi.mock("@/components/shadcn/checkbox/checkbox", () => ({
Checkbox: () => null,
}));
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
CodeSnippet: ({ value }: { value: string }) => <code>{value}</code>,
}));
vi.mock("@/components/ui/entities", () => ({
DateWithTime: ({ dateTime }: { dateTime: string | null }) => (
<time>{dateTime}</time>
),
EntityInfo: ({
entityAlias,
entityId,
}: {
entityAlias?: string;
entityId?: string;
}) => <span>{entityAlias ?? entityId}</span>,
}));
vi.mock("@/components/ui/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
vi.mock("@/components/ui/table/data-table-expand-all-toggle", () => ({
DataTableExpandAllToggle: () => null,
}));
vi.mock("@/components/ui/table/data-table-expandable-cell", () => ({
DataTableExpandableCell: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../link-to-scans", () => ({
LinkToScans: () => null,
}));
vi.mock("./data-table-row-actions", () => ({
DataTableRowActions: () => null,
}));
const providerRow: ProvidersProviderRow = {
id: "provider-1",
rowType: PROVIDERS_ROW_TYPE.PROVIDER,
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production",
status: "completed",
resources: 0,
connection: {
connected: true,
last_checked_at: "2026-01-01T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: { data: { id: "secret-1", type: "secrets" } },
provider_groups: { meta: { count: 0 }, data: [] },
},
groupNames: [],
hasSchedule: false,
};
function renderLastScanCell(row: ProvidersTableRow) {
const lastScanColumn = getColumnProviders(
{},
[],
[],
[],
vi.fn(),
vi.fn(),
vi.fn(),
).find((column) => column.id === "lastScan");
const cell = lastScanColumn?.cell;
if (typeof cell !== "function") {
throw new Error("Last Scan column cell renderer not found");
}
const element = cell({
row: { original: row },
} as unknown as Parameters<typeof cell>[0]);
render(<>{element as ReactNode}</>);
}
describe("getColumnProviders", () => {
it("falls back to connection last_checked_at when lastScanAt is undefined", () => {
renderLastScanCell({ ...providerRow, lastScanAt: undefined });
expect(screen.getByText("2026-01-01T00:00:00Z")).toBeVisible();
expect(screen.queryByText("Never")).not.toBeInTheDocument();
});
it("treats a null lastScanAt as authoritative", () => {
renderLastScanCell({ ...providerRow, lastScanAt: null });
expect(screen.getByText("Never")).toBeVisible();
expect(screen.queryByText("2026-01-01T00:00:00Z")).not.toBeInTheDocument();
});
});
@@ -227,22 +227,25 @@ export function getColumnProviders(
return <span className="text-text-neutral-tertiary text-sm">-</span>;
}
const lastCheckedAt = (row.original as ProvidersProviderRow).attributes
.connection.last_checked_at;
const provider = row.original as ProvidersProviderRow;
const lastScanAt =
provider.lastScanAt !== undefined
? provider.lastScanAt
: provider.attributes.connection.last_checked_at;
if (!lastCheckedAt) {
if (!lastScanAt) {
return (
<span className="text-text-neutral-tertiary text-sm">Never</span>
);
}
return <DateWithTime dateTime={lastCheckedAt} showTime />;
return <DateWithTime dateTime={lastScanAt} showTime />;
},
enableSorting: false,
},
{
id: "scanSchedule",
size: 140,
size: 180,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Scan Schedule" />
),
@@ -255,12 +258,7 @@ export function getColumnProviders(
);
}
return (
<LinkToScans
hasSchedule={row.original.hasSchedule}
schedule={row.original.scheduleSummary}
/>
);
return <LinkToScans schedule={row.original.scheduleSummary} />;
},
enableSorting: false,
},
@@ -79,7 +79,7 @@ describe("LaunchStep", () => {
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
it("defaults to run now and locks schedule mode outside Cloud", async () => {
it("defaults to daily schedule mode and locks advanced cadence outside Cloud", async () => {
// Given
const onFooterChange = vi.fn();
seedConnectedProvider();
@@ -94,19 +94,20 @@ describe("LaunchStep", () => {
// Then
expect(screen.getByText("Account Connected!")).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Run now" })).toBeChecked();
expect(
screen.getByRole("radio", { name: "On a schedule" }),
).toBeDisabled();
).toBeChecked();
expect(screen.getByRole("radio", { name: "Run now" })).not.toBeChecked();
expect(
screen.queryByRole("combobox", { name: /repeats/i }),
).not.toBeInTheDocument();
screen.getByRole("radio", { name: "On a schedule" }),
).toBeEnabled();
expect(screen.getByRole("combobox", { name: /repeats/i })).toBeDisabled();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Launch scan");
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe("Save");
});
it("launches only an on-demand scan and never creates a legacy daily schedule", async () => {
it("saves a legacy daily schedule by default", async () => {
// Given
const onClose = vi.fn();
const onFooterChange = vi.fn();
@@ -127,9 +128,43 @@ describe("LaunchStep", () => {
});
// Then
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
const sentFormData = scanOnDemandMock.mock.calls[0]?.[0] as FormData;
await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1));
const sentFormData = scheduleDailyMock.mock.calls[0]?.[0] as FormData;
expect(sentFormData.get("providerId")).toBe("provider-1");
expect(scanOnDemandMock).not.toHaveBeenCalled();
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("launches only an on-demand scan when run now is selected", async () => {
// Given
const user = userEvent.setup();
const onClose = vi.fn();
const onFooterChange = vi.fn();
seedConnectedProvider();
render(
<LaunchStep
onBack={vi.fn()}
onClose={onClose}
onFooterChange={onFooterChange}
/>,
);
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
// When
await user.click(screen.getByRole("radio", { name: "Run now" }));
await waitFor(() =>
expect(lastFooterConfig(onFooterChange)?.actionLabel).toBe(
"Launch scan",
),
);
await act(async () => {
lastFooterConfig(onFooterChange)?.onAction?.();
});
// Then
await waitFor(() => expect(scanOnDemandMock).toHaveBeenCalledTimes(1));
expect(scheduleDailyMock).not.toHaveBeenCalled();
expect(updateScheduleMock).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
@@ -438,7 +473,7 @@ describe("LaunchStep", () => {
);
// Then
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
await waitFor(() => expect(onFooterChange).toHaveBeenCalled());
expect(lastFooterConfig(onFooterChange)?.actionDisabled).toBe(true);
});
@@ -93,17 +93,19 @@ export function LaunchStep({
const capability = capabilityProp ?? getScanScheduleCapability(isCloud());
const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY;
const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED;
const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY;
const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED;
const canUseScheduleMode = isAdvanced || isDailyLegacy;
const [isLaunching, setIsLaunching] = useState(false);
const [mode, setMode] = useState<LaunchMode>(
isAdvanced ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
canUseScheduleMode ? LAUNCH_MODE.SCHEDULE : LAUNCH_MODE.NOW,
);
const form = useForm<ScheduleFormValues>({
resolver: zodResolver(scheduleFormSchema),
defaultValues: getScheduleFormDefaults(),
});
const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE;
const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE;
const isLimitBlocked = mode === LAUNCH_MODE.NOW && isScanLimitReached;
const isActionBlocked =
isLaunching ||
@@ -129,10 +131,10 @@ export function LaunchStep({
})();
useEffect(() => {
if (!isAdvanced && mode !== LAUNCH_MODE.NOW) {
if (!canUseScheduleMode && mode !== LAUNCH_MODE.NOW) {
setMode(LAUNCH_MODE.NOW);
}
}, [isAdvanced, mode]);
}, [canUseScheduleMode, mode]);
const launchOnDemandScan = async (): Promise<ActionErrorResult | null> => {
if (!providerId || isBlocked) return null;
@@ -327,10 +329,10 @@ export function LaunchStep({
<RadioGroupItem
value={LAUNCH_MODE.SCHEDULE}
aria-label="On a schedule"
disabled={!isAdvanced}
disabled={!canUseScheduleMode}
/>
On a schedule
{!isAdvanced &&
{!canUseScheduleMode &&
!isBlocked &&
(isManualOnly ? (
<CloudFeatureBadge label="Requires subscription" size="sm" />
@@ -341,7 +343,7 @@ export function LaunchStep({
</RadioGroup>
</Field>
{!isAdvanced && !isBlocked && (
{isManualOnly && !isBlocked && (
<p className="text-text-neutral-secondary text-sm">
Scheduled scans are not available for this account. Run now to get
immediate findings.
@@ -350,8 +352,16 @@ export function LaunchStep({
{(isLimitBlocked || isBlocked) && (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
You have exceeded the usage limit of one provider. You can add more
providers and run unlimited scans by adding a subscription.{" "}
<Link
href="https://cloud.prowler.com/billing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Manage Billing
</Link>
</p>
)}
@@ -361,6 +371,8 @@ export function LaunchStep({
disabled={isLaunching || !providerId}
showLaunchInitialScan
showNextScheduledCopy
canUseAdvancedSchedule={isAdvanced}
showCloudUpgradeBadge={isDailyLegacy}
/>
)}
</div>
@@ -11,11 +11,13 @@ import {
FilterSummaryStrip,
} from "@/components/filters/filter-summary-strip";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { Button } from "@/components/shadcn";
import { ExpandableSection } from "@/components/ui/expandable-section";
import { DataTableFilterCustom } from "@/components/ui/table";
import { useFilterBatch } from "@/hooks/use-filter-batch";
import { getGroupLabel } from "@/lib/categories";
import { ProviderGroup } from "@/types/components";
import { DATA_TABLE_FILTER_MODE } from "@/types/filters";
import { ProviderProps } from "@/types/providers";
@@ -26,6 +28,7 @@ import {
interface ResourcesFiltersProps {
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
uniqueRegions: string[];
uniqueServices: string[];
uniqueResourceTypes: string[];
@@ -40,6 +43,7 @@ const FILTER_CONTROL_COLUMN_CLASS =
export const ResourcesFilters = ({
providers,
providerGroups = [],
uniqueRegions,
uniqueServices,
uniqueResourceTypes,
@@ -93,10 +97,12 @@ export const ResourcesFilters = ({
const appliedFilterChips: FilterChip[] = buildResourcesFilterChips(
appliedFilters,
providers,
providerGroups,
);
const pendingFilterChips: FilterChip[] = buildResourcesFilterChips(
changedFilters,
providers,
providerGroups,
);
const appliedCount = countVisibleFilterKeys(appliedFilters);
const showAppliedRow = appliedFilterChips.length > 0;
@@ -178,6 +184,13 @@ export const ResourcesFilters = ({
providerSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
accountSelectorClassName={FILTER_CONTROL_COLUMN_CLASS}
/>
<div className={FILTER_CONTROL_COLUMN_CLASS}>
<ProviderGroupSelector
groups={providerGroups}
selectedValues={getFilterValue("filter[provider_groups__in]")}
onBatchChange={setPending}
/>
</div>
{hasCustomFilters && (
<Button
variant="outline"
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import {
buildResourcesFilterChips,
getResourcesFilterDisplayValue,
} from "./resources-filters.utils";
const providerGroups: ProviderGroup[] = [
{
type: "provider-groups",
id: "group-1",
attributes: { name: "Production", inserted_at: "", updated_at: "" },
relationships: {
providers: { meta: { count: 0 }, data: [] },
roles: { meta: { count: 0 }, data: [] },
},
links: { self: "" },
},
];
const providers: ProviderProps[] = [];
describe("getResourcesFilterDisplayValue", () => {
it("shows the provider group name for provider_groups filters", () => {
expect(
getResourcesFilterDisplayValue(
"filter[provider_groups__in]",
"group-1",
providers,
providerGroups,
),
).toBe("Production");
});
it("keeps the raw value when the provider group cannot be resolved", () => {
expect(
getResourcesFilterDisplayValue(
"filter[provider_groups__in]",
"missing-group",
providers,
providerGroups,
),
).toBe("missing-group");
});
});
describe("buildResourcesFilterChips", () => {
it("labels provider group chips and resolves their names", () => {
const chips = buildResourcesFilterChips(
{ "filter[provider_groups__in]": ["group-1"] },
providers,
providerGroups,
);
expect(chips).toEqual([
{
key: "filter[provider_groups__in]",
label: "Provider Group",
value: "group-1",
displayValue: "Production",
},
]);
});
});
@@ -1,11 +1,15 @@
import type { ResourcesFilterParam } from "@/actions/resources/resources-filters";
import type { FilterChip } from "@/components/filters/filter-summary-strip";
import { formatLabel, getGroupLabel } from "@/lib/categories";
import { getProviderGroupDisplayValue } from "@/lib/helper-filters";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import { getProviderDisplayName } from "@/types/providers";
const RESOURCE_FILTER_KEY_LABELS: Record<string, string> = {
const RESOURCE_FILTER_KEY_LABELS: Record<ResourcesFilterParam, string> = {
"filter[provider_type__in]": "Provider",
"filter[provider_id__in]": "Account",
"filter[provider_groups__in]": "Provider Group",
"filter[region__in]": "Region",
"filter[service__in]": "Service",
"filter[type__in]": "Type",
@@ -28,6 +32,7 @@ export function getResourcesFilterDisplayValue(
filterKey: string,
value: string,
providers: ProviderProps[],
providerGroups: ProviderGroup[] = [],
): string {
if (!value) return value;
@@ -39,6 +44,10 @@ export function getResourcesFilterDisplayValue(
return getProviderAccountDisplayValue(value, providers);
}
if (filterKey === "filter[provider_groups__in]") {
return getProviderGroupDisplayValue(value, providerGroups);
}
if (filterKey === "filter[groups__in]") {
return getGroupLabel(value);
}
@@ -53,15 +62,17 @@ export function getResourcesFilterDisplayValue(
export function buildResourcesFilterChips(
pendingFilters: Record<string, string[]>,
providers: ProviderProps[],
providerGroups: ProviderGroup[] = [],
): FilterChip[] {
const chips: FilterChip[] = [];
Object.entries(pendingFilters).forEach(([key, values]) => {
if (!values || values.length === 0) return;
const label = RESOURCE_FILTER_KEY_LABELS[key] ?? key;
const label =
RESOURCE_FILTER_KEY_LABELS[key as ResourcesFilterParam] ?? key;
const displayValues = values.map((value) =>
getResourcesFilterDisplayValue(key, value, providers),
getResourcesFilterDisplayValue(key, value, providers, providerGroups),
);
const chip: FilterChip = {
+90 -4
View File
@@ -1,7 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
getScheduleMock,
@@ -91,12 +91,14 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
onBatchChange,
selectedValues,
id,
placeholder = "All Providers",
}: {
disabledValues?: string[];
providers: { id: string; attributes: { alias: string; uid: string } }[];
onBatchChange: (filterKey: string, values: string[]) => void;
selectedValues: string[];
id?: string;
placeholder?: string;
}) => (
<div>
<input aria-label="Search Providers" placeholder="Search Providers..." />
@@ -108,7 +110,7 @@ vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({
onBatchChange("provider_id__in", [event.target.value])
}
>
<option value="">All Providers</option>
<option value="">{placeholder}</option>
{providers.map((provider) => (
<option
key={provider.id}
@@ -191,6 +193,10 @@ describe("LaunchScanModal", () => {
scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } });
});
afterEach(() => {
vi.useRealTimers();
});
it("shows a searchable provider selector", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
@@ -199,6 +205,16 @@ describe("LaunchScanModal", () => {
expect(screen.getByPlaceholderText("Search Providers...")).toBeVisible();
});
it("uses a single-provider placeholder in the launch selector", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
expect(
screen.getByRole("option", { name: "Select a Provider" }),
).toBeInTheDocument();
});
it("disables disconnected providers in the launch selector", () => {
render(
<LaunchScanModal
@@ -438,6 +454,64 @@ describe("LaunchScanModal", () => {
);
});
it("keeps the upcoming local hour when provider scan fields say there is no schedule", async () => {
// Given
const user = userEvent.setup();
vi.setSystemTime(new Date(2026, 5, 10, 11, 59, 0, 0));
getScheduleMock.mockResolvedValue({
data: {
type: "schedules",
id: provider.id,
attributes: {
scan_enabled: true,
scan_frequency: "DAILY",
scan_hour: 0,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
},
},
});
const providerWithoutSchedule = {
...provider,
attributes: {
...provider.attributes,
scan_enabled: true,
scan_frequency: "DAILY" as const,
scan_hour: null,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
next_scan_at: null,
last_scan_at: null,
},
};
render(
<LaunchScanModal
open
onOpenChange={vi.fn()}
providers={[providerWithoutSchedule]}
capability={SCAN_SCHEDULE_CAPABILITY.ADVANCED}
/>,
);
// When
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
// Then
expect(
await screen.findByRole("combobox", { name: "Scan Time" }),
).toHaveTextContent("12:00pm");
expect(
screen.getByRole("combobox", { name: "Scan Time" }),
).not.toHaveTextContent("12:00am");
expect(getScheduleMock).not.toHaveBeenCalled();
});
it("launches the initial scan when the checkbox is checked", async () => {
const user = userEvent.setup();
renderAdvanced();
@@ -460,6 +534,18 @@ describe("LaunchScanModal", () => {
);
});
it("disables Save Schedule until a provider is selected", async () => {
const user = userEvent.setup();
renderAdvanced();
await user.click(screen.getByRole("radio", { name: "On a schedule" }));
expect(
screen.getByRole("button", { name: /save schedule/i }),
).toBeDisabled();
expect(getScheduleMock).not.toHaveBeenCalled();
});
it("locks schedule mode outside ADVANCED (OSS default)", () => {
render(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
@@ -505,7 +591,7 @@ describe("LaunchScanModal", () => {
);
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /launch scan/i }),
).toBeDisabled();
@@ -524,7 +610,7 @@ describe("LaunchScanModal", () => {
);
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
expect(screen.getByText(/reached your scan limit/i)).toBeInTheDocument();
expect(screen.getByText(/exceeded the usage limit/i)).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText("Providers"), provider.id);
await user.click(screen.getByRole("button", { name: /launch scan/i }));
+34 -3
View File
@@ -22,6 +22,7 @@ import { FormButtons } from "@/components/ui/form";
import { toast, ToastAction } from "@/components/ui/toast";
import { getActionErrorMessage, hasActionError } from "@/lib/action-errors";
import {
buildScheduleAttributesFromProvider,
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
@@ -123,6 +124,14 @@ function LaunchScanForm({
.filter((provider) => provider.attributes.connection.connected !== true)
.map((provider) => provider.id);
const getProviderScheduleAttributes = (id: string) => {
const selectedProvider = providers.find((provider) => provider.id === id);
return selectedProvider
? buildScheduleAttributesFromProvider(selectedProvider.attributes)
: undefined;
};
const loadSchedule = async (id: string) => {
requestedProviderRef.current = id;
if (!id) {
@@ -130,6 +139,13 @@ function LaunchScanForm({
return;
}
const providerScheduleAttributes = getProviderScheduleAttributes(id);
if (providerScheduleAttributes) {
scheduleForm.reset(getScheduleFormValues(providerScheduleAttributes));
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADED);
return;
}
setScheduleLoad(SCHEDULE_LOAD_STATE.LOADING);
const response = (await getSchedule(id)) as
| ScheduleApiResponse
@@ -279,6 +295,9 @@ function LaunchScanForm({
}
selectedValues={providerId ? [providerId] : []}
closeOnSelect
placeholder="Select a Provider"
emptySelectionLabel="No provider selected"
clearSelectionLabel="Clear provider selection"
/>
{providerError && <FieldError>{providerError}</FieldError>}
</Field>
@@ -311,8 +330,16 @@ function LaunchScanForm({
{isBlocked && (
<p className="text-text-error-primary text-sm">
You have reached your scan limit, so additional scans are not
available right now.
You have exceeded the usage limit of one provider. You can add more
providers and run unlimited scans by adding a subscription.{" "}
<Link
href="https://cloud.prowler.com/billing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Manage Billing
</Link>
</p>
)}
@@ -365,7 +392,11 @@ function LaunchScanForm({
}
loadingText={isScheduleMode ? "Saving..." : "Launching..."}
isDisabled={
isSubmitting || !providers.length || isScheduleLoading || isBlocked
isSubmitting ||
!providers.length ||
isScheduleLoading ||
isBlocked ||
(isScheduleMode && !providerId)
}
rightIcon={<Rocket className="size-4" />}
/>
@@ -9,6 +9,10 @@ vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
}));
vi.mock("@/components/filters/provider-group-selector", () => ({
ProviderGroupSelector: () => <div>Provider group selector</div>,
}));
vi.mock("@/components/shadcn", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
+13 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import {
Select,
SelectContent,
@@ -9,7 +10,8 @@ import {
SelectValue,
} from "@/components/shadcn";
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import { FilterType } from "@/types/filters";
import type { ProviderGroup } from "@/types/components";
import { FILTER_FIELD } from "@/types/filters";
import type { ProviderProps } from "@/types/providers";
import {
@@ -19,6 +21,7 @@ import {
interface ScansFilterBarProps {
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
activeTab: ScanJobsTab;
scheduleType: string;
scanStatus: string;
@@ -31,6 +34,7 @@ const filterItemClass = "w-full md:w-[calc(50%-0.375rem)] xl:w-60";
export function ScansFilterBar({
providers,
providerGroups = [],
activeTab,
scheduleType,
scanStatus,
@@ -47,13 +51,20 @@ export function ScansFilterBar({
<>
<ProviderAccountSelectors
providers={providers}
accountFilterKey={FilterType.PROVIDER}
accountFilterKey={FILTER_FIELD.PROVIDER}
accountValue="id"
paramsToDeleteOnChange={["page", "scanId"]}
providerSelectorClassName={filterItemClass}
accountSelectorClassName={filterItemClass}
/>
<div className={filterItemClass}>
<ProviderGroupSelector
groups={providerGroups}
paramsToDeleteOnChange={["page", "scanId"]}
/>
</div>
{showScheduleTypeFilter && (
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
+4
View File
@@ -20,6 +20,7 @@ import {
import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour";
import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderGroup } from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import type { ScanScheduleCapability } from "@/types/schedules";
@@ -32,6 +33,7 @@ import { useScansFilters } from "./use-scans-filters";
interface ScansPageShellProps {
providers: ProviderProps[];
providerGroups?: ProviderGroup[];
hasManageScansPermission: boolean;
activeScanCount?: number;
children: ReactNode;
@@ -42,6 +44,7 @@ interface ScansPageShellProps {
export function ScansPageShell({
providers,
providerGroups = [],
hasManageScansPermission,
activeScanCount = 0,
children,
@@ -116,6 +119,7 @@ export function ScansPageShell({
>
<ScansFilterBar
providers={providers}
providerGroups={providerGroups}
activeTab={filters.activeTab}
scheduleType={filters.scheduleType}
scanStatus={filters.scanStatus}
@@ -27,7 +27,13 @@ beforeAll(() => {
});
});
function ScheduleFieldsHarness() {
function ScheduleFieldsHarness({
canUseAdvancedSchedule = true,
showCloudUpgradeBadge = false,
}: {
canUseAdvancedSchedule?: boolean;
showCloudUpgradeBadge?: boolean;
} = {}) {
const form = useForm<ScheduleFormValues>({
defaultValues: getScheduleFormDefaults(),
});
@@ -36,7 +42,8 @@ function ScheduleFieldsHarness() {
<ScanScheduleFields
form={form}
showNextScheduledCopy
canUseAdvancedSchedule
canUseAdvancedSchedule={canUseAdvancedSchedule}
showCloudUpgradeBadge={showCloudUpgradeBadge}
/>
);
}
@@ -73,4 +80,42 @@ describe("ScanScheduleFields", () => {
}),
).not.toBeInTheDocument();
});
it("uses ordinal copy for monthly schedules", async () => {
// Given
const user = userEvent.setup();
render(<ScheduleFieldsHarness />);
// When
await user.click(screen.getByRole("combobox", { name: /repeats/i }));
await user.click(screen.getByRole("option", { name: /monthly/i }));
// Then
expect(getHelperCopy(/Monthly on the 1st/)).toBeInTheDocument();
expect(getHelperCopy(/Monthly on the 1st/)).not.toHaveTextContent(
/Monthly on day/,
);
});
it("shows a single cloud badge beside the Scan Schedule title when advanced controls are locked", () => {
// Given
render(
<ScheduleFieldsHarness
canUseAdvancedSchedule={false}
showCloudUpgradeBadge
/>,
);
// Then
expect(screen.getAllByText("Available in Prowler Cloud")).toHaveLength(1);
expect(screen.getByText("Scan Schedule").parentElement).toHaveTextContent(
"Available in Prowler Cloud",
);
expect(screen.getByText("Scan Time").parentElement).not.toHaveTextContent(
"Available in Prowler Cloud",
);
expect(screen.getByText("Repeats").parentElement).not.toHaveTextContent(
"Available in Prowler Cloud",
);
});
});
@@ -17,6 +17,7 @@ import {
} from "@/components/shadcn";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import {
formatDayOfMonth,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
@@ -59,20 +60,18 @@ interface ScanScheduleFieldsProps {
* (interval/weekly/monthly) are disabled. Used for non-Cloud (OSS) accounts.
*/
canUseAdvancedSchedule?: boolean;
/** Render the "Available in Prowler Cloud" upsell badge on locked controls. */
/** Render the "Available in Prowler Cloud" upsell badge in the header. */
showCloudUpgradeBadge?: boolean;
}
function NumberSelect({
label,
labelAddon,
value,
values,
onChange,
disabled,
}: {
label: string;
labelAddon?: ReactNode;
value: number;
values: ReadonlyArray<{ value: number; label: string }>;
onChange: (value: number) => void;
@@ -80,10 +79,7 @@ function NumberSelect({
}) {
return (
<Field>
<div className="flex items-center justify-between gap-2">
<FieldLabel>{label}</FieldLabel>
{labelAddon}
</div>
<FieldLabel>{label}</FieldLabel>
<Select
value={String(value)}
onValueChange={(nextValue) => onChange(Number(nextValue))}
@@ -119,7 +115,7 @@ function getScheduleSummary({
case SCHEDULE_FREQUENCY.WEEKLY:
return `Weekly on ${SCHEDULE_WEEKDAY_LABELS[dayOfWeek] ?? SCHEDULE_WEEKDAY_LABELS[0]}`;
case SCHEDULE_FREQUENCY.MONTHLY:
return `Monthly on day ${dayOfMonth}`;
return `Monthly on the ${formatDayOfMonth(dayOfMonth)}`;
default:
return "Daily";
}
@@ -164,6 +160,7 @@ export function ScanScheduleFields({
<h3 className="text-text-neutral-primary text-sm font-medium">
Scan Schedule
</h3>
{cloudUpgradeBadge}
</div>
{headerAction}
</div>
@@ -175,7 +172,6 @@ export function ScanScheduleFields({
render={({ field }) => (
<NumberSelect
label="Scan Time"
labelAddon={cloudUpgradeBadge}
value={field.value}
values={HOUR_OPTIONS}
onChange={field.onChange}
@@ -189,10 +185,7 @@ export function ScanScheduleFields({
name="frequency"
render={({ field }) => (
<Field>
<div className="flex items-center justify-between gap-2">
<FieldLabel>Repeats</FieldLabel>
{cloudUpgradeBadge}
</div>
<FieldLabel>Repeats</FieldLabel>
<Select
value={
canUseAdvancedSchedule
@@ -139,7 +139,10 @@ describe("DataTable", () => {
// Then
expect(nameHeader).not.toHaveClass("sticky");
expect(nameCell).not.toHaveClass("sticky");
expect(nameHeader).toHaveClass("pr-6");
expect(nameCell).toHaveClass("pr-6");
expect(actionsHeaderElement).not.toHaveClass("sticky");
expect(actionsHeaderElement).not.toHaveClass("pr-6");
expect(actionsHeaderElement).not.toHaveClass("right-0");
expect(actionsHeaderElement).not.toHaveClass("z-20");
expect(actionsHeaderElement).toHaveClass("bg-bg-neutral-tertiary");
@@ -159,6 +162,7 @@ describe("DataTable", () => {
expect(actionsHeaderElement).not.toHaveClass("after:rounded-r-full");
expect(actionsHeaderElement.querySelector("div")).not.toBeInTheDocument();
expect(actionsCell).toHaveClass("sticky");
expect(actionsCell).not.toHaveClass("pr-6");
expect(actionsCell).toHaveClass("right-0");
expect(actionsCell).toHaveClass("z-20");
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
+9 -5
View File
@@ -45,16 +45,20 @@ type DataTableRowAttributes = {
*/
const DEFAULT_COLUMN_SIZE = 150;
const ACTIONS_COLUMN_ID = "actions";
const TABLE_COLUMN_GAP_CLASS = "pr-6";
const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12";
const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`;
const getStickyActionColumnClassName = (
const getTableColumnClassName = (
columnId: string,
variant: "header" | "cell",
) => {
if (columnId !== ACTIONS_COLUMN_ID) return undefined;
const isActionsColumn = columnId === ACTIONS_COLUMN_ID;
return variant === "header" ? undefined : STICKY_ACTION_CELL_CLASS;
return cn(
!isActionsColumn && TABLE_COLUMN_GAP_CLASS,
isActionsColumn && variant === "cell" && STICKY_ACTION_CELL_CLASS,
);
};
interface DataTableProviderProps<TData, TValue> {
@@ -302,7 +306,7 @@ export function DataTable<TData, TValue>({
return (
<TableHead
key={header.id}
className={getStickyActionColumnClassName(
className={getTableColumnClassName(
header.column.id,
"header",
)}
@@ -348,7 +352,7 @@ export function DataTable<TData, TValue>({
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getStickyActionColumnClassName(
className={getTableColumnClassName(
cell.column.id,
"cell",
)}
+4 -4
View File
@@ -62,22 +62,22 @@ describe("useFilterBatch", () => {
expect(result.current.hasChanges).toBe(false);
});
it("should expose filter[delta]=new under the FilterType.DELTA key so the dropdown shows it selected", async () => {
it("should expose filter[delta]=new under the FILTER_FIELD.DELTA key so the dropdown shows it selected", async () => {
// Given — URL from LinkToFindings uses `filter[delta]` (singular), matching the API.
setSearchParams({
"filter[status__in]": "FAIL",
"filter[delta]": "new",
});
const { FilterType } = await import("@/types/filters");
const { FILTER_FIELD } = await import("@/types/filters");
// When
const { result } = renderHook(() => useFilterBatch());
// Then — the Delta dropdown reads via getFilterValue(`filter[${FilterType.DELTA}]`).
// Then — the Delta dropdown reads via getFilterValue(`filter[${FILTER_FIELD.DELTA}]`).
// For the checkbox of "new" to appear checked, that lookup must return ["new"].
expect(
result.current.getFilterValue(`filter[${FilterType.DELTA}]`),
result.current.getFilterValue(`filter[${FILTER_FIELD.DELTA}]`),
).toEqual(["new"]);
});
+10 -5
View File
@@ -2,8 +2,9 @@ import { useSearchParams } from "next/navigation";
import { isScanEntity } from "@/lib/helper-filters";
import {
FILTER_FIELD,
FilterEntity,
FilterType,
FilterParam,
ProviderEntity,
ProviderType,
ScanEntity,
@@ -16,7 +17,9 @@ interface UseRelatedFiltersProps {
completedScanIds?: string[];
scanDetails?: { [key: string]: ScanEntity }[];
enableScanRelation?: boolean;
providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID;
providerFilterType?:
| typeof FILTER_FIELD.PROVIDER
| typeof FILTER_FIELD.PROVIDER_UID;
}
/**
@@ -38,15 +41,17 @@ export const useRelatedFilters = ({
completedScanIds = [],
scanDetails = [],
enableScanRelation = false,
providerFilterType = FilterType.PROVIDER,
providerFilterType = FILTER_FIELD.PROVIDER,
}: UseRelatedFiltersProps) => {
const searchParams = useSearchParams();
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
const providerParam = searchParams.get(
`filter[${providerFilterType}]` satisfies FilterParam,
);
const providerTypeParam = searchParams.get(
`filter[${FilterType.PROVIDER_TYPE}]`,
`filter[${FILTER_FIELD.PROVIDER_TYPE}]` satisfies FilterParam,
);
const currentProviders = providerParam ? providerParam.split(",") : [];
+30
View File
@@ -1,14 +1,23 @@
import { describe, expect, it } from "vitest";
import type { ProviderGroup } from "@/types/components";
import type { ScanEntity } from "@/types/scans";
import {
getProviderGroupDisplayValue,
getScanEntityLabel,
hasDateFilter,
hasDateOrScanFilter,
hasHistoricalFindingFilter,
} from "./helper-filters";
const makeProviderGroup = (id: string, name: string): ProviderGroup =>
({
type: "provider-groups",
id,
attributes: { name, inserted_at: "", updated_at: "" },
}) as ProviderGroup;
function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
return {
id: "scan-1",
@@ -25,6 +34,27 @@ function makeScan(overrides: Partial<ScanEntity> = {}): ScanEntity {
};
}
describe("getProviderGroupDisplayValue", () => {
const groups = [
makeProviderGroup("g1", "Production"),
makeProviderGroup("g2", "Staging"),
];
it("resolves the group name when the id matches", () => {
expect(getProviderGroupDisplayValue("g1", groups)).toBe("Production");
});
it("falls back to the raw id when the group is not found", () => {
expect(getProviderGroupDisplayValue("unknown", groups)).toBe("unknown");
});
it("falls back to the raw id when the group name is empty", () => {
expect(
getProviderGroupDisplayValue("g3", [makeProviderGroup("g3", "")]),
).toBe("g3");
});
});
describe("hasDateOrScanFilter", () => {
it("returns true for scan filters", () => {
expect(hasDateOrScanFilter({ "filter[scan__in]": "scan-1" })).toBe(true);
+14
View File
@@ -1,4 +1,5 @@
import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types";
import { ProviderGroup } from "@/types/components";
import { FilterEntity } from "@/types/filters";
import {
getProviderDisplayName,
@@ -119,6 +120,19 @@ export function getScanEntityLabel(scan: ScanEntity): string {
return providerLabel || scanName;
}
/**
* Resolves the display name for a provider group filter value, falling back to
* the raw id when the group can't be resolved. Shared by the findings and
* resources filter utils so their chips stay in sync.
*/
export function getProviderGroupDisplayValue(
groupId: string,
groups: ProviderGroup[],
): string {
const group = groups.find((item) => item.id === groupId);
return group?.attributes.name || groupId;
}
/**
* Creates a scan details mapping for filters from completed scans.
* Used to provide detailed information for scan filters in the UI.
+92 -12
View File
@@ -2,12 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildProviderScheduleSummary,
buildScheduleAttributesFromProvider,
buildSchedulesByProviderId,
buildScheduleUpdatePayload,
formatDayOfMonth,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
getScanScheduleCapability,
getScheduleFormDefaults,
getScheduleFormValues,
isScheduleConfigured,
} from "@/lib/schedules";
@@ -161,6 +164,25 @@ describe("formatScheduleHour", () => {
});
});
describe("formatDayOfMonth", () => {
it.each([
[1, "1st"],
[2, "2nd"],
[3, "3rd"],
[4, "4th"],
[11, "11th"],
[12, "12th"],
[13, "13th"],
[21, "21st"],
[22, "22nd"],
[23, "23rd"],
[24, "24th"],
[31, "31st"],
])("formats day %i as %s", (day, expected) => {
expect(formatDayOfMonth(day)).toBe(expected);
});
});
describe("isScheduleConfigured", () => {
it("treats a null scan_hour as not configured", () => {
expect(isScheduleConfigured({ scan_hour: null })).toBe(false);
@@ -175,6 +197,26 @@ describe("isScheduleConfigured", () => {
});
});
describe("getScheduleFormDefaults", () => {
it("uses the current local hour when the clock is exactly on the hour", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 0, 0, 0)).hour,
).toBe(10);
});
it("uses the next local hour when the current hour already started", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 10, 30, 0, 0)).hour,
).toBe(11);
});
it("wraps the upcoming hour from 23:xx to 0", () => {
expect(
getScheduleFormDefaults(new Date(2026, 5, 10, 23, 1, 0, 0)).hour,
).toBe(0);
});
});
describe("getScheduleFormValues", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
@@ -190,7 +232,9 @@ describe("getScheduleFormValues", () => {
});
it("returns defaults when there is no schedule", () => {
expect(getScheduleFormValues(null)).toEqual({
expect(
getScheduleFormValues(null, new Date(2026, 5, 10, 0, 0, 0, 0)),
).toEqual({
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
@@ -201,16 +245,19 @@ describe("getScheduleFormValues", () => {
});
it("returns defaults when scan_hour is null (unconfigured provider)", () => {
expect(getScheduleFormValues(buildAttributes({ scan_hour: null }))).toEqual(
{
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
},
);
expect(
getScheduleFormValues(
buildAttributes({ scan_hour: null }),
new Date(2026, 5, 10, 0, 0, 0, 0),
),
).toEqual({
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: 0,
dayOfWeek: 1,
dayOfMonth: 1,
intervalHours: 48,
launchInitialScan: false,
});
});
it("maps a configured schedule onto the form", () => {
@@ -439,6 +486,39 @@ describe("buildSchedulesByProviderId", () => {
});
});
describe("buildScheduleAttributesFromProvider", () => {
it("returns undefined when the provider payload does not include scan fields", () => {
expect(buildScheduleAttributesFromProvider({})).toBeUndefined();
});
it("keeps scan_hour null as an unconfigured provider schedule", () => {
const attributes = buildScheduleAttributesFromProvider({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: null,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
next_scan_at: null,
last_scan_at: null,
});
expect(attributes).toEqual({
scan_enabled: true,
scan_frequency: SCHEDULE_FREQUENCY.DAILY,
scan_hour: null,
scan_timezone: "UTC",
scan_interval_hours: null,
scan_day_of_week: null,
scan_day_of_month: null,
next_scan_at: null,
last_scan_at: null,
});
expect(isScheduleConfigured(attributes!)).toBe(false);
});
});
describe("buildProviderScheduleSummary", () => {
const buildAttributes = (
overrides: Partial<ScheduleAttributes> = {},
@@ -463,7 +543,7 @@ describe("buildProviderScheduleSummary", () => {
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.MONTHLY, scan_day_of_month: 15 },
"Monthly on day 15",
"Monthly on the 15th",
],
[
{ scan_frequency: SCHEDULE_FREQUENCY.INTERVAL, scan_interval_hours: 72 },
+61 -5
View File
@@ -12,13 +12,21 @@ import {
type ScheduleUpdatePayload,
} from "@/types/schedules";
const DEFAULT_SCHEDULE_HOUR = 0;
const DEFAULT_DAY_OF_WEEK = 1;
const DEFAULT_DAY_OF_MONTH = 1;
// The backend (prowler-cloud) enforces SCAN_INTERVAL_HOURS_MIN = 24. 48 is well
// above that floor.
const SCAN_INTERVAL_HOURS_MIN = 24;
const DEFAULT_INTERVAL_HOURS = 48;
const ordinalRules = new Intl.PluralRules("en-US", { type: "ordinal" });
const DAY_OF_MONTH_SUFFIXES: Record<Intl.LDMLPluralRule, string> = {
zero: "th",
one: "st",
two: "nd",
few: "rd",
many: "th",
other: "th",
};
export const scheduleFormSchema = z.object({
frequency: z.enum(SCHEDULE_FREQUENCY),
@@ -65,6 +73,10 @@ export function formatScheduleHour(hour: number): string {
return `${displayHour}:00${period}`;
}
export function formatDayOfMonth(day: number): string {
return `${day}${DAY_OF_MONTH_SUFFIXES[ordinalRules.select(day)]}`;
}
export function getBrowserTimezone(): string {
if (typeof window === "undefined") {
return "UTC";
@@ -73,10 +85,19 @@ export function getBrowserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
export function getScheduleFormDefaults(): ScheduleFormValues {
function getDefaultScheduleHour(now: Date): number {
const isOnTheHour =
now.getMinutes() === 0 &&
now.getSeconds() === 0 &&
now.getMilliseconds() === 0;
return (now.getHours() + (isOnTheHour ? 0 : 1)) % 24;
}
export function getScheduleFormDefaults(now = new Date()): ScheduleFormValues {
return {
frequency: SCHEDULE_FREQUENCY.DAILY,
hour: DEFAULT_SCHEDULE_HOUR,
hour: getDefaultScheduleHour(now),
dayOfWeek: DEFAULT_DAY_OF_WEEK,
dayOfMonth: DEFAULT_DAY_OF_MONTH,
intervalHours: DEFAULT_INTERVAL_HOURS,
@@ -86,8 +107,9 @@ export function getScheduleFormDefaults(): ScheduleFormValues {
export function getScheduleFormValues(
schedule?: ScheduleAttributes | null,
now = new Date(),
): ScheduleFormValues {
const defaults = getScheduleFormDefaults();
const defaults = getScheduleFormDefaults(now);
if (!schedule || schedule.scan_hour === null) {
return defaults;
@@ -148,6 +170,38 @@ export function buildSchedulesByProviderId(
return byProviderId;
}
interface ProviderScheduleAttributeSource {
scan_enabled?: boolean | null;
scan_frequency?: ScheduleAttributes["scan_frequency"] | null;
scan_hour?: number | null;
scan_timezone?: string | null;
scan_interval_hours?: number | null;
scan_day_of_week?: number | null;
scan_day_of_month?: number | null;
next_scan_at?: string | null;
last_scan_at?: string | null;
}
export function buildScheduleAttributesFromProvider(
attributes: ProviderScheduleAttributeSource,
): ScheduleAttributes | undefined {
if (!Object.prototype.hasOwnProperty.call(attributes, "scan_hour")) {
return undefined;
}
return {
scan_enabled: attributes.scan_enabled ?? true,
scan_frequency: attributes.scan_frequency ?? SCHEDULE_FREQUENCY.DAILY,
scan_hour: attributes.scan_hour ?? null,
scan_timezone: attributes.scan_timezone ?? "UTC",
scan_interval_hours: attributes.scan_interval_hours ?? null,
scan_day_of_week: attributes.scan_day_of_week ?? null,
scan_day_of_month: attributes.scan_day_of_month ?? null,
next_scan_at: attributes.next_scan_at,
last_scan_at: attributes.last_scan_at,
};
}
/**
* Whether a provider has an explicitly configured scan schedule.
*
@@ -224,7 +278,9 @@ export function getScheduleCadenceParts(
}
case SCHEDULE_FREQUENCY.MONTHLY:
return {
cadence: `Monthly on day ${attributes.scan_day_of_month ?? 1}`,
cadence: `Monthly on the ${formatDayOfMonth(
attributes.scan_day_of_month ?? 1,
)}`,
time,
};
case SCHEDULE_FREQUENCY.INTERVAL:
+36 -40
View File
@@ -29,32 +29,44 @@ export interface CustomDropdownFilterProps {
onFilterChange: (key: string, values: string[]) => void;
}
export enum FilterType {
SCAN = "scan__in",
PROVIDER = "provider__in",
PROVIDER_ID = "provider_id__in",
PROVIDER_UID = "provider_uid__in",
PROVIDER_TYPE = "provider_type__in",
REGION = "region__in",
SERVICE = "service__in",
RESOURCE_TYPE = "resource_type__in",
SEVERITY = "severity__in",
STATUS = "status__in",
/**
* Filter field names the inner part of a `filter[...]` URL param key, and the
* `key` values used to build `FilterOption` dropdown configs. Single source of
* truth for the `FilterParam` template; per-view modules compose their own field
* set from these plus their own extras.
*/
export const FILTER_FIELD = {
// core — provider scope + shared resource dimensions (used across views)
PROVIDER_TYPE: "provider_type__in",
PROVIDER_ID: "provider_id__in",
PROVIDER_UID: "provider_uid__in",
PROVIDER_GROUPS: "provider_groups__in",
REGION: "region__in",
SERVICE: "service__in",
// view dimensions — dropdown configs (mostly findings; `provider__in` is the
// providers-list type filter)
PROVIDER: "provider__in",
SCAN: "scan__in",
RESOURCE_TYPE: "resource_type__in",
SEVERITY: "severity__in",
STATUS: "status__in",
// The API only registers `delta` (exact, singular). `delta__in` is silently
// dropped, so the dropdown, URL, and backend must all use `delta`.
DELTA = "delta",
CATEGORY = "category__in",
RESOURCE_GROUPS = "resource_groups__in",
}
DELTA: "delta",
CATEGORY: "category__in",
RESOURCE_GROUPS: "resource_groups__in",
} as const;
export type FilterField = (typeof FILTER_FIELD)[keyof typeof FILTER_FIELD];
/**
* Filter keys the account selectors accept: a provider id (`provider__in` /
* `provider_id__in`) or the cloud account uid (`provider_uid__in`).
*/
export type AccountFilterKey =
| FilterType.PROVIDER
| FilterType.PROVIDER_ID
| FilterType.PROVIDER_UID;
export type AccountFilterKey = (typeof FILTER_FIELD)[
| "PROVIDER"
| "PROVIDER_ID"
| "PROVIDER_UID"];
/**
* Controls the filter dispatch behavior of DataTableFilterCustom.
@@ -70,25 +82,9 @@ 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.
* URL filter param key template wraps a field name in `filter[...]`.
* Parameterize with a view's own field union (e.g. `FilterParam<FindingsFilterField>`)
* so each view's param-keyed records stay in sync with the filters it supports.
*/
export type FilterParam =
| "filter[provider_type__in]"
| "filter[provider_id__in]"
| "filter[severity__in]"
| "filter[status__in]"
| "filter[delta__in]"
| "filter[delta]"
| "filter[region__in]"
| "filter[service__in]"
| "filter[resource_type__in]"
| "filter[category__in]"
| "filter[resource_groups__in]"
| "filter[scan]"
| "filter[scan__in]"
| "filter[scan_id]"
| "filter[scan_id__in]"
| "filter[inserted_at]"
| "filter[muted]";
export type FilterParam<Field extends string = FilterField> =
`filter[${Field}]`;
+4 -1
View File
@@ -1,4 +1,4 @@
import { MetaDataProps } from "./components";
import { MetaDataProps, ProviderGroup } from "./components";
import { FilterOption } from "./filters";
import {
OrganizationResource,
@@ -55,6 +55,8 @@ export interface ProvidersProviderRow
hasSchedule: boolean;
/** Cadence/next-run summary when the provider has a configured schedule. */
scheduleSummary?: ScanScheduleSummary;
/** Completed-at timestamp for the provider's last scan when exposed by API. */
lastScanAt?: string | null;
subRows?: ProvidersTableRow[];
}
@@ -84,6 +86,7 @@ export interface ProvidersAccountsViewData {
filters: FilterOption[];
metadata?: MetaDataProps;
providers: ProviderProps[];
providerGroups: ProviderGroup[];
rows: ProvidersTableRow[];
}
+11
View File
@@ -1,3 +1,5 @@
import type { ScheduleFrequency } from "./schedules";
export const PROVIDER_TYPES = [
"aws",
"azure",
@@ -65,6 +67,15 @@ export interface ProviderProps {
};
inserted_at: string;
updated_at: string;
scan_frequency?: ScheduleFrequency | null;
scan_hour?: number | null;
scan_day_of_week?: number | null;
scan_day_of_month?: number | null;
scan_interval_hours?: number | null;
scan_timezone?: string | null;
scan_enabled?: boolean | null;
next_scan_at?: string | null;
last_scan_at?: string | null;
created_by: {
object: string;
id: string;