diff --git a/.env b/.env index c5c1f34342..62971841d1 100644 --- a/.env +++ b/.env @@ -58,15 +58,18 @@ NEO4J_DBMS_MAX__DATABASES=1000 NEO4J_SERVER_MEMORY_PAGECACHE_SIZE=1G NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE=1G NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE=1G -NEO4J_POC_EXPORT_FILE_ENABLED=true -NEO4J_APOC_IMPORT_FILE_ENABLED=true -NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true NEO4J_PLUGINS=["apoc"] NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST=apoc.* -NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=apoc.* +NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED= +NEO4J_APOC_EXPORT_FILE_ENABLED=false +NEO4J_APOC_IMPORT_FILE_ENABLED=false +NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true +NEO4J_APOC_TRIGGER_ENABLED=false NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687 # Neo4j Prowler settings ATTACK_PATHS_BATCH_SIZE=1000 +ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES=3 +ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS=30 # Celery-Prowler task settings TASK_RETRY_DELAY_SECONDS=0.1 diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index cb01a36de2..63034c4352 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to the **Prowler API** are documented in this file. - Attack Paths: Upgrade Cartography from fork 0.126.1 to upstream 0.129.0 and Neo4j driver from 5.x to 6.x [(#10110)](https://github.com/prowler-cloud/prowler/pull/10110) - Attack Paths: Query results now filtered by provider, preventing future cross-tenant and cross-provider data leakage [(#10118)](https://github.com/prowler-cloud/prowler/pull/10118) - Attack Paths: Add private labels and properties in Attack Paths graphs for avoiding future overlapping with Cartography's ones [(#10124)](https://github.com/prowler-cloud/prowler/pull/10124) +- Attack Paths: Query endpoint executes them in read only mode [(#10140)](https://github.com/prowler-cloud/prowler/pull/10140) ### 🐞 Fixed diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index f8d9cedd0a..202734013c 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -2,6 +2,8 @@ import atexit import logging import threading +from typing import Any + from contextlib import contextmanager from typing import Iterator from uuid import UUID @@ -12,6 +14,7 @@ import neo4j.exceptions from django.conf import settings from api.attack_paths.retryable_session import RetryableSession +from config.env import env from tasks.jobs.attack_paths.config import ( BATCH_SIZE, DEPRECATED_PROVIDER_RESOURCE_LABEL, @@ -21,7 +24,16 @@ from tasks.jobs.attack_paths.config import ( logging.getLogger("neo4j").setLevel(logging.ERROR) logging.getLogger("neo4j").propagate = False -SERVICE_UNAVAILABLE_MAX_RETRIES = 3 +SERVICE_UNAVAILABLE_MAX_RETRIES = env.int( + "ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3 +) +READ_QUERY_TIMEOUT_SECONDS = env.int( + "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 +) +READ_EXCEPTION_CODES = [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", +] # Module-level process-wide driver singleton _driver: neo4j.Driver | None = None @@ -78,17 +90,29 @@ def close_driver() -> None: # TODO: Use it @contextmanager -def get_session(database: str | None = None) -> Iterator[RetryableSession]: +def get_session( + database: str | None = None, default_access_mode: str | None = None +) -> Iterator[RetryableSession]: session_wrapper: RetryableSession | None = None try: session_wrapper = RetryableSession( - session_factory=lambda: get_driver().session(database=database), + session_factory=lambda: get_driver().session( + database=database, default_access_mode=default_access_mode + ), max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES, ) yield session_wrapper except neo4j.exceptions.Neo4jError as exc: + if ( + default_access_mode == neo4j.READ_ACCESS + and exc.code in READ_EXCEPTION_CODES + ): + message = "Read query not allowed" + code = READ_EXCEPTION_CODES[0] + raise WriteQueryNotAllowedException(message=message, code=code) + message = exc.message if exc.message is not None else str(exc) raise GraphDatabaseQueryException(message=message, code=exc.code) @@ -97,6 +121,22 @@ def get_session(database: str | None = None) -> Iterator[RetryableSession]: session_wrapper.close() +def execute_read_query( + database: str, + cypher: str, + parameters: dict[str, Any] | None = None, +) -> neo4j.graph.Graph: + with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session: + + def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph: + result = tx.run( + cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS + ) + return result.graph() + + return session.execute_read(_run) + + def create_database(database: str) -> None: query = "CREATE DATABASE $database IF NOT EXISTS" parameters = {"database": database} @@ -182,3 +222,7 @@ class GraphDatabaseQueryException(Exception): return f"{self.code}: {self.message}" return self.message + + +class WriteQueryNotAllowedException(GraphDatabaseQueryException): + pass diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py index 9d4c82694e..41d15cdf01 100644 --- a/api/src/backend/api/attack_paths/views_helpers.py +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -2,7 +2,7 @@ import logging from typing import Any, Iterable -from rest_framework.exceptions import APIException, ValidationError +from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from api.attack_paths import database as graph_database, AttackPathsQueryDefinition from config.custom_logging import BackendLogger @@ -87,9 +87,17 @@ def execute_attack_paths_query( provider_id: str, ) -> dict[str, Any]: try: - with graph_database.get_session(database_name) as session: - result = session.run(definition.cypher, parameters) - return _serialize_graph(result.graph(), provider_id) + graph = graph_database.execute_read_query( + database=database_name, + cypher=definition.cypher, + parameters=parameters, + ) + return _serialize_graph(graph, provider_id) + + except graph_database.WriteQueryNotAllowedException: + raise PermissionDenied( + "Attack Paths query execution failed: read-only queries are enforced" + ) except graph_database.GraphDatabaseQueryException as exc: logger.error(f"Query failed for Attack Paths query `{definition.id}`: {exc}") diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py index bb820ab780..e671f59547 100644 --- a/api/src/backend/api/tests/test_attack_paths.py +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -1,14 +1,21 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch - import pytest -from rest_framework.exceptions import APIException, ValidationError +import neo4j +import neo4j.exceptions + +from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from api.attack_paths import database as graph_database from api.attack_paths import views_helpers +def _make_neo4j_error(message, code): + """Build a Neo4jError with the given message and code.""" + return neo4j.exceptions.Neo4jError._hydrate_neo4j(code=code, message=message) + + def test_normalize_run_payload_extracts_attributes_section(): payload = { "data": { @@ -122,28 +129,25 @@ def test_execute_attack_paths_query_serializes_graph( ) graph = SimpleNamespace(nodes=[node, node_2], relationships=[relationship]) - run_result = MagicMock() - run_result.graph.return_value = graph - - session = MagicMock() - session.run.return_value = run_result - - session_ctx = MagicMock() - session_ctx.__enter__.return_value = session - session_ctx.__exit__.return_value = False + graph_result = MagicMock() + graph_result.nodes = graph.nodes + graph_result.relationships = graph.relationships database_name = "db-tenant-test-tenant-id" with patch( - "api.attack_paths.views_helpers.graph_database.get_session", - return_value=session_ctx, - ) as mock_get_session: + "api.attack_paths.views_helpers.graph_database.execute_read_query", + return_value=graph_result, + ) as mock_execute_read_query: result = views_helpers.execute_attack_paths_query( database_name, definition, parameters, provider_id=provider_id ) - mock_get_session.assert_called_once_with(database_name) - session.run.assert_called_once_with(definition.cypher, parameters) + mock_execute_read_query.assert_called_once_with( + database=database_name, + cypher=definition.cypher, + parameters=parameters, + ) assert result["nodes"][0]["id"] == "node-1" assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value" assert result["relationships"][0]["label"] == "OWNS" @@ -163,17 +167,10 @@ def test_execute_attack_paths_query_wraps_graph_errors( database_name = "db-tenant-test-tenant-id" parameters = {"provider_uid": "123"} - class ExplodingContext: - def __enter__(self): - raise graph_database.GraphDatabaseQueryException("boom") - - def __exit__(self, exc_type, exc, tb): - return False - with ( patch( - "api.attack_paths.views_helpers.graph_database.get_session", - return_value=ExplodingContext(), + "api.attack_paths.views_helpers.graph_database.execute_read_query", + side_effect=graph_database.GraphDatabaseQueryException("boom"), ), patch("api.attack_paths.views_helpers.logger") as mock_logger, ): @@ -185,6 +182,33 @@ def test_execute_attack_paths_query_wraps_graph_errors( mock_logger.error.assert_called_once() +def test_execute_attack_paths_query_raises_permission_denied_on_read_only( + attack_paths_query_definition_factory, +): + definition = attack_paths_query_definition_factory( + id="aws-rds", + name="RDS", + short_description="Short desc", + description="", + cypher="MATCH (n) RETURN n", + parameters=[], + ) + database_name = "db-tenant-test-tenant-id" + parameters = {"provider_uid": "123"} + + with patch( + "api.attack_paths.views_helpers.graph_database.execute_read_query", + side_effect=graph_database.WriteQueryNotAllowedException( + message="Read query not allowed", + code="Neo.ClientError.Statement.AccessMode", + ), + ): + with pytest.raises(PermissionDenied): + views_helpers.execute_attack_paths_query( + database_name, definition, parameters, provider_id="test-provider-123" + ) + + def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes): provider_id = "provider-keep" @@ -216,3 +240,105 @@ def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes) assert result["nodes"][0]["id"] == "n1" assert len(result["relationships"]) == 1 assert result["relationships"][0]["id"] == "r1" + + +# -- execute_read_query read-only enforcement --------------------------------- + + +@pytest.fixture +def mock_neo4j_session(): + """Mock the Neo4j driver so execute_read_query uses a fake session.""" + mock_session = MagicMock(spec=neo4j.Session) + mock_driver = MagicMock(spec=neo4j.Driver) + mock_driver.session.return_value = mock_session + + with patch("api.attack_paths.database.get_driver", return_value=mock_driver): + yield mock_session + + +def test_execute_read_query_succeeds_with_select(mock_neo4j_session): + mock_graph = MagicMock(spec=neo4j.graph.Graph) + mock_neo4j_session.execute_read.return_value = mock_graph + + result = graph_database.execute_read_query( + database="test-db", + cypher="MATCH (n:AWSAccount) RETURN n LIMIT 10", + ) + + assert result is mock_graph + + +def test_execute_read_query_rejects_create(mock_neo4j_session): + mock_neo4j_session.execute_read.side_effect = _make_neo4j_error( + "Writing in read access mode not allowed", + "Neo.ClientError.Statement.AccessMode", + ) + + with pytest.raises(graph_database.WriteQueryNotAllowedException): + graph_database.execute_read_query( + database="test-db", + cypher="CREATE (n:Node {name: 'test'}) RETURN n", + ) + + +def test_execute_read_query_rejects_update(mock_neo4j_session): + mock_neo4j_session.execute_read.side_effect = _make_neo4j_error( + "Writing in read access mode not allowed", + "Neo.ClientError.Statement.AccessMode", + ) + + with pytest.raises(graph_database.WriteQueryNotAllowedException): + graph_database.execute_read_query( + database="test-db", + cypher="MATCH (n:Node) SET n.name = 'updated' RETURN n", + ) + + +def test_execute_read_query_rejects_delete(mock_neo4j_session): + mock_neo4j_session.execute_read.side_effect = _make_neo4j_error( + "Writing in read access mode not allowed", + "Neo.ClientError.Statement.AccessMode", + ) + + with pytest.raises(graph_database.WriteQueryNotAllowedException): + graph_database.execute_read_query( + database="test-db", + cypher="MATCH (n:Node) DELETE n", + ) + + +@pytest.mark.parametrize( + "cypher", + [ + "CALL apoc.create.vNode(['Label'], {name: 'test'}) YIELD node RETURN node", + "MATCH (a)-[r]->(b) CALL apoc.create.vRelationship(a, 'REL', {}, b) YIELD rel RETURN rel", + ], + ids=["apoc.create.vNode", "apoc.create.vRelationship"], +) +def test_execute_read_query_succeeds_with_apoc_virtual_create( + mock_neo4j_session, cypher +): + mock_graph = MagicMock(spec=neo4j.graph.Graph) + mock_neo4j_session.execute_read.return_value = mock_graph + + result = graph_database.execute_read_query(database="test-db", cypher=cypher) + + assert result is mock_graph + + +@pytest.mark.parametrize( + "cypher", + [ + "CALL apoc.create.node(['Label'], {name: 'test'}) YIELD node RETURN node", + "MATCH (a), (b) CALL apoc.create.relationship(a, 'REL', {}, b) YIELD rel RETURN rel", + ], + ids=["apoc.create.Node", "apoc.create.Relationship"], +) +def test_execute_read_query_rejects_apoc_real_create(mock_neo4j_session, cypher): + mock_neo4j_session.execute_read.side_effect = _make_neo4j_error( + "There is no procedure with the name `apoc.create.node` registered", + "Neo.ClientError.Procedure.ProcedureNotFound", + ) + + with pytest.raises(graph_database.WriteQueryNotAllowedException): + graph_database.execute_read_query(database="test-db", cypher=cypher) diff --git a/api/src/backend/api/tests/test_attack_paths_database.py b/api/src/backend/api/tests/test_attack_paths_database.py index 46ba101c4a..8b458cb7b7 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -9,6 +9,7 @@ remain lazy. These tests validate the database module behavior itself. import threading from unittest.mock import MagicMock, patch +import neo4j import pytest @@ -241,6 +242,146 @@ class TestCloseDriver: assert db_module._driver is None +class TestExecuteReadQuery: + """Test read query execution helper.""" + + def test_execute_read_query_calls_read_session_and_returns_result(self): + import api.attack_paths.database as db_module + + tx = MagicMock() + expected_graph = MagicMock() + run_result = MagicMock() + run_result.graph.return_value = expected_graph + tx.run.return_value = run_result + + session = MagicMock() + + def execute_read_side_effect(fn): + return fn(tx) + + session.execute_read.side_effect = execute_read_side_effect + + session_ctx = MagicMock() + session_ctx.__enter__.return_value = session + session_ctx.__exit__.return_value = False + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ) as mock_get_session: + result = db_module.execute_read_query( + "db-tenant-test-tenant-id", + "MATCH (n) RETURN n", + {"provider_uid": "123"}, + ) + + mock_get_session.assert_called_once_with( + "db-tenant-test-tenant-id", + default_access_mode=neo4j.READ_ACCESS, + ) + session.execute_read.assert_called_once() + tx.run.assert_called_once_with( + "MATCH (n) RETURN n", + {"provider_uid": "123"}, + timeout=db_module.READ_QUERY_TIMEOUT_SECONDS, + ) + run_result.graph.assert_called_once_with() + assert result is expected_graph + + def test_execute_read_query_defaults_parameters_to_empty_dict(self): + import api.attack_paths.database as db_module + + tx = MagicMock() + run_result = MagicMock() + run_result.graph.return_value = MagicMock() + tx.run.return_value = run_result + + session = MagicMock() + session.execute_read.side_effect = lambda fn: fn(tx) + + session_ctx = MagicMock() + session_ctx.__enter__.return_value = session + session_ctx.__exit__.return_value = False + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + db_module.execute_read_query( + "db-tenant-test-tenant-id", + "MATCH (n) RETURN n", + ) + + tx.run.assert_called_once_with( + "MATCH (n) RETURN n", + {}, + timeout=db_module.READ_QUERY_TIMEOUT_SECONDS, + ) + run_result.graph.assert_called_once_with() + + +class TestGetSessionReadOnly: + """Test that get_session translates Neo4j read-mode errors.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + import api.attack_paths.database as db_module + + original_driver = db_module._driver + db_module._driver = None + yield + db_module._driver = original_driver + + @pytest.mark.parametrize( + "neo4j_code", + [ + "Neo.ClientError.Statement.AccessMode", + "Neo.ClientError.Procedure.ProcedureNotFound", + ], + ) + def test_get_session_raises_write_query_not_allowed(self, neo4j_code): + """Read-mode Neo4j errors should raise `WriteQueryNotAllowedException`.""" + import api.attack_paths.database as db_module + + mock_session = MagicMock() + neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j( + code=neo4j_code, + message="Write operations are not allowed", + ) + mock_session.run.side_effect = neo4j_error + + mock_driver = MagicMock() + mock_driver.session.return_value = mock_session + db_module._driver = mock_driver + + with pytest.raises(db_module.WriteQueryNotAllowedException): + with db_module.get_session( + default_access_mode=neo4j.READ_ACCESS + ) as session: + session.run("CREATE (n) RETURN n") + + def test_get_session_raises_generic_exception_for_other_errors(self): + """Non-read-mode Neo4j errors should raise GraphDatabaseQueryException.""" + import api.attack_paths.database as db_module + + mock_session = MagicMock() + neo4j_error = neo4j.exceptions.Neo4jError._hydrate_neo4j( + code="Neo.ClientError.Statement.SyntaxError", + message="Invalid syntax", + ) + mock_session.run.side_effect = neo4j_error + + mock_driver = MagicMock() + mock_driver.session.return_value = mock_session + db_module._driver = mock_driver + + with pytest.raises(db_module.GraphDatabaseQueryException): + with db_module.get_session( + default_access_mode=neo4j.READ_ACCESS + ) as session: + session.run("INVALID CYPHER") + + class TestThreadSafety: """Test thread-safe initialization.""" diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 9ab59f017d..554177bc5c 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -103,12 +103,13 @@ services: - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G} - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G} # APOC - - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true} - - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true} - - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}" - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}" - - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}" + - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-}" + - apoc.export.file.enabled=${NEO4J_APOC_EXPORT_FILE_ENABLED:-false} + - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-false} + - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} + - apoc.trigger.enabled=${NEO4J_APOC_TRIGGER_ENABLED:-false} # Networking - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}" # 7474 is the UI port diff --git a/docker-compose.yml b/docker-compose.yml index 798d1ecaba..4112624dc2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -89,12 +89,13 @@ services: - NEO4J_server_memory_heap_initial__size=${NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE:-1G} - NEO4J_server_memory_heap_max__size=${NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE:-1G} # APOC - - apoc.export.file.enabled=${NEO4J_POC_EXPORT_FILE_ENABLED:-true} - - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-true} - - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} - "NEO4J_PLUGINS=${NEO4J_PLUGINS:-[\"apoc\"]}" - "NEO4J_dbms_security_procedures_allowlist=${NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST:-apoc.*}" - - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-apoc.*}" + - "NEO4J_dbms_security_procedures_unrestricted=${NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED:-}" + - apoc.export.file.enabled=${NEO4J_APOC_EXPORT_FILE_ENABLED:-false} + - apoc.import.file.enabled=${NEO4J_APOC_IMPORT_FILE_ENABLED:-false} + - apoc.import.file.use_neo4j_config=${NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG:-true} + - apoc.trigger.enabled=${NEO4J_APOC_TRIGGER_ENABLED:-false} # Networking - "dbms.connector.bolt.listen_address=${NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS:-0.0.0.0:7687}" ports: diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 6ae605587c..8c7811d81b 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Updated GitHub provider form placeholder to clarify both username and organization names are valid inputs [(#9830)](https://github.com/prowler-cloud/prowler/pull/9830) - CSA CCM detailed view and small fix related with `Top Failed Sections` width [(#10018)](https://github.com/prowler-cloud/prowler/pull/10018) - Attack Paths: Show scan data availability status with badges and tooltips, allow selecting scans for querying while a new scan is in progress [(#10089)](https://github.com/prowler-cloud/prowler/pull/10089) +- Attack Paths: Catches not found and permissions (for read only queries) errors [(#10140)](https://github.com/prowler-cloud/prowler/pull/10140) ### 🔐 Security diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx index ff66a7e68f..f02b1205ab 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/page.tsx @@ -200,7 +200,24 @@ export default function AttackPathAnalysisPage() { parameters, ); - if (result?.data?.attributes) { + if (result && "error" in result) { + const apiError = result as unknown as { error: string; status: number }; + graphState.resetGraph(); + + if (apiError.status === 404) { + graphState.setError("No data found"); + showErrorToast("No data found", "The query returned no data"); + } else if (apiError.status === 403) { + graphState.setError("Not enough permissions to execute this query"); + showErrorToast( + "Error", + "Not enough permissions to execute this query", + ); + } else { + graphState.setError(apiError.error); + showErrorToast("Error", apiError.error); + } + } else if (result?.data?.attributes) { const graphData = adaptQueryResultToGraphData(result.data.attributes); graphState.updateGraphData(graphData); toast({ @@ -218,8 +235,11 @@ export default function AttackPathAnalysisPage() { }, 100); } else { graphState.resetGraph(); - graphState.setError("No data returned from query"); - showErrorToast("Error", "Query returned no data"); + graphState.setError("Failed to execute query due to an unknown error"); + showErrorToast( + "Error", + "Failed to execute query due to an unknown error", + ); } } catch (error) { const errorMsg =