SOCFWPackManager
Downloads a SOC Framework content pack from a ZIP URL and installs it on the tenant as system content. Used internally by the SOCFWPackManager script — end users invoke the script, not this integration directly.
Utilities · SOC Framework Pack Manager
Details
| ID | SOCFWPackManager |
|---|---|
| Provider | SOC Framework |
| Category | Utilities |
| From Version | 8.0.0 |
| Docker Image | demisto/demisto-sdk:1.38.14.6091684 |
| Supported Modules | Agentix XSIAM |
README
SOC Framework Pack Manager — internal HTTP layer used by the SOCFWPackManager
script to install SOC Framework content packs as system content. End users do
not invoke this integration directly.
Architecture
This integration is paired with the SOCFWPackManager script in the same
pack. The script reads the SOC Framework pack catalog, sequences pack
installs, configures integration instances and jobs, and synchronizes the
value_tags lookup. Because Cortex XSIAM scripts can call
demisto.executeCommand, all orchestration lives there.
This integration stores the tenant URL, credentials, TLS verification setting,
and the pack catalog location. It exposes two commands: socfw-install-pack,
which downloads a pack ZIP and uploads it as system content, and
socfw-catalog-url-get, which returns the configured catalog location so the
script can read it. Cortex XSIAM integrations cannot call
demisto.executeCommand, so the integration deliberately performs only the
work that needs raw HTTP.
End users run !SOCFWPackManager action=apply pack_id=... from the XSIAM
Playground. The script invokes socfw-install-pack on this integration
internally.
Configure SOC Framework Pack Manager on Cortex XSIAM
- Navigate to Settings > Configurations > API Keys and create a
Standard API key. - Copy the Key, the Key ID, and click Copy URL to capture the
tenant Server URL. - Navigate to Settings > Configurations > Integrations.
- Search for SOC Framework Pack Manager.
- Click Add instance to create and configure a new integration instance.
| Parameter | Description | Required |
|---|---|---|
| Server URL | The tenant API URL or tenant URL. The integration adds the api- prefix when it is missing. | True |
| API Key ID | The numeric ID of the Standard API key, shown in the API Keys table. | True |
| API Key | The secret value of the Standard API key. | True |
| Trust any certificate (not secure) | Whether to disable TLS certificate validation. Off by default. | False |
| Use system proxy settings | Whether to route HTTP traffic through the system proxy. Off by default. | False |
| Pack catalog URL | The location of the SOC Framework pack_catalog.json. Override to point at a fork or branch. Leave empty to use the SOC Framework repository default. | False |
- Click Test to validate the URL and credentials, then Done.
Commands
You can execute these commands from the Cortex XSIAM 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.
socfw-install-pack
Downloads a SOC Framework pack ZIP from the supplied URL and installs it on
the tenant as system content. Called by the SOCFWPackManager script — do not
invoke directly.
Base Command
socfw-install-pack
Input
| Argument Name | Description | Required |
|---|---|---|
| url | URL of the pack ZIP to install (typically a GitHub release asset). | Required |
| filename | Asset filename, including the .zip extension. Derived from the URL when omitted. |
Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| SOCFramework.PackInstall.filename | String | Installed pack filename. |
| SOCFramework.PackInstall.url | String | Source URL the pack was downloaded from. |
| SOCFramework.PackInstall.status | String | Install status (success on completion). |
| SOCFramework.PackInstall.response | Unknown | Raw response from the demisto-sdk upload step. |
Command example
!socfw-install-pack url=https://github.com/Palo-Cortex/secops-framework/releases/download/soc-optimization-unified-v3.6.3/soc-optimization-unified-v3.6.3.zip
Context Example
{
"SOCFramework": {
"PackInstall": {
"filename": "soc-optimization-unified-v3.6.3.zip",
"url": "https://github.com/Palo-Cortex/secops-framework/releases/download/soc-optimization-unified-v3.6.3/soc-optimization-unified-v3.6.3.zip",
"status": "success",
"response": {
"success": true,
"message": "Uploaded /home/demisto/Packs/soc-optimization-unified-v3.6.3"
}
}
}
}
Human Readable Output
Pack soc-optimization-unified-v3.6.3.zip installed successfully.
socfw-catalog-url-get
Returns the SOC Framework pack catalog URL configured on this instance. Called by the SOCFWPackManager script so the catalog location is set once on the instance instead of passed as an argument on every run.
Base Command
socfw-catalog-url-get
Input
| Argument Name | Description | Required |
| — | — | — |
Context Output
| Path | Type | Description |
|---|---|---|
| SOCFramework.PackManager.CatalogURL | String | The pack catalog URL configured on this instance, or the SOC Framework default when the field is empty. |
Configuration parameters
url— Server URL (required)credentials— API Key ID (required)insecure— Trust any certificate (not secure)proxy— Use system proxy settingscatalog_url— Pack catalog URL
Commands (2)
-
socfw-catalog-url-getReturns the SOC Framework pack catalog URL configured on this instance. Called by the SOCFWPackManager script so the catalog location is set once on the instance instead of passed as an argument on every run.
-
socfw-install-packDownload and install a SOC Framework pack ZIP as system content. Called by the SOCFWPackManager script — do not invoke directly.
"""SOC Framework Pack Manager integration. Internal HTTP layer for the SOCFWPackManager script. Downloads a SOC Framework content pack ZIP from a URL and uploads it to the tenant as system content. End users do not call this integration directly. The SOCFWPackManager script invokes ``socfw-install-pack`` on this integration. """ import os import re import shutil import tempfile import zipfile from pathlib import Path from urllib.parse import urlparse from typing import Any import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401,F403 INTEGRATION_NAME = "SOCFWPackManager" # Location of the SOC Framework pack catalog. Set on the instance so a fork or # branch can be used without editing the pack or passing an argument on every # run; this value is the fallback when the instance leaves the field empty. DEFAULT_CATALOG_URL = "https://raw.githubusercontent.com/Palo-Cortex/secops-framework/refs/heads/main/pack_catalog.json" # Trailing release-version suffix on a release asset filename, e.g. "-v3.11.2" # or "-v3.11.1-pr1008". The pre-release group is restricted to recognized tag # forms on purpose: allowing any word there would strip a legitimate trailing # component, turning "soc-v3-tools" into "soc". PACK_VERSION_SUFFIX = re.compile(r"-v\d+(?:\.\d+)*(?:-(?:pr|rc|alpha|beta|dev)\d*)?$", re.IGNORECASE) def pack_dir_name(filename: str) -> str: """Pack directory name for a release asset filename. The directory name becomes the pack ID on the tenant, so the version suffix has to be stripped. Keeping it installs every release as a separate pack -- soc-optimization-unified-v3.11.2 alongside soc-optimization-unified -- instead of upgrading the existing one in place. """ name = filename[:-4] if filename.lower().endswith(".zip") else filename return PACK_VERSION_SUFFIX.sub("", name).strip() # Hard cap on the size of a pack ZIP we will download or extract. # SOC Framework packs are small (a few MB); 500 MB leaves plenty of headroom # while bounding memory / disk usage for a wrong or malicious URL. MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024 # 500 MB DOWNLOAD_CHUNK_BYTES = 1024 * 1024 # 1 MB streaming chunks DEFAULT_DOWNLOAD_TIMEOUT = 300 # seconds DEFAULT_TEST_TIMEOUT = 15 # seconds # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- class ContentClient(BaseClient): """HTTP client for SOC Framework pack downloads and tenant connectivity. All HTTP traffic for this integration flows through this class so that timeout, TLS verification, and proxy settings are configured in exactly one place. The ``insecure`` and ``proxy`` integration parameters are threaded into ``BaseClient`` here and used uniformly for every request. """ def __init__( self, base_url: str, api_id: str, api_key: str, verify: bool, proxy: bool, ): # public_api endpoints are served from api-<tenant>.xdr... api_base = (base_url or "").rstrip("/") if "://api-" not in api_base: api_base = api_base.replace("://", "://api-", 1) super().__init__( base_url=api_base, verify=verify, proxy=proxy, headers={ "x-xdr-auth-id": str(api_id), "Authorization": api_key, }, ) self._verify = verify self._api_id = str(api_id) self._api_key = api_key self._raw_base_url = base_url.rstrip("/") self._api_base_url = api_base # -- connectivity ------------------------------------------------------- def test_connectivity(self) -> None: """Probe a low-cost public_api endpoint to confirm credentials work.""" self._http_request( method="POST", url_suffix="/public_api/v1/xql/get_datasets", json_data={"request_data": {}}, timeout=DEFAULT_TEST_TIMEOUT, ok_codes=(200, 207), resp_type="response", ) # -- pack download / upload -------------------------------------------- def stream_download_zip(self, url: str, dest_path: str) -> int: """Download a pack ZIP to ``dest_path`` with an enforced size cap. Reads ``Content-Length`` up front when present, then bounds the actual bytes written so a server that lies about (or omits) length cannot blow past the cap. Uses BaseClient TLS verify / proxy settings. Returns the number of bytes written. """ resp = self._http_request( method="GET", full_url=url, timeout=DEFAULT_DOWNLOAD_TIMEOUT, resp_type="response", stream=True, ) advertised = resp.headers.get("Content-Length") if advertised is not None: try: if int(advertised) > MAX_DOWNLOAD_BYTES: raise DemistoException(f"Pack ZIP exceeds size limit " f"({int(advertised)} bytes > {MAX_DOWNLOAD_BYTES})") except ValueError: # Non-integer Content-Length — fall through to streaming guard. pass written = 0 with open(dest_path, "wb") as fh: for chunk in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_BYTES): if not chunk: continue written += len(chunk) if written > MAX_DOWNLOAD_BYTES: fh.close() if os.path.exists(dest_path): os.unlink(dest_path) raise DemistoException(f"Pack ZIP exceeds size limit during download " f"(> {MAX_DOWNLOAD_BYTES} bytes)") fh.write(chunk) return written def upload_pack_as_system_content(self, pack_path: str) -> dict: """Upload a pack directory as system content via demisto-sdk. ``upload_content_entity(xsiam=True, zip=True)`` is the documented path for installing a pack as system content. Credentials and TLS verify flow in via process env so the SDK call sees the same configuration as ContentClient itself. """ self._set_sdk_env() # Imported lazily because demisto-sdk is heavy and only needed here. from demisto_sdk.commands.common.logger import logging_setup from demisto_sdk.commands.upload.upload import upload_content_entity # A CRITICAL console threshold suppresses the SDK's FAILED-UPLOADS # report, which is the only place the real reason for a failed upload # appears. Keep errors visible, and raise to DEBUG on demand. logging_setup( INTEGRATION_NAME, console_threshold="DEBUG" if is_debug_mode() else "ERROR", propagate=True, ) try: upload_content_entity( input=pack_path, zip=True, xsiam=True, insecure=(not self._verify), ) return {"success": True, "message": f"Uploaded {pack_path}"} except BaseException as exc: # demisto-sdk raises SystemExit (or its own Exit class) on # completion. Exit code 0 / None is success. code = getattr(exc, "code", getattr(exc, "exit_code", None)) if code is None: raise if str(code) not in ("0", "None"): raise DemistoException(f"demisto-sdk upload failed with exit code {code}: {exc}") from exc return {"success": True, "message": f"Uploaded {pack_path}"} # -- internals ---------------------------------------------------------- def _set_sdk_env(self) -> None: """Set env vars required by ``demisto-sdk upload_content_entity``.""" os.environ["DEMISTO_API_KEY"] = self._api_key os.environ["XSIAM_AUTH_ID"] = self._api_id os.environ["DEMISTO_BASE_URL"] = self._api_base_url os.environ["DEMISTO_SDK_IGNORE_CONTENT_WARNING"] = "1" os.environ["DEMISTO_SDK_SKIP_LOGGER_SETUP"] = "yes" os.environ["DEMISTO_SDK_OFFLINE_ENV"] = "False" os.environ["ARTIFACTS_FOLDER"] = "/tmp/artifacts" os.environ["DEMISTO_SDK_LOG_NO_COLORS"] = "true" # --------------------------------------------------------------------------- # Safe ZIP extraction (ZipSlip / path-traversal hardened) # --------------------------------------------------------------------------- def _safe_extract_zip(zip_path: str, target_dir: str) -> None: """Extract ``zip_path`` into ``target_dir`` rejecting any member that would escape the destination directory via ``..`` or absolute paths. Mitigates ZipSlip (CWE-22) and bounds total uncompressed size to the same cap used for the download. """ target_root = os.path.realpath(target_dir) with zipfile.ZipFile(zip_path, "r") as zf: # Pre-flight: validate every member path and size. total_uncompressed = 0 for info in zf.infolist(): member_name = info.filename if not member_name or member_name.endswith("/"): continue # directories handled by extractall # Reject absolute paths and Windows drive paths. if Path.is_absolute(Path(member_name)) or (len(member_name) > 1 and member_name[1] == ":"): # type: ignore[arg-type] raise DemistoException(f"Refusing to extract absolute path from ZIP: {member_name}") dest_path = os.path.realpath(os.path.join(target_root, member_name)) if dest_path != target_root and not dest_path.startswith(target_root + os.sep): raise DemistoException(f"Refusing to extract member outside destination " f"(ZipSlip): {member_name}") total_uncompressed += info.file_size if total_uncompressed > MAX_DOWNLOAD_BYTES: raise DemistoException("Pack ZIP uncompressed size exceeds limit " f"(> {MAX_DOWNLOAD_BYTES} bytes)") # Validation passed — safe to extract. zf.extractall(target_root) def _safe_flatten_one_level(pack_path: str) -> None: """Flatten ``pack_path/<single_root>/*`` up into ``pack_path/*``. Many pack ZIPs ship as ``<packname>/<files>``; this strips the leading directory. Every move target is validated to stay inside ``pack_path``. """ pack_root = os.path.realpath(pack_path) entries = os.listdir(pack_root) if len(entries) != 1: return inner = os.path.join(pack_root, entries[0]) if not os.path.isdir(inner): return inner_real = os.path.realpath(inner) if not inner_real.startswith(pack_root + os.sep): # Shouldn't happen post-extract, but verify anyway. return for item in os.listdir(inner_real): src = os.path.join(inner_real, item) dst = os.path.realpath(os.path.join(pack_root, item)) if dst != pack_root and not dst.startswith(pack_root + os.sep): raise DemistoException(f"Refusing to flatten file outside pack root: {item}") shutil.move(src, dst) Path(inner_real).rmdir() def _prepare_pack_dir(zip_path: str, filename: str) -> str: """Extract ``zip_path`` into ``Packs/<packname>/`` and flatten one level. Mirrors the layout demisto-sdk's ``upload_content_entity`` expects. Creates ``Tests/Marketplace/landingPage_sections.json`` to suppress SDK warnings during upload. """ pack_name = pack_dir_name(filename) packs_path = os.path.join(os.getcwd(), "Packs") pack_path = os.path.join(packs_path, pack_name) os.makedirs(pack_path, exist_ok=True) if not zipfile.is_zipfile(zip_path): raise DemistoException("Downloaded file is not a valid zip.") test_path = os.path.join(os.getcwd(), "Tests", "Marketplace") os.makedirs(test_path, exist_ok=True) landing_page = os.path.join(test_path, "landingPage_sections.json") if not os.path.exists(landing_page): with open(landing_page, "w") as fh: fh.write('{"sections": []}') # ZipSlip-hardened extract. _safe_extract_zip(zip_path, pack_path) # Confirm the pack actually has metadata before we hand it to the SDK. if not _has_pack_metadata(pack_path): raise DemistoException("Zip missing pack_metadata.json — not a valid pack.") _safe_flatten_one_level(pack_path) return pack_path def _has_pack_metadata(pack_path: str) -> bool: """True if pack_metadata.json is at pack root or one level deep.""" if os.path.isfile(os.path.join(pack_path, "pack_metadata.json")): return True for entry in os.listdir(pack_path): sub = os.path.join(pack_path, entry) if os.path.isdir(sub) and os.path.isfile(os.path.join(sub, "pack_metadata.json")): return True return False # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- def test_module(client: ContentClient) -> str: """Connectivity test for the integration instance.""" client.test_connectivity() return "ok" def install_pack_command(client: ContentClient, args: dict[str, Any]) -> CommandResults: """Download a pack ZIP from ``url`` and install it as system content. The ``url`` argument is declared ``required: true`` in the YAML, so the XSOAR engine rejects missing values before this function runs. """ url = (args.get("url") or "").strip() filename = (args.get("filename") or "").strip() if not filename: filename = url.rstrip("/").split("/")[-1] # Strip any path components from the filename to prevent the pack name # (derived from filename) from escaping Packs/ via "../" segments. filename = os.path.basename(filename) if not filename or filename in (".", ".."): raise DemistoException("filename argument resolves to an empty or unsafe value") if not filename.endswith(".zip"): filename += ".zip" tmp_dir = tempfile.mkdtemp() zip_path = os.path.join(tmp_dir, filename) try: client.stream_download_zip(url, zip_path) pack_path = _prepare_pack_dir(zip_path, filename) result = client.upload_pack_as_system_content(pack_path) return CommandResults( outputs_prefix="SOCFramework.PackInstall", outputs_key_field="filename", outputs={ "filename": filename, "url": url, "status": "success", "response": result, }, readable_output=f"Pack **{filename}** installed successfully.", ) finally: try: if os.path.exists(zip_path): os.unlink(zip_path) if os.path.isdir(tmp_dir): shutil.rmtree(tmp_dir, ignore_errors=True) except Exception: # pragma: no cover - cleanup is best-effort pass # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def _is_valid_catalog_url(url: str) -> bool: """Whether the value is an absolute http(s) URL with a hostname. A relative or scheme-less value resolves against the tenant host when the catalog is fetched, which surfaces as a confusing 404 from the tenant rather than an obvious misconfiguration of this parameter. """ try: parsed = urlparse(url) except ValueError: return False return parsed.scheme in ("http", "https") and bool(parsed.hostname) def get_catalog_url_command(params: dict[str, Any]) -> CommandResults: """Return the pack catalog URL configured on this instance. The SOCFWPackManager script cannot read another integration's instance parameters, so it reads the configured location through this command. That keeps the catalog location set once on the instance rather than passed as an argument on every run. An unusable configured value is reported and ignored rather than returned, because the caller would otherwise fetch it and fail somewhere less obvious. """ configured = (params.get("catalog_url") or "").strip() if configured and not _is_valid_catalog_url(configured): demisto.error(f"Configured pack catalog URL is not an absolute http(s) URL: {configured!r}") return CommandResults( outputs_prefix="SOCFramework.PackManager", outputs={"CatalogURL": DEFAULT_CATALOG_URL}, raw_response={"catalog_url": DEFAULT_CATALOG_URL}, readable_output=( f"⚠️ The configured pack catalog URL is not an absolute http(s) URL and was ignored: `{configured}`\n\n" f"Using the default instead: {DEFAULT_CATALOG_URL}" ), ) catalog_url = configured or DEFAULT_CATALOG_URL return CommandResults( outputs_prefix="SOCFramework.PackManager", outputs={"CatalogURL": catalog_url}, raw_response={"catalog_url": catalog_url}, readable_output=f"Pack catalog URL: {catalog_url}", ) def main() -> None: params = demisto.params() args = demisto.args() command = demisto.command() base_url = (params.get("url") or "").rstrip("/") creds = params.get("credentials") or {} api_id = str(creds.get("identifier") or "") api_key = creds.get("password") or "" insecure = argToBoolean(params.get("insecure", False)) proxy = argToBoolean(params.get("proxy", False)) client = ContentClient( base_url=base_url, api_id=api_id, api_key=api_key, verify=(not insecure), proxy=proxy, ) try: if command == "test-module": return_results(test_module(client)) elif command == "socfw-install-pack": return_results(install_pack_command(client, args)) elif command == "socfw-catalog-url-get": return_results(get_catalog_url_command(params)) else: raise NotImplementedError(f"Command not implemented: {command}") except Exception as exc: return_error(f"{INTEGRATION_NAME}: {exc}") if __name__ in ("__main__", "__builtin__", "builtins"): main()