Skip to content

Commit e688e60

Browse files
authored
feat(attack-paths): configure Neo4j for read-only queries (#10140)
1 parent 51dbf17 commit e688e60

File tree

10 files changed

+393
-47
lines changed

10 files changed

+393
-47
lines changed

.env

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,18 @@ NEO4J_DBMS_MAX__DATABASES=1000
5858
NEO4J_SERVER_MEMORY_PAGECACHE_SIZE=1G
5959
NEO4J_SERVER_MEMORY_HEAP_INITIAL__SIZE=1G
6060
NEO4J_SERVER_MEMORY_HEAP_MAX__SIZE=1G
61-
NEO4J_POC_EXPORT_FILE_ENABLED=true
62-
NEO4J_APOC_IMPORT_FILE_ENABLED=true
63-
NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
6461
NEO4J_PLUGINS=["apoc"]
6562
NEO4J_DBMS_SECURITY_PROCEDURES_ALLOWLIST=apoc.*
66-
NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=apoc.*
63+
NEO4J_DBMS_SECURITY_PROCEDURES_UNRESTRICTED=
64+
NEO4J_APOC_EXPORT_FILE_ENABLED=false
65+
NEO4J_APOC_IMPORT_FILE_ENABLED=false
66+
NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
67+
NEO4J_APOC_TRIGGER_ENABLED=false
6768
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
6869
# Neo4j Prowler settings
6970
ATTACK_PATHS_BATCH_SIZE=1000
71+
ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES=3
72+
ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS=30
7073

7174
# Celery-Prowler task settings
7275
TASK_RETRY_DELAY_SECONDS=0.1

api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ All notable changes to the **Prowler API** are documented in this file.
2727
- 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)
2828
- 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)
2929
- 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)
30+
- Attack Paths: Query endpoint executes them in read only mode [(#10140)](https://github.com/prowler-cloud/prowler/pull/10140)
3031

3132
### 🐞 Fixed
3233

api/src/backend/api/attack_paths/database.py

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import logging
33
import threading
44

5+
from typing import Any
6+
57
from contextlib import contextmanager
68
from typing import Iterator
79
from uuid import UUID
@@ -12,6 +14,7 @@
1214
from django.conf import settings
1315

1416
from api.attack_paths.retryable_session import RetryableSession
17+
from config.env import env
1518
from tasks.jobs.attack_paths.config import (
1619
BATCH_SIZE,
1720
DEPRECATED_PROVIDER_RESOURCE_LABEL,
@@ -21,7 +24,16 @@
2124
logging.getLogger("neo4j").setLevel(logging.ERROR)
2225
logging.getLogger("neo4j").propagate = False
2326

24-
SERVICE_UNAVAILABLE_MAX_RETRIES = 3
27+
SERVICE_UNAVAILABLE_MAX_RETRIES = env.int(
28+
"ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES", default=3
29+
)
30+
READ_QUERY_TIMEOUT_SECONDS = env.int(
31+
"ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
32+
)
33+
READ_EXCEPTION_CODES = [
34+
"Neo.ClientError.Statement.AccessMode",
35+
"Neo.ClientError.Procedure.ProcedureNotFound",
36+
]
2537

2638
# Module-level process-wide driver singleton
2739
_driver: neo4j.Driver | None = None
@@ -78,17 +90,29 @@ def close_driver() -> None: # TODO: Use it
7890

7991

8092
@contextmanager
81-
def get_session(database: str | None = None) -> Iterator[RetryableSession]:
93+
def get_session(
94+
database: str | None = None, default_access_mode: str | None = None
95+
) -> Iterator[RetryableSession]:
8296
session_wrapper: RetryableSession | None = None
8397

8498
try:
8599
session_wrapper = RetryableSession(
86-
session_factory=lambda: get_driver().session(database=database),
100+
session_factory=lambda: get_driver().session(
101+
database=database, default_access_mode=default_access_mode
102+
),
87103
max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
88104
)
89105
yield session_wrapper
90106

91107
except neo4j.exceptions.Neo4jError as exc:
108+
if (
109+
default_access_mode == neo4j.READ_ACCESS
110+
and exc.code in READ_EXCEPTION_CODES
111+
):
112+
message = "Read query not allowed"
113+
code = READ_EXCEPTION_CODES[0]
114+
raise WriteQueryNotAllowedException(message=message, code=code)
115+
92116
message = exc.message if exc.message is not None else str(exc)
93117
raise GraphDatabaseQueryException(message=message, code=exc.code)
94118

@@ -97,6 +121,22 @@ def get_session(database: str | None = None) -> Iterator[RetryableSession]:
97121
session_wrapper.close()
98122

99123

124+
def execute_read_query(
125+
database: str,
126+
cypher: str,
127+
parameters: dict[str, Any] | None = None,
128+
) -> neo4j.graph.Graph:
129+
with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session:
130+
131+
def _run(tx: neo4j.ManagedTransaction) -> neo4j.graph.Graph:
132+
result = tx.run(
133+
cypher, parameters or {}, timeout=READ_QUERY_TIMEOUT_SECONDS
134+
)
135+
return result.graph()
136+
137+
return session.execute_read(_run)
138+
139+
100140
def create_database(database: str) -> None:
101141
query = "CREATE DATABASE $database IF NOT EXISTS"
102142
parameters = {"database": database}
@@ -182,3 +222,7 @@ def __str__(self) -> str:
182222
return f"{self.code}: {self.message}"
183223

184224
return self.message
225+
226+
227+
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
228+
pass

api/src/backend/api/attack_paths/views_helpers.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any, Iterable
44

5-
from rest_framework.exceptions import APIException, ValidationError
5+
from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
66

77
from api.attack_paths import database as graph_database, AttackPathsQueryDefinition
88
from config.custom_logging import BackendLogger
@@ -87,9 +87,17 @@ def execute_attack_paths_query(
8787
provider_id: str,
8888
) -> dict[str, Any]:
8989
try:
90-
with graph_database.get_session(database_name) as session:
91-
result = session.run(definition.cypher, parameters)
92-
return _serialize_graph(result.graph(), provider_id)
90+
graph = graph_database.execute_read_query(
91+
database=database_name,
92+
cypher=definition.cypher,
93+
parameters=parameters,
94+
)
95+
return _serialize_graph(graph, provider_id)
96+
97+
except graph_database.WriteQueryNotAllowedException:
98+
raise PermissionDenied(
99+
"Attack Paths query execution failed: read-only queries are enforced"
100+
)
93101

94102
except graph_database.GraphDatabaseQueryException as exc:
95103
logger.error(f"Query failed for Attack Paths query `{definition.id}`: {exc}")

api/src/backend/api/tests/test_attack_paths.py

Lines changed: 151 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
from types import SimpleNamespace
22
from unittest.mock import MagicMock, patch
3-
43
import pytest
54

6-
from rest_framework.exceptions import APIException, ValidationError
5+
import neo4j
6+
import neo4j.exceptions
7+
8+
from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
79

810
from api.attack_paths import database as graph_database
911
from api.attack_paths import views_helpers
1012

1113

14+
def _make_neo4j_error(message, code):
15+
"""Build a Neo4jError with the given message and code."""
16+
return neo4j.exceptions.Neo4jError._hydrate_neo4j(code=code, message=message)
17+
18+
1219
def test_normalize_run_payload_extracts_attributes_section():
1320
payload = {
1421
"data": {
@@ -122,28 +129,25 @@ def test_execute_attack_paths_query_serializes_graph(
122129
)
123130
graph = SimpleNamespace(nodes=[node, node_2], relationships=[relationship])
124131

125-
run_result = MagicMock()
126-
run_result.graph.return_value = graph
127-
128-
session = MagicMock()
129-
session.run.return_value = run_result
130-
131-
session_ctx = MagicMock()
132-
session_ctx.__enter__.return_value = session
133-
session_ctx.__exit__.return_value = False
132+
graph_result = MagicMock()
133+
graph_result.nodes = graph.nodes
134+
graph_result.relationships = graph.relationships
134135

135136
database_name = "db-tenant-test-tenant-id"
136137

137138
with patch(
138-
"api.attack_paths.views_helpers.graph_database.get_session",
139-
return_value=session_ctx,
140-
) as mock_get_session:
139+
"api.attack_paths.views_helpers.graph_database.execute_read_query",
140+
return_value=graph_result,
141+
) as mock_execute_read_query:
141142
result = views_helpers.execute_attack_paths_query(
142143
database_name, definition, parameters, provider_id=provider_id
143144
)
144145

145-
mock_get_session.assert_called_once_with(database_name)
146-
session.run.assert_called_once_with(definition.cypher, parameters)
146+
mock_execute_read_query.assert_called_once_with(
147+
database=database_name,
148+
cypher=definition.cypher,
149+
parameters=parameters,
150+
)
147151
assert result["nodes"][0]["id"] == "node-1"
148152
assert result["nodes"][0]["properties"]["complex"]["items"][0] == "value"
149153
assert result["relationships"][0]["label"] == "OWNS"
@@ -163,17 +167,10 @@ def test_execute_attack_paths_query_wraps_graph_errors(
163167
database_name = "db-tenant-test-tenant-id"
164168
parameters = {"provider_uid": "123"}
165169

166-
class ExplodingContext:
167-
def __enter__(self):
168-
raise graph_database.GraphDatabaseQueryException("boom")
169-
170-
def __exit__(self, exc_type, exc, tb):
171-
return False
172-
173170
with (
174171
patch(
175-
"api.attack_paths.views_helpers.graph_database.get_session",
176-
return_value=ExplodingContext(),
172+
"api.attack_paths.views_helpers.graph_database.execute_read_query",
173+
side_effect=graph_database.GraphDatabaseQueryException("boom"),
177174
),
178175
patch("api.attack_paths.views_helpers.logger") as mock_logger,
179176
):
@@ -185,6 +182,33 @@ def __exit__(self, exc_type, exc, tb):
185182
mock_logger.error.assert_called_once()
186183

187184

185+
def test_execute_attack_paths_query_raises_permission_denied_on_read_only(
186+
attack_paths_query_definition_factory,
187+
):
188+
definition = attack_paths_query_definition_factory(
189+
id="aws-rds",
190+
name="RDS",
191+
short_description="Short desc",
192+
description="",
193+
cypher="MATCH (n) RETURN n",
194+
parameters=[],
195+
)
196+
database_name = "db-tenant-test-tenant-id"
197+
parameters = {"provider_uid": "123"}
198+
199+
with patch(
200+
"api.attack_paths.views_helpers.graph_database.execute_read_query",
201+
side_effect=graph_database.WriteQueryNotAllowedException(
202+
message="Read query not allowed",
203+
code="Neo.ClientError.Statement.AccessMode",
204+
),
205+
):
206+
with pytest.raises(PermissionDenied):
207+
views_helpers.execute_attack_paths_query(
208+
database_name, definition, parameters, provider_id="test-provider-123"
209+
)
210+
211+
188212
def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes):
189213
provider_id = "provider-keep"
190214

@@ -216,3 +240,105 @@ def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes)
216240
assert result["nodes"][0]["id"] == "n1"
217241
assert len(result["relationships"]) == 1
218242
assert result["relationships"][0]["id"] == "r1"
243+
244+
245+
# -- execute_read_query read-only enforcement ---------------------------------
246+
247+
248+
@pytest.fixture
249+
def mock_neo4j_session():
250+
"""Mock the Neo4j driver so execute_read_query uses a fake session."""
251+
mock_session = MagicMock(spec=neo4j.Session)
252+
mock_driver = MagicMock(spec=neo4j.Driver)
253+
mock_driver.session.return_value = mock_session
254+
255+
with patch("api.attack_paths.database.get_driver", return_value=mock_driver):
256+
yield mock_session
257+
258+
259+
def test_execute_read_query_succeeds_with_select(mock_neo4j_session):
260+
mock_graph = MagicMock(spec=neo4j.graph.Graph)
261+
mock_neo4j_session.execute_read.return_value = mock_graph
262+
263+
result = graph_database.execute_read_query(
264+
database="test-db",
265+
cypher="MATCH (n:AWSAccount) RETURN n LIMIT 10",
266+
)
267+
268+
assert result is mock_graph
269+
270+
271+
def test_execute_read_query_rejects_create(mock_neo4j_session):
272+
mock_neo4j_session.execute_read.side_effect = _make_neo4j_error(
273+
"Writing in read access mode not allowed",
274+
"Neo.ClientError.Statement.AccessMode",
275+
)
276+
277+
with pytest.raises(graph_database.WriteQueryNotAllowedException):
278+
graph_database.execute_read_query(
279+
database="test-db",
280+
cypher="CREATE (n:Node {name: 'test'}) RETURN n",
281+
)
282+
283+
284+
def test_execute_read_query_rejects_update(mock_neo4j_session):
285+
mock_neo4j_session.execute_read.side_effect = _make_neo4j_error(
286+
"Writing in read access mode not allowed",
287+
"Neo.ClientError.Statement.AccessMode",
288+
)
289+
290+
with pytest.raises(graph_database.WriteQueryNotAllowedException):
291+
graph_database.execute_read_query(
292+
database="test-db",
293+
cypher="MATCH (n:Node) SET n.name = 'updated' RETURN n",
294+
)
295+
296+
297+
def test_execute_read_query_rejects_delete(mock_neo4j_session):
298+
mock_neo4j_session.execute_read.side_effect = _make_neo4j_error(
299+
"Writing in read access mode not allowed",
300+
"Neo.ClientError.Statement.AccessMode",
301+
)
302+
303+
with pytest.raises(graph_database.WriteQueryNotAllowedException):
304+
graph_database.execute_read_query(
305+
database="test-db",
306+
cypher="MATCH (n:Node) DELETE n",
307+
)
308+
309+
310+
@pytest.mark.parametrize(
311+
"cypher",
312+
[
313+
"CALL apoc.create.vNode(['Label'], {name: 'test'}) YIELD node RETURN node",
314+
"MATCH (a)-[r]->(b) CALL apoc.create.vRelationship(a, 'REL', {}, b) YIELD rel RETURN rel",
315+
],
316+
ids=["apoc.create.vNode", "apoc.create.vRelationship"],
317+
)
318+
def test_execute_read_query_succeeds_with_apoc_virtual_create(
319+
mock_neo4j_session, cypher
320+
):
321+
mock_graph = MagicMock(spec=neo4j.graph.Graph)
322+
mock_neo4j_session.execute_read.return_value = mock_graph
323+
324+
result = graph_database.execute_read_query(database="test-db", cypher=cypher)
325+
326+
assert result is mock_graph
327+
328+
329+
@pytest.mark.parametrize(
330+
"cypher",
331+
[
332+
"CALL apoc.create.node(['Label'], {name: 'test'}) YIELD node RETURN node",
333+
"MATCH (a), (b) CALL apoc.create.relationship(a, 'REL', {}, b) YIELD rel RETURN rel",
334+
],
335+
ids=["apoc.create.Node", "apoc.create.Relationship"],
336+
)
337+
def test_execute_read_query_rejects_apoc_real_create(mock_neo4j_session, cypher):
338+
mock_neo4j_session.execute_read.side_effect = _make_neo4j_error(
339+
"There is no procedure with the name `apoc.create.node` registered",
340+
"Neo.ClientError.Procedure.ProcedureNotFound",
341+
)
342+
343+
with pytest.raises(graph_database.WriteQueryNotAllowedException):
344+
graph_database.execute_read_query(database="test-db", cypher=cypher)

0 commit comments

Comments
 (0)