ZscalerZIdentity

Zscaler Internet Access via ZIdentity OAuth 2.0. Provides URL/IP/domain classification, denylist and allowlist management, URL category management, sandbox reporting, user and group management, and IP destination group management using OAuth 2.0 client credentials authentication through ZIdentity.

Network Security · Zscaler Internet Access

Details

IDZscalerZIdentity
ProviderZscaler
CategoryNetwork Security
From Version6.10.0
Docker Imagedemisto/python3:3.12.14.12343672
Supported ModulesAgentix XSIAM EDR Cortex Cloud Cloud Runtime Security

README

This integration uses ZIdentity OAuth 2.0 client credentials to authenticate with Zscaler Internet Access (ZIA). It enables the management of denylists, allowlists, URL categories, IP destination groups, and users/groups, while also providing URL, IP, and domain classifications and sandbox reporting..

Configure Zscaler Internet Access via ZIdentity in Cortex

Parameter Description Required
Server URL The Server URL assigned to your organization. For example, www.acme.zslogin.net. True
Client ID The OAuth 2.0 client ID from ZIdentity. True
Client Secret The OAuth 2.0 client secret from ZIdentity. True
Auto Activate Changes If enabled, the integration will activate the command changes after each execution. If disabled, use the ‘zia-activate-changes’ command to activate Zscaler command changes. False
Source Reliability Reliability of the source providing the intelligence data. True
Suspicious URL categories Suspicious URL categories for security alerts. Default: SUSPICIOUS_DESTINATION, SPYWARE_OR_ADWARE. False
Trust any certificate (not secure)   False
Use system proxy settings   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.

zia-denylist-list


Gets a list of URLs and IPs that are in the denylist.

Base Command

zia-denylist-list

Input

Argument Name Description Required
filter Filter results by URL or IP objects. Possible values are: url, ip. Optional
query Query to match against (Python regular expressions, for example, 8...8). Optional
limit The number of items to return. Default is 50. Optional
all_results Whether to retrieve all results at once. Default is False. Optional

Context Output

Path Type Description
ZIA.DenyList String The list of URLs and IPs on the denylist.

Command Example

!zia-denylist-list filter=url limit=10

Human Readable Output

Denylist

URL
malware.com
phishing.net

zia-denylist-update


Updates the list of URLs in the denylist.

Base Command

zia-denylist-update

Input

Argument Name Description Required
url A comma-separated list of URLs to update the denylist. For example, snapchat.com,facebook.com. Optional
ip Deprecated. The Zscaler blacklistUrls field only supports URLs. Use the url argument instead. Optional
action The action applied to the denylist. Possible values are: ADD_TO_LIST, REMOVE_FROM_LIST, OVERWRITE. Required

Context Output

There is no context output for this command.

Command Example

!zia-denylist-update url=malware.com,phishing.net action=ADD_TO_LIST

Human Readable Output

Denylist updated successfully.

zia-allowlist-list


Gets a list of URLs and IPs that are in the allowlist.

Base Command

zia-allowlist-list

Input

Argument Name Description Required
filter Filter results by URL or IP objects. Possible values are: url, ip. Optional
query Query to match against (Python regular expressions, for example, 8...8). Optional
limit The number of items to return. Default is 50. Optional
all_results Whether to retrieve all results at once. Default is False. Optional

Context Output

Path Type Description
ZIA.AllowList String The list of URLs in the allowlist.

Command Example

!zia-allowlist-list filter=url limit=10

Human Readable Output

Allowlist

URL
trusted.com
safe.net

zia-allowlist-update


Updates the list of URLs in the allowlist.

Base Command

zia-allowlist-update

Input

Argument Name Description Required
url A comma-separated list of URLs to update in the allowlist. For example, snapchat.com,facebook.com. Optional
ip Deprecated. The Zscaler whitelistUrls field only supports URLs. Use the url argument instead. Optional
action The action applied to the allowlist. Possible values are: ADD_TO_LIST, REMOVE_FROM_LIST, OVERWRITE. Required

Context Output

There is no context output for this command.

Command Example

!zia-allowlist-update url=trusted.com action=ADD_TO_LIST

Human Readable Output

Allowlist updated successfully.

zia-category-list


Gets information about all or custom URL categories. By default, the response includes keywords. The lite option cannot be used in combination with other parameters.

Base Command

zia-category-list

Input

Argument Name Description Required
category_id The URL category for the specified ID. For more information about category ID values, see the Zscaler documentation. Optional
custom_only If set to true, gets information on custom URL categories only. Default is false. Optional
include_only_url_keyword_counts If set to true, the response only includes URL and keyword counts. Default is false. Optional
lite Whether to get a lightweight key-value list of all or custom URL categories. Cannot be used with other parameters. Default is false. Optional
limit The number of items to return. Default is 50. Optional
all_results Whether to retrieve all results at once. Default is False. Optional
display_url Whether to display the URLs of each category in the War Room. URLs will always be returned to the Context Data. Default is false. Optional

Context Output

Path Type Description
ZIA.Category.id String The ID of the category.
ZIA.Category.configuredName String The configured name of the category.
ZIA.Category.superCategory String The super category of the category.
ZIA.Category.keywords String The keywords associated with the category.
ZIA.Category.urls String The URLs in the category.
ZIA.Category.customCategory Boolean Whether the category is a custom category.

Command Example

!zia-category-list custom_only=true

Context Example

{
    "ZIA": {
        "Category": [
            {
                "id": "CUSTOM_01",
                "configuredName": "My Custom Category",
                "superCategory": "USER_DEFINED",
                "keywords": ["example"],
                "urls": ["example.com"],
                "customCategory": true
            }
        ]
    }
}

Human Readable Output

URL Categories

ID Configured Name Super Category Custom Category
CUSTOM_01 My Custom Category USER_DEFINED true

zia-category-update


Updates the URL category for the specified ID.

Base Command

zia-category-update

Input

Argument Name Description Required
category_id The URL category for the specified ID. For more information about category ID values, see the Zscaler documentation. Required
url A comma-separated list of URLs to update in the specified category. For example, pandora.com,spotify.com. Important: If any URL contains a comma (,), you must pass the url argument as a JSON list wrapped in backticks (`). Example: url=`[“https://example.com/foo,bar”]`. Optional
ip A comma-separated list of custom IP address ranges to update in the specified category. Values must be in CIDR notation. For example, 1.2.3.4/32,8.8.8.8/32. Up to 2000 custom IP address ranges and retaining parent custom IP address ranges can be added, per organization, across all categories. Note: This field is available only if the option to configure custom IP ranges is enabled for your organization. To enable this option, contact Zscaler Support. Optional
action The action applied to the URL category. Possible values are: ADD_TO_LIST, REMOVE_FROM_LIST, OVERWRITE. Required
keywords Custom keywords associated with a URL category. Up to 2048 custom keywords can be added per organization across all categories. Optional
description Description of the URL category. Contains tag name and needs to be localized on client side in case of predefined category. Optional
db_categorized_urls URLs added to a custom URL category that are also retained under the original parent URL category. Optional
keywords_retaining_parent_category Retained custom keywords from the parent URL category. Up to 2048 retained parent keywords can be added per organization across all categories. Optional
ip_ranges_retaining_parent_category The retaining parent custom IP address ranges associated with a URL category. Up to 2000 custom IP ranges and retaining parent custom IP address ranges can be added, per organization, across all categories. Optional

Context Output

There is no context output for this command.

Command Example

!zia-category-update category_id=CUSTOM_01 url=pandora.com,spotify.com action=ADD_TO_LIST

Human Readable Output

Category CUSTOM_01 updated successfully.

zia-url-quota-get


Gets information on the number of unique URLs that are currently provisioned for your organization as well as how many URLs you can add before reaching that number.

Base Command

zia-url-quota-get

Input

There are no input arguments for this command.

Context Output

Path Type Description
ZIA.UrlQuota.uniqueUrlsProvisioned Number The number of unique URLs that are currently provisioned for your organization.
ZIA.UrlQuota.remainingUrlsQuota Number The number of URLs you can add before reaching the quota.

Command Example


#### Context Example

```json
{
    "ZIA": {
        "UrlQuota": {
            "uniqueUrlsProvisioned": 25000,
            "remainingUrlsQuota": 24850
        }
    }
}

Human Readable Output

URL Quota

Unique URLs Provisioned Remaining URLs Quota
25000 24850

zia-ip-destination-group-list


Gets a list of all IP destination groups or for the specified ID.

Base Command

zia-ip-destination-group-list

Input

Argument Name Description Required
group_id The unique identifier for the IP destination group. Optional
include_ipv6 Whether to retrieve IPv6 destination groups. Default is False. Optional
exclude_type Filter based on the IP destination group’s type. Possible values are: DSTN_IP, DSTN_FQDN, DSTN_DOMAIN, DSTN_OTHER. Optional
category_type Filter based on the IP destination group’s type. Possible values are: DSTN_IP, DSTN_FQDN, DSTN_DOMAIN, DSTN_OTHER. Optional
lite Gets a lightweight dictionary (name and ID) of all IP destination groups. Default is False. Optional
limit The number of items to return. Default is 50. Optional
all_results Whether to retrieve all results at once. Default is False. Optional

Context Output

Path Type Description
ZIA.IPDestinationGroup.id Number Unique identifier for the destination IP group.
ZIA.IPDestinationGroup.name String Destination IP group name.
ZIA.IPDestinationGroup.type String Destination IP group type.
ZIA.IPDestinationGroup.addresses String Destination IP addresses, FQDNs, or wildcard FQDNs added to the group.
ZIA.IPDestinationGroup.description String Additional information about the destination IP group.
ZIA.IPDestinationGroup.countries String Destination IP address countries.
ZIA.IPDestinationGroup.ipCategories String Destination IP address URL categories.

Command Example

!zia-ip-destination-group-list limit=5

Context Example

{
    "ZIA": {
        "IPDestinationGroup": [
            {
                "id": 1234,
                "name": "My IP Group",
                "type": "DSTN_IP",
                "addresses": ["8.8.8.8", "1.1.1.1"],
                "description": "DNS servers",
                "countries": [],
                "ipCategories": []
            }
        ]
    }
}

Human Readable Output

IP Destination Groups

ID Name Type Addresses Description
1234 My IP Group DSTN_IP 8.8.8.8, 1.1.1.1 DNS servers

zia-ip-destination-group-update


Updates an existing IP destination group.

Base Command

zia-ip-destination-group-update

Input

Argument Name Description Required
group_id The unique identifier for the IP destination group. Required
group_name Destination IP group name. Optional
group_type Destination IP group type. Possible values are: DSTN_IP, DSTN_FQDN, DSTN_DOMAIN, DSTN_OTHER. Optional
address Destination IP addresses. Optional
action The action applied to the addresses list. Possible values are: ADD_TO_LIST, REMOVE_FROM_LIST, OVERWRITE. Required
description Additional information about the destination IP group. Optional
ip_category Destination IP address URL categories. Possible values can be found here. Optional
country Destination IP address countries. Possible values can be found here. Optional

Context Output

Path Type Description
ZIA.IPDestinationGroup.id Number Unique identifier for the destination IP group.
ZIA.IPDestinationGroup.name String Destination IP group name.
ZIA.IPDestinationGroup.type String Destination IP group type.
ZIA.IPDestinationGroup.addresses String Destination IP addresses, FQDNs, or wildcard FQDNs added to the group.
ZIA.IPDestinationGroup.description String Additional information about the destination IP group.
ZIA.IPDestinationGroup.countries String Destination IP address countries.
ZIA.IPDestinationGroup.ipCategories String Destination IP address URL categories.

Command Example

!zia-ip-destination-group-update group_id=1234 address=9.9.9.9 action=ADD_TO_LIST

Human Readable Output

IP destination group 1234 updated successfully.

zia-ip-destination-group-add


Adds a new IP destination group.

Base Command

zia-ip-destination-group-add

Input

Argument Name Description Required
group_name Destination IP group name. Optional
group_type Destination IP group type. Possible values are: DSTN_IP, DSTN_FQDN, DSTN_DOMAIN, DSTN_OTHER. Optional
address Destination IP addresses. Optional
description Additional information about the destination IP group. Optional
ip_category Destination IP address URL categories. Possible values can be found here. Optional
country Destination IP address countries. Possible values can be found here. Optional
is_non_editable If set to true, the destination IP address group is non-editable. Default is false. Optional

Context Output

Path Type Description
ZIA.IPDestinationGroup.id Number Unique identifier for the destination IP group.
ZIA.IPDestinationGroup.name String Destination IP group name.
ZIA.IPDestinationGroup.type String Destination IP group type.
ZIA.IPDestinationGroup.addresses String Destination IP addresses, FQDNs, or wildcard FQDNs added to the group.
ZIA.IPDestinationGroup.description String Additional information about the destination IP group.
ZIA.IPDestinationGroup.countries String Destination IP address countries.
ZIA.IPDestinationGroup.ipCategories String Destination IP address URL categories.

Command Example

!zia-ip-destination-group-add group_name="New Group" group_type=DSTN_IP address=10.0.0.1

Context Example

{
    "ZIA": {
        "IPDestinationGroup": {
            "id": 5678,
            "name": "New Group",
            "type": "DSTN_IP",
            "addresses": ["10.0.0.1"],
            "description": "",
            "countries": [],
            "ipCategories": []
        }
    }
}

Human Readable Output

IP destination group created successfully with ID 5678.

zia-ip-destination-group-delete


Deletes the IP destination group for the specified ID.

Base Command

zia-ip-destination-group-delete

Input

Argument Name Description Required
group_id The unique identifier for the IP destination group. Required

Context Output

There is no context output for this command.

Command Example

!zia-ip-destination-group-delete group_id=5678

Human Readable Output

IP destination group 5678 deleted successfully.

zia-user-list


Gets a list of all users or the user information for the specified ID.

Base Command

zia-user-list

Input

Argument Name Description Required
user_id Filters by the unique identifier for the user. Optional
dept Filters by department name. Optional
group Filters by group name. Optional
page Specifies the page offset. Default is 1. Optional
page_size Specifies the page size. The maximum size is 10,000. Default is 100. Optional
all_results Whether to retrieve all results at once. Default is False. Optional

Context Output

Path Type Description
ZIA.User.id Number The unique identifier for the user.
ZIA.User.name String User name.
ZIA.User.email String User email address.
ZIA.User.comments String Additional information about the user.

Command Example

!zia-user-list dept="Engineering" page_size=50

Context Example

{
    "ZIA": {
        "User": [
            {
                "id": 100,
                "name": "John Doe",
                "email": "john.doe@example.com",
                "comments": "Engineering team member"
            }
        ]
    }
}

Human Readable Output

Users

ID Name Email Comments
100 John Doe john.doe@example.com Engineering team member

zia-user-update


Updates the user information for the specified ID.

Base Command

zia-user-update

Input

Argument Name Description Required
user_id The unique identifier for the user. Required
user Full user object as JSON. If provided, individual field arguments are applied on top of this. Optional
user_name User name. This appears when choosing users for policies. Optional
email User email consists of a user name and domain name. Optional
comments Additional information about this user. Optional
temp_auth_email Temporary Authentication Email. Optional
password User’s password. Applicable only when authentication type is Hosted DB. Optional

Context Output

Path Type Description
ZIA.User.id Number The unique identifier for the user.
ZIA.User.name String User name.
ZIA.User.email String User email address.
ZIA.User.comments String Additional information about the user.

Command Example

!zia-user-update user_id=100 comments="Updated comment"

Human Readable Output

User 100 updated successfully.

zia-groups-list


Gets a list of groups.

Base Command

zia-groups-list

Input

Argument Name Description Required
search The search string used to match against a group’s name or comments attributes. Optional
defined_by The string value defined by the group name or other applicable attributes. Optional
sort_by Sorts the groups based on available values. Possible values are: id, name, expiry, status, externalId, rank, modTime. Default is id. Optional
sort_order Sorts the order of groups based on available values. Possible values are: asc, desc, ruleExecution. Default is asc. Optional
page Specifies the page offset. Default is 1. Optional
page_size Specifies the page size. The maximum size is 10,000. Default is 100. Optional
all_results Whether to retrieve all results at once. Default is False. Optional

Context Output

Path Type Description
ZIA.Groups.id Number The unique identifier for the group.
ZIA.Groups.name String Group name.
ZIA.Groups.idpId Number Unique identifier for the identity provider (IdP).
ZIA.Groups.comments String Additional information about the group.
ZIA.Groups.isSystemDefined Boolean Whether the group is system-defined.

Command Example

!zia-groups-list search="Engineering" sort_by=name sort_order=asc

Context Example

{
    "ZIA": {
        "Groups": [
            {
                "id": 200,
                "name": "Engineering",
                "idpId": 1,
                "comments": "Engineering department group",
                "isSystemDefined": false
            }
        ]
    }
}

Human Readable Output

Groups

ID Name IdP ID Comments System Defined
200 Engineering 1 Engineering department group false

zia-departments-list


Gets a list of all departments or the department information for the specified ID.

Base Command

zia-departments-list

Input

Argument Name Description Required
department_id Unique identifier for the department. Optional
search The search string used to match against a department’s name or comments attributes. Optional
limit_search Whether to limit the search to match only against the department name. Default is false. Optional
sort_by Sorts the departments based on available values. Possible values are: id, name, expiry, status, externalId, rank. Default is id. Optional
sort_order Sorts the order of departments based on available values. Possible values are: asc, desc, ruleExecution. Default is asc. Optional
page Specifies the page offset. Default is 1. Optional
page_size Specifies the page size. The maximum size is 10,000. Default is 100. Optional
all_results Whether to retrieve all results at once. Default is False. Optional

Context Output

Path Type Description
ZIA.Department.id Number The unique identifier for the department.
ZIA.Department.name String Department name.
ZIA.Department.idpId Number Unique identifier for the identity provider (IdP).
ZIA.Department.comments String Additional information about the department.
ZIA.Department.deleted Boolean Whether the department is deleted.

Command Example

!zia-departments-list search="Engineering"

Context Example

{
    "ZIA": {
        "Department": [
            {
                "id": 300,
                "name": "Engineering",
                "idpId": 1,
                "comments": "Engineering department",
                "deleted": false
            }
        ]
    }
}

Human Readable Output

Departments

ID Name IdP ID Comments Deleted
300 Engineering 1 Engineering department false

zia-sandbox-report-get


Gets a full or summary detail report for an MD5 hash of a file that was analyzed by Sandbox.

Base Command

zia-sandbox-report-get

Input

Argument Name Description Required
md5 MD5 hash of the file that was analyzed by Sandbox. Required
report_type Type of report, full or summary. Possible values are: full, summary. Default is summary. Optional

Context Output

Path Type Description
ZIA.SandboxReport Unknown The full sandbox report response.
DBotScore.Indicator String The indicator that was tested.
DBotScore.Type String The indicator type.
DBotScore.Vendor String The vendor used to calculate the score.
DBotScore.Score Number The actual score.
File.MD5 String The MD5 hash of the file.
File.Malicious.Vendor String For malicious files, the vendor that tagged the file as malicious.
File.Malicious.Description String For malicious files, the reason the vendor tagged the file as malicious.
File.FileType String The file type.

Command Example

!zia-sandbox-report-get md5=9de5069c5afe602b2ea0a04b66beb2c0 report_type=summary

Context Example

{
    "DBotScore": {
        "Indicator": "9de5069c5afe602b2ea0a04b66beb2c0",
        "Score": 3,
        "Type": "file",
        "Vendor": "Zscaler"
    },
    "File": {
        "MD5": "9de5069c5afe602b2ea0a04b66beb2c0",
        "FileType": "PE32",
        "Malicious": {
            "Vendor": "Zscaler",
            "Description": "Malware detected"
        }
    },
    "ZIA": {
        "SandboxReport": {
            "Summary": {
                "Status": "MALICIOUS",
                "Category": "Malware"
            }
        }
    }
}

Human Readable Output

Sandbox Report for 9de5069c5afe602b2ea0a04b66beb2c0

Status Category File Type Score
MALICIOUS Malware PE32 3

zia-activate-changes


Activates the saved configuration changes.

Base Command

zia-activate-changes

Input

There are no input arguments for this command.

Context Output

Path Type Description
ZIA.ActivationStatus.status String The activation status.

Command Example


#### Context Example

```json
{
    "ZIA": {
        "ActivationStatus": {
            "status": "ACTIVE"
        }
    }
}

Human Readable Output

Activation Status

Status
ACTIVE

url


Retrieve Zscaler’s default classification for a given set of URLs.

Base Command

url

Input

Argument Name Description Required
url A comma-separated list of URLs for which to look up the classification. For example, abc.com,xyz.com. Up to 100 URLs can be looked up per request, and a URL cannot exceed 1,024 characters. Required

Context Output

Path Type Description
ZIA.URL.Data String The URL that was searched.
ZIA.URL.Address String The URL that was searched.
ZIA.URL.urlClassifications String The classification of the URL.
ZIA.URL.urlClassificationsWithSecurityAlert String The classifications of the URLs that have security alerts.
URL.Data String The URL that was searched.
URL.Address String The URL that was searched.
URL.Malicious.Vendor String For malicious URLs, the vendor that tagged the URL as malicious.
URL.Malicious.Description String For malicious URLs, the reason the vendor tagged the URL as malicious.
DBotScore.Indicator String The indicator that was tested.
DBotScore.Type String The indicator type.
DBotScore.Vendor String The vendor used to calculate the score.
DBotScore.Score Number The actual score.
DBotScore.Reliability String Reliability of the source providing the intelligence data.

Command Example

!url url=facebook.com

Context Example

{
    "DBotScore": [
        {
            "Indicator": "facebook.com",
            "Score": 1,
            "Type": "url",
            "Vendor": "Zscaler",
            "Reliability": "C - Fairly reliable"
        }
    ],
    "URL": {
        "Address": "facebook.com",
        "Data": "facebook.com"
    },
    "ZIA": {
        "URL": {
            "Address": "facebook.com",
            "Data": "facebook.com",
            "urlClassifications": "SOCIAL_NETWORKING",
            "urlClassificationsWithSecurityAlert": []
        }
    }
}

Human Readable Output

Zscaler URL Lookup

URL Classifications Security Alert Classifications
facebook.com SOCIAL_NETWORKING  

ip


Retrieve the classification for each of the specified IP addresses.

Base Command

ip

Input

Argument Name Description Required
ip A comma-separated list of IP addresses for which to look up the classification. For example, 8.8.8.8,1.2.3.4. The maximum number of IPs per call is 100. Required

Context Output

Path Type Description
ZIA.IP.Address String The IP address that was searched.
ZIA.IP.Classifications String The classification of the IP address.
ZIA.IP.ClassificationsWithSecurityAlert String Classifications that have a security alert for the IP address.
IP.Address String The IP address that was searched.
IP.Malicious.Vendor String For malicious IP addresses, the vendor that tagged the IP address as malicious.
IP.Malicious.Description String For malicious IP addresses, the reason the vendor tagged the IP address as malicious.
DBotScore.Indicator String The indicator that was tested.
DBotScore.Type String The indicator type.
DBotScore.Vendor String The vendor used to calculate the score.
DBotScore.Score Number The actual score.
DBotScore.Reliability String Reliability of the source providing the intelligence data.

Command Example

!ip ip=8.8.8.8

Context Example

{
    "DBotScore": [
        {
            "Indicator": "8.8.8.8",
            "Score": 1,
            "Type": "ip",
            "Vendor": "Zscaler",
            "Reliability": "C - Fairly reliable"
        }
    ],
    "IP": {
        "Address": "8.8.8.8"
    },
    "ZIA": {
        "IP": {
            "Address": "8.8.8.8",
            "Classifications": "WEB_SEARCH",
            "ClassificationsWithSecurityAlert": []
        }
    }
}

Human Readable Output

Zscaler IP Lookup

IP Classifications Security Alert Classifications
8.8.8.8 WEB_SEARCH  

domain


Retrieve Zscaler’s default classification for a given set of domains.

Base Command

domain

Input

Argument Name Description Required
domain A comma-separated list of domains for which to look up the classification. For example, abc.com,xyz.com. The maximum number of domains per call is 100. Required

Context Output

Path Type Description
ZIA.Domain.Data String The domain that was searched.
ZIA.Domain.Address String The domain that was searched.
ZIA.Domain.Classifications String The classification of the domain.
ZIA.Domain.ClassificationsWithSecurityAlert String Classifications that have a security alert for the domain.
Domain.Name String The domain that was searched.
Domain.Malicious.Vendor String For malicious domains, the vendor that tagged the domain as malicious.
Domain.Malicious.Description String For malicious domains, the reason the vendor tagged the domain as malicious.
DBotScore.Indicator String The indicator that was tested.
DBotScore.Type String The indicator type.
DBotScore.Vendor String The vendor used to calculate the score.
DBotScore.Score Number The actual score.
DBotScore.Reliability String Reliability of the source providing the intelligence data.

Command Example

!domain domain=google.com

Context Example

{
    "DBotScore": [
        {
            "Indicator": "google.com",
            "Score": 1,
            "Type": "domain",
            "Vendor": "Zscaler",
            "Reliability": "C - Fairly reliable"
        }
    ],
    "Domain": {
        "Name": "google.com"
    },
    "ZIA": {
        "Domain": {
            "Data": "google.com",
            "Address": "google.com",
            "Classifications": "WEB_SEARCH",
            "ClassificationsWithSecurityAlert": []
        }
    }
}

Human Readable Output

Zscaler Domain Lookup

Domain Classifications Security Alert Classifications
google.com WEB_SEARCH  

Configuration parameters

  • server_url — Server URL (required)
  • credentials — Client ID (required)
  • auto_activate — Auto Activate Changes
  • reliability — Source Reliability (required)
  • suspicious_categories — Suspicious URL categories
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (20)

  • domain

    Retrieve Zscaler's default classification for a given set of domains.

  • ip

    Retrieve the classification for each of the specified IP addresses.

  • url

    Retrieve Zscaler's default classification for a given set of URLs.

  • zia-activate-changes

    Activates the saved configuration changes.

  • zia-allowlist-list

    Gets a list of URLs and IPs that are in the allowlist.

  • zia-allowlist-update

    Updates the list of URLs in the allowlist.

  • zia-category-list

    Gets information about all or custom URL categories. By default, the response includes keywords. The lite option cannot be used in combination with other parameters.

  • zia-category-update

    Updates the URL category for the specified ID.

  • zia-denylist-list

    Gets a list of URLs and IPs that are in the denylist.

  • zia-denylist-update

    Updates the list of URLs in the denylist.

  • zia-departments-list

    Gets a list of all departments or the department information for the specified ID.

  • zia-groups-list

    Gets a list of groups.

  • zia-ip-destination-group-add

    Adds a new IP destination group.

  • zia-ip-destination-group-delete

    Deletes the IP destination group for the specified ID.

  • zia-ip-destination-group-list

    Gets a list of all IP destination groups or for the specified ID.

  • zia-ip-destination-group-update

    Updates an existing IP destination group.

  • zia-sandbox-report-get

    Gets a full or summary detail report for an MD5 hash of a file that was analyzed by Sandbox.

  • zia-url-quota-get

    Gets information on the number of unique URLs that are currently provisioned for your organization as well as how many URLs you can add before reaching that number.

  • zia-user-list

    Gets a list of all users or the user information for the specified ID.

  • zia-user-update

    Updates the user information for the specified ID.

import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401

import re

""" CONSTANTS """

INTEGRATION_NAME = "ZscalerZIA"
BASE_API_URL = "https://api.zsapi.net/zia/api/v1"
TOKEN_URL_TEMPLATE = "https://{server_url}/oauth2/v1/token"
AUDIENCE = "https://api.zscaler.com"
SUSPICIOUS_CATEGORIES = ["SUSPICIOUS_DESTINATION", "SPYWARE_OR_ADWARE"]
TOKEN_EXPIRY_BUFFER_SECONDS = 120  # Refresh the cached token this many seconds before it actually expires

ERROR_CODES_DICT = {
    400: "Invalid or bad request",
    404: "Resource does not exist",
    406: "Not Acceptable",
    409: (
        "Request could not be processed because of possible edit conflict occurred. "
        "Another admin might be saving a configuration change at the same time. "
        "In this scenario, the client is expected to retry after a short time period."
    ),
    415: "Unsupported media type.",
    429: "Exceeded the rate limit or quota.",
    500: "Unexpected error",
    503: "Service is temporarily unavailable",
}

AUTO_ACTIVATE_CHANGES_COMMANDS = (
    "zia-denylist-update",
    "zia-allowlist-update",
    "zia-category-update",
    "zia-ip-destination-group-update",
    "zia-ip-destination-group-add",
    "zia-ip-destination-group-delete",
    "zia-user-update",
)

""" HANDLE PROXY """
handle_proxy()


""" CLIENT CLASS """


class Client(BaseClient):
    """Client for Zscaler ZIA via ZIdentity OAuth 2.0.

    Authenticates using the OAuth 2.0 client credentials grant type against
    the ZIdentity token endpoint and forwards Bearer tokens to the ZIA REST API.

    Attributes:
        server_url: The ZIdentity server URL (e.g. "www.vanity.zslogin.net").
        client_id: The OAuth 2.0 client ID.
        client_secret: The OAuth 2.0 client secret.
        reliability: Source reliability string for DBotScore.
        auto_activate: Whether to auto-activate changes after write commands.
        suspicious_categories: URL categories treated as suspicious for scoring.
    """

    def __init__(
        self,
        server_url: str,
        client_id: str,
        client_secret: str,
        verify: bool,
        proxy: bool,
        reliability: str,
        auto_activate: bool,
        suspicious_categories: list[str],
    ):
        """Initializes the Client.

        Args:
            server_url: The ZIdentity server URL (e.g. "www.vanity.zslogin.net").
            client_id: The OAuth 2.0 client ID registered in ZIdentity.
            client_secret: The OAuth 2.0 client secret.
            verify: Whether to verify SSL certificates.
            proxy: Whether to use system proxy settings.
            reliability: Source reliability for DBotScore (e.g. "C - Fairly reliable").
            auto_activate: If True, activate ZIA changes after each write command.
            suspicious_categories: List of URL categories considered suspicious.
        """
        super().__init__(base_url=BASE_API_URL, verify=verify, proxy=proxy)
        self.server_url = server_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.reliability = reliability
        self.auto_activate = auto_activate
        self.suspicious_categories = suspicious_categories
        self._access_token: str | None = None

    def _get_access_token(self) -> str:
        """Obtains an OAuth 2.0 access token from ZIdentity using client credentials flow.

        Checks the integration context for a cached, non-expired token first.
        If the cached token is missing or within 30 seconds of expiry, fetches
        a new token from the ZIdentity token endpoint and caches it.

        Returns:
            A valid Bearer access token string.

        Raises:
            DemistoException: If ZIdentity does not return an access_token in the response.
        """
        ctx = get_integration_context() or {}
        token = ctx.get("access_token")
        expires_at = ctx.get("token_expires_at", 0)

        if token and time.time() < expires_at - TOKEN_EXPIRY_BUFFER_SECONDS:
            demisto.debug("Using cached ZIdentity access token.")
            return token

        demisto.debug("Fetching new ZIdentity access token.")
        token_url = TOKEN_URL_TEMPLATE.format(server_url=self.server_url)
        payload = {
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "audience": AUDIENCE,
        }
        response = self._http_request(
            method="POST",
            full_url=token_url,
            data=payload,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            resp_type="json",
            ok_codes=(200,),
        )
        token = response.get("access_token")
        if not token:
            raise DemistoException("Failed to obtain access token from ZIdentity. Response: " + str(response))

        expires_in = int(response.get("expires_in", 3600))
        ctx["access_token"] = token
        ctx["token_expires_at"] = time.time() + expires_in
        set_integration_context(ctx)
        add_sensitive_log_strs(token)
        return token

    def _get_auth_headers(self) -> dict:
        """Builds the Authorization headers for ZIA API requests.

        Returns:
            A dict containing the Bearer Authorization header and Content-Type.
        """
        token = self._get_access_token()
        return {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
        }

    def _error_handler(self, res) -> None:
        """Handles HTTP error responses from the ZIA API.

        Raises a DemistoException with a descriptive message based on the
        HTTP status code and response body.

        Args:
            res: The requests.Response object from the failed HTTP call.

        Raises:
            DemistoException: Always raised with an appropriate error message.
        """
        if res.status_code in (401, 403):
            raise DemistoException(
                f"Authentication/Authorization error ({res.status_code}): {res.text}. "
                "Verify your Client ID, Client Secret, and Domain are correct.",
                res=res,
            )
        elif res.status_code == 400 and res.request.method == "PUT" and "/urlCategories/" in res.request.url:
            raise DemistoException(
                f"The request failed with error {res.status_code}.\nMessage: {res.text}\n"
                "This error might be due to an invalid URL or exceeding your organization's quota.\n"
                "For more information about URL formatting, refer to the Zscaler URL Format Guidelines: "
                "https://help.zscaler.com/zia/url-format-guidelines\n"
                "To check your quota usage, run the command `zia-url-quota-get`."
            )
        elif res.status_code in ERROR_CODES_DICT:
            raise DemistoException(f"The request failed with error: {ERROR_CODES_DICT[res.status_code]}.\nMessage: {res.text}")
        else:
            raise DemistoException(f"The request failed with status code {res.status_code}.\nMessage: {res.text}")

    def _do_http_request(
        self,
        method: str,
        url_suffix: str,
        data: dict | list | None,
        params: dict | None,
        resp_type: str,
    ):
        """Executes a single authenticated HTTP request to the ZIA API.

        Injects the current Bearer token Authorization header and retries
        automatically on HTTP 429 (rate limit) responses up to 3 times.

        Args:
            method: HTTP method string (e.g. "GET", "POST", "PUT", "DELETE").
            url_suffix: The API path suffix appended to BASE_API_URL.
            data: Optional JSON-serializable body payload (dict or list).
            params: Optional URL query parameters dict.
            resp_type: Response parsing mode passed to _http_request.

        Returns:
            The parsed API response (type depends on resp_type).
        """
        return self._http_request(
            method=method,
            url_suffix=url_suffix,
            json_data=data,
            params=params,
            headers=self._get_auth_headers(),
            error_handler=self._error_handler,
            ok_codes=(200, 204),
            resp_type=resp_type,
            retries=3,
            status_list_to_retry=[429],
        )

    def api_request(
        self,
        method: str,
        url_suffix: str,
        data: dict | list | None = None,
        params: dict | None = None,
        resp_type: str = "json",
    ):
        """Makes an authenticated API request to the ZIA API.

        Automatically injects the Bearer token Authorization header and retries
        on HTTP 429 (rate limit) responses up to 3 times.

        If a 401 response is received (e.g. due to a stale cached token caused
        by clock drift), the cached token is cleared and the request is retried
        once with a freshly obtained token.

        Args:
            method: HTTP method string (e.g. "GET", "POST", "PUT", "DELETE").
            url_suffix: The API path suffix appended to BASE_API_URL.
            data: Optional JSON-serializable body payload (dict or list).
            params: Optional URL query parameters dict.
            resp_type: Response parsing mode passed to _http_request
                (e.g. "json", "response", "content").

        Returns:
            The parsed API response (type depends on resp_type).
        """
        try:
            return self._do_http_request(method, url_suffix, data, params, resp_type)
        except DemistoException as e:
            if getattr(getattr(e, "res", None), "status_code", None) == 401:
                demisto.debug("401 detected - forcing token refresh and retrying request.")
                ctx = get_integration_context() or {}
                ctx.pop("access_token", None)
                ctx.pop("token_expires_at", None)
                set_integration_context(ctx)
                return self._do_http_request(method, url_suffix, data, params, resp_type)
            raise

    def activate_changes(self) -> dict:
        """Activates saved ZIA configuration changes.

        Returns:
            The API response dict containing the activation status.
        """
        return self.api_request("POST", "/status/activate")

    # ---- Denylist ----

    def get_denylist(self) -> dict:
        """Retrieves the current ZIA advanced security policy including the denylist.

        Returns:
            A dict with the full security/advanced policy, including 'blacklistUrls'.
        """
        return self.api_request("GET", "/security/advanced")

    def update_denylist(self, urls: list[str], ips: list[str], action: str) -> None:
        """Updates the ZIA denylist with the given URLs and IPs.

        For OVERWRITE, fetches the current policy and replaces blacklistUrls entirely
        via PUT. For ADD_TO_LIST and REMOVE_FROM_LIST, uses the POST action endpoint.

        Args:
            urls: List of URL strings to add/remove/overwrite.
            ips: List of IP address strings to add/remove/overwrite.
            action: One of "ADD_TO_LIST", "REMOVE_FROM_LIST", or "OVERWRITE".
        """
        items = urls + ips
        if action == "OVERWRITE":
            # OVERWRITE uses PUT on /security/advanced with blacklistUrls field
            current = self.get_denylist()
            current["blacklistUrls"] = items
            self.api_request("PUT", "/security/advanced", data=current, resp_type="response")
        else:
            payload = {"blacklistUrls": items}
            self.api_request(
                "POST",
                f"/security/advanced/blacklistUrls?action={action}",
                data=payload,
                resp_type="response",
            )

    # ---- Allowlist ----

    def get_allowlist(self) -> dict:
        """Retrieves the current ZIA security policy including the allowlist.

        Returns:
            A dict with the full security policy, including 'whitelistUrls'.
        """
        return self.api_request("GET", "/security")

    def update_allowlist(self, items: list[str], action: str) -> None:
        """Updates the ZIA allowlist with the given URLs and IPs.

        Since the ZIA API only supports PUT (full replacement), this method
        fetches the current allowlist and merges the changes before sending.
        Deduplicates entries when adding.

        Args:
            items: List of URL/IP strings to add/remove/overwrite.
            action: One of "ADD_TO_LIST", "REMOVE_FROM_LIST", or "OVERWRITE".
        """
        current = self.get_allowlist()
        existing = current.get("whitelistUrls", [])
        if action == "ADD_TO_LIST":
            # Only add items not already present
            new_items = [u for u in items if u not in existing]
            current["whitelistUrls"] = existing + new_items
        elif action == "REMOVE_FROM_LIST":
            current["whitelistUrls"] = [u for u in existing if u not in items]
        else:  # OVERWRITE
            current["whitelistUrls"] = items
        self.api_request("PUT", "/security", data=current, resp_type="response")

    # ---- URL Categories ----

    def get_url_categories(
        self,
        category_id: str | None = None,
        custom_only: bool = False,
        include_only_url_keyword_counts: bool = False,
        lite: bool = False,
    ) -> list | dict:
        """Retrieves URL categories from ZIA.

        Args:
            category_id: If provided, fetches only the category with this ID.
            custom_only: If True, returns only custom URL categories.
            include_only_url_keyword_counts: If True, returns only URL and keyword
                counts instead of full URL lists.
            lite: If True, returns a lightweight list of category IDs and names only.
                Cannot be combined with other parameters.

        Returns:
            A list of category dicts, or a single category dict if category_id is given.
        """
        if category_id:
            return self.api_request("GET", f"/urlCategories/{category_id}")
        if lite:
            return self.api_request("GET", "/urlCategories/lite")
        params: dict = {}
        if custom_only:
            params["customOnly"] = "true"
        if include_only_url_keyword_counts:
            params["includeOnlyUrlKeywordCounts"] = "true"
        return self.api_request("GET", "/urlCategories", params=params)

    def update_url_category(
        self,
        category_id: str,
        urls: list[str],
        ips: list[str],
        action: str,
        keywords: list[str] | None = None,
        description: str | None = None,
        db_categorized_urls: list[str] | None = None,
        keywords_retaining_parent_category: list[str] | None = None,
        ip_ranges_retaining_parent_category: list[str] | None = None,
    ) -> None:
        """Updates a URL category by merging the given URLs and IPs with existing ones.

        Fetches the current category state first, then applies the action
        (ADD_TO_LIST, REMOVE_FROM_LIST, or OVERWRITE) to URLs and IP ranges,
        and sends the merged payload via PUT.

        Args:
            category_id: The unique identifier of the URL category to update.
            urls: List of URL strings to add/remove/overwrite.
            ips: List of IP range strings to add/remove/overwrite.
            action: One of "ADD_TO_LIST", "REMOVE_FROM_LIST", or "OVERWRITE".
            keywords: Optional list of custom keywords to associate with the category.
            description: Optional description string for the category.
            db_categorized_urls: Optional URLs to retain under the parent category.
            keywords_retaining_parent_category: Optional keywords retained from parent.
            ip_ranges_retaining_parent_category: Optional IP ranges retained from parent.
        """
        # Fetch current category to merge
        current = self.api_request("GET", f"/urlCategories/{category_id}")
        existing_urls = current.get("urls", [])
        existing_ips = current.get("ipRanges", [])

        if action == "ADD_TO_LIST":
            new_urls = existing_urls + [u for u in urls if u not in existing_urls]
            new_ips = existing_ips + [ip for ip in ips if ip not in existing_ips]
        elif action == "REMOVE_FROM_LIST":
            new_urls = [u for u in existing_urls if u not in urls]
            new_ips = [ip for ip in existing_ips if ip not in ips]
        else:  # OVERWRITE
            new_urls = urls
            new_ips = ips

        payload: dict = {
            "id": category_id,
            "customCategory": current.get("customCategory"),
            "urls": new_urls,
            "ipRanges": new_ips,
        }
        if current.get("configuredName"):
            payload["configuredName"] = current["configuredName"]
        if current.get("superCategory"):
            payload["superCategory"] = current["superCategory"]
        if description is not None:
            payload["description"] = description
        if keywords is not None:
            payload["keywords"] = keywords
        if db_categorized_urls is not None:
            payload["dbCategorizedUrls"] = db_categorized_urls
        if keywords_retaining_parent_category is not None:
            payload["keywordsRetainingParentCategory"] = keywords_retaining_parent_category
        if ip_ranges_retaining_parent_category is not None:
            payload["ipRangesRetainingParentCategory"] = ip_ranges_retaining_parent_category

        self.api_request("PUT", f"/urlCategories/{category_id}", data=payload, resp_type="response")

    # ---- URL Quota ----

    def get_url_quota(self) -> dict:
        """Retrieves the URL quota information for the organization.

        Returns:
            A dict containing 'uniqueUrlsProvisioned' and 'remainingUrlsQuota'.
        """
        return self.api_request("GET", "/urlCategories/urlQuota")

    # ---- IP Destination Groups ----

    def list_ip_destination_groups(
        self,
        group_id: int | None = None,
        include_ipv6: bool = False,
        exclude_type: str | None = None,
        category_type: list[str] | None = None,
        lite: bool = False,
    ) -> list | dict:
        """Lists IP destination groups from ZIA.

        If group_id is provided, returns only that specific group. Otherwise,
        returns all IPv4 groups (and optionally IPv6 groups).

        Args:
            group_id: If provided, fetches only the group with this ID.
            include_ipv6: If True, also fetches IPv6 destination groups.
            exclude_type: Filter to exclude groups of this type
                (e.g. "DSTN_IP", "DSTN_FQDN", "DSTN_DOMAIN", "DSTN_OTHER").
            category_type: Filter by group type (only valid with lite=True).
            lite: If True, returns lightweight name/ID-only results.

        Returns:
            A list of group dicts, or a single group dict if group_id is given.
        """
        if group_id is not None:
            return self.api_request("GET", f"/ipDestinationGroups/{group_id}")

        lite_suffix = "/lite" if lite else ""
        params: dict = {}
        if exclude_type:
            params["excludeType"] = exclude_type
        if category_type and lite:
            params["type"] = category_type

        results = []
        ipv4_resp = self.api_request("GET", f"/ipDestinationGroups{lite_suffix}", params=params)
        results.extend(ipv4_resp if isinstance(ipv4_resp, list) else [ipv4_resp])

        if include_ipv6:
            ipv6_resp = self.api_request("GET", f"/ipDestinationGroups/ipv6DestinationGroups{lite_suffix}", params=params)
            results.extend(ipv6_resp if isinstance(ipv6_resp, list) else [ipv6_resp])

        return results

    def update_ip_destination_group(self, group_id: int, payload: dict) -> dict:
        """Updates an existing IP destination group.

        Args:
            group_id: The unique identifier of the group to update.
            payload: A dict containing the full updated group definition.

        Returns:
            The updated group dict as returned by the API.
        """
        return self.api_request("PUT", f"/ipDestinationGroups/{group_id}", data=payload)

    def add_ip_destination_group(self, payload: dict) -> dict:
        """Creates a new IP destination group.

        Args:
            payload: A dict containing the new group definition (name, type,
                addresses, description, ipCategories, countries, isNonEditable).

        Returns:
            The created group dict as returned by the API.
        """
        return self.api_request("POST", "/ipDestinationGroups", data=payload)

    def delete_ip_destination_group(self, group_id: int) -> None:
        """Deletes an IP destination group by ID.

        Args:
            group_id: The unique identifier of the group to delete.
        """
        self.api_request("DELETE", f"/ipDestinationGroups/{group_id}", resp_type="response")

    # ---- Users ----

    def get_users(
        self,
        user_id: str | None = None,
        dept: str | None = None,
        group: str | None = None,
        page: int = 1,
        page_size: int = 100,
    ) -> list | dict:
        """Retrieves ZIA users.

        If user_id is provided, returns only that specific user. Otherwise,
        returns a paginated list of users with optional department/group filters.

        Args:
            user_id: If provided, fetches only the user with this ID.
            dept: Filter users by department name.
            group: Filter users by group name.
            page: Page offset for pagination (1-based).
            page_size: Number of results per page (max 10,000).

        Returns:
            A list of user dicts, or a single user dict if user_id is given.
        """
        if user_id:
            return self.api_request("GET", f"/users/{user_id}")
        params: dict = {"page": page, "pageSize": page_size}
        if dept:
            params["dept"] = dept
        if group:
            params["group"] = group
        return self.api_request("GET", "/users", params=params)

    def update_user(self, user_id: str, payload: dict) -> dict:
        """Updates a ZIA user by ID.

        Args:
            user_id: The unique identifier of the user to update.
            payload: A dict containing the full updated user definition.

        Returns:
            The updated user dict as returned by the API.
        """
        return self.api_request("PUT", f"/users/{user_id}", data=payload)

    # ---- Groups ----

    def get_groups(
        self,
        search: str | None = None,
        defined_by: str | None = None,
        sort_by: str = "id",
        sort_order: str = "asc",
        page: int = 1,
        page_size: int = 100,
    ) -> list:
        """Retrieves a paginated list of ZIA user groups.

        Args:
            search: Search string matched against group name or comments.
            defined_by: Filter by the attribute that defines the group.
            sort_by: Field to sort results by (e.g. "id", "name", "modTime").
            sort_order: Sort direction, one of "asc", "desc", or "ruleExecution".
            page: Page offset for pagination (1-based).
            page_size: Number of results per page (max 10,000).

        Returns:
            A list of group dicts.
        """
        params: dict = {"page": page, "pageSize": page_size, "sortBy": sort_by, "sortOrder": sort_order}
        if search:
            params["search"] = search
        if defined_by:
            params["definedBy"] = defined_by
        return self.api_request("GET", "/groups", params=params)

    # ---- Departments ----

    def get_departments(
        self,
        department_id: str | None = None,
        search: str | None = None,
        limit_search: bool = False,
        sort_by: str = "id",
        sort_order: str = "asc",
        page: int = 1,
        page_size: int = 100,
    ) -> list | dict:
        """Retrieves ZIA departments.

        If department_id is provided, returns only that specific department.
        Otherwise, returns a paginated list with optional search filters.

        Args:
            department_id: If provided, fetches only the department with this ID.
            search: Search string matched against department name or comments.
            limit_search: If True, restricts search to match only the department name.
            sort_by: Field to sort results by (e.g. "id", "name", "rank").
            sort_order: Sort direction, one of "asc", "desc", or "ruleExecution".
            page: Page offset for pagination (1-based).
            page_size: Number of results per page (max 10,000).

        Returns:
            A list of department dicts, or a single department dict if
            department_id is given.
        """
        if department_id:
            return self.api_request("GET", f"/departments/{department_id}")
        params: dict = {"page": page, "pageSize": page_size, "sortBy": sort_by, "sortOrder": sort_order}
        if search:
            params["search"] = search
            if limit_search:
                params["limitSearch"] = "true"
        return self.api_request("GET", "/departments", params=params)

    # ---- Sandbox ----

    def get_sandbox_report(self, md5: str, report_type: str = "summary") -> dict:
        """Retrieves a Sandbox analysis report for a file identified by MD5 hash.

        Args:
            md5: The MD5 hash of the file analyzed by Sandbox.
            report_type: Type of report to retrieve, either "full" or "summary".
                Defaults to "summary".

        Returns:
            A dict containing the sandbox report data.
        """
        details = "full" if report_type.lower() == "full" else "summary"
        return self.api_request("GET", f"/sandbox/report/{md5}?details={details}")

    # ---- URL Lookup ----

    def url_lookup(self, ioc_list: list[str]) -> list:
        """Looks up the classification for a list of URLs, IPs, or domains.

        Strips http:// and https:// prefixes before sending to the ZIA API,
        as Zscaler expects bare hostnames/URLs without protocol schemes.

        Args:
            ioc_list: A list of URL, IP, or domain strings to classify.
                Maximum 100 items per request.

        Returns:
            A list of classification result dicts, each containing 'url',
            'urlClassifications', and 'urlClassificationsWithSecurityAlert'.
        """
        # Strip protocol prefixes as Zscaler expects bare URLs
        cleaned = [u.replace("https://", "").replace("http://", "") for u in ioc_list]
        return self.api_request("POST", "/urlLookup", data=cleaned)


""" HELPER FUNCTIONS """


def _filter_and_limit(items: list, filter_: str, query: str, limit: int, all_results: bool) -> list:
    """Applies filter, query, and limit to a list of URL/IP strings.

    Filters items by type (url or ip) and/or by a regex query pattern,
    then applies a count limit unless all_results is True.

    Args:
        items: The list of URL or IP strings to filter.
        filter_: Type filter string, either "url" or "ip". Empty string means no filter.
        query: Python regex pattern to match against each item. Empty string means no filter.
        limit: Maximum number of items to return when all_results is False.
        all_results: If True, returns all matching items ignoring limit.

    Returns:
        A filtered and optionally limited list of strings.
    """
    if filter_ or query:
        filtered = []
        for entity in items:
            is_filter_match = not filter_
            is_query_match = not query
            if filter_:
                if re.match(ipv4Regex, entity):
                    is_filter_match = filter_ == "ip"
                else:
                    is_filter_match = filter_ == "url"
            if query:
                is_query_match = bool(re.search(query, entity))
            if is_filter_match and is_query_match:
                filtered.append(entity)
        items = filtered
    return items if all_results else items[:limit]


def _dbot_score_for_url(
    url_classifications: str, url_classifications_with_security_alert: str, suspicious_list: list[str]
) -> int:
    """Calculates the DBotScore for a URL, IP, or domain based on Zscaler classifications.

    Scoring logic:
        - MISCELLANEOUS_OR_UNKNOWN classification → DBotScore.NONE
        - Security alert category in suspicious_list → DBotScore.SUSPICIOUS
        - Any other security alert category → DBotScore.BAD
        - No security alert → DBotScore.GOOD

    Args:
        url_classifications: The primary URL classification string from Zscaler.
        url_classifications_with_security_alert: The security alert classification
            string from Zscaler (empty string if none).
        suspicious_list: List of category strings considered suspicious
            (e.g. ["SUSPICIOUS_DESTINATION", "SPYWARE_OR_ADWARE"]).

    Returns:
        An integer DBotScore value (Common.DBotScore.NONE/GOOD/SUSPICIOUS/BAD).
    """
    if url_classifications == "MISCELLANEOUS_OR_UNKNOWN":
        return Common.DBotScore.NONE
    if url_classifications_with_security_alert:
        if url_classifications_with_security_alert in suspicious_list:
            return Common.DBotScore.SUSPICIOUS
        return Common.DBotScore.BAD
    return Common.DBotScore.GOOD


""" COMMAND FUNCTIONS """


def test_module_command(client: Client) -> str:
    """Tests connectivity to the ZIA API by fetching the status endpoint.

    Args:
        client: The authenticated ZIA Client instance.

    Returns:
        The string "ok" if the connection is successful.

    Raises:
        DemistoException: If the API request fails.
    """
    client.api_request("GET", "/status")
    return "ok"


def zia_denylist_list_command(client: Client, args: dict) -> CommandResults:
    """Retrieves the ZIA denylist with optional filtering and limiting.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - filter: "url" or "ip" to filter by type.
            - query: Python regex to match against entries.
            - limit: Maximum number of results (default 50).
            - all_results: If "True", returns all results ignoring limit.

    Returns:
        A CommandResults object with the denylist entries.
    """
    filter_ = args.get("filter", "")
    query = args.get("query", "")
    limit = arg_to_number(args.get("limit", 50)) or 50
    all_results = argToBoolean(args.get("all_results", False))

    response = client.get_denylist()
    denylist = response.get("blacklistUrls", [])
    denylist = _filter_and_limit(denylist, filter_, query, limit, all_results)

    hr_lines = "\n".join(f"- {item}" for item in denylist) if denylist else "No items found."
    readable_output = f"### ZIA Denylist\n{hr_lines}"

    return CommandResults(
        outputs_prefix="ZIA.DenyList",
        outputs=response,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_denylist_update_command(client: Client, args: dict) -> CommandResults:
    """Updates the ZIA denylist by adding, removing, or overwriting entries.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - url: Comma-separated list of URLs to update.
            - ip: Comma-separated list of IPs to update.
            - action: One of "ADD_TO_LIST", "REMOVE_FROM_LIST", "OVERWRITE" (required).

    Returns:
        A CommandResults object with a success message.

    Raises:
        DemistoException: If neither url nor ip is provided, or action is missing.
    """
    urls = argToList(args.get("url", ""))
    ips = argToList(args.get("ip", ""))
    action = args.get("action", "")

    if not urls and not ips:
        raise DemistoException("At least one of 'url' or 'ip' arguments must be provided.")
    if not action:
        raise DemistoException("The 'action' argument is required.")

    client.update_denylist(urls, ips, action)
    return CommandResults(readable_output="The deny list has been successfully updated.")


def zia_allowlist_list_command(client: Client, args: dict) -> CommandResults:
    """Retrieves the ZIA allowlist with optional filtering and limiting.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - filter: "url" or "ip" to filter by type.
            - query: Python regex to match against entries.
            - limit: Maximum number of results (default 50).
            - all_results: If "True", returns all results ignoring limit.

    Returns:
        A CommandResults object with the allowlist entries.
    """
    filter_ = args.get("filter", "")
    query = args.get("query", "")
    limit = arg_to_number(args.get("limit", 50)) or 50
    all_results = argToBoolean(args.get("all_results", False))

    response = client.get_allowlist()
    allowlist = response.get("whitelistUrls", [])
    allowlist = _filter_and_limit(allowlist, filter_, query, limit, all_results)

    hr_lines = "\n".join(f"- {item}" for item in allowlist) if allowlist else "No items found."
    readable_output = f"### ZIA Allowlist\n{hr_lines}"

    return CommandResults(
        outputs_prefix="ZIA.AllowList",
        outputs=response,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_allowlist_update_command(client: Client, args: dict) -> CommandResults:
    """Updates the ZIA allowlist by adding, removing, or overwriting entries.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - url: Comma-separated list of URLs to update.
            - ip: Comma-separated list of IPs to update.
            - action: One of "ADD_TO_LIST", "REMOVE_FROM_LIST", "OVERWRITE" (required).

    Returns:
        A CommandResults object with a success message.

    Raises:
        DemistoException: If neither url nor ip is provided, or action is missing.
    """
    urls = argToList(args.get("url", ""))
    ips = argToList(args.get("ip", ""))
    action = args.get("action", "")

    if not urls and not ips:
        raise DemistoException("At least one of 'url' or 'ip' arguments must be provided.")
    if not action:
        raise DemistoException("The 'action' argument is required.")

    client.update_allowlist(urls + ips, action)
    return CommandResults(readable_output="The allowlist has been successfully updated.")


def zia_category_list_command(client: Client, args: dict) -> CommandResults:
    """Retrieves ZIA URL categories with optional filtering and display options.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - category_id: Fetch a specific category by ID.
            - custom_only: If "true", returns only custom categories.
            - include_only_url_keyword_counts: If "true", returns counts only.
            - lite: If "true", returns lightweight name/ID list only.
                Cannot be combined with other parameters.
            - limit: Maximum number of results (default 50).
            - all_results: If "True", returns all results ignoring limit.
            - display_url: If "true", includes URLs in the human-readable output.

    Returns:
        A CommandResults object with the URL category data.

    Raises:
        DemistoException: If lite is combined with incompatible parameters.
    """
    category_id = args.get("category_id")
    custom_only = argToBoolean(args.get("custom_only", False))
    include_only_url_keyword_counts = argToBoolean(args.get("include_only_url_keyword_counts", False))
    lite = argToBoolean(args.get("lite", False))
    limit = arg_to_number(args.get("limit", 50)) or 50
    all_results = argToBoolean(args.get("all_results", False))
    display_url = argToBoolean(args.get("display_url", False))

    if lite and (category_id or custom_only or include_only_url_keyword_counts):
        raise DemistoException("The 'lite' option cannot be used in combination with other parameters.")

    response = client.get_url_categories(
        category_id=category_id,
        custom_only=custom_only,
        include_only_url_keyword_counts=include_only_url_keyword_counts,
        lite=lite,
    )

    if isinstance(response, dict):
        categories = [response]
    else:
        categories = response if all_results else response[:limit]

    hr_rows = []
    for cat in categories:
        row: dict = {
            "Category ID": cat.get("id"),
            "Configured Name": cat.get("configuredName"),
            "Super Category": cat.get("superCategory"),
            "Keywords": cat.get("keywords"),
        }
        if display_url:
            row["Urls"] = cat.get("urls")
        hr_rows.append(row)

    readable_output = tableToMarkdown("ZIA URL Categories", hr_rows, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.Category",
        outputs_key_field="id",
        outputs=categories,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_category_update_command(client: Client, args: dict) -> CommandResults:
    """Updates a ZIA URL category with new URLs, IPs, keywords, or description.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - category_id: The category ID to update (required).
            - url: Comma-separated list of URLs to update.
            - ip: Comma-separated list of IP ranges to update.
            - action: One of "ADD_TO_LIST", "REMOVE_FROM_LIST", "OVERWRITE" (required).
            - keywords: Comma-separated custom keywords.
            - description: Category description string.
            - db_categorized_urls: URLs to retain under the parent category.
            - keywords_retaining_parent_category: Keywords retained from parent.
            - ip_ranges_retaining_parent_category: IP ranges retained from parent.

    Returns:
        A CommandResults object with a success message.

    Raises:
        DemistoException: If category_id is missing, neither url nor ip is provided,
            or action is missing.
    """
    category_id = args.get("category_id", "")
    if not category_id:
        raise DemistoException("The 'category_id' argument is required.")

    urls = argToList(args.get("url", ""))
    ips = argToList(args.get("ip", ""))
    action = args.get("action", "")

    if not urls and not ips:
        raise DemistoException("At least one of 'url' or 'ip' arguments must be provided.")
    if not action:
        raise DemistoException("The 'action' argument is required.")

    keywords = argToList(args.get("keywords")) if args.get("keywords") else None
    description = args.get("description")
    db_categorized_urls = argToList(args.get("db_categorized_urls")) if args.get("db_categorized_urls") else None
    keywords_retaining = (
        argToList(args.get("keywords_retaining_parent_category")) if args.get("keywords_retaining_parent_category") else None
    )
    ip_ranges_retaining = (
        argToList(args.get("ip_ranges_retaining_parent_category")) if args.get("ip_ranges_retaining_parent_category") else None
    )

    client.update_url_category(
        category_id=category_id,
        urls=urls,
        ips=ips,
        action=action,
        keywords=keywords,
        description=description,
        db_categorized_urls=db_categorized_urls,
        keywords_retaining_parent_category=keywords_retaining,
        ip_ranges_retaining_parent_category=ip_ranges_retaining,
    )
    return CommandResults(readable_output="The category has been successfully updated.")


def zia_url_quota_get_command(client: Client, args: dict) -> CommandResults:
    """Retrieves the URL quota information for the organization.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict (no arguments required for this command).

    Returns:
        A CommandResults object with quota information including
        uniqueUrlsProvisioned and remainingUrlsQuota.
    """
    response = client.get_url_quota()
    hr = tableToMarkdown(
        "ZIA URL Quota",
        {
            "Unique Urls Provisioned": response.get("uniqueUrlsProvisioned"),
            "Remaining Urls Quota": response.get("remainingUrlsQuota"),
        },
    )
    return CommandResults(
        outputs_prefix="ZIA.UrlQuota",
        outputs=response,
        readable_output=hr,
        raw_response=response,
    )


def zia_ip_destination_group_list_command(client: Client, args: dict) -> CommandResults:
    """Lists ZIA IP destination groups with optional filtering and limiting.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - group_id: Fetch a specific group by ID.
            - include_ipv6: If "True", also fetches IPv6 destination groups.
            - exclude_type: Exclude groups of this type from results.
            - category_type: Filter by group type (only valid with lite=True).
            - lite: If "True", returns lightweight name/ID results only.
            - limit: Maximum number of results (default 50).
            - all_results: If "True", returns all results ignoring limit.

    Returns:
        A CommandResults object with the IP destination group data.

    Raises:
        DemistoException: If category_type is used without lite=True.
    """
    group_id = arg_to_number(args.get("group_id"))
    include_ipv6 = argToBoolean(args.get("include_ipv6", False))
    exclude_type = args.get("exclude_type")
    category_type = argToList(args.get("category_type", "")) or None
    lite = argToBoolean(args.get("lite", False))
    limit = arg_to_number(args.get("limit", 50)) or 50
    all_results = argToBoolean(args.get("all_results", False))

    if category_type and not lite:
        raise DemistoException("The 'category_type' argument only works with the 'lite' argument set to True.")

    response = client.list_ip_destination_groups(
        group_id=group_id,
        include_ipv6=include_ipv6,
        exclude_type=exclude_type,
        category_type=category_type,
        lite=lite,
    )

    if isinstance(response, dict):
        groups = [response]
    else:
        groups = response if all_results else response[:limit]

    hr_rows = [
        {
            "IP Destination Group ID": g.get("id"),
            "Name": g.get("name"),
            "Type": g.get("type"),
            "Addresses": g.get("addresses"),
            "Description": g.get("description"),
            "Countries": g.get("countries"),
            "Ip Categories": g.get("ipCategories"),
        }
        for g in groups
    ]
    readable_output = tableToMarkdown("ZIA IP Destination Groups", hr_rows, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.IPDestinationGroup",
        outputs_key_field="id",
        outputs=groups,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_ip_destination_group_update_command(client: Client, args: dict) -> CommandResults:
    """Updates an existing ZIA IP destination group.

    Fetches the current group state and merges provided arguments on top,
    so only specified fields are changed. The 'action' argument controls how
    the 'address' list is applied: ADD_TO_LIST adds new addresses, REMOVE_FROM_LIST
    removes specified addresses, and OVERWRITE replaces the entire list.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - group_id: The unique identifier of the group to update (required).
            - group_name: New name for the group.
            - group_type: New type (DSTN_IP, DSTN_FQDN, DSTN_DOMAIN, DSTN_OTHER).
            - address: Comma-separated list of addresses.
            - action: How to apply the address list: ADD_TO_LIST, REMOVE_FROM_LIST,
                or OVERWRITE (required).
            - description: Group description.
            - ip_category: Comma-separated list of IP categories.
            - country: Comma-separated list of country codes.

    Returns:
        A CommandResults object with the updated group data.

    Raises:
        DemistoException: If group_id is not provided.
    """
    group_id = arg_to_number(args.get("group_id"))
    if group_id is None:
        raise DemistoException("The 'group_id' argument is required.")

    action = args.get("action", "OVERWRITE")
    new_addresses = argToList(args.get("address", []))

    # Fetch existing group to merge
    existing = client.list_ip_destination_groups(group_id=group_id)
    if isinstance(existing, list):
        existing = existing[0] if existing else {}

    existing_addresses: list[str] = existing.get("addresses", [])
    if action == "ADD_TO_LIST":
        merged_addresses = existing_addresses + [a for a in new_addresses if a not in existing_addresses]
    elif action == "REMOVE_FROM_LIST":
        merged_addresses = [a for a in existing_addresses if a not in new_addresses]
    else:  # OVERWRITE
        merged_addresses = new_addresses if new_addresses else existing_addresses

    payload: dict = {
        "id": group_id,
        "name": args.get("group_name", existing.get("name", "")),
        "type": args.get("group_type", existing.get("type", "")),
        "addresses": merged_addresses,
        "description": args.get("description", existing.get("description", "")),
        "ipCategories": argToList(args.get("ip_category", existing.get("ipCategories", []))),
        "countries": argToList(args.get("country", existing.get("countries", []))),
    }

    response = client.update_ip_destination_group(group_id, payload)
    hr_row = {
        "IP Destination Group ID": response.get("id"),
        "Name": response.get("name"),
        "Type": response.get("type"),
        "Addresses": response.get("addresses"),
        "Description": response.get("description"),
        "Countries": response.get("countries"),
        "Ip Categories": response.get("ipCategories"),
    }
    readable_output = tableToMarkdown("The Ip Destination Group Resource has been successfully edited", hr_row, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.IPDestinationGroup",
        outputs_key_field="id",
        outputs=response,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_ip_destination_group_add_command(client: Client, args: dict) -> CommandResults:
    """Creates a new ZIA IP destination group.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - group_name: Name for the new group.
            - group_type: Type (DSTN_IP, DSTN_FQDN, DSTN_DOMAIN, DSTN_OTHER).
            - address: Comma-separated list of addresses.
            - description: Group description.
            - ip_category: Comma-separated list of IP categories.
            - country: Comma-separated list of country codes.
            - is_non_editable: If "true", marks the group as non-editable.

    Returns:
        A CommandResults object with the newly created group data.
    """
    payload: dict = {
        "name": args.get("group_name", ""),
        "type": args.get("group_type", ""),
        "addresses": argToList(args.get("address", [])),
        "description": args.get("description", ""),
        "ipCategories": argToList(args.get("ip_category", [])),
        "countries": argToList(args.get("country", [])),
        "isNonEditable": argToBoolean(args.get("is_non_editable", False)),
    }

    response = client.add_ip_destination_group(payload)
    hr_row = {
        "IP Destination Group ID": response.get("id"),
        "Name": response.get("name"),
        "Type": response.get("type"),
        "Addresses": response.get("addresses"),
        "Description": response.get("description"),
        "Countries": response.get("countries"),
        "Ip Categories": response.get("ipCategories"),
    }
    readable_output = tableToMarkdown("The Ip Destination Group Resource has been successfully added", hr_row, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.IPDestinationGroup",
        outputs_key_field="id",
        outputs=response,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_ip_destination_group_delete_command(client: Client, args: dict) -> CommandResults:
    """Deletes a ZIA IP destination group by ID.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - group_id: The unique identifier of the group to delete (required).

    Returns:
        A CommandResults object with a success message.

    Raises:
        DemistoException: If group_id is not provided.
    """
    group_id = arg_to_number(args.get("group_id"))
    if group_id is None:
        raise DemistoException("The 'group_id' argument is required.")

    client.delete_ip_destination_group(group_id)
    return CommandResults(readable_output="The Ip Destination Group Resource has been successfully deleted.")


def zia_user_list_command(client: Client, args: dict) -> CommandResults:
    """Retrieves ZIA users with optional filtering by department or group.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - user_id: Fetch a specific user by ID.
            - dept: Filter by department name.
            - group: Filter by group name.
            - page: Page offset for pagination (default 1).
            - page_size: Number of results per page (default 100, max 10,000).

    Returns:
        A CommandResults object with the user data.
    """
    user_id = args.get("user_id")
    dept = args.get("dept")
    group = args.get("group")
    page = arg_to_number(args.get("page", 1)) or 1
    page_size = arg_to_number(args.get("page_size", 100)) or 100

    response = client.get_users(user_id=user_id, dept=dept, group=group, page=page, page_size=page_size)

    if isinstance(response, dict):
        users = [response]
    else:
        users = response

    hr_rows = [
        {
            "User ID": u.get("id"),
            "Name": u.get("name"),
            "Email": u.get("email"),
            "Comment": u.get("comments"),
        }
        for u in users
    ]
    readable_output = tableToMarkdown(f"ZIA Users ({len(users)})", hr_rows, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.User",
        outputs_key_field="id",
        outputs=users,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_user_update_command(client: Client, args: dict) -> CommandResults:
    """Updates a ZIA user's information.

    Fetches the current user state and merges provided arguments on top.
    If a full JSON user object is provided via the 'user' argument, it is used
    as the base payload, with individual field arguments applied on top.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - user_id: The unique identifier of the user to update (required).
            - user: Full user object as a JSON string (optional base payload).
            - user_name: New display name for the user.
            - email: New email address.
            - comments: Additional information about the user.
            - temp_auth_email: Temporary authentication email.
            - password: New password (Hosted DB auth only).

    Returns:
        A CommandResults object with the updated user data.

    Raises:
        DemistoException: If user_id is missing or the 'user' JSON is invalid.
    """
    user_id = args.get("user_id", "")
    if not user_id:
        raise DemistoException("The 'user_id' argument is required.")

    # Fetch existing user to merge
    existing = client.get_users(user_id=user_id)
    if isinstance(existing, list):
        existing = existing[0] if existing else {}

    # If a full JSON user object is provided, use it as base
    user_json = args.get("user")
    if user_json:
        try:
            payload = json.loads(user_json)
        except json.JSONDecodeError as e:
            raise DemistoException(f"Invalid JSON in 'user' argument: {e}")
    else:
        payload = dict(existing)

    # Override individual fields if provided
    if args.get("user_name"):
        payload["name"] = args["user_name"]
    if args.get("email"):
        payload["email"] = args["email"]
    if args.get("comments"):
        payload["comments"] = args["comments"]
    if args.get("temp_auth_email"):
        payload["tempAuthEmail"] = args["temp_auth_email"]
    if args.get("password"):
        payload["password"] = args["password"]

    response = client.update_user(user_id, payload)
    hr_row = {
        "User ID": response.get("id"),
        "Name": response.get("name"),
        "Email": response.get("email"),
        "Comment": response.get("comments"),
    }
    readable_output = tableToMarkdown("ZIA User Updated", hr_row, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.User",
        outputs_key_field="id",
        outputs=response,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_groups_list_command(client: Client, args: dict) -> CommandResults:
    """Retrieves a paginated list of ZIA user groups.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - search: Search string matched against group name or comments.
            - defined_by: Filter by the attribute that defines the group.
            - sort_by: Field to sort by (default "id").
            - sort_order: Sort direction "asc", "desc", or "ruleExecution" (default "asc").
            - page: Page offset for pagination (default 1).
            - page_size: Number of results per page (default 100, max 10,000).
            - all_results: Accepted but not used; pagination is server-side.

    Returns:
        A CommandResults object with the group data.
    """
    search = args.get("search")
    defined_by = args.get("defined_by")
    sort_by = args.get("sort_by", "id")
    sort_order = args.get("sort_order", "asc")
    page = arg_to_number(args.get("page", 1)) or 1
    page_size = arg_to_number(args.get("page_size", 100)) or 100

    response = client.get_groups(
        search=search,
        defined_by=defined_by,
        sort_by=sort_by,
        sort_order=sort_order,
        page=page,
        page_size=page_size,
    )

    hr_rows = [
        {
            "Group ID": g.get("id"),
            "Name": g.get("name"),
            "IdpId": g.get("idpId"),
            "Comment": g.get("comments"),
            "Is System Defined": g.get("isSystemDefined"),
        }
        for g in response
    ]
    readable_output = tableToMarkdown(f"ZIA Groups ({len(response)})", hr_rows, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.Groups",
        outputs_key_field="id",
        outputs=response,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_departments_list_command(client: Client, args: dict) -> CommandResults:
    """Retrieves ZIA departments with optional filtering.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with optional keys:
            - department_id: Fetch a specific department by ID.
            - search: Search string matched against department name or comments.
            - limit_search: If "true", restricts search to department name only.
            - sort_by: Field to sort by (default "id").
            - sort_order: Sort direction "asc", "desc", or "ruleExecution" (default "asc").
            - page: Page offset for pagination (default 1).
            - page_size: Number of results per page (default 100, max 10,000).
            - all_results: Accepted but not used; pagination is server-side.

    Returns:
        A CommandResults object with the department data.
    """
    department_id = args.get("department_id")
    search = args.get("search")
    limit_search = argToBoolean(args.get("limit_search", False))
    sort_by = args.get("sort_by", "id")
    sort_order = args.get("sort_order", "asc")
    page = arg_to_number(args.get("page", 1)) or 1
    page_size = arg_to_number(args.get("page_size", 100)) or 100

    response = client.get_departments(
        department_id=department_id,
        search=search,
        limit_search=limit_search,
        sort_by=sort_by,
        sort_order=sort_order,
        page=page,
        page_size=page_size,
    )

    if isinstance(response, dict):
        departments = [response]
    else:
        departments = response

    hr_rows = [
        {
            "Department ID": d.get("id"),
            "Name": d.get("name"),
            "IdpId": d.get("idpId"),
            "Comment": d.get("comments"),
            "Deleted": d.get("deleted"),
        }
        for d in departments
    ]
    readable_output = tableToMarkdown(f"ZIA Departments ({len(departments)})", hr_rows, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.Department",
        outputs_key_field="id",
        outputs=departments,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_sandbox_report_get_command(client: Client, args: dict) -> CommandResults:
    """Retrieves a Sandbox analysis report for a file identified by MD5 hash.

    Calculates a DBotScore based on the classification type:
        - MALICIOUS → BAD
        - SUSPICIOUS → SUSPICIOUS
        - BENIGN → GOOD
        - Other/unknown → NONE

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - md5: The MD5 hash of the file analyzed by Sandbox (required).
            - report_type: "full" or "summary" (default "summary").

    Returns:
        A CommandResults object with the sandbox report, a File indicator,
        and a DBotScore.

    Raises:
        DemistoException: If md5 is not provided.
    """
    md5 = args.get("md5", "")
    if not md5:
        raise DemistoException("The 'md5' argument is required.")
    report_type = args.get("report_type", "summary")

    response = client.get_sandbox_report(md5, report_type)

    report_key = "Full Details" if report_type.lower() == "full" else "Summary"
    classification_type = demisto.get(response, f"{report_key}.Classification.Type")
    if classification_type == "MALICIOUS":
        dbot_score = Common.DBotScore.BAD
    elif classification_type == "SUSPICIOUS":
        dbot_score = Common.DBotScore.SUSPICIOUS
    elif classification_type == "BENIGN":
        dbot_score = Common.DBotScore.GOOD
    else:
        dbot_score = Common.DBotScore.NONE

    file_type = demisto.get(response, f"{report_key}.File Properties.File Type")
    detected_malware = demisto.get(response, f"{report_key}.Classification.DetectedMalware")
    zscaler_score = demisto.get(response, f"{report_key}.Classification.Score")
    category = demisto.get(response, f"{report_key}.Classification.Category")

    malicious_description = None
    if dbot_score == Common.DBotScore.BAD:
        malicious_description = f"Classified as Malicious, with threat score: {zscaler_score} Zscaler ZIA Score"

    dbot = Common.DBotScore(
        indicator=md5,
        indicator_type=DBotScoreType.FILE,
        integration_name=INTEGRATION_NAME,
        score=dbot_score,
        malicious_description=malicious_description,
        reliability=demisto.params().get("reliability"),
    )

    file_indicator = Common.File(
        md5=md5,
        file_type=file_type,
        dbot_score=dbot,
    )

    hr_data = {
        "DBotScore": dbot_score,
        "Detected Malware": detected_malware,
        "Zscaler ZIA Score": zscaler_score,
        "Category": category,
    }
    readable_output = tableToMarkdown("ZIA Sandbox Report", hr_data, removeNull=True)

    return CommandResults(
        outputs_prefix="ZIA.SandboxReport",
        outputs_key_field="md5",
        outputs=response,
        indicator=file_indicator,
        readable_output=readable_output,
        raw_response=response,
    )


def zia_activate_changes_command(client: Client, args: dict) -> CommandResults:
    """Activates saved ZIA configuration changes.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict (no arguments required for this command).

    Returns:
        A CommandResults object with the activation status.
    """
    response = client.activate_changes()
    hr = tableToMarkdown("ZIA Activation Status", {"Status": response.get("status")}, removeNull=True)
    return CommandResults(
        outputs_prefix="ZIA.ActivationStatus",
        outputs=response,
        readable_output=hr,
        raw_response=response,
    )


def url_command(client: Client, args: dict) -> list[CommandResults]:
    """Looks up the classification for a list of URLs and creates URL indicators.

    Restores the original URL with protocol prefix in the output if the protocol
    was stripped before sending to the ZIA API.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - url: Comma-separated list of URLs to classify (required).
                Maximum 100 URLs per request.

    Returns:
        A list of CommandResults objects, one per URL, each containing a URL
        indicator with DBotScore. Returns a single "No results found" result
        if the API returns an empty response.
    """
    url_arg = args.get("url", "")
    urls = argToList(url_arg)
    response = client.url_lookup(urls)
    results: list[CommandResults] = []

    for data in response:
        res_url = data.get("url", "")
        # Restore original URL with protocol if it was stripped
        for original_url in urls:
            if "http://" + res_url in original_url or "https://" + res_url in original_url:
                data["url"] = original_url
                res_url = original_url
                break

        url_classifications = "".join(data.get("urlClassifications", []))
        url_classifications_with_security_alert = "".join(data.get("urlClassificationsWithSecurityAlert", []))

        score = _dbot_score_for_url(url_classifications, url_classifications_with_security_alert, client.suspicious_categories)

        url_indicator = Common.URL(
            url=res_url,
            dbot_score=Common.DBotScore(
                indicator=res_url,
                indicator_type=DBotScoreType.URL,
                integration_name=INTEGRATION_NAME,
                score=score,
                malicious_description=url_classifications_with_security_alert or None,
                reliability=client.reliability,
            ),
        )

        context_data = {
            "Address": res_url,
            "Data": res_url,
            "urlClassifications": url_classifications,
            "urlClassificationsWithSecurityAlert": url_classifications_with_security_alert,
        }

        results.append(
            CommandResults(
                outputs_prefix=f"{INTEGRATION_NAME}.URL",
                outputs_key_field="Data",
                indicator=url_indicator,
                readable_output=tableToMarkdown(f"Zscaler URL Lookup for {res_url}", data, removeNull=True),
                outputs=createContext(data=context_data, removeNull=True),
                raw_response=data,
            )
        )

    return results or [CommandResults(readable_output="No results found.")]


def ip_command(client: Client, args: dict) -> list[CommandResults]:
    """Looks up the classification for a list of IP addresses and creates IP indicators.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - ip: Comma-separated list of IP addresses to classify (required).
                Maximum 100 IPs per request.

    Returns:
        A list of CommandResults objects, one per IP address, each containing
        an IP indicator with DBotScore. Returns a single "No results found"
        result if the API returns an empty response.
    """
    ip_arg = args.get("ip", "")
    ips = argToList(ip_arg)
    response = client.url_lookup(ips)
    results: list[CommandResults] = []

    for data in response:
        ip_addr = data.get("url", "")
        ip_classifications = "".join(data.get("urlClassifications", []))
        ip_classifications_with_security_alert = "".join(data.get("urlClassificationsWithSecurityAlert", []))

        score = _dbot_score_for_url(ip_classifications, ip_classifications_with_security_alert, client.suspicious_categories)

        ip_indicator = Common.IP(
            ip=ip_addr,
            dbot_score=Common.DBotScore(
                indicator=ip_addr,
                indicator_type=DBotScoreType.IP,
                integration_name=INTEGRATION_NAME,
                score=score,
                malicious_description=ip_classifications_with_security_alert or None,
                reliability=client.reliability,
            ),
        )

        context_data = {
            "Address": ip_addr,
            "Classifications": ip_classifications,
            "ClassificationsWithSecurityAlert": ip_classifications_with_security_alert,
        }

        results.append(
            CommandResults(
                outputs_prefix=f"{INTEGRATION_NAME}.IP",
                outputs_key_field="Address",
                indicator=ip_indicator,
                readable_output=tableToMarkdown(f"Zscaler IP Lookup for {ip_addr}", data, removeNull=True),
                outputs=createContext(data=context_data, removeNull=True),
                raw_response=data,
            )
        )

    return results or [CommandResults(readable_output="No results found.")]


def domain_command(client: Client, args: dict) -> list[CommandResults]:
    """Looks up the classification for a list of domains and creates Domain indicators.

    Args:
        client: The authenticated ZIA Client instance.
        args: Command arguments dict with keys:
            - domain: Comma-separated list of domains to classify (required).
                Maximum 100 domains per request.

    Returns:
        A list of CommandResults objects, one per domain, each containing a
        Domain indicator with DBotScore. Returns a single "No results found"
        result if the API returns an empty response.
    """
    domain_arg = args.get("domain", "")
    domains = argToList(domain_arg)
    response = client.url_lookup(domains)
    results: list[CommandResults] = []

    for data in response:
        domain_val = data.get("url", "")
        domain_classifications = "".join(data.get("urlClassifications", []))
        domain_classifications_with_security_alert = "".join(data.get("urlClassificationsWithSecurityAlert", []))

        score = _dbot_score_for_url(
            domain_classifications, domain_classifications_with_security_alert, client.suspicious_categories
        )

        domain_indicator = Common.Domain(
            domain=domain_val,
            dbot_score=Common.DBotScore(
                indicator=domain_val,
                indicator_type=DBotScoreType.DOMAIN,
                integration_name=INTEGRATION_NAME,
                score=score,
                malicious_description=domain_classifications_with_security_alert or None,
                reliability=client.reliability,
            ),
        )

        context_data = {
            "Data": domain_val,
            "Address": domain_val,
            "Classifications": domain_classifications,
            "ClassificationsWithSecurityAlert": domain_classifications_with_security_alert,
        }

        results.append(
            CommandResults(
                outputs_prefix=f"{INTEGRATION_NAME}.Domain",
                outputs_key_field="Data",
                indicator=domain_indicator,
                readable_output=tableToMarkdown(f"Zscaler Domain Lookup for {domain_val}", data, removeNull=True),
                outputs=createContext(data=context_data, removeNull=True),
                raw_response=data,
            )
        )

    return results or [CommandResults(readable_output="No results found.")]


""" MAIN """


def main() -> None:  # pragma: no cover
    """Entry point for the integration.

    Reads instance parameters, constructs the Client, dispatches the command,
    and handles auto-activation of ZIA changes after write commands.
    """
    params = demisto.params()
    args = demisto.args()
    command = demisto.command()

    server_url = params.get("server_url", "")
    client_id = params.get("credentials", {}).get("identifier", "")
    client_secret = params.get("credentials", {}).get("password", "")
    verify = not params.get("insecure", False)
    proxy = params.get("proxy", False)
    reliability = params.get("reliability", "C - Fairly reliable")
    auto_activate = argToBoolean(params.get("auto_activate", True))

    suspicious_categories_param = params.get("suspicious_categories", "")
    if suspicious_categories_param:
        suspicious_categories = argToList(suspicious_categories_param)
    else:
        suspicious_categories = SUSPICIOUS_CATEGORIES

    add_sensitive_log_strs(client_secret)

    client = Client(
        server_url=server_url,
        client_id=client_id,
        client_secret=client_secret,
        verify=verify,
        proxy=proxy,
        reliability=reliability,
        auto_activate=auto_activate,
        suspicious_categories=suspicious_categories,
    )

    demisto.debug(f"Command is: {command}")

    try:
        if command == "test-module":
            return_results(test_module_command(client))
        elif command == "zia-denylist-list":
            return_results(zia_denylist_list_command(client, args))
        elif command == "zia-denylist-update":
            return_results(zia_denylist_update_command(client, args))
        elif command == "zia-allowlist-list":
            return_results(zia_allowlist_list_command(client, args))
        elif command == "zia-allowlist-update":
            return_results(zia_allowlist_update_command(client, args))
        elif command == "zia-category-list":
            return_results(zia_category_list_command(client, args))
        elif command == "zia-category-update":
            return_results(zia_category_update_command(client, args))
        elif command == "zia-url-quota-get":
            return_results(zia_url_quota_get_command(client, args))
        elif command == "zia-ip-destination-group-list":
            return_results(zia_ip_destination_group_list_command(client, args))
        elif command == "zia-ip-destination-group-update":
            return_results(zia_ip_destination_group_update_command(client, args))
        elif command == "zia-ip-destination-group-add":
            return_results(zia_ip_destination_group_add_command(client, args))
        elif command == "zia-ip-destination-group-delete":
            return_results(zia_ip_destination_group_delete_command(client, args))
        elif command == "zia-user-list":
            return_results(zia_user_list_command(client, args))
        elif command == "zia-user-update":
            return_results(zia_user_update_command(client, args))
        elif command == "zia-groups-list":
            return_results(zia_groups_list_command(client, args))
        elif command == "zia-departments-list":
            return_results(zia_departments_list_command(client, args))
        elif command == "zia-sandbox-report-get":
            return_results(zia_sandbox_report_get_command(client, args))
        elif command == "zia-activate-changes":
            return_results(zia_activate_changes_command(client, args))
        elif command == "url":
            return_results(url_command(client, args))
        elif command == "ip":
            return_results(ip_command(client, args))
        elif command == "domain":
            return_results(domain_command(client, args))
        else:
            raise NotImplementedError(f"Command '{command}' is not implemented.")
    except Exception as e:
        return_error(f"Failed to execute '{command}' command.\nError: {e}")
    finally:
        if auto_activate and command in AUTO_ACTIVATE_CHANGES_COMMANDS:
            try:
                demisto.debug(f"Auto-activating changes after command: {command}")
                client.activate_changes()
            except Exception as err:
                demisto.error(f"Failed to auto-activate changes: {err}")
                return_warning(
                    f"Auto-activation of changes failed: {err}\n"
                    "Your changes were saved but are not yet active. "
                    "Run the 'zia-activate-changes' command to apply them manually."
                )


if __name__ in ("__builtin__", "builtins", "__main__"):  # pragma: no cover
    main()