Merge remote-tracking branch 'origin/master' into feature/eslint-typescript-flat

This commit is contained in:
Pablo F.G
2026-06-25 08:56:00 +02:00
37 changed files with 2047 additions and 139 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler API** are documented in this file.
## [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"
+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)
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [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
@@ -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} />);
@@ -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). */
@@ -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();
@@ -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);
@@ -886,6 +886,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 +1010,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 +1036,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,
@@ -3,7 +3,6 @@ import {
listOrganizationUnitsSafe,
} from "@/actions/organizations/organizations";
import { getAllProviders, getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getSchedules } from "@/actions/schedules";
import {
extractFiltersAndQuery,
@@ -11,6 +10,7 @@ import {
} from "@/lib/helper-filters";
import {
buildProviderScheduleSummary,
buildScheduleAttributesFromProvider,
buildSchedulesByProviderId,
isScheduleConfigured,
} from "@/lib/schedules";
@@ -33,7 +33,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 +114,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 +125,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 +163,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),
};
});
};
@@ -506,7 +502,6 @@ export async function loadProvidersAccountsViewData({
const [
providersResponse,
allProvidersResponse,
scansResponse,
schedulesResponse,
organizationsResponse,
organizationUnitsResponse,
@@ -523,18 +518,8 @@ 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).
// Fetch configured schedules as a fallback when provider scan_* fields are
// absent (best-effort: typically empty in OSS).
resolveActionResult(getSchedules()),
isCloud
? listOrganizationsSafe()
@@ -544,18 +529,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,
+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>;
};
@@ -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();
});
});
@@ -228,22 +228,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" />
),
@@ -256,12 +259,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);
@@ -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.
@@ -361,6 +363,8 @@ export function LaunchStep({
disabled={isLaunching || !providerId}
showLaunchInitialScan
showNextScheduledCopy
canUseAdvancedSchedule={isAdvanced}
showCloudUpgradeBadge={isDailyLegacy}
/>
)}
</div>
+88 -2
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]} />,
+24 -1
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>
@@ -365,7 +384,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" />}
/>
@@ -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",
)}
+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:
+2
View File
@@ -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[];
}
+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;