Skip to content
Merged
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
71 changes: 37 additions & 34 deletions tenable/tenableone/inventory/findings/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
findings_response: dict = self._post(
'search', params=query_params, json=payload
)
return Findings(**findings_response)
122 changes: 63 additions & 59 deletions tests/tenableone/inventory/findings/test_inventory_findings_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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""",
}
]
}
Expand All @@ -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(
Expand All @@ -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)
Loading