Tenzai
Validate Cortex ASM-discovered exposures with Tenzai's agentic penetration testing. Create a scan for an exposure, poll it to completion, and fetch the verdict and evidence back into the Cortex issue.
Vulnerability Management · Tenzai
Details
| ID | Tenzai |
|---|---|
| Provider | Tenzai |
| Category | Vulnerability Management |
| From Version | 6.10.0 |
| Docker Image | demisto/python3:3.12.14.13053055 |
README
Validate Cortex ASM-discovered exposures with Tenzai’s agentic penetration testing. Create a scan for an exposure, poll it to completion, and fetch the verdict and evidence back into the Cortex issue.
Prerequisites
- A Tenzai license.
- A Tenzai partner API key. To obtain one:
- Sign in to the Tenzai application.
- Generate a partner API key for your tenant.
- Copy the key — you paste it into the integration instance below (it is stored encrypted).
Configure Tenzai in Cortex
| Parameter | Description | Required |
|---|---|---|
| Tenzai Server URL (e.g., https://api.tenzai.io) | The base URL of the Tenzai API. | True |
| API Key | The Tenzai partner API key, generated in the Tenzai application. Stored encrypted. | True |
| Use system proxy settings | False | |
| Trust any certificate (not secure) | False | |
| Tenzai App URL (e.g., https://app.tenzai.io) | The base URL of the Tenzai web app, used to build a deep link (referenceUrl) to the scan results. Leave empty to derive it from the Tenzai Server URL (the API and web-app hosts mirror each other); set it only to override that. | False |
| HTTP request timeout (seconds) | The per-request timeout for calls to the Tenzai API. Kept low so a stalled or unreachable host fails fast and the validation poll automation can reschedule instead of exceeding its execution timeout. | 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.
tenzai-create-scan
Create a Tenzai agentic scan for a Cortex ASM-discovered exposure. Finds or creates a Tenzai application for the target domain, then triggers an EXTERNAL_LEAD scan — a short, targeted confirm/refute of the single externally-reported exposure — scoped to the exposed socket. Returns a scan id used to poll status and fetch results.
Base Command
tenzai-create-scan
Input
| Argument Name | Description | Required |
|---|---|---|
| target | The exposure target — an IP address, FQDN, host:port, or URL. | Required |
| exposure_name | The human-readable name for the exposure (e.g. the ASM issue/alert name). Used as the scan name. | Required |
| supporting_data | The free-text context for the scan objective (e.g. inferred CVE(s), attack-surface rule, service classification, detected technology, certificate details). | Optional |
| application_type | The Tenzai application type to scan as. Derived from the target/service classification when omitted. Possible values are: WEB_APP, NETWORK_SERVICE, NETWORK_HOST. | Optional |
| port | The exposed service port. | Optional |
| protocol | The exposed service protocol (e.g. tcp, udp). | Optional |
| service_classification | The Cortex ASM service classification (e.g. WebServer, SshServer). | Optional |
| asm_service_id | The Cortex ASM ExternalService id (folded into the app guidelines as a correlation note). | Optional |
| alert_internal_id | The Cortex issue/alert id (folded into the app guidelines as a correlation note). | Optional |
| issue_description | The Cortex issue Description, folded into the application guidelines at create time. Also sent as the EXTERNAL_LEAD exposure description. | Optional |
| category | Whether the exposure is a CVE or a misconfiguration. Inferred from cve_id when omitted (cve when a CVE id is present, otherwise misconfiguration). Possible values are: cve, misconfiguration. | Optional |
| cve_id | The CVE identifier for the exposure (e.g. CVE-2018-15473), taken from the Cortex/ASM structured CVE field. Sets the EXTERNAL_LEAD category to cve. | Optional |
| rule_id | The external source’s rule identifier for the exposure (e.g. a Cortex attack-surface rule id). | Optional |
| severity | The severity as reported by Cortex (free text), attached to the EXTERNAL_LEAD exposure reference. | Optional |
| cwe | The CWE identifier for the exposure when Cortex supplies one. | Optional |
| guidelines | The optional analyst guidelines for this scan (free text). Appended to the synthesized scan guidelines; the exposure focus and the single-target scope lock are always kept. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Tenzai.Scan.id | String | The Tenzai scan (test) id. |
| Tenzai.Scan.applicationId | String | The Tenzai application id the scan runs under. |
| Tenzai.Scan.status | String | The initial status of the scan (e.g. Pending, Running). |
| Tenzai.Scan.alertId | String | The originating Cortex alert id the exposure lead was seeded with (re-supply to tenzai-get-scan-result to scope the verdict to this alert’s lead). |
| Tenzai.Scan.cve | String | The CVE id the exposure lead was seeded with, when the exposure is a CVE. |
| Tenzai.Scan.ruleId | String | The Cortex rule id the exposure lead was seeded with, when supplied. |
tenzai-get-scan
Poll the status of a Tenzai scan until it reaches a terminal state (Complete or Error).
Base Command
tenzai-get-scan
Input
| Argument Name | Description | Required |
|---|---|---|
| id | The Tenzai scan id (returned by tenzai-create-scan). | Required |
| interval_in_seconds | The interval, in seconds, between status polls. Default is 60. | Optional |
| timeout_in_seconds | The timeout, in seconds, for polling. Default is 3600. | Optional |
| hide_polling_output | Whether to hide the polling result while waiting (automatically filled by the platform). | Optional |
| polling | Whether to poll until the scan reaches a terminal state. Possible values are: true, false. Default is true. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Tenzai.Scan.id | String | The Tenzai scan id. |
| Tenzai.Scan.status | String | The scan status (Pending, Running, Complete, Error). |
tenzai-get-scan-result
Fetch the verdict and evidence of a completed Tenzai scan.
Base Command
tenzai-get-scan-result
Input
| Argument Name | Description | Required |
|---|---|---|
| id | The Tenzai scan id. | Required |
| alert_id | The originating Cortex alert id, used to correlate the verdict to this alert’s exposure lead on a multi-lead host scan. Strongest correlation key. | Optional |
| cve | The exposure’s CVE id, used (with rule_id) to correlate the verdict to the matching exposure lead when alert_id does not resolve. | Optional |
| rule_id | The Cortex rule id, used together with cve to disambiguate same-CVE sibling leads. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Tenzai.Scan.id | String | The Tenzai scan id. |
| Tenzai.Scan.applicationId | String | The Tenzai application id the scan ran under. |
| Tenzai.Scan.status | String | The scan status. |
| Tenzai.Scan.validated | Boolean | The tri-state verdict for the matched exposure lead — true when its status is MATERIALIZED, false when INVALIDATED, and null (no verdict) when BLOCKED, unresolved, or no lead correlated to the alert. |
| Tenzai.Scan.correlationState | String | The tri-state lead correlation for this alert: resolved (a lead matched), unmatched (leads fetched but none correlate — final), or pending (the leads fetch failed or returned none yet — transient). Drives the verdict write-back readiness gate. |
| Tenzai.Scan.evidence | String | The markdown assessment summary — impact-first per confirmed finding. |
| Tenzai.Scan.reproduction | String | The markdown reproduction steps (prerequisites, steps, scripts) across the scan’s findings. |
| Tenzai.Scan.guidance | String | The markdown remediation guidance (fix items and coding-agent prompt) across the scan’s findings. |
| Tenzai.Scan.creditUsage | Number | The approximate Tenzai ACU cost of the scan. |
| Tenzai.Scan.duration | Number | The wall-clock duration of the scan, in whole seconds. |
| Tenzai.Scan.referenceUrl | String | The deep link to view the scan results in the Tenzai web app. |
| Tenzai.Scan.exposureStatus | String | The exposure lead’s terminal status (e.g. MATERIALIZED, INVALIDATED, BLOCKED) — the literal lead status shown in the panel’s Status cell. |
| Tenzai.Scan.startedAt | Date | The date when the assessment started, as a full ISO-8601 timestamp (e.g., 2024-01-15T12:34:56Z) (the exposure lead’s earliest OPEN status-history entry). |
| Tenzai.Scan.cwe | String | The exposure lead’s CWE classification (e.g. CWE-79). |
| Tenzai.Scan.owaspCategory | String | The exposure lead’s OWASP category (e.g. A03). |
| Tenzai.Scan.leadRationale | String | The markdown Description/Conclusion narrative for a CVE exposure lead. |
| Tenzai.Scan.timeline | Unknown | The exposure lead’s status history — one entry per status change (status + time). |
| Tenzai.Finding.title | String | The finding title. |
| Tenzai.Finding.severity | String | The finding severity (uppercase). |
| Tenzai.Finding.details | String | The markdown assessment details for the finding — impact then description. |
| Tenzai.Finding.reproduction | String | The markdown reproduction steps for the finding. |
| Tenzai.Finding.guidance | String | The remediation guidance for the finding. |
| Tenzai.Finding.detail | String | The combined markdown (details + reproduction + fix guidance) shown in the Tenzai Findings grid’s Details cell. |
| Tenzai.Finding.cve | String | The finding’s CVE id, parsed from its structured field or name (display only). |
| Tenzai.Finding.attribution | String | The finding’s attribution relative to the matched exposure: own (this alert’s exposure), discovered (a different CVE found while testing the host), or unattributed (no lead correlated to the alert). |
Configuration parameters
url— Tenzai Server URL (e.g., https://api.tenzai.io) (required)credentials— (required)proxy— Use system proxy settingsinsecure— Trust any certificate (not secure)frontend_url— Tenzai App URL (e.g., https://app.tenzai.io)timeout— HTTP request timeout (seconds)
Commands (3)
-
tenzai-create-scanCreate a Tenzai agentic scan for a Cortex ASM-discovered exposure. Finds or creates a Tenzai application for the target domain, then triggers an EXTERNAL_LEAD scan — a short, targeted confirm/refute of the single externally-reported exposure — scoped to the exposed socket. Returns a scan id used to poll status and fetch results.
-
tenzai-get-scanPoll the status of a Tenzai scan until it reaches a terminal state (Complete or Error).
-
tenzai-get-scan-resultFetch the verdict and evidence of a completed Tenzai scan.
import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 """Tenzai Integration for Cortex. Connects Cortex to the Tenzai agentic penetration-testing platform to validate ASM-discovered exposures. This integration talks to Tenzai's first-party v1 APIs directly (applications / tests / findings): it maps one Cortex ASM exposure to a find-or-create Tenzai **Application** (per domain) plus an EXTERNAL_LEAD **scan** — a short, targeted confirm/refute of the single externally-reported exposure — polls the scan to a terminal state, and reads the verdict + evidence back out. A confirmed exposure becomes a Tenzai finding. """ from CommonServerUserPython import * # noqa: F401 import re from datetime import datetime, UTC from typing import Any from urllib.parse import urlparse # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # test-module probes this endpoint: a cheap, authenticated list. 200 => key ok. TEST_ENDPOINT = "/v1/applications" # Per-request HTTP timeout (seconds), overridable via the `timeout` instance # param. Deliberately low: it MUST stay well under the StartAgenticValidation # poll automation's execution timeout. That script's re-entry makes three # sequential API calls (get_test + get_test_findings + get_test_leads), so a # stalled host has to fail within ~3x this budget for the script's graceful # "retry next tick" path to run — otherwise the whole automation is killed by a # Docker timeout and the poll loop dies (this is what left issues stuck on # "Running"). 3x20=60s stays well under the automation's 3m timeout. See ENG-5184. DEFAULT_HTTP_TIMEOUT = 20 # Ports that imply an HTTP(S) web surface when no explicit type is supplied. _WEB_PORTS = frozenset({80, 443, 8080, 8443}) _HTTP_PORTS = frozenset({80, 8080}) # HTTP-server product named in the free-text exposure name / issue description. # Cortex ASM frequently reports a web server on a non-standard port (e.g. "HTTP # Server ... on version(s) ['ApacheWebServer 2.4.41']" at :15580), which carries # no http scheme and no known web port — so the scheme/port/classification checks # miss it and it would fall through to NETWORK_HOST. Matching a concrete web- # server *product* (not a bare http/https token) keeps such exposures WEB_APP and # scanned over https://, while an unrelated https:// URL sitting in a description # (e.g. an NVD reference on a PPTP exposure) does NOT trip it. Scheme/protocol are # detected separately from the actual target — never from arbitrary description text. # Only products that *imply* an HTTP surface are listed: web servers (apache/nginx/ # httpd/iis/…) and HTTP app servers (gunicorn/uvicorn/kestrel). General-purpose L4/L7 # proxies (haproxy, envoy) are deliberately excluded — they equally front raw TCP, so # their presence is not evidence the exposed port speaks HTTP. _WEB_PRODUCT_RE = re.compile( r"\b(?:" r"web[\s_-]*server|http[\s_-]*server|" r"apache(?:[\s_-]*web[\s_-]*server)?|" r"nginx(?:[\s_-]*web[\s_-]*server)?|" r"httpd|iis|tomcat|jetty|lighttpd|caddy|openresty|" r"gunicorn|uvicorn|kestrel|litespeed|traefik" r")\b", re.IGNORECASE, ) # Base62 alphabet + fixed width the UI router uses to encode a UUID. # Mirrors platform/common/utils.py::uuid_to_base62 and the UI's base62.utils.ts. _BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" _BASE62_UUID_LENGTH = 22 # Nested scan status (status.type, camelCase) -> coarse status the pack polls on. # Keep polling while Pending|Running; treat Complete|Error as terminal. _STATUS_MAP = { "PENDING": "Pending", "INITIALIZING": "Pending", "INPROGRESS": "Running", "PAUSED": "Running", "CANCELLING": "Running", "SUCCESS": "Complete", "ERROR": "Error", "TERMINATED": "Error", "CANCELED": "Error", } _APP_DESCRIPTION = "Auto-created from a Cortex ASM exposure validation." # One leading bullet/number prefix to strip before we re-number a step, so we # don't emit "1. 1. foo". Mirrors platform's finding_ticket_helpers._strip_step. _STEP_PREFIX_RE = re.compile(r"^\s*(?:[-*+]\s+|\d+[.)]\s+)") # --------------------------------------------------------------------------- # base62 (ported from platform/common/utils.py) # --------------------------------------------------------------------------- def uuid_to_base62(uuid_value: str) -> str: """Encode a UUID to the 22-char base62 form the UI router uses. Known vector: ``0053254e-423e-4ac4-88f0-f0d22b92281d`` -> ``00bzrAULhh4ZlgSZbYKf3V``. Raises ``ValueError`` if the input is not a valid UUID hex string. """ hex_str = str(uuid_value).replace("-", "").lower() if len(hex_str) != 32 or not all(c in "0123456789abcdef" for c in hex_str): raise ValueError(f"Invalid UUID: {uuid_value}") num = int(hex_str, 16) if num == 0: return "0" * _BASE62_UUID_LENGTH chars: list[str] = [] while num > 0: chars.append(_BASE62_CHARS[num % 62]) num //= 62 return "".join(reversed(chars)).rjust(_BASE62_UUID_LENGTH, "0") # --------------------------------------------------------------------------- # Client # --------------------------------------------------------------------------- class Client(BaseClient): """Client to interact with Tenzai's first-party v1 API. Implements API calls only; holds no Cortex/Demisto logic. Inherits from ``BaseClient`` (CommonServerPython), which handles proxy and SSL verification. Authentication is a bearer token sent in the ``Authorization`` header, wired in ``main`` and passed via ``headers``. """ def test_connection(self) -> dict[str, Any]: """Probe a lightweight authenticated endpoint to validate connectivity + key.""" return self._http_request(method="GET", url_suffix=TEST_ENDPOINT, params={"size": 1}) def list_applications_by_name(self, name: str) -> dict[str, Any]: """List applications whose name *contains* ``name`` (API filter is contains, not exact).""" return self._http_request(method="GET", url_suffix="/v1/applications", params={"name": name, "size": 100}) def create_application(self, payload: dict[str, Any]) -> dict[str, Any]: """Create an application. May raise a 422 when the name already exists (race).""" return self._http_request(method="POST", url_suffix="/v1/applications", json_data=payload) def create_test(self, app_id: str, payload: dict[str, Any]) -> dict[str, Any]: """Create a scan (test) under an application. Returns the created TestResponse.""" return self._http_request(method="POST", url_suffix=f"/v1/applications/{app_id}/tests", json_data=payload) def get_test(self, test_id: str) -> dict[str, Any]: """Get a test/scan by id (TestResponse; status is nested under ``status.type``).""" return self._http_request(method="GET", url_suffix=f"/v1/tests/{test_id}") def get_test_findings(self, test_id: str) -> dict[str, Any]: """Get a test's findings (a fastapi Page: ``{items: [...], total, ...}``).""" return self._http_request(method="GET", url_suffix=f"/v1/tests/{test_id}/findings", params={"size": 100}) def get_test_leads(self, test_id: str) -> dict[str, Any]: """Get a test's leads (a fastapi Page: ``{items: [...], total, ...}``). Leads are the agent's investigation hypotheses. For an EXTERNAL_LEAD scan there is one planner-seeded lead (``origin == "external"``) that carries the assessment narrative (``hypothesis``) and terminal verdict (``closedReason``). """ return self._http_request(method="GET", url_suffix=f"/v1/tests/{test_id}/leads", params={"size": 100}) # --------------------------------------------------------------------------- # Target parsing / app-type derivation / target-url building # --------------------------------------------------------------------------- def _parse_target(target: str) -> tuple[str, int | None, str]: """Parse a target into ``(host, port, scheme)``. Accepts a URL (``https://host:port/path``), a ``host:port`` pair, or a bare host/IP. ``host`` is lowercased (it is the per-domain dedupe key); ``port``/``scheme`` are ``None``/``""`` when absent. Raises ValueError when no host can be parsed. """ raw = (target or "").strip() if not raw: raise ValueError("target is required") # urlparse needs a netloc; prepend "//" for schemeless host[:port] inputs. parsed = urlparse(raw if "://" in raw else f"//{raw}") host = (parsed.hostname or "").strip().rstrip(".") if not host: raise ValueError(f"Could not derive a host from target: {target!r}") return host.lower(), parsed.port, (parsed.scheme or "").lower() def _derive_app_type( explicit_type: str | None, *, scheme: str, port: int | None, service_classification: str | None, protocol: str | None, hints: str | None = None, ) -> str: """Use the explicit type if given, else infer the app type. ``hints`` is free text (the exposure name + issue description) that Cortex ASM supplies for the exposure. An HTTP-server product named there (e.g. "HTTP Server … ApacheWebServer 2.4.41") classifies the exposure as WEB_APP even on a non-standard port that isn't in ``_WEB_PORTS`` — otherwise such a web exposure falls through to NETWORK_HOST and gets scanned as a bare network host. """ if explicit_type: return explicit_type classification = (service_classification or "").lower() proto = (protocol or "").lower() if ( scheme in {"http", "https"} or "web" in classification or "http" in classification or proto in {"http", "https"} or port in _WEB_PORTS or bool(_WEB_PRODUCT_RE.search(hints or "")) ): return "WEB_APP" # Any non-web network exposure — with or without a specific port — is a # NETWORK_HOST. A single service's port is authorized via the target's # network_port_scope, not a separate application type: the platform retired # NETWORK_SERVICE (ENG-6302), leaving WEB_APP / NETWORK_HOST / ANDROID_APP. return "NETWORK_HOST" def _build_target_url(host: str, app_type: str, port: int | None, protocol: str, scheme: str = "") -> str: """Build the single scan target in the form each app type's validator expects. WEB_APP -> ``https://host[:port]``; NETWORK_HOST -> bare ``host`` (the platform's host-target validator rejects a scheme or a ``:port`` suffix — a single service's port rides in the target's ``network_port_scope``, set in ``_find_or_create_app``). """ if app_type == "WEB_APP": if protocol in {"http", "https"}: chosen = protocol elif scheme in {"http", "https"}: chosen = scheme elif port in _HTTP_PORTS: chosen = "http" else: chosen = "https" return f"{chosen}://{host}:{port}" if port else f"{chosen}://{host}" # NETWORK_HOST — a bare host (no scheme, no ``:port``); the port, when known, # is authorized via the target's ``network_port_scope`` rather than the URL. return host # --------------------------------------------------------------------------- # Guidelines synthesis (CREATE only) # --------------------------------------------------------------------------- def _synthesize_app_guidelines(args: dict[str, Any]) -> dict[str, Any]: """Structured app guidelines: focus on the exposure, lock scope to one socket. Folds in the Cortex issue ``issue_description`` and any ASM correlation ids. Applied at app CREATE only; when reusing an existing app we keep its guidelines untouched. """ exposure_name = args.get("exposure_name") supporting_data = args.get("supporting_data") service_classification = args.get("service_classification") issue_description = args.get("issue_description") asm_service_id = args.get("asm_service_id") alert_internal_id = args.get("alert_internal_id") focus_parts = [f"Validate the externally-reported exposure: {exposure_name}."] if supporting_data: focus_parts.append(f"Supporting data from Cortex ASM:\n{supporting_data}") if service_classification: focus_parts.append(f"Service classification: {service_classification}.") if issue_description: focus_parts.append(f"Cortex issue description:\n{issue_description}") correlation = [ f"{label}={value}" for label, value in (("asm_service_id", asm_service_id), ("alert_internal_id", alert_internal_id)) if value ] guidelines: dict[str, Any] = { "focusArea": "\n\n".join(focus_parts), "outOfScope": ( "Scope is locked to the single socket/target from the Cortex ASM exposure. " "Do not pivot to other hosts, ports, or services." ), } if correlation: guidelines["additional"] = "Cortex correlation: " + ", ".join(correlation) + "." return guidelines def _external_lead_category(args: dict[str, Any], cve_id: str | None) -> str: """Classify the exposure as a CVE or a misconfiguration for the EXTERNAL_LEAD profile. Prefers an explicit ``category`` arg (a structured Cortex signal); otherwise infers from whether a ``cve_id`` is present. A CVE lead is only meaningful with an identifier, so ``cve`` is emitted only when a ``cve_id`` accompanies it — an explicit ``category=cve`` without one degrades to ``misconfiguration`` (a direct probe) rather than an id-less CVE objective. No free-text parsing. """ explicit = (args.get("category") or "").strip().lower() if explicit == "misconfiguration": return explicit if explicit == "cve" or cve_id: return "cve" if cve_id else "misconfiguration" return "misconfiguration" def _build_external_lead_profile(args: dict[str, Any], target_url: str, port: int | None) -> dict[str, Any]: """Build the EXTERNAL_LEAD ``profileConfig`` from the Cortex exposure reference. Maps the exposure onto the first-party profile (camelCase wire keys). The four required fields (``alertId``, ``title``, ``category``, ``target``) always resolve to a non-empty value; optional fields are included only when present and valid (``port`` is dropped unless it is an in-range 0..65535 int, matching the server-side constraint). """ exposure_name = args.get("exposure_name") or "ASM Exposure" cve_id = args.get("cve_id") or None category = _external_lead_category(args, cve_id) # alertId is required and should correlate the lead back to its source: prefer # the Cortex issue id, then the ASM service id, and only fall back to the # exposure name when no real identifier was supplied. alert_id = args.get("alert_internal_id") or args.get("asm_service_id") or exposure_name in_range_port = port if isinstance(port, int) and not isinstance(port, bool) and 0 <= port <= 65535 else None # The API's category enum is upper-case (``CVE`` / ``MISCONFIGURATION``); keep the # lower-case token internally for the ``cveId`` gate below, but emit upper-case on the wire. profile: dict[str, Any] = { "profile": "EXTERNAL_LEAD", "alertId": alert_id, "title": exposure_name, "category": category.upper(), "target": target_url, } optional = { "cveId": cve_id if category == "cve" else None, "ruleId": args.get("rule_id"), "port": in_range_port, "severity": args.get("severity"), "cwe": args.get("cwe"), "description": args.get("issue_description"), "supportingEvidence": args.get("supporting_data"), } profile.update({k: v for k, v in optional.items() if v not in (None, "")}) return profile def _synthesize_scan_guidelines(args: dict[str, Any]) -> str: """Free-text scan guidelines mirroring the focus + single-socket scope lock. Analyst-supplied ``guidelines`` are appended as their own section rather than replacing the synthesized text: the exposure focus and the scope lock must survive whatever the analyst types. """ parts = [f"Validate the externally-reported exposure: {args.get('exposure_name')}."] if args.get("supporting_data"): parts.append(f"Supporting data: {args.get('supporting_data')}") if args.get("issue_description"): parts.append(f"Cortex issue description: {args.get('issue_description')}") analyst_guidelines = (args.get("guidelines") or "").strip() if analyst_guidelines: parts.append(f"Analyst guidelines:\n{analyst_guidelines}") parts.append("Scope is locked to the single target above; do not pivot to other hosts, ports, or services.") return "\n\n".join(parts) # --------------------------------------------------------------------------- # Find-or-create (per-domain idempotency) # --------------------------------------------------------------------------- def _find_app_by_exact_name(client: Client, domain: str) -> dict[str, Any] | None: """Case-insensitive EXACT-name lookup. The API's ``name`` filter is *contains*, so we fetch candidates then filter for an exact (case-insensitive) match on ``name``. """ page = client.list_applications_by_name(domain) for app in page.get("items") or []: if (app.get("name") or "").lower() == domain.lower(): return app return None def _find_or_create_app( client: Client, domain: str, app_type: str, target_url: str, args: dict[str, Any], port: int | None = None, protocol: str = "", ) -> dict[str, Any]: """Reuse the app for this domain, or create it. Resilient to the create race. For a NETWORK_HOST app the platform requires a ``networkGoal`` and expresses a single authorized service port via the target's ``networkPortScope`` (the host-target validator forbids a ``:port`` in the URL). ``port``/``protocol`` describe that single service; absent a port the scope defaults to ALL. """ existing = _find_app_by_exact_name(client, domain) if existing is not None: return existing target: dict[str, Any] = {"url": target_url} payload: dict[str, Any] = { "applicationType": app_type, "name": domain, "description": _APP_DESCRIPTION, "guidelines": _synthesize_app_guidelines(args), } if app_type == "NETWORK_HOST": # networkGoal is mandatory for NETWORK_HOST; validating a reported exposure # is a targeted vulnerability-exploitation objective. A known single port is # authorized via a SELECTED port scope; without one the scope defaults to ALL. payload["networkGoal"] = "VULNERABILITY_EXPLOITATION" # Only an in-range port yields a SELECTED scope (the port-rule schema is 1..65535); # a missing or out-of-range port leaves the target at the default ALL scope. if port is not None and 1 <= port <= 65535: proto = "UDP" if protocol.lower() == "udp" else "TCP" target["networkPortScope"] = { "mode": "SELECTED", "rules": [{"protocol": proto, "fromPort": port, "toPort": port}], } payload["targets"] = [target] try: return client.create_application(payload) except DemistoException as e: # A concurrent trigger (or a prior partial run) created the same-domain # app between our lookup and this insert — the unique lower(name) index # rejects it with a 422. Re-GET and reuse the winner. message = str(e) if "422" in message or "already exists" in message: found = _find_app_by_exact_name(client, domain) if found is not None: return found # The connected Tenzai API doesn't recognise this application type and rejects # it with a raw pydantic 422 — a content-pack/API version mismatch. Surface an # actionable message instead of the cryptic API error; do NOT silently retry as # WEB_APP (that would mis-scan a real non-HTTP service over http://). if "not a valid ApplicationType" in message or "valid ApplicationType" in message: raise DemistoException( f"The Tenzai API rejected application type '{app_type}'. The connected Tenzai " "instance does not recognise this type — the Tenzai content pack and the Tenzai " "API version are out of sync. Update the Tenzai pack to a version aligned with " "this API, or (only for a genuine HTTP service) validate the exposure as a WEB_APP." ) from e raise # --------------------------------------------------------------------------- # Status mapping # --------------------------------------------------------------------------- def _map_status(scan: dict[str, Any]) -> str: """Map a test's nested ``status.type`` to the coarse status (default Error).""" status_type = ((scan.get("status") or {}).get("type") or "").upper() return _STATUS_MAP.get(status_type, "Error") # --------------------------------------------------------------------------- # Markdown rendering helpers (ported from validation_service.py) # --------------------------------------------------------------------------- def _strip_step(step: str) -> str: """Strip one leading bullet/number prefix so re-numbering doesn't produce ``1. 1. foo``.""" return _STEP_PREFIX_RE.sub("", step).strip() def _fenced_code(body: str, language: str) -> list[str]: """Wrap ``body`` in a backtick fence long enough to survive any backtick run inside it.""" longest = max((len(run) for run in re.findall(r"`+", body)), default=0) fence = "`" * max(3, longest + 1) return [f"{fence}{language}", body, fence] def _ordered_item(number: int, text: str) -> list[str]: """Render ``text`` as a single markdown ordered-list item ``number. text``. Continuation lines are indented to the marker width so multi-line content and double-digit positions stay inside the list under CommonMark. """ marker = f"{number}. " indent = " " * len(marker) body = text.split("\n") return [f"{marker}{body[0]}", *(f"{indent}{line}" if line else "" for line in body[1:])] _CVE_RE = re.compile(r"\bCVE-\d{4}-\d{4,}\b", re.IGNORECASE) # Per-finding attribution relative to the matched exposure lead. ATTR_OWN = "own" # this alert's own exposure (finding CVE matches the matched lead, or lead has no CVE) ATTR_DISCOVERED = "discovered" # a different CVE found while testing the host — a validation by-product ATTR_UNATTRIBUTED = "unattributed" # no lead correlated to the alert, so ownership cannot be established def _finding_cve(finding: dict[str, Any]) -> str | None: """Best-effort CVE id parsed from a finding's structured field or its name. DISPLAY ONLY — used to attribute a finding relative to the matched exposure's CVE. It is deliberately NOT used for lead correlation: the findings API exposes no structured ``cve``/``leadId``, so name parsing is the only signal available, and a fragile signal must never drive which verdict an alert shows. """ structured = (finding.get("cve") or finding.get("cveId") or "").strip() if structured: return structured.upper() match = _CVE_RE.search(str(finding.get("name") or "")) return match.group(0).upper() if match else None def _finding_attribution(correlation_known: bool, lead_cve: str | None, finding_cve: str | None) -> str: """Classify a finding relative to the matched exposure lead (own / discovered / unattributed). - No lead correlated to the alert => ``unattributed``: we cannot claim the finding is this alert's own exposure, so it is neither counted into the verdict nor treated as a discovery. - A CVE that differs from the matched lead's CVE => ``discovered`` (a by-product of testing). - Otherwise (matching CVE, or a misconfiguration lead that carries no CVE) => ``own``. """ if not correlation_known: return ATTR_UNATTRIBUTED if lead_cve and finding_cve and finding_cve != lead_cve: return ATTR_DISCOVERED return ATTR_OWN def _finding_name(finding: dict[str, Any]) -> str: return finding.get("name") or "Finding" def _finding_severity(finding: dict[str, Any]) -> str: """The finding's severity, uppercased (grid label).""" severity = finding.get("severity") return str(severity).strip().upper() if severity else "" def _finding_details(finding: dict[str, Any]) -> str | None: """Impact-first then description body for one finding (no header/severity).""" parts: list[str] = [] impact = (finding.get("impact") or "").strip() description = (finding.get("description") or "").strip() if impact: parts.append(f"**Impact:** {impact}") if description: parts.append(description) return "\n\n".join(parts) or None def _finding_reproduction(finding: dict[str, Any]) -> str | None: """Reproduction body for one finding: prerequisites + steps + scripts. Sensitive parameter defaults already arrive as the literal ``[REDACTED]`` from the API — the platform never serves the real value. """ parts: list[str] = [] prerequisites = finding.get("prerequisites") or [] if prerequisites: parts.append("**Prerequisites:**") parts.extend(f"- {item}" for item in prerequisites) parts.append("") steps = [stripped for stripped in (_strip_step(s) for s in (finding.get("steps") or [])) if stripped] if steps: parts.append("**Steps:**") for i, step in enumerate(steps, start=1): parts.extend(_ordered_item(i, step)) parts.append("") reproduction = finding.get("reproduction") or {} scripts = reproduction.get("scripts") or [] if scripts: parameters = reproduction.get("parameters") or [] if parameters: parts.append("**Parameters:**") parts.extend(f"- `{p.get('name')}` = `{p.get('defaultValue')}`" for p in parameters) parts.append("") for script in scripts: language = (script.get("language") or "").lower() parts.extend([*_fenced_code((script.get("script") or "").rstrip(), language), ""]) if not parts: return None return "\n".join(parts).rstrip() def _finding_guidance(finding: dict[str, Any]) -> str | None: """Remediation body for one finding: items (in order) + coding-agent prompt. The finding response's ``remediation.items`` are already ordered (position is dropped at the schema level), so we number them sequentially. """ remediation = finding.get("remediation") or {} items = remediation.get("items") or [] prompt = (remediation.get("codingAgentPrompt") or "").strip() if not items and not prompt: return None parts: list[str] = [] for i, item in enumerate(items, start=1): block = f"**{item.get('title') or ''}**" description = item.get("description") if description: block += "\n\n" + description parts.extend(_ordered_item(i, block)) if prompt: parts.extend(["", "**Coding-agent prompt:**", "", prompt]) return "\n".join(parts).rstrip() def _render_evidence_markdown(scan: dict[str, Any], findings: list[dict[str, Any]]) -> str: """Render a markdown summary of the scan's confirmed findings (status-aware when none).""" if not findings: if ((scan.get("status") or {}).get("type") or "").upper() == "SUCCESS": return "No exploitable findings were confirmed for this exposure." return "_The validation scan has not produced a confirmed verdict yet._" lines = ["## Confirmed findings", ""] for finding in findings: lines.append(f"### {_finding_name(finding)}") lines.append(f"- **Severity:** {_finding_severity(finding)}") details = _finding_details(finding) if details: lines.extend(["", details]) lines.append("") return "\n".join(lines).rstrip() + "\n" def _render_reproduction_markdown(findings: list[dict[str, Any]]) -> str | None: """Per finding: prepend the ``### name`` header, concatenate under ``## Reproduction``.""" sections: list[str] = [] for finding in findings: body = _finding_reproduction(finding) if body: sections.extend([f"### {_finding_name(finding)}", "", body, ""]) if not sections: return None return ("## Reproduction\n\n" + "\n".join(sections)).rstrip() + "\n" def _render_guidance_markdown(findings: list[dict[str, Any]]) -> str | None: """Per finding: prepend the ``### name`` header, concatenate under ``## Remediation guidance``.""" sections: list[str] = [] for finding in findings: body = _finding_guidance(finding) if body: sections.extend([f"### {_finding_name(finding)}", "", body, ""]) if not sections: return None return ("## Remediation guidance\n\n" + "\n".join(sections)).rstrip() + "\n" def _finding_detail_markdown(details: str | None, reproduction: str | None, guidance: str | None) -> str: """Combine a finding's details + reproduction + fix guidance into one markdown blob.""" sections: list[str] = [] if details: sections.append(details.strip()) if reproduction: sections.append(f"## Reproduction\n\n{reproduction.strip()}") if guidance: sections.append(f"## Fix Guidance\n\n{guidance.strip()}") return "\n\n".join(sections) # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- def test_module(client: Client) -> str: """Test API connectivity and authentication. Returns ``'ok'`` when the integration can reach Tenzai and the API key is accepted. Auth failures are translated into a readable message. """ try: client.test_connection() except DemistoException as e: message = str(e) if "Unauthorized" in message or "Forbidden" in message or "401" in message or "403" in message: return "Authorization Error: make sure the Tenzai API Key is correctly set." raise return "ok" def _derive_app_url(api_url: str) -> str | None: """Derive the Tenzai web-app URL from the API URL, or ``None`` when the host shape is unrecognised. The two hosts mirror each other on every Tenzai environment, so the app URL is inferable when the optional ``frontend_url`` param is not configured: * ``api.tenzai.io`` / ``api.dev.tenzai.io`` -> ``app.<rest>`` (leading label) * ``eu.api.tenzai.io`` -> ``eu.tenzai.io`` (shard label first, ``api`` second -> drop ``api``) Anything else returns ``None`` rather than a guess: a wrong link is worse than no link, so an unfamiliar host keeps the pre-derivation behaviour. """ parsed = urlparse(api_url if "://" in api_url else f"https://{api_url}") host = (parsed.hostname or "").lower() if not host: return None labels = host.split(".") if labels[0] == "api" and len(labels) > 1: app_host = ".".join(["app"] + labels[1:]) elif len(labels) > 2 and labels[1] == "api": app_host = ".".join([labels[0]] + labels[2:]) else: return None netloc = f"{app_host}:{parsed.port}" if parsed.port else app_host return f"{parsed.scheme or 'https'}://{netloc}" def _resolve_frontend_url() -> str: """The Tenzai web-app base URL: the explicit ``frontend_url`` param when set, otherwise derived from the API URL. Empty string when neither is available.""" configured = (demisto.params().get("frontend_url") or "").strip().rstrip("/") if configured: return configured return (_derive_app_url((demisto.params().get("url") or "").strip()) or "").rstrip("/") def _build_reference_url(scan_id: str, application_id: str | None, tab: str) -> str | None: """Build the Tenzai UI URL for a scan's ``tab`` view, or ``None`` if not derivable. ``tab`` is the test-detail route segment: ``log`` for the live agent-activity view (linked while a scan is running) or ``findings`` for the terminal verdict. Needs a web-app base URL (the ``frontend_url`` param, else derived from the API URL) and a known application id; a missing base or an unparseable id degrades to ``None`` (no link) rather than raising. """ frontend_url = _resolve_frontend_url() if not frontend_url or not application_id: return None try: return f"{frontend_url}/apps/{uuid_to_base62(application_id)}/tests/{uuid_to_base62(scan_id)}/{tab}" except ValueError: return None def create_scan_command(client: Client, args: dict[str, Any]) -> CommandResults: """Map a Cortex exposure -> find-or-create app + EXTERNAL_LEAD scan. Triggers a short, targeted confirm/refute of the single externally-reported exposure. Outputs the scan id, application id, and initial status under ``Tenzai.Scan``. """ host, target_port, target_scheme = _parse_target(str(args.get("target") or "")) port = arg_to_number(args.get("port")) if port is None: port = target_port app_type = _derive_app_type( args.get("application_type"), scheme=target_scheme, port=port, service_classification=args.get("service_classification"), protocol=args.get("protocol"), hints=f"{args.get('exposure_name') or ''}\n{args.get('issue_description') or ''}", ) target_url = _build_target_url(host, app_type, port, (args.get("protocol") or "").lower(), target_scheme) app = _find_or_create_app(client, host, app_type, target_url, args, port=port, protocol=(args.get("protocol") or "").lower()) app_id = str(app.get("id")) # NOTE: do not send ``targets`` here — targets are owned by the application # (set at create above) and the API rejects a test that tries to override them # (422 "Test targets are managed by the application and cannot be overridden"). # The test inherits the app's target. lead_profile = _build_external_lead_profile(args, target_url, port) scan_payload = { "name": args.get("exposure_name"), "guidelines": _synthesize_scan_guidelines(args), "profileConfig": lead_profile, "trigger": "MANUAL", } scan = client.create_test(app_id, scan_payload) outputs = { "id": scan.get("id"), "applicationId": scan.get("applicationId") or app_id, "status": _map_status(scan), } # Echo the correlation keys the lead was seeded with so the caller can re-supply them to # ``tenzai-get-scan-result`` (which later runs with only the scan id). Without this the result # command cannot scope the verdict to this alert's lead on a multi-lead host scan. for out_key, seed_key in (("alertId", "alertId"), ("cve", "cveId"), ("ruleId", "ruleId")): seeded = lead_profile.get(seed_key) if seeded not in (None, ""): outputs[out_key] = seeded # Live agent-activity ("Agent log") URL so the Running panel can link straight # into the scan in flight; omitted when frontend_url is not configured. reference_url = _build_reference_url(str(outputs["id"]), outputs["applicationId"], "log") if reference_url: outputs["referenceUrl"] = reference_url readable = tableToMarkdown( "Tenzai Scan Created", { "Scan ID": outputs["id"], "Application ID": outputs["applicationId"], "Status": outputs["status"], "Target": target_url, "Reference URL": reference_url, }, removeNull=True, ) return CommandResults( outputs_prefix="Tenzai.Scan", outputs_key_field="id", outputs=outputs, readable_output=readable, raw_response=scan, ) @polling_function( name="tenzai-get-scan", interval=arg_to_number(demisto.args().get("interval_in_seconds", 60)), timeout=arg_to_number(demisto.args().get("timeout_in_seconds", 3600)), requires_polling_arg=False, # always polls by default (polling=true) ) def get_scan_command(args: dict[str, Any], client: Client) -> PollResult: """Poll a Tenzai scan until it reaches a terminal state. Reschedules while Pending/Running; resolves on Complete/Error. """ scan_id = str(args.get("id")) scan = client.get_test(scan_id) status = _map_status(scan) outputs = {"id": scan_id, "status": status} if status in ("Pending", "Running"): return PollResult( response=None, partial_result=CommandResults( outputs_prefix="Tenzai.Scan", outputs_key_field="id", readable_output=f"Waiting for Tenzai scan {scan_id} to finish (status: {status})...", ), continue_to_poll=True, args_for_next_run={"id": scan_id, **args}, ) command_results = CommandResults( outputs_prefix="Tenzai.Scan", outputs_key_field="id", outputs=outputs, readable_output=tableToMarkdown("Tenzai Scan Status", outputs, removeNull=True, headerTransform=string_to_table_header), raw_response=scan, ) return PollResult(response=command_results, continue_to_poll=False) def _norm(value: Any) -> str: """Trim + casefold a candidate identifier for equality matching (empty -> '').""" return str(value or "").strip().casefold() def _unique_match(leads: list[dict[str, Any]], key: str, wanted: str) -> dict[str, Any] | None: """Return the sole lead whose ``key`` equals ``wanted`` — or ``None`` if 0 or >1 match. Uniqueness is the whole point: a host scan can carry sibling leads that share a value (e.g. two leads for the same CVE), so a key that selects more than one lead is NOT a safe correlation and must be rejected rather than resolved to an arbitrary ``[0]``. """ if not wanted: return None hits = [lead for lead in leads if _norm(lead.get(key)) == wanted] return hits[0] if len(hits) == 1 else None def _match_lead_to_alert( leads: list[dict[str, Any]], alert_id: str | None, cve: str | None, rule_id: str | None, ) -> dict[str, Any] | None: """Correlate the exposure lead to *this* Cortex alert, or ``None`` when unresolved. Match precedence, each accepted only when it selects EXACTLY ONE lead: 1. ``alertId`` — the strongest key (the lead was seeded with the originating alert id). 2. ``cve`` + ``ruleId`` together — a composite that disambiguates same-CVE siblings. 3. ``cve`` alone — last structured resort (skipped when the CVE is not unique). A sole lead on the scan is a safe fallback only when NO correlation keys were supplied (nothing to disambiguate against); if keys were supplied and none matched, that is a real miss and must return ``None`` — never borrow a sibling lead's verdict. Selection is on STRUCTURED lead fields only; finding-name CVE parsing is display-only, never here. """ alert_id_n, cve_n, rule_id_n = _norm(alert_id), _norm(cve), _norm(rule_id) match = _unique_match(leads, "alertId", alert_id_n) if match is not None: return match if cve_n and rule_id_n: composite = [lead for lead in leads if _norm(lead.get("cve")) == cve_n and _norm(lead.get("ruleId")) == rule_id_n] if len(composite) == 1: return composite[0] match = _unique_match(leads, "cve", cve_n) if match is not None: return match # No correlation keys at all -> a sole lead is unambiguous and safe to use. Any supplied # key that failed to resolve is a miss (ambiguous or absent), so we return None. if not (alert_id_n or cve_n or rule_id_n): return leads[0] if len(leads) == 1 else None return None def _validated_from_status(exposure_status: str | None) -> bool | None: """Map the matched lead's terminal status to the tri-state ``validated`` verdict. MATERIALIZED -> True (exploit confirmed), INVALIDATED -> False (assessed, not exploitable), everything else (BLOCKED / OPEN / IN_PROGRESS / unknown / unmatched) -> ``None`` (no verdict). BLOCKED explicitly must NOT read as False: the target was reached but payloads stopped at the edge, so no exploitability verdict was determined. Lead-scoped, replacing the old test-scoped ``SUCCESS and len(findings) > 0`` gate that answered "did the host scan find anything". """ key = _norm(exposure_status).upper() if key == "MATERIALIZED": return True if key == "INVALIDATED": return False return None def _render_lead_rationale_markdown(lead: dict[str, Any] | None) -> str | None: """Render a CVE lead's assessment narrative + verdict as markdown, or ``None``. Only CVE (vulnerability) leads get a rationale block — a CVE lead is the one with a structured ``cve`` set (misconfiguration leads have none). ``hypothesis`` is the DESCRIPTION narrative; ``closedReason`` is the terminal CONCLUSION (absent until the lead is closed). Returns ``None`` when the lead is not a CVE lead or carries no text. """ if not lead or not (lead.get("cve") or "").strip(): return None description = (lead.get("hypothesis") or "").strip() conclusion = (lead.get("closedReason") or "").strip() sections: list[str] = [] if description: sections.append(f"## Description\n\n{description}") if conclusion: sections.append(f"## Conclusion\n\n{conclusion}") return "\n\n".join(sections) or None def _format_timeline_ts(value: Any) -> str: """ISO-8601 timestamp -> compact ``MM-DD HH:MM:SS`` via slicing; raw fallback if unexpected.""" raw = str(value or "").strip() if len(raw) >= 19 and raw[10:11] == "T": return f"{raw[5:10]} {raw[11:19]}" return raw def _timeline_rows(lead: dict[str, Any]) -> list[dict[str, str]]: """Rows for the Tenzai Timeline grid from the lead's ``statusHistory`` (chronological).""" rows: list[dict[str, str]] = [] for entry in lead.get("statusHistory") or []: if not isinstance(entry, dict): continue status = str(entry.get("status") or "").strip() if status: rows.append({"status": status, "time": _format_timeline_ts(entry.get("timestamp"))}) return rows def _parse_iso_utc(value: str) -> datetime | None: """Parse an ISO-8601 timestamp into a tz-aware UTC ``datetime``, or ``None``. Tolerates a trailing ``Z`` and fractional seconds; a naive value is assumed UTC. """ raw = value.strip() if not raw: return None iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw try: dt = datetime.fromisoformat(iso) except ValueError: return None return (dt if dt.tzinfo else dt.replace(tzinfo=UTC)).astimezone(UTC) def _lead_started_at(lead: dict[str, Any]) -> str | None: """The assessment's start timestamp, as the FULL ISO-8601 string (no truncation). The assessment start is the exposure lead's ``OPEN`` transition — the earliest one, chosen by PARSED timestamp rather than trusting API/list order. A non-OPEN status is never treated as the start (a lead can be created directly IN_PROGRESS/BLOCKED with no meaningful start). Unlike ``_format_timeline_ts`` (which drops the year for the compact grid), this keeps the raw ISO so the panel can render a real "Started at" date. Returns ``None`` when no timestamped OPEN entry exists (the panel then renders the cell as "—"). """ opens: list[tuple[datetime, str]] = [] for entry in lead.get("statusHistory") or []: if not isinstance(entry, dict) or str(entry.get("status") or "").strip().upper() != "OPEN": continue ts = str(entry.get("timestamp") or "").strip() parsed = _parse_iso_utc(ts) if parsed is not None: opens.append((parsed, ts)) if not opens: return None return min(opens, key=lambda pair: pair[0])[1] def get_scan_result_command(client: Client, args: dict[str, Any]) -> list[CommandResults]: """Fetch the verdict + evidence of a completed Tenzai scan, scoped to the alert's own lead. The verdict fields (``validated``, ``exposureStatus``, ``cwe``, ``owaspCategory``, ``timeline``, ``startedAt``) all derive from the ONE exposure lead correlated to this alert (by ``alertId``, then ``cve`` + ``ruleId``); ``validated`` is that lead's terminal status mapped to a tri-state (MATERIALIZED->true / INVALIDATED->false / else None) — NOT the old test-wide "SUCCESS and any finding" gate. The scan-level narrative (``evidence`` / ``reproduction`` / ``guidance``) is rendered from the matched exposure's OWN findings only, so a sibling CVE found while testing the host never describes this alert's verdict. Returns two ``CommandResults``: the verdict under ``Tenzai.Scan`` and, when there are findings, one row per finding under ``Tenzai.Finding`` — each tagged with an ``attribution`` (own / discovered / unattributed) so the consumer can present by-products and uncorrelated findings distinctly from the alert's own exposure. """ scan_id = str(args.get("id")) scan = client.get_test(scan_id) findings_page = client.get_test_findings(scan_id) findings = findings_page.get("items") or [] # Correlation keys threaded from the caller (the button self-poll re-supplies these on every # scheduled tick; the playbook maps them from the alert). They scope the verdict to THIS alert's # lead rather than an arbitrary sibling on the same host. Absent -> unresolved (safe dashes). alert_id = args.get("alert_id") cve_arg = args.get("cve") rule_id = args.get("rule_id") # Best-effort: the exposure lead carries the assessment rationale + structured classification. # A stalled/failed leads fetch must never break the verdict — degrade to no lead (dashes). leads_fetch_ok = True try: leads = client.get_test_leads(scan_id).get("items") or [] except DemistoException as exc: demisto.debug(f"Tenzai leads fetch failed for {scan_id}; omitting rationale: {exc}") leads, leads_fetch_ok = [], False matched_lead = _match_lead_to_alert(leads, alert_id, cve_arg, rule_id) correlation_known = matched_lead is not None # Tri-state correlation for the write-back readiness gate — a definitive no-match must NOT # be conflated with a transient failed/empty fetch (which would write a partial verdict): # resolved -> a lead matched this alert. # unmatched -> leads were fetched and none correlate (final; nothing to wait for). # pending -> the fetch failed OR returned no leads yet (transient; keep polling). if correlation_known: correlation_state = "resolved" elif not leads_fetch_ok or not leads: correlation_state = "pending" else: correlation_state = "unmatched" lead_rationale = _render_lead_rationale_markdown(matched_lead) lead = matched_lead or {} # The exposure lead also carries the assessment's structured classification; surface it # so the panel can render CWE / OWASP chips. Empty strings collapse to None (omitted). cwe = (lead.get("cwe") or "").strip() or None owasp_category = (lead.get("owaspCategory") or "").strip() or None # The lead's terminal status (BLOCKED / MATERIALIZED / INVALIDATED / …) is the exposure # outcome the panel shows in its Status metric — distinct from the coarse scan status. exposure_status = (lead.get("status") or "").strip() or None lead_cve = (lead.get("cve") or "").strip().upper() or None # Structured extra for the panel's sidebar: the exposure Timeline (the lead's status history). timeline = _timeline_rows(lead) # The assessment start (full ISO) for the panel's "Started at" cell — the lead's OPEN entry. started_at = _lead_started_at(lead) # Verdict is now LEAD-scoped (the matched exposure's terminal status), not test-scoped: a host # scan finding *something* no longer marks THIS alert exploitable. Unmatched/BLOCKED -> None. validated = _validated_from_status(exposure_status) application_id = scan.get("applicationId") reference_url = _build_reference_url(scan_id, application_id, "findings") # One structured row per finding for the Tenzai Findings grid. Every finding produced while # testing the host is kept, each tagged with an ``attribution``: ``own`` (this alert's exposure), # ``discovered`` (a different CVE found while testing — a by-product), or ``unattributed`` (no # lead correlated, so ownership is unknown). The CVE is parsed from the finding name for DISPLAY # only — never for the lead correlation above. ``own_findings`` (below) scopes the scan-level # narrative so a by-product never describes this alert's verdict. finding_rows: list[dict[str, Any]] = [] own_findings: list[dict[str, Any]] = [] for finding in findings: details = _finding_details(finding) reproduction = _finding_reproduction(finding) guidance = _finding_guidance(finding) finding_cve = _finding_cve(finding) attribution = _finding_attribution(correlation_known, lead_cve, finding_cve) if attribution == ATTR_OWN: own_findings.append(finding) row = { "title": _finding_name(finding), "severity": _finding_severity(finding), "details": details, "reproduction": reproduction, "guidance": guidance, "detail": _finding_detail_markdown(details, reproduction, guidance), "cve": finding_cve, "attribution": attribution, } finding_rows.append(row) outputs = { "id": scan_id, "applicationId": application_id, "status": _map_status(scan), "validated": validated, # Tri-state lead correlation (resolved / unmatched / pending) the write-back readiness # gate keys off: only ``resolved`` carries enrichment worth waiting for; ``unmatched`` is # written at once; ``pending`` (failed/empty fetch) keeps the poll loop waiting. "correlationState": correlation_state, # Scan-level narrative is scoped to the exposure's OWN findings — a sibling/uncorrelated # finding must never populate this alert's evidence/reproduction/guidance. By-products and # unattributed findings remain visible per-row under Tenzai.Finding (with their attribution). "evidence": _render_evidence_markdown(scan, own_findings), "reproduction": _render_reproduction_markdown(own_findings), "guidance": _render_guidance_markdown(own_findings), "leadRationale": lead_rationale, "creditUsage": scan.get("acuCount"), "duration": scan.get("duration"), "referenceUrl": reference_url, "cwe": cwe, "owaspCategory": owasp_category, "exposureStatus": exposure_status, "startedAt": started_at, "timeline": timeline, } # `validated` is a real boolean verdict — keep it even when False (remove_empty_elements # would strip a False). Strip only the optional Nones from the scan outputs. scan_outputs = {k: v for k, v in outputs.items() if v is not None or k == "validated"} readable = tableToMarkdown( "Tenzai Scan Result", { "Scan ID": scan_id, "Application ID": application_id, "Status": outputs["status"], "Validated": validated, "Findings": len(findings), "Credit Usage": outputs["creditUsage"], "Duration (s)": outputs["duration"], "Reference URL": reference_url, "CWE": cwe, "OWASP": owasp_category, "Exposure Status": exposure_status, "Started At": started_at, }, removeNull=True, ) # Two root context paths (per the frozen contract, and how the consumer # script reads them): the verdict under Tenzai.Scan and one row per finding # under the sibling Tenzai.Finding — findings are NOT nested inside the scan. results = [ CommandResults( outputs_prefix="Tenzai.Scan", outputs_key_field="id", outputs=scan_outputs, readable_output=readable, raw_response={"scan": scan, "findings": findings}, ) ] if finding_rows: # Each finding row keeps its keys even when a section is None (so the grid # cells are addressable); drop only the intra-row Nones for cleanliness. finding_outputs = [{k: v for k, v in row.items() if v is not None} for row in finding_rows] results.append( CommandResults( outputs_prefix="Tenzai.Finding", outputs_key_field="title", outputs=finding_outputs, readable_output=tableToMarkdown( "Tenzai Findings", [ { "Title": r["title"], "Severity": r["severity"], "CVE": r.get("cve"), # Only surface a non-own attribution in the table (own is the default). "Attribution": r["attribution"] if r.get("attribution") != ATTR_OWN else None, } for r in finding_rows ], headers=["Title", "Severity", "CVE", "Attribution"], removeNull=True, ), ) ) return results def _client_from_params(params: dict[str, Any]) -> Client: """Build the API client from instance params, with a bounded HTTP timeout. The per-request timeout defaults to ``DEFAULT_HTTP_TIMEOUT`` and is overridable via the ``timeout`` param. Keeping it low is what lets the StartAgenticValidation poll loop reschedule on a stalled host instead of the whole automation being killed by its execution timeout (see ``DEFAULT_HTTP_TIMEOUT``). """ api_key = (params.get("credentials") or {}).get("password") timeout = arg_to_number(params.get("timeout")) or DEFAULT_HTTP_TIMEOUT return Client( base_url=(params.get("url") or "").rstrip("/"), verify=not argToBoolean(params.get("insecure", False)), headers={"Authorization": f"Bearer {api_key}"}, proxy=argToBoolean(params.get("proxy", False)), timeout=timeout, ) def main() -> None: # pragma: no cover """Parse params and dispatch the command.""" params = demisto.params() command = demisto.command() args = demisto.args() api_key = (params.get("credentials") or {}).get("password") if not api_key: return_error("A Tenzai API Key is required. Configure it in the integration instance.") demisto.debug(f"Command being called is {command}") try: client = _client_from_params(params) if command == "test-module": return_results(test_module(client)) elif command == "tenzai-create-scan": return_results(create_scan_command(client, args)) elif command == "tenzai-get-scan": return_results(get_scan_command(args, client)) elif command == "tenzai-get-scan-result": return_results(get_scan_result_command(client, args)) else: raise NotImplementedError(f"Command {command} is not implemented") except Exception as e: return_error(f"Failed to execute {command} command.\nError:\n{str(e)}", error=e) """ ENTRY POINT """ if __name__ in ("__main__", "__builtin__", "builtins"): main()