From 517d3ff8a23252efad7455348e3084f367497a66 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Thu, 23 Apr 2026 18:15:08 +0200 Subject: [PATCH] fix(api): make Neo4j connection acquisition timeout configurable and enable Sentry tracing (#10875) Co-authored-by: Josema Camacho --- api/CHANGELOG.md | 24 ++++++ api/src/backend/api/attack_paths/database.py | 3 +- .../api/tests/test_attack_paths_database.py | 86 +++++++++---------- api/src/backend/config/settings/sentry.py | 1 + 4 files changed, 67 insertions(+), 47 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2d91bbb93b..3c98e7e7f9 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.26.0] (Prowler UNRELEASED) + +### 🚀 Added + +- CIS Benchmark PDF report generation for scans, exposing the latest CIS version per provider via `GET /scans/{id}/cis/{name}/` and picking the variant dynamically via `_pick_latest_cis_variant` (no hard-coded provider → version mapping) [(#10650)](https://github.com/prowler-cloud/prowler/pull/10650) + +### 🔄 Changed + +- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787) + +--- + +## [1.25.4] (Prowler UNRELEASED) + +### 🚀 Added + +- `DJANGO_SENTRY_TRACES_SAMPLE_RATE` env var (default `0.02`) enables Sentry performance tracing for the API [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873) + +### 🔄 Changed + +- Attack Paths: Neo4j driver `connection_acquisition_timeout` is now configurable via `NEO4J_CONN_ACQUISITION_TIMEOUT` (default lowered from 120 s to 15 s) [(#10873)](https://github.com/prowler-cloud/prowler/pull/10873) + +--- + ## [1.25.3] (Prowler v5.24.3) ### 🚀 Added diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index f8e20b659e..f5fddd0613 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -28,6 +28,7 @@ READ_QUERY_TIMEOUT_SECONDS = env.int( "ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30 ) MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250) +CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) READ_EXCEPTION_CODES = [ "Neo.ClientError.Statement.AccessMode", "Neo.ClientError.Procedure.ProcedureNotFound", @@ -62,7 +63,7 @@ def init_driver() -> neo4j.Driver: auth=(config["USER"], config["PASSWORD"]), keep_alive=True, max_connection_lifetime=7200, - connection_acquisition_timeout=120, + connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, max_connection_pool_size=50, ) _driver.verify_connectivity() 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 7e07792a69..8828d23911 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -12,6 +12,8 @@ from unittest.mock import MagicMock, patch import neo4j import pytest +import api.attack_paths.database as db_module + class TestLazyInitialization: """Test that Neo4j driver is initialized lazily on first use.""" @@ -19,8 +21,6 @@ class TestLazyInitialization: @pytest.fixture(autouse=True) def reset_module_state(self): """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - original_driver = db_module._driver db_module._driver = None @@ -31,8 +31,6 @@ class TestLazyInitialization: def test_driver_not_initialized_at_import(self): """Driver should be None after module import (no eager connection).""" - import api.attack_paths.database as db_module - assert db_module._driver is None @patch("api.attack_paths.database.settings") @@ -41,8 +39,6 @@ class TestLazyInitialization: self, mock_driver_factory, mock_settings ): """init_driver() should create connection only when called.""" - import api.attack_paths.database as db_module - mock_driver = MagicMock() mock_driver_factory.return_value = mock_driver mock_settings.DATABASES = { @@ -69,8 +65,6 @@ class TestLazyInitialization: self, mock_driver_factory, mock_settings ): """Subsequent calls should return cached driver without reconnecting.""" - import api.attack_paths.database as db_module - mock_driver = MagicMock() mock_driver_factory.return_value = mock_driver mock_settings.DATABASES = { @@ -99,8 +93,6 @@ class TestLazyInitialization: self, mock_driver_factory, mock_settings ): """get_driver() should use init_driver() for lazy initialization.""" - import api.attack_paths.database as db_module - mock_driver = MagicMock() mock_driver_factory.return_value = mock_driver mock_settings.DATABASES = { @@ -118,14 +110,50 @@ class TestLazyInitialization: mock_driver_factory.assert_called_once() +class TestConnectionAcquisitionTimeout: + """Test that the connection acquisition timeout is configurable.""" + + @pytest.fixture(autouse=True) + def reset_module_state(self): + original_driver = db_module._driver + original_timeout = db_module.CONN_ACQUISITION_TIMEOUT + + db_module._driver = None + + yield + + db_module._driver = original_driver + db_module.CONN_ACQUISITION_TIMEOUT = original_timeout + + @patch("api.attack_paths.database.settings") + @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") + def test_driver_receives_configured_timeout( + self, mock_driver_factory, mock_settings + ): + """init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver.""" + mock_driver_factory.return_value = MagicMock() + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + db_module.CONN_ACQUISITION_TIMEOUT = 42 + + db_module.init_driver() + + _, kwargs = mock_driver_factory.call_args + assert kwargs["connection_acquisition_timeout"] == 42 + + class TestAtexitRegistration: """Test that atexit cleanup handler is registered correctly.""" @pytest.fixture(autouse=True) def reset_module_state(self): """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - original_driver = db_module._driver db_module._driver = None @@ -141,8 +169,6 @@ class TestAtexitRegistration: self, mock_driver_factory, mock_atexit_register, mock_settings ): """atexit.register should be called on first initialization.""" - import api.attack_paths.database as db_module - mock_driver_factory.return_value = MagicMock() mock_settings.DATABASES = { "neo4j": { @@ -168,8 +194,6 @@ class TestAtexitRegistration: The double-checked locking on _driver ensures the atexit registration block only executes once (when _driver is first created). """ - import api.attack_paths.database as db_module - mock_driver_factory.return_value = MagicMock() mock_settings.DATABASES = { "neo4j": { @@ -194,8 +218,6 @@ class TestCloseDriver: @pytest.fixture(autouse=True) def reset_module_state(self): """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - original_driver = db_module._driver db_module._driver = None @@ -206,8 +228,6 @@ class TestCloseDriver: def test_close_driver_closes_and_clears_driver(self): """close_driver() should close the driver and set it to None.""" - import api.attack_paths.database as db_module - mock_driver = MagicMock() db_module._driver = mock_driver @@ -218,8 +238,6 @@ class TestCloseDriver: def test_close_driver_handles_none_driver(self): """close_driver() should handle case where driver is None.""" - import api.attack_paths.database as db_module - db_module._driver = None # Should not raise @@ -229,8 +247,6 @@ class TestCloseDriver: def test_close_driver_clears_driver_even_on_close_error(self): """Driver should be cleared even if close() raises an exception.""" - import api.attack_paths.database as db_module - mock_driver = MagicMock() mock_driver.close.side_effect = Exception("Connection error") db_module._driver = mock_driver @@ -246,8 +262,6 @@ 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() @@ -289,8 +303,6 @@ class TestExecuteReadQuery: 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() @@ -325,8 +337,6 @@ class TestGetSessionReadOnly: @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 @@ -341,8 +351,6 @@ class TestGetSessionReadOnly: ) 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, @@ -362,8 +370,6 @@ class TestGetSessionReadOnly: 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", @@ -388,8 +394,6 @@ class TestThreadSafety: @pytest.fixture(autouse=True) def reset_module_state(self): """Reset module-level singleton state before each test.""" - import api.attack_paths.database as db_module - original_driver = db_module._driver db_module._driver = None @@ -404,8 +408,6 @@ class TestThreadSafety: self, mock_driver_factory, mock_settings ): """Multiple threads calling init_driver() should create only one driver.""" - import api.attack_paths.database as db_module - mock_driver = MagicMock() mock_driver_factory.return_value = mock_driver mock_settings.DATABASES = { @@ -448,8 +450,6 @@ class TestHasProviderData: """Test has_provider_data helper for checking provider nodes in Neo4j.""" def test_returns_true_when_nodes_exist(self): - import api.attack_paths.database as db_module - mock_session = MagicMock() mock_result = MagicMock() mock_result.single.return_value = MagicMock() # non-None record @@ -468,8 +468,6 @@ class TestHasProviderData: mock_session.run.assert_called_once() def test_returns_false_when_no_nodes(self): - import api.attack_paths.database as db_module - mock_session = MagicMock() mock_result = MagicMock() mock_result.single.return_value = None @@ -486,8 +484,6 @@ class TestHasProviderData: assert db_module.has_provider_data("db-tenant-abc", "provider-123") is False def test_returns_false_when_database_not_found(self): - import api.attack_paths.database as db_module - session_ctx = MagicMock() session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( message="Database does not exist", @@ -503,8 +499,6 @@ class TestHasProviderData: ) def test_raises_on_other_errors(self): - import api.attack_paths.database as db_module - session_ctx = MagicMock() session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( message="Connection refused", diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 65c6277817..580821f7b2 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -120,6 +120,7 @@ sentry_sdk.init( # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info before_send=before_send, send_default_pii=True, + traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02), _experiments={ # Set continuous_profiling_auto_start to True # to automatically start the profiler on when