Compare commits

...
Author SHA1 Message Date
Prowler BotandJosema Camacho c35ab7e91a fix(api): handle deleted scans during progress saves (#11697)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-06-25 15:34:52 +02:00
Prowler Botandprowler-bot 3d8cd467d6 chore(release): Bump versions to v5.31.2 (#11693)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-25 09:34:46 +02:00
Prowler BotandPepe Fagoaga c4b49790df chore: changelog v5.31.1 (#11692)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-25 08:30:36 +02:00
Prowler BotandAlejandro Bailo 2c3980d4eb fix(ui): improve scan scheduling flows (#11688)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-06-24 17:39:08 +02:00
Prowler BotandJosema Camacho 8b4868fbde fix(api): avoid mutating API key manager during auth (#11687)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-06-24 17:17:25 +02:00
Prowler BotandDaniel Barranquero 4b79557fed fix(alibabacloud): implement ram_password_policy_number and fix cs weekly check loading (#11685)
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
2026-06-24 12:49:18 +02:00
Prowler BotandAdrián Peña 46f4ef5b15 docs(api): move SAML changelog entry to v5.31.0 (#11678)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-06-23 17:20:15 +02:00
Prowler Botandprowler-bot 6bcf9e12ec chore(release): Bump versions to v5.31.1 (#11674)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-23 16:53:25 +02:00
Prowler Botandprowler-bot 0c79414c6f chore(api): Update prowler dependency to v5.31 for release 5.31.0 (#11672)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-23 14:30:02 +02:00
36 changed files with 1107 additions and 183 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.2
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+17 -1
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
@@ -17,6 +33,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Sentry now drops ASGI's `RequestAborted` errors from health-check probe disconnects on `/health/live` [(#11632)](https://github.com/prowler-cloud/prowler/pull/11632)
- Gunicorn keep-alive timeout now exceeds the load balancer idle timeout, stopping 502s from reused connections [(#11647)](https://github.com/prowler-cloud/prowler/pull/11647)
- API runs under the Uvicorn worker so keep-alive outlives the load balancer idle timeout, fixing Gunicorn's intermittent 502s [(#11663)](https://github.com/prowler-cloud/prowler/pull/11663)
- SAML logins no longer wipe a user's roles when the IdP does not send the `userType` attribute; existing roles are kept, and when `userType` names a role that does not exist it is now created with read-only access (visibility over all providers, no management permissions) instead of no permissions at all [(#11520)](https://github.com/prowler-cloud/prowler/pull/11520)
### 🐞 Fixed
@@ -68,7 +85,6 @@ All notable changes to the **Prowler API** are documented in this file.
### 🔄 Changed
- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- SAML logins no longer wipe a user's roles when the IdP does not send the `userType` attribute; existing roles are kept, and when `userType` names a role that does not exist it is now created with read-only access (visibility over all providers, no management permissions) instead of no permissions at all [(#11520)](https://github.com/prowler-cloud/prowler/pull/11520)
### 🐞 Fixed
+2 -2
View File
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.31",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.32.0"
version = "1.32.2"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+43 -9
View File
@@ -1,10 +1,13 @@
from typing import Optional, Tuple
from math import isfinite
from uuid import UUID
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
@@ -23,18 +26,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)
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.32.0
version: 1.32.2
description: |-
Prowler API specification.
@@ -1,9 +1,12 @@
import time
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from django.db.models.query import QuerySet
from django.test import RequestFactory
from rest_framework.exceptions import AuthenticationFailed
@@ -65,6 +68,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(timezone.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
):
+55 -19
View File
@@ -14,7 +14,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,
@@ -43,7 +43,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,
@@ -118,6 +118,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,
@@ -1030,13 +1044,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=timezone.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):
@@ -1104,7 +1123,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
@@ -1146,7 +1165,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
@@ -1173,26 +1196,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=timezone.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=timezone.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
+84 -14
View File
@@ -8,6 +8,19 @@ from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from api.db_router import MainRouter
from api.exceptions import ProviderConnectionError, ProviderDeletedException
from api.models import (
Finding,
MuteRule,
Provider,
Resource,
ResourceScanSummary,
Scan,
ScanSummary,
StateChoices,
StatusChoices,
)
from tasks.jobs.scan import (
_ATTACK_SURFACE_MAPPING_CACHE,
_aggregate_findings_by_region,
@@ -29,19 +42,6 @@ from tasks.jobs.scan import (
)
from tasks.utils import CustomEncoder
from api.db_router import MainRouter
from api.exceptions import ProviderConnectionError
from api.models import (
Finding,
MuteRule,
Provider,
Resource,
ResourceScanSummary,
Scan,
ScanSummary,
StateChoices,
StatusChoices,
)
from prowler.lib.check.models import Severity
from prowler.lib.outputs.finding import Status
@@ -263,6 +263,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",
[
@@ -4591,9 +4660,10 @@ class TestScanIsFullScope:
# If the SDK adds a new filter, this test still passes via the
# introspection-driven derivation; if it adds a non-filter kwarg
# (e.g. provider-like), keep the exclusion list in sync in models.py.
from prowler.lib.scan.scan import Scan as ProwlerScan
import inspect
from prowler.lib.scan.scan import Scan as ProwlerScan
expected = tuple(
name
for name in inspect.signature(ProwlerScan.__init__).parameters
Generated
+36 -3
View File
@@ -2223,6 +2223,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
name = "deprecated"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
]
[[package]]
name = "detect-secrets"
version = "1.5.0"
@@ -3513,6 +3525,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/10/9f8af3e6f569685ce3af7faab51c8dd9d93b9c38eba339ca31c746119447/kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998", size = 1988070, upload-time = "2025-02-18T21:06:31.391Z" },
]
[[package]]
name = "linode-api4"
version = "5.45.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "polling" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/b5/fce03d9b81008dcc0fe4961ce10e140ac3ae5ab17f2cdd659763e4964c0d/linode_api4-5.45.0.tar.gz", hash = "sha256:af8a0a5638345ad467447112dcf5d58ec47e7dd192b89ce0c8537a1e5c435d04", size = 283375, upload-time = "2026-06-11T18:05:13.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/38/19e3c8f7b7a9dbeea2aa5af61f70162bff5131b3d39acbe73e8d0dd12972/linode_api4-5.45.0-py3-none-any.whl", hash = "sha256:3cc2650b13d8d3bc7735fa8e92a639669618f320471dc8e519db778c6020eacd", size = 158336, upload-time = "2026-06-11T18:05:11.799Z" },
]
[[package]]
name = "lxml"
version = "6.1.0"
@@ -4332,6 +4358,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/f5/65b66420c275e9b26513fdd6d84687403d11ac8be4650b67d1e5572b8f48/policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321", size = 484251, upload-time = "2023-11-30T19:12:43.463Z" },
]
[[package]]
name = "polling"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/c5/4249317962180d97ec7a60fe38aa91f86216533bd478a427a5468945c5c9/polling-0.3.2.tar.gz", hash = "sha256:3afd62320c99b725c70f379964bf548b302fc7f04d4604e6c315d9012309cc9a", size = 5189, upload-time = "2021-05-22T19:48:41.466Z" }
[[package]]
name = "portalocker"
version = "2.10.1"
@@ -4449,7 +4481,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.31.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b5bb85c9564f6ca6a7f66c851bb56bde719205ee" }
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.31#fb995a79bfd318e0ae5e6d2653c039c103bda9b5" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4507,6 +4539,7 @@ dependencies = [
{ name = "h2" },
{ name = "jsonschema" },
{ name = "kubernetes" },
{ name = "linode-api4" },
{ name = "markdown" },
{ name = "microsoft-kiota-abstractions" },
{ name = "msgraph-sdk" },
@@ -4536,7 +4569,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.32.0"
version = "1.32.2"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4636,7 +4669,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.31" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [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
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.31.0"
prowler_version = "5.31.2"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -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
+1 -1
View File
@@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.31.0"
version = "5.31.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -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"
+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();
});
});
@@ -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);
@@ -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;
Generated
+1 -1
View File
@@ -3553,7 +3553,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.31.0"
version = "5.31.2"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },