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

IDPalo Alto Networks Enterprise DLP
ProviderPalo Alto Networks
CategoryNetwork Security
From Version5.0.0
Docker Imagedemisto/python3:3.12.13.10404775
Supported ModulesAgentix 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: 30
high
Credit Card Number Low: 30
Medium: 30
High: 30
high

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:

  1. 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.
  2. 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 URL
  • auth_url — Authentication URL
  • credentials — Access Token (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetch — Fetch incidents
  • incidentType — Incident type
  • incidentFetchInterval — Incidents Fetch Interval
  • first_fetch — First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)
  • max_fetch — Maximum number of incidents per fetch
  • look_back — Fetch Lookback Window (minutes)
  • dlp_regions — DLP Regions
  • dlp_exemptible_list — Data profiles to allow exemption
  • dlp_slack_message — Bot Message

Commands (5)

  • pan-dlp-exemption-eligible

    Determines whether exemption can be granted on incidents from a certain data profile.

  • pan-dlp-get-report

    Fetches DLP reports associated with a report ID.

  • pan-dlp-reset-last-run Deprecated

    Deprecated. Reset the "last run" timestamp via the integration instance configuration window.

  • pan-dlp-slack-message

    Gets the Slack bot message to send to the user for gathering feedback.

  • pan-dlp-update-incident

    Updates 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}&region={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()