diff --git a/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/README.md b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/README.md new file mode 100644 index 00000000000..1f8735d4554 --- /dev/null +++ b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/README.md @@ -0,0 +1,48 @@ +This is the SailPoint IdentityNow event collector integration for Cortex XSIAM. +This integration was integrated and tested with version 3 of SailPoint API. + +## Configure SailPoint IdentityNow Event Collector on Cortex XSOAR + +1. Navigate to **Settings** > **Integrations** > **Servers & Services**. +2. Search for SailPoint IdentityNow Event Collector. +3. Click **Add instance** to create and configure a new integration instance. + + | **Parameter** | **Required** | + | --- | --- | + | IdentityNow Server URL (e.g., https://{tenant}.api.identitynow.com)
In order to get the tenant name, follow this [link](https://developer.sailpoint.com/docs/api/getting-started/#find-your-tenant-name).| True | + | Client ID
In order to generate the Client ID and Client Secret, follow this [link](https://developer.sailpoint.com/docs/api/authentication/#generate-a-personal-access-token). | True | + | Client Secret | True | + | Max number of events per fetch | False | + | Trust any certificate (not secure) | False | + | Use system proxy settings | False | + +4. Click **Test** to validate the URLs, token, and connection. + +Note: After generating client credentials, it is required to allow the following scopes: sp, search, read. + +## Commands + +You can execute these commands from the Cortex XSIAM CLI, as part of an automation, or in a playbook. +After you successfully execute a command, a DBot message appears in the War Room with the command details. + +### identitynow-get-events + +*** +Gets events from SailPoint IdentityNow. This command is used for developing/debugging and is to be used with caution, as it can create events, leading to event duplication and exceeding API request limitations. + +#### Base Command + +`identitynow-get-events` + +#### Input + +| **Argument Name** | **Description** | **Required** | +| --- | --- | --- | +| should_push_events | If true, the command will create events, otherwise it will only display them. Possible values are: true, false. Default is false. | Optional | +| limit | Maximum number of results to return. Default is 50. | Optional | +| from_date | Date from which to get events in the format of %Y-%m-%dT%H:%M:%S. | Optional | +| from_id | An ID of the event to retrieve events from.| Optional | + +#### Context Output + +There is no context output for this command. \ No newline at end of file diff --git a/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector.py b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector.py new file mode 100644 index 00000000000..a31a39633cb --- /dev/null +++ b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector.py @@ -0,0 +1,359 @@ +from datetime import datetime +import demistomock as demisto +from CommonServerPython import * +import urllib3 +from dateutil import parser + +# Disable insecure warnings +urllib3.disable_warnings() + +''' CONSTANTS ''' + +DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +VENDOR = 'sailpoint' +PRODUCT = 'identitynow' +CURRENT_TIME_STR = datetime.now().strftime(DATE_FORMAT) + +''' CLIENT CLASS ''' + + +class Client(BaseClient): + """Client class to interact with the service API + """ + + def __init__(self, client_id: str, client_secret: str, base_url: str, proxy: bool, verify: bool, token: str | None = None): + super().__init__(base_url=base_url, proxy=proxy, verify=verify) + self.client_id = client_id + self.client_secret = client_secret + self.token = token + + try: + self.token = self.get_token() + self.headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.token}' + } + except Exception as e: + raise Exception(f'Failed to get token. Error: {str(e)}') + + def generate_token(self) -> str: + """ + Generates an OAuth 2.0 token using client credentials. + Returns: + str: token + """ + resp = self._http_request( + method='POST', + url_suffix="oauth/token", + data={ + 'grant_type': 'client_credentials', + }, + auth=(self.client_id, self.client_secret) + ) + + token = resp.get('access_token') + now_timestamp = arg_to_datetime('now').timestamp() # type:ignore + expiration_time = now_timestamp + resp.get('expires_in') + demisto.debug(f'Generated token that expires at: {expiration_time}.') + integration_context = get_integration_context() + integration_context.update({'token': token}) + # Subtract 60 seconds from the expiration time to make sure the token is still valid + integration_context.update({'expires': expiration_time - 60}) + set_integration_context(integration_context) + + return token + + def get_token(self) -> str: + """ + Obtains token from integration context if available and still valid. + After expiration, new token are generated and stored in the integration context. + Returns: + str: token that will be added to authorization header. + """ + integration_context = get_integration_context() + token = integration_context.get('token', '') + valid_until = integration_context.get('expires') + + now_timestamp = arg_to_datetime('now').timestamp() # type:ignore + # if there is a key and valid_until, and the current time is smaller than the valid until + # return the current token + if token and valid_until and now_timestamp < valid_until: + demisto.debug(f'Using existing token that expires at: {valid_until}.') + return token + + # else generate a token and update the integration context accordingly + token = self.generate_token() + demisto.debug('Generated a new token.') + + return token + + def search_events(self, from_date: str, limit: int, prev_id: str | None = None) -> List[Dict]: + """ + Searches for events in SailPoint IdentityNow + Args: + from_date: The date from which to fetch events + limit: Maximum number of events to fetch + prev_id: The id of the last event fetched + Returns: + List of events + """ + query: Dict = { + "indices": ["events"], + "queryType": "SAILPOINT", + "queryVersion": "5.2", + "sort": ["+created"] if not prev_id else ["+id"], + } + if prev_id: + query["query"] = {"query": "type:* "} + query["searchAfter"] = [prev_id] + else: + query["query"] = {"query": f"type:* AND created: [{from_date} TO now]"} + query["timeZone"] = "GMT" + + url_suffix = f'/v3/search?limit={limit}' + demisto.debug(f'Searching for events with query: {query}.') + return self._http_request(method='POST', headers=self.headers, url_suffix=url_suffix, data=json.dumps(query)) + + +def test_module(client: Client) -> str: + """ + Tests API connectivity and authentication + Args: + client: Client object with the API client + Returns: + 'ok' if test passed, anything else will fail the test + """ + + try: + fetch_events( + client=client, + limit=1, + last_run={}, + ) + + except Exception as e: + if 'Forbidden' in str(e): + return 'Authorization Error: make sure API Key is correctly set' + else: + raise e + + return 'ok' + + +def get_events(client: Client, from_date: str, from_id: str | None, limit: int = 50) -> tuple[List[Dict], CommandResults]: + """ + Gets events from the SailPoint IdentityNow API + Args: + client: Client object with the API client + limit: Maximum number of events to fetch + from_date: The date from which to get events + from_id: The ID of an event from which to start to get events from + Returns: + List of events and CommandResults object + """ + events = client.search_events( + prev_id=from_id, + from_date=from_date, + limit=limit + ) + demisto.debug(f'Got {len(events)} events.') + hr = tableToMarkdown(name='Test Events', t=events) + return events, CommandResults(readable_output=hr) + + +def fetch_events(client: Client, + limit: int, last_run: dict) -> tuple[Dict, List[Dict]]: + """ + Fetches events from the SailPoint IdentityNow API + Args: + client: Client object with the API client + last_run: Dict containing the last run data + limit: Maximum number of events to fetch per call + Returns: + Tuple with the next run data and the list of events fetched + """ + # currently the API fails fetching events by id, so we are fetching by date only. + # Once the issue is resolved, we just need to uncomment the commented lines, + # and remove the dedup function and last_fetched_ids from everywhere.. + demisto.debug(f'Starting fetch_events with last_run: {last_run}.') + # last_fetched_id = last_run.get('prev_id') + last_fetched_creation_date = last_run.get('prev_date', CURRENT_TIME_STR) + last_fetched_ids: list = last_run.get('last_fetched_ids', []) + + all_events = [] + remaining_events_to_fetch = limit + # since we allow the user to set the limit to 50,000, but the API only allows 10000 events per call + # we need to make multiple calls to the API to fetch all the events + while remaining_events_to_fetch > 0: + current_batch_to_fetch = min(remaining_events_to_fetch, 10000) + demisto.debug(f'trying to fetch {current_batch_to_fetch} events.') + + events = client.search_events( + # prev_id=last_fetched_id + from_date=last_fetched_creation_date, + limit=current_batch_to_fetch + ) + demisto.debug(f'Successfully fetched {len(events)} events in this cycle.') + if not events: + demisto.debug('No events fetched. Exiting the loop.') + events = dedup_events(events, last_fetched_ids) + if events: + last_fetched_event = events[-1] + last_fetched_id = last_fetched_event['id'] + last_fetched_creation_date = last_fetched_event['created'] + demisto.debug( + f'information of the last event in this cycle: id: {last_fetched_id}, created: {last_fetched_creation_date}.') + remaining_events_to_fetch -= len(events) + demisto.debug(f'{remaining_events_to_fetch} events are left to fetch in the next calls.') + last_fetched_ids = get_last_fetched_ids(events) + all_events.extend(events) + else: + # to avoid infinite loop, if no events are fetched, or all events are duplicates, exit the loop + break + # next_run = {'prev_id': last_fetched_id, 'prev_date': last_fetched_creation_date} + next_run = {'prev_date': last_fetched_creation_date, 'last_fetched_ids': last_fetched_ids} + demisto.debug(f'Done fetching. Sum of all events: {len(all_events)}, the next run is {next_run}.') + return next_run, all_events + + +''' HELPER FUNCTIONS ''' + + +def dedup_events(events: List[Dict], last_fetched_ids: list) -> List[Dict]: + """ + Dedupes the events fetched based on the last fetched ids and creation date. + This process is based on the assumption that the events are sorted by creation date. + + Args: + events: List of events. + last_fetched_ids: List of the last fetched ids. + Returns: + List of deduped events. + """ + if not last_fetched_ids: + demisto.debug("No last fetched ids. Skipping deduping.") + return events + + demisto.debug(f"Starting deduping. Number of events before deduping: {len(events)}, last fetched ids: {last_fetched_ids}") + + last_fetched_ids_set = set(last_fetched_ids) + deduped_events = [event for event in events if event['id'] not in last_fetched_ids_set] + + demisto.debug(f"Done deduping. Number of events after deduping: {len(deduped_events)}") + return deduped_events + + +def get_last_fetched_ids(events: List[Dict]) -> List[str]: + """ + Gets the ids of the last fetched events + Args: + events: List of events, assumed to be sorted ASC by creation date + Returns: + List of the last fetched ids + """ + last_creation_date = events[-1]['created'] + return [event['id'] for event in events if event['created'] == last_creation_date] + + +def add_time_and_status_to_events(events: List[Dict]) -> None: + """ + Adds _time and _ENTRY_STATUS fields to events + Args: + events: List of events + Returns: + None + """ + for event in events: + created = event['created'] + created = parser.parse(created) + + modified = event.get('modified') + if modified: + modified = parser.parse(modified) + + is_modified = created and modified and modified > created + event['_time'] = modified.strftime(DATE_FORMAT) if is_modified else created.strftime(DATE_FORMAT) + event["_ENTRY_STATUS"] = "modified" if is_modified else "new" + + +''' MAIN FUNCTION ''' + + +def main() -> None: # pragma: no cover + """ + main function, parses params and runs command functions + """ + + params = demisto.params() + args = demisto.args() + command = demisto.command() + client_id = params.get('credentials', {}).get('identifier') + client_secret = params.get('credentials', {}).get('password') + base_url = params['url'] + verify_certificate = not params.get('insecure', False) + proxy = params.get('proxy', False) + fetch_limit = arg_to_number(params.get('limit')) or 50000 + + demisto.debug(f'Command being called is {command}') + try: + client = Client( + client_id=client_id, + client_secret=client_secret, + base_url=base_url, + verify=verify_certificate, + proxy=proxy) + + if command == 'test-module': + result = test_module(client) + return_results(result) + + elif command == 'identitynow-get-events': + limit = arg_to_number(args.get('limit', 50)) or 50 + should_push_events = argToBoolean(args.get('should_push_events', False)) + time_to_start = arg_to_datetime(args.get('from_date')) + formatted_time_to_start = time_to_start.strftime(DATE_FORMAT) if time_to_start else CURRENT_TIME_STR + id_to_start = args.get('from_id') + if not (id_to_start or time_to_start) or (id_to_start and time_to_start): + raise DemistoException("Please provide either from_id or from_date.") + events, results = get_events(client, from_date=formatted_time_to_start, + from_id=id_to_start, limit=limit) + return_results(results) + if should_push_events: + add_time_and_status_to_events(events) + send_events_to_xsiam( + events, + vendor=VENDOR, + product=PRODUCT + ) + + elif command == 'fetch-events': + + last_run = demisto.getLastRun() + next_run, events = fetch_events( + client=client, + limit=fetch_limit, + last_run=last_run, + ) + + add_time_and_status_to_events(events) + demisto.debug(f'Sending {len(events)} events to Xsiam.') + send_events_to_xsiam( + events, + vendor=VENDOR, + product=PRODUCT + ) + demisto.setLastRun(next_run) + demisto.debug(f'Next run is set to: {next_run}.') + else: + raise NotImplementedError(f'Command {command} is not implemented') + + # Log exceptions and return errors + except Exception as e: + return_error(f'Failed to execute {command} command.\nError:\n{str(e)}') + + +''' ENTRY POINT ''' + +if __name__ in ('__main__', '__builtin__', 'builtins'): + main() diff --git a/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector.yml b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector.yml new file mode 100644 index 00000000000..a05afc89efd --- /dev/null +++ b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector.yml @@ -0,0 +1,74 @@ +category: Analytics & SIEM +sectionOrder: +- Connect +- Collect +commonfields: + id: SailPointIdentityNowEventCollector + version: -1 +configuration: +- display: IdentityNow Server URL (e.g., https://{tenant}.api.identitynow.com) + name: url + required: true + type: 0 + section: Connect +- display: Client ID + name: credentials + type: 9 + required: true + displaypassword: Client Secret +- defaultvalue: 50000 + section: Collect + display: Max number of events per fetch + name: limit + required: false + type: 0 +- display: Trust any certificate (not secure) + name: insecure + required: false + type: 8 + section: Connect +- display: Use system proxy settings + name: proxy + required: false + type: 8 + section: Connect +description: This is the SailPoint IdentityNow event collector integration for Cortex XSIAM. +display: SailPoint IdentityNow Event Collector +name: SailPointIdentityNowEventCollector +supportlevelheader: xsoar +script: + commands: + - arguments: + - auto: PREDEFINED + defaultValue: 'false' + description: If true, the command will create events, otherwise it will only display them. + name: should_push_events + predefined: + - 'true' + - 'false' + required: false + - description: Maximum number of results to return. + name: limit + required: false + defaultValue: 50 + - description: An ID of the event to retrieve events from. + name: from_id + required: false + - default: false + description: Date from which to get events. + name: from_date + deprecated: false + description: Gets events from SailPoint IdentityNow. This command is used for developing/debugging and is to be used with caution, as it can create events, leading to event duplication and exceeding API request limitations. + + name: identitynow-get-events + dockerimage: demisto/python3:3.11.9.106403 + isfetchevents: true + runonce: false + script: '-' + subtype: python3 + type: python +marketplaces: +- marketplacev2 +fromversion: 8.4.0 +tests: +- No tests (auto formatted) diff --git a/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_description.md b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_description.md new file mode 100644 index 00000000000..d1529197c88 --- /dev/null +++ b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_description.md @@ -0,0 +1,11 @@ +## SailPoint IdentityNow Event Collector + +**Identity Now Server URL**: +In order to get the tenant name, follow this [link](https://developer.sailpoint.com/docs/api/getting-started/#find-your-tenant-name). + + +**Client ID and Client Secret**: +In order to generate the Client ID and Client Secret, follow this [link](https://developer.sailpoint.com/docs/api/authentication/#generate-a-personal-access-token). + + +Note: After generating client credentials, it is required to allow the following scopes: sp, search, read. \ No newline at end of file diff --git a/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_image.png b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_image.png new file mode 100644 index 00000000000..f953f12dded Binary files /dev/null and b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_image.png differ diff --git a/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_test.py b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_test.py new file mode 100644 index 00000000000..dc7c91833a1 --- /dev/null +++ b/Packs/SailPointIdentityNow/Integrations/SailPointIdentityNowEventCollector/SailPointIdentityNowEventCollector_test.py @@ -0,0 +1,228 @@ +import pytest +import demistomock as demisto +from SailPointIdentityNowEventCollector import fetch_events, add_time_and_status_to_events, Client, \ + dedup_events, get_last_fetched_ids + +EVENTS_WITH_THE_SAME_DATE = [ + {'created': '2022-01-01T00:00:00Z', 'id': '1'}, + {'created': '2022-01-01T00:00:00Z', 'id': '2'}, + {'created': '2022-01-01T00:00:00Z', 'id': '3'}, + {'created': '2022-01-01T00:00:00Z', 'id': '4'}, +] + +EVENTS_WITH_DIFFERENT_DATE = [ + {'created': '2022-01-01T00:00:00Z', 'id': '1'}, + {'created': '2022-01-01T00:00:00Z', 'id': '2'}, + {'created': '2022-01-02T00:00:00Z', 'id': '3'}, + {'created': '2022-01-02T00:00:00Z', 'id': '4'}, +] + + +@pytest.mark.parametrize('expiration_time, expected', [ + (9999999999, 'valid_token'), + (0, 'new_token')]) +def test_get_token(mocker, expiration_time, expected): + """ + Given: + - A SailPointIdentityNow client + - A context with a token and expiration time + case 1: expiration time is in the future + case 2: expiration time is in the past + When: + - calling get_token + Then: + - Ensure the token is returned correctly + case 1: the token from the context + case 2: a new token + """ + mocker.patch.object(Client, '_http_request').return_value = {"access_token": "dummy token", + "expires_in": 1} + client = Client(base_url="https://example.com", client_id="test_id", client_secret="test_secret", verify=False, proxy=False) + mocker.patch('SailPointIdentityNowEventCollector.get_integration_context', + return_value={'token': 'valid_token', 'expires': expiration_time}) + mocker.patch.object(Client, 'generate_token', return_value='new_token') + token = client.get_token() + assert token == expected + + +def test_fetch_events__end_to_end_with_affective_dedup(mocker): + """ + Given: + - A SailPointIdentityNow client with max of 3 events ro return per call + When: + - calling fetch_events with a max_events_per_fetch of 5 + Then: + - Ensure the pagination is working correctly, and 3 sets of events are fetched to reach + the max_events_per_fetch or the end of the events + - Ensure the next_run object is returned correctly + - ensure the events are deduped correctly. + """ + mocker.patch.object(demisto, 'debug') + client = mocker.patch('SailPointIdentityNowEventCollector.Client') + last_run = {'prev_id': '0', 'prev_date': '2022-01-01T00:00:00'} + max_events_per_fetch = 5 + + mocker.patch.object(client, 'search_events', side_effect=[[ + {'id': str(i), 'created': f'2022-01-01T00:0{i}:00'} for i in range(1, 4) + ], [ + {'id': str(i), 'created': f'2022-01-01T00:0{i}:00'} for i in range(3, 5) + ], + []]) + + next_run, events = fetch_events(client, max_events_per_fetch, last_run) + + assert next_run == {'prev_date': '2022-01-01T00:04:00', 'last_fetched_ids': ['4']} + assert len(events) == 4 + + +def test_fetch_events__no_events(mocker): + """ + Given: + - A SailPointIdentityNow client with max of 3 events per call + When: + - calling fetch_events with a max_events_per_fetch of 5 and no events to fetch + Then: + - Ensure the next_run object is returned correctly and we did not enter an infinite loop + - Ensure the debug logs are correct + + """ + mock_debug = mocker.patch.object(demisto, 'debug') + client = mocker.patch('SailPointIdentityNowEventCollector.Client') + last_run = {'prev_date': '2022-01-01T00:00:00', 'last_fetched_ids': ['0']} + max_events_per_fetch = 5 + + mocker.patch.object(client, 'search_events', return_value=[]) + next_run, _ = fetch_events(client, max_events_per_fetch, last_run) + + assert next_run == last_run + assert mock_debug.call_args_list[3][0][0] == 'No events fetched. Exiting the loop.' + + +def test_fetch_events__all_events_are_dedup(mocker): + """ + Given: + - A SailPointIdentityNow client with max of 3 events per call + When: + - calling fetch_events with a max_events_per_fetch of 5 and all events are duplicates + Then: + - Ensure the next_run object is returned correctly + - Ensure the we are not stuck in an infinite loop + - Ensure the debug messages are correct + """ + mock_debug = mocker.patch.object(demisto, 'debug') + client = mocker.patch('SailPointIdentityNowEventCollector.Client') + last_run = {'prev_date': '2022-01-01T00:00:00', 'last_fetched_ids': [0]} + max_events_per_fetch = 5 + mocker.patch('SailPointIdentityNowEventCollector.dedup_events', return_value=[]) + + mocker.patch.object(client, 'search_events', return_value=[ + {'id': str(i), 'created': f'2022-01-01T00:0{i}:00'} for i in range(1, 4) + ]) + next_run, _ = fetch_events(client, max_events_per_fetch, last_run) + assert next_run == last_run + assert 'Successfully fetched 3 events in this cycle.' in mock_debug.call_args_list[2][0][0] + assert "Done fetching. Sum of all events: 0, the next run is" in mock_debug.call_args_list[3][0][0] + + +def test_add_time_and_status_to_events(mocker): + """ + Given: + - A list of events + case 1: created and modified are both present and modified > created + case 2: created and modified are both present and modified < created + case 3: created is present and modified is not + When: + - calling add_time_and_status_to_events + Then: + - Ensure the _ENTRY_STATUS field is added correctly based on the created and modified fields + - Ensure the _time field is added correctly + case 1: _ENTRY_STATUS = modified, _time = modified time + case 2: _ENTRY_STATUS = new, _time = created time + case 3: _ENTRY_STATUS = new, _time = created time + """ + mocker.patch.object(demisto, 'debug') + + events = [ + {'created': '2022-01-01T00:00:00', 'modified': '2022-01-01T00:01:00'}, + {'created': '2022-01-01T00:02:00', 'modified': '2022-01-01T00:01:00'}, + {'created': '2022-01-01T00:03:00'}, + ] + + add_time_and_status_to_events(events) + + assert events[0] == {'created': '2022-01-01T00:00:00', 'modified': '2022-01-01T00:01:00', + '_ENTRY_STATUS': 'modified', '_time': '2022-01-01T00:01:00Z'} + assert events[1] == {'created': '2022-01-01T00:02:00', 'modified': '2022-01-01T00:01:00', + '_ENTRY_STATUS': 'new', '_time': '2022-01-01T00:02:00Z'} + assert events[2] == {'created': '2022-01-01T00:03:00', '_time': '2022-01-01T00:03:00Z', '_ENTRY_STATUS': 'new'} + + +@pytest.mark.parametrize('prev_id, expected', [ + ("123", + '{"indices": ["events"], "queryType": "SAILPOINT", "queryVersion": "5.2", "sort": ["+id"], "query": {"query": "type:* "}, "searchAfter": ["123"]}' # noqa: E501 + ), + (None, + '{"indices": ["events"], "queryType": "SAILPOINT", "queryVersion": "5.2", "sort": ["+created"], "query": {"query": "type:* AND created: [2022-01-01T00:00:00 TO now]"}, "timeZone": "GMT"}' # noqa: E501 + ) +]) +def test_search_events(mocker, prev_id, expected): + """ + Given: + - A SailPointIdentityNow client + When: + - calling search_events + case 1: with a prev_id + case 2: without a prev_id + Then: + - Ensure the correct request is sent to the API + """ + mocker_request = mocker.patch.object(Client, '_http_request') + mocker.patch.object(Client, 'get_token').return_value = {} + client = Client(base_url="https://example.com", client_id="test_id", client_secret="test_secret", + verify=False, proxy=False, token='dummy_token') + client.search_events(from_date='2022-01-01T00:00:00', limit=1, prev_id=prev_id) + assert mocker_request.call_args.kwargs["data"] == expected + + +def test_get_last_fetched_ids(mocker): + """ + Given: + - A list of events with different creation dates + When: + - calling get_last_fetched_ids + Then: + - Ensure the function returns the ids of the events that have the same creation date as the last event + """ + mocker.patch.object(demisto, 'debug') + + assert get_last_fetched_ids(EVENTS_WITH_DIFFERENT_DATE) == ['3', '4'] + + +@pytest.mark.parametrize('events, last_fetched_ids, expected, debug_msgs', [ + (EVENTS_WITH_DIFFERENT_DATE, ['1', '2'], [{'created': '2022-01-02T00:00:00Z', 'id': '3'}, + {'created': '2022-01-02T00:00:00Z', 'id': '4'}], + ["Starting deduping. Number of events before deduping: 4, last fetched ids: ['1', '2']", + 'Done deduping. Number of events after deduping: 2']), + (EVENTS_WITH_THE_SAME_DATE, ['1', '2', '3', '4'], [], []), + (EVENTS_WITH_THE_SAME_DATE, ['6', '5'], EVENTS_WITH_THE_SAME_DATE, []) +]) +def test_dedup_events(mocker, events, last_fetched_ids, expected, debug_msgs): + """ + Given: + - A list of events with duplicate and unique entries + - A list of last fetched ids + case 1 - some of the new events were fetched in the last fetch. + case 2 - all of the new events were fetched in the last fetch. + case 3 - none of the new events were fetched in the last fetch. + When: + - calling dedup_events + Then: + - Ensure the duplicate events are removed + - Ensure the log message contains the info of the dropped events. + """ + debug_msg = mocker.patch.object(demisto, 'debug') + deduped_events = dedup_events(events, last_fetched_ids=last_fetched_ids) + + assert deduped_events == expected + for i, msg in enumerate(debug_msgs): + assert msg in debug_msg.call_args_list[i][0][0] diff --git a/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow.xif b/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow.xif new file mode 100644 index 00000000000..2660a043a1f --- /dev/null +++ b/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow.xif @@ -0,0 +1,29 @@ +[MODEL: dataset= "sailpoint_identitynow_raw"] +alter + src_name = actor -> name, + dst_name = `target` -> name, + objects = objects-> [] +| alter + src_email = arrayindex(regextract(src_name, "(?:\w+)\@\S+"),0), + dst_email = arrayindex(regextract(dst_name, "(?:\w+)\@\S+"),0), + objects = arraymap(objects , trim("@element", "\"")) +| alter + xdm.event.original_event_type = action, + xdm.event.id = id, + xdm.alert.name = name, + xdm.event.tags = objects, + xdm.event.operation_sub_type = operation, + xdm.source.user.ou = org, + xdm.source.cloud.region = pod, + xdm.event.outcome = if(status in ("PASSED", "SUCCESS","APPROVED","PROCESSED","UPDATED","CREATED","DELETED","ENABLED","DISABLED","FORWARDED","ESCALATED","DETECTED", "ACCESSED", "CANCELLED"), XDM_CONST.OUTCOME_SUCCESS, status in ("FAILED","BLOCKED", "REJECTED"), XDM_CONST.OUTCOME_FAILED, status in ("STARTED", "REMEDIATE"), XDM_CONST.OUTCOME_UNKNOWN,status), + xdm.event.is_completed = if(status in ("PASSED", "SUCCESS","APPROVED","PROCESSED","UPDATED","CREATED","DELETED","ENABLED","DISABLED","FORWARDED","ESCALATED","DETECTED", "ACCESSED", "CANCELLED", "FAILED", "BLOCKED", "REJECTED"), True, status in ("STARTED","REMEDIATE"), False), + xdm.event.type = type, + xdm.target.resource.name = if(stack = "", null, stack), + xdm.target.resource.type = "Stack", + xdm.target.ipv4 = if(incidr(ipAddress, "0.0.0.0/0"), ipAddress), + xdm.target.ipv6 = arrayindex(regextract(ipAddress, "(?:[a-fA-F\d]{0,4}\:){7}[\wa-fA-F]{0,4}"), 0), + xdm.source.user.username = if(src_email != null, arrayindex(regextract(src_email, "(\w+)\@"),0), src_name), + xdm.source.user.upn = src_email, + xdm.target.user.username = if(dst_email != null, arrayindex(regextract(dst_email, "(\w+)\@"),0), dst_name), + xdm.target.user.upn = dst_email, + xdm.event.description = attributes; \ No newline at end of file diff --git a/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow.yml b/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow.yml new file mode 100644 index 00000000000..4fa062fcd9f --- /dev/null +++ b/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow.yml @@ -0,0 +1,6 @@ +fromversion: 8.6.0 # Will be updated with XSIAM version updates +id: SailPoint_IdentityNow_ModelingRule +name: SailPoint IdentityNow Modeling Rule +rules: '' +schema: '' +tags: '' \ No newline at end of file diff --git a/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow_schema.json b/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow_schema.json new file mode 100644 index 00000000000..923fc05ad46 --- /dev/null +++ b/Packs/SailPointIdentityNow/ModelingRules/SailPointIdentityNow/SailPointIdentityNow_schema.json @@ -0,0 +1,64 @@ +{ + "sailpoint_identitynow_raw": { + "action": { + "type": "string", + "is_array": false + }, + "created": { + "type": "datetime", + "is_array": false + }, + "id": { + "type": "string", + "is_array": false + }, + "name": { + "type": "string", + "is_array": false + }, + "objects": { + "type": "string", + "is_array": false + }, + "operation": { + "type": "string", + "is_array": false + }, + "org": { + "type": "string", + "is_array": false + }, + "pod": { + "type": "string", + "is_array": false + }, + "stack": { + "type": "string", + "is_array": false + }, + "ipaddress": { + "type": "string", + "is_array": false + }, + "actor": { + "type": "string", + "is_array": false + }, + "target": { + "type": "string", + "is_array": false + }, + "status": { + "type": "string", + "is_array": false + }, + "attributes": { + "type": "string", + "is_array": false + }, + "type": { + "type": "string", + "is_array": false + } + } +} \ No newline at end of file diff --git a/Packs/SailPointIdentityNow/ReleaseNotes/1_0_8.md b/Packs/SailPointIdentityNow/ReleaseNotes/1_0_8.md new file mode 100644 index 00000000000..cd1611c5a05 --- /dev/null +++ b/Packs/SailPointIdentityNow/ReleaseNotes/1_0_8.md @@ -0,0 +1,14 @@ + +#### Modeling Rules + +##### New: SailPoint IdentityNow Modeling Rule + +<~XSIAM> (Available from Cortex XSIAM 2.2). + + +#### Integrations + +##### New: SailPoint IdentityNow Event Collector + +New: This is the SailPoint IdentityNow event collector integration for Cortex XSIAM.<~XSIAM> (Available from Cortex XSIAM 2.2). + diff --git a/Packs/SailPointIdentityNow/pack_metadata.json b/Packs/SailPointIdentityNow/pack_metadata.json index 355e6c19b3d..72ed2465e8c 100644 --- a/Packs/SailPointIdentityNow/pack_metadata.json +++ b/Packs/SailPointIdentityNow/pack_metadata.json @@ -2,7 +2,7 @@ "name": "SailPoint IdentityNow", "description": "SailPoint IdentityNow content pack enables XSOAR customers to utilize the deep, enriched contextual data in the SailPoint IdentityNow platform to better drive identity-aware security practices.", "support": "partner", - "currentVersion": "1.0.7", + "currentVersion": "1.0.8", "author": "SailPoint", "url": "https://support.sailpoint.com/hc/en-us/requests/new", "email": "support.idplusa@sailpoint.com",