Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Tenable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Consider rephrasing this line for grammatical correctness and clarity.

The phrase is a bit awkward and also uses “assets details” instead of the more natural “asset details.” Consider rewording to something like:

  • “Use exported assets instead of getting asset details for each vulnerability,” or
  • “Use exported assets instead of fetching asset details for each vulnerability.”
Suggested change
- Use export assets instead of get assets details for each vulnerability
- Use exported assets instead of fetching asset details for each vulnerability


## 2026-02-23 - 1.0.12

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Tenable/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
77 changes: 69 additions & 8 deletions Tenable/tenable_conn/asset_connector/vulnerability_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_asset_map is typed as dict[str, dict] | None but initialized to {}. Since the implementation treats it as a dict (calls .clear() etc.), the | None union is misleading; either initialize to None and handle that explicitly, or change the type to a plain dict.

Suggested change
self._asset_map: dict[str, dict] | None = {}
self._asset_map: dict[str, dict] = {}

Copilot uses AI. Check for mistakes.
self._max_cache_size = 5000
self._last_asset_refresh: datetime = datetime.now()
Comment on lines +70 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Clarify _asset_map optionality and initialization pattern.

The type and usage of _asset_map are inconsistent: it’s annotated as dict[str, dict] | None but initialized as {}, and _get_asset_info checks if not self._asset_map or self._should_refresh_asset_cache():. Either:

  • Use None to mean “not yet built”: initialize _asset_map to None, check if self._asset_map is None or self._should_refresh_asset_cache():, and have _build_asset_map always assign a dict; or
  • Drop the None from the type and rely solely on the refresh logic with a non-optional dict[str, dict].

That keeps the type hints and control flow aligned.

self._asset_refresh_interval = timedelta(hours=1)

@cached_property
def client(self) -> TenableIO:
Expand Down Expand Up @@ -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")

Comment on lines +122 to +131
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Return type annotation for _build_asset_map does not match actual behavior.

The method is annotated as returning dict[str, dict] but only mutates self._asset_map and returns nothing, which will cause type-checker errors and mislead callers. Please either return self._asset_map or change the return type to None to match the current behavior.

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

Comment on lines +132 to +142
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_build_asset_map() clears self._asset_map before the export loop. If the export call fails part-way (exception), the connector will be left with an empty cache, increasing downstream API calls; consider populating a temporary dict and only replacing the cache on success.

Suggested change
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
temp_asset_map: dict[str, dict] = {}
for asset in self.client.exports.assets_v2(since=self._latest_time, chunk_size=4000):
asset_uuid = asset.get("id")
if asset_uuid:
temp_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._asset_map = temp_asset_map

Copilot uses AI. Check for mistakes.
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")

Comment on lines +122 to +147
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_build_asset_map is annotated/documented as returning dict[str, dict], but it never returns anything (implicitly returns None). Either return the built map explicitly or change the return annotation/docstring to None to avoid misleading callers and type-checking issues.

Copilot uses AI. Check for mistakes.
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()
Comment on lines +156 to +158
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if not self._asset_map or self._should_refresh_asset_cache() treats an empty cache as 'not built'. If the export legitimately returns 0 assets, _get_asset_info will rebuild the cache on every call (potentially an expensive export loop). Prefer checking an explicit sentinel (e.g., self._asset_map is None) for the 'never built' case.

Copilot uses AI. Check for mistakes.

# 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.
Expand Down Expand Up @@ -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.
Expand Down
219 changes: 216 additions & 3 deletions Tenable/tests/asset_connector/test_vulnerability_asset.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line is syntactically invalid: assert result is Nonetenable_asset_connector.log.assert_called() combines two statements and will break test collection. Split into two separate statements (assert + log.assert_called()).

Suggested change
assert result is Nonetenable_asset_connector.log.assert_called()
assert result is None
tenable_asset_connector.log.assert_called()

Copilot uses AI. Check for mistakes.


def test_update_checkpoint_success(tenable_asset_connector):
Expand Down Expand Up @@ -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")
Comment on lines +686 to +690
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_get_asset_info_exception is defined twice in this file (once around lines 605-610 and again around 686-694). Pytest will keep only the last definition, which can hide failures and makes the test suite confusing; rename one of them or remove the duplicate.

Copilot uses AI. Check for mistakes.

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

Comment on lines +696 to +705
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for _build_asset_map error handling and cache-size limit behavior

Current tests cover the happy path of _build_asset_map and cache usage in _get_asset_info, but not the error/limit branches. Please also add tests that:

  • Simulate client.exports.assets_v2 raising to assert log_exception is called and that _build_asset_map exits cleanly.
  • Yield more than _max_cache_size assets to assert the map is capped at _max_cache_size and that the warning "Stop uploading, limit exceeded (...)" is logged.

That will exercise the failure and limit behavior of the new export/caching logic under load.

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

Loading