Palo Alto Networks Enterprise DLP
Palo Alto Networks Enterprise DLP discovers and protects company data across every data channel and repository. Integrated Enterprise DLP enables data protection and compliance everywhere without complexity.
Network Security · Enterprise DLP by Palo Alto Networks
Details
| ID | Palo Alto Networks Enterprise DLP |
|---|---|
| Provider | Palo Alto Networks |
| Category | Network Security |
| From Version | 5.0.0 |
| Docker Image | demisto/python3:3.12.13.10404775 |
| Supported Modules | Agentix XSIAM EDR Cloud Runtime Security |
README
Palo Alto Networks Enterprise DLP discovers and protects company data across every data channel and repository. Integrated Enterprise DLP enables data protection and compliance everywhere without complexity.
This integration was integrated and tested with version 2.0 of Palo Alto Networks Enterprise DLP.
Note:
This integration currently supports fetching DLP incidents from “NGFW” and “Prisma Access” channels only.
Setup
Go to the Settings tab on the DLP web interface.
Choose Alerts on the left menu. Follow all the steps under Setup Instructions.
Make sure the toggle at the bottom is switched on.
Configure Palo Alto Networks Enterprise DLP in Cortex
| Parameter | Description | Required |
|---|---|---|
| Server URL | Default value is https://api.dlp.paloaltonetworks.com/v1/ | False |
| Authentication URL | Default value is https://auth.apps.paloaltonetworks.com/auth/v1/oauth2/access_token | False |
| Access Token | Access token generated in the Enterprise DLP UI | True |
| Refresh Token | Refresh token generated in the Enterprise DLP UI | True |
| Trust any certificate (not secure) | False | |
| Use system proxy settings | False | |
| Fetch incidents | False | |
| Maximum number of incidents per fetch | Default value is 50. | False |
| First fetch timestamp | First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days). Default value is 60 minutes. | False |
| Fetch Lookback Window (minutes) | The number of minutes to look back during each fetch to capture late-indexed incidents. Default value is 0. | False |
| DLP Regions | Possible values: US (United States), EU (European Union), AP (Asia-Pacific), UK (United Kingdom), CA (Canada), AU (Australia), IN (India), JP (Japan), BR (Brazil), PAR (Paris), SUI (Switzerland). |
False |
| Data profiles to allow exemption | A comma-separated list of data profile names to request an exemption. Use “*” to allow everything. | False |
| Bot Message | The message to send to the user to ask for feedback. | False |
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.
pan-dlp-get-report
Fetches DLP reports associated with a report ID.
Base Command
pan-dlp-get-report
Input
| Argument Name | Description | Required |
|---|---|---|
| report_id | DLP report ID. | Required |
| fetch_snippets | If True, includes snippets with the reports. Possible values are: true, false. Default is false. | Optional |
| service_name | The DLP service that the report belongs to. Determines which backend the report is retrieved from. When empty, the request does not specify a service and the server retrieves the report from Prisma Access. Possible values are: ngfw, prisma-access, prisma-saas, prisma-access-browser, endpoint-dlp. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| DLP.Report.DataProfile | unknown | The data profile name. |
| DLP.Report.DataPatternMatches.DataPatternName | unknown | The DLP data pattern name. |
| DLP.Report.DataPatternMatches.Detections | unknown | The DLP detection snippets. |
| DLP.Report.DataPatternMatches.HighConfidenceFrequency | unknown | The number of high confidence occurrences. |
| DLP.Report.DataPatternMatches.MediumConfidenceFrequency | unknown | The number of medium confidence occurrences. |
| DLP.Report.DataPatternMatches.LowConfidenceFrequency | unknown | The number of low confidence occurrences. |
| DLP.Report.DataPatternMatches.MatchedConfidenceLevel | String | The matched confidence level of the data pattern (e.g., “high”, “medium”, “low”). Only present for patterns that matched. |
| DLP.Report.DataProfiles.Name | String | The name of the data profile. |
| DLP.Report.DataProfiles.Id | Number | The ID of the data profile. |
| DLP.Report.DataProfiles.Version | Number | The version of the data profile. |
| DLP.Report.DataProfiles.IsTriggered | Boolean | Whether the data profile was triggered. |
| DLP.Report.DataProfiles.DataPatterns.Id | String | The data pattern ID within the profile. |
| DLP.Report.DataProfiles.DataPatterns.IsMatched | Boolean | Whether the data pattern matched. |
| DLP.Report.DataProfiles.DataPatterns.ConfidenceLevel | String | The confidence level configured for the pattern. |
| DLP.Report.DataProfiles.DataPatterns.OccurrenceCount | Number | The number of occurrences detected. |
| DLP.Report.DataProfiles.DataPatterns.OccurrenceOperatorType | String | The occurrence operator type (e.g., “more_than_equal_to”, “between”). |
| DLP.Report.DataProfiles.DataPatterns.OccurrenceLow | Number | The low bound for “between” operator type. |
| DLP.Report.DataProfiles.DataPatterns.OccurrenceHigh | Number | The high bound for “between” operator type. |
Command example
!pan-dlp-get-report report_id=3165792284 service_name=prisma-saas
Human Readable Output
DLP Report for profile: Sample-Data-Profile
DataPatternName ConfidenceFrequency MatchedConfidenceLevel National Id - US Social Security Number - SSN Low: 30
Medium: 0
High: 30high Credit Card Number Low: 30
Medium: 30
High: 30high
pan-dlp-update-incident
Updates a DLP incident with user feedback.
Base Command
pan-dlp-update-incident
Input
| Argument Name | Description | Required |
|---|---|---|
| incident_id | The ID of the incident to update. | Required |
| feedback | The user feedback. Possible values are: PENDING_RESPONSE, CONFIRMED_SENSITIVE, CONFIRMED_FALSE_POSITIVE, EXCEPTION_REQUESTED, EXCEPTION_GRANTED, EXCEPTION_NOT_REQUESTED, OPERATIONAL_ERROR, SEND_NOTIFICATION_FAILURE, EXCEPTION_DENIED. | Required |
| user_id | The ID of the user the feedback is collected from. | Required |
| region | The region where the incident originated. | Optional |
| report_id | The DLP report ID, needed only for granting exemptions. | Optional |
| dlp_channel | The DLP channel, needed only for granting exemptions. | Optional |
| error_details | Error details if status is SEND_NOTIFICATION_FAILURE. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| DLP.IncidentUpdate.success | boolean | Whether the update was successful. |
| DLP.IncidentUpdate.exemption_duration | number | The exemption duration, only available for “EXCEPTION_GRANTED”. |
pan-dlp-exemption-eligible
Determines whether exemption can be granted on incidents from a certain data profile.
Base Command
pan-dlp-exemption-eligible
Input
| Argument Name | Description | Required |
|---|---|---|
| data_profile | The name of the data profile. | Required |
Context Output
| Path | Type | Description |
|---|---|---|
| DLP.exemption.eligible | boolean | Whether the data profile is eligible for exemption. |
pan-dlp-slack-message
Gets the Slack bot message to send to the user for gathering feedback.
Base Command
pan-dlp-slack-message
Input
| Argument Name | Description | Required |
|---|---|---|
| user | The name of the user that receives this message. | Required |
| file_name | The name of the file that triggered the incident. | Required |
| data_profile_name | The data profile name associated with the incident. | Required |
| snippets | The snippets of the violation. | Optional |
| app_name | The name of the application that performed the activity. | Required |
Context Output
| Path | Type | Description |
|---|---|---|
| DLP.slack_message | string | The Slack bot message. |
pan-dlp-reset-last-run
Deprecated. Reset the “last run” timestamp via the integration instance configuration window.
Base Command
pan-dlp-reset-last-run
Input
There are no input arguments for this command.
Context Output
There is no context output for this command.
Troubleshooting
In case specific DLP incidents are not appearing on the Cortex tenant, verify the following:
- DLP Regions Configuration
- Check the Strata Cloud Manager to confirm which regions generated the incidents.
- Note: The DLP Regions dropdown menu shows all currently-supported regions.
- Ensure all regions where incidents originated are selected from the dropdown menu.
- Strata Cloud Manager (SCM) Channel Support
- Verify the channel in SCM console under the incident details.
- Note: Only incidents from the “NGFW” and “Prisma Access” SCM channels are supported.
- Incidents from other channels, such as Endpoint DLP, will not be fetched by this integration.
Configuration parameters
base_url— Server URLauth_url— Authentication URLcredentials— Access Token (required)insecure— Trust any certificate (not secure)proxy— Use system proxy settingsisFetch— Fetch incidentsincidentType— Incident typeincidentFetchInterval— Incidents Fetch Intervalfirst_fetch— First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)max_fetch— Maximum number of incidents per fetchlook_back— Fetch Lookback Window (minutes)dlp_regions— DLP Regionsdlp_exemptible_list— Data profiles to allow exemptiondlp_slack_message— Bot Message
Commands (5)
-
pan-dlp-exemption-eligibleDetermines whether exemption can be granted on incidents from a certain data profile.
-
pan-dlp-get-reportFetches DLP reports associated with a report ID.
-
pan-dlp-reset-last-runDeprecatedDeprecated. Reset the "last run" timestamp via the integration instance configuration window.
-
pan-dlp-slack-messageGets the Slack bot message to send to the user for gathering feedback.
-
pan-dlp-update-incidentUpdates a DLP incident with user feedback.
import base64 import bz2 import urllib.parse from enum import Enum from string import Template from datetime import UTC import demistomock as demisto import urllib3 from CommonServerPython import * from CommonServerUserPython import * # Disable insecure warnings urllib3.disable_warnings() """ GLOBALS/PARAMS """ MAX_ATTEMPTS = 3 DEFAULT_MAX_FETCH = 50 DEFAULT_BASE_URL = "https://api.dlp.paloaltonetworks.com/v1/" DEFAULT_AUTH_URL = "https://auth.apps.paloaltonetworks.com/auth/v1/oauth2/access_token" REPORT_URL = "public/report/{}" SERVICE_NAME_HEADER = "service-name" INCIDENTS_URL = "public/incident-notifications" REFRESH_TOKEN_URL = "public/oauth/refreshToken" UPDATE_INCIDENT_URL = "public/incident-feedback" SLEEP_TIME_URL = "public/seconds-between-incident-notifications-pull" FETCH_SLEEP = 5 # sleep between fetches (in seconds) LAST_FETCH_TIME = "last_fetch_time" DEFAULT_FIRST_FETCH = "60 minutes" ACCESS_TOKEN = "access_token" RESET_KEY = "reset" CREDENTIAL = "credential" IDENTIFIER = "identifier" PASSWORD = "password" END_TIME_BUFFER = 30 # seconds MAX_API_CALLS_PER_FETCH = 100 # Last run LAST_RUN_KEY = "last_run" START_TIMESTAMP_KEY = "start_timestamp" LAST_IDS_KEY = "last_ids" # Legacy key (list[str]) — kept for migration only LAST_IDS_TIMESTAMPS_KEY = "last_ids_timestamps" # New key: dict[str, int] mapping incident_id → committedAt epoch LOCAL_LAST_RUN: dict[str, Any] = {} # In memory last run object during long running execution class FeedbackStatus(Enum): PENDING_RESPONSE = "PENDING_RESPONSE" CONFIRMED_SENSITIVE = "CONFIRMED_SENSITIVE" CONFIRMED_FALSE_POSITIVE = "CONFIRMED_FALSE_POSITIVE" EXCEPTION_REQUESTED = "EXCEPTION_REQUESTED" OPERATIONAL_ERROR = "OPERATIONAL_ERROR" EXCEPTION_GRANTED = "EXCEPTION_GRANTED" EXCEPTION_NOT_REQUESTED = "EXCEPTION_NOT_REQUESTED" SEND_NOTIFICATION_FAILURE = "SEND_NOTIFICATION_FAILURE" EXCEPTION_DENIED = "EXCEPTION_DENIED" class Client(BaseClient): def __init__(self, base_url: str, auth_url: str, credentials, verify: bool, proxy: bool): super().__init__(base_url=base_url, headers=None, verify=verify, proxy=proxy) self.credentials = credentials self.auth_url = auth_url credential_name = credentials[CREDENTIAL] if not credential_name: self.access_token = credentials[IDENTIFIER] self.refresh_token = credentials[PASSWORD] else: self.access_token = "" self._refresh_token_with_client_credentials() def _refresh_token(self): """Refreshes Access Token""" headers = {"Authorization": "Bearer " + self.access_token, "Content-Type": "application/json"} params = {"refresh_token": self.refresh_token} print_debug_msg(f"Calling endpoint {self._base_url}{REFRESH_TOKEN_URL}") try: r = self._http_request( method="POST", headers=headers, url_suffix=REFRESH_TOKEN_URL, json_data=params, ok_codes=[200, 201, 204] ) new_token = r.get("access_token") if new_token: self.access_token = new_token except Exception as e: print_debug_msg(str(e)) raise def _refresh_token_with_client_credentials(self): client_id = self.credentials[IDENTIFIER] client_secret = self.credentials[PASSWORD] credentials = f"{client_id}:{client_secret}" auth_header = f"Basic {b64_encode(credentials)}" headers = {"Authorization": auth_header, "Content-Type": "application/x-www-form-urlencoded"} payload = "grant_type=client_credentials" try: r = self._http_request(full_url=self.auth_url, method="POST", headers=headers, data=payload, ok_codes=[200, 201, 204]) new_token = r.get("access_token") if new_token: self.access_token = new_token except Exception as e: print_debug_msg(str(e)) raise def _handle_4xx_errors(self, res): """ Handles 4xx exception on get-dlp-report and tries to refresh token Args: res: Response of DLP API call """ if res.status_code < 400 or res.status_code >= 500: return try: print_debug_msg(f"Got {res.status_code}, attempting to refresh access token") if self.credentials[CREDENTIAL]: print_debug_msg("Requesting access token with client id/client secret") self._refresh_token_with_client_credentials() else: print_debug_msg("Requesting new access token with old access token/refresh token") self._refresh_token() except Exception: pass def _get_dlp_api_call(self, url_suffix: str, extra_headers: dict[str, str] | None = None) -> tuple[dict[str, Any], int]: """ Makes a HTTPS Get call on the DLP API Args: url_suffix: URL suffix for dlp api call extra_headers: Optional additional request headers """ count = 0 print_debug_msg(f"Calling GET method on {self._base_url}{url_suffix}") while count < MAX_ATTEMPTS: headers = {"Authorization": "Bearer " + self.access_token} if extra_headers: headers.update(extra_headers) res = self._http_request( method="GET", headers=headers, url_suffix=url_suffix, ok_codes=[200, 201, 204], error_handler=self._handle_4xx_errors, resp_type="", return_empty_response=True, ) if res.status_code < 400 or res.status_code >= 500: break count += 1 result_json = {} if res.status_code != 204: try: result_json = res.json() # when installing simplejson the type of exception is requests.exceptions.JSONDecodeError except (json.decoder.JSONDecodeError, requests.exceptions.JSONDecodeError): result_json = {} return result_json, res.status_code def _post_dlp_api_call(self, url_suffix: str, payload: dict = None): """ Makes a POST HTTP(s) call to the DLP API Args: url_suffix: URL suffix for dlp api call payload: Optional JSON payload """ count = 0 while count < MAX_ATTEMPTS: res = self._http_request( method="POST", headers={"Authorization": f"Bearer {self.access_token}"}, url_suffix=url_suffix, json_data=payload, ok_codes=[200, 201, 204], error_handler=self._handle_4xx_errors, resp_type="response", return_empty_response=True, ) if res.status_code < 400 or res.status_code >= 500: break count += 1 result_json = {} if res.status_code != 204: try: result_json = res.json() # when installing simplejson the type of exception is requests.exceptions.JSONDecodeError except (json.decoder.JSONDecodeError, requests.exceptions.JSONDecodeError): result_json = {} return result_json, res.status_code def set_access_token(self, access_token): self.access_token = access_token def get_dlp_report(self, report_id: str, fetch_snippets=False, service_name: str | None = None): """ Fetches DLP reports Args: report_id: Report ID to fetch from DLP service fetch_snippets: if True, fetches the snippets service_name: Optional DLP service the report belongs to. When omitted, the service defaults to Prisma Access on the server side. Returns: DLP Report json """ url = REPORT_URL.format(report_id) if fetch_snippets: url = url + "?fetchSnippets=true" extra_headers = {SERVICE_NAME_HEADER: service_name} if service_name else None return self._get_dlp_api_call(url, extra_headers) def get_dlp_incidents( self, regions: str, start_time: int | None = None, end_time: int | None = None, ) -> tuple[dict[str, Any], int]: url = INCIDENTS_URL params = {} if regions: params["regions"] = regions if start_time: params["start_timestamp"] = str(start_time) if end_time: params["end_timestamp"] = str(end_time) query_string = urllib.parse.urlencode(params) url = f"{url}?{query_string}" resp, status_code = self._get_dlp_api_call(url) return resp, status_code def update_dlp_incident( self, incident_id: str, feedback: FeedbackStatus, user_id: str, region: str, report_id: str, dlp_channel: str, error_details: str = None, ): """ Update Incident with user provided feedback Args: incident_id: The id of the incident to update feedback: 'business_justified', 'true_positive' or 'false_positive' user_id: The user that initiated the request region: The DLP region report_id: The report ID for the incident dlp_channel: The DLP channel (service name) error_details: The error details if there is an error Returns: DLP Incident json """ payload = {"user_id": user_id, "report_id": report_id, "service_name": dlp_channel} if error_details: payload["error_details"] = error_details url = f"{UPDATE_INCIDENT_URL}/{incident_id}?feedback_type={feedback.value}®ion={region}" return self._post_dlp_api_call(url, payload) def query_for_sleep_time(self): resp, status = self._get_dlp_api_call(SLEEP_TIME_URL) return resp def parse_data_pattern_rule(report_json, verdict_field, results_field): """ Parses data pattern matches from a given rule in DLP report JSON Args: report_json: DLP report json verdict_field: Name of the verdict field results_field: Name of the result field Returns: data pattern matches for the given rule """ if report_json.get(verdict_field) != "MATCHED": return [] data_patterns = [] for dp in report_json.get("scanContentRawReport", {}).get(results_field, []): if (dp.get("state") == "EVALUATED") and (dp.get("unique_detection_frequency", 0) >= 1): data_patterns.append( { "DataPatternName": dp.get("name"), "LowConfidenceFrequency": dp.get("low_confidence_frequency"), "HighConfidenceFrequency": dp.get("high_confidence_frequency"), "MediumConfidenceFrequency": dp.get("medium_confidence_frequency"), "MatchedConfidenceLevel": dp.get("matched_confidence_level"), "Detections": dp.get("detections"), } ) return data_patterns def parse_data_profiles(report_json: dict) -> list: """ Parses the data_profiles array from the DLP report JSON. Args: report_json: DLP report JSON Returns: List of parsed data profile dicts with CamelCase keys """ profiles = [] data_profiles = report_json.get("data_profiles") or [] for profile in data_profiles: parsed_patterns = [] data_patterns = profile.get("data_patterns") or [] for pattern in data_patterns: parsed_patterns.append( { "Id": pattern.get("id"), "IsMatched": pattern.get("is_matched"), "ConfidenceLevel": pattern.get("confidence_level"), "OccurrenceCount": pattern.get("occurrence_count"), "OccurrenceOperatorType": pattern.get("occurrence_operator_type"), "OccurrenceLow": pattern.get("occurrence_low"), "OccurrenceHigh": pattern.get("occurrence_high"), } ) profiles.append( { "Name": profile.get("name"), "Id": profile.get("id"), "Version": profile.get("version"), "IsTriggered": profile.get("is_triggered"), "DataPatterns": parsed_patterns, } ) return profiles def parse_data_patterns(report_json): """ Parse data pattern matches from the raw report Args: report_json: DLP report JSON Returns: Data pattern matches """ data_patterns = [] data_patterns.extend(parse_data_pattern_rule(report_json, "data_pattern_rule_1_verdict", "data_pattern_rule_1_results")) data_patterns.extend(parse_data_pattern_rule(report_json, "data_pattern_rule_2_verdict", "data_pattern_rule_2_results")) data_profiles = parse_data_profiles(report_json) result: dict = {"DataProfile": report_json.get("data_profile_name"), "DataPatternMatches": data_patterns} if data_profiles: result["DataProfiles"] = data_profiles return result def convert_to_human_readable(data_patterns): """ Converts the results for human readable format Args: data_patterns: Data Pattern matches Returns: Human Readable Format result """ matches: list = [] if not data_patterns: return matches headers = ["DataPatternName", "ConfidenceFrequency", "MatchedConfidenceLevel"] for k in data_patterns.get("DataPatternMatches", []): match = { "DataPatternName": k.get("DataPatternName"), "ConfidenceFrequency": { "Low": k.get("LowConfidenceFrequency"), "Medium": k.get("MediumConfidenceFrequency"), "High": k.get("HighConfidenceFrequency"), }, "MatchedConfidenceLevel": k.get("MatchedConfidenceLevel"), } index = 1 detections = k.get("Detections", []) if detections: for detection in detections: col = f"Detection {index}" if col not in headers: headers.append(col) match[col] = detection index += 1 matches.append(match) title = "DLP Report for profile: {}".format(data_patterns.get("DataProfile")) return tableToMarkdown(title, matches, headers) def parse_dlp_report(report_json) -> CommandResults: """ Parses DLP Report for display Args: report_json: DLP report json Returns: DLP report results """ data_patterns = parse_data_patterns(report_json) return CommandResults( outputs_prefix="DLP.Report", outputs_key_field="DataPatternName", outputs=data_patterns, readable_output=convert_to_human_readable(data_patterns), raw_response=report_json, ) def get_dlp_report_command(client: Client, args: dict) -> CommandResults: """ Retrieves a DLP report and parses it for display. Args: client: DLP client args: Command arguments Returns: DLP report results """ report_id = args.get("report_id", "") fetch_snippets = argToBoolean(args.get("fetch_snippets")) service_name = args.get("service_name") report_json, _ = client.get_dlp_report(report_id, fetch_snippets, service_name) return parse_dlp_report(report_json) def test(client: Client, params: dict): """Test Function to test validity of access and refresh tokens""" dlp_regions = params.get("dlp_regions", "") report_json, status_code = client.get_dlp_incidents(regions=dlp_regions) if status_code in [200, 204]: return_results("ok") else: message = f"Integration test failed: Unexpected status ({status_code}) - " if "error" in report_json: message += f"Error message: \"{report_json.get('error')}\"" else: message += "Could not determine the error reason. Make sure the DLP Regions parameter is configured correctly." raise DemistoException(message) def print_debug_msg(msg: str): """ Prints a message to debug with PAN-DLP-Msg prefix. Args: msg (str): Message to be logged. """ demisto.debug(f"PAN-DLP-Msg - {msg}") def update_incident_command(client: Client, args: dict) -> CommandResults: incident_id = args.get("incident_id", "") feedback = args.get("feedback", "") user_id = args.get("user_id", "") region = args.get("region", "") report_id = args.get("report_id", "") dlp_channel = args.get("dlp_channel", "") error_details = args.get("error_details") feedback_enum = FeedbackStatus[feedback.upper()] result_json, status = client.update_dlp_incident( incident_id, feedback_enum, user_id, region, report_id, dlp_channel, error_details ) output = {"feedback": feedback_enum.value, "success": status == 200} if feedback_enum == FeedbackStatus.EXCEPTION_GRANTED: minutes = result_json["expiration_duration_in_minutes"] if minutes and minutes < 60: output["duration"] = f"{minutes} minutes" elif minutes: output["duration"] = f"{minutes / 60} hours" result = CommandResults(outputs_prefix="Exemption", outputs_key_field="duration", outputs=output) else: result = CommandResults(outputs_prefix="IncidentUpdate", outputs_key_field="feedback", outputs=output) return result def parse_incident_details(compressed_details: str): details_byte_data = bz2.decompress(base64.b64decode(compressed_details)) details_string = details_byte_data.decode("utf-8") details_obj = json.loads(details_string) demisto.debug(f"Parsed incident details: {details_obj}.") return details_obj def create_incident(notification: dict, region: str, incident_type: str = "Data Loss Prevention") -> dict[str, Any]: """ Create an XSOAR incident from a DLP notification. Args: notification: DLP notification containing incident data and previous notifications region: DLP region where the incident occurred incident_type: Type of incident to create (default: "Data Loss Prevention") Returns: dict[str, Any]: XSOAR incident object with name, type, occurred time, and raw JSON data """ raw_incident = notification["incident"] previous_notifications = notification["previous_notifications"] raw_incident["region"] = region raw_incident["previousNotification"] = previous_notifications[0] if len(previous_notifications) > 0 else None parsed_details = parse_incident_details(raw_incident["incidentDetails"]) raw_incident["incidentDetails"] = parsed_details if not raw_incident.get("userId"): for header in parsed_details.get("headers", []): attribute_name = header.get("attribute_name") attribute_value = header.get("attribute_value") if attribute_name == "username" and attribute_value: raw_incident["userId"] = attribute_value incident_creation_time = cast(datetime, dateparser.parse(raw_incident["createdAt"])) incident_id = raw_incident["incidentId"] incident_timestamp = int(incident_creation_time.timestamp()) demisto.debug(f"Creating new incident with {incident_id=} and {incident_timestamp=} in {region=}.") event_dump = json.dumps(raw_incident) return { "name": f"Palo Alto Networks DLP Incident {incident_id}", "type": incident_type, "occurred": incident_creation_time.isoformat(), "rawJSON": event_dump, "details": event_dump, } def compute_next_run( incident_ids_committed_timestamps: dict[str, int], last_run: dict[str, Any], has_new_incidents: bool, last_queried_end_time: int, look_back_minutes: int = 0, ) -> dict[str, Any]: """ Compute the next run state based on fetched incidents using their committed timestamps. Retains incident IDs within the lookback retention window `[max_ts - (look_back_minutes * 60 + END_TIME_BUFFER), max_ts]` so that the next fetch can deduplicate incidents re-queried due to lookback. When no new incidents were fetched, advances `start_timestamp` to `last_queried_end_time` so the query window always slides forward and never grows unboundedly. Args: incident_ids_committed_timestamps (dict[str, int]): Mapping of incident ID → committedAt epoch timestamp (seconds). Must include carry-over IDs from the previous last run. last_run (dict[str, Any]): Previous last run state. has_new_incidents (bool): Whether any new (non-duplicate) incidents were fetched. last_queried_end_time (int): The end_time of the last queried interval. Used to advance start_timestamp when no new incidents are found. look_back_minutes (int): Minutes of lookback configured for the integration. Determines how wide the ID retention window is. Defaults to 0. Returns: dict[str, Any]: Next run state with `start_timestamp` and `last_ids_timestamps`. """ if not has_new_incidents: demisto.debug( f"No new incidents were fetched. Advancing last run {START_TIMESTAMP_KEY} to {last_queried_end_time=} " "to slide the query window forward." ) return {**last_run, START_TIMESTAMP_KEY: last_queried_end_time} new_last_committed_timestamp = max(incident_ids_committed_timestamps.values()) # Retain IDs within (look_back_minutes * 60 + END_TIME_BUFFER) seconds of the latest timestamp # so they are available for deduplication on the next fetch that re-queries the lookback window. retention_cutoff = new_last_committed_timestamp - (look_back_minutes * 60 + END_TIME_BUFFER) demisto.debug(f"Computing next run: {new_last_committed_timestamp=}, {look_back_minutes=}, {retention_cutoff=}.") new_last_ids_timestamps: dict[str, int] = { _id: ts for _id, ts in incident_ids_committed_timestamps.items() if ts >= retention_cutoff } demisto.debug(f"Retaining {len(new_last_ids_timestamps)} incident IDs in last run for deduplication.") return {START_TIMESTAMP_KEY: new_last_committed_timestamp, LAST_IDS_TIMESTAMPS_KEY: new_last_ids_timestamps} def get_start_end_time_intervals(start: int, end: int, seconds_delta: int) -> list[tuple[int, int]]: """ Generate a list of time interval tuples from start to end timestamp. Args: start (int): Starting epoch timestamp in seconds end (int): Ending epoch timestamp in seconds seconds_delta (int): The delta in seconds for each interval Returns: A list of tuples where each tuple contains (interval_start, interval_end) Example: >>> get_start_end_time_intervals(0, 900, 300) [(0, 300), (300, 600), (600, 900)] """ intervals: list[tuple[int, int]] = [] current = start while current < end: next_timestamp = min(current + seconds_delta, end) intervals.append((current, next_timestamp)) current = next_timestamp return intervals def _migrate_last_run(last_run: dict[str, Any], start_timestamp: int) -> dict[str, int]: """ Migrate the legacy `last_ids` list schema to the new `last_ids_timestamps` dict schema. Legacy IDs are seeded with `start_timestamp` as a conservative deduplication baseline. Args: last_run (dict[str, Any]): Raw last run object from `demisto.getLastRun()`. start_timestamp (int): Epoch timestamp (seconds) to assign to each migrated ID. Returns: dict[str, int]: Mapping of incident_id → committedAt epoch timestamp. """ if LAST_IDS_TIMESTAMPS_KEY in last_run: return dict(last_run[LAST_IDS_TIMESTAMPS_KEY]) # Legacy schema: plain list of IDs — migrate by seeding with start_timestamp legacy_ids: list[str] = last_run.get(LAST_IDS_KEY) or [] if legacy_ids: demisto.debug( f"Migrating {len(legacy_ids)} legacy incident IDs from '{LAST_IDS_KEY}' " f"to '{LAST_IDS_TIMESTAMPS_KEY}' schema, seeding with {start_timestamp=}." ) return {incident_id: start_timestamp for incident_id in legacy_ids} def fetch_notifications( client: Client, regions: str, first_fetch_timestamp: int, incident_type: str = "Data Loss Prevention", max_fetch: int = DEFAULT_MAX_FETCH, look_back_minutes: int = 0, ) -> tuple[dict, list[dict]]: """ Fetch DLP notifications using time-based queries with ID-based deduplication and optional lookback. Args: client (Client): DLP API client. regions (str): Comma-separated DLP regions to fetch from. first_fetch_timestamp (int): Timestamp to use for first fetch (unix epoch seconds). incident_type (str): Type of incident to create (default: "Data Loss Prevention"). max_fetch (int): Maximum number of incidents to fetch (default: DEFAULT_MAX_FETCH). look_back_minutes (int): Minutes to look back from the last committed timestamp to catch late-indexed incidents. Defaults to 0 (no lookback). Returns: tuple[dict, list[dict]]: Next run state and list of new incidents. """ integration_context = demisto.getIntegrationContext() access_token = integration_context.get(ACCESS_TOKEN) if access_token: client.set_access_token(access_token) last_run = demisto.getLastRun() or {} # May return as "None" on the first fetch demisto.debug(f"Got {last_run=}.") start_timestamp = last_run.get(START_TIMESTAMP_KEY) or first_fetch_timestamp # Apply lookback: re-query from (start_timestamp - look_back_minutes) to catch late-indexed incidents effective_start_timestamp = start_timestamp - look_back_minutes * 60 demisto.debug(f"Lookback applied: {look_back_minutes=}, {start_timestamp=}, {effective_start_timestamp=}.") # Provide buffer to account for minor indexing delays end_timestamp = int(datetime.now(tz=UTC).timestamp()) - END_TIME_BUFFER # Migrate legacy schema and seed the deduplication accumulator with previously seen IDs fetched_incident_ids_committed_timestamps: dict[str, int] = _migrate_last_run(last_run, start_timestamp) demisto.debug(f"Starting to fetch incidents using {max_fetch=} between {effective_start_timestamp=} and {end_timestamp=}.") demisto.debug( f"Deduplicating using {len(fetched_incident_ids_committed_timestamps)} IDs: " f"{list(fetched_incident_ids_committed_timestamps.keys())}." ) new_incidents: list[dict] = [] last_queried_end_time: int = effective_start_timestamp # Query the API in 3 minute start/end time window, this filters incidents according to their "committedAt" timestamps start_end_time_intervals = get_start_end_time_intervals(effective_start_timestamp, end_timestamp, seconds_delta=180) for api_call_number, (start_time, end_time) in enumerate(start_end_time_intervals, start=1): if len(new_incidents) >= max_fetch: demisto.debug(f"Reached or exceeded fetch limit. Fetched {len(new_incidents)} incidents. Breaking...") break if api_call_number > MAX_API_CALLS_PER_FETCH: demisto.debug(f"Reached or exceeded maximum number of API calls per fetch. Fetched {len(new_incidents)} incidents. ") break demisto.debug(f"Getting incidents between {start_time=} and {end_time=} from {regions=}.") notification_map, _ = client.get_dlp_incidents(regions, start_time, end_time) last_queried_end_time = end_time notifications = [ {**raw_notification, "region": region} for region, raw_notifications in notification_map.items() for raw_notification in raw_notifications ] demisto.debug(f"Received {len(notifications)} notifications between {start_time=} and {end_time=}.") notifications.sort(key=lambda x: x["incident"]["committedAt"]) for notification in notifications: # Use "incidentId" and "committedAt" fields for deduplication and last run tracking # These are required fields that are guaranteed to exist for each DLP incident region = notification["region"] incident_id = notification["incident"]["incidentId"] incident_committed_timestamp = int(dateparser.parse(notification["incident"]["committedAt"]).timestamp()) # type: ignore if incident_id in fetched_incident_ids_committed_timestamps: demisto.debug(f"Skipping duplicate {incident_id=} with {incident_committed_timestamp=} in {region=}.") continue if len(new_incidents) >= max_fetch: demisto.debug(f"Reached or exceeded fetch limit. Fetched {len(new_incidents)} incidents. Breaking...") break incident = create_incident(notification, region, incident_type) new_incidents.append(incident) fetched_incident_ids_committed_timestamps[incident_id] = incident_committed_timestamp demisto.debug(f"Finished fetching incidents using {max_fetch=} between {effective_start_timestamp=} and {end_timestamp=}.") demisto.debug(f"Fetched {len(new_incidents)} deduplicated incidents: {[inc.get('name') for inc in new_incidents]}.") demisto.debug("Updating integration context with access token.") demisto.setIntegrationContext({ACCESS_TOKEN: client.access_token}) next_run = compute_next_run( fetched_incident_ids_committed_timestamps, last_run=last_run, look_back_minutes=look_back_minutes, has_new_incidents=bool(new_incidents), last_queried_end_time=last_queried_end_time, ) demisto.debug(f"Computed updated {next_run=}.") return next_run, new_incidents def fetch_incidents(client: Client, params: dict) -> tuple[dict, list[dict]]: """ Fetch incidents from Palo Alto Networks Enterprise DLP using time-based queries with deduplication. Args: client (Client): DLP API client instance. params (dict): Integration instance configuration parameters. Returns: tuple[dict, list[dict]]: Next run state and list of fetched incidents. """ regions = params.get("dlp_regions", "") incident_type = params.get("incidentType", "Data Loss Prevention") first_fetch = params.get("first_fetch") or DEFAULT_FIRST_FETCH first_fetch_datetime = arg_to_datetime(first_fetch, settings={"TIMEZONE": "UTC"}) first_fetch_timestamp = int(first_fetch_datetime.timestamp()) # type: ignore max_fetch = arg_to_number(params.get("max_fetch")) or DEFAULT_MAX_FETCH look_back_minutes = arg_to_number(params.get("look_back")) or 0 return fetch_notifications( client=client, regions=regions, first_fetch_timestamp=first_fetch_timestamp, incident_type=incident_type, max_fetch=max_fetch, look_back_minutes=look_back_minutes, ) def exemption_eligible_command(args: dict, params: dict) -> CommandResults: data_profile = args.get("data_profile") eligible_list = params.get("dlp_exemptible_list", "") if eligible_list == "*": eligible = True else: eligible = data_profile in eligible_list result = {"eligible": eligible} return CommandResults(outputs_prefix="DLP.exemption", outputs_key_field="eligible", outputs=result) def slack_bot_message_command(args: dict, params: dict): message_template = params.get("dlp_slack_message", "") template = Template(message_template) message = template.substitute( user=args.get("user"), file_name=args.get("file_name"), data_profile_name=args.get("data_profile_name"), app_name=args.get("app_name"), snippets=args.get("snippets", ""), ) result = {"message": message} return CommandResults(outputs_prefix="DLP.slack_message", outputs_key_field="slack_message", outputs=result) def reset_last_run_command() -> CommandResults: """ Deprecated command to reset flag inside integration context. Returns: CommandResults: Contains a human-readable message. """ return CommandResults( readable_output="This command is deprecated." 'Reset the "last run" timestamp via the integration instance configuration window.', entry_type=EntryType.WARNING, ) def main(): """Main Function""" params = demisto.params() args = demisto.args() command = demisto.command() try: print_debug_msg(f'Received parameters: {",".join(params.keys())}.') credentials = params.get("credentials", {}) base_url = params.get("base_url") or DEFAULT_BASE_URL auth_url = params.get("auth_url") or DEFAULT_AUTH_URL verify = not params.get("insecure", True) proxy = params.get("proxy", False) demisto.info(f"Command being called is {command}.") client = Client(base_url, auth_url, credentials, verify, proxy) if command == "pan-dlp-get-report": return_results(get_dlp_report_command(client, args)) elif command == "fetch-incidents": next_run, new_incidents = fetch_incidents(client, params) demisto.incidents(new_incidents) demisto.setLastRun(next_run) elif command == "pan-dlp-update-incident": return_results(update_incident_command(client, args)) elif command == "pan-dlp-exemption-eligible": return_results(exemption_eligible_command(args, params)) elif command == "pan-dlp-slack-message": return_results(slack_bot_message_command(args, params)) elif command == "pan-dlp-reset-last-run": return_results(reset_last_run_command()) elif command == "test-module": test(client, params) else: raise NotImplementedError(f"Unknown command {command}.") except Exception as e: return_error(f"Failed to execute {command} command.\nError:\n{e!s}") if __name__ in ["__builtin__", "builtins", "__main__"]: main()