diff --git a/Tenable/CHANGELOG.md b/Tenable/CHANGELOG.md index 4a56c48ed..6307698e9 100644 --- a/Tenable/CHANGELOG.md +++ b/Tenable/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 2026-03-04 - 1.0.13 + +### Changed + +- Use export assets instead of get assets details for each vulnerability + ## 2026-02-23 - 1.0.12 ### Changed diff --git a/Tenable/manifest.json b/Tenable/manifest.json index 70cea8f29..034a43972 100644 --- a/Tenable/manifest.json +++ b/Tenable/manifest.json @@ -30,7 +30,7 @@ "description": "Tenable is a cybersecurity company specializing in vulnerability management and risk assessment solutions, known for its flagship product, Nessus. It helps organizations identify, assess, and prioritize security risks across their IT infrastructure.", "name": "Tenable", "uuid": "1214e603-6c86-4e86-896f-70198c9ade86", - "version": "1.0.12", + "version": "1.0.13", "slug": "tenable", "categories": [ "Endpoint" diff --git a/Tenable/tenable_conn/asset_connector/vulnerability_asset.py b/Tenable/tenable_conn/asset_connector/vulnerability_asset.py index 62221f122..538d09787 100644 --- a/Tenable/tenable_conn/asset_connector/vulnerability_asset.py +++ b/Tenable/tenable_conn/asset_connector/vulnerability_asset.py @@ -67,6 +67,10 @@ def __init__(self, *args, **kwargs): start_at=timedelta(days=120), ) self._latest_time = self.cursor.offset + self._asset_map: dict[str, dict] | None = {} + self._max_cache_size = 5000 + self._last_asset_refresh: datetime = datetime.now() + self._asset_refresh_interval = timedelta(hours=1) @cached_property def client(self) -> TenableIO: @@ -106,6 +110,71 @@ def severities(self) -> list[str]: sev_list.append(str(severity.value)) return sev_list + def _should_refresh_asset_cache(self) -> bool: + """ + Determine if the asset cache should be refreshed based on the last refresh time and the defined interval. + :return: + bool: True if the asset cache should be refreshed, False otherwise. + """ + time_since_refresh = datetime.now() - self._last_asset_refresh + return time_since_refresh >= self._asset_refresh_interval + + def _build_asset_map(self) -> dict[str, dict]: + """ + Build a map of asset UUIDs to their corresponding asset information. + :return: + dict[str, dict]: A dictionary mapping asset UUIDs to asset information dictionaries. + """ + asset_count = 0 + try: + self.log("Start uploading assets from tenable.", level="info") + + self._asset_map.clear() + + for asset in self.client.exports.assets_v2(since=self._latest_time, chunk_size=4000): + asset_uuid = asset.get("id") + if asset_uuid: + self._asset_map[asset_uuid] = asset + asset_count += 1 + if asset_count >= self._max_cache_size: + self.log(f"Stop uploading, limit exceeded ({self._max_cache_size})", level="warning") + break + + self._last_asset_refresh = datetime.now() + self.log(f"{len(self._asset_map)} assets uploaded", level="info") + except Exception as e: + self.log_exception(e, message="Error while building asset map from Tenable") + + def _get_asset_info(self, asset_uuid: str) -> dict | None: + """ + Retrieve asset information for a given asset UUID, using a cached map for efficiency. + :param asset_uuid: + The UUID of the asset to retrieve information for. + :return: + dict: A dictionary containing asset information, or None if retrieval fails. + """ + # Build the asset map if it hasn't been built yet + if not self._asset_map or self._should_refresh_asset_cache(): + self._build_asset_map() + + # Attempt to retrieve asset information from the cache + if asset_uuid in self._asset_map: + return self._asset_map[asset_uuid] + + # If the asset information is not in the cache, attempt to retrieve it via API call + try: + self.log(f"Can't find asset {asset_uuid}, try another call", level="debug") + asset_info: dict = self.client.assets.details(asset_uuid) + + # Add the newly retrieved asset information to the cache if there's room, but don't exceed the max cache size + if len(self._asset_map) < self._max_cache_size: + self._asset_map[asset_uuid] = asset_info + + return asset_info + except Exception as e: + self.log(f"Error while searching for asset {asset_uuid}: {e}", level="error") + return None + def extract_timestamp(self, vuln: dict, field: str = "first_found") -> int: """ Extract timestamp from vulnerability field. @@ -457,14 +526,6 @@ def update_checkpoint(self) -> None: except Exception as e: self.log_exception(e, message="Failed to update checkpoint") - def _get_asset_info(self, asset_uuid: str) -> dict | None: - try: - asset_info: dict = self.client.assets.details(asset_uuid) - return asset_info - except Exception as e: - self.log(f"Failed to get asset info for {asset_uuid}: {e}", level="error") - return None - def _get_tenable_vul(self) -> Generator[VulnerabilityOCSFModel, None, None]: """ Retrieve vulnerabilities from Tenable and map them to OCSF model. diff --git a/Tenable/tests/asset_connector/test_vulnerability_asset.py b/Tenable/tests/asset_connector/test_vulnerability_asset.py index a9a4fc248..40bea5b4f 100644 --- a/Tenable/tests/asset_connector/test_vulnerability_asset.py +++ b/Tenable/tests/asset_connector/test_vulnerability_asset.py @@ -1,5 +1,6 @@ +from datetime import datetime, timedelta +from unittest.mock import patch, Mock, MagicMock import pytest -from unittest.mock import patch, Mock from sekoia_automation.asset_connector.models.ocsf.vulnerability import VulnerabilityOCSFModel from sekoia_automation.asset_connector.models.ocsf.device import ( @@ -271,6 +272,8 @@ def tenable_asset_connector(symphony_storage): yield test_tenable_asset_connector +# ==================== Tests existants ==================== + def test_states_property(tenable_asset_connector): expected = [state.value for state in VulnerabilityState] assert tenable_asset_connector.states == expected @@ -603,8 +606,7 @@ def test_get_asset_info_exception(tenable_asset_connector): with patch.object(tenable_asset_connector.client.assets, "details", side_effect=Exception("API Error")): result = tenable_asset_connector._get_asset_info("test-uuid") - assert result is None - tenable_asset_connector.log.assert_called() + assert result is Nonetenable_asset_connector.log.assert_called() def test_update_checkpoint_success(tenable_asset_connector): @@ -679,3 +681,214 @@ def test_client_property_exception(tenable_asset_connector): _ = tenable_asset_connector.client tenable_asset_connector.log_exception.assert_called() + + +def test_get_asset_info_exception(tenable_asset_connector): + tenable_asset_connector._asset_map = {} + + with patch.object(tenable_asset_connector.client.assets, "details", side_effect=Exception("API Error")): + result = tenable_asset_connector._get_asset_info("test-uuid") + + assert result is None + tenable_asset_connector.log.assert_called() + + +def test_build_asset_map_success(tenable_asset_connector): + mock_assets = [ + {"id": "asset-1", "name": "host1"}, + {"id": "asset-2", "name": "host2"}, + {"id": "asset-3", "name": "host3"}, + ] + + mock_client = MagicMock() + mock_client.exports.assets_v2.return_value = mock_assets + + with patch.object(tenable_asset_connector, "client", mock_client): + tenable_asset_connector._build_asset_map() + + assert len(tenable_asset_connector._asset_map) == 3 + assert tenable_asset_connector._asset_map["asset-1"]["name"] == "host1" + assert tenable_asset_connector._asset_map["asset-2"]["name"] == "host2" + assert tenable_asset_connector._asset_map["asset-3"]["name"] == "host3" + + +def test_should_refresh_asset_cache_no_refresh_needed(tenable_asset_connector): + tenable_asset_connector._last_asset_refresh = datetime.now() + + assert tenable_asset_connector._should_refresh_asset_cache() is False + + +def test_should_refresh_asset_cache_refresh_needed(tenable_asset_connector): + tenable_asset_connector._last_asset_refresh = datetime.now() - timedelta(hours=2) + + assert tenable_asset_connector._should_refresh_asset_cache() is True + + +def test_should_refresh_asset_cache_exactly_at_interval(tenable_asset_connector): + tenable_asset_connector._last_asset_refresh = datetime.now() - timedelta(hours=1) + + assert tenable_asset_connector._should_refresh_asset_cache() is True + + +def test_get_asset_info_from_cache(tenable_asset_connector): + asset = {"id": "asset-1", "name": "host1"} + tenable_asset_connector._asset_map = {"asset-1": asset} + tenable_asset_connector._last_asset_refresh = datetime.now() + + result = tenable_asset_connector._get_asset_info("asset-1") + + assert result == asset + +def test_get_asset_info_builds_cache_on_first_call(tenable_asset_connector): + mock_assets = [ + {"id": "asset-1", "name": "host1"}, + {"id": "asset-2", "name": "host2"}, + ] + + mock_client = MagicMock() + mock_client.exports.assets_v2.return_value = mock_assets + mock_client.assets.details.return_value = {"id": "asset-1", "name": "host1"} + + with patch.object(tenable_asset_connector, "client", mock_client): + result = tenable_asset_connector._get_asset_info("asset-1") + + assert result == mock_assets[0] + assert len(tenable_asset_connector._asset_map) == 2 + + +def test_get_asset_info_refreshes_cache_if_needed(tenable_asset_connector): + asset1 = {"id": "asset-1", "name": "host1"} + asset2 = {"id": "asset-2", "name": "host2-updated"} + + tenable_asset_connector._asset_map = {"asset-1": asset1} + tenable_asset_connector._last_asset_refresh = datetime.now() - timedelta(hours=2) + + mock_client = MagicMock() + mock_client.exports.assets_v2.return_value = [asset2] + + with patch.object(tenable_asset_connector, "client", mock_client): + result = tenable_asset_connector._get_asset_info("asset-2") + + assert result == asset2 + assert len(tenable_asset_connector._asset_map) == 1 + assert "asset-1" not in tenable_asset_connector._asset_map + + +def test_get_asset_info_fallback_api_call(tenable_asset_connector, asset_info): + tenable_asset_connector._asset_map = {} + tenable_asset_connector._last_asset_refresh = datetime.now() + + mock_client = MagicMock() + mock_client.assets.details.return_value = asset_info + + with patch.object(tenable_asset_connector, "client", mock_client): + result = tenable_asset_connector._get_asset_info("74a507c2-c885-41ee-a34d-e151c99e3f60") + + assert result == asset_info + assert "74a507c2-c885-41ee-a34d-e151c99e3f60" in tenable_asset_connector._asset_map + + +def test_get_asset_info_fallback_api_respects_cache_size(tenable_asset_connector, asset_info): + tenable_asset_connector._asset_map = {f"asset-{i}": {"id": f"asset-{i}"} for i in range(5000)} + tenable_asset_connector._max_cache_size = 5000 + tenable_asset_connector._last_asset_refresh = datetime.now() + + mock_client = MagicMock() + mock_client.assets.details.return_value = asset_info + + with patch.object(tenable_asset_connector, "client", mock_client): + result = tenable_asset_connector._get_asset_info("74a507c2-c885-41ee-a34d-e151c99e3f60") + + assert result == asset_info + assert len(tenable_asset_connector._asset_map) == 5000 + + +def test_get_asset_info_api_error_handling(tenable_asset_connector): + tenable_asset_connector._asset_map = {} + tenable_asset_connector._last_asset_refresh = datetime.now() + + mock_client = MagicMock() + mock_client.assets.details.side_effect = Exception("API Error") + + with patch.object(tenable_asset_connector, "client", mock_client): + result = tenable_asset_connector._get_asset_info("invalid-uuid") + + assert result is None + tenable_asset_connector.log.assert_called() + + +def test_get_asset_info_multiple_calls_same_asset(tenable_asset_connector, asset_info): + tenable_asset_connector._asset_map = {} + tenable_asset_connector._last_asset_refresh = datetime.now() + + mock_client = MagicMock() + mock_details = mock_client.assets.details + mock_details.return_value = asset_info + + with patch.object(tenable_asset_connector, "client", mock_client): + result1 = tenable_asset_connector._get_asset_info("74a507c2-c885-41ee-a34d-e151c99e3f60") + result2 = tenable_asset_connector._get_asset_info("74a507c2-c885-41ee-a34d-e151c99e3f60") + + assert result1 == asset_info + assert result2 == asset_info + mock_details.assert_called_once() + + +def test_cache_persistence_across_calls(tenable_asset_connector): + asset1 = {"id": "asset-1", "name": "host1"} + asset2 = {"id": "asset-2", "name": "host2"} + + tenable_asset_connector._asset_map = {"asset-1": asset1} + tenable_asset_connector._last_asset_refresh = datetime.now() + + result1 = tenable_asset_connector._get_asset_info("asset-1") + assert result1 == asset1 + + mock_client = MagicMock() + mock_client.assets.details.return_value = asset2 + + with patch.object(tenable_asset_connector, "client", mock_client): + result2 = tenable_asset_connector._get_asset_info("asset-2") + + assert result2 == asset2 + assert "asset-1" in tenable_asset_connector._asset_map + assert "asset-2" in tenable_asset_connector._asset_map + + +def test_refresh_interval_configuration(tenable_asset_connector): + assert tenable_asset_connector._asset_refresh_interval == timedelta(hours=1) + + +def test_empty_cache_initialization(tenable_asset_connector): + assert tenable_asset_connector._asset_map == {} + + +def test_build_asset_map_logs_count(tenable_asset_connector): + mock_assets = [{"id": f"asset-{i}", "name": f"host-{i}"} for i in range(100)] + + mock_client = MagicMock() + mock_client.exports.assets_v2.return_value = mock_assets + + with patch.object(tenable_asset_connector, "client", mock_client): + tenable_asset_connector._build_asset_map() + + tenable_asset_connector.log.assert_any_call(f"{len(tenable_asset_connector._asset_map)} assets uploaded", level="info") + + +def test_get_asset_info_with_missing_asset_id(tenable_asset_connector): + mock_assets = [ + {"id": "asset-1", "name": "host1"}, + {"name": "host2"}, # Missing ID + {"id": "asset-2", "name": "host3"}, + ] + + mock_client = MagicMock() + mock_client.exports.assets_v2.return_value = mock_assets + + with patch.object(tenable_asset_connector, "client", mock_client): + tenable_asset_connector._build_asset_map() + + assert len(tenable_asset_connector._asset_map) == 2 + assert "asset-1" in tenable_asset_connector._asset_map + assert "asset-2" in tenable_asset_connector._asset_map +