Skip to content

Commit 07f983d

Browse files
committed
Initial ASM support & Tests
1 parent b9b6aed commit 07f983d

17 files changed

+388
-0
lines changed

docs/api/asm/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.. automodule:: tenable.asm.session

docs/api/asm/inventory.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.. automodule:: tenable.asm.inventory

docs/api/asm/smart_folders.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.. automodule:: tenable.asm.smart_folders

docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
api/dl/index
1010
api/ie/index
1111
api/apa/index
12+
api/asm/index
1213
api/nessus/index
1314
api/reports/index
1415
api/base/index

tenable/asm/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .session import TenableASM

tenable/asm/inventory.py

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Inventory
3+
=========
4+
5+
Methods described in this section relate to the inventory API and can be accessed at
6+
``TenableASM.inventory``.
7+
8+
.. rst-class:: hide-signature
9+
.. autoclass:: InventoryAPI
10+
:members:
11+
12+
.. autoclass:: InventoryIterator
13+
:members:
14+
"""
15+
from typing import Dict, List, Any, Optional, Tuple, TYPE_CHECKING
16+
from copy import copy
17+
from tenable.base.endpoint import APIEndpoint
18+
from restfly.iterator import APIIterator
19+
20+
if TYPE_CHECKING:
21+
from .session import TenableASM
22+
from box import BoxList
23+
24+
25+
class InventoryIterator(APIIterator):
26+
"""
27+
Asset inventory iterator
28+
"""
29+
_after_asset_id: str = '0000000000'
30+
_filters: List[Dict[str, str]]
31+
_query: Dict[str, Any]
32+
_api: 'TenableASM'
33+
page: 'BoxList'
34+
limit: int = 1000
35+
total: int
36+
stats: Dict[str, Any]
37+
38+
def _get_page(self):
39+
query = copy(self._query)
40+
if not query.get('after'):
41+
query['after'] = self._after_asset_id
42+
43+
query['limit'] = self.limit
44+
resp = self._api.post('inventory', params=query, json=self._filters)
45+
self.page = resp.assets
46+
self.total = resp.total
47+
self.stats = resp.stats
48+
49+
if self.page:
50+
self._after_asset_id = self.page[-1].id
51+
52+
53+
54+
class InventoryAPI(APIEndpoint):
55+
def list(self,
56+
*search: Tuple[str, str, str],
57+
columns: Optional[List[str]] = None,
58+
size: int = 1000,
59+
sort_field: Optional[str] = None,
60+
sort_asc: bool = True,
61+
inventory: bool = False,
62+
) -> InventoryIterator:
63+
"""
64+
Lists the assets in the inventory
65+
66+
Args:
67+
*search (tuple[str, str, str], optional):
68+
A 3-part search tuple detailing what to search for from the ASM
69+
dataset. For example:
70+
``('bd.original_hostname', 'ends with', '.com')``
71+
columns (list[str], optional):
72+
The list of columns to return in the response.
73+
size (int, optional):
74+
The number of records to return with each page from the API. Must be
75+
an integer between `1` and `10000`.
76+
sort_field (str, optional):
77+
What field should the results be worted by?
78+
sort_asc (bool):
79+
How should the results be sorted? ``True`` specifies ascending sort,
80+
whereas ``False`` refers to descending.
81+
82+
Example:
83+
>>> for item in asm.inventory.list():
84+
... print(item)
85+
"""
86+
if not columns:
87+
columns = [
88+
'bd.original_hostname',
89+
'bd.severity_ranking',
90+
'bd.hostname',
91+
'bd.record_type',
92+
'bd.ip_address',
93+
'id',
94+
'bd.addedtoportfolio',
95+
'bd.smartfolders',
96+
'bd.app_updates',
97+
'ports.ports',
98+
'screenshot.redirect_chain',
99+
'screenshot.finalurl',
100+
'ports.cves',
101+
]
102+
return InventoryIterator(
103+
self._api,
104+
_query={
105+
'columns': ','.join(columns),
106+
'inventory': str(bool(inventory)).lower(),
107+
'sortorder': str(bool(sort_asc)).lower(),
108+
'sortby': sort_field,
109+
},
110+
_filters = [{'column': c, 'type': t, 'value': v} for c, t, v in search],
111+
limit=size
112+
)

tenable/asm/session.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""
2+
Tenable Attack Surface Management
3+
=================================
4+
5+
This package covers the Tenable ASM application.
6+
7+
.. autoclass:: TenableASM
8+
:members:
9+
10+
11+
.. toctree::
12+
:hidden:
13+
:glob:
14+
15+
inventory
16+
smart_folders
17+
18+
"""
19+
from tenable.base.platform import APIPlatform
20+
from .inventory import InventoryAPI
21+
from .smart_folders import SmartFoldersAPI
22+
23+
24+
class TenableASM(APIPlatform):
25+
"""
26+
The TenableASM class is the primary interaction point for users to interface with
27+
Tenable Attack Surface Management via the pyTenable library. All the API endpoint
28+
classes that wrap the various aspects of ASM will be attached to this base class.
29+
30+
Args:
31+
api_key (str, optional):
32+
The user's API key to interface into Tenable ASM. If the key isn't
33+
specified, then the library will attempt to read the environment
34+
variable `TASM_API_KEY` to get the key.
35+
url (str, optional):
36+
The base URL that the paths will be appended onto. The default is
37+
``https://asm.cloud.tenable.com`` If the url isn't specified, then the
38+
library will attempt to read the environment variable `TASM_URL`.
39+
retries (int, optional):
40+
The number of retries to make before failing a request. The
41+
default is ``5``.
42+
backoff (float, optional):
43+
If a 429 response is returned, how much do we want to backoff
44+
if the response didn't send a Retry-After header. The default
45+
backoff is ``1`` second.
46+
vendor (str, optional):
47+
The vendor name for the User-Agent string.
48+
product (str, optional):
49+
The product name for the User-Agent string.
50+
build (str, optional):
51+
The version or build identifier for the User-Agent string.
52+
timeout (int, optional):
53+
The connection timeout parameter informing the library how long to
54+
wait in seconds for a stalled response before terminating the
55+
connection. If unspecified, the default is 120 seconds.
56+
57+
Examples:
58+
59+
Basic example:
60+
61+
>>> from tenable.asm import TenableASM
62+
>>> tasm = TenableASM(api_key='abcdef1234567890')
63+
64+
Another example with proper identification:
65+
66+
>>> tasm = TenableASM(api_key='abcdef1234567890',
67+
... vendor='Company Name',
68+
... product='My Awesome Widget',
69+
... build='1.0.0'
70+
... )
71+
72+
Yet another example thats leveraging the `TASM_API_KEY` environment variable:
73+
74+
>>> tasm = TenableASM(vendor='Company Name',
75+
... product='My Awesome Widget',
76+
... build='1.0.0'
77+
... )
78+
"""
79+
_url = 'https://asm.cloud.tenable.com'
80+
_base_path = 'api/1.0'
81+
_env_base = 'TASM'
82+
_box = True
83+
_allowed_auth_mech_priority = ['key']
84+
_allowed_auth_mech_params = {'key': ['api_key']}
85+
86+
def _key_auth(self, api_key, **kwargs):
87+
"""
88+
API Key authorization mechanism for Tenable ASM.
89+
"""
90+
self._session.headers.update({'Authorization': api_key})
91+
self._auth_meth = 'key'
92+
93+
@property
94+
def inventory(self):
95+
"""
96+
The interface object for the
97+
:doc:`Tenable ASM Inventory API <inventory>`
98+
"""
99+
return InventoryAPI(self)
100+
101+
@property
102+
def smart_folders(self):
103+
"""
104+
The interface object for the
105+
:doc:`Tenable ASM Smart Folders API <smart_folders>`
106+
"""
107+
return SmartFoldersAPI(self)

tenable/asm/smart_folders.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""
2+
Smart Folders
3+
=============
4+
5+
Methods described in this section relate to the smart folders API and can be accessed at
6+
``TenableASM.smart_folders``.
7+
8+
.. rst-class:: hide-signature
9+
.. autoclass:: SmartFoldersAPI
10+
:members:
11+
"""
12+
from typing import Dict, List, Any
13+
from tenable.base.endpoint import APIEndpoint
14+
15+
16+
class SmartFoldersAPI(APIEndpoint):
17+
_path = 'smartfolders'
18+
19+
def list(self) -> List[Dict[str, Any]]:
20+
"""
21+
Returns the list of smart folders from ASM.
22+
23+
Example:
24+
>>> folders = asm.smartfolders.list()
25+
"""
26+
return self._get()

tests/apa/__init__.py

Whitespace-only changes.

tests/asm/__init__.py

Whitespace-only changes.

tests/asm/test_inventory.py

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import pytest
2+
import responses
3+
from responses.registries import OrderedRegistry
4+
from responses.matchers import json_params_matcher, query_param_matcher
5+
from tenable.asm import TenableASM
6+
7+
8+
@responses.activate(registry=OrderedRegistry)
9+
def test_asm_inventory_list():
10+
test_item = {'id': 123456}
11+
responses.post(
12+
'https://nourl/api/1.0/inventory',
13+
json={'assets': [test_item for _ in range(1000)], 'total': 2005, 'stats': {}},
14+
match=[
15+
query_param_matcher({
16+
'columns': 'id,name',
17+
'inventory': 'false',
18+
'sortorder': 'true',
19+
'sortby': 'id',
20+
'after': '0000000000',
21+
'limit': 1000,
22+
}),
23+
json_params_matcher([
24+
{'column': 'id', 'type': 'equals', 'value': 'something'}
25+
])
26+
]
27+
)
28+
responses.post(
29+
'https://nourl/api/1.0/inventory',
30+
json={'assets': [test_item for _ in range(1000)], 'total': 2005, 'stats': {}},
31+
match=[
32+
query_param_matcher({
33+
'columns': 'id,name',
34+
'inventory': 'false',
35+
'sortorder': 'true',
36+
'sortby': 'id',
37+
'after': '123456',
38+
'limit': 1000,
39+
}),
40+
json_params_matcher([
41+
{'column': 'id', 'type': 'equals', 'value': 'something'}
42+
])
43+
]
44+
)
45+
responses.post(
46+
'https://nourl/api/1.0/inventory',
47+
json={'assets': [test_item for _ in range(5)], 'total': 2005, 'stats': {}},
48+
match=[
49+
query_param_matcher({
50+
'columns': 'id,name',
51+
'inventory': 'false',
52+
'sortorder': 'true',
53+
'sortby': 'id',
54+
'after': '123456',
55+
'limit': 1000,
56+
}),
57+
json_params_matcher([
58+
{'column': 'id', 'type': 'equals', 'value': 'something'}
59+
])
60+
]
61+
)
62+
asm = TenableASM(url='https://nourl', api_key='12345')
63+
items = asm.inventory.list(
64+
('id', 'equals', 'something'),
65+
columns=['id', 'name'],
66+
sort_field='id'
67+
)
68+
for item in items:
69+
assert dict(item) == test_item
70+
assert items.count == 2005

tests/asm/test_session.py

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import os
2+
import pytest
3+
from tenable.asm import TenableASM
4+
from tenable.errors import AuthenticationWarning
5+
6+
7+
def test_asm_session_authentication():
8+
asm = TenableASM(api_key='abcdef')
9+
assert asm._session.headers['Authorization'] == 'abcdef'
10+
11+
os.environ['TASM_API_KEY'] = 'efghi'
12+
asm = TenableASM()
13+
assert asm._session.headers['Authorization'] == 'efghi'
14+
15+
os.environ.pop('TASM_API_KEY')
16+
with pytest.warns(AuthenticationWarning):
17+
asm = TenableASM()

0 commit comments

Comments
 (0)