SailPointIdentityNowEventCollector
This is the SailPoint IdentityNow event collector integration for Cortex XSIAM.
Analytics & SIEM · SailPoint IdentityNow
Details
| ID | SailPointIdentityNowEventCollector |
|---|---|
| Provider | SailPoint Technologies |
| Category | Analytics & SIEM |
| From Version | 8.4.0 |
| Docker Image | demisto/python3:3.12.13.10116658 |
| Supported Modules | Agentix XSIAM EDR Cortex Cloud Cloud Runtime Security |
README
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 in Cortex
| Parameter | Required |
|---|---|
| IdentityNow Server URL (e.g., https://{tenant}.api.identitynow.com) In order to get the tenant name, follow this link. |
True |
| Client ID In order to generate the Client ID and Client Secret, follow this link. |
True |
| Client Secret | True |
| Max number of events per fetch | False |
| Minutes to look back when fetching | False |
| Trust any certificate (not secure) | False |
| Use system proxy settings | False |
Note: The Minutes to look back when fetching parameter is recommended when missing events due to API indexing delay. It should be increased with caution, since in cases of dense events within a minute, increasing the lookback for several minutes can affect performance.
Note: After generating client credentials, it is required to allow the following scopes: sp, search, read.
Commands
You can execute these commands from the 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.
Configuration parameters
url— IdentityNow Server URL (e.g., https://{tenant}.api.identitynow.com) (required)credentials— Client ID (required)limit— Max number of events per fetchinsecure— Trust any certificate (not secure)proxy— Use system proxy settingslook_back— Minutes to look back when fetching
Commands (1)
-
identitynow-get-eventsGets 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.
from datetime import datetime, timedelta, UTC import demistomock as demisto import urllib3 from CommonServerPython import * 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(tz=UTC).strftime(DATE_FORMAT) MAX_EVENTS_PER_API_CALL = 10000 # API limitation """ 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: {e!s}") 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, look_back=0, 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, look_back: int, last_run: dict) -> tuple[Dict, List[Dict]]: """ Fetches events from the SailPoint IdentityNow API Args: client: Client object with the API client look_back: Look back timedelta in minutes limit: Maximum number of events to fetch per call last_run: Dict containing the last run data 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 can switch to ID-based fetching and remove # all deduplication logic (dedup_events, _migrate_legacy_ids, etc.). demisto.debug(f"Starting fetch up to {limit} events with last_run: {last_run} and look_back: {look_back}.") # Calculate fetch start time with lookback if "prev_date" in last_run: # Use prev_date from last run and apply lookback prev_date = dateparser.parse(last_run["prev_date"], settings={"TIMEZONE": "UTC", "RETURN_AS_TIMEZONE_AWARE": True}) fetch_start = prev_date - timedelta(minutes=look_back) # type: ignore[operator] last_fetched_creation_date = fetch_start.strftime(DATE_FORMAT) # type: ignore[union-attr] demisto.debug( f"Using prev_date '{last_run['prev_date']}' with {look_back} min lookback → " f"fetch from '{last_fetched_creation_date}'" ) else: # First run - use current time last_fetched_creation_date = CURRENT_TIME_STR demisto.debug(f"First run: fetching from current time '{last_fetched_creation_date}'") # Handle backward compatibility for deduplication if "last_fetched_id_timestamps" in last_run: # New format: pass full timestamp dict to maintain lookback window previous_id_timestamps = last_run["last_fetched_id_timestamps"] cached_ids_for_dedup = list(previous_id_timestamps.keys()) demisto.debug(f"Using new timestamped format with {len(cached_ids_for_dedup)} cached IDs") elif "last_fetched_ids" in last_run: # Legacy format: use old IDs as-is for this cycle, no timestamps available previous_id_timestamps = {} cached_ids_for_dedup = last_run["last_fetched_ids"] demisto.debug(f"Using legacy format with {len(cached_ids_for_dedup)} cached IDs") else: # First run: no previous IDs previous_id_timestamps = {} cached_ids_for_dedup = [] demisto.debug("First run: no previous IDs to cache") # Fetch events in batches with deduplication all_events, updated_dedup_cache = _fetch_events_batch( client, limit, last_fetched_creation_date, previous_id_timestamps, last_run.get("prev_date") ) # Build next_run with filtered deduplication cache next_run = _build_next_run(all_events, updated_dedup_cache, last_fetched_creation_date, look_back) 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 _fetch_events_batch( client: Client, limit: int, from_date: str, previous_id_timestamps: dict = None, prev_date: str = None ) -> tuple[List[Dict], dict]: """ Fetches events in batches with deduplication until limit is reached or no more events. Args: client: API client for fetching events limit: Maximum number of events to fetch total from_date: Start date for fetching events previous_id_timestamps: Dict of previous event IDs to timestamps (for merging) prev_date: Previous progression date for lookback analysis Returns: Tuple of (deduplicated events list, updated dedup_cache dict with timestamps) """ all_events = [] remaining_events_to_fetch = limit # Start with previous timestamps and add current run's events to it dedup_cache = previous_id_timestamps.copy() if previous_id_timestamps else {} demisto.debug(f"Starting with {len(dedup_cache)} cached IDs from previous run") current_from_date = from_date loop_count = 0 while remaining_events_to_fetch > 0: loop_count += 1 current_batch_to_fetch = min(remaining_events_to_fetch, MAX_EVENTS_PER_API_CALL) demisto.debug(f"Loop {loop_count}: API call with from_date: {current_from_date}, remaining: {remaining_events_to_fetch}") events = client.search_events(from_date=current_from_date, limit=current_batch_to_fetch) if not events: demisto.debug("No events fetched. Exiting the loop.") break # If we got fewer events than requested, API has no more events if len(events) < current_batch_to_fetch: demisto.debug( f"Got {len(events)} events, requested {current_batch_to_fetch}. " f"API has no more events. Will process these and exit." ) should_break_after_processing = True else: should_break_after_processing = False events_before_dedup = len(events) events = dedup_events(events, list(dedup_cache.keys()), prev_date) demisto.debug(f"After dedup: {events_before_dedup} -> {len(events)} events.") if events: # Add the batch of events to the total all_events.extend(events) # Update dedup cache with these events dedup_cache.update({event["id"]: event["created"] for event in events}) # Update the from_date to the last fetched event's creation date for next iteration last_fetched_event = events[-1] current_from_date = last_fetched_event["created"] demisto.debug(f"Updated from_date to: {current_from_date}") # Only decrease remaining by NEW events after dedup remaining_events_to_fetch -= len(events) demisto.debug(f"Fetched {len(events)} new events, {remaining_events_to_fetch} remaining") else: # All events are duplicates - exit loop demisto.debug(f"Loop {loop_count}: All {events_before_dedup} events were duplicates. " f"Exiting loop.") break # Break if API returned fewer events than requested (no more events available) if should_break_after_processing: demisto.debug("API returned fewer events than requested. No more events available.") break demisto.debug(f"_fetch_events_batch completed after {loop_count} loops, returning {len(all_events)} events") return all_events, dedup_cache def _build_next_run(all_events: List[Dict], dedup_cache: dict, fallback_date: str, look_back: int) -> dict: """ Builds the next_run dict with the most recent timestamp and filtered deduplication cache. Args: all_events: Events fetched in this cycle dedup_cache: Current deduplication cache with timestamps fallback_date: Date to use if no events were fetched look_back: Lookback window in minutes Returns: Dict with prev_date and filtered last_fetched_id_timestamps """ # Determine the most recent timestamp for next_run if all_events: most_recent_timestamp = max(dedup_cache.values()) demisto.debug(f"Most recent timestamp from {len(all_events)} events: {most_recent_timestamp}") else: # No new events this cycle - use most recent from existing cache if available if dedup_cache: most_recent_timestamp = max(dedup_cache.values()) demisto.debug(f"No new events, but using most recent from cache: {most_recent_timestamp}") else: most_recent_timestamp = fallback_date # Truly first run with no events demisto.debug(f"No events and no cache, using fallback date: {most_recent_timestamp}") # Filter ID timestamps to maintain only those within the lookback window filtered_id_timestamps = _filter_dedup_cache(dedup_cache, most_recent_timestamp, look_back, bool(all_events)) demisto.debug(f"Filtered dedup cache: {len(dedup_cache)} -> {len(filtered_id_timestamps)} IDs within lookback window") return {"prev_date": most_recent_timestamp, "last_fetched_id_timestamps": filtered_id_timestamps} def _filter_dedup_cache(id_timestamps: dict, most_recent_timestamp: str, look_back: int, has_events: bool) -> dict: """ Filters the deduplication cache based on lookback settings and current fetch results. Args: id_timestamps: Dict of event_id -> timestamp mappings most_recent_timestamp: The most recent timestamp from this fetch look_back: Look back time in minutes (0 means no lookback) has_events: Whether events were fetched in this cycle Returns: Filtered dict of IDs within the appropriate time window """ if look_back > 0: demisto.debug(f"Applying lookback window of {look_back} minutes from timestamp {most_recent_timestamp}") return filter_id_timestamps_by_lookback_window(id_timestamps, most_recent_timestamp, look_back) # When no lookback, only keep IDs from the most recent timestamp if has_events and id_timestamps: filtered_same_timestamp = { event_id: timestamp for event_id, timestamp in id_timestamps.items() if timestamp == most_recent_timestamp } demisto.debug(f"No lookback configured, keeping {len(filtered_same_timestamp)} IDs from most recent timestamp") return filtered_same_timestamp demisto.debug("No events or no lookback, returning all cached IDs") return id_timestamps def dedup_events(events: List[Dict], last_fetched_ids: list, prev_date: str = None) -> 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. Events before: {len(events)}, cached ids: {len(last_fetched_ids)}") last_fetched_ids_set = set(last_fetched_ids) deduped_events = [] filtered_ids = [] kept_ids = [] for event in events: if event["id"] not in last_fetched_ids_set: deduped_events.append(event) kept_ids.append(event["id"]) else: filtered_ids.append(event["id"]) if filtered_ids: demisto.debug(f"Filtered out {len(filtered_ids)} duplicate event IDs: {filtered_ids}") if kept_ids: demisto.debug(f"Kept {len(kept_ids)} new event IDs: {kept_ids}") return deduped_events def filter_id_timestamps_by_lookback_window(id_timestamps: dict, current_date: str, look_back: int) -> dict: """ Filters ID-timestamp mappings to only keep those within the lookback time window. This prevents the ID dict from growing indefinitely and maintains proper deduplication. Args: id_timestamps: Dict of event_id -> timestamp mappings from previous fetches current_date: Current fetch end date in string format look_back: Look back time in minutes Returns: Filtered dict of IDs within the lookback window """ if not id_timestamps: return id_timestamps current_datetime = arg_to_datetime(current_date, required=True) # Add 1 minute buffer to lookback for better cache retention retention_minutes = look_back + 1 if look_back > 0 else look_back lookback_cutoff = current_datetime - timedelta(minutes=retention_minutes) # type: ignore demisto.debug(f"Lookback window: from {current_datetime.strftime(DATE_FORMAT)} to {lookback_cutoff.strftime(DATE_FORMAT)}") # type: ignore[union-attr] filtered_id_timestamps = {} removed_count = 0 removed_ids = [] for event_id, timestamp in id_timestamps.items(): event_datetime = arg_to_datetime(timestamp, required=True) if event_datetime >= lookback_cutoff: # type: ignore filtered_id_timestamps[event_id] = timestamp else: removed_count += 1 removed_ids.append(event_id) if removed_ids: demisto.debug(f"Removing {removed_count} event IDs from cache: {removed_ids}") demisto.debug(f"Lookback filtering: kept {len(filtered_id_timestamps)} IDs, removed {removed_count} older IDs") return filtered_id_timestamps 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 fetch_look_back = arg_to_number(params.get("look_back")) or 0 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, look_back=fetch_look_back, 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{e!s}") """ ENTRY POINT """ if __name__ in ("__main__", "__builtin__", "builtins"): main()