From e0212409cd3a03155e426670908f5608bf015c60 Mon Sep 17 00:00:00 2001 From: tushar-balwani <79524298+tushar-balwani@users.noreply.github.com> Date: Wed, 19 Jan 2022 16:50:19 +0530 Subject: [PATCH] Added feature - TenableAD AD Object APIs updated schema file removed unused imports implemented AD object iterator for search all method fixed broken test --- docs/api/ad/ad_object.rst | 1 + tenable/ad/__init__.py | 1 + tenable/ad/ad_object/__init__.py | 0 tenable/ad/ad_object/api.py | 282 ++++++++++++++++++++ tenable/ad/ad_object/schema.py | 39 +++ tenable/ad/base/iterator.py | 51 ++++ tenable/ad/session.py | 9 + tests/ad/ad_object/test_ad_object_api.py | 179 +++++++++++++ tests/ad/ad_object/test_ad_object_schema.py | 70 +++++ 9 files changed, 632 insertions(+) create mode 100644 docs/api/ad/ad_object.rst create mode 100644 tenable/ad/ad_object/__init__.py create mode 100644 tenable/ad/ad_object/api.py create mode 100644 tenable/ad/ad_object/schema.py create mode 100644 tenable/ad/base/iterator.py create mode 100644 tests/ad/ad_object/test_ad_object_api.py create mode 100644 tests/ad/ad_object/test_ad_object_schema.py diff --git a/docs/api/ad/ad_object.rst b/docs/api/ad/ad_object.rst new file mode 100644 index 000000000..93f2fa6fc --- /dev/null +++ b/docs/api/ad/ad_object.rst @@ -0,0 +1 @@ +.. automodule:: tenable.ad.ad_object.api diff --git a/tenable/ad/__init__.py b/tenable/ad/__init__.py index 638c04dd1..027d40c67 100644 --- a/tenable/ad/__init__.py +++ b/tenable/ad/__init__.py @@ -13,6 +13,7 @@ :glob: about + ad_object api_keys attack_types category diff --git a/tenable/ad/ad_object/__init__.py b/tenable/ad/ad_object/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tenable/ad/ad_object/api.py b/tenable/ad/ad_object/api.py new file mode 100644 index 000000000..9dd05c943 --- /dev/null +++ b/tenable/ad/ad_object/api.py @@ -0,0 +1,282 @@ +''' +AD Object +============= + +Methods described in this section relate to the ad object API. +These methods can be accessed at ``TenableAD.ad_object``. + +.. rst-class:: hide-signature +.. autoclass:: ADObjectAPI + :members: +''' +from typing import List, Dict, Mapping +from restfly.utils import dict_clean +from tenable.ad.ad_object.schema import ADObjectSchema, ADObjectChangesSchema +from tenable.ad.base.iterator import ADIterator +from tenable.base.endpoint import APIEndpoint + + +class ADObjectIterator(ADIterator): + ''' + The ad object iterator provides a scalable way to work through alert list + result sets of any size. The iterator will walk through each page of data, + returning one record at a time. If it reaches the end of a page of + records, then it will request the next page of information and then + continue to return records from the next page (and the next, and the next) + until the counter reaches the total number of records that the API has + reported. + ''' + + +class ADObjectAPI(APIEndpoint): + _schema = ADObjectSchema() + + def details(self, + directory_id: str, + infrastructure_id: str, + ad_object_id: str + ) -> Dict: + ''' + Retrieves the details of a specific AD object. + + Args: + directory_id (str): + The directory instance identifier. + infrastructure_id (str): + The infrastructure instance identifier. + ad_object_id (str): + The AD Object identifier. + + Returns: + dict: + The AD object. + + Examples: + >>> tad.ad_object.details( + ... directory_id='1', + ... infrastructure_id='1', + ... ad_object_id='1' + ... ) + ''' + return self._schema.load( + self._api.get(f"infrastructures/{infrastructure_id}/" + f"directories/{directory_id}/" + f"ad-objects/{ad_object_id}")) + + def details_by_profile_and_checker(self, + profile_id: str, + checker_id: str, + ad_object_id: str + ) -> Dict: + ''' + Retrieves an AD object details by id that have deviances for a + specific profile and checker + + Args: + profile_id (str): + The profile instance identifier. + checker_id (str): + The checker instance identifier. + ad_object_id (str): + The AD Object identifier. + + Returns: + dict: + The AD object. + + Examples: + >>> tad.ad_object.details_by_profile_and_checker( + ... profile_id='1', + ... checker_id='1', + ... ad_object_id='1' + ... ) + ''' + return self._schema.load( + self._api.get(f"profiles/{profile_id}/" + f"checkers/{checker_id}/" + f"ad-objects/{ad_object_id}")) + + def details_by_event(self, + directory_id: str, + infrastructure_id: str, + ad_object_id: str, + event_id: str + ) -> Dict: + ''' + Retrieves the details of a specific AD object. + + Args: + directory_id (str): + The directory instance identifier. + infrastructure_id (str): + The infrastructure instance identifier. + ad_object_id (str): + The AD Object identifier. + event_id (str): + The event identifier. + + Returns: + dict: + The AD object. + + Examples: + >>> tad.ad_object.details_by_event( + ... directory_id='1', + ... infrastructure_id='1', + ... ad_object_id='1', + ... event_id='1' + ... ) + ''' + return self._schema.load( + self._api.get(f"infrastructures/{infrastructure_id}/" + f"directories/{directory_id}/" + f"events/{event_id}/" + f"ad-objects/{ad_object_id}")) + + def get_changes(self, + directory_id: str, + infrastructure_id: str, + ad_object_id: str, + event_id: str, + **kwargs + ) -> List[Dict]: + ''' + Get the AD object changes between a given event and event which + precedes it. + + Args: + directory_id (str): + The directory instance identifier. + infrastructure_id (str): + The infrastructure instance identifier. + ad_object_id (str): + The AD Object identifier. + event_id (str): + The event identifier. + wanted_values (optional, list[str]): + Which values user wants to include. ``before`` to include the + values just before the event, ``after`` to include the values + just after the event or ``current`` to include the current + values. + + Returns: + list[dict]: + The list of AD objects. + + Examples: + >>> tad.ad_object.get_changes( + ... directory_id='1', + ... infrastructure_id='1', + ... ad_object_id='1', + ... event_id='1', + ... wanted_values=['current', 'after'] + ... ) + ''' + schema = ADObjectChangesSchema() + params = self._schema.dump(self._schema.load(kwargs)) + return schema.load( + self._api.get(f"infrastructures/{infrastructure_id}/" + f"directories/{directory_id}/" + f"events/{event_id}/" + f"ad-objects/{ad_object_id}/changes", params=params), + many=True) + + def search_all(self, + profile_id: str, + checker_id: str, + expression: Mapping, + directories: List[int], + reasons: List[int], + show_ignored: bool, + **kwargs + ) -> ADObjectIterator: + ''' + Search all AD objects having deviances by profile by checker + + Args: + profile_id (str): + The profile instance identifier. + checker_id (str): + The checker instance identifier. + expression (mapping): + An object describing a filter for searched items. + directories (list[int]): + The list of directory instance identifiers. + reasons (list[int]): + The list of reasons identifiers. + show_ignored (bool): + Whether AD Object that only have ignored deviances should be + included? + date_start (optional, str): + The date after which the AD object deviances should have been + emitted. + date_end (optional, str): + The date before which the AD object deviances should have been + emitted. + page (optional, int): + The page number user wants to retrieve. + per_page (optional, int): + The number of records per page user wants to retrieve. + max_items (optional, int): + The maximum number of records to return before + stopping iteration. + max_pages (optional, int): + The maximum number of pages to request before throwing + stopping iteration. + + Returns: + :obj:`ADObjectIterator`: + An iterator that handles the page management of the requested + records. + + Examples: + >>> for ado in tad.ad_object.search_all( + ... profile_id='1', + ... checker_id='1', + ... show_ignored=False, + ... reasons=[1, 2], + ... directories=[1], + ... expression={'OR': [{ + ... 'whencreated': '2021-07-29T12:27:50.0000000Z' + ... }]}, + ... date_end='2022-12-31T18:30:00.000Z', + ... date_start='2021-12-31T18:30:00.000Z', + ... page=1, + ... per_page=20, + ... max_pages=10, + ... max_items=200 + ... ): + ... pprint(ado) + ''' + params = self._schema.dump(self._schema.load({ + 'page': kwargs.get('page') or 1, + 'perPage': kwargs.get('per_page'), + 'maxItems': kwargs.get('max_items'), + 'maxPages': kwargs.get('max_pages') + })) + + payload = self._schema.dump(self._schema.load( + dict_clean({ + 'expression': expression, + 'directories': directories, + 'reasons': reasons, + 'dateStart': kwargs.get('date_start'), + 'dateEnd': kwargs.get('date_end'), + 'showIgnored': show_ignored + }) + )) + + return ADObjectIterator( + api=self._api, + _path=f'profiles/{profile_id}/' + f'checkers/{checker_id}/' + f'ad-objects/search', + _method='post', + num_pages=params.get('page'), + _per_page=params.get('perPage'), + _query=params, + _payload=payload, + _schema=self._schema, + max_pages=params.pop('maxPages', None), + max_items=params.pop('maxItems', None) + ) diff --git a/tenable/ad/ad_object/schema.py b/tenable/ad/ad_object/schema.py new file mode 100644 index 000000000..2ceceaf18 --- /dev/null +++ b/tenable/ad/ad_object/schema.py @@ -0,0 +1,39 @@ +from marshmallow import fields +from tenable.ad.base.schema import CamelCaseSchema + + +class ADObjectChangeValuesSchema(CamelCaseSchema): + after = fields.Str() + before = fields.Str(allow_none=True) + current = fields.Str() + + +class ADObjectChangesSchema(CamelCaseSchema): + attribute_name = fields.Str() + values = fields.Nested(ADObjectChangeValuesSchema) + value_type = fields.Str() + + +class ADObjectAttributesSchema(CamelCaseSchema): + name = fields.Str() + value = fields.Str() + value_type = fields.Str() + + +class ADObjectSchema(CamelCaseSchema): + id = fields.Int() + directory_id = fields.Int() + object_id = fields.Str() + type = fields.Str() + object_attributes = fields.Nested(ADObjectAttributesSchema, many=True) + reasons = fields.List(fields.Int()) + wanted_values = fields.List(fields.Str()) + expression = fields.Mapping() + directories = fields.List(fields.Int()) + date_start = fields.DateTime() + date_end = fields.DateTime() + show_ignored = fields.Bool() + page = fields.Int(allow_none=True) + per_page = fields.Int(allow_none=True) + max_pages = fields.Int(allow_none=True) + max_items = fields.Int(allow_none=True) diff --git a/tenable/ad/base/iterator.py b/tenable/ad/base/iterator.py new file mode 100644 index 000000000..0cd7c30c9 --- /dev/null +++ b/tenable/ad/base/iterator.py @@ -0,0 +1,51 @@ +from restfly import APIIterator + + +class ADIterator(APIIterator): + ''' + The following methods allows us to iterate through pages and get data + + Attributes: + _api (restfly.session.APISession): + The APISession object that will be used for querying for the + data. + _path (str): + The URL for API call. + _schema (object): + The marshmallow schema object for deserialized response. + _method (str): + The API request method. supported values are ``get`` and ``post``. + default is ``get`` + _query (dict): + The query params for API call. + _payload (dict): + The payload object for API call. it is applicable only for + post method. + ''' + _api = None + _query = None + _payload = None + _method = None + _per_page = None + _path = None + _schema = None + + def _get_page(self) -> None: + ''' + Request the next page of data + ''' + # The first thing that we need to do is construct the query with the + # current page and per_page + query = self._query + query['page'] = self.num_pages + query['perPage'] = self._per_page + + # Lets make the actual call at this point. + if self._method == 'post': + self.page = self._schema.load( + self._api.post(self._path, params=query, json=self._payload), + many=True) + else: + self.page = self._schema.load( + self._api.get(self._path, params=query), + many=True) diff --git a/tenable/ad/session.py b/tenable/ad/session.py index e3c08e7f8..b94d9aad6 100644 --- a/tenable/ad/session.py +++ b/tenable/ad/session.py @@ -7,6 +7,7 @@ from tenable.base.platform import APIPlatform from .about import AboutAPI +from .ad_object.api import ADObjectAPI from .api_keys import APIKeyAPI from .attack_types.api import AttackTypesAPI from .category.api import CategoryAPI @@ -60,6 +61,14 @@ def about(self): ''' return AboutAPI(self) + @property + def ad_object(self): + ''' + The interface object for the + :doc:`Tenable.ad AD Object APIs `. + ''' + return ADObjectAPI(self) + @property def api_keys(self): ''' diff --git a/tests/ad/ad_object/test_ad_object_api.py b/tests/ad/ad_object/test_ad_object_api.py new file mode 100644 index 000000000..b0627114f --- /dev/null +++ b/tests/ad/ad_object/test_ad_object_api.py @@ -0,0 +1,179 @@ +import responses + +from tenable.ad.ad_object.api import ADObjectIterator +from tests.ad.conftest import RE_BASE + + +@responses.activate +def test_ad_object_details(api): + responses.add(responses.GET, + f'{RE_BASE}/infrastructures/1/directories/1/ad-objects/1', + json={ + 'directory_id': 1, + 'id': 1, + 'object_attributes': [{ + 'name': 'accountexpires', + 'value': '"NEVER"', + 'value_type': 'string' + }], + 'object_id': '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'type': 'LDAP' + } + ) + resp = api.ad_object.details( + infrastructure_id='1', + directory_id='1', + ad_object_id='1' + ) + assert isinstance(resp, dict) + assert resp['id'] == 1 + assert resp['directory_id'] == 1 + assert resp['object_attributes'][0]['name'] == 'accountexpires' + assert resp['object_attributes'][0]['value'] == '"NEVER"' + assert resp['object_attributes'][0]['value_type'] == 'string' + assert resp['object_id'] == '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + assert resp['type'] == 'LDAP' + + +@responses.activate +def test_ad_object_details_by_profile_and_checker(api): + responses.add(responses.GET, + f'{RE_BASE}/profiles/1/checkers/1/ad-objects/1', + json={ + 'directory_id': 1, + 'id': 1, + 'object_attributes': [{ + 'name': 'accountexpires', + 'value': '"NEVER"', + 'value_type': 'string' + }], + 'object_id': '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'reasons': [1], + 'type': 'LDAP' + } + ) + resp = api.ad_object.details_by_profile_and_checker( + profile_id='1', + checker_id='1', + ad_object_id='1' + ) + assert isinstance(resp, dict) + assert resp['id'] == 1 + assert resp['directory_id'] == 1 + assert resp['object_attributes'][0]['name'] == 'accountexpires' + assert resp['object_attributes'][0]['value'] == '"NEVER"' + assert resp['object_attributes'][0]['value_type'] == 'string' + assert resp['object_id'] == '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + assert resp['reasons'] == [1] + assert resp['type'] == 'LDAP' + + +@responses.activate +def test_ad_object_details_by_event(api): + responses.add(responses.GET, + f'{RE_BASE}/infrastructures/1/' + f'directories/1/events/1/ad-objects/1', + json={ + 'directory_id': 1, + 'id': 1, + 'object_attributes': [{ + 'name': 'accountexpires', + 'value': '"NEVER"', + 'value_type': 'string' + }], + 'object_id': '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'type': 'LDAP' + } + ) + resp = api.ad_object.details_by_event( + infrastructure_id='1', + directory_id='1', + event_id='1', + ad_object_id='1' + ) + assert isinstance(resp, dict) + assert resp['id'] == 1 + assert resp['directory_id'] == 1 + assert resp['object_attributes'][0]['name'] == 'accountexpires' + assert resp['object_attributes'][0]['value'] == '"NEVER"' + assert resp['object_attributes'][0]['value_type'] == 'string' + assert resp['object_id'] == '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + assert resp['type'] == 'LDAP' + + +@responses.activate +def test_ad_object_get_changes(api): + responses.add(responses.GET, + f'{RE_BASE}/infrastructures/1/' + f'directories/1/events/1/ad-objects/1/changes' + f'?wantedValues=after' + f'&wantedValues=before' + f'&wantedValues=current', + json=[{ + 'attribute_name': 'whencreated', + 'value_type': 'string', + 'values': { + 'after': '"2021-07-29T12:27:50.0000000Z"', + 'before': None, + 'current': '"2021-07-29T12:27:50.0000000Z"' + } + }] + ) + resp = api.ad_object.get_changes( + infrastructure_id='1', + directory_id='1', + ad_object_id='1', + event_id='1', + wanted_values=['after', 'before', 'current'] + ) + assert isinstance(resp, list) + assert len(resp) == 1 + assert resp[0]['attribute_name'] == 'whencreated' + assert resp[0]['value_type'] == 'string' + assert resp[0]['values']['after'] == '"2021-07-29T12:27:50.0000000Z"' + assert resp[0]['values']['before'] is None + assert resp[0]['values']['current'] == '"2021-07-29T12:27:50.0000000Z"' + + +@responses.activate +def test_ad_object_search_all(api): + responses.add(responses.POST, + f'{RE_BASE}/profiles/1/checkers/1/ad-objects/search', + json=[{ + 'directory_id': 1, + 'id': 1, + 'object_attributes': [{ + 'name': 'accountexpires', + 'value': '"NEVER"', + 'value_type': 'string' + }], + 'object_id': '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'reasons': [1], + 'type': 'LDAP' + }] + ) + ado = api.ad_object.search_all( + profile_id='1', + checker_id='1', + show_ignored=True, + reasons=[1], + directories=[1], + expression={'OR': [{'whencreated': '2021-07-29T12:27:50.0000000Z'}]}, + date_end='2022-12-31T18:30:00.000Z', + date_start='2021-12-31T18:30:00.000Z', + page=1, + per_page=10, + max_pages=10, + max_items=1000 + ) + assert isinstance(ado, ADObjectIterator) + + resp = ado.next() + assert resp['id'] == 1 + assert resp['directory_id'] == 1 + assert resp['object_attributes'][0]['name'] == 'accountexpires' + assert resp['object_attributes'][0]['value'] == '"NEVER"' + assert resp['object_attributes'][0]['value_type'] == 'string' + assert resp['object_id'] == '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + assert resp['reasons'] == [1] + assert resp['type'] == 'LDAP' diff --git a/tests/ad/ad_object/test_ad_object_schema.py b/tests/ad/ad_object/test_ad_object_schema.py new file mode 100644 index 000000000..d20d31d21 --- /dev/null +++ b/tests/ad/ad_object/test_ad_object_schema.py @@ -0,0 +1,70 @@ +''' +Testing the AD Object schema +''' +import pytest +from marshmallow import ValidationError +from tenable.ad.ad_object.schema import ADObjectSchema, ADObjectChangesSchema + + +def test_ad_object_schema(): + ''' + test ad object schema + ''' + test_resp = [{ + 'directory_id': 1, + 'id': 1, + 'object_attributes': [{ + 'name': 'accountexpires', + 'value': '"NEVER"', + 'value_type': 'string' + }], + 'object_id': '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'reasons': [1], + 'type': 'LDAP' + }] + + schema = ADObjectSchema(many=True) + resp = schema.load(test_resp) + assert isinstance(resp, list) + assert len(resp) == 1 + assert resp[0]['id'] == 1 + assert resp[0]['directory_id'] == 1 + assert resp[0]['object_attributes'][0]['name'] == 'accountexpires' + assert resp[0]['object_attributes'][0]['value'] == '"NEVER"' + assert resp[0]['object_attributes'][0]['value_type'] == 'string' + assert resp[0]['object_id'] == '1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + assert resp[0]['reasons'] == [1] + assert resp[0]['type'] == 'LDAP' + + with pytest.raises(ValidationError): + test_resp[0]['some_val'] = 'something' + schema.load(test_resp) + + +def test_ad_object_change_schema(): + ''' + test ad object change schema + ''' + test_resp = [{ + 'attribute_name': 'whencreated', + 'value_type': 'string', + 'values': { + 'after': '"2021-07-29T12:27:50.0000000Z"', + 'before': None, + 'current': '"2021-07-29T12:27:50.0000000Z"' + } + }] + + schema = ADObjectChangesSchema(many=True) + resp = schema.load(test_resp) + assert isinstance(resp, list) + assert len(resp) == 1 + assert resp[0]['attribute_name'] == 'whencreated' + assert resp[0]['value_type'] == 'string' + assert resp[0]['values']['after'] == '"2021-07-29T12:27:50.0000000Z"' + assert resp[0]['values']['before'] is None + assert resp[0]['values']['current'] == '"2021-07-29T12:27:50.0000000Z"' + + with pytest.raises(ValidationError): + test_resp[0]['some_val'] = 'something' + schema.load(test_resp)