From 43daa41da215cefe5945780cedef15fc1547d715 Mon Sep 17 00:00:00 2001 From: stevemcgrath Date: Mon, 24 Aug 2026 16:31:28 -0500 Subject: [PATCH] Updated the T1 Inventory Findings module and tests to correct #1016 --- tenable/tenableone/inventory/findings/api.py | 71 +++++----- .../findings/test_inventory_findings_api.py | 122 +++++++++--------- 2 files changed, 100 insertions(+), 93 deletions(-) diff --git a/tenable/tenableone/inventory/findings/api.py b/tenable/tenableone/inventory/findings/api.py index 9619c6aff..9e8d853b4 100644 --- a/tenable/tenableone/inventory/findings/api.py +++ b/tenable/tenableone/inventory/findings/api.py @@ -12,14 +12,21 @@ """ from typing import Optional -from urllib.parse import urlencode from tenable.base.endpoint import APIEndpoint from tenable.tenableone.inventory.findings.schema import Findings -from tenable.tenableone.inventory.schema import Field, Properties, QueryMode, PropertyFilter, SortDirection +from tenable.tenableone.inventory.schema import ( + Field, + Properties, + PropertyFilter, + QueryMode, + SortDirection, +) class FindingsAPI(APIEndpoint): + _path = 'api/v1/t1/inventory/findings' + def list_properties(self) -> list[Field]: """ Retrieve finding properties @@ -33,21 +40,19 @@ def list_properties(self) -> list[Field]: ... pprint(finding_property) """ - finding_properties_response: dict[str, list[dict]] = self._get( - path="api/v1/t1/inventory/findings/properties" - ) + finding_properties_response: dict[str, list[dict]] = self._get('properties') return Properties(**finding_properties_response).data def list( - self, - query_text: Optional[str] = None, - query_mode: Optional[QueryMode] = None, - filters: Optional[list[PropertyFilter]] = None, - extra_properties: Optional[list[str]] = None, - offset: Optional[int] = None, - limit: Optional[int] = None, - sort_by: Optional[str] = None, - sort_direction: Optional[SortDirection] = None, + self, + query_text: Optional[str] = None, + query_mode: Optional[QueryMode] = None, + filters: Optional[list[PropertyFilter]] = None, + extra_properties: Optional[list[str]] = None, + offset: Optional[int] = None, + limit: Optional[int] = None, + sort_by: Optional[str] = None, + sort_direction: Optional[SortDirection] = None, ) -> Findings: """ Retrieve findings @@ -80,32 +85,30 @@ def list( """ payload = {} + if filters is not None: + payload['filters'] = [f.model_dump(mode='json') for f in filters] + if query_text is not None and query_mode is not None: + payload['query'] = {'text': query_text, 'mode': query_mode.value} - # TODO: check what is the actual contract - if query_text is not None and query_mode is not None and filters is not None: - payload = { - "query": {"text": query_text, "mode": query_mode.value}, - "filters": [filter_.model_dump(mode="json") for filter_ in filters] - if filters is not None - else [], - } - base_path = "api/v1/t1/inventory/findings/search" + # base_path = 'api/v1/t1/inventory/findings/search' query_params = {} if extra_properties is not None: - query_params["extra_properties"] = ",".join(extra_properties) + query_params['extra_properties'] = ','.join(extra_properties) if offset is not None: - query_params["offset"] = offset + query_params['offset'] = offset if limit is not None: - query_params["limit"] = limit + query_params['limit'] = limit if sort_by is not None and sort_direction is not None: - query_params["sort"] = f"{sort_by}:{sort_direction}" + query_params['sort'] = f'{sort_by}:{str(sort_direction)}' - if query_params: - query_string = urlencode(query_params) - path = f"{base_path}?{query_string}" - else: - path = base_path + # if query_params: + # query_string = urlencode(query_params) + # path = f'{base_path}?{query_string}' + # else: + # path = base_path - findings_response: dict = self._post(path=path, json=payload) - return Findings(**findings_response) \ No newline at end of file + findings_response: dict = self._post( + 'search', params=query_params, json=payload + ) + return Findings(**findings_response) diff --git a/tests/tenableone/inventory/findings/test_inventory_findings_api.py b/tests/tenableone/inventory/findings/test_inventory_findings_api.py index ab4752596..dbd3047af 100644 --- a/tests/tenableone/inventory/findings/test_inventory_findings_api.py +++ b/tests/tenableone/inventory/findings/test_inventory_findings_api.py @@ -7,42 +7,39 @@ from tenable.tenableone.inventory.findings.schema import Findings from tenable.tenableone.inventory.schema import ( Field, + Operator, Properties, - QueryMode, PropertyFilter, - Operator, + QueryMode, SortDirection, ) -BASE_URL = "https://cloud.tenable.com/" +BASE_URL = 'https://cloud.tenable.com/' @pytest.fixture def findings_properties_response() -> dict[str, list[dict]]: return { - "data": [ + 'data': [ { - "key": "finding_id", - "readable_name": "Finding ID", - "is_extra_property": True, - "control": { - "type": "STRING", - "multiple_allowed": False, - "regex": { - "hint": "01234567-abcd-ef01-2345-6789abcdef01", - "expression": "[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}(,[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})*" - } + 'key': 'finding_id', + 'readable_name': 'Finding ID', + 'is_extra_property': True, + 'control': { + 'type': 'STRING', + 'multiple_allowed': False, + 'regex': { + 'hint': '01234567-abcd-ef01-2345-6789abcdef01', + 'expression': '[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}(,[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12})*', + }, }, - "operators": [ - "=", - "!=" - ], - "displayable": True, - "sortable": True, - "filterable": True, - "deprecated": False, - "is_key_property": False, - "description": """# Finding ID\n## A unique identifier for a security issue\nEach security issue identified by Exposure Management is assigned a unique Finding ID. This ID helps to track and manage individual findings throughout their lifecycle.\n\nHere"s why Finding IDs are important:\n- **Unique Identification:** Each finding receives a distinct ID, preventing confusion when dealing with multiple security issues.\n- **Tracking and Management:** The ID helps track a finding"s status, remediation efforts, and history over time.\n- **Reporting and Analysis:** Finding IDs enable efficient reporting and analysis of security issues, allowing you to identify trends and patterns.\n- **Integration:** Finding IDs can be integrated with other security tools and systems for streamlined workflows.\n""" + 'operators': ['=', '!='], + 'displayable': True, + 'sortable': True, + 'filterable': True, + 'deprecated': False, + 'is_key_property': False, + 'description': """# Finding ID\n## A unique identifier for a security issue\nEach security issue identified by Exposure Management is assigned a unique Finding ID. This ID helps to track and manage individual findings throughout their lifecycle.\n\nHere"s why Finding IDs are important:\n- **Unique Identification:** Each finding receives a distinct ID, preventing confusion when dealing with multiple security issues.\n- **Tracking and Management:** The ID helps track a finding"s status, remediation efforts, and history over time.\n- **Reporting and Analysis:** Finding IDs enable efficient reporting and analysis of security issues, allowing you to identify trends and patterns.\n- **Integration:** Finding IDs can be integrated with other security tools and systems for streamlined workflows.\n""", } ] } @@ -51,75 +48,82 @@ def findings_properties_response() -> dict[str, list[dict]]: @pytest.fixture def findings_response() -> dict: return { - "data": [ + 'data': [ { - "id": "000caf26-c44f-5db1-891f-4d98383812b9", - "name": "Dangerous SYSVOL share path", - "severity": "HIGH", - "state": "ACTIVE", - "asset_id": "ea3305bf-6067-4f5d-a381-ffbd9eda2296", + 'id': '000caf26-c44f-5db1-891f-4d98383812b9', + 'name': 'Dangerous SYSVOL share path', + 'severity': 'HIGH', + 'state': 'ACTIVE', + 'asset_id': 'ea3305bf-6067-4f5d-a381-ffbd9eda2296', } ], - "pagination": { - "total": 1, - "offset": 0, - "limit": 100, - "sort": { - "name": "name", - "order": "asc" - } + 'pagination': { + 'total': 1, + 'offset': 0, + 'limit': 100, + 'sort': {'name': 'name', 'order': 'asc'}, }, } + @responses.activate def test_properties_list(tenable_one_api, findings_properties_response): # Arrange - endpoint = "/api/v1/t1/inventory/findings/properties" + endpoint = '/api/v1/t1/inventory/findings/properties' full_url = urljoin(BASE_URL, endpoint) - responses.get(full_url, json=findings_properties_response, match=[responses.matchers.query_param_matcher({})]) + responses.get( + full_url, + json=findings_properties_response, + match=[responses.matchers.query_param_matcher({})], + ) # Act - finding_properties_result: list[Field] = tenable_one_api.inventory.findings.list_properties() + finding_properties_result: list[Field] = ( + tenable_one_api.inventory.findings.list_properties() + ) # Assert assert finding_properties_result == Properties(**findings_properties_response).data @responses.activate def test_list(tenable_one_api, findings_response): - query_text = "Dangerous SYSVOL share path" + query_text = 'Dangerous SYSVOL share path' query_mode = QueryMode.SIMPLE - filters = [PropertyFilter(property="name", operator=Operator.EQUAL, value=["Dangerous SYSVOL share path"])] - extra_properties = ["product_code"] + filters = [ + PropertyFilter( + property='name', + operator=Operator.EQUAL, + value=['Dangerous SYSVOL share path'], + ) + ] + extra_properties = ['product_code'] offset = 0 limit = 100 - sort_by = "name" + sort_by = 'name' sort_direction = SortDirection.ASC # Expected query parameters expected_params = { - "extra_properties": ",".join(extra_properties), - "offset": offset, - "limit": limit, - "sort": f"{sort_by}:{sort_direction}" + 'extra_properties': ','.join(extra_properties), + 'offset': offset, + 'limit': limit, + 'sort': f'{sort_by}:{sort_direction.value}', } payload = { - "query": { - "text": query_text, - "mode": query_mode.value - }, - "filters": [filter_.model_dump(mode="json") for filter_ in filters] + 'query': {'text': query_text, 'mode': query_mode.value}, + 'filters': [filter_.model_dump(mode='json') for filter_ in filters], } - endpoint = "/api/v1/t1/inventory/findings/search" + endpoint = '/api/v1/t1/inventory/findings/search' full_url = urljoin(BASE_URL, endpoint) responses.add( responses.POST, full_url, json=findings_response, match=[ - responses.matchers.body_matcher(params=json.dumps(payload)), - responses.matchers.query_param_matcher(expected_params) - ] + responses.matchers.json_params_matcher(params=payload), + responses.matchers.query_param_matcher(expected_params), + ], ) # Act findings: Findings = tenable_one_api.inventory.findings.list( @@ -130,7 +134,7 @@ def test_list(tenable_one_api, findings_response): offset=offset, limit=limit, sort_by=sort_by, - sort_direction=sort_direction, + sort_direction=sort_direction.value, ) # Assert assert findings == Findings(**findings_response)