Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Zscaler

Publisher: Splunk <br>
Connector Version: 3.0.4 <br>
Connector Version: 3.1.0 <br>
Product Vendor: Zscaler <br>
Product Name: Zscaler <br>
Minimum Product Version: 6.2.2
Expand All @@ -10,6 +10,13 @@ This app implements containment and investigative actions on Zscaler

**NOTE:** Zscaler is deprecating https://admin.<Zscaler Cloud Name> for Public API traffic in September. In the app's asset settings, update the Base URL to https://zsapi.<Zscaler Cloud Name> to ensure the app continues working.

This app supports either authentication method below (one complete method is required):

- **API session authentication:** `username`, `password`, and `api_key`
- **OAuth client credentials:** `oauth_token_url`, `oauth_client_id`, and `oauth_client_secret`

If neither method is fully configured, the app returns an authentication error.

Below points are considered for providing the **URL Category** parameter value.

- Entire URL category string has to be mentioned in block letters
Expand Down Expand Up @@ -78,9 +85,12 @@ This table lists the configuration variables required to operate Zscaler. These
VARIABLE | REQUIRED | TYPE | DESCRIPTION
-------- | -------- | ---- | -----------
**base_url** | required | string | Base URL (e.g. https://zsapi.zscaler_instance.net) |
**api_key** | required | password | API Key |
**username** | required | string | Username |
**password** | required | password | Password |
**api_key** | optional | password | API Key |
**username** | optional | string | Username |
**password** | optional | password | Password |
**oauth_token_url** | optional | string | OAuth Token URL |
**oauth_client_id** | optional | string | OAuth Client ID |
**oauth_client_secret** | optional | password | OAuth Client Secret |
**sandbox_base_url** | optional | string | Sandbox Base URL |
**sandbox_api_token** | optional | password | Sandbox API Token |

Expand Down
7 changes: 7 additions & 0 deletions manual_readme_content.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
**NOTE:** Zscaler is deprecating https://admin.<Zscaler Cloud Name> for Public API traffic in September. In the app's asset settings, update the Base URL to https://zsapi.<Zscaler Cloud Name> to ensure the app continues working.

This app supports either authentication method below (one complete method is required):

- **API session authentication:** `username`, `password`, and `api_key`
- **OAuth client credentials:** `oauth_token_url`, `oauth_client_id`, and `oauth_client_secret`

If neither method is fully configured, the app returns an authentication error.

Below points are considered for providing the **URL Category** parameter value.

- Entire URL category string has to be mentioned in block letters
Expand Down
1 change: 1 addition & 0 deletions release_notes/unreleased.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
**Unreleased**
* Added OAuth support
32 changes: 26 additions & 6 deletions zscaler.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@
"product_name": "Zscaler",
"product_version_regex": ".*",
"publisher": "Splunk",
"contributors": [
{
"name": "Andrei Irimia"
}
],
"license": "Copyright (c) 2017-2026 Splunk Inc.",
"app_version": "3.0.4",
"app_version": "3.1.0",
"utctime_updated": "2026-07-21T16:35:40.352247Z",
"package_name": "phantom_zscaler",
"main_module": "zscaler_connector.py",
Expand All @@ -31,30 +36,45 @@
"api_key": {
"description": "API Key",
"data_type": "password",
"required": true,
"order": 1
},
"username": {
"description": "Username",
"data_type": "string",
"required": true,
"order": 2
},
"password": {
"description": "Password",
"data_type": "password",
"required": true,
"order": 3
},
"oauth_token_url": {
"description": "OAuth Token URL",
"data_type": "string",
"required": false,
"order": 4
},
"oauth_client_id": {
"description": "OAuth Client ID",
"data_type": "string",
"required": false,
"order": 5
},
"oauth_client_secret": {
"description": "OAuth Client Secret",
"data_type": "password",
"required": false,
"order": 6
},
"sandbox_base_url": {
"data_type": "string",
"description": "Sandbox Base URL",
"order": 4
"order": 7
},
"sandbox_api_token": {
"data_type": "password",
"description": "Sandbox API Token",
"order": 5
"order": 8
}
},
"actions": [
Expand Down
178 changes: 159 additions & 19 deletions zscaler_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@
import subprocess
import sys
import time
from datetime import datetime, timedelta
from urllib.parse import quote

import encryption_helper
import phantom.app as phantom
import phantom.rules as phantom_rules
import requests
from bs4 import BeautifulSoup
from phantom.action_result import ActionResult
from phantom.base_connector import BaseConnector
from requests.auth import HTTPBasicAuth

from zscaler_consts import *

Expand Down Expand Up @@ -84,6 +87,12 @@ def __init__(self):
self._headers = None
self._category = None
self._retry_rest_call = None # Retry rest call when get status_code 409 or 429
self._oauth_token_url = None
self._oauth_client_id = None
self._oauth_client_secret = None
self._oauth_access_token = None
self._oauth_token_expiry_time = None
self._use_oauth = False

def _get_err_msg_from_exception(self, e):
"""
Expand Down Expand Up @@ -342,27 +351,121 @@ def _obfuscate_api_key(self, api_key):
return now, key

def _init_session(self):
username = self._username
password = self._password
api_key = self._api_key
try:
timestamp, obf_api_key = self._obfuscate_api_key(api_key)
except Exception:
return self.set_status(phantom.APP_ERROR, "Error obfuscating API key")
"""Attempts to authenticate with Zscaler using either OAuth or API key authentication based on the asset configuration parameters"""
action_result = ActionResult()

body = {"apiKey": obf_api_key, "username": username, "password": password, "timestamp": timestamp}
# Use OAuth if OAuth credentials are provided
if self._use_oauth:
self.save_progress("Using OAuth for authentication")
access_token = self._generate_oauth_access_token(action_result)
if not access_token:
return self.set_status(phantom.APP_ERROR, "Error generating OAuth access token")

action_result = ActionResult()
ret_val, _ = self._make_rest_call_helper("/api/v1/authenticatedSession", action_result, data=body, method="post")
if phantom.is_fail(ret_val):
self.debug_print(f"Error starting Zscaler session: {action_result.get_message()}")
return self.set_status(phantom.APP_ERROR, f"Error starting Zscaler session: {action_result.get_message()}")
else:
self.save_progress("Successfully started Zscaler session")
self._headers = {"cookie": self._response.headers["Set-Cookie"].split(";")[0].strip()}
self._headers = {"Authorization": f"Bearer {access_token}"}
return phantom.APP_SUCCESS
else: # Fallback to API key authentication
try:
timestamp, obf_api_key = self._obfuscate_api_key(self._api_key)
except Exception:
return self.set_status(phantom.APP_ERROR, "Error obfuscating API key")

body = {
"apiKey": obf_api_key,
"username": self._username,
"password": self._password,
"timestamp": timestamp,
}

ret_val, _ = self._make_rest_call_helper("/api/v1/authenticatedSession", action_result, data=body, method="post")
if phantom.is_fail(ret_val):
self.debug_print(f"Error starting Zscaler session: {action_result.get_message()}")
return self.set_status(
phantom.APP_ERROR,
f"Error starting Zscaler session: {action_result.get_message()}",
)
else:
self.save_progress("Successfully started Zscaler session")
self._headers = {"cookie": self._response.headers["Set-Cookie"].split(";")[0].strip()}
return phantom.APP_SUCCESS

def _generate_oauth_access_token(self, action_result: ActionResult) -> str | None:
"""Generates OAuth access token using asset configuration parameters

:param action_result: ActionResult object to set status and debug data
:return: access token if successful, None otherwise
"""
# Use existing token if valid
if self._oauth_access_token and self._oauth_token_expiry_time and datetime.now() < self._oauth_token_expiry_time:
self.save_progress("OAuth access token is still valid, using existing token")
return self._oauth_access_token

self.save_progress("Generating OAuth access token")

# Generate new token by making a request to the token URL
self.save_progress("Generating new OAuth access token")
payload = {"grant_type": "client_credentials"}
try:
response = requests.post(
self._oauth_token_url,
data=payload,
auth=HTTPBasicAuth(self._oauth_client_id, self._oauth_client_secret),
timeout=ZSCALER_DEFAULT_TIMEOUT,
)
except Exception as e:
action_result.set_status(
phantom.APP_ERROR,
f"Error generating OAuth access token: {self._get_err_msg_from_exception(e)}",
)
return None

# Cache the token using the expires_in value returned by the OAuth token endpoint
try:
response_json = response.json()
self._oauth_access_token = response_json.get(ZSCALER_OAUTH_ACCESS_TOKEN_KEY)
expires_in = response_json.get("expires_in")
if self._oauth_access_token and isinstance(expires_in, (int, float)) and expires_in > 0:
self._oauth_token_expiry_time = datetime.now() + timedelta(seconds=int(expires_in))
else:
self._oauth_token_expiry_time = None
except Exception as e:
action_result.set_status(
phantom.APP_ERROR,
f"Error parsing OAuth access token from response: {self._get_err_msg_from_exception(e)}",
)
return None

# If we don't have an access token at this point, something went wrong
if not self._oauth_access_token:
action_result.set_status(phantom.APP_ERROR, "OAuth access token not found in response")
return None

self.save_progress("Successfully generated OAuth access token")
return self._oauth_access_token

def encrypt(self, encrypt_var, token_name):
"""Handle encryption of token

:param encrypt_var: Variable that needs to be encrypted
:param token_name: Name of the token to be encrypted, used for logging purposes
:return: encrypted variable
"""
self.debug_print(f"Encrypting the {token_name} token")
return encryption_helper.encrypt(encrypt_var, self.get_asset_id())

def decrypt(self, decrypt_var, token_name):
"""Handle decryption of token

:param decrypt_var: Variable needs to be decrypted
:param token_name: Name of the token to be decrypted, used for logging purposes
:return: decrypted variable
"""
self.debug_print(f"Decrypting the {token_name} token")
return encryption_helper.decrypt(decrypt_var, self.get_asset_id())

def _deinit_session(self):
if self._use_oauth:
return phantom.APP_SUCCESS

action_result = ActionResult()
config = self.get_config()
self._base_url = config["base_url"].rstrip("/")
Expand Down Expand Up @@ -1643,11 +1746,36 @@ def initialize(self):
self.debug_print("Resetting the state file with the default format")
self._state = {"app_version": self.get_app_json().get("app_version")}

# API authentication
config = self.get_config()
self._base_url = config["base_url"].rstrip("/")
self._username = config["username"]
self._password = config["password"]
self._api_key = config["api_key"]
self._username = config.get("username", None)
self._password = config.get("password", None)
self._api_key = config.get("api_key", None)

# OAuth
self._oauth_token_url = config.get(ZSCALER_OAUTH_TOKEN_URL_KEY, "").rstrip("/") or None
self._oauth_client_id = config.get(ZSCALER_OAUTH_CLIENT_ID_KEY, None)
self._oauth_client_secret = config.get(ZSCALER_OAUTH_CLIENT_SECRET_KEY, None)
self._oauth_access_token = self._state.get(ZSCALER_OAUTH_ACCESS_TOKEN_KEY, None)
oauth_token_expiry_time = self._state.get(ZSCALER_OAUTH_TOKEN_EXPIRY_TIME_KEY)
if oauth_token_expiry_time:
try:
self._oauth_token_expiry_time = datetime.fromisoformat(oauth_token_expiry_time)
except Exception as e:
self.debug_print(f"Failed to parse saved OAuth token expiry time: {e}")
self._oauth_token_expiry_time = None
self._use_oauth = self._oauth_token_url is not None and self._oauth_client_id is not None and self._oauth_client_secret is not None

# Decrypt the access token if it exists in the state and is encrypted
if self._use_oauth and self._oauth_access_token and self._state.get(ZSCALER_OAUTH_TOKEN_ENCRYPTED_KEY, False):
try:
self._oauth_access_token = self.decrypt(self._oauth_access_token, ZSCALER_OAUTH_ACCESS_TOKEN_KEY)
self._state[ZSCALER_OAUTH_TOKEN_ENCRYPTED_KEY] = False
except Exception as e:
self.debug_print(ZSCALER_DECRYPTION_ERROR_MSG.format(e))
return self.set_status(phantom.APP_ERROR, ZSCALER_DECRYPTION_ERROR_MSG.format(e))

self._sandbox_base_url = config.get("sandbox_base_url", None)
if self._sandbox_base_url:
self._sandbox_base_url = self._sandbox_base_url.rstrip("/")
Expand All @@ -1661,6 +1789,18 @@ def initialize(self):
return self._init_session()

def finalize(self):
try:
# Encrypt the access token before saving it to the state
if self._use_oauth and self._oauth_access_token:
self._state[ZSCALER_OAUTH_ACCESS_TOKEN_KEY] = self.encrypt(self._oauth_access_token, ZSCALER_OAUTH_ACCESS_TOKEN_KEY)
self._state[ZSCALER_OAUTH_TOKEN_ENCRYPTED_KEY] = True
self._state[ZSCALER_OAUTH_TOKEN_EXPIRY_TIME_KEY] = (
self._oauth_token_expiry_time.isoformat() if self._oauth_token_expiry_time else None
)
except Exception as e:
self.debug_print(ZSCALER_ENCRYPTION_ERROR_MSG.format(e))
return self.set_status(phantom.APP_ERROR, ZSCALER_ENCRYPTION_ERROR_MSG.format(e))

self.save_state(self._state)
return self._deinit_session()

Expand Down
10 changes: 10 additions & 0 deletions zscaler_consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
ZSCALER_MAX_PAGESIZE = 1000
ZSCALER_DEFAULT_TIMEOUT = 30
ZSCALER_MAX_RETRY_WAIT_SECONDS = 60
ZSCALER_ENCRYPTION_ERROR_MSG = "Error occurred while encrypting access token: {}"
ZSCALER_DECRYPTION_ERROR_MSG = "Error occurred while decrypting access token: {}"

# Constants relating to OAuth
ZSCALER_OAUTH_TOKEN_URL_KEY = "oauth_token_url"
ZSCALER_OAUTH_CLIENT_ID_KEY = "oauth_client_id"
ZSCALER_OAUTH_CLIENT_SECRET_KEY = "oauth_client_secret" # pragma: allowlist secret
ZSCALER_OAUTH_ACCESS_TOKEN_KEY = "access_token"
ZSCALER_OAUTH_TOKEN_ENCRYPTED_KEY = "is_encrypted"
ZSCALER_OAUTH_TOKEN_EXPIRY_TIME_KEY = "expiry_time"

# Constants relating to "_validate_integer"
ZSCALER_VALID_INTEGER_MSG = "Please provide a valid integer value in the {param}"
Expand Down
Loading