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
import pytest
import json
import time
from pathlib import Path

from ZscalerZIdentity import (
    Client,
    _filter_and_limit,
    _dbot_score_for_url,
    zia_denylist_list_command,
    zia_denylist_update_command,
    zia_allowlist_list_command,
    zia_allowlist_update_command,
    zia_category_list_command,
    zia_category_update_command,
    zia_url_quota_get_command,
    zia_ip_destination_group_list_command,
    zia_ip_destination_group_update_command,
    zia_ip_destination_group_add_command,
    zia_ip_destination_group_delete_command,
    zia_user_list_command,
    zia_user_update_command,
    zia_groups_list_command,
    zia_departments_list_command,
    zia_sandbox_report_get_command,
    zia_activate_changes_command,
    url_command,
    ip_command,
    domain_command,
    SUSPICIOUS_CATEGORIES,
)
from CommonServerPython import DemistoException, Common, CommandResults


# ---- Fixtures ----


def load_json(path: str):
    """Load a JSON file from disk and return its parsed content."""
    full_path = Path(__file__).parent / path.lstrip("/")
    with open(full_path) as f:
        return json.load(f)


def get_outputs(result: CommandResults) -> dict:
    """Extract outputs dict from a CommandResults, asserting it is a dict."""
    ctx = result.to_context()
    # outputs are nested under EntryContext key
    entry_context = ctx.get("EntryContext", {})
    if not entry_context:
        return {}
    # Return the first value (the actual outputs)
    for val in entry_context.values():
        return val  # type: ignore[return-value]
    return {}


def get_outputs_list(result: CommandResults) -> list:
    """Extract outputs as a list from a CommandResults."""
    ctx = result.to_context()
    entry_context = ctx.get("EntryContext", {})
    if not entry_context:
        return []
    for val in entry_context.values():
        if isinstance(val, list):
            return val
        return [val]
    return []


@pytest.fixture
def mock_client() -> Client:
    """Return a Client instance with mocked token retrieval."""
    client = Client(
        server_url="www.testdomain.zslogin.net",
        client_id="test_client_id",
        client_secret="test_client_secret",
        verify=False,
        proxy=False,
        reliability="C - Fairly reliable",
        auto_activate=True,
        suspicious_categories=list(SUSPICIOUS_CATEGORIES),
    )
    return client


@pytest.fixture(autouse=True)
def mock_demisto_params(mocker):
    """Patch demisto.params() with test instance configuration."""
    mocker.patch.object(
        demisto,
        "params",
        return_value={
            "server_url": "www.testdomain.zslogin.net",
            "credentials": {"identifier": "test_client_id", "password": "test_client_secret"},
            "reliability": "C - Fairly reliable",
            "auto_activate": True,
            "suspicious_categories": "SUSPICIOUS_DESTINATION,SPYWARE_OR_ADWARE",
            "insecure": True,
            "proxy": False,
        },
    )
    mocker.patch.object(demisto, "command", return_value="test-module")


@pytest.fixture(autouse=True)
def mock_integration_context(mocker):
    """Mock integration context to return a valid cached token."""
    mocker.patch(
        "ZscalerZIdentity.get_integration_context",
        return_value={
            "access_token": "mock_access_token",
            "token_expires_at": time.time() + 3600,
        },
    )
    mocker.patch("ZscalerZIdentity.set_integration_context")


# ---- Helper ----


def _patch_api(mocker, client: Client, return_value):
    """Patch client.api_request to return a fixed value."""
    return mocker.patch.object(client, "api_request", return_value=return_value)


# ---- Unit tests: helpers ----


class TestFilterAndLimit:
    def test_no_filter_no_query(self):
        """
        Given: A mixed list of URLs and IPs with no filter or query.
        When: _filter_and_limit is called with empty filter and query strings.
        Then: The original list is returned unchanged.
        """
        items = ["a.com", "1.2.3.4", "b.net"]
        result = _filter_and_limit(items, "", "", 50, False)
        assert result == items

    def test_filter_url(self):
        """
        Given: A mixed list of URLs and IPs.
        When: _filter_and_limit is called with filter='url'.
        Then: Only URL entries (non-IP) are returned; IP addresses are excluded.
        """
        items = ["a.com", "1.2.3.4", "b.net"]
        result = _filter_and_limit(items, "url", "", 50, False)
        assert "1.2.3.4" not in result
        assert "a.com" in result

    def test_filter_ip(self):
        """
        Given: A mixed list of URLs and IPs.
        When: _filter_and_limit is called with filter='ip'.
        Then: Only IPv4 address entries are returned.
        """
        items = ["a.com", "1.2.3.4", "b.net"]
        result = _filter_and_limit(items, "ip", "", 50, False)
        assert result == ["1.2.3.4"]

    def test_query_match(self):
        """
        Given: A list of domain strings.
        When: _filter_and_limit is called with a regex query matching only one entry.
        Then: Only the matching entry is returned.
        """
        items = ["malicious.com", "safe.net", "malware.org"]
        result = _filter_and_limit(items, "", "malicious", 50, False)
        assert result == ["malicious.com"]

    def test_limit(self):
        """
        Given: A list of four items and a limit of 2.
        When: _filter_and_limit is called with all_results=False.
        Then: Only the first two items are returned.
        """
        items = ["a.com", "b.com", "c.com", "d.com"]
        result = _filter_and_limit(items, "", "", 2, False)
        assert result == ["a.com", "b.com"]

    def test_all_results_overrides_limit(self):
        """
        Given: A list of four items and a limit of 2.
        When: _filter_and_limit is called with all_results=True.
        Then: All four items are returned, ignoring the limit.
        """
        items = ["a.com", "b.com", "c.com", "d.com"]
        result = _filter_and_limit(items, "", "", 2, True)
        assert result == items


class TestDBotScoreCalculation:
    def test_miscellaneous_returns_none(self):
        """
        Given: A URL classified as MISCELLANEOUS_OR_UNKNOWN with no security alert.
        When: _dbot_score_for_url is called.
        Then: DBotScore.NONE is returned.
        """
        score = _dbot_score_for_url("MISCELLANEOUS_OR_UNKNOWN", "", list(SUSPICIOUS_CATEGORIES))
        assert score == Common.DBotScore.NONE

    def test_suspicious_category(self):
        """
        Given: A URL with a security alert category that is in the suspicious list.
        When: _dbot_score_for_url is called.
        Then: DBotScore.SUSPICIOUS is returned.
        """
        score = _dbot_score_for_url("MALWARE_SITE", "SUSPICIOUS_DESTINATION", list(SUSPICIOUS_CATEGORIES))
        assert score == Common.DBotScore.SUSPICIOUS

    def test_bad_category(self):
        """
        Given: A URL with a security alert category that is NOT in the suspicious list.
        When: _dbot_score_for_url is called.
        Then: DBotScore.BAD is returned.
        """
        score = _dbot_score_for_url("MALWARE_SITE", "MALWARE_SITE", list(SUSPICIOUS_CATEGORIES))
        assert score == Common.DBotScore.BAD

    def test_good_no_security_alert(self):
        """
        Given: A URL with a benign classification and no security alert.
        When: _dbot_score_for_url is called.
        Then: DBotScore.GOOD is returned.
        """
        score = _dbot_score_for_url("BUSINESS_AND_ECONOMY", "", list(SUSPICIOUS_CATEGORIES))
        assert score == Common.DBotScore.GOOD


# ---- Unit tests: OAuth token caching ----


class TestTokenCaching:
    def test_uses_cached_token(self, mock_client, mocker):
        """
        Given: A valid non-expired access token is cached in the integration context.
        When: _get_access_token is called.
        Then: The cached token is returned without making any HTTP request.
        """
        http_mock = mocker.patch.object(mock_client, "_http_request")
        token = mock_client._get_access_token()
        assert token == "mock_access_token"
        http_mock.assert_not_called()

    def test_fetches_new_token_when_expired(self, mock_client, mocker):
        """
        Given: An expired access token is in the integration context.
        When: _get_access_token is called.
        Then: A new token is fetched from ZIdentity and returned.
        """
        token_response = load_json("/test_data/responses/token.json")
        mocker.patch(
            "ZscalerZIdentity.get_integration_context",
            return_value={
                "access_token": "old_token",
                "token_expires_at": time.time() - 10,  # expired
            },
        )
        mocker.patch.object(
            mock_client,
            "_http_request",
            return_value=token_response,
        )
        token = mock_client._get_access_token()
        assert token == token_response["access_token"]

    def test_raises_on_missing_token(self, mock_client, mocker):
        """
        Given: The integration context has an expired token and ZIdentity returns no access_token.
        When: _get_access_token is called.
        Then: A DemistoException is raised with a message about failing to obtain the token.
        """
        mocker.patch(
            "ZscalerZIdentity.get_integration_context",
            return_value={"token_expires_at": time.time() - 10},
        )
        mocker.patch.object(mock_client, "_http_request", return_value={"error": "invalid_client"})
        with pytest.raises(DemistoException, match="Failed to obtain access token"):
            mock_client._get_access_token()


# ---- Unit tests: denylist ----


class TestDenylistCommands:
    def test_list_returns_items(self, mock_client, mocker):
        """
        Given: The ZIA API returns a denylist with multiple entries.
        When: zia_denylist_list_command is called with no filters.
        Then: The result has the correct outputs_prefix and all entries appear in the output.
        """
        data = load_json("/test_data/responses/denylist.json")
        _patch_api(mocker, mock_client, data)
        result = zia_denylist_list_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.DenyList"
        assert "malicious.com" in result.readable_output

    def test_list_with_filter_ip(self, mock_client, mocker):
        """
        Given: The ZIA API returns a denylist containing both URLs and IPs.
        When: zia_denylist_list_command is called with filter='ip'.
        Then: Only IP addresses appear in the output; URL entries are excluded.
        """
        data = load_json("/test_data/responses/denylist.json")
        _patch_api(mocker, mock_client, data)
        result = zia_denylist_list_command(mock_client, {"filter": "ip"})
        assert "1.2.3.4" in result.readable_output
        assert "malicious.com" not in result.readable_output

    def test_list_with_query(self, mock_client, mocker):
        """
        Given: The ZIA API returns a denylist with multiple entries.
        When: zia_denylist_list_command is called with a query matching only one entry.
        Then: Only the matching entry appears in the output.
        """
        data = load_json("/test_data/responses/denylist.json")
        _patch_api(mocker, mock_client, data)
        result = zia_denylist_list_command(mock_client, {"query": "malicious"})
        assert "malicious.com" in result.readable_output
        assert "badsite.net" not in result.readable_output

    def test_update_add(self, mock_client, mocker):
        """
        Given: A URL and action=ADD_TO_LIST are provided.
        When: zia_denylist_update_command is called.
        Then: The denylist is updated and a success message is returned.
        """
        mocker.patch.object(mock_client, "get_denylist", return_value={"blacklistUrls": []})
        mocker.patch.object(mock_client, "api_request", return_value=None)
        result = zia_denylist_update_command(mock_client, {"url": "evil.com", "action": "ADD_TO_LIST"})
        assert "successfully updated" in result.readable_output

    def test_update_requires_url_or_ip(self, mock_client):
        """
        Given: Neither url nor ip argument is provided.
        When: zia_denylist_update_command is called.
        Then: A DemistoException is raised indicating at least one is required.
        """
        with pytest.raises(DemistoException, match="At least one"):
            zia_denylist_update_command(mock_client, {"action": "ADD_TO_LIST"})

    def test_update_requires_action(self, mock_client):
        """
        Given: A URL is provided but the action argument is missing.
        When: zia_denylist_update_command is called.
        Then: A DemistoException is raised indicating action is required.
        """
        with pytest.raises(DemistoException, match="'action' argument is required"):
            zia_denylist_update_command(mock_client, {"url": "evil.com"})


# ---- Unit tests: allowlist ----


class TestAllowlistCommands:
    def test_list_returns_items(self, mock_client, mocker):
        """
        Given: The ZIA API returns an allowlist with multiple entries.
        When: zia_allowlist_list_command is called with no filters.
        Then: The result has the correct outputs_prefix and all entries appear in the output.
        """
        data = load_json("/test_data/responses/allowlist.json")
        _patch_api(mocker, mock_client, data)
        result = zia_allowlist_list_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.AllowList"
        assert "trusted.com" in result.readable_output

    def test_update_add(self, mock_client, mocker):
        """
        Given: A new URL and action=ADD_TO_LIST are provided.
        When: zia_allowlist_update_command is called.
        Then: The allowlist is updated and a success message is returned.
        """
        mocker.patch.object(mock_client, "get_allowlist", return_value={"whitelistUrls": ["existing.com"]})
        mocker.patch.object(mock_client, "api_request", return_value=None)
        result = zia_allowlist_update_command(mock_client, {"url": "new.com", "action": "ADD_TO_LIST"})
        assert "successfully updated" in result.readable_output

    def test_update_requires_url_or_ip(self, mock_client):
        """
        Given: Neither url nor ip argument is provided.
        When: zia_allowlist_update_command is called.
        Then: A DemistoException is raised indicating at least one is required.
        """
        with pytest.raises(DemistoException, match="At least one of 'url' or 'ip'"):
            zia_allowlist_update_command(mock_client, {"action": "ADD_TO_LIST"})

    def test_update_add_ip(self, mock_client, mocker):
        """
        Given: A new IP and action=ADD_TO_LIST are provided.
        When: zia_allowlist_update_command is called with only the ip argument.
        Then: The allowlist is updated and a success message is returned.
        """
        mocker.patch.object(mock_client, "get_allowlist", return_value={"whitelistUrls": ["existing.com"]})
        api_mock = mocker.patch.object(mock_client, "api_request", return_value=None)
        result = zia_allowlist_update_command(mock_client, {"ip": "1.2.3.4", "action": "ADD_TO_LIST"})
        assert "successfully updated" in result.readable_output
        sent_data = api_mock.call_args[1].get("data") or api_mock.call_args[0][2]
        assert "1.2.3.4" in sent_data["whitelistUrls"]

    def test_update_add_deduplicates(self, mock_client, mocker):
        """
        Given: The allowlist already contains a URL that is being added again.
        When: zia_allowlist_update_command is called with action=ADD_TO_LIST.
        Then: The URL appears only once in the PUT payload sent to the API.
        """
        existing = {"whitelistUrls": ["existing.com"]}
        mocker.patch.object(mock_client, "get_allowlist", return_value=existing)
        api_mock = mocker.patch.object(mock_client, "api_request", return_value=None)
        zia_allowlist_update_command(mock_client, {"url": "existing.com", "action": "ADD_TO_LIST"})
        call_kwargs = api_mock.call_args
        sent_data = call_kwargs[1].get("data") or call_kwargs[0][2]
        assert sent_data["whitelistUrls"].count("existing.com") == 1


# ---- Unit tests: URL categories ----


class TestCategoryCommands:
    def test_list_all(self, mock_client, mocker):
        """
        Given: The ZIA API returns a list of two URL categories.
        When: zia_category_list_command is called with no arguments.
        Then: The result has the correct outputs_prefix and two categories in the raw response.
        """
        data = load_json("/test_data/responses/url_categories.json")
        _patch_api(mocker, mock_client, data)
        result = zia_category_list_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.Category"
        assert isinstance(result.raw_response, list)
        assert len(result.raw_response) == 2  # type: ignore[arg-type]

    def test_list_lite_with_other_params_raises(self, mock_client):
        """
        Given: Both lite=true and custom_only=true are provided.
        When: zia_category_list_command is called.
        Then: A DemistoException is raised because lite cannot be combined with other parameters.
        """
        with pytest.raises(DemistoException, match="cannot be used in combination"):
            zia_category_list_command(mock_client, {"lite": "true", "custom_only": "true"})

    def test_update_requires_category_id(self, mock_client):
        """
        Given: The category_id argument is missing.
        When: zia_category_update_command is called.
        Then: A DemistoException is raised indicating category_id is required.
        """
        with pytest.raises(DemistoException, match="'category_id' argument is required"):
            zia_category_update_command(mock_client, {"url": "test.com", "action": "ADD_TO_LIST"})

    def test_update_requires_url_or_ip(self, mock_client):
        """
        Given: A category_id and action are provided but neither url nor ip is given.
        When: zia_category_update_command is called.
        Then: A DemistoException is raised indicating at least one of url or ip is required.
        """
        with pytest.raises(DemistoException, match="At least one"):
            zia_category_update_command(mock_client, {"category_id": "MUSIC", "action": "ADD_TO_LIST"})

    def test_update_success(self, mock_client, mocker):
        """
        Given: A valid category_id, url, and action=ADD_TO_LIST are provided.
        When: zia_category_update_command is called.
        Then: The category is updated and a success message is returned.
        """
        current_cat = {
            "id": "MUSIC",
            "configuredName": "Music",
            "superCategory": "ENTERTAINMENT",
            "customCategory": False,
            "urls": ["pandora.com"],
            "ipRanges": [],
        }
        mocker.patch.object(mock_client, "api_request", side_effect=[current_cat, None])
        result = zia_category_update_command(mock_client, {"category_id": "MUSIC", "url": "spotify.com", "action": "ADD_TO_LIST"})
        assert "successfully updated" in result.readable_output


# ---- Unit tests: URL quota ----


class TestUrlQuotaCommand:
    def test_returns_quota(self, mock_client, mocker):
        """
        Given: The ZIA API returns URL quota information.
        When: zia_url_quota_get_command is called.
        Then: The result has the correct outputs_prefix, raw_response matches the API data,
            and the human-readable output contains the provisioned URL count.
        """
        data = load_json("/test_data/responses/url_quota.json")
        _patch_api(mocker, mock_client, data)
        result = zia_url_quota_get_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.UrlQuota"
        assert result.raw_response == data  # type: ignore[comparison-overlap]
        assert "Unique Urls Provisioned" in result.readable_output
        assert "25000" in result.readable_output


# ---- Unit tests: IP destination groups ----


class TestIPDestinationGroupCommands:
    def test_list_all(self, mock_client, mocker):
        """
        Given: The ZIA API returns a list of two IP destination groups.
        When: zia_ip_destination_group_list_command is called with no arguments.
        Then: The result has the correct outputs_prefix and two groups in the raw response.
        """
        data = load_json("/test_data/responses/ip_destination_groups.json")
        _patch_api(mocker, mock_client, data)
        result = zia_ip_destination_group_list_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.IPDestinationGroup"
        assert isinstance(result.raw_response, list)
        assert len(result.raw_response) == 2  # type: ignore[arg-type]

    def test_list_category_type_without_lite_raises(self, mock_client):
        """
        Given: category_type is provided but lite is not set to True.
        When: zia_ip_destination_group_list_command is called.
        Then: A DemistoException is raised indicating category_type requires lite=True.
        """
        with pytest.raises(DemistoException, match="only works with the 'lite' argument"):
            zia_ip_destination_group_list_command(mock_client, {"category_type": "DSTN_IP"})

    def test_update_requires_group_id(self, mock_client):
        """
        Given: The group_id argument is missing.
        When: zia_ip_destination_group_update_command is called.
        Then: A DemistoException is raised indicating group_id is required.
        """
        with pytest.raises(DemistoException, match="'group_id' argument is required"):
            zia_ip_destination_group_update_command(mock_client, {})

    def test_update_success(self, mock_client, mocker):
        """
        Given: A valid group_id and a new group_name are provided.
        When: zia_ip_destination_group_update_command is called.
        Then: The group is updated, the success message appears in the output,
            and the raw_response matches the updated group data.
        """
        existing = {
            "id": 1001,
            "name": "Test Group",
            "type": "DSTN_IP",
            "addresses": ["10.0.0.1"],
            "description": "desc",
            "ipCategories": [],
            "countries": [],
        }
        updated = {**existing, "name": "Updated Group"}
        mocker.patch.object(mock_client, "list_ip_destination_groups", return_value=existing)
        mocker.patch.object(mock_client, "update_ip_destination_group", return_value=updated)
        result = zia_ip_destination_group_update_command(
            mock_client, {"group_id": "1001", "group_name": "Updated Group", "action": "OVERWRITE"}
        )
        assert "successfully edited" in result.readable_output
        assert result.raw_response == updated  # type: ignore[comparison-overlap]

    def test_update_action_add_to_list(self, mock_client, mocker):
        """
        Given: A valid group_id, address list, and action=ADD_TO_LIST are provided.
        When: zia_ip_destination_group_update_command is called.
        Then: The new address is appended to the existing addresses list.
        """
        existing = {
            "id": 1001,
            "name": "Test Group",
            "type": "DSTN_IP",
            "addresses": ["10.0.0.1"],
            "description": "desc",
            "ipCategories": [],
            "countries": [],
        }
        updated = {**existing, "addresses": ["10.0.0.1", "10.0.0.2"]}
        mocker.patch.object(mock_client, "list_ip_destination_groups", return_value=existing)
        mock_update = mocker.patch.object(mock_client, "update_ip_destination_group", return_value=updated)
        zia_ip_destination_group_update_command(mock_client, {"group_id": "1001", "address": "10.0.0.2", "action": "ADD_TO_LIST"})
        call_payload = mock_update.call_args[0][1]
        assert call_payload["addresses"] == ["10.0.0.1", "10.0.0.2"]

    def test_update_action_add_to_list_no_duplicates(self, mock_client, mocker):
        """
        Given: A valid group_id, an address already in the list, and action=ADD_TO_LIST.
        When: zia_ip_destination_group_update_command is called.
        Then: The existing address is not duplicated in the resulting list.
        """
        existing = {
            "id": 1001,
            "name": "Test Group",
            "type": "DSTN_IP",
            "addresses": ["10.0.0.1"],
            "description": "desc",
            "ipCategories": [],
            "countries": [],
        }
        mocker.patch.object(mock_client, "list_ip_destination_groups", return_value=existing)
        mock_update = mocker.patch.object(mock_client, "update_ip_destination_group", return_value=existing)
        zia_ip_destination_group_update_command(mock_client, {"group_id": "1001", "address": "10.0.0.1", "action": "ADD_TO_LIST"})
        call_payload = mock_update.call_args[0][1]
        assert call_payload["addresses"] == ["10.0.0.1"]

    def test_update_action_remove_from_list(self, mock_client, mocker):
        """
        Given: A valid group_id, an address to remove, and action=REMOVE_FROM_LIST.
        When: zia_ip_destination_group_update_command is called.
        Then: The specified address is removed from the addresses list.
        """
        existing = {
            "id": 1001,
            "name": "Test Group",
            "type": "DSTN_IP",
            "addresses": ["10.0.0.1", "10.0.0.2"],
            "description": "desc",
            "ipCategories": [],
            "countries": [],
        }
        updated = {**existing, "addresses": ["10.0.0.1"]}
        mocker.patch.object(mock_client, "list_ip_destination_groups", return_value=existing)
        mock_update = mocker.patch.object(mock_client, "update_ip_destination_group", return_value=updated)
        zia_ip_destination_group_update_command(
            mock_client, {"group_id": "1001", "address": "10.0.0.2", "action": "REMOVE_FROM_LIST"}
        )
        call_payload = mock_update.call_args[0][1]
        assert call_payload["addresses"] == ["10.0.0.1"]

    def test_update_action_overwrite(self, mock_client, mocker):
        """
        Given: A valid group_id, a new address list, and action=OVERWRITE.
        When: zia_ip_destination_group_update_command is called.
        Then: The addresses list is fully replaced with the provided addresses.
        """
        existing = {
            "id": 1001,
            "name": "Test Group",
            "type": "DSTN_IP",
            "addresses": ["10.0.0.1", "10.0.0.2"],
            "description": "desc",
            "ipCategories": [],
            "countries": [],
        }
        updated = {**existing, "addresses": ["192.168.1.1"]}
        mocker.patch.object(mock_client, "list_ip_destination_groups", return_value=existing)
        mock_update = mocker.patch.object(mock_client, "update_ip_destination_group", return_value=updated)
        zia_ip_destination_group_update_command(
            mock_client, {"group_id": "1001", "address": "192.168.1.1", "action": "OVERWRITE"}
        )
        call_payload = mock_update.call_args[0][1]
        assert call_payload["addresses"] == ["192.168.1.1"]

    def test_add_success(self, mock_client, mocker):
        """
        Given: Valid group_name, group_type, and address arguments are provided.
        When: zia_ip_destination_group_add_command is called.
        Then: A new group is created, the success message appears in the output,
            and the raw_response matches the created group data.
        """
        new_group = {
            "id": 1003,
            "name": "New Group",
            "type": "DSTN_IP",
            "addresses": ["192.168.0.1"],
            "description": "",
            "ipCategories": [],
            "countries": [],
        }
        mocker.patch.object(mock_client, "add_ip_destination_group", return_value=new_group)
        result = zia_ip_destination_group_add_command(
            mock_client, {"group_name": "New Group", "group_type": "DSTN_IP", "address": "192.168.0.1"}
        )
        assert "successfully added" in result.readable_output
        assert result.raw_response == new_group  # type: ignore[comparison-overlap]

    def test_delete_requires_group_id(self, mock_client):
        """
        Given: The group_id argument is missing.
        When: zia_ip_destination_group_delete_command is called.
        Then: A DemistoException is raised indicating group_id is required.
        """
        with pytest.raises(DemistoException, match="'group_id' argument is required"):
            zia_ip_destination_group_delete_command(mock_client, {})

    def test_delete_success(self, mock_client, mocker):
        """
        Given: A valid group_id is provided.
        When: zia_ip_destination_group_delete_command is called.
        Then: The group is deleted and a success message is returned.
        """
        mocker.patch.object(mock_client, "delete_ip_destination_group", return_value=None)
        result = zia_ip_destination_group_delete_command(mock_client, {"group_id": "1001"})
        assert "successfully deleted" in result.readable_output


# ---- Unit tests: users ----


class TestUserCommands:
    def test_list_all(self, mock_client, mocker):
        """
        Given: The ZIA API returns a list of two users.
        When: zia_user_list_command is called with no arguments.
        Then: The result has the correct outputs_prefix, two users in the raw response,
            and the first user's name appears in the human-readable output.
        """
        data = load_json("/test_data/responses/users.json")
        _patch_api(mocker, mock_client, data)
        result = zia_user_list_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.User"
        assert isinstance(result.raw_response, list)
        assert len(result.raw_response) == 2  # type: ignore[arg-type]
        assert "John Doe" in result.readable_output

    def test_update_requires_user_id(self, mock_client):
        """
        Given: The user_id argument is missing.
        When: zia_user_update_command is called.
        Then: A DemistoException is raised indicating user_id is required.
        """
        with pytest.raises(DemistoException, match="'user_id' argument is required"):
            zia_user_update_command(mock_client, {})

    def test_update_success(self, mock_client, mocker):
        """
        Given: A valid user_id and a new comments value are provided.
        When: zia_user_update_command is called.
        Then: The user is updated, the success heading appears in the output,
            and the raw_response matches the updated user data.
        """
        existing = {"id": 1, "name": "John Doe", "email": "john@example.com", "comments": ""}
        updated = {**existing, "comments": "Updated comment"}
        mocker.patch.object(mock_client, "get_users", return_value=existing)
        mocker.patch.object(mock_client, "update_user", return_value=updated)
        result = zia_user_update_command(mock_client, {"user_id": "1", "comments": "Updated comment"})
        assert "ZIA User Updated" in result.readable_output
        assert result.raw_response == updated  # type: ignore[comparison-overlap]

    def test_update_with_json_user(self, mock_client, mocker):
        """
        Given: A valid user_id and a full JSON user object are provided.
        When: zia_user_update_command is called.
        Then: The JSON object is used as the base payload and the user is updated accordingly.
        """
        existing = {"id": 1, "name": "John Doe", "email": "john@example.com"}
        updated = {"id": 1, "name": "New Name", "email": "john@example.com"}
        mocker.patch.object(mock_client, "get_users", return_value=existing)
        mocker.patch.object(mock_client, "update_user", return_value=updated)
        result = zia_user_update_command(
            mock_client, {"user_id": "1", "user": '{"id": 1, "name": "New Name", "email": "john@example.com"}'}
        )
        assert result.raw_response == updated  # type: ignore[comparison-overlap]

    def test_update_invalid_json_raises(self, mock_client, mocker):
        """
        Given: A user_id is provided and the 'user' argument contains invalid JSON.
        When: zia_user_update_command is called.
        Then: A DemistoException is raised indicating the JSON is invalid.
        """
        mocker.patch.object(mock_client, "get_users", return_value={"id": 1})
        with pytest.raises(DemistoException, match="Invalid JSON"):
            zia_user_update_command(mock_client, {"user_id": "1", "user": "not-json"})


# ---- Unit tests: groups ----


class TestGroupsCommand:
    def test_list_groups(self, mock_client, mocker):
        """
        Given: The ZIA API returns a list of two user groups.
        When: zia_groups_list_command is called with no arguments.
        Then: The result has the correct outputs_prefix, two groups in the raw response,
            and the first group's name appears in the human-readable output.
        """
        data = load_json("/test_data/responses/groups.json")
        _patch_api(mocker, mock_client, data)
        result = zia_groups_list_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.Groups"
        assert isinstance(result.raw_response, list)
        assert len(result.raw_response) == 2  # type: ignore[arg-type]
        assert "Engineering" in result.readable_output


# ---- Unit tests: departments ----


class TestDepartmentsCommand:
    def test_list_departments(self, mock_client, mocker):
        """
        Given: The ZIA API returns a list of two departments.
        When: zia_departments_list_command is called with no arguments.
        Then: The result has the correct outputs_prefix, two departments in the raw response,
            and the first department's name appears in the human-readable output.
        """
        data = load_json("/test_data/responses/departments.json")
        _patch_api(mocker, mock_client, data)
        result = zia_departments_list_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.Department"
        assert isinstance(result.raw_response, list)
        assert len(result.raw_response) == 2  # type: ignore[arg-type]
        assert "IT" in result.readable_output


# ---- Unit tests: sandbox report ----


class TestSandboxReportCommand:
    def test_malicious_report(self, mock_client, mocker):
        """
        Given: The ZIA Sandbox API returns a report classifying the file as MALICIOUS.
        When: zia_sandbox_report_get_command is called with a valid MD5 hash.
        Then: The result has the correct outputs_prefix, a non-None indicator,
            a BAD DBotScore, and "Malicious" appears in the human-readable output.
        """
        data = load_json("/test_data/responses/sandbox_report.json")
        _patch_api(mocker, mock_client, data)
        result = zia_sandbox_report_get_command(
            mock_client, {"md5": "abc123def456abc123def456abc123de", "report_type": "summary"}
        )
        assert result.outputs_prefix == "ZIA.SandboxReport"
        assert result.indicator is not None
        # Access dbot_score via to_context() to avoid Pylance issues with base Indicator type
        ctx = result.to_context()
        dbot_entries = ctx.get("EntryContext", {}).get("DBotScore(val.Indicator && val.Indicator == obj.Indicator)", [])
        if isinstance(dbot_entries, list) and dbot_entries:
            assert dbot_entries[0].get("Score") == Common.DBotScore.BAD
        assert "3" in result.readable_output  # "3" is the severity level for "Malicious" in XSOAR

    def test_requires_md5(self, mock_client):
        """
        Given: The md5 argument is missing.
        When: zia_sandbox_report_get_command is called.
        Then: A DemistoException is raised indicating md5 is required.
        """
        with pytest.raises(DemistoException, match="'md5' argument is required"):
            zia_sandbox_report_get_command(mock_client, {})


# ---- Unit tests: activate changes ----


class TestActivateChangesCommand:
    def test_activate(self, mock_client, mocker):
        """
        Given: The ZIA API returns an activation status of ACTIVE.
        When: zia_activate_changes_command is called.
        Then: The result has the correct outputs_prefix, the raw_response matches the API data,
            and "ACTIVE" appears in the human-readable output.
        """
        _patch_api(mocker, mock_client, {"status": "ACTIVE"})
        result = zia_activate_changes_command(mock_client, {})
        assert result.outputs_prefix == "ZIA.ActivationStatus"
        assert result.raw_response == {"status": "ACTIVE"}  # type: ignore[comparison-overlap]
        assert "ACTIVE" in result.readable_output


# ---- Unit tests: URL/IP/Domain lookup commands ----


class TestLookupCommands:
    def test_url_command_bad(self, mock_client, mocker):
        """
        Given: The ZIA API returns a classification with a non-suspicious security alert for a URL.
        When: url_command is called with two URLs.
        Then: Two results are returned and the malicious URL has a BAD DBotScore.
        """
        data = load_json("/test_data/responses/url_lookup.json")
        mocker.patch.object(mock_client, "url_lookup", return_value=data)
        results = url_command(mock_client, {"url": "malicious-site.com,safe-site.com"})
        assert len(results) == 2
        bad_result = next(r for r in results if "malicious-site.com" in r.readable_output)
        # Verify via to_context that DBotScore is BAD
        ctx = bad_result.to_context()
        dbot_key = next((k for k in ctx.get("EntryContext", {}) if "DBotScore" in k), None)
        assert dbot_key is not None
        dbot_val = ctx["EntryContext"][dbot_key]
        score = dbot_val.get("Score") if isinstance(dbot_val, dict) else dbot_val[0].get("Score")
        assert score == Common.DBotScore.BAD

    def test_url_command_good(self, mock_client, mocker):
        """
        Given: The ZIA API returns a benign classification with no security alert for a URL.
        When: url_command is called with two URLs.
        Then: The benign URL has a GOOD DBotScore.
        """
        data = load_json("/test_data/responses/url_lookup.json")
        mocker.patch.object(mock_client, "url_lookup", return_value=data)
        results = url_command(mock_client, {"url": "malicious-site.com,safe-site.com"})
        good_result = next(r for r in results if "safe-site.com" in r.readable_output)
        ctx = good_result.to_context()
        dbot_key = next((k for k in ctx.get("EntryContext", {}) if "DBotScore" in k), None)
        assert dbot_key is not None
        dbot_val = ctx["EntryContext"][dbot_key]
        score = dbot_val.get("Score") if isinstance(dbot_val, dict) else dbot_val[0].get("Score")
        assert score == Common.DBotScore.GOOD

    def test_ip_command(self, mock_client, mocker):
        """
        Given: The ZIA API returns a benign classification for an IP address.
        When: ip_command is called with a single IP.
        Then: One result is returned and the IP address appears in the human-readable output.
        """
        data = [{"url": "8.8.8.8", "urlClassifications": ["DNS"], "urlClassificationsWithSecurityAlert": []}]
        mocker.patch.object(mock_client, "url_lookup", return_value=data)
        results = ip_command(mock_client, {"ip": "8.8.8.8"})
        assert len(results) == 1
        assert "8.8.8.8" in results[0].readable_output

    def test_domain_command(self, mock_client, mocker):
        """
        Given: The ZIA API returns a benign classification for a domain.
        When: domain_command is called with a single domain.
        Then: One result is returned and the domain appears in the human-readable output.
        """
        data = [{"url": "example.com", "urlClassifications": ["BUSINESS_AND_ECONOMY"], "urlClassificationsWithSecurityAlert": []}]
        mocker.patch.object(mock_client, "url_lookup", return_value=data)
        results = domain_command(mock_client, {"domain": "example.com"})
        assert len(results) == 1
        assert "example.com" in results[0].readable_output

    def test_url_command_suspicious(self, mock_client, mocker):
        """
        Given: The ZIA API returns a security alert category that is in the suspicious list.
        When: url_command is called with the URL.
        Then: The result has a SUSPICIOUS DBotScore.
        """
        data = [
            {
                "url": "spyware-site.com",
                "urlClassifications": ["SPYWARE_OR_ADWARE"],
                "urlClassificationsWithSecurityAlert": ["SPYWARE_OR_ADWARE"],
            }
        ]
        mocker.patch.object(mock_client, "url_lookup", return_value=data)
        results = url_command(mock_client, {"url": "spyware-site.com"})
        ctx = results[0].to_context()
        dbot_key = next((k for k in ctx.get("EntryContext", {}) if "DBotScore" in k), None)
        assert dbot_key is not None
        dbot_val = ctx["EntryContext"][dbot_key]
        score = dbot_val.get("Score") if isinstance(dbot_val, dict) else dbot_val[0].get("Score")
        assert score == Common.DBotScore.SUSPICIOUS

    def test_url_command_empty_response(self, mock_client, mocker):
        """
        Given: The ZIA API returns an empty list for the URL lookup.
        When: url_command is called.
        Then: A single result with "No results found" in the readable output is returned.
        """
        mocker.patch.object(mock_client, "url_lookup", return_value=[])
        results = url_command(mock_client, {"url": "test.com"})
        assert len(results) == 1
        assert "No results found" in results[0].readable_output


# ---- Unit tests: client allowlist update logic ----


class TestClientAllowlistUpdate:
    def test_add_to_list(self, mock_client, mocker):
        """
        Given: The current allowlist contains "a.com" and "b.com" is being added.
        When: update_allowlist is called with action=ADD_TO_LIST.
        Then: Both "a.com" and "b.com" are present in the PUT payload sent to the API.
        """
        mocker.patch.object(mock_client, "get_allowlist", return_value={"whitelistUrls": ["a.com"]})
        api_mock = mocker.patch.object(mock_client, "api_request", return_value=None)
        mock_client.update_allowlist(["b.com"], "ADD_TO_LIST")
        sent = api_mock.call_args[1]["data"]
        assert "a.com" in sent["whitelistUrls"]
        assert "b.com" in sent["whitelistUrls"]

    def test_remove_from_list(self, mock_client, mocker):
        """
        Given: The current allowlist contains "a.com" and "b.com", and "a.com" is being removed.
        When: update_allowlist is called with action=REMOVE_FROM_LIST.
        Then: "a.com" is absent and "b.com" is present in the PUT payload sent to the API.
        """
        mocker.patch.object(mock_client, "get_allowlist", return_value={"whitelistUrls": ["a.com", "b.com"]})
        api_mock = mocker.patch.object(mock_client, "api_request", return_value=None)
        mock_client.update_allowlist(["a.com"], "REMOVE_FROM_LIST")
        sent = api_mock.call_args[1]["data"]
        assert "a.com" not in sent["whitelistUrls"]
        assert "b.com" in sent["whitelistUrls"]

    def test_overwrite(self, mock_client, mocker):
        """
        Given: The current allowlist contains "a.com" and "b.com", and OVERWRITE with "c.com" is requested.
        When: update_allowlist is called with action=OVERWRITE.
        Then: Only "c.com" is present in the PUT payload sent to the API.
        """
        mocker.patch.object(mock_client, "get_allowlist", return_value={"whitelistUrls": ["a.com", "b.com"]})
        api_mock = mocker.patch.object(mock_client, "api_request", return_value=None)
        mock_client.update_allowlist(["c.com"], "OVERWRITE")
        sent = api_mock.call_args[1]["data"]
        assert sent["whitelistUrls"] == ["c.com"]


# ---- Unit tests: 401 retry logic ----


def _make_401_exception() -> DemistoException:
    """Build a DemistoException whose .res.status_code is 401, matching the
    production error-handler output."""
    exc = DemistoException("Authentication/Authorization error (401): Unauthorized.")
    mock_res = type("MockResponse", (), {"status_code": 401})()
    exc.res = mock_res
    return exc


def _make_500_exception() -> DemistoException:
    """Build a DemistoException whose .res.status_code is 500."""
    exc = DemistoException("The request failed with status code 500.")
    mock_res = type("MockResponse", (), {"status_code": 500})()
    exc.res = mock_res
    return exc


class TestApiRequest401Retry:
    def test_401_clears_token_and_retries(self, mock_client, mocker):
        """
        Given: The first API call raises a DemistoException with res.status_code == 401
               (simulating a stale cached token due to clock drift).
        When: api_request is called.
        Then: The cached token is cleared from integration context, a new token
              is fetched, and the request is retried exactly once, returning the
              successful response from the retry.
        """
        success_response = {"data": "ok"}

        mock_do_http = mocker.patch.object(
            mock_client,
            "_do_http_request",
            side_effect=[
                _make_401_exception(),
                success_response,
            ],
        )
        mock_set_ctx = mocker.patch("ZscalerZIdentity.set_integration_context")

        result = mock_client.api_request("GET", "/some/endpoint")

        assert result == success_response
        assert mock_do_http.call_count == 2
        # Verify the cached token was cleared from integration context
        cleared_ctx = mock_set_ctx.call_args[0][0]
        assert "access_token" not in cleared_ctx
        assert "token_expires_at" not in cleared_ctx

    def test_non_401_exception_is_reraised(self, mock_client, mocker):
        """
        Given: The API call raises a DemistoException with res.status_code == 500
               (not a 401 auth error).
        When: api_request is called.
        Then: The exception is re-raised without retrying and without clearing
              the integration context.
        """
        mocker.patch.object(
            mock_client,
            "_do_http_request",
            side_effect=_make_500_exception(),
        )
        mock_set_ctx = mocker.patch("ZscalerZIdentity.set_integration_context")

        with pytest.raises(DemistoException, match="500"):
            mock_client.api_request("GET", "/some/endpoint")

        mock_set_ctx.assert_not_called()

    def test_401_retry_also_fails_raises_exception(self, mock_client, mocker):
        """
        Given: Both the initial request and the retry after token refresh raise
               a DemistoException with res.status_code == 401.
        When: api_request is called.
        Then: The exception from the retry is propagated to the caller.
        """
        mocker.patch.object(
            mock_client,
            "_do_http_request",
            side_effect=[
                _make_401_exception(),
                _make_401_exception(),
            ],
        )
        mocker.patch("ZscalerZIdentity.set_integration_context")

        with pytest.raises(DemistoException, match="401"):
            mock_client.api_request("GET", "/some/endpoint")

    def test_successful_request_does_not_clear_token(self, mock_client, mocker):
        """
        Given: The API call succeeds on the first attempt (no 401).
        When: api_request is called.
        Then: The integration context is never modified (token is not cleared).
        """
        success_response = {"data": "success"}
        mocker.patch.object(mock_client, "_do_http_request", return_value=success_response)
        mock_set_ctx = mocker.patch("ZscalerZIdentity.set_integration_context")

        result = mock_client.api_request("GET", "/some/endpoint")

        assert result == success_response
        mock_set_ctx.assert_not_called()

    def test_exception_without_res_attribute_is_reraised(self, mock_client, mocker):
        """
        Given: The API call raises a DemistoException that has no .res attribute
               (e.g. a network-level error before any HTTP response).
        When: api_request is called.
        Then: The exception is re-raised without retrying and without clearing
              the integration context.
        """
        exc = DemistoException("Connection error: no response received.")
        # No .res attribute set — simulates a network-level failure
        mocker.patch.object(mock_client, "_do_http_request", side_effect=exc)
        mock_set_ctx = mocker.patch("ZscalerZIdentity.set_integration_context")

        with pytest.raises(DemistoException, match="Connection error"):
            mock_client.api_request("GET", "/some/endpoint")

        mock_set_ctx.assert_not_called()


class TestErrorHandler:
    """Covers the authentication branch of Client._error_handler, which must attach the
    originating response to the raised DemistoException so that api_request's 401 retry
    logic can detect it."""

    @staticmethod
    def _mock_response(status_code: int, text: str = "Unauthorized"):
        """Build a minimal stand-in for a requests.Response as consumed by _error_handler."""
        return type("MockResponse", (), {"status_code": status_code, "text": text})()

    @pytest.mark.parametrize("status_code", [401, 403])
    def test_auth_error_attaches_response_to_exception(self, mock_client, status_code):
        """
        Given: The API returns an authentication/authorization failure (401 or 403).
        When: _error_handler is called with that response.
        Then: The raised DemistoException carries the originating response as .res,
              so callers can inspect the status code.
        """
        res = self._mock_response(status_code)

        with pytest.raises(DemistoException) as exc_info:
            mock_client._error_handler(res)

        assert exc_info.value.res is res

    def test_401_from_error_handler_triggers_token_refresh_and_retry(self, mock_client, mocker):
        """
        Given: A 401 exception produced by the real _error_handler rather than a
               hand-built one.
        When: api_request receives it from the underlying request.
        Then: The retry logic recognizes the 401, clears the cached token and retries,
              proving the contract between _error_handler and api_request holds.
        """
        with pytest.raises(DemistoException) as exc_info:
            mock_client._error_handler(self._mock_response(401))

        success_response = {"data": "ok"}
        mock_do_http = mocker.patch.object(
            mock_client,
            "_do_http_request",
            side_effect=[exc_info.value, success_response],
        )
        mock_set_ctx = mocker.patch("ZscalerZIdentity.set_integration_context")

        result = mock_client.api_request("GET", "/some/endpoint")

        assert result == success_response
        assert mock_do_http.call_count == 2
        cleared_ctx = mock_set_ctx.call_args[0][0]
        assert "access_token" not in cleared_ctx
        assert "token_expires_at" not in cleared_ctx