From 1a1e804c000ead233c278fdbf1737f465fabfbef Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Tue, 14 Jul 2026 08:46:52 +0200 Subject: [PATCH 01/22] chore(banner): highlight link to Prowler Cloud (#11964) --- prowler/lib/banner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index 8115983bc6..cfd576ed15 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -66,5 +66,5 @@ def print_prowler_cloud_banner(provider: str = None): {bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds. {bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC. {bar} -{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL} +{bar} {banner_color}Start free at 👉 cloud.prowler.com{Style.RESET_ALL} """) From 8debf70d5cf5d32d5c76c3311a7ae5df42e15b20 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Tue, 14 Jul 2026 09:45:52 +0200 Subject: [PATCH 02/22] fix(api): retry transient attack paths graph mutations (#11961) --- ...tack-paths-graph-mutation-retries.fixed.md | 1 + .../api/attack_paths/retryable_session.py | 45 +++- api/src/backend/api/attack_paths/sink/drop.py | 7 +- .../backend/api/attack_paths/sink/neo4j.py | 6 +- .../backend/api/attack_paths/sink/neptune.py | 20 +- .../api/tests/test_retryable_session.py | 85 +++++++ api/src/backend/api/tests/test_sink.py | 213 ++++++++++-------- 7 files changed, 269 insertions(+), 108 deletions(-) create mode 100644 api/changelog.d/attack-paths-graph-mutation-retries.fixed.md create mode 100644 api/src/backend/api/tests/test_retryable_session.py diff --git a/api/changelog.d/attack-paths-graph-mutation-retries.fixed.md b/api/changelog.d/attack-paths-graph-mutation-retries.fixed.md new file mode 100644 index 0000000000..f48e99462c --- /dev/null +++ b/api/changelog.d/attack-paths-graph-mutation-retries.fixed.md @@ -0,0 +1 @@ +Attack Paths graph mutations now retry transient Neptune concurrency and deadline failures, while Neo4j mutations use managed transaction retries diff --git a/api/src/backend/api/attack_paths/retryable_session.py b/api/src/backend/api/attack_paths/retryable_session.py index 16f0d9e31a..2cd32f54ee 100644 --- a/api/src/backend/api/attack_paths/retryable_session.py +++ b/api/src/backend/api/attack_paths/retryable_session.py @@ -1,4 +1,6 @@ import logging +import random +import time from collections.abc import Callable from typing import Any @@ -9,17 +11,19 @@ logger = logging.getLogger(__name__) class RetryableSession: - """ - Wrapper around `neo4j.Session` that retries `neo4j.exceptions.ServiceUnavailable` errors. - """ + """Wrapper around ``neo4j.Session`` with a refreshable retry policy.""" def __init__( self, session_factory: Callable[[], neo4j.Session], max_retries: int, + retry_if: Callable[[Exception], bool] | None = None, + initial_retry_delay_seconds: float = 0, ) -> None: self._session_factory = session_factory self._max_retries = max(0, max_retries) + self._retry_if = retry_if + self._initial_retry_delay_seconds = max(0.0, initial_retry_delay_seconds) self._session = self._session_factory() def close(self) -> None: @@ -56,24 +60,47 @@ class RetryableSession: method = getattr(self._session, method_name) return method(*args, **kwargs) - except ( - BrokenPipeError, - ConnectionResetError, - neo4j.exceptions.ServiceUnavailable, - ) as exc: # pragma: no cover - depends on infra + except Exception as exc: + if not self._should_retry(exc): + raise + last_exc = exc attempt += 1 if attempt > self._max_retries: raise + delay = self._retry_delay(attempt) logger.warning( - f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..." + "Graph session %s failed with %s; retry %s/%s in %.3fs", + method_name, + type(exc).__name__, + attempt, + self._max_retries, + delay, ) self._refresh_session() + if delay: + time.sleep(delay) raise last_exc if last_exc else RuntimeError("Unexpected retry loop exit") + def _should_retry(self, exc: Exception) -> bool: + if isinstance( + exc, + ( + BrokenPipeError, + ConnectionResetError, + neo4j.exceptions.ServiceUnavailable, + ), + ): + return True + return self._retry_if(exc) if self._retry_if else False + + def _retry_delay(self, attempt: int) -> float: + max_delay = self._initial_retry_delay_seconds * (2**attempt) + return random.uniform(max_delay / 2, max_delay) if max_delay else 0 + def _refresh_session(self) -> None: if self._session is not None: try: diff --git a/api/src/backend/api/attack_paths/sink/drop.py b/api/src/backend/api/attack_paths/sink/drop.py index 9b4044a8f0..be13a394f5 100644 --- a/api/src/backend/api/attack_paths/sink/drop.py +++ b/api/src/backend/api/attack_paths/sink/drop.py @@ -42,6 +42,10 @@ def delete_batches( batch_size: int, drop_t0: float, ) -> tuple[int, int]: + def delete_batch(tx: Any) -> int: + record = tx.run(query, {"batch_size": batch_size}).single() + return (record[count_key] if record else 0) or 0 + deleted_total = initial_total batches = 0 while True: @@ -56,8 +60,7 @@ def delete_batches( deleted_total, time.perf_counter() - drop_t0, ) - record = session.run(query, {"batch_size": batch_size}).single() - deleted = (record[count_key] if record else 0) or 0 + deleted = session.execute_write(delete_batch) if deleted == 0: return deleted_total, batches diff --git a/api/src/backend/api/attack_paths/sink/neo4j.py b/api/src/backend/api/attack_paths/sink/neo4j.py index c248237f01..cb7d4889b0 100644 --- a/api/src/backend/api/attack_paths/sink/neo4j.py +++ b/api/src/backend/api/attack_paths/sink/neo4j.py @@ -355,7 +355,7 @@ class Neo4jSink(SinkDatabase): f"ON (n.`{PROVIDER_ELEMENT_ID_PROPERTY}`)" ) with self.get_session(database) as session: - session.run(query).consume() + session.execute_write(lambda tx: tx.run(query).consume()) def write_nodes( self, @@ -377,7 +377,7 @@ class Neo4jSink(SinkDatabase): SET n += row.props """ with self.get_session(database) as session: - session.run(query, {"rows": rows}).consume() + session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume()) def write_relationships( self, @@ -403,7 +403,7 @@ class Neo4jSink(SinkDatabase): SET r += row.props """ with self.get_session(database) as session: - session.run(query, {"rows": rows}).consume() + session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume()) # For compatibility with test harnesses that patch the concrete driver def get_driver(self) -> neo4j.Driver: diff --git a/api/src/backend/api/attack_paths/sink/neptune.py b/api/src/backend/api/attack_paths/sink/neptune.py index b0d12069a3..dbb687e89c 100644 --- a/api/src/backend/api/attack_paths/sink/neptune.py +++ b/api/src/backend/api/attack_paths/sink/neptune.py @@ -59,17 +59,28 @@ CONNECTION_TIMEOUT = env.int("NEPTUNE_CONNECTION_TIMEOUT", default=10) # Roll connections hourly so SigV4 rotations and cert refreshes don't strand long-lived pool entries MAX_CONNECTION_LIFETIME = env.int("NEPTUNE_MAX_CONNECTION_LIFETIME", default=3600) MAX_CONNECTION_POOL_SIZE = env.int("NEPTUNE_MAX_CONNECTION_POOL_SIZE", default=50) +NEPTUNE_WRITE_RETRY_DELAY_SECONDS = 2 READ_EXCEPTION_CODES = [ "Neo.ClientError.Statement.AccessMode", "Neo.ClientError.Procedure.ProcedureNotFound", ] CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement." +RETRYABLE_WRITE_ERROR_PREFIXES = ( + "Operation failed due to conflicting concurrent operations", + "Operation terminated (deadline exceeded)", +) # Refresh 60s before the 5-minute SigV4 window closes SIGV4_TOKEN_LIFETIME_MINUTES = 4 +def _is_retryable_write_error(exc: Exception) -> bool: + if not isinstance(exc, neo4j.exceptions.Neo4jError): + return False + return bool(exc.message and exc.message.startswith(RETRYABLE_WRITE_ERROR_PREFIXES)) + + class NeptuneSink(SinkDatabase): """Neptune-backed sink. Single database; isolation is label-based.""" @@ -205,11 +216,16 @@ class NeptuneSink(SinkDatabase): session_wrapper: RetryableSession | None = None try: + is_write_session = default_access_mode != neo4j.READ_ACCESS session_wrapper = RetryableSession( session_factory=lambda: driver.session( default_access_mode=default_access_mode ), max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, + retry_if=_is_retryable_write_error if is_write_session else None, + initial_retry_delay_seconds=( + NEPTUNE_WRITE_RETRY_DELAY_SECONDS if is_write_session else 0 + ), ) yield session_wrapper @@ -405,7 +421,7 @@ class NeptuneSink(SinkDatabase): SET n.`{PROVIDER_ELEMENT_ID_PROPERTY}` = row.provider_element_id """ with self.get_session() as session: - session.run(query, {"rows": rows}).consume() + session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume()) def write_relationships( self, @@ -429,7 +445,7 @@ class NeptuneSink(SinkDatabase): SET r += row.props """ with self.get_session() as session: - session.run(query, {"rows": rows}).consume() + session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume()) # Test helpers diff --git a/api/src/backend/api/tests/test_retryable_session.py b/api/src/backend/api/tests/test_retryable_session.py new file mode 100644 index 0000000000..8bb457b8b6 --- /dev/null +++ b/api/src/backend/api/tests/test_retryable_session.py @@ -0,0 +1,85 @@ +from unittest.mock import MagicMock, patch + +import pytest +from api.attack_paths.retryable_session import RetryableSession +from neo4j.exceptions import ServiceUnavailable + + +class TestRetryableSession: + @patch("api.attack_paths.retryable_session.time.sleep") + @patch("api.attack_paths.retryable_session.random.uniform", return_value=3.0) + def test_custom_retry_uses_backoff_and_a_fresh_session( + self, mock_uniform, mock_sleep + ): + retryable_error = RuntimeError("retryable") + first_session = MagicMock() + first_session.execute_write.side_effect = retryable_error + second_session = MagicMock() + second_session.execute_write.return_value = "success" + session_factory = MagicMock(side_effect=[first_session, second_session]) + work = MagicMock() + + session = RetryableSession( + session_factory=session_factory, + max_retries=3, + retry_if=lambda exc: exc is retryable_error, + initial_retry_delay_seconds=2, + ) + + assert session.execute_write(work) == "success" + assert session_factory.call_count == 2 + first_session.close.assert_called_once_with() + mock_uniform.assert_called_once_with(2.0, 4.0) + mock_sleep.assert_called_once_with(3.0) + + def test_connection_errors_remain_retryable(self): + first_session = MagicMock() + first_session.run.side_effect = ServiceUnavailable("unavailable") + second_session = MagicMock() + second_session.run.return_value = "success" + session_factory = MagicMock(side_effect=[first_session, second_session]) + + session = RetryableSession(session_factory=session_factory, max_retries=1) + + assert session.run("RETURN 1") == "success" + first_session.close.assert_called_once_with() + + def test_non_retryable_error_is_raised_without_refreshing_session(self): + error = RuntimeError("do not retry") + driver_session = MagicMock() + driver_session.execute_write.side_effect = error + session_factory = MagicMock(return_value=driver_session) + session = RetryableSession( + session_factory=session_factory, + max_retries=3, + retry_if=lambda _: False, + initial_retry_delay_seconds=2, + ) + + with pytest.raises(RuntimeError) as exc_info: + session.execute_write(MagicMock()) + + assert exc_info.value is error + session_factory.assert_called_once_with() + driver_session.close.assert_not_called() + + def test_retry_exhaustion_raises_the_last_error(self): + error = RuntimeError("still retryable") + driver_sessions = [MagicMock() for _ in range(3)] + for driver_session in driver_sessions: + driver_session.execute_write.side_effect = error + session_factory = MagicMock(side_effect=driver_sessions) + session = RetryableSession( + session_factory=session_factory, + max_retries=2, + retry_if=lambda _: True, + ) + + with pytest.raises(RuntimeError) as exc_info: + session.execute_write(MagicMock()) + + assert exc_info.value is error + assert session_factory.call_count == 3 + driver_sessions[0].close.assert_called_once_with() + driver_sessions[1].close.assert_called_once_with() + driver_sessions[2].close.assert_not_called() diff --git a/api/src/backend/api/tests/test_sink.py b/api/src/backend/api/tests/test_sink.py index 4bb302d492..3636771ddc 100644 --- a/api/src/backend/api/tests/test_sink.py +++ b/api/src/backend/api/tests/test_sink.py @@ -6,18 +6,20 @@ builds dual writer/reader Bolt drivers. """ import json -from importlib import import_module from unittest.mock import MagicMock, patch +import neo4j import pytest - -# Prime patch-target resolution. `api.attack_paths.sink/__init__.py` doesn't -# eagerly import these submodules (they're loaded on demand inside the -# factory), so `mock.patch("api.attack_paths.sink..…")` would fail with -# AttributeError on first call. Importing here registers them as attributes -# of the package before any decorator runs. -import_module("api.attack_paths.sink.neo4j") -import_module("api.attack_paths.sink.neptune") +from api.attack_paths import sink as sink_module +from api.attack_paths.database import GraphDatabaseQueryException +from api.attack_paths.sink import factory +from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink +from api.attack_paths.sink.neptune import ( + NEPTUNE_WRITE_RETRY_DELAY_SECONDS, + NeptuneSink, + _is_retryable_write_error, + _NeptuneAuthToken, +) @pytest.fixture(autouse=True) @@ -26,8 +28,6 @@ def reset_sink_state(): The cache lives in `api.attack_paths.sink.factory`, not on the package. """ - from api.attack_paths.sink import factory - original_backend = factory._backend original_secondary = dict(factory._secondary_backends) factory._backend = None @@ -40,29 +40,20 @@ def reset_sink_state(): class TestSinkFactory: def test_default_resolves_to_neo4j(self, settings): - from api.attack_paths.sink import factory - settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" assert factory._resolve_setting() == "neo4j" def test_neptune_resolves_correctly(self, settings): - from api.attack_paths.sink import factory - settings.ATTACK_PATHS_SINK_DATABASE = "neptune" assert factory._resolve_setting() == "neptune" def test_invalid_value_raises(self, settings): - from api.attack_paths.sink import factory - settings.ATTACK_PATHS_SINK_DATABASE = "foo" with pytest.raises(RuntimeError, match="ATTACK_PATHS_SINK_DATABASE"): factory._resolve_setting() @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") def test_init_builds_neo4j_backend_by_default(self, mock_driver, settings): - from api.attack_paths import sink as sink_module - from api.attack_paths.sink.neo4j import Neo4jSink - settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" settings.DATABASES = { **settings.DATABASES, @@ -85,9 +76,6 @@ class TestSinkFactory: def test_init_builds_neptune_backend( self, mock_driver, mock_auth_provider, settings ): - from api.attack_paths import sink as sink_module - from api.attack_paths.sink.neptune import NeptuneSink - settings.ATTACK_PATHS_SINK_DATABASE = "neptune" settings.DATABASES = { **settings.DATABASES, @@ -116,8 +104,6 @@ class TestSinkFactory: def test_neptune_reader_falls_back_to_writer( self, mock_driver, mock_auth_provider, settings ): - from api.attack_paths import sink as sink_module - settings.ATTACK_PATHS_SINK_DATABASE = "neptune" settings.DATABASES = { **settings.DATABASES, @@ -144,8 +130,6 @@ class TestGetBackendForScan: def test_legacy_scan_in_neo4j_process_uses_active_backend( self, mock_driver, settings ): - from api.attack_paths import sink as sink_module - settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" settings.DATABASES = { **settings.DATABASES, @@ -164,8 +148,6 @@ class TestGetBackendForScan: assert backend is sink_module.get_backend() def test_neptune_scan_on_neo4j_process_uses_neptune_secondary(self, settings): - from api.attack_paths.sink import factory - settings.ATTACK_PATHS_SINK_DATABASE = "neo4j" active_neo4j = MagicMock(name="neo4j-active") factory._backend = active_neo4j @@ -190,6 +172,29 @@ def _count_result(key: str, count: int) -> MagicMock: return MagicMock(single=MagicMock(return_value={key: count})) +def _run_managed_write(session: MagicMock) -> MagicMock: + transaction = MagicMock() + session.execute_write.call_args.args[0](transaction) + return transaction + + +def _managed_write_session( + results: list[MagicMock], +) -> tuple[MagicMock, list[MagicMock]]: + session = MagicMock() + transactions: list[MagicMock] = [] + result_iter = iter(results) + + def execute_write(work): + transaction = MagicMock() + transaction.run.return_value = next(result_iter) + transactions.append(transaction) + return work(transaction) + + session.execute_write.side_effect = execute_write + return session, transactions + + def _directed_drop_results( outgoing_rels: int, incoming_rels: int, @@ -207,31 +212,26 @@ def _directed_drop_results( class TestNeo4jSinkSyncWrites: def test_ensure_sync_indexes_runs_create_index_idempotent(self): - from api.attack_paths.sink.neo4j import Neo4jSink - sink = Neo4jSink() session = MagicMock() - session.run.return_value = MagicMock() with patch.object(sink, "get_session", return_value=_session_ctx(session)): sink.ensure_sync_indexes("db-tenant-x") - query = session.run.call_args.args[0] + transaction = _run_managed_write(session) + query = transaction.run.call_args.args[0] assert "CREATE INDEX" in query assert "IF NOT EXISTS" in query assert "`_ProviderResource`" in query assert "`_provider_element_id`" in query + transaction.run.return_value.consume.assert_called_once_with() def test_write_nodes_skips_empty_batch(self): - from api.attack_paths.sink.neo4j import Neo4jSink - sink = Neo4jSink() with patch.object(sink, "get_session") as get_session: sink.write_nodes("db-tenant-x", "`AWSUser`", []) get_session.assert_not_called() def test_write_nodes_merges_on_provider_resource_label(self): - from api.attack_paths.sink.neo4j import Neo4jSink - sink = Neo4jSink() session = MagicMock() with patch.object(sink, "get_session", return_value=_session_ctx(session)): @@ -241,15 +241,15 @@ class TestNeo4jSinkSyncWrites: [{"provider_element_id": "p:e", "props": {"k": "v"}}], ) - query, params = session.run.call_args.args + transaction = _run_managed_write(session) + query, params = transaction.run.call_args.args assert "MERGE (n:`_ProviderResource`" in query assert "`_provider_element_id`: row.provider_element_id" in query assert "SET n:`AWSUser`:`_ProviderResource`" in query assert params == {"rows": [{"provider_element_id": "p:e", "props": {"k": "v"}}]} + transaction.run.return_value.consume.assert_called_once_with() def test_write_relationships_scopes_endpoints_by_provider_label(self): - from api.attack_paths.sink.neo4j import Neo4jSink - sink = Neo4jSink() session = MagicMock() provider_id = "00000000-0000-0000-0000-000000000abc" @@ -268,24 +268,22 @@ class TestNeo4jSinkSyncWrites: ], ) - query = session.run.call_args.args[0] + transaction = _run_managed_write(session) + query = transaction.run.call_args.args[0] assert ":`_Provider_00000000000000000000000000000abc`" in query assert ":RESOURCE" in query.replace("`", "") assert "MERGE (s)-[r:`RESOURCE`" in query + transaction.run.return_value.consume.assert_called_once_with() class TestNeptuneSinkSyncWrites: def test_ensure_sync_indexes_is_noop(self): - from api.attack_paths.sink.neptune import NeptuneSink - sink = NeptuneSink() with patch.object(sink, "get_session") as get_session: sink.ensure_sync_indexes("ignored") get_session.assert_not_called() def test_write_nodes_merges_on_neptune_id_with_provider_resource_label(self): - from api.attack_paths.sink.neptune import NeptuneSink - sink = NeptuneSink() session = MagicMock() with patch.object(sink, "get_session", return_value=_session_ctx(session)): @@ -295,16 +293,16 @@ class TestNeptuneSinkSyncWrites: [{"provider_element_id": "p:e", "props": {"k": "v"}}], ) - query = session.run.call_args.args[0] + transaction = _run_managed_write(session) + query = transaction.run.call_args.args[0] # Neptune assigns a default `vertex` label to any unlabeled node, # so the MERGE must pin a real label at creation time. assert "MERGE (n:`_ProviderResource` {`~id`: row.provider_element_id})" in query assert "SET n:`AWSUser`" in query assert "SET n.`_provider_element_id` = row.provider_element_id" in query + transaction.run.return_value.consume.assert_called_once_with() def test_write_relationships_matches_endpoints_by_id(self): - from api.attack_paths.sink.neptune import NeptuneSink - sink = NeptuneSink() session = MagicMock() with patch.object(sink, "get_session", return_value=_session_ctx(session)): @@ -322,30 +320,86 @@ class TestNeptuneSinkSyncWrites: ], ) - query = session.run.call_args.args[0] + transaction = _run_managed_write(session) + query = transaction.run.call_args.args[0] assert "MATCH (s) WHERE id(s) = row.start_element_id" in query assert "MATCH (e) WHERE id(e) = row.end_element_id" in query assert "MERGE (s)-[r:`RESOURCE`" in query + transaction.run.return_value.consume.assert_called_once_with() + + +class TestNeptuneRetryPolicy: + @pytest.mark.parametrize( + "message", + [ + "Operation failed due to conflicting concurrent operations " + + "(please retry), 0 transactions are currently rolling back.", + "Operation terminated (deadline exceeded)", + ], + ) + def test_observed_transient_write_errors_are_retryable(self, message): + error = MagicMock(spec=neo4j.exceptions.Neo4jError) + error.message = message + + assert _is_retryable_write_error(error) is True + + def test_unrelated_database_error_is_not_retryable(self): + error = MagicMock(spec=neo4j.exceptions.Neo4jError) + error.message = "Operation terminated (out of memory)" + + assert _is_retryable_write_error(error) is False + + def test_non_neo4j_error_is_not_retryable(self): + error = RuntimeError( + "Operation failed due to conflicting concurrent operations" + ) + + assert _is_retryable_write_error(error) is False + + @patch("api.attack_paths.sink.neptune.RetryableSession") + def test_writer_session_enables_neptune_retry_policy(self, retryable_session): + sink = NeptuneSink() + driver = MagicMock() + with patch.object(sink, "_get_writer", return_value=driver): + with sink.get_session(): + pass + + kwargs = retryable_session.call_args.kwargs + assert kwargs["retry_if"] is _is_retryable_write_error + assert ( + kwargs["initial_retry_delay_seconds"] == NEPTUNE_WRITE_RETRY_DELAY_SECONDS + ) + + @patch("api.attack_paths.sink.neptune.RetryableSession") + def test_reader_session_does_not_enable_write_retry_policy(self, retryable_session): + sink = NeptuneSink() + driver = MagicMock() + with patch.object(sink, "_get_reader", return_value=driver): + with sink.get_session(default_access_mode=neo4j.READ_ACCESS): + pass + + kwargs = retryable_session.call_args.kwargs + assert kwargs["retry_if"] is None + assert kwargs["initial_retry_delay_seconds"] == 0 class TestNeptuneSinkDropSubgraph: def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self): - from api.attack_paths.sink.neptune import NeptuneSink - sink = NeptuneSink() - session = MagicMock() - session.run.side_effect = _directed_drop_results( - outgoing_rels=50, - incoming_rels=30, - nodes=10, + session, transactions = _managed_write_session( + _directed_drop_results( + outgoing_rels=50, + incoming_rels=30, + nodes=10, + ) ) with patch.object(sink, "get_session", return_value=_session_ctx(session)): deleted = sink.drop_subgraph("ignored", "provider-1") assert deleted == 10 - assert session.run.call_count == 6 - queries = [call.args[0] for call in session.run.call_args_list] + assert session.execute_write.call_count == 6 + queries = [transaction.run.call_args.args[0] for transaction in transactions] assert ")-[r]->()" in queries[0] assert ")<-[r]-()" in queries[2] @@ -362,14 +416,13 @@ class TestNeo4jSinkDropSubgraph: """Neo4j drop deletes relationships then nodes in batches (no ``DETACH DELETE``).""" def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self): - from api.attack_paths.sink.neo4j import Neo4jSink - sink = Neo4jSink() - session = MagicMock() - session.run.side_effect = _directed_drop_results( - outgoing_rels=50, - incoming_rels=30, - nodes=10, + session, transactions = _managed_write_session( + _directed_drop_results( + outgoing_rels=50, + incoming_rels=30, + nodes=10, + ) ) provider_id = "00000000-0000-0000-0000-000000000abc" @@ -378,9 +431,9 @@ class TestNeo4jSinkDropSubgraph: # Only phase-2 node counts contribute to the return value. assert deleted == 10 - assert session.run.call_count == 6 + assert session.execute_write.call_count == 6 - queries = [call.args[0] for call in session.run.call_args_list] + queries = [transaction.run.call_args.args[0] for transaction in transactions] # Regression guard: the memory blow-up was caused by DETACH DELETE. assert all("DETACH DELETE" not in query for query in queries) assert all("DISTINCT r" not in query for query in queries) @@ -399,12 +452,9 @@ class TestNeo4jSinkDropSubgraph: assert last_rel < first_node def test_drop_subgraph_returns_zero_when_database_does_not_exist(self): - from api.attack_paths.database import GraphDatabaseQueryException - from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink - sink = Neo4jSink() session = MagicMock() - session.run.side_effect = GraphDatabaseQueryException( + session.execute_write.side_effect = GraphDatabaseQueryException( message="db missing", code=DATABASE_NOT_FOUND_CODE ) @@ -418,8 +468,6 @@ class TestSinkHasProviderData: """``has_provider_data`` is the read-path probe used by API views.""" def test_neo4j_returns_true_when_provider_node_exists(self): - from api.attack_paths.sink.neo4j import Neo4jSink - sink = Neo4jSink() session = MagicMock() session.run.return_value.single.return_value = MagicMock() @@ -433,9 +481,6 @@ class TestSinkHasProviderData: assert ":`_Provider_00000000000000000000000000000abc`" in query def test_neo4j_returns_false_when_database_does_not_exist(self): - from api.attack_paths.database import GraphDatabaseQueryException - from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink - sink = Neo4jSink() session = MagicMock() session.run.side_effect = GraphDatabaseQueryException( @@ -448,8 +493,6 @@ class TestSinkHasProviderData: assert present is False def test_neptune_returns_true_when_provider_node_exists(self): - from api.attack_paths.sink.neptune import NeptuneSink - sink = NeptuneSink() session = MagicMock() session.run.return_value.single.return_value = MagicMock() @@ -463,8 +506,6 @@ class TestGetBackendForScanCutover: """``get_backend_for_scan`` keeps old-sink scans queryable after cutover.""" def test_legacy_scan_on_neptune_process_uses_neo4j_secondary(self, settings): - from api.attack_paths.sink import factory - settings.ATTACK_PATHS_SINK_DATABASE = "neptune" active_neptune = MagicMock(name="neptune-active") factory._backend = active_neptune @@ -487,8 +528,6 @@ class TestSinkVerifyConnectivity: @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") def test_neo4j_verifies_its_driver(self, mock_driver, settings): - from api.attack_paths.sink.neo4j import Neo4jSink - settings.DATABASES = { **settings.DATABASES, "neo4j": { @@ -513,8 +552,6 @@ class TestSinkVerifyConnectivity: def test_neptune_verifies_reader_not_writer( self, mock_driver, mock_auth_provider, settings ): - from api.attack_paths.sink.neptune import NeptuneSink - settings.DATABASES = { **settings.DATABASES, "neptune": { @@ -548,8 +585,6 @@ class TestSinkInitToleratesUnreachableSink: @patch("api.attack_paths.sink.neo4j.neo4j.GraphDatabase.driver") def test_neo4j_init_continues_when_verify_fails(self, mock_driver, settings): - from api.attack_paths.sink.neo4j import Neo4jSink - settings.DATABASES = { **settings.DATABASES, "neo4j": { @@ -573,8 +608,6 @@ class TestSinkInitToleratesUnreachableSink: def test_neptune_init_continues_when_verify_fails( self, mock_driver, mock_auth_provider, settings ): - from api.attack_paths.sink.neptune import NeptuneSink - settings.DATABASES = { **settings.DATABASES, "neptune": { @@ -601,8 +634,6 @@ class TestNeptuneAdminNoOps: @pytest.mark.parametrize("method", ["create_database", "drop_database"]) def test_admin_ops_return_none_without_touching_a_session(self, method): - from api.attack_paths.sink.neptune import NeptuneSink - sink = NeptuneSink() with patch.object(sink, "get_session") as get_session: assert getattr(sink, method)("ignored") is None @@ -617,8 +648,6 @@ class TestNeptuneAuthToken: def test_host_header_includes_non_default_port(self, mock_boto, mock_sigv4): # Neptune runs on 8182; the SigV4 canonical Host must keep the port or # the signature is rejected. - from api.attack_paths.sink.neptune import _NeptuneAuthToken - credentials = MagicMock() credentials.get_frozen_credentials.return_value = MagicMock() mock_boto.return_value.get_credentials.return_value = credentials From d37d5058bb9a61d39d9fc7b8f41b3050cffeb3c3 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Tue, 14 Jul 2026 09:48:12 +0200 Subject: [PATCH 03/22] fix(api): bound attack paths normalized-list child IDs (#11960) --- .../attack-paths-child-id-size.fixed.md | 1 + .../backend/tasks/jobs/attack_paths/sync.py | 9 ++-- .../tasks/tests/test_attack_paths_scan.py | 49 +++++++++++++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 api/changelog.d/attack-paths-child-id-size.fixed.md diff --git a/api/changelog.d/attack-paths-child-id-size.fixed.md b/api/changelog.d/attack-paths-child-id-size.fixed.md new file mode 100644 index 0000000000..4479bbbb20 --- /dev/null +++ b/api/changelog.d/attack-paths-child-id-size.fixed.md @@ -0,0 +1 @@ +Attack Paths scans now use bounded child node identifiers for normalized list values in Neo4j and Neptune, preventing Neo4j RANGE index key size failures diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py index 7b73fa21e2..00c2c585c7 100644 --- a/api/src/backend/tasks/jobs/attack_paths/sync.py +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -19,6 +19,7 @@ import json import time from collections import defaultdict from collections.abc import Iterator +from hashlib import sha256 from typing import Any import neo4j @@ -392,11 +393,11 @@ def _build_child_props( def _build_child_id(provider_id: str, child_label: str, value_key: str) -> str: """Deterministic `_provider_element_id` for a list-item child node. - Dedupes within (tenant, provider): multiple parents referencing the same - value share one child node via the existing MERGE-on-_provider_element_id - index in both sinks. + Hashing the value keeps the ID bounded while preserving deduplication within + each provider and child label. """ - return f"{provider_id}::{child_label}::{value_key}" + value_digest = sha256(value_key.encode("utf-8")).hexdigest() + return f"{provider_id}::{child_label}::{value_digest}" def _build_catalog_index( diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index d29d0980b0..db4dc4b0c6 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -1828,6 +1828,55 @@ def _make_session_ctx(session, call_order=None, name=None): return ctx +class TestBuildChildId: + def test_large_value_is_hashed_and_preserved_as_child_data(self): + value = "x" * 22_796 + spec = sync_module.NormalizedList( + "SomeLabel", + "values", + "SomeLabelValuesItem", + "HAS_VALUES", + ) + record = { + "element_id": "elem-1", + "labels": ["SomeLabel"], + "props": {"values": [value]}, + } + + _, parent, children, relationships = sync_module._node_to_sync_dict( + record, + "prov-1", + sync_module._build_catalog_index([spec]), + ) + + child = children[0]["row"] + child_id = child["provider_element_id"] + prefix = "prov-1::SomeLabelValuesItem::" + assert parent["provider_element_id"] == "prov-1:elem-1" + assert child["props"]["value"] == value + assert len(child_id) == len(prefix) + 64 + assert value not in child_id + assert relationships[0]["row"]["end_element_id"] == child_id + + @pytest.mark.parametrize( + ("provider_id", "child_label", "value_key"), + [ + ("prov-2", "ChildLabel", "value"), + ("prov-1", "OtherChildLabel", "value"), + ("prov-1", "ChildLabel", "other-value"), + ], + ) + def test_each_identity_component_changes_id( + self, provider_id, child_label, value_key + ): + child_id = sync_module._build_child_id("prov-1", "ChildLabel", "value") + + assert sync_module._build_child_id("prov-1", "ChildLabel", "value") == child_id + assert ( + sync_module._build_child_id(provider_id, child_label, value_key) != child_id + ) + + class TestSyncNodes: def test_iter_sink_batches_rejects_zero_batch_size(self): with pytest.raises( From 53772e2931ce3e7b40c9fa1034723134fa781ef3 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Tue, 14 Jul 2026 10:40:26 +0200 Subject: [PATCH 04/22] fix(api): prevent concurrent scan summary deadlocks (#11970) --- .../scan-summary-deadlocks.fixed.md | 1 + api/src/backend/tasks/jobs/scan.py | 17 +++- api/src/backend/tasks/tests/test_scan.py | 89 +++++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 api/changelog.d/scan-summary-deadlocks.fixed.md diff --git a/api/changelog.d/scan-summary-deadlocks.fixed.md b/api/changelog.d/scan-summary-deadlocks.fixed.md new file mode 100644 index 0000000000..0fcb4479bd --- /dev/null +++ b/api/changelog.d/scan-summary-deadlocks.fixed.md @@ -0,0 +1 @@ +`scan-summary` aggregation now upserts summaries in deterministic conflict-key order, preventing PostgreSQL deadlocks during concurrent reaggregation diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 8290c45a7a..39db32abe4 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -1464,7 +1464,7 @@ def aggregate_findings(tenant_id: str, scan_id: str): ) with rls_transaction(tenant_id): - scan_aggregations = { + scan_aggregations = [ ScanSummary( tenant_id=tenant_id, scan_id=scan_id, @@ -1489,9 +1489,18 @@ def aggregate_findings(tenant_id: str, scan_id: str): for agg in aggregation if agg["resources__service"] is not None and agg["resources__region"] is not None - } - # Upsert so re-runs (post-mute reaggregation) don't trip - # `unique_scan_summary`; race-safe under concurrent writers. + ] + # Needed sort so concurrent upserts acquire locks consistently + scan_aggregations.sort( + key=lambda summary: ( + summary.tenant_id, + summary.scan_id, + summary.check_id, + summary.service, + summary.severity, + summary.region, + ) + ) ScanSummary.objects.bulk_create( scan_aggregations, batch_size=3000, diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index f49ca6655b..2a66985276 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -3652,6 +3652,95 @@ class TestAggregateFindings: regions = {s.region for s in summaries} assert regions == {"us-east-1", "us-west-2"} + @patch("tasks.jobs.scan.Finding.objects.filter") + @patch("tasks.jobs.scan.ScanSummary.objects.bulk_create") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_orders_upserts_by_conflict_key( + self, mock_rls_transaction, mock_bulk_create, mock_findings_filter + ): + """Scan summaries must use a stable lock order for concurrent upserts.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + counts = { + "fail": 1, + "_pass": 0, + "muted_count": 0, + "total": 1, + "new": 1, + "changed": 0, + "unchanged": 0, + "fail_new": 1, + "fail_changed": 0, + "pass_new": 0, + "pass_changed": 0, + "muted_new": 0, + "muted_changed": 0, + } + + mock_queryset = MagicMock() + mock_queryset.values.return_value = mock_queryset + mock_queryset.annotate.return_value = [ + { + "check_id": "check-b", + "resources__service": "s3", + "severity": "high", + "resources__region": "us-east-1", + **counts, + }, + { + "check_id": "check-a", + "resources__service": "sqs", + "severity": "high", + "resources__region": "us-east-1", + **counts, + }, + { + "check_id": "check-a", + "resources__service": "s3", + "severity": "medium", + "resources__region": "us-east-1", + **counts, + }, + { + "check_id": "check-a", + "resources__service": "s3", + "severity": "high", + "resources__region": "us-west-2", + **counts, + }, + { + "check_id": "check-a", + "resources__service": "s3", + "severity": "high", + "resources__region": "us-east-1", + **counts, + }, + ] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + aggregate_findings(tenant_id, scan_id) + + summaries = mock_bulk_create.call_args.args[0] + assert isinstance(summaries, list) + conflict_keys = [ + ( + str(summary.tenant_id), + str(summary.scan_id), + summary.check_id, + summary.service, + summary.severity, + summary.region, + ) + for summary in summaries + ] + assert len(conflict_keys) == 5 + assert conflict_keys == sorted(conflict_keys) + @patch("tasks.jobs.scan.Finding.objects.filter") @patch("tasks.jobs.scan.ScanSummary.objects.bulk_create") @patch("tasks.jobs.scan.rls_transaction") From 7df1054966e617f504230915ace7d6017e52ba72 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Tue, 14 Jul 2026 12:02:35 +0200 Subject: [PATCH 05/22] chore(step-security): allow endpoints (#11973) --- .github/workflows/api-container-checks.yml | 2 ++ .github/workflows/renovate-config-validate.yml | 2 ++ .github/workflows/sdk-container-checks.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 4798fd9df7..f8d5df5417 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -92,6 +92,8 @@ jobs: _http._tcp.deb.debian.org:443 powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 get.trivy.dev:443 + raw.githubusercontent.com:443 + releases.astral.sh:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/renovate-config-validate.yml b/.github/workflows/renovate-config-validate.yml index 8426efa6b4..daed2353c1 100644 --- a/.github/workflows/renovate-config-validate.yml +++ b/.github/workflows/renovate-config-validate.yml @@ -34,6 +34,7 @@ jobs: allowed-endpoints: > api.github.com:443 github.com:443 + raw.githubusercontent.com:443 objects.githubusercontent.com:443 codeload.github.com:443 release-assets.githubusercontent.com:443 @@ -41,6 +42,7 @@ jobs: files.pythonhosted.org:443 registry.npmjs.org:443 nodejs.org:443 + releases.astral.sh:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 709a785974..1e2e092dbc 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -94,6 +94,8 @@ jobs: _http._tcp.deb.debian.org:443 powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 get.trivy.dev:443 + raw.githubusercontent.com:443 + releases.astral.sh:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From 54237d824a89ce265100d76a62e6d5437569fb94 Mon Sep 17 00:00:00 2001 From: Pepe Fagoaga Date: Tue, 14 Jul 2026 12:15:13 +0200 Subject: [PATCH 06/22] chore(banner): missing feats and highlight component (#11974) --- prowler/lib/banner.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index cfd576ed15..5b2332e772 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -18,8 +18,8 @@ def print_banner(legend: bool = False, provider: str = None): _ __ _ __ _____ _| | ___ _ __ | '_ \| '__/ _ \ \ /\ / / |/ _ \ '__| | |_) | | | (_) \ V V /| | __/ | -| .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version} -|_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL} +| .__/|_| \___/ \_/\_/ |_|\___|_| CLI - v{prowler_version} +|_| {Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL} """ @@ -43,8 +43,9 @@ def print_prowler_cloud_banner(provider: str = None): the open-source CLI. Shown at the start and end of a scan to let users know about the managed - platform capabilities they are missing (attack paths, AI, organizations, - continuous scanning, integrations and live compliance dashboards). + platform capabilities they are missing (CLI findings upload, attack paths, + AI, triage, organizations, continuous scanning with custom scheduling and + scan configuration, integrations and live compliance dashboards). Parameters: - provider (str): The provider that was scanned, used to tailor the message. @@ -57,7 +58,9 @@ def print_prowler_cloud_banner(provider: str = None): print(f""" {bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL} {bar} -{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - scheduled scans with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Send your findings{Style.RESET_ALL} - directly from the Prowler CLI to Prowler Cloud. +{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - custom scheduling and scan configuration with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Triage{Style.RESET_ALL} - review findings, flag false positives and track accepted risk with your team. {bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation. {bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening. {bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date. From db9356ba867776f54d2ae4c793faf1e1b0234393 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Tue, 14 Jul 2026 12:16:36 +0200 Subject: [PATCH 07/22] chore(changelog): v5.33.2 forward-sync to master (#11977) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- api/CHANGELOG.md | 10 ++++++++++ api/changelog.d/attack-paths-child-id-size.fixed.md | 1 - .../attack-paths-graph-mutation-retries.fixed.md | 1 - api/changelog.d/scan-summary-deadlocks.fixed.md | 1 - prowler/CHANGELOG.md | 9 +++++++++ .../ec2-imdsv2-regional-resource-identity.fixed.md | 1 - prowler/changelog.d/ec2-targeted-ami-loading.fixed.md | 1 - 7 files changed, 19 insertions(+), 5 deletions(-) delete mode 100644 api/changelog.d/attack-paths-child-id-size.fixed.md delete mode 100644 api/changelog.d/attack-paths-graph-mutation-retries.fixed.md delete mode 100644 api/changelog.d/scan-summary-deadlocks.fixed.md delete mode 100644 prowler/changelog.d/ec2-imdsv2-regional-resource-identity.fixed.md delete mode 100644 prowler/changelog.d/ec2-targeted-ami-loading.fixed.md diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 9e8b541130..ba5e0638e0 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.34.2] (Prowler v5.33.2) + +### 🐞 Fixed + +- Attack Paths graph mutations now retry transient Neptune concurrency and deadline failures, while Neo4j mutations use managed transaction retries [(#11968)](https://github.com/prowler-cloud/prowler/pull/11968) +- Attack Paths scans now use bounded child node identifiers for normalized list values in Neo4j and Neptune, preventing Neo4j RANGE index key size failures [(#11969)](https://github.com/prowler-cloud/prowler/pull/11969) +- `scan-summary` aggregation now upserts summaries in deterministic conflict-key order, preventing PostgreSQL deadlocks during concurrent reaggregation [(#11971)](https://github.com/prowler-cloud/prowler/pull/11971) + +--- + ## [1.34.1] (Prowler v5.33.1) ### 🐞 Fixed diff --git a/api/changelog.d/attack-paths-child-id-size.fixed.md b/api/changelog.d/attack-paths-child-id-size.fixed.md deleted file mode 100644 index 4479bbbb20..0000000000 --- a/api/changelog.d/attack-paths-child-id-size.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Attack Paths scans now use bounded child node identifiers for normalized list values in Neo4j and Neptune, preventing Neo4j RANGE index key size failures diff --git a/api/changelog.d/attack-paths-graph-mutation-retries.fixed.md b/api/changelog.d/attack-paths-graph-mutation-retries.fixed.md deleted file mode 100644 index f48e99462c..0000000000 --- a/api/changelog.d/attack-paths-graph-mutation-retries.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Attack Paths graph mutations now retry transient Neptune concurrency and deadline failures, while Neo4j mutations use managed transaction retries diff --git a/api/changelog.d/scan-summary-deadlocks.fixed.md b/api/changelog.d/scan-summary-deadlocks.fixed.md deleted file mode 100644 index 0fcb4479bd..0000000000 --- a/api/changelog.d/scan-summary-deadlocks.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`scan-summary` aggregation now upserts summaries in deterministic conflict-key order, preventing PostgreSQL deadlocks during concurrent reaggregation diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 3a9ebff986..2a0d5899f5 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.33.2] (Prowler v5.33.2) + +### 🐞 Fixed + +- EC2 AMI loading now targets Amazon-owned AMIs used by audited instances, reducing AWS API calls during EC2 scans [(#11958)](https://github.com/prowler-cloud/prowler/pull/11958) +- `ec2_instance_account_imdsv2_enabled` findings now use regional resource ARNs, preventing findings from different AWS Regions from collapsing into one resource [(#11966)](https://github.com/prowler-cloud/prowler/pull/11966) + +--- + ## [5.33.1] (Prowler v5.33.1) ### 🐞 Fixed diff --git a/prowler/changelog.d/ec2-imdsv2-regional-resource-identity.fixed.md b/prowler/changelog.d/ec2-imdsv2-regional-resource-identity.fixed.md deleted file mode 100644 index 97e854ce61..0000000000 --- a/prowler/changelog.d/ec2-imdsv2-regional-resource-identity.fixed.md +++ /dev/null @@ -1 +0,0 @@ -`ec2_instance_account_imdsv2_enabled` findings now use regional resource ARNs, preventing findings from different AWS Regions from collapsing into one resource diff --git a/prowler/changelog.d/ec2-targeted-ami-loading.fixed.md b/prowler/changelog.d/ec2-targeted-ami-loading.fixed.md deleted file mode 100644 index 6fed547536..0000000000 --- a/prowler/changelog.d/ec2-targeted-ami-loading.fixed.md +++ /dev/null @@ -1 +0,0 @@ -EC2 AMI loading now targets Amazon-owned AMIs used by audited instances, reducing AWS API calls during EC2 scans From 9c15796c846cb76352668a9d2d76cd070333de38 Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:26:33 +0200 Subject: [PATCH 08/22] fix(ci): extend .trivyignore CVE suppression expiries to 2026-08-15 (#11975) --- .trivyignore | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/.trivyignore b/.trivyignore index 744c94a193..166f6389ad 100644 --- a/.trivyignore +++ b/.trivyignore @@ -15,21 +15,21 @@ # neither vulnerable code path (Archive::Tar parsing or regex compilation of # attacker-controlled input) is reachable from Prowler. No Debian bookworm fix # is available yet. -CVE-2026-42496 pkg:perl exp:2026-07-15 -CVE-2026-42496 pkg:perl-base exp:2026-07-15 -CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15 -CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15 -CVE-2026-8376 pkg:perl exp:2026-07-15 -CVE-2026-8376 pkg:perl-base exp:2026-07-15 -CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15 -CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15 +CVE-2026-42496 pkg:perl exp:2026-08-15 +CVE-2026-42496 pkg:perl-base exp:2026-08-15 +CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15 +CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15 +CVE-2026-8376 pkg:perl exp:2026-08-15 +CVE-2026-8376 pkg:perl-base exp:2026-08-15 +CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15 +CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15 # CVE-2025-7458 — SQLite integer overflow. # Package: libsqlite3-0. # Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The # Prowler SDK does not open user-supplied SQLite databases; SQLite usage is # internal and bounded. No Debian bookworm fix is available. -CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15 +CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15 # CVE-2026-43185 — Linux kernel ksmbd signedness bug. # Package: linux-libc-dev. @@ -37,7 +37,7 @@ CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15 # not a running kernel. Containers execute against the host kernel, so these # headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and # has not been backported to Debian's 6.1 LTS line. -CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15 +CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15 # CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in # zipOpenNewFileInZip4_64. @@ -49,8 +49,8 @@ CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15 # zlib 1.3.1, available in Debian trixie (13); migrating the base image would # clear it fully. # Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853 -CVE-2023-45853 pkg:zlib1g exp:2026-07-15 -CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15 +CVE-2023-45853 pkg:zlib1g exp:2026-08-15 +CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15 # CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to # an unchecked packet_length field in transport.c (heap corruption, possible RCE). @@ -63,7 +63,7 @@ CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15 # affected code is unreachable at runtime. Fixed upstream in libssh2 commit # 97acf3df (PR #2052); no Debian bookworm fix is available yet. # Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200 -CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15 +CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15 # --- API container image (api/Dockerfile) --- # The entries below are specific to the Prowler API image, which ships @@ -78,13 +78,13 @@ CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15 # at runtime. The vulnerable path requires parsing attacker-controlled XML with # the affected interpreter, which Prowler does not do with the system Python. # Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet. -CVE-2026-7210 pkg:python3.11 exp:2026-07-15 -CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15 -CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15 -CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15 -CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15 -CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15 -CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15 +CVE-2026-7210 pkg:python3.11 exp:2026-08-15 +CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15 +CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15 +CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15 +CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15 +CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15 +CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15 # CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE). # CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records. @@ -94,5 +94,5 @@ CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15 # vulnerabilities require operating a live Unbound recursive DNSSEC validator # that processes attacker-influenced DNS responses. Prowler never starts an # Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet. -CVE-2026-33278 pkg:libunbound8 exp:2026-07-15 -CVE-2026-42960 pkg:libunbound8 exp:2026-07-15 +CVE-2026-33278 pkg:libunbound8 exp:2026-08-15 +CVE-2026-42960 pkg:libunbound8 exp:2026-08-15 From 22401a54a0bd0a8b477e005c91db391797263ad8 Mon Sep 17 00:00:00 2001 From: weedle02 <139173311+Weedle02@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:23:17 -0400 Subject: [PATCH 09/22] feat(kubernetes): add core check for readonly root filesystem enabled (#11835) Co-authored-by: wbro <80239840234@proton.me> Co-authored-by: Hugo P.Brito --- ...-readonly-root-filesystem-enabled.added.md | 1 + .../__init__.py | 0 ...only_root_filesystem_enabled.metadata.json | 37 +++++ .../core_readonly_root_filesystem_enabled.py | 38 ++++++ .../kubernetes/services/core/conftest.py | 3 +- ...e_readonly_root_filesystem_enabled_test.py | 126 ++++++++++++++++++ .../services/core/core_service_test.py | 64 +++++++++ 7 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 prowler/changelog.d/core-readonly-root-filesystem-enabled.added.md create mode 100644 prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/__init__.py create mode 100644 prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.metadata.json create mode 100644 prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.py create mode 100644 tests/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled_test.py create mode 100644 tests/providers/kubernetes/services/core/core_service_test.py diff --git a/prowler/changelog.d/core-readonly-root-filesystem-enabled.added.md b/prowler/changelog.d/core-readonly-root-filesystem-enabled.added.md new file mode 100644 index 0000000000..cfc398b111 --- /dev/null +++ b/prowler/changelog.d/core-readonly-root-filesystem-enabled.added.md @@ -0,0 +1 @@ +`core_readonly_root_filesystem_enabled` check for Kubernetes provider, verifying that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context diff --git a/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/__init__.py b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.metadata.json b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.metadata.json new file mode 100644 index 0000000000..d28b7ab94f --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_readonly_root_filesystem_enabled", + "CheckTitle": "Containers should run with a read-only root filesystem", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated to ensure every container sets `readOnlyRootFilesystem: true` in its `securityContext`. A writable root filesystem lets an attacker who gains code execution modify binaries, drop tools, or persist malicious files inside the container.", + "Risk": "Without a read-only root filesystem an attacker with code execution inside a container can tamper with binaries or libraries (**integrity**), write credential files or exfiltration tools (**confidentiality**), and persist across process restarts (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n -p '{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"\",\"securityContext\":{\"readOnlyRootFilesystem\":true}}]}}}}'\n# Deployment template example for regular containers. For init containers, use initContainers instead of containers. Ephemeral containers are added through the ephemeralcontainers subresource; remove or recreate debug containers with a compliant securityContext.", + "NativeIaC": "", + "Other": "1. Open your Kubernetes Dashboard (or your cloud provider's Kubernetes console) and locate the workload managing the failing Pod (Deployment/StatefulSet/DaemonSet)\n2. Click Edit to modify the manifest (YAML)\n3. For each container missing `securityContext.readOnlyRootFilesystem: true`, add or set it to `true`\n4. If the container needs write access, mount a writable `emptyDir` or `persistentVolumeClaim` at the specific paths that require writes instead of making the entire root filesystem writable\n5. Save/Apply the changes to trigger a rollout\n6. Verify new Pods have `securityContext.readOnlyRootFilesystem` set to `true`", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"main\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"app\"\n image = \"nginx\"\n security_context {\n read_only_root_filesystem = true # Critical: enforce a read-only root filesystem\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set `readOnlyRootFilesystem: true` for every regular, init, and ephemeral container that is part of a Pod spec. Mount writable `emptyDir` volumes only at the specific paths that genuinely need write access (e.g., `/tmp`, `/var/cache`). Enforce this at admission time with custom admission policies such as ValidatingAdmissionPolicy or OPA/Gatekeeper.", + "Url": "https://hub.prowler.com/check/core_readonly_root_filesystem_enabled" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Regular, init, and ephemeral containers are evaluated. Workloads that genuinely require root filesystem writes should mount writable volumes at specific paths and may be exempted via Prowler's mutelist." +} diff --git a/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.py b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.py new file mode 100644 index 0000000000..cb7eebcdf7 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled.py @@ -0,0 +1,38 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_readonly_root_filesystem_enabled(Check): + """Check whether every container in each Pod has readOnlyRootFilesystem set to true.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes read-only root filesystem check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has read-only root filesystem enabled for all containers." + + for containers in ( + pod.containers, + pod.init_containers, + pod.ephemeral_containers, + ): + for container in (containers or {}).values(): + if ( + container.security_context.get("read_only_root_filesystem") + is not True + ): + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have readOnlyRootFilesystem set to true." + break + if report.status == "FAIL": + break + + findings.append(report) + + return findings diff --git a/tests/providers/kubernetes/services/core/conftest.py b/tests/providers/kubernetes/services/core/conftest.py index 9b0becd33c..31c7e77d13 100644 --- a/tests/providers/kubernetes/services/core/conftest.py +++ b/tests/providers/kubernetes/services/core/conftest.py @@ -13,6 +13,7 @@ def make_container( resources=None, liveness_probe=None, readiness_probe=None, + security_context=None, ): return Container( name=name, @@ -20,7 +21,7 @@ def make_container( command=None, ports=None, env=None, - security_context={}, + security_context=security_context if security_context is not None else {}, resources=resources, liveness_probe=liveness_probe, readiness_probe=readiness_probe, diff --git a/tests/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled_test.py b/tests/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled_test.py new file mode 100644 index 0000000000..352c08dc85 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_readonly_root_filesystem_enabled/core_readonly_root_filesystem_enabled_test.py @@ -0,0 +1,126 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_readonly_root_filesystem_enabled.core_readonly_root_filesystem_enabled" +CLASS = "core_readonly_root_filesystem_enabled" + + +class TestCoreReadonlyRootFilesystemEnabled: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_readonly_root_filesystem_enabled_pass(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": True} + ) + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has read-only root filesystem enabled for all containers." + ) + + def test_readonly_root_filesystem_false_fail(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": False} + ) + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have readOnlyRootFilesystem set to true." + ) + + def test_readonly_root_filesystem_unset_fail(self): + pod = make_pod(containers={"app": make_container(security_context={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have readOnlyRootFilesystem set to true." + ) + + def test_mixed_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", + security_context={"read_only_root_filesystem": True}, + ), + "sidecar": make_container( + name="sidecar", + security_context={"read_only_root_filesystem": False}, + ), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have readOnlyRootFilesystem set to true." + ) + + def test_init_container_missing_readonly_root_filesystem_fails(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": True} + ) + }, + init_containers={ + "init": make_container(name="init", security_context=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container init does not have readOnlyRootFilesystem set to true." + ) + + def test_ephemeral_container_missing_readonly_root_filesystem_fails(self): + pod = make_pod( + containers={ + "app": make_container( + security_context={"read_only_root_filesystem": True} + ) + }, + ephemeral_containers={ + "debug": make_container(name="debug", security_context={}) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container debug does not have readOnlyRootFilesystem set to true." + ) diff --git a/tests/providers/kubernetes/services/core/core_service_test.py b/tests/providers/kubernetes/services/core/core_service_test.py new file mode 100644 index 0000000000..5fef91159c --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_service_test.py @@ -0,0 +1,64 @@ +from kubernetes import client +from prowler.providers.kubernetes.services.core.core_service import Core, Pod + + +def test_pod_model_preserves_core_service_container_groups(): + containers = Core._build_containers( + [ + client.V1Container( + name="app", + image="nginx:1.25", + security_context=client.V1SecurityContext( + read_only_root_filesystem=True + ), + ) + ] + ) + init_containers = Core._build_containers( + [ + client.V1Container( + name="init", + image="busybox:1.36", + security_context=client.V1SecurityContext( + read_only_root_filesystem=True + ), + ) + ] + ) + ephemeral_containers = Core._build_containers( + [ + client.V1EphemeralContainer( + name="debug", + image="busybox:1.36", + security_context=client.V1SecurityContext( + read_only_root_filesystem=True + ), + ) + ] + ) + + pod = Pod( + name="test-pod", + uid="test-pod-uid", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=False, + host_ipc=False, + host_network=False, + security_context={}, + containers=containers, + init_containers=init_containers, + ephemeral_containers=ephemeral_containers, + ) + + assert list(pod.containers) == ["app"] + assert list(pod.init_containers) == ["init"] + assert list(pod.ephemeral_containers) == ["debug"] + assert "init" not in pod.containers + assert "debug" not in pod.containers From fa9b2c707b0deb67281de17a8d9b66df0a96a32d Mon Sep 17 00:00:00 2001 From: Haitao Zheng Date: Tue, 14 Jul 2026 13:23:59 +0200 Subject: [PATCH 10/22] feat(kubernetes): add hostPath volume check (#11837) Co-authored-by: Hugo P.Brito --- ...e-minimize-hostpath-volume-mounts.added.md | 1 + .../__init__.py | 0 ...imize_hostpath_volume_mounts.metadata.json | 38 ++++++++ .../core_minimize_hostpath_volume_mounts.py | 23 +++++ .../kubernetes/services/core/core_service.py | 17 ++++ .../kubernetes/services/core/conftest.py | 2 + ...re_minimize_hostpath_volume_mounts_test.py | 91 +++++++++++++++++++ 7 files changed, 172 insertions(+) create mode 100644 prowler/changelog.d/core-minimize-hostpath-volume-mounts.added.md create mode 100644 prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/__init__.py create mode 100644 prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.metadata.json create mode 100644 prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.py create mode 100644 tests/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts_test.py diff --git a/prowler/changelog.d/core-minimize-hostpath-volume-mounts.added.md b/prowler/changelog.d/core-minimize-hostpath-volume-mounts.added.md new file mode 100644 index 0000000000..af1595a354 --- /dev/null +++ b/prowler/changelog.d/core-minimize-hostpath-volume-mounts.added.md @@ -0,0 +1 @@ +`core_minimize_hostpath_volume_mounts` check for Kubernetes provider, detecting Pods that use `hostPath` volumes diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/__init__.py b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.metadata.json new file mode 100644 index 0000000000..f25b22231f --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_minimize_hostpath_volume_mounts", + "CheckTitle": "Pod does not use hostPath volumes", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for volumes of type `hostPath`, which mount paths from the node filesystem into a pod.", + "Risk": "`hostPath` volumes weaken the container boundary by exposing node files to workloads. A compromised container can read sensitive host data, tamper with node files, or use writable mounts to escalate privileges and affect node availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/storage/volumes/#hostpath", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl get pod -n -o jsonpath='{range .metadata.ownerReferences[*]}{.kind}/{.name}{\"\\n\"}{end}{range .spec.volumes[?(@.hostPath)]}{.name}{\"\\t\"}{.hostPath.path}{\"\\n\"}{end}'\n# If the Pod is managed by a controller, edit the owning workload and remove hostPath volumes from its Pod template:\nkubectl edit / -n \n# For a standalone Pod, export the manifest, remove hostPath volumes, then recreate it:\nkubectl get pod -n -o yaml > pod.yaml\nkubectl delete pod -n \nkubectl apply -f pod.yaml", + "NativeIaC": "", + "Other": "1. Identify the workload that owns the failing Pod (Deployment/DaemonSet/StatefulSet/Job) or confirm it is a standalone Pod\n2. Edit the Pod template and remove volumes that define `hostPath`\n3. Replace hostPath with a safer volume type such as ConfigMap, Secret, emptyDir, persistentVolumeClaim, or a CSI volume when appropriate\n4. Apply the updated manifest and allow the workload to recreate Pods without hostPath volumes", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"app\"\n image = \"nginx\"\n }\n # Do not define host_path blocks under volume.\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disallow `hostPath` volumes by default with Pod Security Admission or policy-as-code controls. Permit narrow, read-only exceptions only for trusted system workloads, and prefer Kubernetes-native volume types that do not expose the node filesystem.", + "Url": "https://hub.prowler.com/check/core_minimize_hostpath_volume_mounts" + } + }, + "Categories": [ + "container-security", + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Exceptions for hostPath volumes should be narrowly scoped, read-only where possible, and monitored." +} diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.py b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.py new file mode 100644 index 0000000000..a693f52f0e --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts.py @@ -0,0 +1,23 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_minimize_hostpath_volume_mounts(Check): + def execute(self) -> list[Check_Report_Kubernetes]: + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} does not use hostPath volumes." + + for volume in pod.volumes or []: + if volume.get("host_path"): + report.status = "FAIL" + report.status_extended = ( + f"Pod {pod.name} uses hostPath volume {volume['name']}." + ) + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_service.py b/prowler/providers/kubernetes/services/core/core_service.py index b53a81779c..dc44d8f51f 100644 --- a/prowler/providers/kubernetes/services/core/core_service.py +++ b/prowler/providers/kubernetes/services/core/core_service.py @@ -32,6 +32,7 @@ class Core(KubernetesService): ephemeral_containers = self._build_containers( pod.spec.ephemeral_containers ) + volumes = self._build_volumes(pod.spec.volumes) self.pods[pod.metadata.uid] = Pod( name=pod.metadata.name, uid=pod.metadata.uid, @@ -54,6 +55,7 @@ class Core(KubernetesService): containers=containers, init_containers=init_containers, ephemeral_containers=ephemeral_containers, + volumes=volumes, ) except Exception as error: logger.error( @@ -101,6 +103,20 @@ class Core(KubernetesService): ) return pod_containers + @staticmethod + def _build_volumes(volumes) -> List[dict]: + pod_volumes = [] + for volume in volumes or []: + pod_volumes.append( + { + "name": volume.name, + "host_path": ( + volume.host_path.to_dict() if volume.host_path else None + ), + } + ) + return pod_volumes + def _list_config_maps(self): try: response = self.client.list_config_map_for_all_namespaces() @@ -188,6 +204,7 @@ class Pod(BaseModel): containers: Optional[dict] init_containers: Optional[dict] = None ephemeral_containers: Optional[dict] = None + volumes: Optional[List[dict]] = None class ConfigMap(BaseModel): diff --git a/tests/providers/kubernetes/services/core/conftest.py b/tests/providers/kubernetes/services/core/conftest.py index 31c7e77d13..89add32aa9 100644 --- a/tests/providers/kubernetes/services/core/conftest.py +++ b/tests/providers/kubernetes/services/core/conftest.py @@ -32,6 +32,7 @@ def make_pod( containers=None, init_containers=None, ephemeral_containers=None, + volumes=None, name="test-pod", uid="test-pod-uid", ): @@ -53,6 +54,7 @@ def make_pod( containers=containers or {}, init_containers=init_containers or {}, ephemeral_containers=ephemeral_containers or {}, + volumes=volumes, ) diff --git a/tests/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts_test.py b/tests/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts_test.py new file mode 100644 index 0000000000..586abac5ec --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_minimize_hostpath_volume_mounts/core_minimize_hostpath_volume_mounts_test.py @@ -0,0 +1,91 @@ +from kubernetes import client +from prowler.providers.kubernetes.services.core.core_service import Core +from tests.providers.kubernetes.services.core.conftest import ( + make_core_client, + make_pod, + run_check, +) + +MODULE = ( + "prowler.providers.kubernetes.services.core." + "core_minimize_hostpath_volume_mounts.core_minimize_hostpath_volume_mounts" +) +CLASS = "core_minimize_hostpath_volume_mounts" + + +class TestCoreMinimizeHostpathVolumeMounts: + def test_build_volumes_maps_kubernetes_hostpath_volume(self): + volumes = Core._build_volumes( + [ + client.V1Volume( + name="host-logs", + host_path=client.V1HostPathVolumeSource( + path="/var/log", + type="Directory", + ), + ) + ] + ) + + assert volumes == [ + { + "name": "host-logs", + "host_path": {"path": "/var/log", "type": "Directory"}, + } + ] + + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_no_hostpath_volumes_pass(self): + pod = make_pod( + volumes=[ + {"name": "config", "host_path": None}, + {"name": "scratch", "host_path": None}, + ] + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended == "Pod test-pod does not use hostPath volumes." + ) + + def test_hostpath_volume_fails(self): + pod = make_pod( + volumes=[ + { + "name": "host-logs", + "host_path": {"path": "/var/log", "type": "Directory"}, + } + ] + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended == "Pod test-pod uses hostPath volume host-logs." + ) + + def test_mixed_volumes_fail_on_hostpath(self): + pod = make_pod( + volumes=[ + {"name": "config", "host_path": None}, + { + "name": "host-socket", + "host_path": {"path": "/var/run/docker.sock", "type": "Socket"}, + }, + ] + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod uses hostPath volume host-socket." + ) From bd72ec91ea82240ab596cf91b918b5eb606d9172 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Tue, 14 Jul 2026 13:52:12 +0200 Subject: [PATCH 11/22] fix(api): combine permissions across assigned roles (#11979) --- .../multi-role-permission-gates.fixed.md | 1 + api/src/backend/api/rbac/permissions.py | 11 +++-- api/src/backend/api/tests/test_rbac.py | 43 +++++++++++++++++++ api/src/backend/api/tests/test_views.py | 19 +++++--- 4 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 api/changelog.d/multi-role-permission-gates.fixed.md diff --git a/api/changelog.d/multi-role-permission-gates.fixed.md b/api/changelog.d/multi-role-permission-gates.fixed.md new file mode 100644 index 0000000000..b9a6c1a8ee --- /dev/null +++ b/api/changelog.d/multi-role-permission-gates.fixed.md @@ -0,0 +1 @@ +RBAC permission gates now combine permissions from every role assigned to a user in the active tenant diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index ef0475fefb..3458346a5f 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -34,7 +34,7 @@ class HasPermissions(BasePermission): if not tenant_id: return False - user_roles = ( + user_roles = list( User.objects.using(MainRouter.admin_db) .get(id=request.user.id) .roles.using(MainRouter.admin_db) @@ -43,11 +43,10 @@ class HasPermissions(BasePermission): if not user_roles: return False - for perm in required_permissions: - if not getattr(user_roles[0], perm.value, False): - return False - - return True + return all( + any(getattr(role, permission.value, False) for role in user_roles) + for permission in required_permissions + ) def get_role(user: User, tenant_id: str) -> Role: diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index f2e2fc4bb5..ed177138b2 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -11,6 +11,7 @@ from api.models import ( User, UserRoleRelationship, ) +from api.rbac.permissions import HasPermissions, Permissions from api.v1.serializers import TokenSerializer from conftest import TEST_PASSWORD, TODAY from django.urls import reverse @@ -816,6 +817,48 @@ class TestRolePermissions: assert response.status_code == status.HTTP_403_FORBIDDEN +@pytest.mark.django_db +class TestHasPermissions: + def test_permissions_are_combined_across_roles( + self, create_test_user_rbac_no_roles + ): + user = create_test_user_rbac_no_roles + tenant = Membership.objects.get(user=user).tenant + manage_users_role = Role.objects.create( + name="manage_users_only", + tenant=tenant, + manage_users=True, + ) + UserRoleRelationship.objects.create( + user=user, + role=manage_users_role, + tenant=tenant, + ) + request = Mock(user=user, tenant_id=tenant.id) + view = Mock( + required_permissions=[ + Permissions.MANAGE_USERS, + Permissions.MANAGE_ACCOUNT, + ] + ) + permission = HasPermissions() + + assert not permission.has_permission(request, view) + + manage_account_role = Role.objects.create( + name="manage_account_only", + tenant=tenant, + manage_account=True, + ) + UserRoleRelationship.objects.create( + user=user, + role=manage_account_role, + tenant=tenant, + ) + + assert permission.has_permission(request, view) + + @pytest.mark.django_db class TestUserRoleLinkPermissions: def test_link_user_roles_with_manage_account_only_allowed( diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index be52393dfa..a1e73b46ec 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -9435,20 +9435,22 @@ class TestUserRoleRelationshipViewSet: assert added_role_ids.issubset(relationship_role_ids) def test_create_relationship_already_exists( - self, authenticated_client, roles_fixture, create_test_user + self, authenticated_client, roles_fixture, create_test_user_rbac_no_roles ): - # Only add Role One (which has manage_account=True) to ensure - # the second request has permission to add roles data = { "data": [ - {"type": "roles", "id": str(roles_fixture[0].id)}, + {"type": "roles", "id": str(role.id)} for role in roles_fixture[:2] ] } - authenticated_client.post( - reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}), + setup_response = authenticated_client.post( + reverse( + "user-roles-relationship", + kwargs={"pk": create_test_user_rbac_no_roles.id}, + ), data=data, content_type="application/vnd.api+json", ) + assert setup_response.status_code == status.HTTP_204_NO_CONTENT data = { "data": [ @@ -9456,7 +9458,10 @@ class TestUserRoleRelationshipViewSet: ] } response = authenticated_client.post( - reverse("user-roles-relationship", kwargs={"pk": create_test_user.id}), + reverse( + "user-roles-relationship", + kwargs={"pk": create_test_user_rbac_no_roles.id}, + ), data=data, content_type="application/vnd.api+json", ) From 9822fd97d76b933a8011acbdb445f92719b8da7b Mon Sep 17 00:00:00 2001 From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:40:21 +0200 Subject: [PATCH 12/22] feat(dashboard): redesign local dashboard sidebar and pages (#11972) --- dashboard/__main__.py | 267 +++++++----- dashboard/assets/cloud-pages.css | 389 ++++++++++++++++++ .../assets/images/icons/cloud/alerts.svg | 1 + .../images/icons/cloud/attack-paths.svg | 1 + dashboard/assets/images/icons/cloud/check.svg | 1 + .../assets/images/icons/cloud/compliance.svg | 4 + dashboard/assets/images/icons/cloud/docs.svg | 1 + .../assets/images/icons/cloud/findings.svg | 1 + dashboard/assets/images/icons/cloud/help.svg | 1 + .../images/icons/cloud/integrations.svg | 1 + .../images/icons/cloud/lighthouse-ai.svg | 1 + .../assets/images/icons/cloud/mutelist.svg | 1 + .../images/icons/cloud/organization.svg | 1 + .../assets/images/icons/cloud/overview.svg | 4 + .../images/icons/cloud/prowler-lockup.svg | 1 + .../images/icons/cloud/prowler-mark.svg | 1 + dashboard/pages/cloud.py | 263 ++++++++++++ .../dashboard-local-redesign.changed.md | 1 + 18 files changed, 826 insertions(+), 114 deletions(-) create mode 100644 dashboard/assets/cloud-pages.css create mode 100644 dashboard/assets/images/icons/cloud/alerts.svg create mode 100644 dashboard/assets/images/icons/cloud/attack-paths.svg create mode 100644 dashboard/assets/images/icons/cloud/check.svg create mode 100644 dashboard/assets/images/icons/cloud/compliance.svg create mode 100644 dashboard/assets/images/icons/cloud/docs.svg create mode 100644 dashboard/assets/images/icons/cloud/findings.svg create mode 100644 dashboard/assets/images/icons/cloud/help.svg create mode 100644 dashboard/assets/images/icons/cloud/integrations.svg create mode 100644 dashboard/assets/images/icons/cloud/lighthouse-ai.svg create mode 100644 dashboard/assets/images/icons/cloud/mutelist.svg create mode 100644 dashboard/assets/images/icons/cloud/organization.svg create mode 100644 dashboard/assets/images/icons/cloud/overview.svg create mode 100644 dashboard/assets/images/icons/cloud/prowler-lockup.svg create mode 100644 dashboard/assets/images/icons/cloud/prowler-mark.svg create mode 100644 dashboard/pages/cloud.py create mode 100644 prowler/changelog.d/dashboard-local-redesign.changed.md diff --git a/dashboard/__main__.py b/dashboard/__main__.py index 664ce3bfa5..6a36bda7ed 100644 --- a/dashboard/__main__.py +++ b/dashboard/__main__.py @@ -20,7 +20,7 @@ print_banner() print( f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}" ) -cli.show_server_banner = lambda *x: click.echo( +cli.show_server_banner = lambda *_: click.echo( f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3:///output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files." ) @@ -33,148 +33,187 @@ dashboard = dash.Dash( title="Prowler Dashboard", ) -# Logo -prowler_logo = html.Img( - src="https://cdn.prod.website-files.com/68c4ec3f9fb7b154fbcb6e36/68ffb46d40ed7faa37a592a5_prowler-logo.png", - alt="Prowler Logo", +# ``use_pages`` above already imported dashboard/pages/cloud.py and registered +# every /cloud/* route. Import its metadata now (after app instantiation) so +# the sidebar and the gated pages share a single source of truth. +from dashboard.pages.cloud import CLOUD_FEATURES_BY_SLUG # noqa: E402 + +ICON_DIR = "/assets/images/icons/cloud" + +# Official marketing "PROWLER / LOCAL DASHBOARD" lockup (white wordmark + teal +# gradient sublabel) shown in the expanded sidebar. Vector SVG so it stays crisp +# at any DPI. The sublabel is right-anchored (text-anchor="end"), so a font +# fallback widens it leftward rather than clipping at the edge. +prowler_lockup = html.Img( + src=f"{ICON_DIR}/prowler-lockup.svg", + alt="Prowler Local Dashboard", + className="pc-brand-lockup", ) -menu_icons = { - "overview": "/assets/images/icons/overview.svg", - "compliance": "/assets/images/icons/compliance.svg", -} +# Compact brand mark shown only when the sidebar collapses to its icon rail. +prowler_mark = html.Img( + src=f"{ICON_DIR}/prowler-mark.svg", + alt="Prowler", + className="pc-brand-mark", +) + +# Locally functional destinations (Overview + Compliance). +DASHBOARD_ITEMS = [ + {"label": "Overview", "route": "/", "icon": f"{ICON_DIR}/overview.svg"}, + { + "label": "Compliance", + "route": "/compliance", + "icon": f"{ICON_DIR}/compliance.svg", + }, +] + +# Gated navigation groups reference the shared feature metadata by slug so the +# sidebar and the informational pages never drift apart. +GATED_GROUPS = [ + ("Upgrade to Prowler Cloud", ["lighthouse-ai", "attack-paths", "findings"]), + ("Configuration", ["alerts", "mutelist", "integrations"]), + ("Workspace", ["organization"]), +] + +HELP_LINKS = [ + { + "title": "Help", + "url": "https://github.com/prowler-cloud/prowler/issues", + "icon": f"{ICON_DIR}/help.svg", + }, + { + "title": "Docs", + "url": "https://docs.prowler.com", + "icon": f"{ICON_DIR}/docs.svg", + }, +] -# Function to generate navigation links -def generate_nav_links(current_path): - nav_links = [] - for page in dash.page_registry.values(): - # Gets the icon URL based on the page name - icon_url = menu_icons.get(page["name"].lower()) - is_active = ( - " bg-prowler-stone-950 border-r-4 border-solid border-prowler-lime" - if current_path == page["relative_path"] - else "" - ) - link_class = f"block hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime{is_active}" +def _mask_style(icon_url): + """Inline style rendering a recolorable mask icon from a local asset.""" + return { + "WebkitMaskImage": f"url({icon_url})", + "maskImage": f"url({icon_url})", + } - link_content = html.Span( + +def _nav_icon(icon_url): + return html.Span(className="pc-ico", style=_mask_style(icon_url)) + + +def _nav_item(label, route, icon_url, current_path, gated=False): + is_active = current_path == route + class_name = "pc-nav-item pc-active" if is_active else "pc-nav-item" + + content = [ + _nav_icon(icon_url), + html.Span(label, className="pc-nav-label"), + ] + if gated: + content.append(html.Span("Prowler Cloud", className="pc-pill")) + + return dcc.Link(content, href=route, className=class_name) + + +def _section_label(title): + return html.Div(title, className="pc-section") + + +def generate_sidebar(current_path): + children = [ + # Brand lockup: full wordmark when expanded, compact mark when collapsed. + html.Div( + [prowler_lockup, prowler_mark], + className="pc-brand", + ), + # Dashboards section — the only locally functional destinations. + _section_label("Dashboards"), + html.Nav( [ - html.Img(src=icon_url, className="w-5"), - html.Span( - page["name"], className="font-medium text-base leading-6 text-white" - ), + _nav_item(item["label"], item["route"], item["icon"], current_path) + for item in DASHBOARD_ITEMS ], - className="flex justify-center lg:justify-normal items-center gap-x-3 py-2 px-3", - ) - - nav_link = html.Li( - dcc.Link(link_content, href=page["relative_path"], className=link_class) - ) - nav_links.append(nav_link) - return nav_links - - -def generate_help_menu(): - help_links = [ - { - "title": "Help", - "url": "https://github.com/prowler-cloud/prowler/issues", - "icon": "/assets/images/icons/help.png", - }, - { - "title": "Docs", - "url": "https://docs.prowler.com", - "icon": "/assets/images/icons/docs.png", - }, + className="pc-nav", + ), ] - link_class = "block hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime" - - menu_items = [] - for link in help_links: - menu_item = html.Li( - html.A( - html.Span( - [ - html.Img(src=link["icon"], className="w-5"), - html.Span( - link["title"], - className="font-medium text-base leading-6 text-white", - ), - ], - className="flex items-center gap-x-3 py-2 px-3", - ), - href=link["url"], - target="_blank", - className=link_class, + # Gated groups (Prowler Cloud only). + for section_title, slugs in GATED_GROUPS: + children.append(_section_label(section_title)) + children.append( + html.Nav( + [ + _nav_item( + CLOUD_FEATURES_BY_SLUG[slug]["nav_label"], + CLOUD_FEATURES_BY_SLUG[slug]["route"], + CLOUD_FEATURES_BY_SLUG[slug]["icon"], + current_path, + gated=True, + ) + for slug in slugs + ], + className="pc-nav", ) ) - menu_items.append(menu_item) - return menu_items + # Help and Docs pinned to the bottom, separated by a neutral top border. + children.append( + html.Nav( + [ + html.A( + [ + _nav_icon(link["icon"]), + html.Span(link["title"], className="pc-nav-label"), + ], + href=link["url"], + target="_blank", + rel="noopener noreferrer", + className="pc-nav-item", + ) + for link in HELP_LINKS + ], + className="pc-nav pc-footer", + ) + ) + + return html.Div(children, className="pc-sidebar pc-font") # Layout dashboard.layout = html.Div( [ - dcc.Location(id="url", refresh=False), html.Link(rel="icon", href="assets/favicon.ico"), - # Placeholder for dynamic navigation bar html.Div( [ + # Dynamic sidebar (rebuilt on navigation for active state). + html.Div(id="navigation-bar"), + # Main pane hosting the routed page content. html.Div( - id="navigation-bar", className="bg-prowler-stone-900 min-w-36 z-10" - ), - html.Div( - [ + html.Div( dash.page_container, - ], + className="pc-main-inner", + ), id="content_select", - className="bg-prowler-white w-full col-span-11 h-screen mx-auto overflow-y-scroll no-scrollbar px-10 py-7", + className="pc-main pc-font no-scrollbar", ), ], - className="grid custom-grid 2xl:custom-grid-large h-screen", + className="pc-shell", ), ], className="h-screen mx-auto", ) -# Callback to update navigation bar -@dashboard.callback(Output("navigation-bar", "children"), [Input("url", "pathname")]) +# Callback to update navigation bar. +# +# Triggered off Dash Pages' own location (``_pages_location``) rather than a +# separate ``dcc.Location``. A standalone ``dcc.Location(id="url")`` stops +# emitting ``pathname`` when navigating between two pages that render an +# identical component tree — every ``/cloud/*`` gated page shares the same +# ``build_cloud_layout`` structure — which left the active highlight stuck on +# the first gated page visited. ``_pages_location`` fires on every route change. +@dashboard.callback( + Output("navigation-bar", "children"), [Input("_pages_location", "pathname")] +) def update_nav_bar(pathname): - return html.Div( - [ - html.Div([prowler_logo], className="mb-8 px-3"), - html.H6( - "Dashboards", - className="px-3 text-prowler-stone-500 text-sm opacity-90 font-regular mb-2", - ), - html.Nav( - [html.Ul(generate_nav_links(pathname), className="")], - className="flex flex-col gap-y-6", - ), - html.Nav( - [ - html.A( - [ - html.Span( - [ - html.Img(src="assets/favicon.ico", className="w-5"), - "Subscribe to Prowler Cloud", - ], - className="flex items-center gap-x-3 text-white", - ), - ], - href="https://prowler.com/", - target="_blank", - className="block p-3 uppercase text-xs hover:bg-prowler-stone-950 hover:border-r-4 hover:border-solid hover:border-prowler-lime", - ), - html.Ul(generate_help_menu(), className=""), - ], - className="flex flex-col gap-y-6 mt-auto", - ), - ], - className="flex flex-col bg-prowler-stone-900 py-7 h-full", - ) + return generate_sidebar(pathname) diff --git a/dashboard/assets/cloud-pages.css b/dashboard/assets/cloud-pages.css new file mode 100644 index 0000000000..53052eabda --- /dev/null +++ b/dashboard/assets/cloud-pages.css @@ -0,0 +1,389 @@ +/* + * Prowler Local Dashboard — Cloud upsell chrome & gated pages. + * These styles are self-contained (not dependent on the precompiled Tailwind + * bundle) so pixel specs from the PRD render reliably without a rebuild. + */ + +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"); + +:root { + --pc-btn: #6ee7b7; + --pc-btn-hover: #99f6e4; + --pc-btn-press: #34d399; + --pc-grad-start: #2ee59b; + --pc-grad-end: #62dff0; + --pc-text: #020617; + --pc-text-2: #27272a; + --pc-sidebar-bg: #27272a; + --pc-pane-bg: #fdfdfd; + --pc-border: #e5e5e5; + --pc-teal-accent: #2ee59b; + --pc-sidebar-w: 264px; + --pc-sidebar-w-collapsed: 82px; +} + +.pc-font { + font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; +} + +/* ----------------------------------------------------------------- Shell */ + +.pc-shell { + display: flex; + height: 100vh; + width: 100%; + overflow: hidden; +} + +.pc-main { + flex: 1 1 auto; + height: 100vh; + overflow-y: auto; + background: var(--pc-pane-bg); +} + +.pc-main-inner { + padding: 28px 40px 64px; +} + +/* --------------------------------------------------------------- Sidebar */ + +.pc-sidebar { + flex: 0 0 var(--pc-sidebar-w); + width: var(--pc-sidebar-w); + background: var(--pc-sidebar-bg); + height: 100vh; + display: flex; + flex-direction: column; + padding: 22px 0 16px; + overflow-y: auto; + overflow-x: hidden; +} + +.pc-sidebar::-webkit-scrollbar { + display: none; +} + +.pc-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 18px; + margin-bottom: 22px; +} + +.pc-brand-lockup { + width: 190px; + height: auto; + display: block; +} + +/* Compact mark is only revealed on the collapsed icon rail (see media query). */ +.pc-brand-mark { + width: 30px; + height: 30px; + flex: 0 0 auto; + display: none; +} + +.pc-section { + color: #8a8a90; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + padding: 0 18px; + margin: 18px 0 8px; +} + +.pc-nav { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pc-nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + margin: 0 8px; + color: #ffffff; + font-size: 14px; + font-weight: 500; + text-decoration: none; + border-radius: 8px; + border-left: 3px solid transparent; + overflow: hidden; + transition: background 0.15s ease; +} + +.pc-nav-item:hover { + background: rgba(255, 255, 255, 0.07); +} + +.pc-nav-item.pc-active { + background: rgba(255, 255, 255, 0.09); + border-left-color: var(--pc-teal-accent); +} + +.pc-nav-label { + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Icon rendered as a recolorable mask so one asset serves any color. */ +.pc-ico { + width: 20px; + height: 20px; + flex: 0 0 auto; + display: inline-block; + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +.pc-pill { + flex: 0 0 auto; + margin-left: auto; + display: inline-flex; + align-items: center; + background: linear-gradient( + 112deg, + var(--pc-grad-start) 3.5%, + var(--pc-grad-end) 98.8% + ); + color: var(--pc-text); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + padding: 3px 8px; + border-radius: 9999px; + white-space: nowrap; +} + +.pc-footer { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* -------------------------------------------------------- Gated page body */ + +.pc-page { + max-width: 1120px; + margin: 0 auto; +} + +.pc-page-header { + border-bottom: 1px solid var(--pc-border); + padding-bottom: 18px; + margin-bottom: 28px; +} + +.pc-page-title-row { + display: flex; + align-items: center; + gap: 12px; +} + +/* In the page header the badge sits right next to the title (not pushed to the + far right like the sidebar pills) and is uppercased for emphasis. */ +.pc-page-title-row .pc-pill { + margin-left: 0; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.pc-page-title { + font-size: 24px; + font-weight: 700; + color: var(--pc-text); + margin: 0; +} + +.pc-page-subtitle { + font-size: 15px; + line-height: 24px; + color: #52525b; + margin: 8px 0 0; +} + +/* ----------------------------------------------------------- Upgrade card */ + +.pc-card { + position: relative; + background: #ffffff; + border: 1px solid var(--pc-border); + border-radius: 18px; + padding: 56px 40px; + overflow: hidden; +} + +.pc-card-glow { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 560px; + height: 240px; + background: radial-gradient( + ellipse at top, + rgba(46, 229, 155, 0.2), + rgba(98, 223, 240, 0.06) 45%, + transparent 72% + ); + pointer-events: none; +} + +.pc-card-body { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.pc-feature-icon { + width: 64px; + height: 64px; + border-radius: 16px; + background: linear-gradient( + 135deg, + rgba(46, 229, 155, 0.16), + rgba(98, 223, 240, 0.14) + ); + border: 1px solid rgba(46, 229, 155, 0.3); + display: flex; + align-items: center; + justify-content: center; + color: var(--pc-text); + margin-bottom: 22px; +} + +.pc-feature-icon .pc-ico { + width: 30px; + height: 30px; +} + +.pc-avail { + color: #0e9f6e; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + margin-bottom: 8px; +} + +.pc-card-title { + font-size: 24px; + font-weight: 700; + color: var(--pc-text); + margin: 0 0 14px; +} + +.pc-card-desc { + font-size: 15px; + line-height: 24px; + color: var(--pc-text-2); + max-width: 560px; + margin: 0 0 26px; +} + +.pc-benefits { + list-style: none; + padding: 0; + margin: 0 0 30px; + text-align: left; +} + +.pc-benefit { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 7px 0; + font-size: 15px; + line-height: 22px; + color: var(--pc-text-2); +} + +.pc-benefit-check { + flex: 0 0 auto; + width: 18px; + height: 18px; + margin-top: 2px; + color: var(--pc-btn-press); +} + +.pc-cta { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--pc-btn); + color: var(--pc-text); + font-size: 15px; + font-weight: 700; + padding: 12px 24px; + border-radius: 12px; + border: 1px solid var(--pc-btn-press); + text-decoration: none; + cursor: pointer; + transition: background 0.15s ease; +} + +.pc-cta:hover { + background: var(--pc-btn-hover); + color: var(--pc-text); +} + +.pc-cta:active { + background: var(--pc-btn-press); +} + +/* --------------------------------------------------- Responsive collapse */ + +@media (max-width: 900px) { + .pc-sidebar { + flex-basis: var(--pc-sidebar-w-collapsed); + width: var(--pc-sidebar-w-collapsed); + } + + .pc-brand { + justify-content: center; + padding: 0 8px; + } + + .pc-brand-lockup { + display: none; + } + + .pc-brand-mark { + display: block; + } + + .pc-section, + .pc-nav-label, + .pc-pill { + display: none; + } + + .pc-nav-item { + justify-content: center; + margin: 0 6px; + padding: 10px 0; + } + + .pc-main-inner { + padding: 20px 18px 48px; + } +} diff --git a/dashboard/assets/images/icons/cloud/alerts.svg b/dashboard/assets/images/icons/cloud/alerts.svg new file mode 100644 index 0000000000..6109e378ab --- /dev/null +++ b/dashboard/assets/images/icons/cloud/alerts.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/attack-paths.svg b/dashboard/assets/images/icons/cloud/attack-paths.svg new file mode 100644 index 0000000000..b856c6ea2f --- /dev/null +++ b/dashboard/assets/images/icons/cloud/attack-paths.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/check.svg b/dashboard/assets/images/icons/cloud/check.svg new file mode 100644 index 0000000000..a5f8ed0c8a --- /dev/null +++ b/dashboard/assets/images/icons/cloud/check.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/compliance.svg b/dashboard/assets/images/icons/cloud/compliance.svg new file mode 100644 index 0000000000..e70ebda28c --- /dev/null +++ b/dashboard/assets/images/icons/cloud/compliance.svg @@ -0,0 +1,4 @@ + diff --git a/dashboard/assets/images/icons/cloud/docs.svg b/dashboard/assets/images/icons/cloud/docs.svg new file mode 100644 index 0000000000..efc3dfc61b --- /dev/null +++ b/dashboard/assets/images/icons/cloud/docs.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/findings.svg b/dashboard/assets/images/icons/cloud/findings.svg new file mode 100644 index 0000000000..93fc42a516 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/findings.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/help.svg b/dashboard/assets/images/icons/cloud/help.svg new file mode 100644 index 0000000000..2b3f1024e8 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/help.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/integrations.svg b/dashboard/assets/images/icons/cloud/integrations.svg new file mode 100644 index 0000000000..e7a2d07603 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/integrations.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/lighthouse-ai.svg b/dashboard/assets/images/icons/cloud/lighthouse-ai.svg new file mode 100644 index 0000000000..1c510ac1fc --- /dev/null +++ b/dashboard/assets/images/icons/cloud/lighthouse-ai.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/mutelist.svg b/dashboard/assets/images/icons/cloud/mutelist.svg new file mode 100644 index 0000000000..1d498fa9e3 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/mutelist.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/organization.svg b/dashboard/assets/images/icons/cloud/organization.svg new file mode 100644 index 0000000000..1f3d1da9f5 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/organization.svg @@ -0,0 +1 @@ + diff --git a/dashboard/assets/images/icons/cloud/overview.svg b/dashboard/assets/images/icons/cloud/overview.svg new file mode 100644 index 0000000000..809e63d945 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/overview.svg @@ -0,0 +1,4 @@ + diff --git a/dashboard/assets/images/icons/cloud/prowler-lockup.svg b/dashboard/assets/images/icons/cloud/prowler-lockup.svg new file mode 100644 index 0000000000..9daee36463 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/prowler-lockup.svg @@ -0,0 +1 @@ +LOCALDASHBOARD diff --git a/dashboard/assets/images/icons/cloud/prowler-mark.svg b/dashboard/assets/images/icons/cloud/prowler-mark.svg new file mode 100644 index 0000000000..eb30d44be8 --- /dev/null +++ b/dashboard/assets/images/icons/cloud/prowler-mark.svg @@ -0,0 +1 @@ + diff --git a/dashboard/pages/cloud.py b/dashboard/pages/cloud.py new file mode 100644 index 0000000000..3a6ce48b62 --- /dev/null +++ b/dashboard/pages/cloud.py @@ -0,0 +1,263 @@ +"""Prowler Cloud upsell (gated) informational pages. + +These routes live inside the Local Dashboard but do NOT reproduce any Prowler +Cloud functionality. Each renders the same reusable upgrade template with a +feature-specific name, icon, description, benefit bullets and UTM-tagged CTA. +All copy in ``CLOUD_FEATURES`` is normative — do not change wording, +capitalization or punctuation without Product approval. +""" + +import dash +from dash import html + +# Shared subtitle used across every gated page header. +CLOUD_SUBTITLE = "Discover more ways to protect and operate your cloud." + +# Base Prowler Cloud URL; the UTM content value identifies the feature. +CLOUD_CTA_BASE = "https://cloud.prowler.com/?utm_source=local-dashboard&utm_content=" + +# Path to the recolorable checkmark mask used for benefit bullets. +CHECK_ICON = "/assets/images/icons/cloud/check.svg" + + +# Normative feature definitions. ``icon`` points to a local mask asset so no +# external requests are needed and the glyph recolors per context. +CLOUD_FEATURES = [ + { + "slug": "lighthouse-ai", + "route": "/cloud/lighthouse-ai", + "nav_label": "Lighthouse AI", + "page_title": "Lighthouse AI", + "card_title": "Unlock Lighthouse AI", + "description": ( + "Work with an AI security analyst that understands your cloud " + "posture and helps turn risk into action." + ), + "benefits": [ + "Ask questions about your security posture in plain language", + "Investigate findings with context from your connected providers", + "Move from insight to remediation faster", + ], + "utm_content": "lighthouse-ai", + "icon": "/assets/images/icons/cloud/lighthouse-ai.svg", + }, + { + "slug": "attack-paths", + "route": "/cloud/attack-paths", + "nav_label": "Attack Paths", + "page_title": "Attack Paths", + "card_title": "Unlock Attack Paths", + "description": ( + "Visualize the paths an attacker could take through connected " + "resources before risk becomes compromise." + ), + "benefits": [ + "See exploitable relationships across your AWS environment", + "Focus remediation on the paths with the greatest impact", + "Explore each scan as a point-in-time security graph", + ], + "utm_content": "attack-paths", + "icon": "/assets/images/icons/cloud/attack-paths.svg", + }, + { + "slug": "findings", + "route": "/cloud/findings", + "nav_label": "Findings", + "page_title": "Findings", + "card_title": "Unlock Findings", + "description": ( + "Filter, investigate, and prioritize security findings across " + "providers and scans from one workspace." + ), + "benefits": [ + "Search and filter findings across all connected accounts", + "Track status, severity, ownership, and remediation context", + "Triage findings and share a consistent source of truth with your security team", + ], + "utm_content": "findings", + "icon": "/assets/images/icons/cloud/findings.svg", + }, + { + "slug": "alerts", + "route": "/cloud/alerts", + "nav_label": "Alerts", + "page_title": "Alerts", + "card_title": "Unlock Alerts", + "description": ( + "Create alert rules and stay informed when scan results reveal " + "the risks your team cares about." + ), + "benefits": [ + "Define alerts around the findings that matter most", + "Route security signals to the right responders", + "Reduce the time between detection and action", + ], + "utm_content": "alerts", + "icon": "/assets/images/icons/cloud/alerts.svg", + }, + { + "slug": "mutelist", + "route": "/cloud/mutelist", + "nav_label": "Mutelist", + "page_title": "Mutelist", + "card_title": "Unlock Mutelist", + "description": ( + "Quiet expected findings, document accepted risk, and keep your " + "team focused on actionable work." + ), + "benefits": [ + "Create reusable rules for known exceptions", + "Keep muted findings available for audit and review", + "Cut noise without losing security context", + ], + "utm_content": "mutelist", + "icon": "/assets/images/icons/cloud/mutelist.svg", + }, + { + "slug": "integrations", + "route": "/cloud/integrations", + "nav_label": "Integrations", + "page_title": "Integrations", + "card_title": "Unlock Integrations", + "description": ( + "Connect Prowler to your security workflow so findings and scan " + "data reach the tools your team already uses." + ), + "benefits": [ + "Connect ticketing, notification, and cloud security services", + "Automate the handoff from detection to response", + "Keep teams aligned without manual exports", + ], + "utm_content": "integrations", + "icon": "/assets/images/icons/cloud/integrations.svg", + }, + { + "slug": "organization", + "route": "/cloud/organization", + "nav_label": "Organization", + "page_title": "Organization", + "card_title": "Unlock Organization", + "description": ( + "Manage users, roles, and invitations while organizing cloud " + "security work across your team." + ), + "benefits": [ + "Invite teammates into a shared security workspace", + "Control access with role-based permissions", + "Coordinate security operations across accounts and teams", + ], + "utm_content": "organization", + "icon": "/assets/images/icons/cloud/organization.svg", + }, +] + +# Convenience lookup for the navigation builder in ``__main__``. +CLOUD_FEATURES_BY_SLUG = {feature["slug"]: feature for feature in CLOUD_FEATURES} + + +def _mask_style(icon_url): + """Return the inline style that renders a recolorable mask icon.""" + return { + "WebkitMaskImage": f"url({icon_url})", + "maskImage": f"url({icon_url})", + } + + +def _benefit_item(text): + return html.Li( + [ + html.Span( + className="pc-ico pc-benefit-check", + style=_mask_style(CHECK_ICON), + ), + html.Span(text), + ], + className="pc-benefit", + ) + + +def build_cloud_layout(feature): + """Build the reusable gated informational page for a single feature.""" + cta_url = f"{CLOUD_CTA_BASE}{feature['utm_content']}" + + return html.Div( + html.Div( + [ + # Page header: title + Prowler Cloud badge + shared subtitle. + html.Div( + [ + html.Div( + [ + html.H1( + feature["page_title"], + className="pc-page-title", + ), + html.Span("Prowler Cloud", className="pc-pill"), + ], + className="pc-page-title-row", + ), + html.P(CLOUD_SUBTITLE, className="pc-page-subtitle"), + ], + className="pc-page-header", + ), + # Centered upgrade card. + html.Div( + [ + html.Div(className="pc-card-glow"), + html.Div( + [ + html.Div( + html.Span( + className="pc-ico", + style=_mask_style(feature["icon"]), + ), + className="pc-feature-icon", + ), + html.Div( + "Available in Prowler Cloud", + className="pc-avail", + ), + html.H2( + feature["card_title"], + className="pc-card-title", + ), + html.P( + feature["description"], + className="pc-card-desc", + ), + html.Ul( + [ + _benefit_item(benefit) + for benefit in feature["benefits"] + ], + className="pc-benefits", + ), + html.A( + "Upgrade to Prowler Cloud", + href=cta_url, + target="_blank", + rel="noopener noreferrer", + className="pc-cta", + ), + ], + className="pc-card-body", + ), + ], + className="pc-card", + ), + ], + className="pc-page pc-font", + ), + ) + + +# Register one page per gated feature. A distinct module key keeps each entry +# unique in Dash's page registry while sharing the same template. +for _feature in CLOUD_FEATURES: + dash.register_page( + f"cloud_{_feature['slug'].replace('-', '_')}", + path=_feature["route"], + name=_feature["nav_label"], + title=f"Prowler Dashboard - {_feature['page_title']}", + layout=build_cloud_layout(_feature), + ) diff --git a/prowler/changelog.d/dashboard-local-redesign.changed.md b/prowler/changelog.d/dashboard-local-redesign.changed.md new file mode 100644 index 0000000000..59ba9ca7ea --- /dev/null +++ b/prowler/changelog.d/dashboard-local-redesign.changed.md @@ -0,0 +1 @@ +Redesign the local dashboard sidebar and informational pages From d9224e682f3152b34db2c3d8c5e9a62b02124e69 Mon Sep 17 00:00:00 2001 From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:44:06 +0200 Subject: [PATCH 13/22] docs(github): fix broken fine-grained PAT anchor in authentication table (#11985) --- docs/user-guide/providers/github/authentication.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index 0b5ab6b720..03693e15b5 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -12,7 +12,7 @@ Prowler offers three authentication methods. Fine-Grained Personal Access Tokens | Method | Best For | Key Benefit | |--------|----------|-------------| -| [**Fine-Grained Personal Access Token**](#fine-grained-personal-access-token-recommended) | Individual users, quick setup | Simple, user-scoped access | +| [**Fine-Grained Personal Access Token**](#fine-grained-personal-access-token-recommended-for-individual-use) | Individual users, quick setup | Simple, user-scoped access | | [**GitHub App**](#github-app-credentials) | Organizations, automation, CI/CD | Organization-scoped, no personal account dependency | | [**OAuth App Token**](#oauth-app-token) | Delegated user authorization | User-consented access flows | From a9f6e04a8413f44009fbffef16d65daa5cf6d51a Mon Sep 17 00:00:00 2001 From: Toriola Opeyemi <110393614+GeekyBlessing@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:07:56 +0100 Subject: [PATCH 14/22] docs: fix malformed height attribute in ECR badge (#11987) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50df95484f..3744cd0256 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Python Version PyPI Downloads Docker Pulls - AWS ECR Gallery + AWS ECR Gallery Codecov coverage Linux Foundation insights health score

From a4752d6a277451fa508ac76debed85c2a3ccb880 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Wed, 15 Jul 2026 11:07:21 +0200 Subject: [PATCH 15/22] fix(api): prevent false Attack Paths stale cleanup (#11986) --- .../attack-paths-stale-cleanup.fixed.md | 1 + .../backend/api/attack_paths/views_helpers.py | 8 +- api/src/backend/config/django/base.py | 3 + .../tasks/jobs/attack_paths/cleanup.py | 242 ++++-- .../tasks/jobs/attack_paths/db_utils.py | 10 +- api/src/backend/tasks/jobs/orphan_recovery.py | 11 +- .../tasks/tests/test_attack_paths_scan.py | 694 +++++++++++++++++- .../tasks/tests/test_orphan_recovery.py | 59 +- 8 files changed, 890 insertions(+), 138 deletions(-) create mode 100644 api/changelog.d/attack-paths-stale-cleanup.fixed.md diff --git a/api/changelog.d/attack-paths-stale-cleanup.fixed.md b/api/changelog.d/attack-paths-stale-cleanup.fixed.md new file mode 100644 index 0000000000..758ef9d630 --- /dev/null +++ b/api/changelog.d/attack-paths-stale-cleanup.fixed.md @@ -0,0 +1 @@ +`attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py index d1b351f454..1c1d3e7888 100644 --- a/api/src/backend/api/attack_paths/views_helpers.py +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -152,10 +152,10 @@ def execute_custom_query( scan: AttackPathsScan, ) -> dict[str, Any]: # Defense-in-depth for custom queries: - # 1. `neo4j.READ_ACCESS` — prevents mutations at the driver level - # 2. `inject_provider_label()` — regex-based label injection scopes node patterns - # 3. `_serialize_graph()` — post-query filter drops nodes without the provider label - # 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune — server-side runaway cutoff + # 1. `neo4j.READ_ACCESS` - prevents mutations at the driver level + # 2. `inject_provider_label()` - regex-based label injection scopes node patterns + # 3. `_serialize_graph()` - post-query filter drops nodes without the provider label + # 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff # # Layer 2 is best-effort (regex can't fully parse Cypher); # layer 3 is the safety net that guarantees provider isolation. diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 23af0cc6d3..04251b4479 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -308,6 +308,9 @@ CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True # Attack Paths +ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int( + "ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30 +) ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py index 83192f18d0..11868b3fe0 100644 --- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -1,40 +1,50 @@ from datetime import UTC, datetime, timedelta +from functools import partial from api.attack_paths import database as graph_database from api.db_router import MainRouter from api.db_utils import rls_transaction from api.models import AttackPathsScan, StateChoices -from celery import states +from celery import current_app, states from celery.utils.log import get_task_logger -from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES +from config.django.base import ( + ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES, + ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES, +) +from django.db import DatabaseError +from django.db.transaction import on_commit from tasks.jobs.attack_paths.db_utils import ( mark_scan_finished, recover_graph_data_ready, ) -from tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive from tasks.jobs.orphan_recovery import revoke_task as _revoke_task logger = get_task_logger(__name__) +WORKER_PING_BASE_TIMEOUT_SECONDS = 5 +WORKER_PING_MAX_ATTEMPTS = 3 + def cleanup_stale_attack_paths_scans() -> dict: """ Mark stale `AttackPathsScan` rows as `FAILED`. Covers two stuck-state scenarios: - 1. `EXECUTING` scans whose workers are dead, or that have exceeded the - stale threshold while alive. - 2. `SCHEDULED` scans that never made it to a worker — parent scan + 1. `EXECUTING` scans whose workers are unresponsive and whose rows have + stopped receiving progress updates, or that exceeded the stale threshold. + 2. `SCHEDULED` scans that never made it to a worker - parent scan crashed before dispatch, broker lost the message, etc. Detected by age plus the parent `Scan` no longer being in flight. """ - threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES) now = datetime.now(tz=UTC) - cutoff = now - threshold + stale_cutoff = now - timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES) + inactivity_cutoff = now - timedelta( + minutes=ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES + ) cleaned_up: list[str] = [] - cleaned_up.extend(_cleanup_stale_executing_scans(cutoff)) - cleaned_up.extend(_cleanup_stale_scheduled_scans(cutoff)) + cleaned_up.extend(_cleanup_stale_executing_scans(stale_cutoff, inactivity_cutoff)) + cleaned_up.extend(_cleanup_stale_scheduled_scans(stale_cutoff)) logger.info( f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up" @@ -42,13 +52,57 @@ def cleanup_stale_attack_paths_scans() -> dict: return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up} -def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]: +def _ping_workers(workers: set[str]) -> tuple[set[str], set[str] | None]: + """Ping worker destinations in parallel and retry only missing workers. + + The second tuple item is `None` when the final ping attempt raises. In that + case the pending workers have unknown liveness and their scans must be kept. + """ + pending = set(workers) + responsive: set[str] = set() + + for attempt in range(WORKER_PING_MAX_ATTEMPTS): + if not pending: + return responsive, set() + + timeout = WORKER_PING_BASE_TIMEOUT_SECONDS * 2**attempt + try: + response = current_app.control.inspect( + destination=sorted(pending), timeout=timeout + ).ping() + except Exception: + attempts_remaining = WORKER_PING_MAX_ATTEMPTS - attempt - 1 + if attempts_remaining: + logger.warning( + f"Attack Paths worker ping attempt {attempt + 1} failed; " + f"retrying pending workers with {attempts_remaining} " + "attempt(s) remaining", + exc_info=True, + ) + continue + + logger.exception( + "Attack Paths worker ping attempts exhausted; preserving scans " + "for workers with unknown liveness" + ) + return responsive, None + + responded = pending.intersection((response or {}).keys()) + responsive.update(responded) + pending.difference_update(responded) + + return responsive, pending + + +def _cleanup_stale_executing_scans( + stale_cutoff: datetime, inactivity_cutoff: datetime +) -> list[str]: """ Two-pass detection for `EXECUTING` scans: - 1. If `TaskResult.worker` exists, ping the worker. - - Dead worker: cleanup immediately (any age). - - Alive + past threshold: revoke the task, then cleanup. - - Alive + within threshold: skip. + 1. Ping all recorded workers in parallel with bounded retries. + - Responsive + past stale threshold: cleanup. + - Unresponsive + past inactivity threshold: cleanup. + - Unknown after a final ping exception: preserve. 2. If no worker field: fall back to time-based heuristic only. """ executing_scans = list( @@ -57,14 +111,13 @@ def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]: .select_related("task__task_runner_task") ) - # Cache worker liveness so each worker is pinged at most once workers = { tr.worker for scan in executing_scans if (tr := getattr(scan.task, "task_runner_task", None) if scan.task else None) and tr.worker } - worker_alive = {w: _is_worker_alive(w) for w in workers} + responsive_workers, unresponsive_workers = _ping_workers(workers) cleaned_up: list[str] = [] @@ -75,27 +128,50 @@ def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]: worker = task_result.worker if task_result else None if worker: - alive = worker_alive.get(worker, True) - - if alive: - if scan.started_at and scan.started_at >= cutoff: + if worker in responsive_workers: + if scan.started_at is None or scan.started_at >= stale_cutoff: continue - # Alive but stale — revoke before cleanup - _revoke_task(task_result) - reason = "Scan exceeded stale threshold — cleaned up by periodic task" + reason = "Scan exceeded stale threshold - cleaned up by periodic task" + recheck_activity_cutoff = None + elif unresponsive_workers is None or worker not in unresponsive_workers: + logger.info( + f"Preserving scan {scan.id}: worker {worker} liveness is " + f"unknown (progress={scan.progress}, updated_at={scan.updated_at})" + ) + continue else: - reason = "Worker dead — cleaned up by periodic task" + if scan.updated_at >= inactivity_cutoff: + logger.info( + f"Preserving scan {scan.id}: worker {worker} is unresponsive " + f"but activity is recent (progress={scan.progress}, " + f"updated_at={scan.updated_at})" + ) + continue + + reason = ( + "Worker unresponsive and scan inactive for " + f"{ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES} minutes - " + "cleaned up by periodic task" + ) + recheck_activity_cutoff = inactivity_cutoff else: # No worker recorded, time-based heuristic only - if scan.started_at and scan.started_at >= cutoff: + if scan.started_at is None or scan.started_at >= stale_cutoff: continue reason = ( - "No worker recorded, scan exceeded stale threshold — " + "No worker recorded, scan exceeded stale threshold - " "cleaned up by periodic task" ) + recheck_activity_cutoff = None - if _cleanup_scan(scan, task_result, reason): + if _cleanup_scan( + scan, + task_result, + reason, + revoke=worker is not None, + inactivity_cutoff=recheck_activity_cutoff, + ): cleaned_up.append(str(scan.id)) return cleaned_up @@ -112,10 +188,9 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]: avoids cleaning up rows whose parent Prowler scan is legitimately still running. - For each match: revoke the queued task (best-effort; harmless if already - consumed), atomically flip to `FAILED`, and mark the `TaskResult`. The - temp Neo4j database is never created while `SCHEDULED`, so no drop is - needed. + For each match: lock and recheck the row, mark the scan and `TaskResult` as + failed, then revoke the queued task after the transaction commits. The temp + Neo4j database is never created while `SCHEDULED`, so no drop is needed. """ scheduled_scans = list( AttackPathsScan.all_objects.using(MainRouter.admin_db) @@ -141,42 +216,54 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]: task_result = ( getattr(scan.task, "task_runner_task", None) if scan.task else None ) - if task_result: - _revoke_task(task_result, terminate=False) - - reason = "Scan never started — cleaned up by periodic task" + reason = "Scan never started - cleaned up by periodic task" if _cleanup_scheduled_scan(scan, task_result, reason): cleaned_up.append(str(scan.id)) return cleaned_up -def _cleanup_scan(scan, task_result, reason: str) -> bool: +def _cleanup_scan( + scan, + task_result, + reason: str, + *, + revoke: bool = False, + inactivity_cutoff: datetime | None = None, +) -> bool: """ Clean up a single stale `AttackPathsScan`: - drop temp DB, mark `FAILED`, update `TaskResult`, recover `graph_data_ready`. + lock and recheck, mark `FAILED`, revoke after commit, drop the temp DB, and + recover graph readiness. Returns `True` if the scan was actually cleaned up, `False` if skipped. """ scan_id_str = str(scan.id) - # Drop temp Neo4j database + try: + fresh_scan = _finalize_failed_scan( + scan, + StateChoices.EXECUTING, + reason, + task_result=task_result, + revoke=revoke, + inactivity_cutoff=inactivity_cutoff, + ) + except DatabaseError: + logger.exception( + f"Failed to mark stale Attack Paths scan {scan_id_str} as failed" + ) + return False + + if fresh_scan is None: + return False + tmp_db_name = graph_database.get_database_name(scan.id, temporary=True) try: graph_database.drop_database(tmp_db_name) except Exception: logger.exception(f"Failed to drop temp database {tmp_db_name}") - fresh_scan = _finalize_failed_scan(scan, StateChoices.EXECUTING, reason) - if fresh_scan is None: - return False - - # Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock) - if task_result: - task_result.status = states.FAILURE - task_result.date_done = datetime.now(tz=UTC) - task_result.save(update_fields=["status", "date_done"]) - recover_graph_data_ready(fresh_scan) logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}") @@ -187,31 +274,49 @@ def _cleanup_scheduled_scan(scan, task_result, reason: str) -> bool: """ Clean up a `SCHEDULED` scan that never reached a worker. - Skips the temp Neo4j drop — the database is only created once the worker + Skips the temp Neo4j drop - the database is only created once the worker enters `EXECUTING`, so dropping it here just produces noisy log output. Returns `True` if the scan was actually cleaned up, `False` if skipped. """ scan_id_str = str(scan.id) - fresh_scan = _finalize_failed_scan(scan, StateChoices.SCHEDULED, reason) - if fresh_scan is None: + try: + fresh_scan = _finalize_failed_scan( + scan, + StateChoices.SCHEDULED, + reason, + task_result=task_result, + revoke=task_result is not None, + terminate=False, + ) + except DatabaseError: + logger.exception( + f"Failed to mark scheduled Attack Paths scan {scan_id_str} as failed" + ) return False - if task_result: - task_result.status = states.FAILURE - task_result.date_done = datetime.now(tz=UTC) - task_result.save(update_fields=["status", "date_done"]) + if fresh_scan is None: + return False logger.info(f"Cleaned up scheduled scan {scan_id_str}: {reason}") return True -def _finalize_failed_scan(scan, expected_state: str, reason: str): +def _finalize_failed_scan( + scan, + expected_state: str, + reason: str, + *, + task_result=None, + revoke: bool = False, + terminate: bool = True, + inactivity_cutoff: datetime | None = None, +): """ - Atomically lock the row, verify it's still in `expected_state`, and - mark it `FAILED`. Returns the locked row on success, `None` if the - row is gone or has already moved on. + Atomically lock the row, verify it's still eligible, and mark it `FAILED`. + If requested, register revocation after commit. Returns the locked row on + success, `None` if the row is gone or has already moved on. """ scan_id_str = str(scan.id) with rls_transaction(str(scan.tenant_id)): @@ -225,6 +330,23 @@ def _finalize_failed_scan(scan, expected_state: str, reason: str): logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping") return None + if inactivity_cutoff is not None and fresh_scan.updated_at >= inactivity_cutoff: + logger.info( + f"Scan {scan_id_str} received activity during worker checks, skipping" + ) + return None + mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason}) + if task_result: + task_result.status = states.FAILURE + task_result.date_done = datetime.now(tz=UTC) + task_result.save(update_fields=["status", "date_done"]) + + if revoke and task_result: + on_commit( + partial(_revoke_task, task_result, terminate=terminate), + using=fresh_scan._state.db, + ) + return fresh_scan diff --git a/api/src/backend/tasks/jobs/attack_paths/db_utils.py b/api/src/backend/tasks/jobs/attack_paths/db_utils.py index c444a62602..327d4463e2 100644 --- a/api/src/backend/tasks/jobs/attack_paths/db_utils.py +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -126,14 +126,17 @@ def starting_attack_paths_scan( if locked.state != StateChoices.SCHEDULED: return False + now = datetime.now(tz=UTC) locked.state = StateChoices.EXECUTING - locked.started_at = datetime.now(tz=UTC) + locked.started_at = now + locked.updated_at = now locked.update_tag = cartography_config.update_tag - locked.save(update_fields=["state", "started_at", "update_tag"]) + locked.save(update_fields=["state", "started_at", "updated_at", "update_tag"]) # Keep the in-memory object the caller is holding in sync. attack_paths_scan.state = locked.state attack_paths_scan.started_at = locked.started_at + attack_paths_scan.updated_at = locked.updated_at attack_paths_scan.update_tag = locked.update_tag return True @@ -181,7 +184,8 @@ def update_attack_paths_scan_progress( ) -> None: with rls_transaction(attack_paths_scan.tenant_id): attack_paths_scan.progress = progress - attack_paths_scan.save(update_fields=["progress"]) + attack_paths_scan.updated_at = datetime.now(tz=UTC) + attack_paths_scan.save(update_fields=["progress", "updated_at"]) def set_graph_data_ready( diff --git a/api/src/backend/tasks/jobs/orphan_recovery.py b/api/src/backend/tasks/jobs/orphan_recovery.py index 7211f1a1d5..05a2083dbe 100644 --- a/api/src/backend/tasks/jobs/orphan_recovery.py +++ b/api/src/backend/tasks/jobs/orphan_recovery.py @@ -172,11 +172,9 @@ def reconcile_orphans( window_hours: int = 6, dry_run: bool = False, ) -> dict: - """Run the full orphan sweep under a single-flight advisory lock. + """Run the orphan task sweep under a single-flight advisory lock. - Recovers any orphaned in-flight task and delegates attack-paths scans that - never reached a worker to their existing stale-cleanup. Returns a summary; - a no-op (lock not won) is reported too. + Returns a recovery summary. A no-op is reported when the lock is not acquired. """ with advisory_lock() as acquired: if not acquired: @@ -200,11 +198,6 @@ def reconcile_orphans( logger.info("Orphan task recovery disabled by feature flag") result = {"recovered": [], "failed": [], "skipped": [], "enabled": False} - if not dry_run: - from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans - - result["attack_paths"] = cleanup_stale_attack_paths_scans() - return {"acquired": True, **result} diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index db4dc4b0c6..3be885a7fc 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -17,7 +17,7 @@ from api.models import ( StatusChoices, Task, ) -from django.db import DEFAULT_DB_ALIAS +from django.db import DEFAULT_DB_ALIAS, DatabaseError from django_celery_results.models import TaskResult from prowler.lib.check.models import Severity from tasks.jobs.attack_paths import findings as findings_module @@ -2686,10 +2686,194 @@ class TestAttackPathsDbUtilsGraphDataReady: assert ap_scan_b.graph_data_ready is True +class TestAttackPathsWorkerPing: + @patch("tasks.jobs.attack_paths.cleanup.current_app") + def test_pings_workers_in_parallel_and_retries_only_missing(self, mock_app): + from tasks.jobs.attack_paths.cleanup import _ping_workers + + first_ping = MagicMock(return_value={"worker-a@host": {"ok": "pong"}}) + second_ping = MagicMock(return_value={"worker-b@host": {"ok": "pong"}}) + third_ping = MagicMock(return_value={"worker-c@host": {"ok": "pong"}}) + mock_app.control.inspect.side_effect = [ + MagicMock(ping=first_ping), + MagicMock(ping=second_ping), + MagicMock(ping=third_ping), + ] + + responsive, unresponsive = _ping_workers( + {"worker-c@host", "worker-a@host", "worker-b@host"} + ) + + assert responsive == { + "worker-a@host", + "worker-b@host", + "worker-c@host", + } + assert unresponsive == set() + assert mock_app.control.inspect.call_args_list == [ + call( + destination=["worker-a@host", "worker-b@host", "worker-c@host"], + timeout=5, + ), + call(destination=["worker-b@host", "worker-c@host"], timeout=10), + call(destination=["worker-c@host"], timeout=20), + ] + + @patch("tasks.jobs.attack_paths.cleanup.logger") + @patch("tasks.jobs.attack_paths.cleanup.current_app") + def test_retries_intermediate_ping_exceptions(self, mock_app, mock_logger): + from tasks.jobs.attack_paths.cleanup import _ping_workers + + mock_app.control.inspect.side_effect = [ + MagicMock(ping=MagicMock(side_effect=ConnectionError("first"))), + MagicMock(ping=MagicMock(side_effect=ConnectionError("second"))), + MagicMock(ping=MagicMock(return_value={})), + ] + + responsive, unresponsive = _ping_workers({"worker@host"}) + + assert responsive == set() + assert unresponsive == {"worker@host"} + assert mock_logger.warning.call_count == 2 + assert all( + warning.kwargs["exc_info"] is True + for warning in mock_logger.warning.call_args_list + ) + mock_logger.exception.assert_not_called() + + @patch("tasks.jobs.attack_paths.cleanup.logger") + @patch("tasks.jobs.attack_paths.cleanup.current_app") + def test_final_ping_exception_leaves_pending_workers_unknown( + self, mock_app, mock_logger + ): + from tasks.jobs.attack_paths.cleanup import _ping_workers + + mock_app.control.inspect.side_effect = [ + MagicMock(ping=MagicMock(return_value={"worker-a@host": {"ok": "pong"}})), + MagicMock(ping=MagicMock(return_value={})), + MagicMock(ping=MagicMock(side_effect=ConnectionError("final"))), + ] + + responsive, unresponsive = _ping_workers({"worker-a@host", "worker-b@host"}) + + assert responsive == {"worker-a@host"} + assert unresponsive is None + mock_logger.exception.assert_called_once() + + @patch("tasks.jobs.attack_paths.cleanup.logger") + @patch("tasks.jobs.attack_paths.cleanup.current_app") + def test_worker_can_respond_after_an_intermediate_exception( + self, mock_app, mock_logger + ): + from tasks.jobs.attack_paths.cleanup import _ping_workers + + mock_app.control.inspect.side_effect = [ + MagicMock(ping=MagicMock(side_effect=ConnectionError("first"))), + MagicMock(ping=MagicMock(side_effect=ConnectionError("second"))), + MagicMock(ping=MagicMock(return_value={"worker@host": {"ok": "pong"}})), + ] + + responsive, unresponsive = _ping_workers({"worker@host"}) + + assert responsive == {"worker@host"} + assert unresponsive == set() + assert mock_logger.warning.call_count == 2 + mock_logger.exception.assert_not_called() + + +class TestAttackPathsCleanupTask: + @patch( + "tasks.tasks.cleanup_stale_attack_paths_scans", + return_value={"cleaned_up_count": 1, "scan_ids": ["scan-id"]}, + ) + def test_hourly_task_invokes_attack_paths_cleanup(self, mock_cleanup): + from tasks.tasks import cleanup_stale_attack_paths_scans_task + + result = cleanup_stale_attack_paths_scans_task.run() + + assert result == {"cleaned_up_count": 1, "scan_ids": ["scan-id"]} + mock_cleanup.assert_called_once_with() + + +@pytest.mark.django_db +class TestAttackPathsDbUtilsActivity: + @patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_starting_scan_refreshes_updated_at( + self, tenants_fixture, aws_provider, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import starting_attack_paths_scan + + old_updated_at = datetime.now(tz=UTC) - timedelta(hours=1) + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenants_fixture[0].id, + provider=aws_provider, + scan=scans_fixture[0], + state=StateChoices.SCHEDULED, + ) + AttackPathsScan.objects.filter(id=attack_paths_scan.id).update( + updated_at=old_updated_at + ) + attack_paths_scan.refresh_from_db() + + started = starting_attack_paths_scan( + attack_paths_scan, SimpleNamespace(update_tag=123) + ) + + assert attack_paths_scan.updated_at > old_updated_at + attack_paths_scan.refresh_from_db() + assert started is True + assert attack_paths_scan.updated_at > old_updated_at + + @patch( + "tasks.jobs.attack_paths.db_utils.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_progress_update_refreshes_updated_at( + self, tenants_fixture, aws_provider, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import update_attack_paths_scan_progress + + old_updated_at = datetime.now(tz=UTC) - timedelta(hours=1) + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenants_fixture[0].id, + provider=aws_provider, + scan=scans_fixture[0], + state=StateChoices.EXECUTING, + ) + AttackPathsScan.objects.filter(id=attack_paths_scan.id).update( + updated_at=old_updated_at + ) + attack_paths_scan.refresh_from_db() + + update_attack_paths_scan_progress(attack_paths_scan, 42) + + assert attack_paths_scan.updated_at > old_updated_at + attack_paths_scan.refresh_from_db() + assert attack_paths_scan.progress == 42 + assert attack_paths_scan.updated_at > old_updated_at + + @pytest.mark.django_db class TestCleanupStaleAttackPathsScans: + @pytest.fixture(autouse=True) + def execute_on_commit_callbacks(self): + with patch( + "tasks.jobs.attack_paths.cleanup.on_commit", + side_effect=lambda callback, **kwargs: callback(), + ): + yield + def _create_executing_scan( - self, tenant, provider, scan=None, started_at=None, worker=None + self, + tenant, + provider, + scan=None, + started_at=None, + updated_at=None, + worker=None, ): """Helper to create an EXECUTING AttackPathsScan with optional Task+TaskResult.""" ap_scan = AttackPathsScan.objects.create( @@ -2716,18 +2900,66 @@ class TestCleanupStaleAttackPathsScans: ap_scan.task = task ap_scan.save(update_fields=["task_id"]) + if updated_at is not None: + AttackPathsScan.objects.filter(id=ap_scan.id).update(updated_at=updated_at) + ap_scan.updated_at = updated_at + return ap_scan, task_result + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + def test_defers_revoke_until_scan_failure_is_persisted( + self, + mock_revoke, + tenants_fixture, + aws_provider, + ): + from tasks.jobs.attack_paths.cleanup import _finalize_failed_scan + + ap_scan, task_result = self._create_executing_scan( + tenants_fixture[0], + aws_provider, + worker="unresponsive-worker@host", + ) + + with patch("tasks.jobs.attack_paths.cleanup.on_commit") as mock_on_commit: + finalized_scan = _finalize_failed_scan( + ap_scan, + StateChoices.EXECUTING, + "Cleanup reason", + task_result=task_result, + revoke=True, + ) + + assert finalized_scan is not None + ap_scan.refresh_from_db() + task_result.refresh_from_db() + assert ap_scan.state == StateChoices.FAILED + assert task_result.status == "FAILURE" + mock_revoke.assert_not_called() + mock_on_commit.assert_called_once() + assert mock_on_commit.call_args.kwargs == {"using": DEFAULT_DB_ALIAS} + + callback = mock_on_commit.call_args.args[0] + callback() + + mock_revoke.assert_called_once_with(task_result, terminate=True) + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") @patch( "tasks.jobs.attack_paths.cleanup.rls_transaction", new=lambda *args, **kwargs: nullcontext(), ) - @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) - def test_cleans_up_scan_with_dead_worker( + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + def test_cleans_up_inactive_scan_with_unresponsive_worker( self, - mock_alive, + mock_ping, + mock_revoke, mock_drop_db, mock_recover, tenants_fixture, @@ -2735,19 +2967,39 @@ class TestCleanupStaleAttackPathsScans: scans_fixture, ): from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + from tasks.jobs.attack_paths.db_utils import mark_scan_finished tenant = tenants_fixture[0] provider = aws_provider - # Recent scan — should still be cleaned up because worker is dead + updated_at = datetime.now(tz=UTC) - timedelta(minutes=31) ap_scan, task_result = self._create_executing_scan( - tenant, provider, worker="dead-worker@host" + tenant, + provider, + updated_at=updated_at, + worker="unresponsive-worker@host", ) + mock_ping.return_value = (set(), {"unresponsive-worker@host"}) - result = cleanup_stale_attack_paths_scans() + with patch( + "tasks.jobs.attack_paths.cleanup.mark_scan_finished", + wraps=mark_scan_finished, + ) as mock_mark_failed: + call_order = MagicMock() + call_order.attach_mock(mock_revoke, "revoke") + call_order.attach_mock(mock_mark_failed, "mark_failed") + call_order.attach_mock(mock_drop_db, "drop_database") + + result = cleanup_stale_attack_paths_scans() assert result["cleaned_up_count"] == 1 assert str(ap_scan.id) in result["scan_ids"] + assert [entry[0] for entry in call_order.mock_calls] == [ + "mark_failed", + "revoke", + "drop_database", + ] + mock_revoke.assert_called_once_with(task_result, terminate=True) mock_drop_db.assert_called_once() mock_recover.assert_called_once() @@ -2756,7 +3008,10 @@ class TestCleanupStaleAttackPathsScans: assert ap_scan.progress == 100 assert ap_scan.completed_at is not None assert ap_scan.ingestion_exceptions == { - "global_error": "Worker dead — cleaned up by periodic task" + "global_error": ( + "Worker unresponsive and scan inactive for 30 minutes - " + "cleaned up by periodic task" + ) } task_result.refresh_from_db() @@ -2770,10 +3025,10 @@ class TestCleanupStaleAttackPathsScans: new=lambda *args, **kwargs: nullcontext(), ) @patch("tasks.jobs.attack_paths.cleanup._revoke_task") - @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=True) - def test_revokes_and_cleans_scan_exceeding_threshold_on_live_worker( + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + def test_revokes_and_cleans_scan_exceeding_threshold_on_responsive_worker( self, - mock_alive, + mock_ping, mock_revoke, mock_drop_db, mock_recover, @@ -2790,11 +3045,12 @@ class TestCleanupStaleAttackPathsScans: ap_scan, task_result = self._create_executing_scan( tenant, provider, started_at=old_start, worker="live-worker@host" ) + mock_ping.return_value = ({"live-worker@host"}, set()) result = cleanup_stale_attack_paths_scans() assert result["cleaned_up_count"] == 1 - mock_revoke.assert_called_once_with(task_result) + mock_revoke.assert_called_once_with(task_result, terminate=True) mock_recover.assert_called_once() ap_scan.refresh_from_db() @@ -2806,10 +3062,10 @@ class TestCleanupStaleAttackPathsScans: "tasks.jobs.attack_paths.cleanup.rls_transaction", new=lambda *args, **kwargs: nullcontext(), ) - @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=True) - def test_ignores_recent_executing_scans_on_live_worker( + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + def test_ignores_recent_executing_scans_on_responsive_worker( self, - mock_alive, + mock_ping, mock_drop_db, mock_recover, tenants_fixture, @@ -2821,8 +3077,8 @@ class TestCleanupStaleAttackPathsScans: tenant = tenants_fixture[0] provider = aws_provider - # Recent scan on live worker — should be skipped self._create_executing_scan(tenant, provider, worker="live-worker@host") + mock_ping.return_value = ({"live-worker@host"}, set()) result = cleanup_stale_attack_paths_scans() @@ -2830,6 +3086,130 @@ class TestCleanupStaleAttackPathsScans: mock_drop_db.assert_not_called() mock_recover.assert_not_called() + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + def test_preserves_recent_scan_on_unresponsive_worker( + self, + mock_ping, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + aws_provider, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + self._create_executing_scan( + tenants_fixture[0], + aws_provider, + updated_at=datetime.now(tz=UTC) - timedelta(minutes=29), + worker="unresponsive-worker@host", + ) + mock_ping.return_value = (set(), {"unresponsive-worker@host"}) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + mock_revoke.assert_not_called() + mock_drop_db.assert_not_called() + mock_recover.assert_not_called() + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers", return_value=(set(), None)) + def test_final_ping_exception_preserves_pending_worker_scan( + self, + mock_ping, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + aws_provider, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + ap_scan, _ = self._create_executing_scan( + tenants_fixture[0], + aws_provider, + updated_at=datetime.now(tz=UTC) - timedelta(hours=1), + worker="unknown-worker@host", + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + mock_ping.assert_called_once_with({"unknown-worker@host"}) + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.EXECUTING + mock_revoke.assert_not_called() + mock_drop_db.assert_not_called() + mock_recover.assert_not_called() + + @pytest.mark.parametrize( + ("inactive_seconds", "should_clean"), + [(29 * 60 + 59, False), (30 * 60, False), (30 * 60 + 1, True)], + ) + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + def test_inactivity_boundary_is_strict( + self, + mock_ping, + mock_revoke, + mock_drop_db, + mock_recover, + inactive_seconds, + should_clean, + tenants_fixture, + aws_provider, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + now = datetime.now(tz=UTC) + ap_scan, task_result = self._create_executing_scan( + tenants_fixture[0], + aws_provider, + updated_at=now - timedelta(seconds=inactive_seconds), + worker="unresponsive-worker@host", + ) + mock_ping.return_value = (set(), {"unresponsive-worker@host"}) + + with patch("tasks.jobs.attack_paths.cleanup.datetime") as mock_datetime: + mock_datetime.now.return_value = now + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == int(should_clean) + ap_scan.refresh_from_db() + expected_state = StateChoices.FAILED if should_clean else StateChoices.EXECUTING + assert ap_scan.state == expected_state + if should_clean: + mock_revoke.assert_called_once_with(task_result, terminate=True) + mock_drop_db.assert_called_once() + mock_recover.assert_called_once() + else: + mock_revoke.assert_not_called() + mock_drop_db.assert_not_called() + mock_recover.assert_not_called() + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") @patch( @@ -2868,16 +3248,20 @@ class TestCleanupStaleAttackPathsScans: @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") @patch( "tasks.jobs.attack_paths.cleanup.graph_database.drop_database", - side_effect=Exception("Neo4j unreachable"), + side_effect=[Exception("Neo4j unreachable"), None], ) @patch( "tasks.jobs.attack_paths.cleanup.rls_transaction", new=lambda *args, **kwargs: nullcontext(), ) - @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) - def test_handles_drop_database_failure_gracefully( + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + @patch("tasks.jobs.attack_paths.cleanup.logger") + def test_neo4j_failure_leaves_scan_failed_and_continues( self, - mock_alive, + mock_logger, + mock_ping, + mock_revoke, mock_drop_db, mock_recover, tenants_fixture, @@ -2889,12 +3273,75 @@ class TestCleanupStaleAttackPathsScans: tenant = tenants_fixture[0] provider = aws_provider - self._create_executing_scan(tenant, provider, worker="dead-worker@host") + updated_at = datetime.now(tz=UTC) - timedelta(minutes=31) + ap_scan_1, _ = self._create_executing_scan( + tenant, + provider, + updated_at=updated_at, + worker="unresponsive-worker-1@host", + ) + ap_scan_2, _ = self._create_executing_scan( + tenant, + provider, + updated_at=updated_at, + worker="unresponsive-worker-2@host", + ) + mock_ping.return_value = ( + set(), + {"unresponsive-worker-1@host", "unresponsive-worker-2@host"}, + ) result = cleanup_stale_attack_paths_scans() - assert result["cleaned_up_count"] == 1 - mock_drop_db.assert_called_once() + assert result["cleaned_up_count"] == 2 + assert mock_revoke.call_count == 2 + assert mock_drop_db.call_count == 2 + mock_logger.exception.assert_called_once() + ap_scan_1.refresh_from_db() + ap_scan_2.refresh_from_db() + assert ap_scan_1.state == StateChoices.FAILED + assert ap_scan_2.state == StateChoices.FAILED + + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch( + "tasks.jobs.attack_paths.cleanup.mark_scan_finished", + side_effect=DatabaseError("PostgreSQL unavailable"), + ) + @patch("tasks.jobs.attack_paths.cleanup.logger") + def test_postgresql_failure_prevents_revoke_and_neo4j_deletion( + self, + mock_logger, + mock_mark_failed, + mock_ping, + mock_revoke, + mock_drop_db, + tenants_fixture, + aws_provider, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + self._create_executing_scan( + tenants_fixture[0], + aws_provider, + updated_at=datetime.now(tz=UTC) - timedelta(minutes=31), + worker="unresponsive-worker@host", + ) + mock_ping.return_value = (set(), {"unresponsive-worker@host"}) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + mock_mark_failed.assert_called_once() + mock_logger.exception.assert_called_once() + mock_revoke.assert_not_called() + mock_drop_db.assert_not_called() @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") @@ -2902,10 +3349,12 @@ class TestCleanupStaleAttackPathsScans: "tasks.jobs.attack_paths.cleanup.rls_transaction", new=lambda *args, **kwargs: nullcontext(), ) - @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") def test_cross_tenant_cleanup( self, - mock_alive, + mock_ping, + mock_revoke, mock_drop_db, mock_recover, tenants_fixture, @@ -2924,16 +3373,28 @@ class TestCleanupStaleAttackPathsScans: tenant_id=tenant2.id, ) + updated_at = datetime.now(tz=UTC) - timedelta(minutes=31) ap_scan1, _ = self._create_executing_scan( - tenant1, provider1, worker="dead-worker-1@host" + tenant1, + provider1, + updated_at=updated_at, + worker="unresponsive-worker-1@host", ) ap_scan2, _ = self._create_executing_scan( - tenant2, provider2, worker="dead-worker-2@host" + tenant2, + provider2, + updated_at=updated_at, + worker="unresponsive-worker-2@host", + ) + mock_ping.return_value = ( + set(), + {"unresponsive-worker-1@host", "unresponsive-worker-2@host"}, ) result = cleanup_stale_attack_paths_scans() assert result["cleaned_up_count"] == 2 + assert mock_revoke.call_count == 2 assert mock_recover.call_count == 2 ap_scan1.refresh_from_db() @@ -2947,10 +3408,12 @@ class TestCleanupStaleAttackPathsScans: "tasks.jobs.attack_paths.cleanup.rls_transaction", new=lambda *args, **kwargs: nullcontext(), ) - @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") def test_recovers_graph_data_ready_for_stale_scan( self, - mock_alive, + mock_ping, + mock_revoke, mock_drop_db, mock_recover, tenants_fixture, @@ -2963,11 +3426,16 @@ class TestCleanupStaleAttackPathsScans: provider = aws_provider ap_scan, _ = self._create_executing_scan( - tenant, provider, worker="dead-worker@host" + tenant, + provider, + updated_at=datetime.now(tz=UTC) - timedelta(minutes=31), + worker="unresponsive-worker@host", ) + mock_ping.return_value = (set(), {"unresponsive-worker@host"}) cleanup_stale_attack_paths_scans() + mock_revoke.assert_called_once() mock_recover.assert_called_once() recovered_scan = mock_recover.call_args[0][0] assert recovered_scan.id == ap_scan.id @@ -3013,10 +3481,57 @@ class TestCleanupStaleAttackPathsScans: "tasks.jobs.attack_paths.cleanup.rls_transaction", new=lambda *args, **kwargs: nullcontext(), ) - @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) - def test_shared_worker_is_pinged_only_once( + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + def test_preserves_scans_without_a_started_at_timestamp( self, - mock_alive, + mock_ping, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + aws_provider, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + responsive_scan, _ = self._create_executing_scan( + tenants_fixture[0], + aws_provider, + worker="responsive-worker@host", + ) + AttackPathsScan.objects.filter(id=responsive_scan.id).update(started_at=None) + no_worker_scan = AttackPathsScan.objects.create( + tenant_id=tenants_fixture[0].id, + provider=aws_provider, + state=StateChoices.EXECUTING, + started_at=None, + ) + mock_ping.return_value = ({"responsive-worker@host"}, set()) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + responsive_scan.refresh_from_db() + no_worker_scan.refresh_from_db() + assert responsive_scan.state == StateChoices.EXECUTING + assert no_worker_scan.state == StateChoices.EXECUTING + mock_revoke.assert_not_called() + mock_drop_db.assert_not_called() + mock_recover.assert_not_called() + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._ping_workers") + def test_shared_worker_is_collected_only_once( + self, + mock_ping, + mock_revoke, mock_drop_db, mock_recover, tenants_fixture, @@ -3028,15 +3543,114 @@ class TestCleanupStaleAttackPathsScans: tenant = tenants_fixture[0] provider = aws_provider - # Two scans on the same dead worker - self._create_executing_scan(tenant, provider, worker="shared-worker@host") - self._create_executing_scan(tenant, provider, worker="shared-worker@host") + updated_at = datetime.now(tz=UTC) - timedelta(minutes=31) + self._create_executing_scan( + tenant, + provider, + updated_at=updated_at, + worker="shared-worker@host", + ) + self._create_executing_scan( + tenant, + provider, + updated_at=updated_at, + worker="shared-worker@host", + ) + mock_ping.return_value = (set(), {"shared-worker@host"}) result = cleanup_stale_attack_paths_scans() assert result["cleaned_up_count"] == 2 - # Worker should be pinged exactly once — cache prevents second ping - mock_alive.assert_called_once_with("shared-worker@host") + assert mock_revoke.call_count == 2 + mock_ping.assert_called_once_with({"shared-worker@host"}) + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + def test_locked_recheck_preserves_scan_with_new_activity( + self, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + aws_provider, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + ap_scan, _ = self._create_executing_scan( + tenants_fixture[0], + aws_provider, + updated_at=datetime.now(tz=UTC) - timedelta(minutes=31), + worker="unresponsive-worker@host", + ) + + def record_activity(_workers): + AttackPathsScan.objects.filter(id=ap_scan.id).update( + updated_at=datetime.now(tz=UTC) + ) + return set(), {"unresponsive-worker@host"} + + with patch( + "tasks.jobs.attack_paths.cleanup._ping_workers", + side_effect=record_activity, + ): + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.EXECUTING + mock_revoke.assert_not_called() + mock_drop_db.assert_not_called() + mock_recover.assert_not_called() + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + def test_locked_recheck_preserves_scan_that_changed_state( + self, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + aws_provider, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + ap_scan, _ = self._create_executing_scan( + tenants_fixture[0], + aws_provider, + updated_at=datetime.now(tz=UTC) - timedelta(minutes=31), + worker="unresponsive-worker@host", + ) + + def complete_scan(_workers): + AttackPathsScan.objects.filter(id=ap_scan.id).update( + state=StateChoices.COMPLETED + ) + return set(), {"unresponsive-worker@host"} + + with patch( + "tasks.jobs.attack_paths.cleanup._ping_workers", + side_effect=complete_scan, + ): + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.COMPLETED + mock_revoke.assert_not_called() + mock_drop_db.assert_not_called() + mock_recover.assert_not_called() # `SCHEDULED` state cleanup def _create_scheduled_scan( @@ -3123,7 +3737,7 @@ class TestCleanupStaleAttackPathsScans: assert ap_scan.progress == 100 assert ap_scan.completed_at is not None assert ap_scan.ingestion_exceptions == { - "global_error": "Scan never started — cleaned up by periodic task" + "global_error": "Scan never started - cleaned up by periodic task" } # SCHEDULED revoke must NOT terminate a running worker diff --git a/api/src/backend/tasks/tests/test_orphan_recovery.py b/api/src/backend/tasks/tests/test_orphan_recovery.py index b78aca4e63..074e311b70 100644 --- a/api/src/backend/tasks/tests/test_orphan_recovery.py +++ b/api/src/backend/tasks/tests/test_orphan_recovery.py @@ -7,6 +7,7 @@ from celery import states from django.test import override_settings from django_celery_results.models import TaskResult from tasks.jobs.orphan_recovery import ( + _SKIP_RECOVERY, _decode_celery_field, _reconcile_task_results, _recovery_attempt_count, @@ -14,6 +15,7 @@ from tasks.jobs.orphan_recovery import ( is_worker_alive, reconcile_orphans, reenqueueable_tasks, + revoke_task, ) @@ -180,10 +182,18 @@ class TestReconcileTaskResults: assert tr.task_id in result["failed"] mock_count.assert_not_called() - def test_scan_task_is_skipped_entirely(self, tenants_fixture): + @pytest.mark.parametrize( + "task_name", + [ + "scan-perform", + "attack-paths-scan-perform", + "attack-paths-cleanup-stale-scans", + ], + ) + def test_scan_task_is_skipped_entirely(self, tenants_fixture, task_name): """Scan tasks are excluded from recovery: the watchdog never touches them.""" tr = _orphan_result( - name="scan-perform", + name=task_name, kwargs={ "tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4()), @@ -339,6 +349,15 @@ class TestOrphanRecoveryHelpers: ): assert is_worker_alive("w@h") is False + def test_revoke_task_terminates_with_sigterm_by_default(self): + task_result = MagicMock(task_id="task-id") + with patch( + "tasks.jobs.orphan_recovery.current_app.control.revoke" + ) as mock_revoke: + revoke_task(task_result) + + mock_revoke.assert_called_once_with("task-id", terminate=True, signal="SIGTERM") + def test_recovery_attempt_count_increments(self): # Unique signature so the Valkey counter starts fresh for this test. kwargs_repr = repr({"probe": str(uuid4())}) @@ -350,6 +369,12 @@ class TestOrphanRecoveryHelpers: class TestRecoveryFeatureFlags: + def test_attack_paths_tasks_are_excluded_from_generic_recovery(self): + assert { + "attack-paths-scan-perform", + "attack-paths-cleanup-stale-scans", + } <= _SKIP_RECOVERY + def test_all_groups_enabled_by_default(self): tasks = reenqueueable_tasks() assert "scan-summary" in tasks @@ -374,33 +399,23 @@ class TestRecoveryFeatureFlags: class TestRecoveryMasterFlag: @override_settings(TASK_RECOVERY_ENABLED=False) def test_master_flag_disables_task_recovery(self): - with ( - patch( - "tasks.jobs.orphan_recovery._reconcile_task_results" - ) as mock_reconcile, - patch( - "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", - return_value={}, - ), - ): + with patch( + "tasks.jobs.orphan_recovery._reconcile_task_results" + ) as mock_reconcile: result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) mock_reconcile.assert_not_called() assert result["acquired"] is True assert result["enabled"] is False + assert "attack_paths" not in result @override_settings(TASK_RECOVERY_ENABLED=True) def test_master_flag_enabled_runs_task_recovery(self): - with ( - patch( - "tasks.jobs.orphan_recovery._reconcile_task_results", - return_value={"recovered": [], "failed": [], "skipped": []}, - ) as mock_reconcile, - patch( - "tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans", - return_value={}, - ), - ): - reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) + with patch( + "tasks.jobs.orphan_recovery._reconcile_task_results", + return_value={"recovered": [], "failed": [], "skipped": []}, + ) as mock_reconcile: + result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False) mock_reconcile.assert_called_once() + assert "attack_paths" not in result From 077dff7f68defdb9a23d3d6a58fbfd8e4aa6b7f9 Mon Sep 17 00:00:00 2001 From: Josema Camacho Date: Wed, 15 Jul 2026 12:08:52 +0200 Subject: [PATCH 16/22] fix(api): resolve critical container scan findings (#11991) --- .trivyignore | 11 +++++++++++ api/Dockerfile | 1 + api/changelog.d/trivy-critical-cves.security.md | 1 + 3 files changed, 13 insertions(+) create mode 100644 api/changelog.d/trivy-critical-cves.security.md diff --git a/.trivyignore b/.trivyignore index 166f6389ad..c0207c8374 100644 --- a/.trivyignore +++ b/.trivyignore @@ -24,6 +24,17 @@ CVE-2026-8376 pkg:perl-base exp:2026-08-15 CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15 CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15 +# CVE-2026-13221 - Perl regex trie overflow. +# Packages: perl, perl-base, perl-modules-5.36, libperl5.36. +# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression +# was introduced after this version. Debian currently marks bookworm as +# vulnerable, which causes Trivy to report a false positive. +# Ref: https://github.com/Perl/perl5/issues/23388 +CVE-2026-13221 pkg:perl exp:2026-08-15 +CVE-2026-13221 pkg:perl-base exp:2026-08-15 +CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15 +CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15 + # CVE-2025-7458 — SQLite integer overflow. # Package: libsqlite3-0. # Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The diff --git a/api/Dockerfile b/api/Dockerfile index 0b5b0d7a27..8d6923bbfc 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -113,6 +113,7 @@ RUN apt-get purge -y --auto-remove \ make \ libxml2-dev \ libxmlsec1-dev \ + libxmlsec1-openssl \ pkg-config \ libtool \ libxslt1-dev \ diff --git a/api/changelog.d/trivy-critical-cves.security.md b/api/changelog.d/trivy-critical-cves.security.md new file mode 100644 index 0000000000..f078e69a04 --- /dev/null +++ b/api/changelog.d/trivy-critical-cves.security.md @@ -0,0 +1 @@ +`api` container image removes the unused Debian `libxml2` runtime package and scopes the `CVE-2026-13221` Trivy exception to unaffected Perl 5.36 packages From faa74f4c6cda8a834f5fea7e0793258cb4d6c7cb Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:11:28 +0200 Subject: [PATCH 17/22] feat(ui): add Local Server Cloud upgrade prompts (#11982) --- ui/app/(auth)/layout.tsx | 3 +- .../seed-from-findings-button.test.tsx | 39 +++-- .../_components/seed-from-findings-button.tsx | 21 ++- ui/app/(prowler)/alerts/page.tsx | 3 +- .../_components/compliance-page-tabs.test.tsx | 26 ++- .../_components/compliance-page-tabs.tsx | 26 ++- ui/app/(prowler)/findings/page.tsx | 3 +- ui/app/(prowler)/lighthouse/settings/page.tsx | 3 + ui/app/(prowler)/providers/page.test.ts | 55 ------- ui/app/(prowler)/providers/page.tsx | 14 +- .../local-server-cloud-upgrades.added.md | 1 + ui/components/auth/oss/auth-layout.tsx | 6 - ui/components/auth/oss/public-auth-shell.tsx | 18 +++ .../table/finding-note-modal.test.tsx | 25 ++- .../findings/table/finding-note-modal.tsx | 28 +++- .../table/finding-triage-cells.test.tsx | 66 +++++++- .../findings/table/finding-triage-cells.tsx | 31 ++++ .../icons/prowler/ProwlerIcons.test.tsx | 49 ++++++ ui/components/icons/prowler/ProwlerIcons.tsx | 52 ++++++ .../layout/main-layout/main-layout.test.tsx | 15 +- .../layout/main-layout/main-layout.tsx | 2 + .../layout/sidebar/collapsible-menu.tsx | 31 ++-- ui/components/layout/sidebar/menu-item.tsx | 17 +- ui/components/layout/sidebar/menu.test.tsx | 58 ++++++- ui/components/layout/sidebar/menu.tsx | 68 +++++++- .../layout/sidebar/sheet-menu.test.tsx | 35 ++++ ui/components/layout/sidebar/sheet-menu.tsx | 33 +++- ui/components/layout/sidebar/sidebar.tsx | 5 +- .../layout/sidebar/submenu-item.test.tsx | 69 +++----- ui/components/layout/sidebar/submenu-item.tsx | 91 +++++++---- ui/components/lighthouse-v1/index.ts | 1 + .../managed-lighthouse-callout.test.tsx | 33 ++++ .../managed-lighthouse-callout.tsx | 51 ++++++ .../aws-method-selector.test.tsx | 25 ++- .../organizations/aws-method-selector.tsx | 23 ++- .../wizard/steps/launch-step.test.tsx | 25 +++ .../providers/wizard/steps/launch-step.tsx | 17 +- .../scans/launch-scan-modal.test.tsx | 15 +- ui/components/scans/launch-scan-modal.tsx | 34 ++-- ui/components/scans/scans-filter-bar.tsx | 3 +- ui/components/scans/scans-page-shell.tsx | 3 +- .../schedule/scan-schedule-fields.test.tsx | 30 +++- .../scans/schedule/scan-schedule-fields.tsx | 22 ++- ui/components/shadcn/badge/badge.test.tsx | 33 ++++ ui/components/shadcn/badge/badge.tsx | 11 +- ui/components/shadcn/dialog.tsx | 2 +- ui/components/shadcn/modal/modal.tsx | 3 + ui/components/shared/cloud-feature-badge.tsx | 90 ----------- .../shared/cloud-upgrade-modal.test.tsx | 136 ++++++++++++++++ ui/components/shared/cloud-upgrade-modal.tsx | 107 ++++++++++++ .../sidebar/navigation-mode-toggle.tsx | 54 +++++-- ui/config/site.test.ts | 30 ++++ ui/config/site.ts | 6 +- ui/lib/cloud-upgrade.test.ts | 84 ++++++++++ ui/lib/cloud-upgrade.ts | 152 ++++++++++++++++++ ui/lib/lighthouse-v1/system-prompt.ts | 4 +- ui/lib/menu-list.test.ts | 48 ++++-- ui/lib/menu-list.ts | 81 ++++++++-- ui/public/logos/prowler-cloud-dark.svg | 1 + ui/public/logos/prowler-cloud-light.svg | 1 + ui/public/logos/prowler-local-server-dark.svg | 1 + .../logos/prowler-local-server-light.svg | 1 + ui/store/cloud-upgrade/store.ts | 29 ++++ ui/store/index.ts | 1 + ui/styles/globals.css | 14 ++ ui/types/cloud-upgrade.ts | 14 ++ ui/types/components.ts | 37 ++++- ui/types/index.ts | 1 + 68 files changed, 1687 insertions(+), 429 deletions(-) delete mode 100644 ui/app/(prowler)/providers/page.test.ts create mode 100644 ui/changelog.d/local-server-cloud-upgrades.added.md create mode 100644 ui/components/auth/oss/public-auth-shell.tsx create mode 100644 ui/components/icons/prowler/ProwlerIcons.test.tsx create mode 100644 ui/components/layout/sidebar/sheet-menu.test.tsx create mode 100644 ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx create mode 100644 ui/components/lighthouse-v1/managed-lighthouse-callout.tsx delete mode 100644 ui/components/shared/cloud-feature-badge.tsx create mode 100644 ui/components/shared/cloud-upgrade-modal.test.tsx create mode 100644 ui/components/shared/cloud-upgrade-modal.tsx create mode 100644 ui/config/site.test.ts create mode 100644 ui/lib/cloud-upgrade.test.ts create mode 100644 ui/lib/cloud-upgrade.ts create mode 100644 ui/public/logos/prowler-cloud-dark.svg create mode 100644 ui/public/logos/prowler-cloud-light.svg create mode 100644 ui/public/logos/prowler-local-server-dark.svg create mode 100644 ui/public/logos/prowler-local-server-light.svg create mode 100644 ui/store/cloud-upgrade/store.ts create mode 100644 ui/types/cloud-upgrade.ts diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index 27fff93fe5..b0a9bc2061 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -5,6 +5,7 @@ import { Metadata, Viewport } from "next"; import { connection } from "next/server"; import { ReactNode, Suspense } from "react"; +import { PublicAuthShell } from "@/components/auth/oss/public-auth-shell"; import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/shadcn"; import { fontMono, fontSans } from "@/config/fonts"; @@ -65,7 +66,7 @@ export default async function AuthLayout({ - {children} + {children} {gtmId && } diff --git a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx index 074304e58c..4250a9c579 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx @@ -8,6 +8,8 @@ import type { AlertFormSubmitResult, AlertFormValues, } from "@/app/(prowler)/alerts/_types/alert-form"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; const routerMocks = vi.hoisted(() => ({ push: vi.fn(), @@ -99,6 +101,7 @@ import { SeedFromFindingsButton } from "../seed-from-findings-button"; describe("SeedFromFindingsButton", () => { afterEach(() => { vi.clearAllMocks(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); it("should explain why creating an alert is disabled when no real filters are applied", async () => { @@ -356,8 +359,9 @@ describe("SeedFromFindingsButton", () => { ).not.toBeInTheDocument(); }); - it("should render disabled as a Cloud-only feature in OSS", () => { + it("should open the Alerts upgrade in Local Server", async () => { // Given + const user = userEvent.setup(); render( { // When const button = screen.getByRole("button", { name: /Create Alert/i }); + await user.click(button); // Then - expect(button).toBeDisabled(); + expect(button).not.toBeDisabled(); expect(button.className).not.toContain("min-w"); expect(button).not.toHaveClass("justify-start"); - const pricingLink = screen.getByRole("link", { - name: /available in prowler cloud/i, - }); - expect(pricingLink).toHaveAttribute("href", "https://prowler.com/pricing"); - expect(pricingLink).toHaveClass("whitespace-nowrap"); - expect(pricingLink).toHaveTextContent("Available in Prowler Cloud"); - expect(pricingLink.closest("button")).toBeNull(); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ALERTS, + ); expect(actionMocks.seedAlertRule).not.toHaveBeenCalled(); }); + + it("should expose a single keyboard stop for the Local Server upgrade", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.tab(); + + // Then + expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveFocus(); + }); }); diff --git a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx index e30ed9eee7..6b94549a9e 100644 --- a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx +++ b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx @@ -23,14 +23,16 @@ import type { } from "@/app/(prowler)/alerts/_types/alert-form"; import { buildFindingsFilterChips } from "@/components/findings/findings-filters.utils"; import { + Badge, Button, Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn"; import { ToastAction, useToast } from "@/components/shadcn"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { useCloudUpgradeStore } from "@/store"; import type { ScanEntity } from "@/types"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import type { ProviderProps } from "@/types/providers"; const DISABLED_FILTER_TOOLTIP = @@ -138,6 +140,9 @@ export const SeedFromFindingsButton = ({ }: SeedFromFindingsButtonProps) => { const router = useRouter(); const { toast } = useToast(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const [modalOpen, setModalOpen] = useState(false); const [seeding, setSeeding] = useState(false); const [seededCondition, setSeededCondition] = useState( @@ -154,7 +159,11 @@ export const SeedFromFindingsButton = ({ const canSeedFromFilters = hasFindingFilterValue(filterBag); const handleClick = async () => { - if (!isCloudEnabled || !canSeedFromFilters) return; + if (!isCloudEnabled) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + return; + } + if (!canSeedFromFilters) return; setSeeding(true); const result = await seedAlertRule(withDefaultAlertSeedFilters(filterBag)); setSeeding(false); @@ -201,7 +210,7 @@ export const SeedFromFindingsButton = ({ size={size} variant="default" onClick={handleClick} - disabled={!isCloudEnabled || !canSeedFromFilters || seeding} + disabled={(isCloudEnabled && !canSeedFromFilters) || seeding} className={className} > @@ -236,10 +245,10 @@ export const SeedFromFindingsButton = ({ if (!isCloudEnabled) { return ( - + {button} - - + + Cloud ); diff --git a/ui/app/(prowler)/alerts/page.tsx b/ui/app/(prowler)/alerts/page.tsx index 438313a262..2b7e74336b 100644 --- a/ui/app/(prowler)/alerts/page.tsx +++ b/ui/app/(prowler)/alerts/page.tsx @@ -7,6 +7,7 @@ import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions"; import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager"; import { ContentLayout } from "@/components/shadcn/content-layout"; import { createScanDetailsMapping } from "@/lib"; +import { isCloud } from "@/lib/shared/env"; import type { MetaDataProps, ScanEntity, ScanProps } from "@/types"; interface AlertsPageProps { @@ -49,7 +50,7 @@ const toAlertsSearchParams = ( }; export default async function AlertsPage({ searchParams }: AlertsPageProps) { - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true") { + if (!isCloud()) { redirect("/"); } diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx index b5f25adfe7..617d943030 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -1,6 +1,9 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB } from "../_types"; import { CompliancePageTabs } from "./compliance-page-tabs"; @@ -32,6 +35,10 @@ describe("CompliancePageTabs", () => { pushMock.mockClear(); }); + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + it("navigates with ?tab=cross-provider and back to the bare route", async () => { const user = userEvent.setup(); const { rerender } = render( @@ -59,7 +66,8 @@ describe("CompliancePageTabs", () => { expect(pushMock).toHaveBeenCalledWith("/compliance"); }); - it("disables the cross-provider tab with the cloud upsell badge in OSS", () => { + it("opens the cross-provider upgrade without changing tabs in Local Server", async () => { + const user = userEvent.setup(); render( { const crossProviderTab = screen.getByRole("tab", { name: /cross-provider/i, }); - const tabLabel = screen.getByText("Cross-Provider", { exact: true }); - const cloudBadge = screen.getByText("Available in Prowler Cloud"); + await user.click(crossProviderTab); - expect(crossProviderTab).toBeDisabled(); - expect(crossProviderTab).not.toHaveClass("disabled:opacity-50"); - expect(tabLabel).toHaveClass("opacity-50"); - expect(cloudBadge.parentElement).toHaveClass("gap-2"); + expect(crossProviderTab).not.toBeDisabled(); + expect(crossProviderTab).toHaveAttribute("aria-selected", "false"); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(pushMock).not.toHaveBeenCalled(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE, + ); }); }); diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx index fa6ab6ea70..79e5d8af00 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx @@ -3,8 +3,15 @@ import { useRouter } from "next/navigation"; import { ReactNode } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn"; -import { CloudFeatureBadge } from "@/components/shared/cloud-feature-badge"; +import { + Badge, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; @@ -24,10 +31,18 @@ export const CompliancePageTabs = ({ crossProviderContent, }: CompliancePageTabsProps) => { const router = useRouter(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const handleTabChange = (tab: string) => { const typedTab = tab as ComplianceTab; + if (typedTab === COMPLIANCE_TAB.CROSS_PROVIDER && !crossProviderEnabled) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE); + return; + } + if (typedTab === activeTab) { return; } @@ -52,8 +67,11 @@ export const CompliancePageTabs = ({ Per Scan : undefined} + adornment={ + !crossProviderEnabled ? ( + Cloud + ) : undefined + } > Cross-Provider diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index a93f799f62..77cb1d8d0e 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -25,6 +25,7 @@ import { hasDateOrScanFilter, } from "@/lib"; import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters"; +import { isCloud } from "@/lib/shared/env"; import { ScanEntity, ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; @@ -89,7 +90,7 @@ export default async function Findings({ completedScans || [], providersData, ) as { [uid: string]: ScanEntity }[]; - const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const alertsEnabled = isCloud(); return ( + + + {!isCloudEnv && ( +
+ + + + + {!isOpen && ( + + Explore Prowler Cloud + + )} + +
+ )} + {/* Footer */}
{isOpen ? ( <> {process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION} - {process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + {isCloudEnv && ( <> { target="_blank" rel="noopener noreferrer" className="flex items-center gap-1" + onClick={onSelect} > @@ -188,7 +239,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => { )} ) : ( - process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + isCloudEnv && ( { target="_blank" rel="noopener noreferrer" className="flex items-center" + onClick={onSelect} > diff --git a/ui/components/layout/sidebar/sheet-menu.test.tsx b/ui/components/layout/sidebar/sheet-menu.test.tsx new file mode 100644 index 0000000000..c57e7ecb54 --- /dev/null +++ b/ui/components/layout/sidebar/sheet-menu.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/icons", () => ({ + ProwlerBrand: () => Prowler, +})); + +vi.mock("@/components/layout/sidebar/menu", () => ({ + Menu: ({ onSelect }: { onSelect?: () => void }) => ( + + ), +})); + +import { SheetMenu } from "./sheet-menu"; + +describe("SheetMenu", () => { + it("should close after selecting a menu action", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("button", { name: "Open menu" })); + expect(screen.getByRole("dialog", { name: "Sidebar" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Alerts" })); + + // Then + expect( + screen.queryByRole("dialog", { name: "Sidebar" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/layout/sidebar/sheet-menu.tsx b/ui/components/layout/sidebar/sheet-menu.tsx index 8fef80c3fa..842986f8eb 100644 --- a/ui/components/layout/sidebar/sheet-menu.tsx +++ b/ui/components/layout/sidebar/sheet-menu.tsx @@ -1,7 +1,10 @@ +"use client"; + import { MenuIcon } from "lucide-react"; import Link from "next/link"; +import { useRef, useState } from "react"; -import { ProwlerExtended } from "@/components/icons"; +import { ProwlerBrand } from "@/components/icons"; import { Menu } from "@/components/layout/sidebar/menu"; import { Button } from "@/components/shadcn/button/button"; import { @@ -14,10 +17,24 @@ import { } from "@/components/shadcn/sheet"; export function SheetMenu() { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const handleSelect = () => { + setOpen(false); + return triggerRef.current; + }; + return ( - + - @@ -30,12 +47,16 @@ export function SheetMenu() { variant="link" asChild > - - + + - + ); diff --git a/ui/components/layout/sidebar/sidebar.tsx b/ui/components/layout/sidebar/sidebar.tsx index 79052d3551..f6f788563d 100644 --- a/ui/components/layout/sidebar/sidebar.tsx +++ b/ui/components/layout/sidebar/sidebar.tsx @@ -3,8 +3,7 @@ import clsx from "clsx"; import Link from "next/link"; -import { ProwlerShort } from "@/components/icons"; -import { ProwlerExtended } from "@/components/icons"; +import { ProwlerBrand, ProwlerShort } from "@/components/icons"; import { useSidebar } from "@/hooks/use-sidebar"; import { useStore } from "@/hooks/use-store"; import { cn } from "@/lib/utils"; @@ -49,7 +48,7 @@ export function Sidebar() { "mt-0!": isOpen, })} > - +
diff --git a/ui/components/layout/sidebar/submenu-item.test.tsx b/ui/components/layout/sidebar/submenu-item.test.tsx index 02726b8e46..053a50f3fc 100644 --- a/ui/components/layout/sidebar/submenu-item.test.tsx +++ b/ui/components/layout/sidebar/submenu-item.test.tsx @@ -1,6 +1,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { SUBMENU_KIND } from "@/types/components"; import { SubmenuItem } from "./submenu-item"; @@ -13,64 +17,41 @@ const TestIcon = ({ size = 16 }: { size?: number }) => ( ); describe("SubmenuItem", () => { - it("should show the cloud-only tooltip for disabled cloud menu items", async () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("should open the Alerts upgrade modal from a Local Server menu item", async () => { // Given const user = userEvent.setup(); + const returnFocusElement = document.createElement("button"); + const onSelect = vi.fn(() => returnFocusElement); render( , ); // When const button = screen.getByRole("button", { name: /alerts/i }); - expect(button).toHaveAttribute("aria-disabled", "true"); - expect(button).toHaveClass( - "cursor-not-allowed", - "text-text-neutral-tertiary", - ); - await user.hover(button.parentElement as HTMLElement); + await user.click(button); // Then - expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]"); - expect(screen.queryByText("Cloud")).not.toBeInTheDocument(); + expect(button).not.toHaveAttribute("aria-disabled"); + expect(screen.getByText("Cloud")).toBeVisible(); expect( - await screen.findAllByText("Available in Prowler Cloud"), - ).not.toHaveLength(0); - }); - - it("should render disabled Scan config menu items like disabled Alerts", async () => { - // Given - const user = userEvent.setup(); - render( - , + screen.queryByRole("link", { name: /alerts/i }), + ).not.toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ALERTS, ); - - // When - const button = screen.getByRole("button", { name: /scan/i }); - await user.hover(button.parentElement as HTMLElement); - - // Then - expect(button).toHaveAttribute("aria-disabled", "true"); - expect(button).toHaveClass( - "cursor-not-allowed", - "text-text-neutral-tertiary", + expect(onSelect).toHaveBeenCalledOnce(); + expect(useCloudUpgradeStore.getState().returnFocusElement).toBe( + returnFocusElement, ); - expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]"); - expect( - await screen.findAllByText("Available in Prowler Cloud"), - ).not.toHaveLength(0); }); }); diff --git a/ui/components/layout/sidebar/submenu-item.tsx b/ui/components/layout/sidebar/submenu-item.tsx index 2767381ff0..e481de6f5d 100644 --- a/ui/components/layout/sidebar/submenu-item.tsx +++ b/ui/components/layout/sidebar/submenu-item.tsx @@ -2,41 +2,67 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { type MouseEvent } from "react"; +import { Badge } from "@/components/shadcn/badge/badge"; import { Button } from "@/components/shadcn/button/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; -import { MenuFeatureBadge } from "@/components/shared/cloud-feature-badge"; -import { IconComponent } from "@/types"; +import { useCloudUpgradeStore } from "@/store"; +import { + type MenuSelectionHandler, + SUBMENU_KIND, + type SubmenuProps, +} from "@/types"; -interface SubmenuItemProps { - href: string; - label: string; - icon: IconComponent; - active?: boolean; - target?: string; - disabled?: boolean; - highlight?: boolean; - cloudOnly?: boolean; - onClick?: (event: MouseEvent) => void; -} +type SubmenuItemProps = SubmenuProps & { + onSelect?: MenuSelectionHandler; +}; -export const SubmenuItem = ({ - href, - label, - icon: Icon, - active, - target, - disabled, - highlight, - cloudOnly, - onClick, -}: SubmenuItemProps) => { +export const SubmenuItem = (props: SubmenuItemProps) => { const pathname = usePathname(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + if (props.kind === SUBMENU_KIND.CLOUD_UPGRADE) { + const { cloudUpgradeFeature, icon: Icon, label, onSelect } = props; + + return ( + + ); + } + + const { + active, + cloudOnly, + disabled, + highlight, + href, + icon: Icon, + label, + onSelect, + target, + } = props; const isActive = active !== undefined ? active : pathname === href; // Special case: Mutelist with tooltip when disabled @@ -86,7 +112,9 @@ export const SubmenuItem = ({

{label} {highlight && ( - + + New + )}

@@ -108,7 +136,7 @@ export const SubmenuItem = ({ href={href} target={target} className="flex items-center" - onClick={onClick} + onClick={onSelect} > @@ -116,12 +144,9 @@ export const SubmenuItem = ({

{label} {highlight && ( - + + New + )}

diff --git a/ui/components/lighthouse-v1/index.ts b/ui/components/lighthouse-v1/index.ts index 5f1281f40e..9bdcd76a06 100644 --- a/ui/components/lighthouse-v1/index.ts +++ b/ui/components/lighthouse-v1/index.ts @@ -4,4 +4,5 @@ export * from "./lighthouse-settings"; export * from "./llm-provider-registry"; export * from "./llm-provider-utils"; export * from "./llm-providers-table"; +export * from "./managed-lighthouse-callout"; export * from "./select-model"; diff --git a/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx b/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx new file mode 100644 index 0000000000..43ce9ac993 --- /dev/null +++ b/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { ManagedLighthouseCallout } from "./managed-lighthouse-callout"; + +describe("ManagedLighthouseCallout", () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("opens the managed Lighthouse Cloud upgrade", async () => { + // Given + const user = userEvent.setup(); + render(); + + const upgradeButton = screen.getByRole("button", { + name: "Explore The Agentic Cloud Defender", + }); + + // When + await user.click(upgradeButton); + + // Then + expect(upgradeButton).toHaveClass("bg-button-primary"); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + ); + }); +}); diff --git a/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx b/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx new file mode 100644 index 0000000000..80e38f5ccc --- /dev/null +++ b/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/shadcn/card/card"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +export const ManagedLighthouseCallout = () => { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + return ( + + +
+
+
+ +

+ Prowler Cloud includes managed OpenAI access with no API keys to + provision, plus a hosted remote MCP server to automate security + workflows. +

+ +
+
+ ); +}; diff --git a/ui/components/providers/organizations/aws-method-selector.test.tsx b/ui/components/providers/organizations/aws-method-selector.test.tsx index 62b74a67f4..9279e8dfb2 100644 --- a/ui/components/providers/organizations/aws-method-selector.test.tsx +++ b/ui/components/providers/organizations/aws-method-selector.test.tsx @@ -1,28 +1,43 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + import { AwsMethodSelector } from "./aws-method-selector"; describe("AwsMethodSelector", () => { afterEach(() => { vi.unstubAllEnvs(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); - it("links the OSS AWS Organizations badge to pricing", () => { + it("opens the AWS Organizations upgrade in Local Server", async () => { // Given vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const user = userEvent.setup(); + const onSelectOrganizations = vi.fn(); // When render( , ); // Then - expect( - screen.getByRole("link", { name: /available in prowler cloud/i }), - ).toHaveAttribute("href", "https://prowler.com/pricing"); + await user.click( + screen.getByRole("radio", { + name: /add multiple accounts with aws organizations/i, + }), + ); + + expect(onSelectOrganizations).not.toHaveBeenCalled(); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS, + ); }); }); diff --git a/ui/components/providers/organizations/aws-method-selector.tsx b/ui/components/providers/organizations/aws-method-selector.tsx index ca36407508..882adef1b2 100644 --- a/ui/components/providers/organizations/aws-method-selector.tsx +++ b/ui/components/providers/organizations/aws-method-selector.tsx @@ -1,9 +1,12 @@ "use client"; -import { Ban, Box, Boxes } from "lucide-react"; +import { Box, Boxes } from "lucide-react"; import { RadioCard } from "@/components/providers/radio-card"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { isCloud } from "@/lib/shared/env"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; interface AwsMethodSelectorProps { onSelectSingle: () => void; @@ -14,7 +17,10 @@ export function AwsMethodSelector({ onSelectSingle, onSelectOrganizations, }: AwsMethodSelectorProps) { - const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnv = isCloud(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); return (
@@ -29,12 +35,15 @@ export function AwsMethodSelector({ /> + isCloudEnv + ? onSelectOrganizations() + : openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS) + } > - {!isCloudEnv && } + {!isCloudEnv && Cloud}
); diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index caeef23385..dbc31771d9 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -374,6 +374,31 @@ describe("LaunchStep", () => { scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); + it("uses a warning badge for the subscription requirement", () => { + // Given + seedConnectedProvider(); + + // When + render( + , + ); + + // Then + expect(screen.getByText("Requires subscription")).toHaveClass( + "bg-bg-warning-secondary/20", + "text-text-warning-primary", + ); + expect(screen.getByText("Requires subscription")).toHaveAttribute( + "data-slot", + "badge", + ); + }); + it("defaults to run now, locks schedule mode, and only launches a manual scan", async () => { // Given const onClose = vi.fn(); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 8c3e96b28b..1c24f2b893 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -13,6 +13,7 @@ import { import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; import { Field, FieldLabel } from "@/components/shadcn"; import { ToastAction, useToast } from "@/components/shadcn"; +import { Badge } from "@/components/shadcn/badge/badge"; import { EntityInfo } from "@/components/shadcn/entities"; import { RadioGroup, @@ -20,10 +21,6 @@ import { } from "@/components/shadcn/radio-group/radio-group"; import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; -import { - CloudFeatureBadge, - CloudFeatureBadgeLink, -} from "@/components/shared/cloud-feature-badge"; import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; import { type ActionErrorResult, @@ -333,13 +330,11 @@ export function LaunchStep({ disabled={!canUseScheduleMode} /> On a schedule - {!canUseScheduleMode && - !isBlocked && - (isManualOnly ? ( - - ) : ( - - ))} + {isManualOnly && !isBlocked && ( + + Requires subscription + + )} diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx index 96272e09cd..706b704425 100644 --- a/ui/components/scans/launch-scan-modal.test.tsx +++ b/ui/components/scans/launch-scan-modal.test.tsx @@ -548,15 +548,26 @@ describe("LaunchScanModal", () => { expect(getScheduleMock).not.toHaveBeenCalled(); }); - it("locks schedule mode outside ADVANCED (OSS default)", () => { + it("preserves legacy daily scheduling outside Cloud", async () => { + const user = userEvent.setup(); + scheduleDailyMock.mockResolvedValue({ data: { id: provider.id } }); render( , ); expect( screen.getByRole("radio", { name: "On a schedule" }), - ).toBeDisabled(); + ).toBeEnabled(); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("radio", { name: "On a schedule" })); + expect(screen.getByRole("combobox", { name: "Repeats" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: /save schedule/i })); + + await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1)); expect(getScheduleMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); }); it("hides schedule mode but allows manual scans in MANUAL_ONLY", async () => { diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx index b9c469ce6f..465321dc1a 100644 --- a/ui/components/scans/launch-scan-modal.tsx +++ b/ui/components/scans/launch-scan-modal.tsx @@ -11,7 +11,13 @@ import { z } from "zod"; import { scanOnDemand } from "@/actions/scans"; import { getSchedule } from "@/actions/schedules"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; -import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn"; +import { + Badge, + Field, + FieldError, + FieldLabel, + Input, +} from "@/components/shadcn"; import { FormButtons } from "@/components/shadcn/form"; import { Modal } from "@/components/shadcn/modal"; import { @@ -19,7 +25,6 @@ import { RadioGroupItem, } from "@/components/shadcn/radio-group/radio-group"; import { toast, ToastAction } from "@/components/shadcn/toast"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; import { @@ -111,11 +116,13 @@ function LaunchScanForm({ const requestedProviderRef = useRef(""); const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; + const canUseScheduleMode = isAdvanced || isDailyLegacy; const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED || (isManualOnly && isScanLimitReached); - const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE; + const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE; // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. const providerId = useWatch({ control: form.control, name: "providerId" }); @@ -169,13 +176,15 @@ function LaunchScanForm({ const handleProviderChange = (id: string) => { form.setValue("providerId", id, { shouldValidate: true }); - if (isScheduleMode) void loadSchedule(id); + if (isScheduleMode && isAdvanced) void loadSchedule(id); }; const handleModeChange = (nextMode: string) => { - if (nextMode === LAUNCH_MODE.SCHEDULE && !isAdvanced) return; + if (nextMode === LAUNCH_MODE.SCHEDULE && !canUseScheduleMode) return; setMode(nextMode as LaunchMode); - if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId); + if (nextMode === LAUNCH_MODE.SCHEDULE && isAdvanced) { + void loadSchedule(providerId); + } }; const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => { @@ -216,7 +225,7 @@ function LaunchScanForm({ }); const saveSchedule = async () => { - if (isBlocked || !isAdvanced) return; + if (isBlocked || !canUseScheduleMode) return; const providerValid = await form.trigger("providerId"); if (!providerValid) return; @@ -225,6 +234,7 @@ function LaunchScanForm({ const result = await saveScheduleWithInitialScan({ providerId: form.getValues("providerId"), values, + useLegacyDaily: isDailyLegacy, }); if (result.status === SAVE_SCHEDULE_STATUS.ERROR) { @@ -320,10 +330,14 @@ function LaunchScanForm({ On a schedule - {!isAdvanced && } + {isDailyLegacy && ( + + Cloud + + )} @@ -362,6 +376,8 @@ function LaunchScanForm({ disabled={isSubmitting || !providerId} showLaunchInitialScan showNextScheduledCopy + canUseAdvancedSchedule={isAdvanced} + showCloudUpgradeBadge={isDailyLegacy} /> )} diff --git a/ui/components/scans/scans-filter-bar.tsx b/ui/components/scans/scans-filter-bar.tsx index df635514e6..de4539651f 100644 --- a/ui/components/scans/scans-filter-bar.tsx +++ b/ui/components/scans/scans-filter-bar.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn"; +import { isCloud } from "@/lib/shared/env"; import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types"; import type { ProviderGroup } from "@/types/components"; import { FILTER_FIELD } from "@/types/filters"; @@ -42,7 +43,7 @@ export function ScansFilterBar({ onScheduleTypeChange, onScanStatusChange, }: ScansFilterBarProps) { - const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnvironment = isCloud(); const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment); const statusFilterOptions = getScanStatusFilterOptions(activeTab); const showScheduleTypeFilter = activeTab !== SCAN_JOBS_TAB.SCHEDULED; diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx index 0333570db3..b553d224b5 100644 --- a/ui/components/scans/scans-page-shell.tsx +++ b/ui/components/scans/scans-page-shell.tsx @@ -17,6 +17,7 @@ import { LAUNCH_SCAN_SEARCH_PARAM, LAUNCH_SCAN_SEARCH_VALUE, } from "@/lib/scans-navigation"; +import { isCloud } from "@/lib/shared/env"; import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour"; import { useScansStore } from "@/store"; import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; @@ -67,7 +68,7 @@ export function ScansPageShell({ const hasConnectedProviders = providers.some( (provider) => provider.attributes.connection.connected === true, ); - const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnvironment = isCloud(); const launchDisabled = !hasManageScansPermission || !hasConnectedProviders; const launchOpen = isLaunchScanModalOpen || urlLaunchOpen; // When a scan is already running, the tour highlights its row (anchored in diff --git a/ui/components/scans/schedule/scan-schedule-fields.test.tsx b/ui/components/scans/schedule/scan-schedule-fields.test.tsx index dd311d880e..2beadf68ad 100644 --- a/ui/components/scans/schedule/scan-schedule-fields.test.tsx +++ b/ui/components/scans/schedule/scan-schedule-fields.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useForm } from "react-hook-form"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { getScheduleFormDefaults } from "@/lib/schedules"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import type { ScheduleFormValues } from "@/types/schedules"; import { ScanScheduleFields } from "./scan-schedule-fields"; @@ -58,6 +60,9 @@ function getHelperCopy(text: RegExp) { } describe("ScanScheduleFields", () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); it("updates the helper copy when the cadence changes to interval", async () => { // Given const user = userEvent.setup(); @@ -97,8 +102,9 @@ describe("ScanScheduleFields", () => { ); }); - it("shows a single cloud badge beside the Scan Schedule title when advanced controls are locked", () => { + it("opens advanced scheduling from the Cloud badge when controls are locked", async () => { // Given + const user = userEvent.setup(); render( { ); // Then - expect(screen.getAllByText("Available in Prowler Cloud")).toHaveLength(1); + expect(screen.getAllByText("Cloud")).toHaveLength(1); expect(screen.getByText("Scan Schedule").parentElement).toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", ); expect(screen.getByText("Scan Time").parentElement).not.toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", ); expect(screen.getByText("Repeats").parentElement).not.toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", + ); + + // When + await user.click( + screen.getByRole("button", { + name: "Explore advanced scheduling in Prowler Cloud", + }), + ); + + // Then + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING, ); }); }); diff --git a/ui/components/scans/schedule/scan-schedule-fields.tsx b/ui/components/scans/schedule/scan-schedule-fields.tsx index 9351ca5336..a83d8d3c0b 100644 --- a/ui/components/scans/schedule/scan-schedule-fields.tsx +++ b/ui/components/scans/schedule/scan-schedule-fields.tsx @@ -6,6 +6,8 @@ import type { ReactNode } from "react"; import { Controller, type UseFormReturn, useWatch } from "react-hook-form"; import { + Badge, + Button, Checkbox, Field, FieldLabel, @@ -15,13 +17,14 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { formatDayOfMonth, formatScheduleHour, getBrowserTimezone, getNextScheduledRun, } from "@/lib/schedules"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { SCHEDULE_FREQUENCY, SCHEDULE_WEEKDAY_LABELS, @@ -130,6 +133,9 @@ export function ScanScheduleFields({ canUseAdvancedSchedule = true, showCloudUpgradeBadge = false, }: ScanScheduleFieldsProps) { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. const control = form.control; const [frequency, hour, dayOfWeek, dayOfMonth, intervalHours] = useWatch({ @@ -149,7 +155,19 @@ export function ScanScheduleFields({ // ignores them, so they are display-only with a Cloud upsell. const advancedDisabled = disabled || !canUseAdvancedSchedule; const cloudUpgradeBadge = showCloudUpgradeBadge ? ( - + ) : null; return ( diff --git a/ui/components/shadcn/badge/badge.test.tsx b/ui/components/shadcn/badge/badge.test.tsx index adae2f54b8..51f68d4610 100644 --- a/ui/components/shadcn/badge/badge.test.tsx +++ b/ui/components/shadcn/badge/badge.test.tsx @@ -18,6 +18,39 @@ describe("Badge", () => { expect(badge?.className).toContain("text-bg-data-info"); }); + it("applies the Cloud variant and compact size", () => { + // Given / When + render( + + Cloud + , + ); + + // Then + expect(screen.getByText("Cloud")).toHaveClass( + "bg-feature-cloud", + "h-5", + "rounded-md", + "text-[10px]", + ); + }); + + it("applies the New feature variant tokens", () => { + // Given / When + render( + + New + , + ); + + // Then + expect(screen.getByText("New")).toHaveClass( + "bg-bg-feature-new", + "text-text-feature-new", + "h-5", + ); + }); + it("merges a custom className", () => { const { container } = render( diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx index 57674c4610..7e727e1a33 100644 --- a/ui/components/shadcn/badge/badge.tsx +++ b/ui/components/shadcn/badge/badge.tsx @@ -25,10 +25,18 @@ const badgeVariants = cva( error: "border-transparent bg-bg-fail-secondary text-text-error-primary", info: "border-transparent bg-bg-data-info/15 text-bg-data-info", + cloud: + "bg-feature-cloud h-6 rounded-lg border-0 px-2 py-0 text-xs leading-5 font-bold text-black", + new: "bg-bg-feature-new text-text-feature-new border-0 font-bold", + }, + size: { + default: "", + sm: "h-5 rounded-md px-1.5 py-0 text-[10px] leading-4", }, }, defaultVariants: { variant: "default", + size: "default", }, }, ); @@ -36,6 +44,7 @@ const badgeVariants = cva( function Badge({ className, variant, + size, asChild = false, ...props }: ComponentProps<"span"> & @@ -45,7 +54,7 @@ function Badge({ return ( ); diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index d4ae96d2ed..2b4669190c 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -70,7 +70,7 @@ function DialogContent({ {showCloseButton && ( Close diff --git a/ui/components/shadcn/modal/modal.tsx b/ui/components/shadcn/modal/modal.tsx index d47f0f3055..058cce3e7d 100644 --- a/ui/components/shadcn/modal/modal.tsx +++ b/ui/components/shadcn/modal/modal.tsx @@ -33,6 +33,7 @@ interface ModalProps { size?: ModalSize; className?: string; onOpenAutoFocus?: (event: Event) => void; + onCloseAutoFocus?: (event: Event) => void; /** * Cap the dialog at 90dvh and scroll overflowing content, instead of * letting it grow past the viewport. Opt-in per modal (e.g. for content @@ -51,12 +52,14 @@ export const Modal = ({ size = "xl", className, onOpenAutoFocus = preventInitialAutoFocus, + onCloseAutoFocus, scrollable = false, }: ModalProps) => { return ( , - CSSProperties | undefined -> = { - cloud: { - backgroundImage: - "linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)", - }, - new: undefined, -}; - -const FEATURE_BADGE_VARIANT_CLASS: Record< - NonNullable, - string -> = { - cloud: "text-black", - new: "bg-emerald-500 text-white", -}; - -const FEATURE_BADGE_SIZE_CLASS: Record< - NonNullable, - string -> = { - default: "h-6 rounded-lg px-2 text-xs leading-5", - sm: "h-5 rounded-md px-1.5 text-[10px] leading-4", -}; - -export const MenuFeatureBadge = ({ - label, - variant = "cloud", - size = "default", - className, -}: MenuFeatureBadgeProps) => ( - - {label} - -); - -export const CloudFeatureBadge = ({ - label = "Available in Prowler Cloud", - size, - className, -}: Omit) => ( - -); - -interface CloudFeatureBadgeLinkProps - extends Omit { - href?: string; -} - -export const CloudFeatureBadgeLink = ({ - href = "https://prowler.com/pricing", - label, - size, - className, -}: CloudFeatureBadgeLinkProps) => ( - - - -); diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx new file mode 100644 index 0000000000..318c03740c --- /dev/null +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { CloudUpgradeModal } from "./cloud-upgrade-modal"; + +describe("CloudUpgradeModal", () => { + afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("renders the active contextual upgrade in Local Server", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + + // When + render(); + + // Then + expect( + await screen.findByRole("dialog", { name: "Turn Findings into Alerts" }), + ).toBeVisible(); + expect(screen.getByText("Available in Prowler Cloud")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Create Alerts in Prowler Cloud" }), + ).toHaveAttribute( + "href", + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=alerts", + ); + expect( + screen.getByRole("link", { name: "View Plans & Pricing" }), + ).toHaveAttribute( + "href", + "https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=alerts", + ); + }); + + it("uses the standard equal-width CTA layout", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS); + + // When + render(); + + // Then + const dialog = await screen.findByRole("dialog", { + name: "Add Your Entire AWS Organization", + }); + const primaryCta = screen.getByRole("link", { + name: "Set Up AWS Organizations in Prowler Cloud", + }); + const secondaryCta = screen.getByRole("link", { + name: "View Plans & Pricing", + }); + + expect(dialog).toHaveClass("sm:max-w-2xl"); + expect(primaryCta.parentElement).toHaveClass("gap-3", "md:flex-row"); + expect(primaryCta).toHaveClass( + "h-auto", + "min-h-9", + "whitespace-normal", + "md:flex-1", + ); + expect(secondaryCta).toHaveClass( + "h-auto", + "min-h-9", + "whitespace-normal", + "md:flex-1", + ); + expect(primaryCta.querySelector(".truncate")).not.toBeInTheDocument(); + expect(primaryCta).toHaveAttribute( + "href", + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=organization", + ); + }); + + it("closes the active upgrade and returns focus to its trigger", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const user = userEvent.setup(); + + render( + <> + + + , + ); + + const trigger = screen.getByRole("button", { + name: "Explore Prowler Cloud", + }); + await user.click(trigger); + + // When + await user.click(screen.getByRole("button", { name: "Close" })); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + expect(useCloudUpgradeStore.getState().activeFeature).toBeNull(); + }); + + it("does not render upgrade UI in Prowler Cloud", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + + // When + render(); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/shared/cloud-upgrade-modal.tsx b/ui/components/shared/cloud-upgrade-modal.tsx new file mode 100644 index 0000000000..9d7617d941 --- /dev/null +++ b/ui/components/shared/cloud-upgrade-modal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Check, Cloud } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { Modal } from "@/components/shadcn/modal"; +import { + CLOUD_UPGRADE_CONTENT, + CLOUD_UPGRADE_FOOTER_NOTE, + CLOUD_UPGRADE_SECONDARY_CTA, + getCloudUpgradeCompareUrl, + getCloudUpgradePrimaryUrl, +} from "@/lib/cloud-upgrade"; +import { isCloud } from "@/lib/shared/env"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +const allowInitialAutoFocus = () => {}; + +export const CloudUpgradeModal = () => { + const activeFeature = useCloudUpgradeStore((state) => state.activeFeature); + const closeCloudUpgrade = useCloudUpgradeStore( + (state) => state.closeCloudUpgrade, + ); + const returnFocusElement = useCloudUpgradeStore( + (state) => state.returnFocusElement, + ); + + if (isCloud()) return null; + + const feature = activeFeature ?? CLOUD_UPGRADE_FEATURE.GENERAL; + const content = CLOUD_UPGRADE_CONTENT[feature]; + + return ( + !open && closeCloudUpgrade()} + onOpenAutoFocus={allowInitialAutoFocus} + onCloseAutoFocus={(event) => { + event.preventDefault(); + returnFocusElement?.focus(); + }} + title={content.title} + description={content.description} + size="2xl" + > +
+
+
+
+ Available in Prowler Cloud +
+ +
    + {content.benefits.map((benefit) => ( +
  • +
  • + ))} +
+ + + +

+ {CLOUD_UPGRADE_FOOTER_NOTE} +

+
+
+ ); +}; diff --git a/ui/components/sidebar/navigation-mode-toggle.tsx b/ui/components/sidebar/navigation-mode-toggle.tsx index 548c55dbef..73954e48ab 100644 --- a/ui/components/sidebar/navigation-mode-toggle.tsx +++ b/ui/components/sidebar/navigation-mode-toggle.tsx @@ -4,6 +4,7 @@ import { Home } from "lucide-react"; import { useRouter } from "next/navigation"; import { LighthouseIcon } from "@/components/icons/Icons"; +import { Badge } from "@/components/shadcn/badge/badge"; import { Tooltip, TooltipContent, @@ -14,19 +15,27 @@ import { type SidebarNavigationMode, } from "@/hooks/use-sidebar"; import { cn } from "@/lib/utils"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import type { MenuSelectionHandler } from "@/types/components"; export function SidebarNavigationModeToggle({ isOpen, value, onChange, chatEnabled = true, + onSelect, }: { isOpen: boolean; value: SidebarNavigationMode; onChange: (value: SidebarNavigationMode) => void; chatEnabled?: boolean; + onSelect?: MenuSelectionHandler; }) { const router = useRouter(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const modes = [ { value: SIDEBAR_NAVIGATION_MODE.BROWSE, @@ -41,8 +50,15 @@ export function SidebarNavigationModeToggle({ ] as const; const handleModeChange = (mode: SidebarNavigationMode, disabled: boolean) => { - if (disabled) return; + if (disabled) { + openCloudUpgrade( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + onSelect?.() ?? undefined, + ); + return; + } onChange(mode); + onSelect?.(); if (mode === SIDEBAR_NAVIGATION_MODE.CHAT) { router.push("/lighthouse"); } @@ -66,25 +82,37 @@ export function SidebarNavigationModeToggle({ key={mode.value} type="button" aria-label={mode.label} - // aria-disabled (not disabled) keeps the button hoverable and - // focusable so the availability tooltip can fire. - aria-disabled={disabled || undefined} className={cn( - "flex h-8 items-center justify-center rounded-[6px] border px-2 text-sm transition-all duration-200 ease-out", - isOpen ? "min-w-0 gap-2" : "w-8", - // The active segment grows (~55%) and gains a bordered, shadowed - // "thumb"; the inactive one shrinks (~45%) and stays flat. + "flex h-8 items-center justify-center rounded-[6px] border text-sm transition-all duration-200 ease-out", + isOpen + ? disabled + ? "min-w-0 gap-1 px-1.5" + : "min-w-0 gap-2 px-2" + : "w-8 px-2", active ? "border-border-input-primary bg-bg-neutral-primary text-text-neutral-primary shadow-md" : "text-text-neutral-secondary hover:text-text-neutral-primary border-transparent", - isOpen && (active ? "flex-[11]" : "flex-[9]"), - disabled && - "hover:text-text-neutral-secondary cursor-not-allowed opacity-50", + // Local Server gives the Chat upsell enough room to keep both + // the feature name and its Cloud badge visible. + isOpen && + (!chatEnabled + ? mode.value === SIDEBAR_NAVIGATION_MODE.CHAT + ? "flex-[3]" + : "flex-[2]" + : active + ? "flex-[11]" + : "flex-[9]"), + disabled && "text-text-neutral-secondary", )} onClick={() => handleModeChange(mode.value, disabled)} > - - {isOpen && {mode.label}} +