Vega
Vega integration for fetching alerts and incidents from the Vega platform.
Analytics & SIEM · Vega
Details
| ID | Vega |
|---|---|
| Provider | Vega |
| Category | Analytics & SIEM |
| From Version | 6.10.0 |
| Docker Image | demisto/python3:3.12.13.10404775 |
| Supported Modules | Agentix XSIAM |
README
Vega integration for fetching alerts and incidents from the Vega platform.
This is the default integration for this content pack when configured by the Data Onboarder in Cortex XSIAM.
Configure Vega in Cortex
| Parameter | Description | Required |
|---|---|---|
| Base URL | The Base URL of the Vega API. | True |
| Access Key ID | The Access Key ID used to authenticate with the Vega API. | True |
| Access Key | The Access Key used to authenticate with the Vega API. | True |
| Fetch incidents | False | |
| Incidents Fetch Interval | True | |
| Maximum incidents per fetch | The Maximum number of Vega alerts and incidents to fetch per cycle, combined. Valid range is 1-50. Invalid values default to 50 during fetch. | True |
| Fetch Lookback (minutes) | The number of minutes the query window is shifted backwards on each fetch cycle to catch alerts and incidents that were indexed late on the Vega side. Deduplication prevents re- ingestion. Valid range is 1-60. | True |
| Vega Entities to fetch | The Vega entities to fetch as Cortex XSOAR incidents. | True |
| Backfill Days | The number of days before today to fetch alerts and incidents on the first run. Use 0 for today only. Valid range is 0–365. | True |
| Enable Cortex XSOAR to Vega mirroring | Whether to enable Cortex XSOAR to Vega mirroring. When enabled, changes made in Cortex XSOAR investigations are mirrored to Vega for status, verdict, verdict reasoning, severity, and comments. Requires the Vega Outgoing Mapper on this instance. When disabled, Vega to Cortex XSOAR mirroring remains enabled. | False |
| Outgoing fields to mirror | The investigation fields that are mirrored from Cortex XSOAR to Vega when outgoing mirroring is enabled. If empty, all fields are mirrored. War Room comments are included when Comments is selected. Any custom values entered outside the available options are ignored. | False |
| Alert Severities to fetch | The severities by which to filter alerts. If empty, all severities are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. | False |
| Alert Statuses to fetch | The statuses by which to filter alerts. If empty, all statuses are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. | False |
| Alert Verdicts to fetch | The verdicts by which to filter alerts. If empty, all verdicts are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. | False |
| Has related incidents | The filter for alerts based on whether they have related incidents. Select Yes to fetch alerts with related incidents, No to fetch alerts without related incidents, or both to fetch all alerts. At least one option must be selected. Filter alerts by whether they have related incidents. Select Yes to fetch alerts with related incidents, No to fetch alerts without related incidents, or both to fetch all alerts. At least one option must be selected. | True |
| Incident Severities to fetch | The severities by which to filter incidents. If empty, all severities are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. Filter incidents by severity. If empty, all severities are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. | False |
| Incident Statuses to fetch | The statuses by which to filter incidents. If empty, all statuses are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. Filter incidents by status. If empty, all statuses are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. | False |
| Incident Verdicts to fetch | The verdicts by which to filter incidents. If empty, all verdicts are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. Filter incidents by verdict. If empty, all verdicts are fetched. Any custom values entered outside the available options are ignored and will not affect the fetch cycle. | False |
| Incident type | False | |
| Trust any certificate (not secure) | False | |
| Use system proxy settings | False |
Commands
You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.
vega-get-alert-events
Fetch all aggregated alert events for a Vega alert using internal API pagination, then return the requested display page as a markdown table and CustomFields for the Alert Events layout section.
Base Command
vega-get-alert-events
Input
| Argument Name | Description | Required |
|---|---|---|
| alert_id | The Vega alert API id (UUID). When omitted, resolves the alert id from the current Vega Alert incident. | Optional |
| limit | The number of alert events to display per page. Also used as the Vega API batch size when fetching all events. Default is 200. | Optional |
| offset | The pagination offset for alert events. Default is 0. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Vega.AlertEvents.AlertId | String | Vega alert ID. |
| Vega.AlertEvents.Total | Number | Total number of alert events reported by Vega. |
| Vega.AlertEvents.Offset | Number | Pagination offset used for the current page. |
| Vega.AlertEvents.Limit | Number | Page size used for the current fetch. |
| Vega.AlertEvents.Count | Number | Number of alert events returned in the current page. |
| Vega.AlertEvents.HasAlertEvents | Boolean | Whether the alert returned real alert events instead of aggregated parse-field summary rows. |
| Vega.AlertEvents.Cached | Boolean | Whether the response was served from cached incident data. |
| Vega.AlertEvents.Events | Unknown | Enriched alert events for the current page. JSON fields are parsed and fields._raw contents are promoted to top-level keys under fields. |
| Vega.AlertEvents.CustomFields | Unknown | Incident custom fields to persist for the Alert Events layout section. |
vega-set-detections-state
Set the state for one or more Vega detections.
Base Command
vega-set-detections-state
Input
| Argument Name | Description | Required |
|---|---|---|
| ids | A comma-separated list of Vega detection IDs to update. | Required |
| state | The target detection state. Possible values are: ENABLED, DISABLED, TEST_MODE. | Required |
Context Output
| Path | Type | Description |
|---|---|---|
| Vega.DetectionsState.State | String | The state applied to the detections. |
| Vega.DetectionsState.IDs | String | Detection IDs updated by Vega. |
| Vega.DetectionsState.Count | Number | Number of detection IDs updated. |
vega-update-detections
Update severity, status, state, and/or tags for one or more Vega detections using the updateDetections GraphQL mutation.
Base Command
vega-update-detections
Input
| Argument Name | Description | Required |
|---|---|---|
| detection_id | A comma-separated list of Vega detection IDs to update. | Required |
| severity | The target Vega detection severity. Possible values are: LOW, MEDIUM, HIGH, CRITICAL. | Optional |
| state | The target Vega detection state. Possible values are: ENABLED, DISABLED, TEST_MODE. | Optional |
| tags | A comma-separated list of tags to apply to the Vega detection. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Vega.Detection.ID | String | Updated Vega detection ID. |
| Vega.Detection.Name | String | Updated Vega detection name. |
| Vega.Detection.Severity | String | Updated Vega detection severity. |
| Vega.Detection.Status | String | Updated Vega detection status. |
| Vega.Detection.State | String | Updated Vega detection state. |
| Vega.Detection.Tags | String | Updated Vega detection tags. |
| Vega.Detection.ValidationStatus | String | Vega validation status for the detection update. |
vega-update-alert
Immediately update Vega alert status, severity, verdict, verdict reasoning, assignees, and/or comment on the Vega platform and sync the open Cortex XSOAR investigation when run from a Vega Alert investigation.
Base Command
vega-update-alert
Input
| Argument Name | Description | Required |
|---|---|---|
| alert_ids | A comma-separated list of Vega alert IDs to update. Accepts a comma-separated list or repeated values (for example, alert_ids=alert-1,alert-2). Use this to update alerts directly from the war room without opening an investigation. When omitted, the alert ID is resolved from the current Vega Alert investigation. One or more Vega alert IDs to update. Accepts a comma-separated list or repeated values (for example, alert_ids=alert-1,alert-2). Use this to update alerts directly from the war room without opening an investigation. When omitted, the alert ID is resolved from the current Vega Alert investigation. | Optional |
| status | The target Vega alert status. Possible values are: OPEN, IN PROGRESS, PEER REVIEW, RESOLVED. | Optional |
| verdict | The target Vega alert verdict. Possible values are: MALICIOUS, SUSPICIOUS, BENIGN, INCONCLUSIVE, NA. | Optional |
| severity | The target Vega alert severity. Possible values are: LOW, MEDIUM, HIGH, CRITICAL. | Optional |
| verdict_reasoning | The target Vega alert verdict reasoning. | Optional |
| comment | The comment to add on the Vega alert. | Optional |
| assignees | A comma-separated list of Vega user IDs to assign to the alert. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Vega.Alert.id | String | Updated Vega alert ID. |
| Vega.Alert.status | String | Updated Vega alert status. |
| Vega.Alert.severity | String | Updated Vega alert severity. |
| Vega.Alert.verdict | String | Updated Vega alert verdict. |
| Vega.Alert.assignee | String | Updated Vega alert assignee email, display name, or user ID. |
vega-update-incident
Immediately update Vega incident status, verdict, verdict reasoning, severity, assignee emails, and/or comment on the Vega platform and sync the open Cortex XSOAR investigation when run from a Vega Incident investigation.
Base Command
vega-update-incident
Input
| Argument Name | Description | Required |
|---|---|---|
| incident_ids | A comma-separated list of Vega incident IDs to update. Accepts a comma-separated list or repeated values (for example, incident_ids=inc-1,inc-2). Use this to update incidents directly from the war room without opening an investigation. When omitted, the incident ID is resolved from the current Vega Incident investigation. One or more Vega incident IDs to update. Accepts a comma-separated list or repeated values (for example, incident_ids=inc-1,inc-2). Use this to update incidents directly from the war room without opening an investigation. When omitted, the incident ID is resolved from the current Vega Incident investigation. | Optional |
| status | The target Vega incident status. Possible values are: NEW, INVESTIGATING, ON HOLD, EXTERNAL ESCALATION, RESOLVED, REOPENED, REVIEW RECOMMENDED, RESPONSE REQUIRED, UNDER REVIEW. | Optional |
| verdict | The target Vega incident verdict. Possible values are: MALICIOUS, SUSPICIOUS, BENIGN, INCONCLUSIVE, NA. | Optional |
| severity | The target Vega incident severity. Possible values are: LOW, MEDIUM, HIGH, CRITICAL. | Optional |
| verdict_reasoning | The target Vega incident verdict reasoning. | Optional |
| comment | The comment to add on the Vega incident. | Optional |
| assignee_emails | A comma-separated list of email addresses to assign to the Vega incident. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| Vega.Incident.id | String | Updated Vega incident ID. |
| Vega.Incident.status | String | Updated Vega incident status. |
| Vega.Incident.verdict | String | Updated Vega incident verdict. |
| Vega.Incident.severity | String | Updated Vega incident severity. |
| Vega.Incident.assignee | String | Updated Vega incident assignee email, display name, or user ID. |
get-remote-data
Gets remote data from a remote Vega alert or incident. Used for debugging incoming mirroring.
Base Command
get-remote-data
Input
| Argument Name | Description | Required |
|---|---|---|
| id | The remote Vega alert or incident ID. | Required |
| lastUpdate | The UTC timestamp in seconds (e.g., 1672531200). The incident is only updated if it was modified after the last update time. | Optional |
Context Output
There is no context output for this command.
get-modified-remote-data
Gets Vega alert and incident IDs modified since the last update time. Used for debugging incoming mirroring.
Base Command
get-modified-remote-data
Input
| Argument Name | Description | Required |
|---|---|---|
| lastUpdate | The UTC timestamp in seconds (e.g., 1672531200). Returns Vega alert and incident IDs updated since this time. | Required |
Context Output
There is no context output for this command.
update-remote-system
Pushes Cortex XSOAR investigation changes to Vega when outgoing mirroring is enabled.
Base Command
update-remote-system
Input
There are no input arguments for this command.
Context Output
There is no context output for this command.
get-mapping-fields
Returns the outgoing mirroring fields for Vega Alert and Vega Incident investigations.
Base Command
get-mapping-fields
Input
There are no input arguments for this command.
Context Output
There is no context output for this command.
Incident Mirroring
You can enable incident mirroring between Cortex XSOAR incidents and Vega corresponding events (available from Cortex XSOAR version 6.0.0).
To set up the mirroring:
- Enable Fetching incidents in your instance configuration.
Newly fetched incidents will be mirrored in the chosen direction. However, this selection does not affect existing incidents.
Important Note: To ensure the mirroring works as expected, mappers are required, both for incoming and outgoing, to map the expected fields in Cortex XSOAR and Vega.
Configuration parameters
url— Base URL (required)access_key_id— Access Key ID (required)access_key— Access Key (required)isFetch— Fetch incidentsincidentFetchInterval— Incidents Fetch Interval (required)max_fetch— Maximum incidents per fetch (required)lookback_minutes— Fetch Lookback (minutes) (required)vega_entities— Vega Entities to fetch (required)backfill_days— Backfill Days (required)autoclosure— Enable Cortex XSOAR to Vega mirroringoutgoing_mirror_fields— Outgoing fields to mirroralert_severities— Alert Severities to fetchalert_statuses— Alert Statuses to fetchalert_verdicts— Alert Verdicts to fetchalert_has_related_incidents— Has related incidents (required)incident_severities— Incident Severities to fetchincident_statuses— Incident Statuses to fetchincident_verdicts— Incident Verdicts to fetchincidentType— Incident typeinsecure— Trust any certificate (not secure)proxy— Use system proxy settings
Commands (9)
-
get-mapping-fieldsReturns the outgoing mirroring fields for Vega Alert and Vega Incident investigations.
-
get-modified-remote-dataGets Vega alert and incident IDs modified since the last update time. Used for debugging incoming mirroring.
-
get-remote-dataGets remote data from a remote Vega alert or incident. Used for debugging incoming mirroring.
-
update-remote-systemPushes Cortex XSOAR investigation changes to Vega when outgoing mirroring is enabled.
-
vega-get-alert-eventsFetch all aggregated alert events for a Vega alert using internal API pagination, then return the requested display page as a markdown table and CustomFields for the Alert Events layout section.
-
vega-set-detections-stateSet the state for one or more Vega detections.
-
vega-update-alertImmediately update Vega alert status, severity, verdict, verdict reasoning, assignees, and/or comment on the Vega platform and sync the open Cortex XSOAR investigation when run from a Vega Alert investigation.
-
vega-update-detectionsUpdate severity, status, state, and/or tags for one or more Vega detections using the updateDetections GraphQL mutation.
-
vega-update-incidentImmediately update Vega incident status, verdict, verdict reasoning, severity, assignee emails, and/or comment on the Vega platform and sync the open Cortex XSOAR investigation when run from a Vega Incident investigation.
import demistomock as demisto from CommonServerPython import * from CommonServerUserPython import * import html as html_module import http.client as http_client import math import re import time from collections.abc import Callable from datetime import datetime, timedelta, UTC from typing import Any # ?-------------------------- Import -------------------------------------- # ? ---------------------------- HEADERS LOGS SUPPRESSION -------------------------------------- def _suppress_noisy_http_integration_logs() -> None: """Suppress http.client wire logs such as CloudFront response headers from integration logs.""" try: http_client.HTTPConnection.debuglevel = 0 except Exception as exc: demisto.debug(f"Vega: unable to disable HTTP wire logging: {exc}") if not is_debug_mode(): return integration_logger = LOG if getattr(integration_logger, "_vega_http_log_filter_installed", False): return original_write = integration_logger.write def filtered_write(msg: Any) -> None: encoding = getattr(integration_logger, "encoding", "utf-8") text = msg.decode(encoding) if isinstance(msg, bytes) else str(msg) if text.lstrip().startswith("header:"): return original_write(msg) integration_logger.write = filtered_write # type: ignore[method-assign] integration_logger._vega_http_log_filter_installed = True # type: ignore[attr-defined] # ? ---------------------------- HEADERS LOGS SUPPRESSION -------------------------------------- # ? ------------------------------------CONSTANTS -------------------------------------- VEGA_SEVERITY_TO_XSOAR = { "LOW": IncidentSeverity.LOW, "MEDIUM": IncidentSeverity.MEDIUM, "HIGH": IncidentSeverity.HIGH, "CRITICAL": IncidentSeverity.CRITICAL, } VALID_DETECTION_STATES = frozenset({"ENABLED", "DISABLED", "TEST_MODE"}) VALID_DETECTION_SEVERITIES = frozenset({"LOW", "MEDIUM", "HIGH", "CRITICAL"}) MIRROR_ENTITY_SUFFIX_ALERT = "alert" MIRROR_ENTITY_SUFFIX_INCIDENT = "incident" VEGA_ALERT_STATUS_FIELD = "vegastatus" VEGA_ALERT_SEVERITY_FIELD = "vegaalertseverity" VEGA_INCIDENT_STATUS_FIELD = "vegaincidentstatus" VEGA_SEVERITY_FIELD = "vegaseverity" VEGA_VERDICT_FIELD = "vegaverdict" VEGA_VERDICT_REASONING_FIELD = "vegaverdictreasoning" VEGA_NEW_COMMENT_FIELD = "veganewcomment" VEGA_NEW_COMMENT_LAYOUT_DEFAULT = "comment" VEGA_MIRROR_TAG_FROM_VEGA = "From Vega" VEGA_MIRROR_TAG_TO_VEGA = "To Vega" GET_MODIFIED_REMOTE_DATA_LIMIT = 100 MIRROR_POLL_LOOKBACK = timedelta(minutes=1) MIRROR_LAST_UPDATE_SAFETY_MARGIN = timedelta(minutes=1) MIRROR_UPDATED_TO_BUFFER = timedelta(minutes=1) MITRE_TACTIC_KEYS = ("mitreTactics", "mitre_tactics", "tactics") MITRE_TECHNIQUE_KEYS = ("mitreTechniques", "mitre_techniques", "techniques") VEGA_NUMERIC_SEVERITY_TO_API: dict[int, str] = { 1: "LOW", 2: "MEDIUM", 3: "HIGH", 4: "CRITICAL", } VEGA_OUTGOING_MIRROR_FIELDS: dict[str, dict[str, tuple[str, ...]]] = { "Vega Alert": { VEGA_ALERT_STATUS_FIELD: ("Vega alert status",), VEGA_ALERT_SEVERITY_FIELD: ("Vega alert severity",), VEGA_VERDICT_FIELD: ("Vega alert verdict",), VEGA_VERDICT_REASONING_FIELD: ("Vega alert verdict reasoning",), VEGA_NEW_COMMENT_FIELD: ("Comment to add on the Vega alert",), }, "Vega Incident": { VEGA_SEVERITY_FIELD: ("Vega incident severity",), VEGA_INCIDENT_STATUS_FIELD: ("Vega incident status",), VEGA_VERDICT_FIELD: ("Vega incident verdict",), VEGA_VERDICT_REASONING_FIELD: ("Vega incident verdict reasoning",), VEGA_NEW_COMMENT_FIELD: ("Comment to add on the Vega incident",), }, } OUTGOING_MIRROR_FIELD_STATUS = "Status" OUTGOING_MIRROR_FIELD_SEVERITY = "Severity" OUTGOING_MIRROR_FIELD_VERDICT = "Verdict" OUTGOING_MIRROR_FIELD_VERDICT_REASONING = "Verdict Reasoning" OUTGOING_MIRROR_FIELD_COMMENTS = "Comments" OUTGOING_MIRROR_FIELD_LABELS: tuple[str, ...] = ( OUTGOING_MIRROR_FIELD_STATUS, OUTGOING_MIRROR_FIELD_SEVERITY, OUTGOING_MIRROR_FIELD_VERDICT, OUTGOING_MIRROR_FIELD_VERDICT_REASONING, OUTGOING_MIRROR_FIELD_COMMENTS, ) VALID_OUTGOING_MIRROR_FIELD_LABELS = frozenset(OUTGOING_MIRROR_FIELD_LABELS) VEGA_CLOSE_STATUSES = frozenset({"RESOLVED"}) VEGA_ALERT_OPEN_STATUSES = frozenset({"REOPENED", "OPEN", "NEW", "INVESTIGATING", "IN_PROGRESS", "PEER_REVIEW"}) VEGA_INCIDENT_OPEN_STATUSES = frozenset( { "REOPENED", "OPEN", "NEW", "INVESTIGATING", "IN_PROGRESS", "ON_HOLD", "EXTERNAL_ESCALATION", "REVIEW_RECOMMENDED", "RESPONSE_REQUIRED", "UNDER_REVIEW", } ) RATE_LIMIT_MAX_RETRIES = 10 RATE_LIMIT_INITIAL_WAIT_SECONDS = 2 RATE_LIMIT_WAIT_INCREMENT_SECONDS = 2 FETCH_ENTITIES_PAGE_SIZE = 100 _RETRYABLE_HTTP_STATUS_CODES = frozenset({429, 502, 503, 504}) DEFAULT_ALERT_EVENTS_PAGE_SIZE = 200 ALERT_EVENT_JSON_MERGE_KEYS = frozenset({"fields"}) ALERT_EVENT_JSON_TRUNCATE_KEYS = frozenset({"raw", "_raw"}) ALERT_EVENTS_NOT_AVAILABLE_MARKDOWN = "### Alert Events\n\nNo alert events found." ALERT_EVENT_MAX_FLATTEN_DEPTH = 3 ALERT_EVENT_MAX_COLUMNS = 20 ALERT_EVENT_MAX_CELL_LENGTH = 300 ALERT_EVENT_PREFERRED_COLUMNS: tuple[str, ...] = ( "timestamp", "index_timestamp", "timeframe", "timeFrame", "source", "storage", "catalog", "class", "data_source", "operation", "log_type", "actor.user.uid", "actor.user.name", "actor.user.username", "user.username", "event_count", "eventCount", "unique_events_count", "uniqueEventsCount", "regions_count", "regionsCount", "unique_events", "uniqueEvents", "status_code", "request.uri", "resource.name", "cluster.name", "namespace.name", "account.uid", "user_agent", "src_endpoint.ip", "verb", "raw", "_raw", ) VEGA_TIMELINE_ALERT_SEVERITY_LABELS: dict[int, str] = { 1: "Low", 2: "Medium", 3: "High", 4: "Critical", } BACKFILL_DAYS_MIN = 0 BACKFILL_DAYS_MAX = 365 DEFAULT_BACKFILL_DAYS = 30 MAX_FETCH_MIN = 1 MAX_FETCH_CAP = 50 DEFAULT_MAX_FETCH = 50 MAX_FETCH_ERROR = "Incorrect value please enter between 1-50." DEFAULT_LOOKBACK_MINUTES = 5 MAX_LOOKBACK_MINUTES = 60 INCIDENTS_OFFSET_KEY = "incidents_offset" INCIDENTS_PAGINATION_FROM_KEY = "incidents_pagination_from" ALERTS_OFFSET_KEY = "alerts_offset" ALERTS_PAGINATION_FROM_KEY = "alerts_pagination_from" VALID_ALERT_STATUSES = frozenset({"OPEN", "IN_PROGRESS", "PEER_REVIEW", "RESOLVED"}) ALERT_STATUS_DISPLAY_TO_API: dict[str, str] = { "OPEN": "OPEN", "IN PROGRESS": "IN_PROGRESS", "PEER REVIEW": "PEER_REVIEW", "RESOLVED": "RESOLVED", } VALID_INCIDENT_STATUSES = frozenset( { "NEW", "INVESTIGATING", "ON_HOLD", "EXTERNAL_ESCALATION", "RESOLVED", "REOPENED", "REVIEW_RECOMMENDED", "RESPONSE_REQUIRED", "UNDER_REVIEW", } ) INCIDENT_STATUS_DISPLAY_TO_API: dict[str, str] = { "NEW": "NEW", "INVESTIGATING": "INVESTIGATING", "ON HOLD": "ON_HOLD", "EXTERNAL ESCALATION": "EXTERNAL_ESCALATION", "RESOLVED": "RESOLVED", "REOPENED": "REOPENED", "REVIEW RECOMMENDED": "REVIEW_RECOMMENDED", "RESPONSE REQUIRED": "RESPONSE_REQUIRED", "UNDER REVIEW": "UNDER_REVIEW", } VALID_SEVERITIES = frozenset({"LOW", "MEDIUM", "HIGH", "CRITICAL"}) VALID_VERDICTS = frozenset({"MALICIOUS", "SUSPICIOUS", "BENIGN", "INCONCLUSIVE", "NA"}) VERDICT_DISPLAY_TO_API: dict[str, str] = { "MALICIOUS": "MALICIOUS", "SUSPICIOUS": "SUSPICIOUS", "BENIGN": "BENIGN", "INCONCLUSIVE": "INCONCLUSIVE", "NA": "NA", "N/A": "NA", } _CONNECTION_ERROR_MARKERS = ( "connection timeout error", "verify that the server url", "connection error", "ssl certificate verification failed", "proxy error", "max retries error", "name or service not known", "nodename nor servname", "failed to establish a new connection", "temporary failure in name resolution", "read timed out", ) _URL_UNREACHABLE_STATUS_CODES = frozenset({404, 405, 502, 503, 504}) _AUTH_FAILURE_STATUS_CODES = frozenset({401, 403}) TEST_CONNECTION_URL_ERROR = ( "Unable to connect to the Vega API. Please verify the Base URL is correct " "and reachable from the Cortex XSOAR engine." ) TEST_CONNECTION_BASE_URL_ERROR = "Unable to reach the Vega API at the configured Base URL. Please verify the Base URL is correct." TEST_CONNECTION_ACCESS_KEY_ERROR = "Incorrect Access Key. Please check your credentials." TEST_CONNECTION_ACCESS_KEY_ID_ERROR = "Incorrect Access Key ID. Please check your credentials." # ? ------------------------------------CONSTANTS -------------------------------------- # ? ---------------------------- GRAPHQL QUERIES -------------------------------------- GET_ALERTS_QUERY = ( "query GetAlerts($alertNames: [String!], $alertIds: [ID!], $alertSeverities: [AlertSeverity!], " "$statuses: [AlertStatus!], $detectionIds: [ID!], $dataSourceNames: [String!], " "$alertVerdicts: [AlertVerdict!], $hasRelatedIncidents: Boolean, $from: Time, " "$updatedFrom: Time, $updatedTo: Time, $limit: Int, $offset: Int) { " " getAlerts(alertNames: $alertNames, alertIds: $alertIds, alertSeverities: $alertSeverities, " "statuses: $statuses, detectionIds: $detectionIds, dataSourceNames: $dataSourceNames, " "alertVerdicts: $alertVerdicts, hasRelatedIncidents: $hasRelatedIncidents, from: $from, " "updatedFrom: $updatedFrom, updatedTo: $updatedTo, limit: $limit, offset: $offset) { " " alerts { id vegaAlertId detectionId name description severity status " " assignee { userId displayName email } " " assignees { userId displayName email } " " dataSources createdAt updatedAt " " mitre { mitreTactics mitreTechniques } " " relatedIncidents { incidentId name } " " detectionSource detectionDescription detectionQuery eventCount isTestMode verdict verdictReasoning dedupCount " " comments { text addedBy addedAt } } " " total limit offset " " error { code message } } }" ) GET_ALERT_MIRROR_QUERY = ( "query GetAlerts($alertIds: [ID!], $from: Time, $limit: Int, $offset: Int) { " " getAlerts(alertIds: $alertIds, from: $from, limit: $limit, offset: $offset) { " " alerts { id vegaAlertId status severity verdict verdictReasoning updatedAt " " comments { text addedBy addedAt } } " " total limit offset " " error { code message } } }" ) GET_INCIDENT_MIRROR_QUERY = ( "query GetIncidents($incidentIds: [ID!], $from: Time, $limit: Int, $offset: Int) { " " getIncidents(incidentIds: $incidentIds, from: $from, limit: $limit, offset: $offset) { " " incidents { id status severity verdict verdictReasoning lastUpdated " " comments { text addedBy addedAt } } " " total limit offset " " error { code message } } }" ) GET_INCIDENTS_QUERY = ( "query GetIncidents($incidentNames: [String!], $incidentIds: [ID!], $severities: [IncidentSeverity!], " "$statuses: [IncidentStatusPublic!], $verdicts: [IncidentVerdictPublic!], " "$from: Time, $updatedFrom: Time, $updatedTo: Time, $limit: Int, $offset: Int) { " " getIncidents(incidentNames: $incidentNames, incidentIds: $incidentIds, severities: $severities, " "statuses: $statuses, verdicts: $verdicts, from: $from, updatedFrom: $updatedFrom, " "updatedTo: $updatedTo, limit: $limit, offset: $offset) { " " incidents { id name createdBy createdAt lastUpdated severity status dataSources verdict verdictReasoning " " assignee { userId displayName email } " " assignees { userId displayName email } " " comments { text addedBy addedAt } " " incidentSummary incidentFindings assets observables alertsCount " " alerts { alertId name createdAt } " " recommendedActions { name description actionKey targetParams } " " investigationPlan { stepName stepConclusion cells { cellName query queryId } } " " link } " " total limit offset " " error { code message } } }" ) GET_INCIDENT_TIMELINE_QUERY = ( "query GetIncidentTimeline($incidentId: ID!, $limit: Int, $offset: Int) { " " getIncidentTimeline(incidentId: $incidentId, limit: $limit, offset: $offset) { " " events { " " id timestamp summary dataSources assets observables " " alert { alertId name createdAt } " " } " " total limit offset " " error { code message } } }" ) UPDATE_ALERTS_MUTATION = ( "mutation UpdateAlerts($input: UpdateAlertsInput!) { " " updateAlerts(input: $input) { " " alerts { id vegaAlertId detectionId name description severity status " " assignee { userId displayName email } " " assignees { userId displayName email } " " dataSources createdAt updatedAt " " mitre { mitreTactics mitreTechniques } " " relatedIncidents { incidentId name } " " detectionSource detectionDescription detectionQuery eventCount isTestMode " " verdict verdictReasoning dedupCount " " comments { text addedBy addedAt } } " " error { code message } } }" ) UPDATE_INCIDENTS_MUTATION = ( "mutation UpdateIncidents($input: UpdateIncidentsInput!) { " " updateIncidents(input: $input) { " " incidents { incidentId incidentName status " " assignee { userId displayName email } " " assignees { userId displayName email } " " verdict verdictReasoning updatedAt } " " errors { code message } } }" ) SET_DETECTIONS_STATE_MUTATION = ( "mutation SetDetectionsState($input: SetDetectionsStateInput!) { " " setDetectionsState(input: $input) { " " ids } }" ) UPDATE_DETECTIONS_MUTATION = ( "mutation UpdateDetections($input: UpdateDetectionsInput!) { " " updateDetections(input: $input) { " " results { " " name status " " errors { code message field } " " detection { id name severity status state tags } " " } " " summary { requested valid invalid committed } " " } }" ) GET_ALERTS_EVENTS_QUERY = ( "query GetAlertsEvents($alertId: ID!, $limit: Int, $offset: Int) { " " getAlertsEvents(alertId: $alertId, limit: $limit, offset: $offset) { " " total limit offset results " " error { code message } } }" ) # ? ---------------------------- GRAPHQL QUERIES -------------------------------------- # ? ---------------------------- HELPER FUNCTIONS -------------------------------------- def parse_backfill_days(backfill_days: str | int | float | None) -> str: """Convert backfill day count to an ISO 8601 UTC start time for the first fetch.""" raw = "" if backfill_days is None else str(backfill_days).strip() try: days = int(float(raw)) if raw else DEFAULT_BACKFILL_DAYS except (TypeError, ValueError): days = DEFAULT_BACKFILL_DAYS if days < BACKFILL_DAYS_MIN or days > BACKFILL_DAYS_MAX: days = DEFAULT_BACKFILL_DAYS start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days) return start.strftime("%Y-%m-%dT%H:%M:%SZ") def validate_lookback_minutes(lookback_minutes: int | str | None) -> None: """Validate that lookback_minutes is an integer between 1 and MAX_LOOKBACK_MINUTES inclusive.""" parsed = arg_to_number(lookback_minutes, arg_name="lookback_minutes", required=False) if parsed is None or parsed < 1 or parsed > MAX_LOOKBACK_MINUTES: raise ValueError(f"Fetch Lookback (minutes) must be an integer between 1 and {MAX_LOOKBACK_MINUTES}.") def _parse_lookback_minutes(raw_value: str | int | None) -> int: """Parse the lookback_minutes parameter, clamping to [1, MAX_LOOKBACK_MINUTES].""" if raw_value is None or str(raw_value).strip() == "": return DEFAULT_LOOKBACK_MINUTES try: minutes = int(str(raw_value).strip()) except (TypeError, ValueError): return DEFAULT_LOOKBACK_MINUTES return max(1, min(minutes, MAX_LOOKBACK_MINUTES)) def _apply_lookback_to_from_time(from_time: str, lookback_minutes: int) -> str: """Shift a fetch from_time backwards by lookback_minutes for late-indexed entity detection.""" parsed = _parse_entity_created_at(from_time) if parsed is None: return from_time shifted = parsed - timedelta(minutes=lookback_minutes) return _format_fetch_timestamp(shifted) def _mirror_bool(value: Any) -> bool: """Parse XSOAR mirror boolean args that may be bool, int, or string.""" if value is None or value == "": return False try: return argToBoolean(value) except (ValueError, TypeError): return bool(value) def _mirror_custom_fields(source: dict[str, Any]) -> dict[str, Any]: """Return CustomFields from a mirror payload, parsing JSON strings when needed.""" custom_fields = source.get("CustomFields") if custom_fields is None: custom_fields = source.get("customFields") if isinstance(custom_fields, str): return _mirror_dict(custom_fields) return dict(custom_fields) if isinstance(custom_fields, dict) else {} def validate_backfill_days(backfill_days: str | int | None) -> None: """Validate that backfill_days is an integer between 0 and 365 inclusive.""" if backfill_days is None: raise ValueError("backfill_days must be an integer between 0 and 365.") try: days = int(backfill_days) except (TypeError, ValueError): raise ValueError("backfill_days must be an integer between 0 and 365.") if days < BACKFILL_DAYS_MIN or days > BACKFILL_DAYS_MAX: raise ValueError("backfill_days must be between 0 and 365.") def _filter_fetch_values( values: list[str] | None, valid_api_values: frozenset[str], display_to_api: dict[str, str] | None = None, ) -> list[str] | None: """Keep only recognized fetch filters and map UI labels to Vega API enum values. Unknown or custom multi-select values are ignored. When provided, display_to_api maps human-readable labels to API values; values already in valid_api_values are also accepted (e.g. legacy underscore-separated statuses). """ if not values: return None label_map = display_to_api or {} api_values: list[str] = [] seen: set[str] = set() for raw in values: if raw is None or not str(raw).strip(): continue token = str(raw).strip().upper() api_value = label_map.get(token) if api_value is None and token in valid_api_values: api_value = token if api_value and api_value in valid_api_values and api_value not in seen: seen.add(api_value) api_values.append(api_value) return api_values or None def filter_alert_statuses(values: list[str] | None) -> list[str] | None: """Validate alert status filters and return Vega API status values.""" return _filter_fetch_values(values, VALID_ALERT_STATUSES, ALERT_STATUS_DISPLAY_TO_API) def filter_incident_statuses(values: list[str] | None) -> list[str] | None: """Validate incident status filters and return Vega API status values.""" return _filter_fetch_values(values, VALID_INCIDENT_STATUSES, INCIDENT_STATUS_DISPLAY_TO_API) def filter_alert_severities(values: list[str] | None) -> list[str] | None: """Validate alert severity filters and return Vega API severity values.""" return _filter_fetch_values(values, VALID_SEVERITIES) def filter_incident_severities(values: list[str] | None) -> list[str] | None: """Validate incident severity filters and return Vega API severity values.""" return _filter_fetch_values(values, VALID_SEVERITIES) def filter_alert_verdicts(values: list[str] | None) -> list[str] | None: """Validate alert verdict filters and return Vega API verdict values.""" return _filter_fetch_values(values, VALID_VERDICTS, VERDICT_DISPLAY_TO_API) def filter_incident_verdicts(values: list[str] | None) -> list[str] | None: """Validate incident verdict filters and return Vega API verdict values.""" return _filter_fetch_values(values, VALID_VERDICTS, VERDICT_DISPLAY_TO_API) def _resolve_outgoing_mirror_fields(params: dict[str, Any] | None = None) -> set[str]: """Return enabled outgoing mirror field labels. Empty selection mirrors all fields.""" params = params or demisto.params() selected = argToList(params.get("outgoing_mirror_fields")) if not selected: return set(OUTGOING_MIRROR_FIELD_LABELS) enabled = { str(value).strip() for value in selected if value is not None and str(value).strip() in VALID_OUTGOING_MIRROR_FIELD_LABELS } return enabled if enabled else set(OUTGOING_MIRROR_FIELD_LABELS) def resolve_has_related_incidents(values: list[str] | None) -> bool | None: """Resolve the Vega hasRelatedIncidents filter from Yes/No multi-select values. Returns True when only Yes is selected, False when only No is selected, and None when both or neither are selected so the API filter is omitted. """ if not values: return None normalized = {str(value).strip().upper() for value in values if str(value).strip()} has_yes = "YES" in normalized has_no = "NO" in normalized result: bool | None = None if has_yes and not has_no: result = True elif has_no and not has_yes: result = False return result def _http_status_code(exc: Exception) -> int | None: """Return the HTTP status code from a DemistoException, if present.""" if isinstance(exc, DemistoException) and exc.res is not None: return exc.res.status_code return None def _is_retryable_http_error(exc: Exception) -> bool: """Return True when an HTTP or network failure is likely transient and safe to retry.""" status_code = _http_status_code(exc) if status_code in _RETRYABLE_HTTP_STATUS_CODES: return True message = str(exc).lower() return any(marker in message for marker in _CONNECTION_ERROR_MARKERS) def _is_graphql_rate_limited(errors: Any) -> bool: """Return True when GraphQL errors indicate rate limiting.""" if not isinstance(errors, list): return False for err in errors: if not isinstance(err, dict): continue extensions = err.get("extensions") or {} error_code_name = extensions.get("error_code_name") code = extensions.get("code") if error_code_name == "REQUEST_RATE_LIMITED" or code == "TooManyRequests": return True return False def _is_connection_or_url_error(exc: Exception) -> bool: """Return True when the failure is likely caused by an invalid or unreachable Base URL.""" message = str(exc).lower() if any(marker in message for marker in _CONNECTION_ERROR_MARKERS): return True status_code = _http_status_code(exc) return status_code in _URL_UNREACHABLE_STATUS_CODES if status_code is not None else False def _test_connection_error_message(exc: Exception, key_error_msg: str) -> str: """Map API failures to a user-facing test-connection message.""" if _is_connection_or_url_error(exc): return TEST_CONNECTION_BASE_URL_ERROR if _http_status_code(exc) == 404 else TEST_CONNECTION_URL_ERROR return key_error_msg def _build_alerts_query_variables( *, severities: list[str] | None = None, statuses: list[str] | None = None, verdicts: list[str] | None = None, has_related_incidents: bool | None = None, from_time: str | None = None, updated_from: str | None = None, updated_to: str | None = None, alert_ids: list[str] | None = None, limit: int | None = None, offset: int = 0, ) -> dict[str, Any]: """Build GraphQL variables for the getAlerts query.""" variables: dict[str, Any] = {"offset": offset} if limit is not None: variables["limit"] = limit if severities: variables["alertSeverities"] = severities if statuses: variables["statuses"] = statuses if verdicts: variables["alertVerdicts"] = verdicts if has_related_incidents is not None: variables["hasRelatedIncidents"] = has_related_incidents if from_time: variables["from"] = from_time if updated_from: variables["updatedFrom"] = updated_from if updated_to: variables["updatedTo"] = updated_to if alert_ids: variables["alertIds"] = alert_ids return variables def _build_incidents_query_variables( *, severities: list[str] | None = None, statuses: list[str] | None = None, verdicts: list[str] | None = None, from_time: str | None = None, updated_from: str | None = None, updated_to: str | None = None, incident_ids: list[str] | None = None, limit: int | None = None, offset: int = 0, ) -> dict[str, Any]: """Build GraphQL variables for the getIncidents query.""" variables: dict[str, Any] = {"offset": offset} if limit is not None: variables["limit"] = limit if severities: variables["severities"] = severities if statuses: variables["statuses"] = statuses if verdicts: variables["verdicts"] = verdicts if from_time: variables["from"] = from_time if updated_from: variables["updatedFrom"] = updated_from if updated_to: variables["updatedTo"] = updated_to if incident_ids: variables["incidentIds"] = incident_ids return variables def _validate_test_connection_roles(get_access_key: dict[str, Any]) -> None: """Raise when the access key lacks editor or admin roles.""" roles = get_access_key.get("roles") or [] if not any(re.search(r"(?i)editor|admin", role) for role in roles): raise ValueError("You do not have required access to fetch incidents.") # ? ---------------------------- CLIENT CLASS -------------------------------------- class Client(BaseClient): """ Client Class For Vega API Integration """ def __init__( self, base_url: str, verify: bool, proxy: bool, access_key: str, access_key_id: str = "", ): base_url = base_url.strip() if base_url else "" if base_url: base_url = base_url.rstrip("/") if not base_url.lower().endswith("/api/v1"): base_url = f"{base_url}/api/v1" base_url = f"{base_url}/" super().__init__(base_url=base_url, verify=verify, proxy=proxy) _suppress_noisy_http_integration_logs() self.access_key = access_key self.access_key_id = access_key_id self._session_jwt: str | None = None self._rate_limit_wait_seconds = RATE_LIMIT_INITIAL_WAIT_SECONDS def _reset_rate_limit_wait(self) -> None: """Reset the rate-limit backoff interval after a successful API call.""" self._rate_limit_wait_seconds = RATE_LIMIT_INITIAL_WAIT_SECONDS def _sleep_before_rate_limit_retry(self, context: str, attempt: int) -> None: """Sleep using incremental backoff before retrying a transient Vega API request.""" demisto.debug( f"Vega API transient error ({context}). Waiting {self._rate_limit_wait_seconds}s before retry " f"{attempt + 1}/{RATE_LIMIT_MAX_RETRIES}." ) time.sleep(self._rate_limit_wait_seconds) # pylint: disable=E9003 self._rate_limit_wait_seconds += RATE_LIMIT_WAIT_INCREMENT_SECONDS def _http_request(self, *args, **kwargs): # type: ignore[no-untyped-def] """Execute an HTTP request with retry handling for rate limits and transient gateway errors.""" reset_backoff_on_success = kwargs.pop("reset_backoff_on_success", True) last_exc: Exception | None = None for attempt in range(RATE_LIMIT_MAX_RETRIES): try: response = super()._http_request(*args, **kwargs) if reset_backoff_on_success: self._reset_rate_limit_wait() return response except Exception as exc: if not _is_retryable_http_error(exc): raise last_exc = exc if attempt < RATE_LIMIT_MAX_RETRIES - 1: status_code = _http_status_code(exc) context = f"HTTP {status_code}" if status_code is not None else "network" self._sleep_before_rate_limit_retry(context, attempt) if last_exc is not None: raise last_exc raise DemistoException("Vega API request failed after maximum retries.") def _authenticate(self) -> str: """Authenticate with the Vega API and return a session JWT token.""" if self._session_jwt: return self._session_jwt login_res: dict = self._http_request( method="POST", url_suffix="login_machine", json_data={"access_key": self.access_key}, resp_type="json", ok_codes=(200,), ) session_jwt: str = login_res.get("session_jwt", "") if login_res else "" if not session_jwt: raise ValueError("Authentication failed: no session JWT received.") self._session_jwt = session_jwt return session_jwt def _auth_headers(self, session_jwt: str) -> dict[str, str]: """Build authentication headers for authenticated Vega API requests.""" return { "JWTSessionToken": session_jwt, "X-Vega-Key-Id": self.access_key_id, } def _graphql_request(self, query: str, variables: dict | None = None) -> dict: """Execute a GraphQL query against the Vega API.""" last_rate_limit_errors: list[Any] | None = None for attempt in range(RATE_LIMIT_MAX_RETRIES): response = self._post_graphql_query(query, variables) errors = response.get("errors") if not errors: self._reset_rate_limit_wait() return response if not _is_graphql_rate_limited(errors): raise DemistoException(f"GraphQL error: {errors}") last_rate_limit_errors = errors if attempt < RATE_LIMIT_MAX_RETRIES - 1: self._sleep_before_rate_limit_retry("GraphQL REQUEST_RATE_LIMITED", attempt) raise DemistoException( f"API rate limit exceeded after maximum retries. GraphQL error: {last_rate_limit_errors}. " f"Next wait interval would be {self._rate_limit_wait_seconds}s." ) def _post_graphql_query(self, query: str, variables: dict | None) -> dict: """Authenticate and POST a single GraphQL query.""" session_jwt = self._authenticate() json_data: dict[str, Any] = {"query": query} if variables: json_data["variables"] = variables response = self._http_request( method="POST", url_suffix="query", headers=self._auth_headers(session_jwt), json_data=json_data, resp_type="json", ok_codes=(200,), reset_backoff_on_success=False, ) return response if isinstance(response, dict) else {} def _login_for_test_connection(self) -> str: """Authenticate during test-module and return the session JWT.""" if not self._base_url or not str(self._base_url).strip(): raise ValueError(TEST_CONNECTION_BASE_URL_ERROR) try: login_res: dict = self._http_request( method="POST", url_suffix="login_machine", json_data={"access_key": self.access_key}, resp_type="json", ok_codes=(200,), ) except Exception as exc: raise ValueError(_test_connection_error_message(exc, TEST_CONNECTION_ACCESS_KEY_ERROR)) from exc session_jwt: str = login_res.get("session_jwt", "") if login_res else "" if not session_jwt: raise ValueError("Authentication failed: no session token received. " "Please verify the Access Key and Base URL.") return session_jwt def _query_access_key_for_test_connection(self, session_jwt: str) -> dict: """Validate the configured access key ID during test-module.""" query_data: dict = { "query": ( "query GetAccessKey($id: String!) { getAccessKey(id: $id) { id name description " "status createdBy createdAt expireTime roles bindings { role " "scopeId scopeName } secretValue }}" ), "variables": {"id": self.access_key_id}, } try: response = self._http_request( method="POST", url_suffix="query", headers=self._auth_headers(session_jwt), json_data=query_data, resp_type="response", ok_codes=(200,), ) query_res = response.json() except Exception as exc: raise ValueError(_test_connection_error_message(exc, TEST_CONNECTION_ACCESS_KEY_ID_ERROR)) from exc errors = query_res.get("errors") get_access_key = (query_res.get("data") or {}).get("getAccessKey") if errors or get_access_key is None: raise ValueError(TEST_CONNECTION_ACCESS_KEY_ID_ERROR) _validate_test_connection_roles(get_access_key) return query_res def test_connection(self, backfill_days: str | int | None = None) -> dict: validate_backfill_days(backfill_days) session_jwt = self._login_for_test_connection() return self._query_access_key_for_test_connection(session_jwt) def get_alerts( self, severities: list[str] | None = None, statuses: list[str] | None = None, verdicts: list[str] | None = None, has_related_incidents: bool | None = None, from_time: str | None = None, updated_from: str | None = None, updated_to: str | None = None, alert_ids: list[str] | None = None, limit: int | None = None, offset: int = 0, ) -> dict: """Fetch alerts from the Vega API.""" variables = _build_alerts_query_variables( severities=severities, statuses=statuses, verdicts=verdicts, has_related_incidents=has_related_incidents, from_time=from_time, updated_from=updated_from, updated_to=updated_to, alert_ids=alert_ids, limit=limit, offset=offset, ) response = self._graphql_request(GET_ALERTS_QUERY, variables) raw_data = response.get("data") data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} return data.get("getAlerts") or {} def get_alert_by_id(self, alert_id: str, *, from_time: str | None = None) -> dict: """Fetch a single Vega alert by ID.""" response = self.get_alerts(alert_ids=[alert_id], from_time=from_time, limit=1) or {} alerts = response.get("alerts") or [] if alerts: return _normalize_alert_api_entity(alerts[0]) return {} def get_alert_for_mirror(self, alert_id: str, *, from_time: str | None = None) -> dict: """Fetch mirror-sync alert fields using a lightweight GraphQL query.""" variables: dict[str, Any] = {"alertIds": [alert_id], "limit": 1, "offset": 0} if from_time: variables["from"] = from_time response = self._graphql_request(GET_ALERT_MIRROR_QUERY, variables) get_alerts = (response.get("data") or {}).get("getAlerts") or {} alerts = get_alerts.get("alerts") or [] return alerts[0] if alerts else {} def get_incidents( self, severities: list[str] | None = None, statuses: list[str] | None = None, verdicts: list[str] | None = None, from_time: str | None = None, updated_from: str | None = None, updated_to: str | None = None, incident_ids: list[str] | None = None, limit: int | None = None, offset: int = 0, ) -> dict: """Fetch incidents from the Vega API.""" variables = _build_incidents_query_variables( severities=severities, statuses=statuses, verdicts=verdicts, from_time=from_time, updated_from=updated_from, updated_to=updated_to, incident_ids=incident_ids, limit=limit, offset=offset, ) response = self._graphql_request(GET_INCIDENTS_QUERY, variables) raw_data = response.get("data") data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} return data.get("getIncidents") or {} def get_incident_by_id( self, incident_id: str, *, from_time: str | None = None, ) -> dict: """Fetch a single Vega incident by ID.""" response = ( self.get_incidents( incident_ids=[incident_id], from_time=from_time, limit=1, ) or {} ) incidents = response.get("incidents") or [] if incidents: return _normalize_incident_api_entity(incidents[0]) return {} def get_incident_for_mirror( self, incident_id: str, *, from_time: str | None = None, ) -> dict: """Fetch mirror-sync incident fields using a lightweight GraphQL query.""" variables: dict[str, Any] = {"incidentIds": [incident_id], "limit": 1, "offset": 0} if from_time: variables["from"] = from_time response = self._graphql_request(GET_INCIDENT_MIRROR_QUERY, variables) get_incidents = (response.get("data") or {}).get("getIncidents") or {} incidents = get_incidents.get("incidents") or [] return incidents[0] if incidents else {} def update_alerts(self, update_input: dict[str, Any]) -> dict: """Update one or more Vega alerts.""" raw_response = self._graphql_request(UPDATE_ALERTS_MUTATION, {"input": update_input}) response = raw_response if isinstance(raw_response, dict) else {} raw_data = response.get("data") data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} result = data.get("updateAlerts") or {} api_error = result.get("error") if isinstance(api_error, dict) and api_error.get("message"): raise DemistoException(f"Vega API error updating alerts: {api_error.get('message')}") if not result: errors = response.get("errors") if errors: raise DemistoException(f"Vega API error updating alerts: {errors}") return result def update_incidents(self, update_input: dict[str, Any]) -> dict: """Update one or more Vega incidents.""" raw_response = self._graphql_request(UPDATE_INCIDENTS_MUTATION, {"input": update_input}) response = raw_response if isinstance(raw_response, dict) else {} raw_data = response.get("data") data: dict[str, Any] = raw_data if isinstance(raw_data, dict) else {} result = data.get("updateIncidents") or {} errors = result.get("errors") or [] if errors: messages = [err.get("message", "") for err in errors if isinstance(err, dict)] raise DemistoException(f"Vega API error updating incidents: {', '.join(filter(None, messages))}") if not result: response_errors = response.get("errors") if response_errors: raise DemistoException(f"Vega API error updating incidents: {response_errors}") return result def get_incident_timeline(self, incident_id: str, limit: int = 100, offset: int = 0) -> dict: """Fetch timeline events for a Vega incident. Args: incident_id: Vega incident ID. limit: Maximum number of timeline events to return. offset: Pagination offset. Returns: The getIncidentTimeline response data. """ response = self._graphql_request( GET_INCIDENT_TIMELINE_QUERY, {"incidentId": incident_id, "limit": limit, "offset": offset}, ) data = response.get("data") or {} result = data.get("getIncidentTimeline") or {} return result if isinstance(result, dict) else {} def get_alert_events(self, alert_id: str, limit: int | None = None, offset: int = 0) -> dict: """Fetch aggregated alert events for a Vega alert. Args: alert_id: Vega alert ID. limit: Maximum number of events per request. offset: Pagination offset. Returns: The getAlertsEvents response data. """ variables: dict[str, Any] = {"alertId": alert_id, "offset": offset} if limit is not None: variables["limit"] = limit response = self._graphql_request(GET_ALERTS_EVENTS_QUERY, variables) data = response.get("data") or {} return data.get("getAlertsEvents") or {} def set_detections_state(self, detection_ids: list[str], state: str) -> dict: """Set the state for one or more Vega detections.""" response = self._graphql_request( SET_DETECTIONS_STATE_MUTATION, {"input": {"ids": detection_ids, "state": state}}, ) data = response.get("data") or {} result = data.get("setDetectionsState") or {} return result if isinstance(result, dict) else {} def update_detections(self, detections: list[dict[str, Any]]) -> dict: """Update one or more Vega detections.""" response = self._graphql_request( UPDATE_DETECTIONS_MUTATION, {"input": {"detections": detections}}, ) data = response.get("data") or {} result = data.get("updateDetections") or {} return result if isinstance(result, dict) else {} # ? ---------------------------- CLIENT CLASS -------------------------------------- # ? ---------------------------- HELPER FUNCTIONS -------------------------------------- def _event_has_bad_alert_events_shape(event: dict) -> bool: """Return True when a getAlertsEvents row is vendor raw data identified by cid/eid fields.""" if not event: return False return "cid" in event or "eid" in event def _events_have_bad_alert_events_shape(events: list[dict]) -> bool: """Return True when getAlertsEvents results contain vendor raw rows instead of alert events.""" return any(_event_has_bad_alert_events_shape(event) for event in events) def _parse_alert_events_results(results: Any) -> list[dict]: """Normalize getAlertsEvents results into a list of event dicts.""" if results is None: return [] if isinstance(results, str): text = results.strip() if not text: return [] try: parsed = json.loads(text) except json.JSONDecodeError: demisto.debug(f"Could not parse alert events results payload: {text[:200]!r}") return [] if isinstance(parsed, list): return [item for item in parsed if isinstance(item, dict)] if isinstance(parsed, dict): return [parsed] return [] if isinstance(results, list): return [item for item in results if isinstance(item, dict)] if isinstance(results, dict): return [results] return [] def _try_parse_json_value(value: Any) -> Any: """Parse JSON-encoded alert-event field values when Vega returns them as strings.""" if not isinstance(value, str): return value text = value.strip() if not text or text[0] not in "{[": return value try: return json.loads(text) except (json.JSONDecodeError, TypeError, ValueError): return value def _is_empty_alert_event_field_value(value: Any) -> bool: """Return True when a field value should be treated as empty for _raw promotion.""" return value is None or value == "" or value == {} or value == [] def _deep_merge_alert_event_dicts( base: dict[str, Any], incoming: dict[str, Any], *, prefer_existing: bool = True, ) -> dict[str, Any]: """Deep-merge dictionaries used while promoting alert-event fields.""" merged = dict(base) for key, incoming_value in incoming.items(): if key not in merged or _is_empty_alert_event_field_value(merged.get(key)): merged[key] = incoming_value continue existing_value = merged[key] if isinstance(existing_value, dict) and isinstance(incoming_value, dict): merged[key] = _deep_merge_alert_event_dicts( existing_value, incoming_value, prefer_existing=prefer_existing, ) elif isinstance(existing_value, list) and isinstance(incoming_value, list): merged[key] = _merge_alert_event_object_lists( existing_value, incoming_value, prefer_existing=prefer_existing, ) elif not prefer_existing and not _is_empty_alert_event_field_value(incoming_value): merged[key] = incoming_value return merged def _merge_alert_event_object_lists( left: list[Any], right: list[Any], *, prefer_existing: bool = True, ) -> list[Any]: """Merge list values by index so Splunk multivalue object fields combine cleanly.""" length = max(len(left), len(right)) merged: list[Any] = [] for index in range(length): left_value = left[index] if index < len(left) else None right_value = right[index] if index < len(right) else None if isinstance(left_value, dict) and isinstance(right_value, dict): merged.append(_deep_merge_alert_event_dicts(left_value, right_value, prefer_existing=prefer_existing)) elif _is_empty_alert_event_field_value(left_value): merged.append(right_value) elif _is_empty_alert_event_field_value(right_value): merged.append(left_value) else: merged.append(left_value if prefer_existing else right_value) return merged def _parse_splunk_style_field_path(key: str) -> list[tuple[str, bool]]: """ Parse Splunk-style field names into path segments. Examples: vendorInformation.provider -> [("vendorInformation", False), ("provider", False)] securityResources{}.resourceType -> [("securityResources", True), ("resourceType", False)] """ segments: list[tuple[str, bool]] = [] for part in str(key).split("."): if not part: continue if part.endswith("{}"): name = part[:-2] if name: segments.append((name, True)) continue segments.append((part, False)) return segments def _assign_splunk_style_segments( current: dict[str, Any], segments: list[tuple[str, bool]], value: Any, ) -> None: """Assign a value into current using parsed Splunk path segments.""" if not segments: return name, is_array = segments[0] rest = segments[1:] is_last = not rest if is_array: existing = current.get(name) if not isinstance(existing, list): existing = [] if _is_empty_alert_event_field_value(existing) else [existing] current[name] = existing arr: list[Any] = current[name] values = value if isinstance(value, list) else [value] if is_last: for item_index, item_value in enumerate(values): while len(arr) <= item_index: arr.append(None) if _is_empty_alert_event_field_value(arr[item_index]): arr[item_index] = item_value return for item_index, item_value in enumerate(values): while len(arr) <= item_index: arr.append({}) if not isinstance(arr[item_index], dict): if not _is_empty_alert_event_field_value(arr[item_index]): continue arr[item_index] = {} _assign_splunk_style_segments(arr[item_index], rest, item_value) return if is_last: existing_value = current.get(name) if _is_empty_alert_event_field_value(existing_value): current[name] = value elif isinstance(existing_value, dict) and isinstance(value, dict): current[name] = _deep_merge_alert_event_dicts(existing_value, value, prefer_existing=True) return nested = current.get(name) if not isinstance(nested, dict): if not _is_empty_alert_event_field_value(nested): return nested = {} current[name] = nested _assign_splunk_style_segments(nested, rest, value) def _assign_splunk_style_path(root: dict[str, Any], key: str, value: Any) -> None: """Assign a value into root using Splunk dotted / {}-array field naming.""" segments = _parse_splunk_style_field_path(key) if segments: _assign_splunk_style_segments(root, segments, value) def _expand_flat_raw_fields(raw_fields: dict[str, Any]) -> dict[str, Any]: """ Expand flat Splunk-style keys into nested objects/arrays. Keys without '.' or '{}' are copied as-is. Already-nested values are preserved. """ expanded: dict[str, Any] = {} for key, value in raw_fields.items(): key_name = str(key) if key_name in ALERT_EVENT_JSON_TRUNCATE_KEYS: continue if "{}" in key_name or "." in key_name: _assign_splunk_style_path(expanded, key_name, value) continue if key_name not in expanded or _is_empty_alert_event_field_value(expanded.get(key_name)): expanded[key_name] = value elif isinstance(expanded[key_name], dict) and isinstance(value, dict): expanded[key_name] = _deep_merge_alert_event_dicts(expanded[key_name], value, prefer_existing=True) return expanded def _promote_raw_into_alert_event_fields(fields: dict[str, Any]) -> dict[str, Any]: """ Parse fields._raw and promote its contents to top-level keys under fields. Existing non-empty schema values win on conflict. `_raw` is retained for fidelity. """ raw_value = fields.get("_raw") if raw_value is None: return fields parsed_raw = _try_parse_json_value(raw_value) if not isinstance(parsed_raw, dict): return fields promoted = _expand_flat_raw_fields(parsed_raw) if not promoted: return fields # Keep original _raw payload; merge promoted keys without wiping schema fields. original_raw = fields.get("_raw") merged = _deep_merge_alert_event_dicts(fields, promoted, prefer_existing=True) if original_raw is not None: merged["_raw"] = original_raw return merged def _enrich_alert_event(event: dict[str, Any]) -> dict[str, Any]: """ Return an alert event with fields JSON-parsed and `_raw` promoted for automation use. Dynamic per event: only transforms when `fields` / `fields._raw` are present and parseable. """ enriched = dict(event) fields_value = enriched.get("fields") parsed_fields = _try_parse_json_value(fields_value) if not isinstance(parsed_fields, dict): return enriched enriched["fields"] = _promote_raw_into_alert_event_fields(dict(parsed_fields)) return enriched def _enrich_alert_events(events: list[dict]) -> list[dict]: """Enrich a list of Vega alert events for searchable/filterable context usage.""" return [_enrich_alert_event(event) for event in events] def _format_alert_events_cell_value(value: Any) -> str: """Format a single alert-event table cell for markdown display.""" if value is None: return "—" if isinstance(value, list | dict): text = json.dumps(value, ensure_ascii=False) else: text = str(value).strip() if not text: return "—" if len(text) > ALERT_EVENT_MAX_CELL_LENGTH: return f"{text[: ALERT_EVENT_MAX_CELL_LENGTH - 3]}..." return text def _flatten_alert_event_value( prefix: str, value: Any, flattened: dict[str, Any], *, depth: int = 0, ) -> None: """Flatten nested alert-event values into dot-notation keys for dynamic table columns.""" if depth > ALERT_EVENT_MAX_FLATTEN_DEPTH: if prefix: flattened[prefix] = value return if isinstance(value, dict): if not value: if prefix: flattened[prefix] = value return for key, nested_value in value.items(): nested_key = f"{prefix}.{key}" if prefix else str(key) # Keep raw payloads as a single cell; promoted fields already expose usable keys. if str(key) in ALERT_EVENT_JSON_TRUNCATE_KEYS: flattened[nested_key] = nested_value continue _flatten_alert_event_value(nested_key, nested_value, flattened, depth=depth + 1) return if isinstance(value, list): if prefix: flattened[prefix] = value return if prefix: flattened[prefix] = value def _normalize_alert_event_for_display(event: dict) -> dict[str, Any]: """Normalize a Vega alert event into flat, display-ready key/value pairs.""" flattened: dict[str, Any] = {} for key, value in event.items(): key_name = str(key) parsed_value = _try_parse_json_value(value) if key_name in ALERT_EVENT_JSON_TRUNCATE_KEYS: flattened[key_name] = parsed_value continue if key_name in ALERT_EVENT_JSON_MERGE_KEYS and isinstance(parsed_value, dict): _flatten_alert_event_value("", parsed_value, flattened, depth=0) continue _flatten_alert_event_value(key_name, parsed_value, flattened, depth=0) return flattened def _alert_event_column_sort_key(column: str) -> tuple[int, int, str]: """Sort discovered columns with known fields first, then remaining keys alphabetically.""" try: preferred_index = ALERT_EVENT_PREFERRED_COLUMNS.index(column) return (0, preferred_index, column) except ValueError: return (1, 0, column) def _collect_alert_event_columns(normalized_events: list[dict[str, Any]]) -> list[str]: """Discover table columns from the alert events present in the current page.""" discovered: set[str] = set() for event in normalized_events: for key, value in event.items(): if _format_alert_events_cell_value(value) != "—": discovered.add(key) if not discovered: return [] ordered = sorted(discovered, key=_alert_event_column_sort_key) return ordered def _alert_events_table_rows(events: list[dict]) -> tuple[list[str], list[dict[str, str]]]: """Convert Vega alert events to dynamic markdown table rows based on response shape.""" normalized_events = [_normalize_alert_event_for_display(event) for event in events] headers = _collect_alert_event_columns(normalized_events) if not headers: return [], [] rows: list[dict[str, str]] = [] for normalized_event in normalized_events: row = {header: _format_alert_events_cell_value(normalized_event.get(header)) for header in headers} rows.append(row) return headers, rows def _format_alert_events_markdown( events: list[dict], total: int, offset: int = 0, page_size: int | None = None, ) -> str: """Render Vega alert events as a markdown table for War Room and layout widgets.""" if not events: return f"### Alert Events ({total})\n\nNo alert events are available for this alert." headers, rows = _alert_events_table_rows(events) if not headers: return f"### Alert Events ({total})\n\nNo displayable alert event fields were found in the Vega response." table_md = tableToMarkdown( f"Alert Events ({total})", rows, headers=headers, removeNull=False, ) if page_size and total > page_size: current_page = (offset // page_size) + 1 total_pages = max(1, math.ceil(total / page_size)) table_md += f"\n\nPage {current_page} of {total_pages} " f"(showing {len(events)} of {total} events)" return table_md def _alert_events_command_results(events_markdown: str, outputs: dict[str, Any]) -> CommandResults: """Return alert events as a rendered markdown table war-room entry.""" return CommandResults( readable_output=events_markdown, outputs_prefix="Vega.AlertEvents", outputs_key_field="AlertId", outputs=outputs, ) def build_alert_events_custom_fields( alert_id: str, events_markdown: str, total: Any, offset: int = 0, ) -> dict[str, Any]: """Build incident CustomFields for the alert-events layout section.""" custom_fields: dict[str, Any] = { "vegaalerteventsloadedfor": alert_id, "vegaalertevents": events_markdown, "vegaalerteventsoffset": offset, } if total is not None and str(total).strip() != "": try: custom_fields["vegaalerteventstotal"] = int(total) except (TypeError, ValueError): custom_fields["vegaalerteventstotal"] = total return custom_fields def fetch_alert_events_page( client: Client, alert_id: str, limit: int = DEFAULT_ALERT_EVENTS_PAGE_SIZE, offset: int = 0, ) -> tuple[list[dict], int]: """Fetch a single page of alert events from the Vega API.""" response = client.get_alert_events(alert_id, limit=limit, offset=offset) api_error = response.get("error") if isinstance(api_error, dict) and api_error.get("message"): raise DemistoException(f"Vega API error: {api_error.get('message')}") total_raw = response.get("total") try: total = int(total_raw) if total_raw is not None else 0 except (TypeError, ValueError): total = 0 events = _enrich_alert_events(_parse_alert_events_results(response.get("results"))) if total == 0 and events: total = len(events) return events, total def fetch_all_alert_events( client: Client, alert_id: str, page_limit: int = DEFAULT_ALERT_EVENTS_PAGE_SIZE, ) -> tuple[list[dict], int]: """Fetch all alert events using offset-based API pagination.""" events: list[dict] = [] offset = 0 total = 0 while True: page_events, page_total = fetch_alert_events_page(client, alert_id, limit=page_limit, offset=offset) if page_events and _events_have_bad_alert_events_shape(page_events): return [], 0 if page_total > 0: total = page_total if not page_events: break events.extend(page_events) if total and offset + len(page_events) >= total: break if len(page_events) < page_limit: break offset += len(page_events) if total == 0: total = len(events) return events, total def _fetch_alert_events_for_ingest(client: Client, alert_id: str) -> tuple[list[dict], dict[str, Any]]: """Fetch alert events for a Vega alert during incident ingest.""" alert_id = str(alert_id).strip() try: all_events, total = fetch_all_alert_events(client, alert_id, page_limit=DEFAULT_ALERT_EVENTS_PAGE_SIZE) events_markdown, offset, _, has_alert_events = _resolve_alert_events_page( all_events, total, offset=0, page_limit=DEFAULT_ALERT_EVENTS_PAGE_SIZE, ) custom_fields = build_alert_events_custom_fields(alert_id, events_markdown, total, offset) return (all_events if has_alert_events else []), custom_fields except Exception as exc: demisto.debug(f"Vega: skipped alert events fetch for {alert_id}: {exc}") return [], build_alert_events_custom_fields(alert_id, ALERT_EVENTS_NOT_AVAILABLE_MARKDOWN, 0, 0) def _collect_incident_custom_fields(incident: dict[str, Any]) -> dict[str, Any]: """Merge incident CustomFields with flattened custom-field keys.""" custom_fields: dict[str, Any] = dict(incident.get("CustomFields") or incident.get("customFields") or {}) for field_name in ( "alertid", "vegaalertid", "vegaincidentid", "vegaalerteventsloadedfor", "vegaalertevents", "vegaalerteventsoffset", "vegaalerteventstotal", VEGA_ALERT_STATUS_FIELD, VEGA_ALERT_SEVERITY_FIELD, VEGA_INCIDENT_STATUS_FIELD, "vegaverdict", "vegaverdictreasoning", VEGA_SEVERITY_FIELD, ): if field_name not in custom_fields and incident.get(field_name) is not None: custom_fields[field_name] = incident.get(field_name) return custom_fields def load_current_incident() -> dict[str, Any]: """Load the current investigation incident from the integration runtime context.""" incident: dict[str, Any] = {} try: incident = demisto.incident() or {} except (TypeError, AttributeError, KeyError) as exc: demisto.debug(f"Vega: demisto.incident() unavailable: {exc}") if not incident.get("id"): try: incidents = demisto.incidents() if incidents: incident = incidents[0] or {} except Exception as exc: demisto.debug(f"Vega: demisto.incidents() failed: {exc}") return incident def resolve_alert_id_from_incident(args: dict[str, Any], incident: dict[str, Any]) -> str | None: """Resolve the Vega alert API id (UUID) used for API calls such as getAlertsEvents.""" alert_ids = _collect_alert_ids_from_args(args) if alert_ids: return alert_ids[0] custom_fields = _collect_incident_custom_fields(incident) mapped_alert_id = custom_fields.get("alertid") if mapped_alert_id is not None and str(mapped_alert_id).strip(): return str(mapped_alert_id).strip() loaded_for = custom_fields.get("vegaalerteventsloadedfor") if loaded_for is not None and str(loaded_for).strip(): return str(loaded_for).strip() incident_type = str(incident.get("type") or incident.get("Type") or "").strip() raw_json = incident.get("rawJSON") or incident.get("rawJson") if raw_json: try: raw = json.loads(raw_json) if isinstance(raw_json, str) else raw_json if isinstance(raw, dict): if incident_type and incident_type != "Vega Alert": return None if not incident_type and raw.get("vegaEntityType") not in (None, "Vega Alert"): return None raw_id = raw.get("id") if raw_id is not None and str(raw_id).strip(): return str(raw_id).strip() except (json.JSONDecodeError, TypeError, ValueError): pass return None def _resolve_alert_events_page( all_events: list[dict], total: int, offset: int, page_limit: int, ) -> tuple[str, int, list[dict], bool]: """Resolve pagination state and markdown for alert events.""" has_alert_events = bool(all_events) and not _events_have_bad_alert_events_shape(all_events) if not has_alert_events: return ALERT_EVENTS_NOT_AVAILABLE_MARKDOWN, 0, [], False if offset >= total and total > 0: offset = max(0, total - page_limit) offset = (offset // page_limit) * page_limit page_events = all_events[offset : offset + page_limit] events_markdown = _format_alert_events_markdown(page_events, total, offset=offset, page_size=page_limit) return events_markdown, offset, page_events, True def fetch_alert_events_command(client: Client, args: dict[str, Any]) -> CommandResults: """Fetch alert events for a Vega alert and return a markdown table for the layout section.""" incident = load_current_incident() custom_fields = _collect_incident_custom_fields(incident) alert_id = resolve_alert_id_from_incident(args, incident) if not alert_id: incident_type = str(incident.get("type") or incident.get("Type") or "unknown") incident_id = incident.get("id") or "none" raise DemistoException( "alert_id is required when the command is not run from a Vega Alert incident. " f"Could not resolve alert ID from incident id={incident_id}, type={incident_type}. " "Open a Vega Alert investigation or pass alert_id explicitly." ) alert_id = str(alert_id).strip() offset = arg_to_number(args.get("offset")) if offset is None: offset = arg_to_number(custom_fields.get("vegaalerteventsoffset")) or 0 offset = max(0, int(offset)) page_limit = arg_to_number(args.get("limit")) or DEFAULT_ALERT_EVENTS_PAGE_SIZE page_limit = max(1, int(page_limit)) all_events, total = fetch_all_alert_events(client, alert_id, page_limit=page_limit) events_markdown, offset, page_events, has_alert_events = _resolve_alert_events_page(all_events, total, offset, page_limit) persisted_fields = build_alert_events_custom_fields(alert_id, events_markdown, total, offset) return _alert_events_command_results( events_markdown, { "AlertId": alert_id, "Total": total, "Offset": offset, "Limit": page_limit, "Count": len(page_events), "HasAlertEvents": has_alert_events, "Cached": False, "Events": page_events, "CustomFields": persisted_fields, }, ) def set_detections_state_command(client: Client, args: dict[str, Any]) -> CommandResults: """Set the state for one or more Vega detections.""" detection_ids = [item.strip() for item in argToList(args.get("ids")) if str(item).strip()] if not detection_ids: raise DemistoException("ids is required and must contain at least one detection ID.") state = str(args.get("state", "")).strip().upper() if not state: raise DemistoException("state is required.") if state not in VALID_DETECTION_STATES: raise DemistoException(f"state must be one of: {', '.join(sorted(VALID_DETECTION_STATES))}") result = client.set_detections_state(detection_ids, state) updated_ids = result.get("ids") or [] if not isinstance(updated_ids, list): updated_ids = [] readable_output = tableToMarkdown( f"Updated detection state to {state}", [{"ID": detection_id} for detection_id in updated_ids], headers=["ID"], ) return CommandResults( readable_output=readable_output, outputs_prefix="Vega.DetectionsState", outputs={ "State": state, "IDs": updated_ids, "Count": len(updated_ids), }, ) def _build_detection_update_payload( detection_id: str, severity: str | None = None, status: str | None = None, state: str | None = None, tags: list[str] | None = None, ) -> dict[str, Any]: """Build a single detection update payload for updateDetections.""" payload: dict[str, Any] = {"detectionId": detection_id} if severity is not None: payload["severity"] = severity if status is not None: payload["status"] = status if state is not None: payload["state"] = state if tags is not None: payload["tags"] = tags return payload def _raise_update_detections_errors(result: dict[str, Any]) -> None: """Raise when updateDetections returns per-detection validation errors.""" error_messages: list[str] = [] for item in result.get("results") or []: if not isinstance(item, dict): continue if item.get("status") == "VALID": continue detection_name = str(item.get("name") or "").strip() for error in item.get("errors") or []: if not isinstance(error, dict): continue message = str(error.get("message") or error.get("code") or "").strip() if not message: continue field = str(error.get("field") or "").strip() prefix = f"{detection_name}: " if detection_name else "" error_messages.append(f"{prefix}{field}: {message}" if field else f"{prefix}{message}") if error_messages: raise DemistoException(f"Vega API error updating detections: {', '.join(error_messages)}") summary = result.get("summary") or {} if isinstance(summary, dict) and summary.get("committed") is False: raise DemistoException("Vega API did not commit detection updates.") def _format_update_detection_output(result_item: dict[str, Any]) -> dict[str, Any]: """Format a single updateDetections result item for command outputs.""" detection_raw = result_item.get("detection") detection: dict[str, Any] = detection_raw if isinstance(detection_raw, dict) else {} return { "ID": detection.get("id"), "Name": detection.get("name") or result_item.get("name"), "Severity": detection.get("severity"), "Status": detection.get("status"), "State": detection.get("state"), "Tags": detection.get("tags"), "ValidationStatus": result_item.get("status"), } def _parse_detection_update_args( args: dict[str, Any], ) -> tuple[list[str], str | None, str | None, str | None, list[str] | None]: """Parse and normalize detection update command arguments.""" detection_ids = [item.strip() for item in argToList(args.get("detection_id")) if str(item).strip()] severity_arg = args.get("severity") status_arg = args.get("status") state_arg = args.get("state") tags_arg = args.get("tags") severity = str(severity_arg or "").strip().upper() if severity_arg not in (None, "") else None status = str(status_arg or "").strip().upper() if status_arg not in (None, "") else None state = str(state_arg).strip().upper() if state_arg not in (None, "") else None tags = [item.strip() for item in argToList(tags_arg) if str(item).strip()] or None return detection_ids, severity, status, state, tags def _validate_detection_update_args( detection_ids: list[str], severity: str | None, status: str | None, state: str | None, tags: list[str] | None, ) -> None: """Validate detection update command arguments.""" if not detection_ids: raise DemistoException( "detection_id is required and must contain at least one detection ID. " "Example: !vega-update-detections detection_id=det-1 severity=HIGH state=ENABLED" ) if severity is None and status is None and state is None and tags is None: raise DemistoException("At least one of severity, status, state, or tags must be provided.") if severity is not None and severity not in VALID_DETECTION_SEVERITIES: raise DemistoException(f"severity must be one of: {', '.join(sorted(VALID_DETECTION_SEVERITIES))}") if state is not None and state not in VALID_DETECTION_STATES: raise DemistoException(f"state must be one of: {', '.join(sorted(VALID_DETECTION_STATES))}") def _format_detection_update_readable(outputs: list[dict[str, Any]], summary: dict[str, Any]) -> str: """Build human-readable output for detection update results.""" readable = tableToMarkdown( "Updated Vega Detections", outputs, headers=["ID", "Name", "Severity", "Status", "State", "Tags", "ValidationStatus"], removeNull=True, ) if not summary: return readable return ( readable + "\n\n" + tableToMarkdown( "Update Summary", [ { "Requested": summary.get("requested"), "Valid": summary.get("valid"), "Invalid": summary.get("invalid"), "Committed": summary.get("committed"), } ], headers=["Requested", "Valid", "Invalid", "Committed"], removeNull=True, ) ) def update_detections_command(client: Client, args: dict[str, Any]) -> CommandResults: """Update severity, status, state, and/or tags for one or more Vega detections.""" detection_ids, severity, status, state, tags = _parse_detection_update_args(args) _validate_detection_update_args(detection_ids, severity, status, state, tags) detections = [_build_detection_update_payload(detection_id, severity, status, state, tags) for detection_id in detection_ids] result = client.update_detections(detections) _raise_update_detections_errors(result) outputs = [_format_update_detection_output(item) for item in result.get("results") or [] if isinstance(item, dict)] raw_summary = result.get("summary") summary: dict[str, Any] = raw_summary if isinstance(raw_summary, dict) else {} return CommandResults( readable_output=_format_detection_update_readable(outputs, summary), outputs_prefix="Vega.Detection", outputs_key_field="ID", outputs=outputs[0] if len(outputs) == 1 else outputs, ) def _normalize_list_items(value: Any) -> list[str]: """Extract string items from a list or scalar field value.""" if isinstance(value, str): text = value.strip() return [text] if text else [] if not isinstance(value, list): return [] items: list[str] = [] for item in value: if item is None: continue if isinstance(item, dict): label = _mitre_item_label(item) if label: items.append(label) else: items.append(json.dumps(item)) else: text = str(item).strip() if text: items.append(text) return items def _format_bullet_list(value: Any, empty_display: str | None = None) -> Any: """Format a list field as newline-separated bullet points.""" if value is None or not isinstance(value, list) or not value: return empty_display if empty_display is not None else value items = _normalize_list_items(value) if not items: return empty_display if empty_display is not None else value return "\n".join(f"• {item}" for item in items) def _ensure_recommended_action_description_newline(description: Any) -> str: """Ensure a recommended-action description ends with a newline for XSOAR grid wrapping.""" text = str(description or "") if text and not text.endswith("\n"): text = f"{text}\n" return text VEGA_EMPTY_FIELD_DISPLAY = "N/A" VEGA_NO_ASSETS_DISPLAY = "No assets present." VEGA_NO_OBSERVABLES_DISPLAY = "No observables present." VEGA_NO_RECOMMENDED_ACTIONS_DISPLAY = "No recommended Actions found" def _format_recommended_actions_for_grid(actions: Any) -> Any: """Normalize recommendedActions for the Vega Recommended Actions grid field. Empty lists show a placeholder row in the incident layout. XSOAR longText grid cells truncate single-line values; a trailing newline forces wrap so the full description is visible. """ if actions is None or (isinstance(actions, list) and not actions): return [{"name": VEGA_NO_RECOMMENDED_ACTIONS_DISPLAY}] if not isinstance(actions, list): return actions formatted_actions: list[Any] = [] for action in actions: if not isinstance(action, dict): formatted_actions.append(action) continue formatted_action = dict(action) if formatted_action.get("description") is not None: formatted_description = _ensure_recommended_action_description_newline(formatted_action.get("description")) formatted_action["description"] = formatted_description formatted_actions.append(formatted_action) return formatted_actions def _empty_to_na(value: Any) -> str: """Return a display placeholder when a Vega text field is missing or blank.""" if value is None: return VEGA_EMPTY_FIELD_DISPLAY text = str(value).strip() return text if text else VEGA_EMPTY_FIELD_DISPLAY def _extract_verdict_reasoning_from_entity(raw: dict[str, Any]) -> Any: """Extract analyst verdict reasoning from the Vega ``verdictReasoning`` API field only.""" reasoning = raw.get("verdictReasoning") if reasoning is None: return None text = str(reasoning).strip() return None if not text or text.upper() == VEGA_EMPTY_FIELD_DISPLAY.upper() else reasoning def _normalize_verdict_reasoning_for_display(raw: dict) -> str: """Normalize Vega verdict reasoning for XSOAR display.""" reasoning = _extract_verdict_reasoning_from_entity(raw) return _empty_to_na(reasoning) def _format_vega_detection_query_for_display(value: Any) -> str: """Format a detection SQL query for markdown display, or N/A when empty.""" query = _empty_to_na(value) if query == VEGA_EMPTY_FIELD_DISPLAY: return query return f"```sql\n{query}\n```" def _mitre_item_label(item: dict) -> str: """Extract a display label from a MITRE tactic/technique object.""" for key in ("name", "displayName", "techniqueName", "tacticName", "id", "techniqueId", "tacticId"): value = item.get(key) if value is not None and str(value).strip(): return str(value).strip() return "" def _get_first_mitre_value(mitre: dict, keys: tuple[str, ...]) -> Any: """Return the first present MITRE tactic/technique value from supported API key names.""" for key in keys: if key in mitre and mitre.get(key) is not None: return mitre.get(key) return None def _format_mitre_attack(mitre: Any) -> str | None: """Merge MITRE tactics and techniques into a newline-separated bullet list.""" if not isinstance(mitre, dict): return None tactics = _get_first_mitre_value(mitre, MITRE_TACTIC_KEYS) techniques = _get_first_mitre_value(mitre, MITRE_TECHNIQUE_KEYS) items = _normalize_list_items(tactics) + _normalize_list_items(techniques) if not items: return None return "\n".join(f"• {item}" for item in items) def _apply_vega_mitre_attack_format(raw: dict) -> None: """Populate vegaMitreAttack in raw JSON for visibility in the incident context.""" mitre = raw.get("mitre") if isinstance(mitre, dict): mitre_payload: dict[str, Any] = mitre elif raw.get("mitreTactics") is not None or raw.get("mitreTechniques") is not None: mitre_payload = { "mitreTactics": raw.get("mitreTactics"), "mitreTechniques": raw.get("mitreTechniques"), } else: return mitre_attack = _format_mitre_attack(mitre_payload) if mitre_attack: raw["vegaMitreAttack"] = mitre_attack def _build_vega_alert_custom_fields(raw: dict) -> dict[str, Any]: """Build CustomFields for Vega alerts (set directly on ingest, not via mapper).""" custom_fields: dict[str, Any] = {} alert_uuid = raw.get("id") if alert_uuid is not None and str(alert_uuid).strip(): custom_fields["alertid"] = str(alert_uuid).strip() mitre_attack = raw.get("vegaMitreAttack") if mitre_attack: custom_fields["vegamitreattack"] = str(mitre_attack) created_at = raw.get("createdAt") if created_at: custom_fields["vegacreatedat"] = str(created_at) alert_event_fields = raw.get("_alertEventsCustomFields") if isinstance(alert_event_fields, dict): custom_fields.update(alert_event_fields) custom_fields[VEGA_NEW_COMMENT_FIELD] = VEGA_NEW_COMMENT_LAYOUT_DEFAULT return custom_fields def _timeline_alert_severity_label(severity: Any) -> str: """Map Vega numeric alert severity to a human-readable label.""" if severity is None: return "N/A" try: return VEGA_TIMELINE_ALERT_SEVERITY_LABELS.get(int(severity), str(severity)) except (TypeError, ValueError): return str(severity) def _escape_html(text: str) -> str: """Escape text for safe inclusion in timeline HTML.""" return html_module.escape(str(text)) def _format_timeline_display_timestamp(timestamp: Any) -> str: """Convert an ISO timestamp to the timeline display format (YYYY-MM-DD HH:MM:SS).""" text = str(timestamp or "").strip() if not text: return "—" text = text.replace("T", " ").replace("Z", "").strip() if "." in text: text = text.split(".", maxsplit=1)[0] return text def _format_comment_display_timestamp(timestamp: Any) -> str: """Convert an ISO timestamp to the comment display format (YYYY-MM-DDTHH:MM:SSZ).""" text = str(timestamp or "").strip() if not text: return "—" if "." in text: text = text.split(".", maxsplit=1)[0] if "T" not in text and " " in text: text = text.replace(" ", "T", 1) if not text.endswith("Z") and "+" not in text: text = f"{text}Z" return text def _is_empty_vega_comment_text(text: Any) -> bool: """Return True when a Vega comment body is empty or a placeholder such as '[{}]'.""" if text is None: return True normalized = str(text).strip() return not normalized or normalized in ("[{}]", "[]", "{}") def _format_comment_author(added_by: Any) -> str: """Return a display name for a Vega comment author.""" author = str(added_by or "").strip() if not author: return "Unknown" if len(author) > 24 and " " not in author: return "Unknown" return _escape_html(author) def _format_vega_comment_item_html(comment: dict[str, Any]) -> str: """Render one Vega incident comment in the dark UI card layout.""" comment_text = str(comment.get("text") or "").strip() author = _format_comment_author(comment.get("addedBy")) timestamp = _escape_html(_format_comment_display_timestamp(comment.get("addedAt"))) body = _escape_html(comment_text) return ( "<div style='margin-bottom:20px;'>" "<div style='display:flex;align-items:center;gap:8px;margin-bottom:10px;color:#ffffff;'>" f"<span style='font-size:14px;line-height:1.4;'>" f"<span style='font-weight:600;color:#ffffff;'>{author}</span> " f"<span style='color:#d1d5db;'>added a comment</span></span></div>" f"<div style='border:1px solid #404040;border-radius:10px;padding:12px 14px;" f"background:#141414;color:#e5e5e5;font-size:13px;line-height:1.6;'>{body}</div>" f"<div style='color:#9ca3af;font-size:12px;margin-top:8px;'>{timestamp}</div>" "</div>" ) def _format_vega_comments_html(comments: Any) -> str: """Render Vega incident comments as HTML, skipping empty placeholder entries.""" container_style = f"background:#000000;color:#ffffff;padding:16px;font-family:{_VEGA_DARK_UI_FONT};" if not isinstance(comments, list): return ( f"<div style='{container_style}'>" "<p style='margin:0;color:#9ca3af;font-size:13px;'>No comments are available for this incident.</p>" "</div>" ) visible_comments = [ comment for comment in comments if isinstance(comment, dict) and not _is_empty_vega_comment_text(comment.get("text")) ] visible_comments.sort(key=lambda item: str(item.get("addedAt") or ""), reverse=True) if not visible_comments: return ( f"<div style='{container_style}'>" "<p style='margin:0;color:#9ca3af;font-size:13px;'>No comments are available for this incident.</p>" "</div>" ) rows = "".join(_format_vega_comment_item_html(comment) for comment in visible_comments) return f"<div style='{container_style}'>{rows}</div>" def _timeline_severity_bars_html(severity: Any) -> str: """Render alert severity as vertical bars (Vega UI style).""" try: level = max(1, min(4, int(severity))) except (TypeError, ValueError): level = 2 bar_heights = (8, 11, 14, 16) bars: list[str] = [] for index, height in enumerate(bar_heights, start=1): color = "#f97316" if index <= level else "#404040" bars.append(f"<div style='width:4px;height:{height}px;background:{color};border-radius:1px;'></div>") return f"<div style='display:flex;gap:2px;align-items:flex-end;'>{''.join(bars)}</div>" _TIMELINE_PILL_STYLE = ( "display:inline-block;padding:4px 10px;border-radius:999px;" "background:#2a2a2a;border:1px solid #404040;color:#f5f5f5;" "font-size:11px;line-height:1.4;white-space:normal;max-width:100%;" ) _VEGA_DARK_UI_FONT = "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif" _KEY_FINDINGS_NUMBER_STYLE = ( "display:flex;align-items:center;justify-content:center;flex-shrink:0;" "width:28px;height:28px;background:#141414;border:1px solid #333333;" "border-radius:8px;color:#9ca3af;font-size:12px;font-weight:600;" ) def _timeline_data_source_label(source: dict) -> str: """Build the full display name for a timeline data source.""" vendor = str(source.get("vendor", "")).strip() display_name = str(source.get("displayName", "")).strip() if vendor and display_name: return f"{vendor} · {display_name}" if display_name: return display_name if vendor: return vendor return "" def _timeline_string_pill_html(value: str) -> str: """Render a timeline footer pill for a plain string value.""" label = str(value).strip() if not label: return "" return f"<span style='{_TIMELINE_PILL_STYLE}'>{_escape_html(label)}</span>" def _timeline_footer_data_source_pills(event: dict) -> list[str]: """Build timeline footer pills for data source values.""" parts: list[str] = [] data_sources = event.get("dataSources") if not isinstance(data_sources, list): return parts for source in data_sources: if isinstance(source, dict): label = _timeline_data_source_label(source) if label: parts.append(_timeline_string_pill_html(label)) elif isinstance(source, str): pill = _timeline_string_pill_html(source) if pill: parts.append(pill) return parts def _timeline_footer_alert_severity_pill(alert: dict) -> str | None: """Build a timeline footer pill for alert severity.""" if alert.get("displayName") or alert.get("severity") is not None: severity_label = _timeline_alert_severity_label(alert.get("severity")) return f"<span style='{_TIMELINE_PILL_STYLE}'>Severity: {_escape_html(severity_label)}</span>" return None def _timeline_footer_list_pills(event: dict, key: str) -> list[str]: """Build timeline footer pills from a list of string values on an event.""" parts: list[str] = [] values = event.get(key) if not isinstance(values, list): return parts for value in values: if isinstance(value, str): pill = _timeline_string_pill_html(value) if pill: parts.append(pill) return parts def _timeline_footer_entity_pills(event: dict) -> list[str]: """Build timeline footer pills for entity metadata.""" parts: list[str] = [] entities = event.get("entities") if not isinstance(entities, list): return parts for entity in entities: if not isinstance(entity, dict): continue entity_type = str(entity.get("type", "")).strip() category = str(entity.get("category", "")).strip() value = str(entity.get("value", "")).strip() if not value: continue meta_parts = [part for part in (entity_type, category) if part] pill_text = f"{value} ({', '.join(meta_parts)})" if meta_parts else value parts.append(_timeline_string_pill_html(pill_text)) return parts def _timeline_footer_html(event: dict) -> str: """Build footer badges (data sources, severity, assets, observables) for a timeline event.""" parts = _timeline_footer_data_source_pills(event) alert = event.get("alert") if isinstance(alert, dict): severity_pill = _timeline_footer_alert_severity_pill(alert) if severity_pill: parts.append(severity_pill) parts.extend(_timeline_footer_list_pills(event, "assets")) parts.extend(_timeline_footer_list_pills(event, "observables")) parts.extend(_timeline_footer_entity_pills(event)) if not parts: return "" return f"<div style='display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px;'>" f"{''.join(parts)}</div>" def _timeline_axis_html(is_last: bool) -> str: """Render the vertical timeline axis (line only; Vega API has no timeline event type field).""" line_bottom = "bottom:0;" if is_last else "bottom:-28px;" return ( f"<div style='width:20px;flex-shrink:0;position:relative;align-self:stretch;min-height:20px;'>" f"<div style='position:absolute;left:50%;top:0;width:2px;{line_bottom}" f"background:#404040;transform:translateX(-50%);'></div></div>" ) def _timeline_event_row_html(event: dict, is_last: bool) -> str: """Render a single timeline row (timestamp, axis line, content).""" timestamp = _escape_html(_format_timeline_display_timestamp(event.get("timestamp"))) summary = _escape_html(str(event.get("summary", "")).strip() or "No summary provided.") alert_data = event.get("alert") content_parts: list[str] = [] if isinstance(alert_data, dict): alert_name_value = str(alert_data.get("name") or alert_data.get("displayName") or "").strip() if alert_name_value: alert_name = _escape_html(alert_name_value) severity_bars = _timeline_severity_bars_html(alert_data.get("severity")) content_parts.append( f"<div style='display:inline-flex;align-items:center;gap:10px;" f"background:#141414;border:1px solid #333333;border-radius:10px;" f"padding:10px 14px;margin-bottom:12px;max-width:100%;'>" f"{severity_bars}" f"<span style='color:#ffffff;font-size:14px;font-weight:600;line-height:1.4;'>" f"{alert_name}</span></div>" ) content_parts.append(f"<p style='margin:0;color:#e5e5e5;font-size:13px;line-height:1.65;'>{summary}</p>") footer = _timeline_footer_html(event) if footer: content_parts.append(footer) return ( f"<div style='display:flex;align-items:stretch;margin-bottom:28px;position:relative;'>" f"<div style='width:148px;flex-shrink:0;text-align:right;padding-right:14px;" f"padding-top:2px;color:#9ca3af;font-size:12px;font-family:monospace;'>{timestamp}</div>" f"{_timeline_axis_html(is_last)}" f"<div style='flex:1;min-width:0;padding-left:12px;padding-top:0;'>" f"{''.join(content_parts)}</div></div>" ) def _format_timeline_events_html(timeline_events: list[dict]) -> str: """Render Vega incident timeline as HTML (dark theme, three-column layout).""" if not timeline_events: return ( "<div style='background:#000000;color:#ffffff;padding:16px;font-family:" "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'>" "<div style='font-size:15px;font-weight:600;margin-bottom:8px;'>Timeline</div>" "<p style='margin:0;color:#9ca3af;font-size:13px;'>" "No timeline events are available for this incident.</p></div>" ) sorted_events = sorted( timeline_events, key=lambda event: str(event.get("timestamp", "")), ) rows = [_timeline_event_row_html(event, is_last=index == len(sorted_events) - 1) for index, event in enumerate(sorted_events)] return ( "<div style='background:#000000;color:#ffffff;padding:16px 16px 8px 16px;" "font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'>" "<div style='font-size:15px;font-weight:600;margin-bottom:20px;color:#ffffff;'>" "Timeline</div>" f"<div style='position:relative;'>{''.join(rows)}</div></div>" ) def _build_vega_incident_custom_fields(raw: dict) -> dict[str, str]: """Build CustomFields for Vega incidents (set directly on ingest, not via mapper).""" custom_fields: dict[str, str] = {} # Populate vegaincidentid so outgoing mirroring and vega-update-incident can reliably # resolve the Vega entity without falling back to fragile rawJSON parsing. incident_id = _normalize_entity_id(raw) if incident_id: custom_fields["vegaincidentid"] = incident_id created_at = raw.get("createdAt") if created_at: custom_fields["vegacreatedat"] = str(created_at) timeline_html = raw.get("vegaTimelineEvents") if timeline_html: custom_fields["vegatimelineevents"] = str(timeline_html) findings_html = raw.get("vegaIncidentFindings") if findings_html: custom_fields["vegaincidentfindings"] = str(findings_html) custom_fields[VEGA_NEW_COMMENT_FIELD] = VEGA_NEW_COMMENT_LAYOUT_DEFAULT return custom_fields def _normalize_findings_list(findings: Any) -> list[str]: """Extract non-empty finding strings from API data.""" if findings is None or not isinstance(findings, list): return [] texts: list[str] = [] for finding in findings: text = json.dumps(finding) if isinstance(finding, dict) else str(finding).strip() if text: texts.append(text) return texts def _highlight_values_in_html(text: str, values: set[str]) -> str: """Wrap occurrences of known asset/observable values in timeline-style pills.""" escaped = _escape_html(text) if not values: return escaped result = escaped for value in sorted(values, key=len, reverse=True): if not value: continue escaped_value = _escape_html(value) if escaped_value not in result: continue pill = f"<span style='{_TIMELINE_PILL_STYLE}'>{escaped_value}</span>" result = result.replace(escaped_value, pill) return result def _key_finding_item_html(number: int, text: str, highlight_values: set[str], is_last: bool) -> str: """Render one numbered key finding row (screenshot-style layout, dark theme).""" body = _highlight_values_in_html(text, highlight_values) border = "" if is_last else "border-bottom:1px solid #333333;" return ( f"<div style='display:flex;gap:14px;padding:16px 0;{border}align-items:flex-start;'>" f"<div style='{_KEY_FINDINGS_NUMBER_STYLE}'>{number}</div>" f"<p style='margin:0;flex:1;color:#e5e5e5;font-size:13px;line-height:1.65;'>{body}</p>" f"</div>" ) def _format_key_findings_html(findings: Any, assets: Any, observables: Any) -> str: """Render Vega key findings as HTML (black background, numbered list, entity pills).""" finding_texts = _normalize_findings_list(findings) highlight_values = set(_normalize_list_items(assets) + _normalize_list_items(observables)) header = "<div style='font-size:15px;font-weight:600;margin-bottom:4px;color:#ffffff;'>" "Key findings</div>" container_style = f"background:#000000;color:#ffffff;padding:16px;" f"font-family:{_VEGA_DARK_UI_FONT};" if not finding_texts: return ( f"<div style='{container_style}'>" f"{header}" f"<p style='margin:8px 0 0;color:#9ca3af;font-size:13px;'>" f"No key findings are available for this incident.</p></div>" ) rows = [ _key_finding_item_html( index, text, highlight_values, is_last=index == len(finding_texts), ) for index, text in enumerate(finding_texts, start=1) ] return f"<div style='{container_style}'>{header}{''.join(rows)}</div>" def _api_to_app_url(url: str) -> str: """Replace the api host subdomain with app in a Vega platform URL.""" return url.strip().replace("://api.", "://app.") def _platform_ui_base_url(integration_url: str) -> str: """Derive the Vega app UI base URL from the configured integration URL.""" base = integration_url.rstrip("/") if base.lower().endswith("/api/v1"): base = base[: -len("/api/v1")] return _api_to_app_url(base) def _apply_vega_entity_link(raw: dict, integration_url: str | None = None) -> None: """Normalize or build Vega platform UI links before XSOAR ingestion.""" api_link = raw.get("link") if api_link: raw["link"] = _api_to_app_url(str(api_link)) return entity_type = raw.get("vegaEntityType") entity_id = raw.get("id", "") if entity_type == "Vega Alert" and entity_id and integration_url: platform_base = _platform_ui_base_url(integration_url) raw["link"] = f"{platform_base.rstrip('/')}/incidents/alerts/investigation/{entity_id}" def _format_raw_entity_for_xsoar(raw: dict) -> None: """Format display-oriented list fields in raw entity data before XSOAR ingestion.""" entity_type = raw.get("vegaEntityType") if entity_type == "Vega Alert" and raw.get("status") is not None: raw["status"] = _normalize_vega_status_for_display(str(raw["status"]), MIRROR_ENTITY_SUFFIX_ALERT) elif entity_type == "Vega Incident" and raw.get("status") is not None: raw["status"] = _normalize_vega_status_for_display(str(raw["status"]), MIRROR_ENTITY_SUFFIX_INCIDENT) if raw.get("verdict") is not None or raw.get("userVerdict") is not None: raw["verdict"] = _normalize_vega_verdict_for_display(_extract_vega_verdict_from_entity(raw)) raw["verdictReasoning"] = _normalize_verdict_reasoning_for_display(raw) if raw.get("severity") is not None: raw["severity"] = _normalize_vega_severity_for_display(raw.get("severity")) if entity_type == "Vega Alert": vega_display_id = raw.get("vegaAlertId") if vega_display_id is not None and str(vega_display_id).strip(): raw["vegaAlertId"] = str(vega_display_id).strip() raw["detectionDescription"] = _empty_to_na(raw.get("detectionDescription")) raw["detectionQuery"] = _format_vega_detection_query_for_display(raw.get("detectionQuery")) if "dataSources" in raw: raw["dataSources"] = _format_bullet_list(raw.get("dataSources")) assets = raw.get("assets") observables = raw.get("observables") if "assets" in raw: raw["assets"] = _format_bullet_list(assets, VEGA_NO_ASSETS_DISPLAY) if "observables" in raw: raw["observables"] = _format_bullet_list(observables, VEGA_NO_OBSERVABLES_DISPLAY) findings_source = raw.get("keyFindings") or raw.get("incidentFindings") if findings_source is not None: raw["vegaIncidentFindings"] = _format_key_findings_html(findings_source, assets, observables) if entity_type in ("Vega Incident", "Vega Alert") and "comments" in raw: raw["vegaComments"] = _format_vega_comments_html(raw.get("comments")) if "recommendedActions" in raw: raw["recommendedActions"] = _format_recommended_actions_for_grid(raw.get("recommendedActions")) _apply_vega_mitre_attack_format(raw) def _normalize_incident_verdict_value(verdict: Any) -> str: """Normalize a Vega incident verdict to a string for XSOAR mapping.""" if isinstance(verdict, dict): return str(verdict.get("value") or verdict.get("verdict") or "") return str(verdict or "") def _extract_vega_verdict_from_entity(entity: dict[str, Any]) -> Any: """Extract the analyst-facing verdict, preferring userVerdict over automated state verdict.""" user_verdict = entity.get("userVerdict") if user_verdict is not None: if isinstance(user_verdict, dict): value = user_verdict.get("value") or user_verdict.get("verdict") if value is not None and str(value).strip(): return value elif str(user_verdict).strip(): return user_verdict return entity.get("verdict") def _normalize_vega_verdict_for_display(verdict: Any) -> str: """Normalize a Vega verdict to a value accepted by the XSOAR single-select field.""" normalized = _normalize_incident_verdict_value(verdict).strip().upper() return VERDICT_DISPLAY_TO_API.get(normalized, normalized) def _normalize_vega_severity_for_display(severity: Any) -> str: """Normalize a Vega severity to a value accepted by the XSOAR single-select field.""" if severity is None or severity == "": return "" if isinstance(severity, int | float) or (isinstance(severity, str) and str(severity).strip().isdigit()): mapped = VEGA_NUMERIC_SEVERITY_TO_API.get(int(float(str(severity).strip()))) if mapped: return mapped normalized = str(severity or "").strip().upper() return normalized if normalized in VALID_SEVERITIES else normalized def _normalize_vega_status_for_api(status: str, entity_type: str) -> str: """Map a Vega status value to the GraphQL API enum format.""" normalized = str(status or "").strip().upper() if entity_type == MIRROR_ENTITY_SUFFIX_ALERT: return ALERT_STATUS_DISPLAY_TO_API.get(normalized, normalized.replace(" ", "_")) return INCIDENT_STATUS_DISPLAY_TO_API.get(normalized, normalized.replace(" ", "_")) def _normalize_vega_status_for_display(status: str, entity_type: str) -> str: """Map a Vega API status value to the human-readable dropdown format.""" normalized = str(status or "").strip().upper() if entity_type == MIRROR_ENTITY_SUFFIX_ALERT: api_to_display = {api: display for display, api in ALERT_STATUS_DISPLAY_TO_API.items()} else: api_to_display = {api: display for display, api in INCIDENT_STATUS_DISPLAY_TO_API.items()} return api_to_display.get(normalized, normalized.replace("_", " ")) def _normalize_vega_verdict_for_api(verdict: str) -> str: """Map a Vega verdict value to the GraphQL API enum format.""" normalized = str(verdict or "").strip().upper() return VERDICT_DISPLAY_TO_API.get(normalized, normalized) def _build_close_reopen_sync_entries(status: str, entity_type_suffix: str) -> list[dict]: """Build close or reopen entries when syncing XSOAR investigation status.""" entries: list[dict] = [] api_status = _normalize_vega_status_for_api(status, entity_type_suffix) if api_status in VEGA_CLOSE_STATUSES: entries.append( { "Type": EntryType.NOTE, "Contents": { "dbotIncidentClose": True, "closeReason": "Resolved", "closeNotes": f"Vega {entity_type_suffix} status changed to {api_status}.", }, "ContentsFormat": EntryFormat.JSON, "Note": True, } ) elif api_status in {"REOPENED", "OPEN", "NEW", "INVESTIGATING", "IN_PROGRESS"}: entries.append( { "Type": EntryType.NOTE, "Contents": {"dbotIncidentReopen": True}, "ContentsFormat": EntryFormat.JSON, } ) return entries def _build_comment_war_room_entry(comment_text: str, tags: list[str] | None = None) -> dict[str, Any]: """Build a plain-text War Room comment entry matching native XSOAR comment styling.""" entry: dict[str, Any] = { "Type": EntryType.NOTE, "Contents": comment_text, "ContentsFormat": EntryFormat.TEXT, "Note": True, } if tags: entry["Tags"] = tags return entry def _collect_alert_ids_from_args(args: dict[str, Any]) -> list[str]: """Collect unique Vega alert IDs from command arguments.""" entity_ids: list[str] = [] for key in ("alert_ids", "alert_id"): entity_ids.extend(str(item).strip() for item in argToList(args.get(key)) if str(item).strip()) return list(dict.fromkeys(entity_ids)) def _collect_incident_ids_from_args(args: dict[str, Any]) -> list[str]: """Collect unique Vega incident IDs from command arguments.""" entity_ids: list[str] = [] for key in ("incident_ids", "incident_id"): entity_ids.extend(str(item).strip() for item in argToList(args.get(key)) if str(item).strip()) return list(dict.fromkeys(entity_ids)) def _collect_string_list_from_args(args: dict[str, Any], key: str) -> list[str]: """Collect unique non-empty string values from a command argument list.""" return list(dict.fromkeys(str(item).strip() for item in argToList(args.get(key)) if str(item).strip())) def _validate_alert_status_value(status: str) -> str: """Validate and normalize an alert status for the Vega API.""" api_status = _normalize_vega_status_for_api(status, MIRROR_ENTITY_SUFFIX_ALERT) if api_status not in VALID_ALERT_STATUSES: valid_values = ", ".join(sorted(ALERT_STATUS_DISPLAY_TO_API.keys())) raise DemistoException(f"Invalid alert status '{status}'. Valid values: {valid_values}.") return api_status def _validate_incident_status_value(status: str) -> str: """Validate and normalize an incident status for the Vega API.""" api_status = _normalize_vega_status_for_api(status, MIRROR_ENTITY_SUFFIX_INCIDENT) if api_status not in VALID_INCIDENT_STATUSES: valid_values = ", ".join(sorted(INCIDENT_STATUS_DISPLAY_TO_API.keys())) raise DemistoException(f"Invalid incident status '{status}'. Valid values: {valid_values}.") return api_status def _validate_verdict_value(verdict: str) -> str: """Validate and normalize a verdict for the Vega API.""" api_verdict = _normalize_vega_verdict_for_api(verdict) if api_verdict not in VALID_VERDICTS: raise DemistoException("Invalid verdict. Valid values: MALICIOUS, SUSPICIOUS, BENIGN, INCONCLUSIVE, NA.") return api_verdict def _validate_severity_value(severity: str) -> str: """Validate and normalize a severity for the Vega API.""" normalized = _normalize_vega_severity_for_display(severity) if normalized not in VALID_SEVERITIES: raise DemistoException(f"Invalid severity. Valid values: {', '.join(sorted(VALID_SEVERITIES))}.") return normalized def _build_direct_alert_update_payload(args: dict[str, Any]) -> dict[str, Any]: """Build an UpdateAlertsInput payload from explicit command arguments.""" update_input: dict[str, Any] = {} status = args.get("status") or args.get("alert_status") if status is not None and str(status).strip(): update_input["status"] = _validate_alert_status_value(str(status)) verdict = args.get("verdict") if verdict is not None and str(verdict).strip(): update_input["verdict"] = _validate_verdict_value(str(verdict)) verdict_reasoning = args.get("verdict_reasoning") if verdict_reasoning is not None and str(verdict_reasoning).strip(): update_input["verdictReasoning"] = str(verdict_reasoning).strip() severity = args.get("severity") or args.get("alert_severity") if severity is not None and str(severity).strip(): update_input["severity"] = _validate_severity_value(str(severity)) comment = args.get("comment") if comment is not None and str(comment).strip(): update_input["comment"] = str(comment).strip() assignees = _collect_string_list_from_args(args, "assignees") if assignees: update_input["assignees"] = assignees return update_input def _build_direct_incident_update_payload(args: dict[str, Any]) -> dict[str, Any]: """Build an UpdateIncidentsInput payload from explicit command arguments.""" update_input: dict[str, Any] = {} status = args.get("status") or args.get("incident_status") if status is not None and str(status).strip(): update_input["status"] = _validate_incident_status_value(str(status)) verdict = args.get("verdict") verdict_reasoning = args.get("verdict_reasoning") if verdict is not None and str(verdict).strip(): update_input["verdict"] = { "value": _validate_verdict_value(str(verdict)), "reasoning": str(verdict_reasoning or ""), } elif verdict_reasoning is not None and str(verdict_reasoning).strip(): update_input["verdict"] = { "value": _validate_verdict_value("NA"), "reasoning": str(verdict_reasoning).strip(), } severity = args.get("severity") if severity is not None and str(severity).strip(): update_input["severity"] = _validate_severity_value(str(severity)) comment = args.get("comment") if comment is not None and str(comment).strip(): update_input["comment"] = str(comment).strip() assignee_emails = _collect_string_list_from_args(args, "assignee_emails") if assignee_emails: update_input["assigneeEmails"] = assignee_emails return update_input def _format_assignee_output(assignee: Any) -> str | None: """Format a Vega assignee object for command context output.""" if not isinstance(assignee, dict): return None email = str(assignee.get("email") or "").strip() if email: return email display_name = str(assignee.get("displayName") or "").strip() if display_name: return display_name user_id = str(assignee.get("userId") or "").strip() return user_id or None def _format_push_alert_output(alert: dict[str, Any]) -> dict[str, str]: """Format a Vega alert update response for context output.""" output: dict[str, str] = { "id": str(alert.get("id") or ""), "status": _normalize_vega_status_for_display(str(alert.get("status") or ""), MIRROR_ENTITY_SUFFIX_ALERT), "verdict": _normalize_vega_verdict_for_display(alert.get("verdict")), } if alert.get("severity") is not None: output["severity"] = _normalize_vega_severity_for_display(alert.get("severity")) assignee = _format_assignee_output(alert.get("assignee")) if assignee: output["assignee"] = assignee return output def _format_push_incident_output( incident: dict[str, Any], requested_severity: str | None = None, ) -> dict[str, str]: """Format a Vega incident update response for context output.""" severity_value = incident.get("severity") if severity_value is None and requested_severity is not None: severity_value = requested_severity output: dict[str, str] = { "id": str(incident.get("incidentId") or incident.get("id") or ""), "status": _normalize_vega_status_for_display(str(incident.get("status") or ""), MIRROR_ENTITY_SUFFIX_INCIDENT), "verdict": _normalize_vega_verdict_for_display(incident.get("verdict")), } if severity_value is not None: output["severity"] = _normalize_vega_severity_for_display(severity_value) assignee = _format_assignee_output(incident.get("assignee")) if assignee: output["assignee"] = assignee return output def resolve_incident_id_from_incident(args: dict[str, Any], incident: dict[str, Any]) -> str | None: """Resolve Vega incident ID from command args or the current Vega Incident investigation.""" incident_ids = _collect_incident_ids_from_args(args) if incident_ids: return incident_ids[0] custom_fields = _collect_incident_custom_fields(incident) vega_incident_id = custom_fields.get("vegaincidentid") if vega_incident_id is not None and str(vega_incident_id).strip(): return str(vega_incident_id).strip() incident_type = str(incident.get("type") or incident.get("Type") or "").strip() raw_json = incident.get("rawJSON") or incident.get("rawJson") if raw_json: try: raw = json.loads(raw_json) if isinstance(raw_json, str) else raw_json if isinstance(raw, dict): if incident_type and incident_type != "Vega Incident": return None if not incident_type and raw.get("vegaEntityType") not in (None, "Vega Incident"): return None raw_id = raw.get("id") or raw.get("incidentId") if raw_id is not None and str(raw_id).strip(): return str(raw_id).strip() except (json.JSONDecodeError, TypeError, ValueError): pass return None def _is_field_change_trigger(args: dict[str, Any]) -> bool: """Return True when the command was triggered by a layout field change.""" return "old" in args or "new" in args def _resolve_field_change_update_args(args: dict[str, Any], entity_type_suffix: str) -> dict[str, str]: """Map a layout field-change ``new`` value to the status, verdict, severity, or reasoning arg to update.""" new_value = str(args.get("new") or "").strip() if not new_value: return {} normalized_upper = new_value.strip().upper() api_status = _normalize_vega_status_for_api(new_value, entity_type_suffix) valid_statuses = VALID_ALERT_STATUSES if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT else VALID_INCIDENT_STATUSES if api_status in valid_statuses: return {"status": new_value} if _normalize_vega_verdict_for_api(normalized_upper) in VALID_VERDICTS: return {"verdict": new_value} if _normalize_vega_severity_for_display(normalized_upper) in VALID_SEVERITIES: return {"severity": new_value} return {"verdict_reasoning": new_value} def _args_explicitly_set(args: dict[str, Any], *keys: str) -> bool: """Return True when at least one of the provided args was explicitly set.""" for key in keys: value = args.get(key) if value is not None and str(value).strip() != "": return True return False def _alert_update_fields_explicitly_set(args: dict[str, Any]) -> bool: """Return True when the caller provided alert update command arguments.""" return _args_explicitly_set( args, "status", "alert_status", "verdict", "verdict_reasoning", "severity", "alert_severity", "comment", "assignees", ) def _incident_update_fields_explicitly_set(args: dict[str, Any]) -> bool: """Return True when the caller provided incident status, verdict, severity, reasoning, and/or comment command arguments.""" return _args_explicitly_set( args, "status", "incident_status", "verdict", "verdict_reasoning", "severity", "comment", "assignee_emails", ) def _xsoar_incident_is_closed(incident: dict[str, Any]) -> bool: """Return True when the current XSOAR investigation is closed.""" status = incident.get("status") or incident.get("Status") return status == IncidentStatus.DONE or (isinstance(status, str) and status.strip().lower() in {"closed", "done"}) def _resolve_entity_ids_for_update( args: dict[str, Any], incident: dict[str, Any], collect_fn: Callable[[dict[str, Any]], list[str]], resolve_fn: Callable[[dict[str, Any], dict[str, Any]], str | None], ) -> list[str]: """Resolve Vega entity IDs from args or the current investigation.""" entity_ids = collect_fn(args) if entity_ids: return entity_ids resolved_id = resolve_fn(args, incident) return [resolved_id] if resolved_id else [] def _resolve_alert_ids_for_update(args: dict[str, Any], incident: dict[str, Any]) -> list[str]: """Resolve Vega alert IDs from args or the current investigation.""" return _resolve_entity_ids_for_update(args, incident, _collect_alert_ids_from_args, resolve_alert_id_from_incident) def _resolve_incident_ids_for_update(args: dict[str, Any], incident: dict[str, Any]) -> list[str]: """Resolve Vega incident IDs from args or the current investigation.""" return _resolve_entity_ids_for_update(args, incident, _collect_incident_ids_from_args, resolve_incident_id_from_incident) def _resolve_alert_status_for_update(args: dict[str, Any], incident: dict[str, Any]) -> str | None: """Resolve alert status from args or the current investigation custom fields.""" status = args.get("alert_status") if status is None or str(status).strip() == "": status = args.get("status") if status is None or str(status).strip() == "": custom_fields = _collect_incident_custom_fields(incident) status = custom_fields.get(VEGA_ALERT_STATUS_FIELD) if status is None or str(status).strip() == "": return None return str(status) def _resolve_incident_status_for_update(args: dict[str, Any], incident: dict[str, Any]) -> str | None: """Resolve incident status from args or the current investigation custom fields.""" status = args.get("incident_status") if status is None or str(status).strip() == "": status = args.get("status") if status is None or str(status).strip() == "": custom_fields = _collect_incident_custom_fields(incident) status = custom_fields.get(VEGA_INCIDENT_STATUS_FIELD) or custom_fields.get(VEGA_ALERT_STATUS_FIELD) if status is None or str(status).strip() == "": return None return str(status) def _resolve_verdict_for_update(args: dict[str, Any], incident: dict[str, Any]) -> str | None: """Resolve verdict from args or the current investigation custom fields.""" verdict = args.get("verdict") if verdict is None or str(verdict).strip() == "": custom_fields = _collect_incident_custom_fields(incident) verdict = custom_fields.get("vegaverdict") if verdict is None or str(verdict).strip() == "": return None return str(verdict) def _resolve_alert_severity_for_update(args: dict[str, Any], incident: dict[str, Any]) -> str | None: """Resolve alert severity from args or the current investigation custom fields.""" severity = args.get("alert_severity") if severity is None or str(severity).strip() == "": severity = args.get("severity") if severity is None or str(severity).strip() == "": custom_fields = _collect_incident_custom_fields(incident) severity = custom_fields.get(VEGA_ALERT_SEVERITY_FIELD) if severity is None or str(severity).strip() == "": return None return str(severity) def _resolve_severity_for_update(args: dict[str, Any], incident: dict[str, Any]) -> str | None: """Resolve severity from args or the current investigation custom fields.""" severity = args.get("severity") if severity is None or str(severity).strip() == "": custom_fields = _collect_incident_custom_fields(incident) severity = custom_fields.get(VEGA_SEVERITY_FIELD) if severity is None or str(severity).strip() == "": return None return str(severity) def _resolve_comment_for_update(args: dict[str, Any]) -> str | None: """Resolve an optional Vega incident comment from command args.""" comment = args.get("comment") if comment is None or str(comment).strip() == "": return None return str(comment).strip() def _build_effective_alert_update_args(args: dict[str, Any], incident: dict[str, Any]) -> dict[str, Any]: """Build alert update args from explicit args, field changes, or current investigation fields.""" if _is_field_change_trigger(args): return {**args, **_resolve_field_change_update_args(args, MIRROR_ENTITY_SUFFIX_ALERT)} effective_args = dict(args) if not _alert_update_fields_explicitly_set(args): status = _resolve_alert_status_for_update(args, incident) if status is not None: effective_args["status"] = status verdict = _resolve_verdict_for_update(args, incident) if verdict is not None: effective_args["verdict"] = verdict severity = _resolve_alert_severity_for_update(args, incident) if severity is not None: effective_args["severity"] = severity return effective_args if _args_explicitly_set(args, "status", "alert_status"): status = _resolve_alert_status_for_update(args, incident) if status is not None: effective_args["status"] = status if _args_explicitly_set(args, "verdict"): verdict = _resolve_verdict_for_update(args, incident) if verdict is not None: effective_args["verdict"] = verdict if _args_explicitly_set(args, "severity", "alert_severity"): severity = _resolve_alert_severity_for_update(args, incident) if severity is not None: effective_args["severity"] = severity if _args_explicitly_set(args, "assignees"): effective_args["assignees"] = _collect_string_list_from_args(args, "assignees") return effective_args def _build_effective_incident_update_args(args: dict[str, Any], incident: dict[str, Any]) -> dict[str, Any]: """Build incident update args from explicit args, field changes, or current investigation fields.""" if _is_field_change_trigger(args): resolved_args = _resolve_field_change_update_args(args, MIRROR_ENTITY_SUFFIX_INCIDENT) effective_args = {**args, **resolved_args} if resolved_args.get("verdict_reasoning") and not resolved_args.get("verdict"): verdict = _resolve_verdict_for_update(args, incident) if verdict is not None: effective_args["verdict"] = verdict return effective_args effective_args = dict(args) if not _incident_update_fields_explicitly_set(args): status = _resolve_incident_status_for_update(args, incident) if status is not None: effective_args["status"] = status verdict = _resolve_verdict_for_update(args, incident) if verdict is not None: effective_args["verdict"] = verdict comment = _resolve_comment_for_update(args) if comment is not None: effective_args["comment"] = comment return effective_args if _args_explicitly_set(args, "status", "incident_status"): status = _resolve_incident_status_for_update(args, incident) if status is not None: effective_args["status"] = status if _args_explicitly_set(args, "verdict"): verdict = _resolve_verdict_for_update(args, incident) if verdict is not None: effective_args["verdict"] = verdict comment = _resolve_comment_for_update(args) if comment is not None: effective_args["comment"] = comment if _args_explicitly_set(args, "assignee_emails"): effective_args["assignee_emails"] = _collect_string_list_from_args(args, "assignee_emails") return effective_args def _should_sync_xsoar_entity( args: dict[str, Any], incident: dict[str, Any], entity_ids: list[str], id_arg_keys: tuple[str, ...], resolve_fn: Callable[[dict[str, Any], dict[str, Any]], str | None], ) -> bool: """Return True when the open XSOAR investigation should be synced after an entity update.""" if not incident.get("id"): return False if len(entity_ids) > 1 and _args_explicitly_set(args, *id_arg_keys): current_id = resolve_fn(args, incident) return bool(current_id and current_id in entity_ids) return True def _should_sync_xsoar_alert(args: dict[str, Any], incident: dict[str, Any], alert_ids: list[str]) -> bool: """Return True when the open XSOAR investigation should be synced after an alert update.""" return _should_sync_xsoar_entity(args, incident, alert_ids, ("alert_ids", "alert_id"), resolve_alert_id_from_incident) def _should_sync_xsoar_incident(args: dict[str, Any], incident: dict[str, Any], incident_ids: list[str]) -> bool: """Return True when the open XSOAR investigation should be synced after an incident update.""" return _should_sync_xsoar_entity( args, incident, incident_ids, ("incident_ids", "incident_id"), resolve_incident_id_from_incident ) def _build_xsoar_alert_sync_entries(args: dict[str, Any], incident: dict[str, Any]) -> list[dict]: """Build war room entries that sync the open Vega Alert investigation without executeCommand.""" if not incident.get("id"): return [] entries: list[dict] = [] field_change = _is_field_change_trigger(args) if not field_change and _args_explicitly_set(args, "status", "alert_status", "verdict", "severity", "alert_severity"): custom_fields: dict[str, str] = {} status = _resolve_alert_status_for_update(args, incident) if status is not None: custom_fields[VEGA_ALERT_STATUS_FIELD] = _normalize_vega_status_for_display(status, MIRROR_ENTITY_SUFFIX_ALERT) verdict = _resolve_verdict_for_update(args, incident) if verdict is not None: custom_fields["vegaverdict"] = _normalize_vega_verdict_for_display(verdict) severity = _resolve_alert_severity_for_update(args, incident) if severity is not None: custom_fields[VEGA_ALERT_SEVERITY_FIELD] = _normalize_vega_severity_for_display(severity) if custom_fields: entries.append( { "Type": EntryType.NOTE, "ContentsFormat": EntryFormat.JSON, "Contents": {"CustomFields": custom_fields}, "Note": True, } ) status = _resolve_alert_status_for_update(args, incident) if status is None: return entries api_status = _normalize_vega_status_for_api(status, MIRROR_ENTITY_SUFFIX_ALERT) if api_status in VEGA_CLOSE_STATUSES and not _xsoar_incident_is_closed(incident): entries.extend(_build_close_reopen_sync_entries(status, MIRROR_ENTITY_SUFFIX_ALERT)) elif api_status in VEGA_ALERT_OPEN_STATUSES and _xsoar_incident_is_closed(incident): entries.extend(_build_close_reopen_sync_entries(status, MIRROR_ENTITY_SUFFIX_ALERT)) return entries # ? ---------------------------- HELPER FUNCTIONS -------------------------------------- def _build_xsoar_incident_sync_entries(args: dict[str, Any], incident: dict[str, Any]) -> list[dict]: """Build war room entries that sync the open Vega Incident investigation without executeCommand.""" if not incident.get("id"): return [] entries: list[dict] = [] field_change = _is_field_change_trigger(args) if not field_change and _args_explicitly_set(args, "status", "incident_status", "verdict", "severity"): custom_fields: dict[str, str] = {} status = _resolve_incident_status_for_update(args, incident) if status is not None: custom_fields[VEGA_INCIDENT_STATUS_FIELD] = _normalize_vega_status_for_display(status, MIRROR_ENTITY_SUFFIX_INCIDENT) verdict = _resolve_verdict_for_update(args, incident) if verdict is not None: custom_fields["vegaverdict"] = _normalize_vega_verdict_for_display(verdict) severity = _resolve_severity_for_update(args, incident) if severity is not None: custom_fields[VEGA_SEVERITY_FIELD] = _normalize_vega_severity_for_display(severity) if custom_fields: entries.append( { "Type": EntryType.NOTE, "ContentsFormat": EntryFormat.JSON, "Contents": {"CustomFields": custom_fields}, "Note": True, } ) status = _resolve_incident_status_for_update(args, incident) if status is None: return entries api_status = _normalize_vega_status_for_api(status, MIRROR_ENTITY_SUFFIX_INCIDENT) if api_status in VEGA_CLOSE_STATUSES and not _xsoar_incident_is_closed(incident): entries.extend(_build_close_reopen_sync_entries(status, MIRROR_ENTITY_SUFFIX_INCIDENT)) elif api_status in VEGA_INCIDENT_OPEN_STATUSES and _xsoar_incident_is_closed(incident): entries.extend(_build_close_reopen_sync_entries(status, MIRROR_ENTITY_SUFFIX_INCIDENT)) comment = _resolve_comment_for_update(args) if ( comment is not None and not field_change and _args_explicitly_set(args, "comment") and set(_build_direct_incident_update_payload(args).keys()) != {"comment"} ): entries.append(_build_comment_war_room_entry(comment)) return entries def update_alert_command(client: Client, args: dict[str, Any]) -> CommandResults | list[Any]: """Update Vega alert status and/or verdict and sync the open XSOAR investigation.""" incident = load_current_incident() alert_ids = _resolve_alert_ids_for_update(args, incident) if not alert_ids: incident_type = str(incident.get("type") or incident.get("Type") or "unknown") xsoar_incident_id = incident.get("id") or "none" raise DemistoException( "At least one alert id is required when the command is not run from a Vega Alert incident. " f"Could not resolve alert ID from incident id={xsoar_incident_id}, type={incident_type}. " "Pass alert_ids explicitly, for example: " "!vega-update-alert alert_ids=alert-1,alert-2 status=RESOLVED verdict=MALICIOUS" ) effective_args = _build_effective_alert_update_args(args, incident) update_fields = _build_direct_alert_update_payload(effective_args) if not update_fields: raise DemistoException( "At least one of status, severity, verdict, verdict reasoning, comment, or assignees must be provided." ) result = client.update_alerts({**update_fields, "alertIds": alert_ids}) updated_alerts = result.get("alerts") or [] outputs = [_format_push_alert_output(alert) for alert in updated_alerts] readable = tableToMarkdown( "Updated Vega Alerts", outputs, headers=["id", "status", "severity", "verdict", "assignee"], removeNull=True, ) command_result = CommandResults( readable_output=readable, outputs_prefix="Vega.Alert", outputs_key_field="id", outputs=outputs[0] if len(outputs) == 1 else outputs, ) if not _should_sync_xsoar_alert(args, incident, alert_ids): return command_result sync_entries = _build_xsoar_alert_sync_entries(effective_args, incident) if sync_entries: return [command_result, *sync_entries] return command_result def _build_incident_update_command_result( incident_ids: list[str], outputs: list[dict[str, Any]], update_fields: dict[str, Any], comment_text: str | None, ) -> CommandResults: """Build the command result for a Vega incident update.""" comment_only_update = set(update_fields.keys()) == {"comment"} and bool(comment_text) if comment_only_update: return CommandResults( readable_output=comment_text, entry_type=EntryType.NOTE, mark_as_note=True, outputs_prefix="Vega.Incident", outputs_key_field="id", outputs={"id": incident_ids[0], "comment": comment_text}, ) readable = tableToMarkdown( "Updated Vega Incidents", outputs, headers=["id", "status", "verdict", "severity", "assignee"], removeNull=True, ) return CommandResults( readable_output=readable, outputs_prefix="Vega.Incident", outputs_key_field="id", outputs=outputs[0] if len(outputs) == 1 else outputs, ) def update_incident_command(client: Client, args: dict[str, Any]) -> CommandResults | list[Any]: """Update Vega incident status, verdict, and/or comment and sync the open XSOAR investigation.""" incident = load_current_incident() incident_ids = _resolve_incident_ids_for_update(args, incident) if not incident_ids: incident_type = str(incident.get("type") or incident.get("Type") or "unknown") xsoar_incident_id = incident.get("id") or "none" raise DemistoException( "At least one incident id is required when the command is not run from a Vega Incident investigation. " f"Could not resolve incident ID from incident id={xsoar_incident_id}, type={incident_type}. " "Pass incident_ids explicitly, for example: " "!vega-update-incident incident_ids=inc-1,inc-2 status=RESOLVED verdict=MALICIOUS comment=Updated" ) effective_args = _build_effective_incident_update_args(args, incident) update_fields = _build_direct_incident_update_payload(effective_args) if not update_fields: raise DemistoException( "At least one of status, verdict, verdict reasoning, severity, comment, or assignee_emails must be provided." ) result = client.update_incidents({**update_fields, "incidentIds": incident_ids}) requested_severity = effective_args.get("severity") outputs = [ _format_push_incident_output(incident_item, requested_severity=str(requested_severity) if requested_severity else None) for incident_item in result.get("incidents") or [] ] command_result = _build_incident_update_command_result( incident_ids, outputs, update_fields, _resolve_comment_for_update(effective_args), ) if not _should_sync_xsoar_incident(args, incident, incident_ids): return command_result sync_entries = _build_xsoar_incident_sync_entries(effective_args, incident) if sync_entries: return [command_result, *sync_entries] return command_result def _build_xsoar_incident_dict( raw: dict[str, Any], entity_type: str, entity_type_suffix: str, custom_fields_builder: Callable[[dict], dict[str, str]], ) -> dict[str, Any]: """Build a common XSOAR incident dict from prepared raw entity data.""" severity = VEGA_SEVERITY_TO_XSOAR.get(raw.get("severity", "").upper(), IncidentSeverity.UNKNOWN) xsoar_incident: dict[str, Any] = { "name": f"{raw.get('name', 'Unknown')}", "occurred": raw.get("createdAt", ""), "severity": severity, "type": entity_type, "rawJSON": json.dumps({key: value for key, value in raw.items() if key != "_alertEventsCustomFields"}), } mirror_id = raw.get("mirror_id") if mirror_id: xsoar_incident["dbotMirrorId"] = str(mirror_id).strip() elif raw.get("id"): xsoar_incident["dbotMirrorId"] = _format_dbot_mirror_id(entity_type_suffix, str(raw["id"]).strip()) _apply_xsoar_mirror_metadata(xsoar_incident) custom_fields = custom_fields_builder(raw) if custom_fields: xsoar_incident["CustomFields"] = custom_fields return xsoar_incident def alert_to_incident( alert: dict, integration_url: str | None = None, client: Client | None = None, ) -> dict: """Convert a Vega alert to an XSOAR incident.""" raw = dict(alert) raw["vegaEntityType"] = "Vega Alert" raw.update(_get_mirroring_fields()) _apply_mirror_metadata(raw, MIRROR_ENTITY_SUFFIX_ALERT) _apply_vega_entity_link(raw, integration_url=integration_url) if client is not None: alert_id = _normalize_entity_id(raw) if alert_id: alert_events, event_custom_fields = _fetch_alert_events_for_ingest(client, alert_id) raw["alertEvents"] = alert_events raw["_alertEventsCustomFields"] = event_custom_fields _format_raw_entity_for_xsoar(raw) return _build_xsoar_incident_dict(raw, "Vega Alert", MIRROR_ENTITY_SUFFIX_ALERT, _build_vega_alert_custom_fields) def incident_to_xsoar_incident( incident: dict, timeline_events: list[dict] | None = None, ) -> dict: """Convert a Vega incident to an XSOAR incident.""" raw = dict(incident) raw["vegaEntityType"] = "Vega Incident" raw.update(_get_mirroring_fields()) _apply_mirror_metadata(raw, MIRROR_ENTITY_SUFFIX_INCIDENT) if timeline_events is not None: raw["timelineEvents"] = timeline_events raw["vegaTimelineEvents"] = _format_timeline_events_html(timeline_events) _apply_vega_entity_link(raw) _format_raw_entity_for_xsoar(raw) return _build_xsoar_incident_dict(raw, "Vega Incident", MIRROR_ENTITY_SUFFIX_INCIDENT, _build_vega_incident_custom_fields) def _apply_mirror_metadata(raw: dict[str, Any], entity_type_suffix: str) -> None: """Attach mirror_id used by incoming mappers and mirroring commands.""" entity_id = _normalize_entity_id(raw) if entity_id: raw["mirror_id"] = _format_dbot_mirror_id(entity_type_suffix, str(entity_id).strip()) def _is_xsoar_to_vega_mirroring_enabled(params: dict[str, Any] | None = None) -> bool: """Return True when outgoing XSOAR to Vega mirroring is enabled.""" params = params or demisto.params() return argToBoolean(params.get("autoclosure", True)) def _get_mirroring_fields( params: dict[str, Any] | None = None, mirror_context: dict[str, Any] | None = None, ) -> dict[str, str]: """Build mirror metadata embedded in fetched incidents.""" params = params or demisto.params() fields: dict[str, str] = {} instance = str(demisto.integrationInstance() or "") if not instance and isinstance(mirror_context, dict): instance = str(mirror_context.get("dbotMirrorInstance") or mirror_context.get("mirror_instance") or "").strip() if not instance: custom_fields = mirror_context.get("CustomFields") if isinstance(custom_fields, dict): instance = str(custom_fields.get("dbotmirrorinstance") or custom_fields.get("mirror_instance") or "").strip() if not instance: calling_context = getattr(demisto, "callingContext", None) if isinstance(calling_context, dict): ctx = calling_context.get("context", {}) if isinstance(ctx, dict) and ctx.get("IntegrationInstance"): instance = str(ctx["IntegrationInstance"]) if instance: fields["mirror_instance"] = instance fields["mirror_direction"] = "Both" if _is_xsoar_to_vega_mirroring_enabled(params) else "In" return fields def _apply_xsoar_mirror_metadata( xsoar_incident: dict[str, Any], mirror_context: dict[str, Any] | None = None, ) -> None: """Attach top-level mirror metadata required for XSOAR remote sync.""" mirror_id = xsoar_incident.get("dbotMirrorId") or xsoar_incident.get("mirror_id") if not mirror_id: return _apply_mirror_metadata_fields(xsoar_incident, mirror_context=mirror_context, mirror_id=str(mirror_id).strip()) def _apply_mirror_metadata_fields( payload: dict[str, Any], mirror_context: dict[str, Any] | None = None, *, mirror_id: str | None = None, ) -> None: """Attach mirror direction and instance to an ingest or get-remote-data payload.""" effective_mirror_id = mirror_id or payload.get("dbotMirrorId") or payload.get("mirror_id") if not effective_mirror_id: return mirror_id_text = str(effective_mirror_id).strip() payload["dbotMirrorId"] = mirror_id_text payload["mirror_id"] = mirror_id_text mirror_fields = _get_mirroring_fields(mirror_context=mirror_context or payload) if mirror_fields.get("mirror_direction"): direction = mirror_fields["mirror_direction"] payload["dbotMirrorDirection"] = direction payload["mirror_direction"] = direction if mirror_fields.get("mirror_instance"): instance = mirror_fields["mirror_instance"] payload["dbotMirrorInstance"] = instance payload["mirror_instance"] = instance def _apply_mirror_sync_metadata( sync_object: dict[str, Any], mirror_context: dict[str, Any] | None = None, ) -> None: """Refresh top-level mirror metadata on every incoming mirror sync cycle.""" _apply_mirror_metadata_fields(sync_object, mirror_context=mirror_context) def _normalize_entity_id(entity: dict, id_key: str = "id") -> str: """Return a stable string ID for deduplication.""" entity_id = entity.get(id_key) if entity_id is None or entity_id == "": for fallback_key in ("incidentId", "alertId", "vegaAlertId"): fallback = entity.get(fallback_key) if fallback is not None and str(fallback).strip(): entity_id = fallback break if entity_id is None or entity_id == "": return "" return str(entity_id) def _normalize_incident_api_entity(entity: dict[str, Any]) -> dict[str, Any]: """Normalize incident payloads from list or detail APIs to a common shape.""" if not entity: return {} normalized = dict(entity) entity_id = _normalize_entity_id(normalized) if entity_id: normalized["id"] = entity_id if not normalized.get("lastUpdated") and normalized.get("lastUpdate"): normalized["lastUpdated"] = normalized["lastUpdate"] if normalized.get("alertCount") is not None and normalized.get("alertsCount") is None: normalized["alertsCount"] = normalized["alertCount"] return normalized def _normalize_alert_api_entity(entity: dict[str, Any]) -> dict[str, Any]: """Normalize alert payloads from the Vega API to a common shape.""" if not entity: return {} normalized = dict(entity) entity_id = _normalize_entity_id(normalized) if entity_id: normalized["id"] = entity_id return normalized def _parse_entity_created_at(created_at: Any) -> datetime | None: """Parse a Vega createdAt value to UTC datetime.""" if not created_at: return None return arg_to_datetime(str(created_at), is_utc=True) # type: ignore[return-value] def _normalize_fetch_datetime(created_at: datetime) -> datetime: """Truncate a UTC datetime to whole seconds for cursor comparisons.""" return created_at.replace(microsecond=0) def _format_fetch_timestamp(created_at: datetime) -> str: """Format a datetime as the canonical ISO 8601 timestamp stored in last_run.""" return _normalize_fetch_datetime(created_at).strftime("%Y-%m-%dT%H:%M:%SZ") def _current_fetch_timestamp() -> str: """Return the current UTC time formatted for last_run cursors.""" return _format_fetch_timestamp(datetime.now(UTC)) def _fetch_paginated_entities( fetch_func: Callable[..., dict], entities_key: str, max_entities: int | None = None, start_offset: int = 0, **fetch_kwargs: Any, ) -> tuple[list[dict], int | None]: """Fetch entities from the Vega API. Returns the entity list and an optional next offset when max_entities stops the fetch before all matching records are retrieved. """ entities: list[dict] = [] offset = start_offset while True: request_kwargs = dict(fetch_kwargs) page_limit = FETCH_ENTITIES_PAGE_SIZE if max_entities is not None: remaining = max_entities - len(entities) if remaining <= 0: break page_limit = min(page_limit, remaining) request_kwargs["limit"] = page_limit response = fetch_func(offset=offset, **request_kwargs) page = response.get(entities_key) or [] if not page: break entities.extend(page) if max_entities is not None and len(entities) >= max_entities: entities = entities[:max_entities] total = response.get("total") if total is not None: next_offset = start_offset + len(entities) if next_offset < total: return entities, next_offset break total = response.get("total") if total is not None and offset + len(page) >= total: break offset += len(page) return entities, None def _build_fetch_filter_fingerprint( severities: list[str] | None, statuses: list[str] | None, verdicts: list[str] | None, has_related_incidents: bool | None = None, ) -> str: """Build a stable fingerprint for fetch filter parameters.""" payload: dict[str, Any] = { "severities": sorted(severities or []), "statuses": sorted(statuses or []), "verdicts": sorted(verdicts or []), } if has_related_incidents is not None: payload["hasRelatedIncidents"] = has_related_incidents return json.dumps(payload, sort_keys=True) def _fetch_config_key_for_last_fetch(last_fetch_key: str) -> str: """Map a last_fetch cursor key to its corresponding fetch filter fingerprint key.""" return last_fetch_key.replace("_last_fetch", "_fetch_config") def _resolve_fetch_from_time( last_run: dict, last_fetch_key: str, first_fetch_time: str, ) -> str: """Resolve the Vega API `from` timestamp for the current fetch run.""" stored_fetch = last_run.get(last_fetch_key) fetch_config_key = _fetch_config_key_for_last_fetch(last_fetch_key) if stored_fetch not in (None, "") and last_run.get(fetch_config_key) is not None: return str(stored_fetch) if stored_fetch not in (None, ""): return first_fetch_time return first_fetch_time def _parse_mirror_last_update(last_update: str | None) -> datetime: """Parse a mirroring lastUpdate timestamp to UTC.""" if not last_update: return datetime.fromtimestamp(0, tz=UTC) parsed = arg_to_datetime(last_update, is_utc=True) if parsed is None: return datetime.fromtimestamp(0, tz=UTC) if parsed.tzinfo is None: return parsed.replace(tzinfo=UTC) return parsed.astimezone(UTC) def _format_mirror_timestamp(value: datetime) -> str: """Format a UTC datetime for Vega updatedFrom/updatedTo filters.""" normalized = value.astimezone(UTC).replace(microsecond=0) return normalized.strftime("%Y-%m-%dT%H:%M:%SZ") def _resolve_mirror_updated_from(last_update: str | None) -> str: """Resolve updatedFrom for incident mirror polling.""" cursor_start = _parse_mirror_last_update(last_update) - MIRROR_LAST_UPDATE_SAFETY_MARGIN minimum_lookback = datetime.now(UTC) - MIRROR_POLL_LOOKBACK resolved = _format_mirror_timestamp(min(cursor_start, minimum_lookback)) return resolved def _resolve_mirror_updated_to() -> str: """Resolve updatedTo as current time plus a small buffer.""" resolved = _format_mirror_timestamp(datetime.now(UTC) + MIRROR_UPDATED_TO_BUFFER) return resolved def _poll_entity_is_alert(entity: dict[str, Any]) -> bool: """Return True when a polled Vega entity is an alert (has vegaAlertId).""" vega_alert_id = entity.get("vegaAlertId") return vega_alert_id is not None and str(vega_alert_id).strip() != "" def _mirror_entity_suffix_from_poll_entity(entity: dict[str, Any], *, poll_source: str | None = None) -> str: """Classify a polled entity as alert or incident using vegaAlertId when present.""" if _poll_entity_is_alert(entity): return MIRROR_ENTITY_SUFFIX_ALERT if poll_source == MIRROR_ENTITY_SUFFIX_ALERT: return MIRROR_ENTITY_SUFFIX_ALERT if poll_source == MIRROR_ENTITY_SUFFIX_INCIDENT: return MIRROR_ENTITY_SUFFIX_INCIDENT return MIRROR_ENTITY_SUFFIX_INCIDENT def _append_modified_remote_entity_id( modified_ids: list[str], entity: dict[str, Any], *, poll_source: str, ) -> str | None: """Append prefixed (and legacy bare) mirror IDs for a polled alert or incident entity.""" normalized = ( _normalize_alert_api_entity(entity) if poll_source == MIRROR_ENTITY_SUFFIX_ALERT else _normalize_incident_api_entity(entity) ) entity_id = _normalize_entity_id(normalized) if not entity_id: return None entity_suffix = _mirror_entity_suffix_from_poll_entity(normalized, poll_source=poll_source) prefixed_id = _format_dbot_mirror_id(entity_suffix, entity_id) if prefixed_id and prefixed_id not in modified_ids: modified_ids.append(prefixed_id) return entity_id def _resolve_mirror_entity_lookup_filters() -> dict[str, str]: """Build a wide ``from`` filter for direct mirror entity ID lookups during get-remote-data.""" return {"from_time": parse_backfill_days(demisto.params().get("backfill_days"))} def _resolve_mirror_incident_lookup_filters(last_update: str | None) -> dict[str, str]: """Build getIncidents `from` filter for incident ID lookups during mirroring.""" return {"from_time": _resolve_mirror_updated_from(last_update)} def _normalize_mirror_field_value(value: Any) -> Any: """Return the effective value from a mirror delta field (supports old/new change dicts).""" if isinstance(value, dict): if "new" in value: new_value = value.get("new") if new_value is not None and str(new_value).strip() != "": return new_value if "old" in value: return value.get("old") return value def _outgoing_mirror_comment_value(value: Any) -> str | None: """Return a Vega comment to push, skipping empty values and the layout display default.""" if value is None: return None text = str(value).strip() if not text or text.casefold() == VEGA_NEW_COMMENT_LAYOUT_DEFAULT.casefold(): return None return text def _mirror_delta_raw_field_value(delta: dict[str, Any], field_name: str) -> Any: """Return the raw delta value for a field without falling back to investigation data.""" if not delta: return None custom_fields = _mirror_custom_fields(delta) if field_name in custom_fields: return custom_fields[field_name] if field_name in delta: return delta[field_name] return None def _normalize_outgoing_mirror_compare_value(field_name: str, value: Any, entity_type_suffix: str) -> str: """Normalize a mirror field value for outgoing change comparison.""" if value is None: return "" if isinstance(value, dict): value = value.get("value") or value.get("new") or value.get("old") text = str(value or "").strip() if not text or text in ("-", VEGA_EMPTY_FIELD_DISPLAY): if field_name == VEGA_VERDICT_FIELD: return "NA" return "" if field_name in (VEGA_ALERT_STATUS_FIELD, VEGA_INCIDENT_STATUS_FIELD): return _normalize_vega_status_for_api(text, entity_type_suffix) if field_name in (VEGA_ALERT_SEVERITY_FIELD, VEGA_SEVERITY_FIELD): return _normalize_vega_severity_for_display(text) if field_name == VEGA_VERDICT_FIELD: normalized = text.upper() return VERDICT_DISPLAY_TO_API.get(normalized, normalized) return text def _mirror_field_changed_in_delta(field_name: str, delta: dict[str, Any], entity_type_suffix: str) -> bool: """Return True when delta records a meaningful outgoing change for the field.""" raw = _mirror_delta_raw_field_value(delta, field_name) if raw is None: return False if isinstance(raw, dict) and ("old" in raw or "new" in raw): old_normalized = _normalize_outgoing_mirror_compare_value(field_name, raw.get("old"), entity_type_suffix) new_normalized = _normalize_outgoing_mirror_compare_value(field_name, raw.get("new"), entity_type_suffix) return old_normalized != new_normalized normalized = _normalize_outgoing_mirror_compare_value(field_name, raw, entity_type_suffix) return bool(normalized) def _mirror_delta_changed_value(field_name: str, delta: dict[str, Any], entity_type_suffix: str) -> Any: """Return the new value for a field that changed in delta, or None when unchanged.""" if not _mirror_field_changed_in_delta(field_name, delta, entity_type_suffix): return None raw = _mirror_delta_raw_field_value(delta, field_name) if raw is None: return None return _normalize_mirror_field_value(raw) def _mirror_field_value(field_name: str, delta: dict[str, Any], data: dict[str, Any] | None = None) -> Any: """Read a mirrored field from delta and fall back to the investigation data payload.""" data = data or {} for source in (delta, data): custom_fields = _mirror_custom_fields(source) if field_name in custom_fields: value = _normalize_mirror_field_value(custom_fields.get(field_name)) if value is not None and str(value).strip() != "": return value if field_name in source: value = _normalize_mirror_field_value(source.get(field_name)) if value is not None and str(value).strip() != "": return value return None def _entity_updated_after(entity: dict[str, Any], entity_type_suffix: str, last_update: datetime) -> bool: """Return True when the remote entity was updated after the mirroring cursor.""" if entity_type_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT: updated_at = _parse_entity_created_at(entity.get("lastUpdated") or entity.get("updatedAt")) else: updated_at = _parse_entity_created_at(entity.get("updatedAt")) if updated_at is None: return False return _normalize_fetch_datetime(updated_at) > _normalize_fetch_datetime(last_update) def _format_dbot_mirror_id(entity_type_suffix: str, entity_id: str) -> str: """Format dbotMirrorId with an entity-type prefix for reliable mirror routing.""" normalized_id = str(entity_id).strip() if not normalized_id: return "" if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT: return f"alert:{normalized_id}" if entity_type_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT: return f"incident:{normalized_id}" return normalized_id def _parse_mirror_id(remote_id: str) -> tuple[str, str | None]: """Split legacy prefixed mirror IDs into Vega entity ID and type hint.""" remote_id = str(remote_id).strip() if remote_id.startswith("alert:"): return remote_id[6:], MIRROR_ENTITY_SUFFIX_ALERT if remote_id.startswith("incident:"): return remote_id[9:], MIRROR_ENTITY_SUFFIX_INCIDENT return remote_id, None def _entity_matches_remote_id(entity: dict[str, Any], remote_id: str) -> bool: """Return True when the Vega entity ID matches the mirrored remote ID.""" remote_id = str(remote_id).strip() if not remote_id or not entity: return False candidate_ids = {_normalize_entity_id(entity)} for key in ("id", "incidentId", "alertId", "vegaAlertId"): value = entity.get(key) if value is not None and str(value).strip(): candidate_ids.add(str(value).strip()) candidate_ids.discard("") return remote_id in candidate_ids def _mirror_dict(value: Any) -> dict[str, Any]: """Parse mirror command JSON payloads into dictionaries.""" if isinstance(value, str) and value.strip(): try: parsed = json.loads(value) except (json.JSONDecodeError, TypeError, ValueError): return {} return parsed if isinstance(parsed, dict) else {} return dict(value) if isinstance(value, dict) else {} def _entity_type_from_field_keys(data: dict[str, Any]) -> str | None: """Infer Vega Alert vs Vega Incident from mirrored custom-field keys present in a payload.""" incident_type = str( data.get("type") or data.get("Type") or data.get("incident_type") or data.get("incidentType") or "" ).strip() if incident_type == "Vega Alert": return "Vega Alert" if incident_type == "Vega Incident": return "Vega Incident" custom_fields = _mirror_custom_fields(data) incident_keys = ("vegaincidentid", "vegaincidentstatus", "vegaseverity") alert_keys = ("alertid", "vegaalertid", "vegastatus", "vegaalertseverity") if any(custom_fields.get(key) for key in incident_keys) or any(data.get(key) for key in incident_keys): return "Vega Incident" if any(custom_fields.get(key) for key in alert_keys) or any(data.get(key) for key in alert_keys): return "Vega Alert" return None def _entity_type_from_raw_json(data: dict[str, Any]) -> str | None: """Resolve Vega entity type from a rawJSON field on a mirror payload.""" raw_json = data.get("rawJSON") or data.get("rawJson") if not raw_json: return None try: raw = json.loads(raw_json) if isinstance(raw_json, str) else raw_json except (json.JSONDecodeError, TypeError, ValueError): return None if not isinstance(raw, dict): return None raw_type = str(raw.get("vegaEntityType") or raw.get("type") or raw.get("Type") or "").strip() if raw_type in ("Vega Alert", "Vega Incident"): return raw_type return None def _entity_type_from_mirror_payload(data: dict[str, Any]) -> str | None: """Resolve Vega Alert vs Vega Incident from a mirror payload or investigation dict.""" if not data: return None custom_fields = _mirror_custom_fields(data) vega_incident_id = str(custom_fields.get("vegaincidentid") or "").strip() alert_id = str(custom_fields.get("alertid") or "").strip() if vega_incident_id and not alert_id: return "Vega Incident" if alert_id and not vega_incident_id: return "Vega Alert" entity_type_from_fields = _entity_type_from_field_keys(data) if entity_type_from_fields: return entity_type_from_fields incident_type = str( data.get("type") or data.get("Type") or data.get("incident_type") or data.get("incidentType") or "" ).strip() if incident_type in ("Vega Alert", "Vega Incident"): return incident_type vega_entity_type = str(data.get("vegaEntityType") or "").strip() if vega_entity_type in ("Vega Alert", "Vega Incident"): return vega_entity_type entity_type_from_raw = _entity_type_from_raw_json(data) if entity_type_from_raw: return entity_type_from_raw if custom_fields.get("vegaincidentid"): return "Vega Incident" if custom_fields.get("alertid"): return "Vega Alert" return None def _mirror_entity_type_from_args( args: dict[str, Any], remote_id: str, *, use_investigation_context: bool = True, ) -> str | None: """Resolve Vega Alert vs Vega Incident from mirror command args and investigation context.""" _, hinted_suffix = _parse_mirror_id(remote_id) if hinted_suffix == MIRROR_ENTITY_SUFFIX_ALERT: return "Vega Alert" if hinted_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT: return "Vega Incident" for raw_payload in (args.get("data"), args.get("remoteIncidentData"), args.get("incident"), args.get("delta")): entity_type = _entity_type_from_mirror_payload(_mirror_dict(raw_payload)) if entity_type: return entity_type if use_investigation_context: incident = load_current_incident() entity_type = _entity_type_from_mirror_payload(incident) if entity_type: return entity_type entity_type = _entity_type_from_mirror_payload( { "type": args.get("incidentType") or args.get("type") or args.get("Type") or args.get("incident_type"), } ) if entity_type: return entity_type return None def _fetch_alert_for_mirror_lookup( client: Client, vega_id: str, *, lookup_filters: dict[str, str], require_vega_alert_id: bool = False, ) -> dict[str, Any]: """Fetch and normalize an alert for mirror resolution.""" alert = client.get_alert_for_mirror(vega_id, **lookup_filters) if not alert: alert = client.get_alert_by_id(vega_id, **lookup_filters) normalized = _normalize_alert_api_entity(alert) if alert else {} if not normalized or not _entity_matches_remote_id(normalized, vega_id): return {} if require_vega_alert_id and not _poll_entity_is_alert(normalized): return {} return normalized def _fetch_incident_for_mirror_lookup( client: Client, vega_id: str, *, lookup_filters: dict[str, str], ) -> dict[str, Any]: """Fetch and normalize an incident for mirror resolution.""" incident = client.get_incident_for_mirror(vega_id, **lookup_filters) if not incident: incident = client.get_incident_by_id(vega_id, **lookup_filters) normalized = _normalize_incident_api_entity(incident) if incident else {} if normalized and _entity_matches_remote_id(normalized, vega_id): return normalized return {} def _resolve_preferred_alert_entity( client: Client, vega_id: str, remote_id: str, lookup_filters: dict[str, str], ) -> tuple[dict[str, Any], str]: """Resolve a remote ID when the preferred entity type is Vega Alert.""" normalized = _fetch_alert_for_mirror_lookup(client, vega_id, lookup_filters=lookup_filters) if normalized: return normalized, MIRROR_ENTITY_SUFFIX_ALERT demisto.info(f"Alert not found: remote_id={remote_id}") return {}, "" def _resolve_preferred_incident_entity( client: Client, vega_id: str, remote_id: str, lookup_filters: dict[str, str], ) -> tuple[dict[str, Any], str]: """Resolve a remote ID when the preferred entity type is Vega Incident.""" normalized = _fetch_incident_for_mirror_lookup(client, vega_id, lookup_filters=lookup_filters) if normalized: return normalized, MIRROR_ENTITY_SUFFIX_INCIDENT demisto.info(f"Incident not found: remote_id={remote_id}") return {}, "" def _resolve_ambiguous_remote_entity( client: Client, vega_id: str, remote_id: str, lookup_filters: dict[str, str], ) -> tuple[dict[str, Any], str]: """Resolve a remote ID when the entity type is unknown.""" normalized_alert = _fetch_alert_for_mirror_lookup( client, vega_id, lookup_filters=lookup_filters, require_vega_alert_id=True, ) if normalized_alert: return normalized_alert, MIRROR_ENTITY_SUFFIX_ALERT demisto.info(f"Alert not found or missing vegaAlertId; trying incident lookup: remote_id={remote_id}, vega_id={vega_id}") normalized_incident = _fetch_incident_for_mirror_lookup(client, vega_id, lookup_filters=lookup_filters) if normalized_incident: return normalized_incident, MIRROR_ENTITY_SUFFIX_INCIDENT return {}, "" def _resolve_remote_entity( client: Client, remote_id: str, preferred_entity_type: str | None = None, *, mirror_last_update: str | None = None, ) -> tuple[dict[str, Any], str]: """Resolve a mirrored remote ID to a Vega alert or incident.""" del mirror_last_update vega_id, hinted_suffix = _parse_mirror_id(str(remote_id).strip()) entity_lookup_filters = _resolve_mirror_entity_lookup_filters() if hinted_suffix == MIRROR_ENTITY_SUFFIX_ALERT: preferred_entity_type = "Vega Alert" elif hinted_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT: preferred_entity_type = "Vega Incident" if preferred_entity_type == "Vega Alert": return _resolve_preferred_alert_entity(client, vega_id, remote_id, entity_lookup_filters) if preferred_entity_type == "Vega Incident": return _resolve_preferred_incident_entity(client, vega_id, remote_id, entity_lookup_filters) return _resolve_ambiguous_remote_entity(client, vega_id, remote_id, entity_lookup_filters) def _resolve_mirror_entity_type_suffix( entity_type_suffix: str, preferred_entity_type: str | None, ) -> str: """Apply preferred entity type to the mirror entity suffix.""" if preferred_entity_type == "Vega Incident": return MIRROR_ENTITY_SUFFIX_INCIDENT if preferred_entity_type == "Vega Alert": return MIRROR_ENTITY_SUFFIX_ALERT return entity_type_suffix def _normalize_mirror_entity(entity: dict[str, Any], entity_type_suffix: str) -> dict[str, Any]: """Normalize a mirror entity based on its suffix.""" if entity_type_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT: return _normalize_incident_api_entity(entity) return _normalize_alert_api_entity(entity) #! ---------------------------- MIRROR VEGA TO XSOAR-------------------------------------- def _build_mirror_entity_custom_fields(entity: dict[str, Any], entity_type_suffix: str) -> dict[str, str]: """Build custom fields for a mirrored Vega entity.""" custom_fields: dict[str, str] = {} entity_id = _normalize_entity_id(entity) if entity_id: if entity_type_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT: custom_fields["vegaincidentid"] = entity_id else: custom_fields["alertid"] = entity_id vega_alert_id = entity.get("vegaAlertId") if vega_alert_id and str(vega_alert_id).strip(): custom_fields["vegaalertid"] = str(vega_alert_id).strip() status = entity.get("status") if status is not None and str(status).strip(): status_field = VEGA_ALERT_STATUS_FIELD if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT else VEGA_INCIDENT_STATUS_FIELD custom_fields[status_field] = _normalize_vega_status_for_display(str(status), entity_type_suffix) if entity.get("severity") is not None: severity_field = VEGA_ALERT_SEVERITY_FIELD if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT else VEGA_SEVERITY_FIELD custom_fields[severity_field] = _normalize_vega_severity_for_display(entity.get("severity")) verdict = _extract_vega_verdict_from_entity(entity) if verdict is not None and str(_normalize_incident_verdict_value(verdict)).strip(): custom_fields[VEGA_VERDICT_FIELD] = _normalize_vega_verdict_for_display(verdict) reasoning = _normalize_verdict_reasoning_for_display(entity) if reasoning and reasoning != VEGA_EMPTY_FIELD_DISPLAY: custom_fields[VEGA_VERDICT_REASONING_FIELD] = reasoning if "comments" in entity: custom_fields["vegacomments"] = _format_vega_comments_html(entity.get("comments")) return custom_fields def _build_mirror_sync_object( entity: dict[str, Any], entity_type_suffix: str, remote_id: str | None = None, mirror_context: dict[str, Any] | None = None, preferred_entity_type: str | None = None, ) -> dict[str, Any]: """Build the mirrored object used by XSOAR incoming remote sync.""" entity_type_suffix = _resolve_mirror_entity_type_suffix(entity_type_suffix, preferred_entity_type) entity = _normalize_mirror_entity(entity, entity_type_suffix) entity_id = _normalize_entity_id(entity) vega_entity_type = "Vega Alert" if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT else "Vega Incident" bare_entity_id = str(entity_id).strip() if entity_id else "" parsed_remote_id = str(remote_id).strip() if remote_id else "" vega_id, _ = _parse_mirror_id(parsed_remote_id) if parsed_remote_id else ("", None) effective_remote_id = vega_id or bare_entity_id raw = dict(entity) raw["vegaEntityType"] = vega_entity_type raw.update(_get_mirroring_fields(mirror_context=mirror_context)) _apply_mirror_metadata(raw, entity_type_suffix) _format_raw_entity_for_xsoar(raw) custom_fields = _build_mirror_entity_custom_fields(entity, entity_type_suffix) prefixed_mirror_id = _format_dbot_mirror_id(entity_type_suffix, bare_entity_id) if bare_entity_id else "" sync_object: dict[str, Any] = { "id": bare_entity_id or effective_remote_id, "mirror_id": prefixed_mirror_id or bare_entity_id, "vegaEntityType": vega_entity_type, "CustomFields": custom_fields, } for key in ("status", "severity", "verdict", "verdictReasoning"): value = raw.get(key) if value is not None and str(value).strip() != "": sync_object[key] = value if "vegaComments" in raw: sync_object["vegaComments"] = raw["vegaComments"] _apply_mirror_sync_metadata(sync_object, mirror_context=mirror_context) return sync_object def _build_incoming_status_sync_entries( entity: dict[str, Any], entity_type_suffix: str, last_update: datetime, ) -> list[dict[str, Any]]: """Build close or reopen entries when Vega status changed during incoming mirroring.""" if not _entity_updated_after(entity, entity_type_suffix, last_update): return [] api_status = _normalize_vega_status_for_api(str(entity.get("status") or ""), entity_type_suffix) if api_status in VEGA_CLOSE_STATUSES: return [ { "Type": EntryType.NOTE, "Contents": { "dbotIncidentClose": True, "closeReason": "Resolved", "closeNotes": f"Vega {entity_type_suffix} status changed to {api_status}.", }, "ContentsFormat": EntryFormat.JSON, "Note": True, } ] if api_status == "REOPENED": return [ { "Type": EntryType.NOTE, "Contents": {"dbotIncidentReopen": True}, "ContentsFormat": EntryFormat.JSON, } ] return [] #! ---------------------------- MIRROR VEGA TO XSOAR-------------------------------------- def _normalize_mirror_entries(entries: Any) -> list[dict[str, Any]]: """Parse mirror War Room entries from XSOAR update-remote-system args.""" if entries is None: return [] if isinstance(entries, str): if not entries.strip(): return [] try: entries = json.loads(entries) except (json.JSONDecodeError, TypeError, ValueError): demisto.info("Failed to parse entries JSON") return [] if isinstance(entries, dict): return [entries] if not isinstance(entries, list): return [] return [entry for entry in entries if isinstance(entry, dict)] def _collect_outgoing_entry_comments(entries: list[dict[str, Any]]) -> list[str]: """Collect War Room note texts that should be mirrored to Vega.""" comments: list[str] = [] for entry in entries: if not isinstance(entry, dict): continue entry_type = entry.get("type") if entry.get("type") is not None else entry.get("Type") if entry_type != EntryType.NOTE: continue tags = entry.get("tags") or entry.get("Tags") or [] if VEGA_MIRROR_TAG_FROM_VEGA in tags or VEGA_MIRROR_TAG_TO_VEGA in tags: continue contents = entry.get("contents") if contents is None: contents = entry.get("Contents") if contents is None: continue text = str(contents).strip() if text: comments.append(text) return comments def _poll_modified_mirror_page( response: dict[str, Any], entities_key: str, modified_ids: list[str], bare_ids: set[str], poll_source: str, offset: int, ) -> tuple[int, bool]: """Process one modified-remote-data poll page and return whether pagination should continue.""" entities = response.get(entities_key) or [] for entity in entities: bare_id = _append_modified_remote_entity_id(modified_ids, entity, poll_source=poll_source) if bare_id: bare_ids.add(bare_id) if not entities: return 0, False total = response.get("total") if total is not None and offset + len(entities) >= int(total): return len(entities), False if len(entities) < GET_MODIFIED_REMOTE_DATA_LIMIT: return len(entities), False return len(entities), True def _poll_modified_alerts_for_mirror( client: Client, updated_from: str, modified_ids: list[str], alert_bare_ids: set[str], ) -> None: """Poll updated alerts and collect modified mirror IDs.""" offset = 0 try: while True: response = ( client.get_alerts( updated_from=updated_from, limit=GET_MODIFIED_REMOTE_DATA_LIMIT, offset=offset, ) or {} ) api_error = response.get("error") if isinstance(api_error, dict) and api_error.get("message"): demisto.info(f"Alert poll API error: error={str(api_error.get('message'))}") page_count, should_continue = _poll_modified_mirror_page( response, "alerts", modified_ids, alert_bare_ids, MIRROR_ENTITY_SUFFIX_ALERT, offset, ) if not should_continue: break offset += page_count except Exception as exc: demisto.info(f"Alert poll failed: error={str(exc)}") def _poll_modified_incidents_for_mirror( client: Client, updated_from: str, updated_to: str, modified_ids: list[str], incident_bare_ids: set[str], ) -> None: """Poll updated incidents and collect modified mirror IDs.""" offset = 0 try: while True: response = ( client.get_incidents( updated_from=updated_from, updated_to=updated_to, limit=GET_MODIFIED_REMOTE_DATA_LIMIT, offset=offset, ) or {} ) page_count, should_continue = _poll_modified_mirror_page( response, "incidents", modified_ids, incident_bare_ids, MIRROR_ENTITY_SUFFIX_INCIDENT, offset, ) if not should_continue: break offset += page_count except Exception as exc: demisto.info(f"Incident poll failed: error={str(exc)}") def _finalize_modified_mirror_ids( modified_ids: list[str], alert_bare_ids: set[str], incident_bare_ids: set[str], ) -> list[str]: """Drop ambiguous bare IDs shared by alerts and incidents.""" shared_bare_ids = alert_bare_ids & incident_bare_ids finalized_ids = list(modified_ids) for bare_id in sorted(alert_bare_ids | incident_bare_ids): if bare_id in shared_bare_ids: demisto.info(f"Skipping ambiguous bare mirror id shared by alert and incident: remote_id={bare_id}") continue if bare_id not in finalized_ids: finalized_ids.append(bare_id) return finalized_ids def get_modified_remote_data_command(client: Client, args: dict[str, Any]) -> GetModifiedRemoteDataResponse: """Return Vega alert and incident IDs updated since the last mirroring run.""" remote_args = GetModifiedRemoteDataArgs(args) vega_entities = argToList(demisto.params().get("vega_entities") or ["Alerts", "Incidents"]) fetch_alerts = "Alerts" in vega_entities fetch_incidents = "Incidents" in vega_entities demisto.info( f"Command started: fetch_alerts={fetch_alerts}, fetch_incidents={fetch_incidents}, " f"last_update={remote_args.last_update}" ) modified_ids: list[str] = [] alert_bare_ids: set[str] = set() incident_bare_ids: set[str] = set() if fetch_alerts: _poll_modified_alerts_for_mirror( client, _resolve_mirror_updated_from(remote_args.last_update), modified_ids, alert_bare_ids, ) if fetch_incidents: _poll_modified_incidents_for_mirror( client, _resolve_mirror_updated_from(remote_args.last_update), _resolve_mirror_updated_to(), modified_ids, incident_bare_ids, ) finalized_ids = _finalize_modified_mirror_ids(modified_ids, alert_bare_ids, incident_bare_ids) demisto.info(f"Command finished: total_ids={len(finalized_ids)}, ") return GetModifiedRemoteDataResponse(finalized_ids) def _resolve_remote_entity_for_mirror( client: Client, remote_id: str, args: dict[str, Any], last_update: str | None, ) -> tuple[dict[str, Any], str, str | None, str | None]: """Resolve a remote entity for get-remote-data, enforcing investigation context when needed.""" preferred_entity_type = _mirror_entity_type_from_args(args, remote_id) entity, entity_type_suffix = _resolve_remote_entity( client, remote_id, preferred_entity_type, mirror_last_update=last_update, ) investigation_context = load_current_incident() context_entity_type = _entity_type_from_mirror_payload(investigation_context) if context_entity_type: context_suffix = MIRROR_ENTITY_SUFFIX_INCIDENT if context_entity_type == "Vega Incident" else MIRROR_ENTITY_SUFFIX_ALERT if entity_type_suffix != context_suffix: preferred_entity_type = context_entity_type entity, entity_type_suffix = _resolve_remote_entity( client, remote_id, preferred_entity_type, mirror_last_update=last_update, ) if (preferred_entity_type == "Vega Incident" and entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT) or ( preferred_entity_type == "Vega Alert" and entity_type_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT ): entity, entity_type_suffix = _resolve_remote_entity( client, remote_id, preferred_entity_type, mirror_last_update=last_update, ) return entity, entity_type_suffix, preferred_entity_type, context_entity_type def _build_not_found_mirror_response( remote_id: str, preferred_entity_type: str | None, context_entity_type: str | None, ) -> GetRemoteDataResponse: """Build a get-remote-data response when the remote entity was not found.""" vega_id, hinted_suffix = _parse_mirror_id(remote_id) mirror_key = vega_id or remote_id effective_entity_type = preferred_entity_type or context_entity_type prefixed_mirror_id = mirror_key if effective_entity_type and mirror_key: mirror_suffix = MIRROR_ENTITY_SUFFIX_INCIDENT if effective_entity_type == "Vega Incident" else MIRROR_ENTITY_SUFFIX_ALERT prefixed_mirror_id = _format_dbot_mirror_id(mirror_suffix, mirror_key) elif hinted_suffix and mirror_key: prefixed_mirror_id = _format_dbot_mirror_id(hinted_suffix, mirror_key) not_found: dict[str, Any] = { "id": mirror_key, "mirror_id": prefixed_mirror_id, } if effective_entity_type: not_found["vegaEntityType"] = effective_entity_type _apply_mirror_sync_metadata(not_found) return GetRemoteDataResponse(mirrored_object=not_found, entries=[]) def _enrich_mirror_incident_entity(client: Client, entity: dict[str, Any], last_update: str | None) -> dict[str, Any]: """Fetch additional incident details needed for incoming mirror sync.""" entity_id = _normalize_entity_id(entity) if not entity_id: return entity incident_lookup_filters = _resolve_mirror_incident_lookup_filters(last_update) details = client.get_incident_by_id(entity_id, **incident_lookup_filters) if not isinstance(details, dict) or not details: return entity for key in ("verdictReasoning", "verdict", "comments", "status", "severity", "lastUpdated", "userVerdict"): if key in details and details[key] is not None: entity[key] = details[key] return _normalize_incident_api_entity(entity) def _build_incoming_mirror_comment_entries(entity: dict[str, Any], last_update_dt: datetime) -> list[dict[str, Any]]: """Build incoming mirror war room entries for new Vega comments.""" entries: list[dict[str, Any]] = [] comments = entity.get("comments") if not isinstance(comments, list): return entries for comment in comments: if not isinstance(comment, dict) or _is_empty_vega_comment_text(comment.get("text")): continue added_at = _parse_entity_created_at(comment.get("addedAt")) if added_at is None or _normalize_fetch_datetime(added_at) <= _normalize_fetch_datetime(last_update_dt): continue author = _format_comment_author(comment.get("addedBy")) timestamp = _format_comment_display_timestamp(comment.get("addedAt")) comment_text = str(comment.get("text") or "").strip() entries.append( _build_comment_war_room_entry( f"{author} ({timestamp}): {comment_text}", tags=[VEGA_MIRROR_TAG_FROM_VEGA], ) ) return entries def _build_get_remote_data_error_response(remote_id: str, args: dict[str, Any], exc: Exception) -> GetRemoteDataResponse: """Build a get-remote-data error response that preserves mirror metadata.""" vega_id, _ = _parse_mirror_id(remote_id) mirror_key = vega_id or remote_id preferred_entity_type = _mirror_entity_type_from_args(args, remote_id) error_object: dict[str, Any] = { "id": mirror_key, "in_mirror_error": str(exc), } if preferred_entity_type: error_object["vegaEntityType"] = preferred_entity_type error_object["mirror_id"] = mirror_key _apply_mirror_sync_metadata(error_object) return GetRemoteDataResponse(mirrored_object=error_object, entries=[]) def get_remote_data_command( client: Client, args: dict[str, Any], integration_url: str | None = None, ) -> GetRemoteDataResponse: """Fetch updated Vega alert or incident data and incoming mirror entries.""" del integration_url remote_args = GetRemoteDataArgs(args) remote_id = remote_args.remote_incident_id last_update_dt = _parse_mirror_last_update(remote_args.last_update) demisto.info(f"Command started: remote_id={remote_id}, last_update={remote_args.last_update}") try: entity, entity_type_suffix, preferred_entity_type, context_entity_type = _resolve_remote_entity_for_mirror( client, remote_id, args, remote_args.last_update, ) if not entity: demisto.info(f"Remote entity not found: remote_id={remote_id}") return _build_not_found_mirror_response(remote_id, preferred_entity_type, context_entity_type) if entity_type_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT: entity = _enrich_mirror_incident_entity(client, entity, remote_args.last_update) mirror_context = ( _mirror_dict(args.get("data")) or _mirror_dict(args.get("remoteIncidentData")) or _mirror_dict(args.get("incident")) ) mirrored_object = _build_mirror_sync_object( entity, entity_type_suffix, remote_id=remote_id, mirror_context=mirror_context or None, preferred_entity_type=preferred_entity_type, ) entries = _build_incoming_mirror_comment_entries(entity, last_update_dt) entries.extend(_build_incoming_status_sync_entries(entity, entity_type_suffix, last_update_dt)) return GetRemoteDataResponse(mirrored_object=mirrored_object, entries=entries) except Exception as exc: demisto.info(f"Command failed: remote_id={remote_id}, error={str(exc)}") return _build_get_remote_data_error_response(remote_id, args, exc) def _parse_outgoing_mirror_inc_status(parsed_args: UpdateRemoteSystemArgs) -> int | None: """Parse the XSOAR investigation status from outgoing mirror args.""" if parsed_args.inc_status is None or parsed_args.inc_status == "": return None try: return int(parsed_args.inc_status) except (TypeError, ValueError): return None def _build_outgoing_alert_mirror_update( delta: dict[str, Any], data: dict[str, Any], inc_status: int | None, enabled_fields: set[str] | None = None, ) -> dict[str, Any]: """Build the outgoing mirror update payload for a Vega alert.""" del data update_input: dict[str, Any] = {} entity_type_suffix = MIRROR_ENTITY_SUFFIX_ALERT active_fields = enabled_fields if enabled_fields is not None else _resolve_outgoing_mirror_fields() if OUTGOING_MIRROR_FIELD_STATUS in active_fields: if inc_status == IncidentStatus.DONE: update_input["status"] = "RESOLVED" else: status = _mirror_delta_changed_value(VEGA_ALERT_STATUS_FIELD, delta, entity_type_suffix) if status is not None and str(status).strip(): update_input["status"] = _validate_alert_status_value(str(status)) if OUTGOING_MIRROR_FIELD_VERDICT in active_fields: verdict = _mirror_delta_changed_value(VEGA_VERDICT_FIELD, delta, entity_type_suffix) if verdict is not None and str(verdict).strip(): update_input["verdict"] = _validate_verdict_value(str(verdict)) if OUTGOING_MIRROR_FIELD_SEVERITY in active_fields: severity = _mirror_delta_changed_value(VEGA_ALERT_SEVERITY_FIELD, delta, entity_type_suffix) if severity is not None and str(severity).strip(): update_input["severity"] = _validate_severity_value(str(severity)) if OUTGOING_MIRROR_FIELD_VERDICT_REASONING in active_fields: verdict_reasoning = _mirror_delta_changed_value(VEGA_VERDICT_REASONING_FIELD, delta, entity_type_suffix) if verdict_reasoning is not None and str(verdict_reasoning).strip(): update_input["verdictReasoning"] = str(verdict_reasoning).strip() if OUTGOING_MIRROR_FIELD_COMMENTS in active_fields: comment = _outgoing_mirror_comment_value(_mirror_delta_changed_value(VEGA_NEW_COMMENT_FIELD, delta, entity_type_suffix)) if comment is not None: update_input["comment"] = comment return update_input def _build_outgoing_incident_mirror_update( delta: dict[str, Any], data: dict[str, Any], inc_status: int | None, enabled_fields: set[str] | None = None, ) -> dict[str, Any]: """Build the outgoing mirror update payload for a Vega incident.""" update_input: dict[str, Any] = {} entity_type_suffix = MIRROR_ENTITY_SUFFIX_INCIDENT active_fields = enabled_fields if enabled_fields is not None else _resolve_outgoing_mirror_fields() mirror_verdict = OUTGOING_MIRROR_FIELD_VERDICT in active_fields mirror_verdict_reasoning = OUTGOING_MIRROR_FIELD_VERDICT_REASONING in active_fields if OUTGOING_MIRROR_FIELD_STATUS in active_fields: if inc_status == IncidentStatus.DONE: update_input["status"] = "RESOLVED" else: status = _mirror_delta_changed_value(VEGA_INCIDENT_STATUS_FIELD, delta, entity_type_suffix) if status is None or not str(status).strip(): status = _mirror_delta_changed_value(VEGA_ALERT_STATUS_FIELD, delta, entity_type_suffix) if status is not None and str(status).strip(): update_input["status"] = _validate_incident_status_value(str(status)) if OUTGOING_MIRROR_FIELD_SEVERITY in active_fields: severity = _mirror_delta_changed_value(VEGA_SEVERITY_FIELD, delta, entity_type_suffix) if severity is not None and str(severity).strip(): update_input["severity"] = _validate_severity_value(str(severity)) if mirror_verdict or mirror_verdict_reasoning: verdict = _mirror_delta_changed_value(VEGA_VERDICT_FIELD, delta, entity_type_suffix) verdict_reasoning = _mirror_delta_changed_value(VEGA_VERDICT_REASONING_FIELD, delta, entity_type_suffix) if mirror_verdict and verdict is not None and str(verdict).strip(): update_input["verdict"] = { "value": _validate_verdict_value(str(verdict)), "reasoning": str(verdict_reasoning or "") if mirror_verdict_reasoning else "", } elif mirror_verdict_reasoning and verdict_reasoning is not None and str(verdict_reasoning).strip(): current_verdict = _mirror_field_value(VEGA_VERDICT_FIELD, {}, data) update_input["verdict"] = { "value": _validate_verdict_value(str(current_verdict or "NA")), "reasoning": str(verdict_reasoning).strip(), } if OUTGOING_MIRROR_FIELD_COMMENTS in active_fields: comment = _outgoing_mirror_comment_value(_mirror_delta_changed_value(VEGA_NEW_COMMENT_FIELD, delta, entity_type_suffix)) if comment is not None: update_input["comment"] = comment return update_input def _push_outgoing_mirror_entity_update( client: Client, entity_type_suffix: str, vega_id: str, remote_id: str, update_input: dict[str, Any], ) -> None: """Push outgoing mirror field updates to Vega.""" if not update_input: entity_label = "alert" if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT else "incident" demisto.info(f"No {entity_label} fields to push: remote_id={remote_id}") return if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT: client.update_alerts({**update_input, "alertIds": [vega_id]}) else: client.update_incidents({**update_input, "incidentIds": [vega_id]}) def _mirror_outgoing_war_room_comments( client: Client, mirror_entries: list[dict[str, Any]], entity_type_suffix: str, vega_id: str, remote_id: str, enabled_fields: set[str] | None = None, ) -> None: """Mirror eligible War Room comments to Vega.""" active_fields = enabled_fields if enabled_fields is not None else _resolve_outgoing_mirror_fields() if OUTGOING_MIRROR_FIELD_COMMENTS not in active_fields: demisto.info(f"War Room comments filtered by outgoing mirror field config: remote_id={remote_id}") return for comment in _collect_outgoing_entry_comments(mirror_entries): demisto.info(f"Mirroring War Room comment to Vega: remote_id={remote_id}") if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT: client.update_alerts({"alertIds": [vega_id], "comment": comment}) else: client.update_incidents({"incidentIds": [vega_id], "comment": comment}) def _resolve_outgoing_mirror_entity( client: Client, args: dict[str, Any], remote_id: str, delta: dict[str, Any], data: dict[str, Any], ) -> tuple[dict[str, Any], str, str | None]: """Resolve the Vega entity targeted by an outgoing mirror update.""" mirror_args = dict(args) if data: mirror_args["data"] = data if delta: mirror_args["delta"] = delta preferred_entity_type = _mirror_entity_type_from_args(mirror_args, remote_id, use_investigation_context=True) demisto.info( f"Resolved mirror context: remote_id={remote_id}, entity_type={preferred_entity_type}, " f"delta_keys={','.join(sorted(delta.keys())) or 'none'}" ) mirror_last_update = str(args.get("lastUpdate") or args.get("last_update") or "").strip() or None entity, entity_type_suffix = _resolve_remote_entity( client, remote_id, preferred_entity_type, mirror_last_update=mirror_last_update, ) if (preferred_entity_type == "Vega Alert" and entity_type_suffix == MIRROR_ENTITY_SUFFIX_INCIDENT) or ( preferred_entity_type == "Vega Incident" and entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT ): demisto.info( f"Re-resolving entity with preferred type: remote_id={remote_id}, " f"preferred_entity_type={preferred_entity_type}, resolved_as={entity_type_suffix}" ) entity, entity_type_suffix = _resolve_remote_entity( client, remote_id, preferred_entity_type, mirror_last_update=mirror_last_update, ) return entity, entity_type_suffix, preferred_entity_type def update_remote_system_command(client: Client, args: dict[str, Any]) -> str: """Push XSOAR investigation changes to Vega when outgoing mirroring is enabled.""" parsed_args = UpdateRemoteSystemArgs(args) remote_id = parsed_args.remote_incident_id or "" if not remote_id: demisto.info("Command skipped; no remote ID") return remote_id demisto.info( f"Command started: remote_id={remote_id}, incident_changed={parsed_args.incident_changed}, " f"entry_count={len(parsed_args.entries or [])}" ) if not _is_xsoar_to_vega_mirroring_enabled(): demisto.info(f"Outgoing mirroring disabled; skipping: remote_id={remote_id}") return remote_id if not _mirror_bool(parsed_args.incident_changed) and not parsed_args.entries: demisto.info(f"No changes to mirror; skipping: remote_id={remote_id}") return remote_id delta = _mirror_dict(parsed_args.delta) data = _mirror_dict(parsed_args.data) mirror_entries = _normalize_mirror_entries(parsed_args.entries) vega_id, _ = _parse_mirror_id(remote_id) inc_status = _parse_outgoing_mirror_inc_status(parsed_args) enabled_fields = _resolve_outgoing_mirror_fields() try: entity, entity_type_suffix, _ = _resolve_outgoing_mirror_entity(client, args, remote_id, delta, data) if not entity: demisto.info(f"Remote entity not found: remote_id={remote_id}") return remote_id if _mirror_bool(parsed_args.incident_changed): if entity_type_suffix == MIRROR_ENTITY_SUFFIX_ALERT: update_input = _build_outgoing_alert_mirror_update(delta, data, inc_status, enabled_fields) else: update_input = _build_outgoing_incident_mirror_update(delta, data, inc_status, enabled_fields) _push_outgoing_mirror_entity_update(client, entity_type_suffix, vega_id, remote_id, update_input) _mirror_outgoing_war_room_comments( client, mirror_entries, entity_type_suffix, vega_id, remote_id, enabled_fields, ) demisto.info(f"Command finished: remote_id={remote_id}") except Exception as exc: demisto.info(f"Command failed: remote_id={remote_id}, error={str(exc)}, error_type={type(exc).__name__}") return remote_id def get_mapping_fields_command() -> GetMappingFieldsResponse: """Return outgoing mirroring fields for Vega Alert and Vega Incident investigations.""" mapping_response = GetMappingFieldsResponse() alert_scheme = SchemeTypeMapping(type_name="Vega Alert") for field_name, descriptions in VEGA_OUTGOING_MIRROR_FIELDS["Vega Alert"].items(): alert_scheme.add_field(name=field_name, description=descriptions[0]) mapping_response.add_scheme_type(alert_scheme) incident_scheme = SchemeTypeMapping(type_name="Vega Incident") for field_name, descriptions in VEGA_OUTGOING_MIRROR_FIELDS["Vega Incident"].items(): incident_scheme.add_field(name=field_name, description=descriptions[0]) mapping_response.add_scheme_type(incident_scheme) return mapping_response def _handle_fetch_entity_error(entity_label: str, exc: Exception) -> None: """Log transient fetch failures without advancing cursor; re-raise permanent errors.""" if _is_retryable_http_error(exc): demisto.error( f"Vega {entity_label} fetch skipped due to a transient API error after retries: {exc}. " f"The {entity_label} cursor was not advanced and will retry on the next fetch cycle." ) return raise exc def _fetch_filters_changed(last_run: dict, fetch_config_key: str, fetch_config: str) -> bool: """Return True when fetch filters changed since the previous run.""" previous_config = last_run.get(fetch_config_key) return previous_config is not None and previous_config != fetch_config def _clear_fetch_pagination_state(next_run: dict, offset_key: str, pagination_from_key: str) -> None: """Drop saved offset pagination for an entity type.""" next_run.pop(offset_key, None) next_run.pop(pagination_from_key, None) def _batch_fetch_last_run( last_run: dict, next_run: dict, *, offset_key: str, pagination_from_key: str, fetch_config_key: str, fetch_config: str, ) -> dict: """Build the last_run view used for API pagination. When fetch filters change, discard stale offset state so the next query uses the stored cursor timestamp instead of an old backfill window. """ if not _fetch_filters_changed(last_run, fetch_config_key, fetch_config): return last_run _clear_fetch_pagination_state(next_run, offset_key, pagination_from_key) batch_run = dict(last_run) batch_run.pop(offset_key, None) batch_run.pop(pagination_from_key, None) return batch_run def _is_resuming_fetch_pagination( last_run: dict, offset_key: str, fetch_config_key: str, fetch_config: str, ) -> bool: """Return True only when continuing offset pagination for the same filter set.""" return last_run.get(offset_key) is not None and not _fetch_filters_changed(last_run, fetch_config_key, fetch_config) def validate_max_fetch(max_fetch: int | str | None) -> None: """Validate that max_fetch is an integer between 1 and 50 inclusive.""" parsed = arg_to_number(max_fetch, arg_name="max_fetch", required=False) if parsed is None or parsed < MAX_FETCH_MIN or parsed > MAX_FETCH_CAP: raise ValueError(MAX_FETCH_ERROR) def _resolve_max_fetch(max_fetch: int | str | None) -> int: """Return the combined per-fetch limit, defaulting to 50 when the value is invalid.""" try: validate_max_fetch(max_fetch) except ValueError: return DEFAULT_MAX_FETCH return int(arg_to_number(max_fetch, arg_name="max_fetch", required=False)) # type: ignore[arg-type] def _fetch_vega_entity_batch( last_run: dict, next_run: dict, *, offset_key: str, pagination_from_key: str, default_from_time: str, limit: int, fetch_func: Callable[..., dict], entities_key: str, **fetch_kwargs: Any, ) -> list[dict]: """Fetch up to `limit` Vega entities and store offset state when more remain.""" saved_offset = last_run.get(offset_key) offset = int(saved_offset) if saved_offset is not None else 0 from_time = str(last_run.get(pagination_from_key) or default_from_time) if offset > 0 else default_from_time entities, next_offset = _fetch_paginated_entities( fetch_func, entities_key=entities_key, max_entities=limit, start_offset=offset, from_time=from_time, **fetch_kwargs, ) if next_offset is not None: next_run[offset_key] = next_offset next_run[pagination_from_key] = from_time else: next_run.pop(offset_key, None) next_run.pop(pagination_from_key, None) return entities def _fetch_incident_timeline_events(client: Client, incident_id: str) -> list[dict]: """Fetch timeline events for a Vega incident.""" try: timeline_result = client.get_incident_timeline(str(incident_id)) fetched_events = timeline_result.get("events") if isinstance(fetched_events, list): return [event for event in fetched_events if isinstance(event, dict)] except Exception: return [] return [] def _ingest_fetched_incidents( client: Client, last_run: dict, next_run: dict, xsoar_incidents: list[dict], *, incidents_from_time: str, incidents_last_fetch: str, incidents_last_ids: list[str], incidents_fetch_config: str, incident_severities: list[str] | None, incident_statuses: list[str] | None, incident_verdicts: list[str] | None, limit: int, backfill_days: str | int | None = None, lookback_minutes: int = DEFAULT_LOOKBACK_MINUTES, ) -> int: try: batch_last_run = _batch_fetch_last_run( last_run, next_run, offset_key=INCIDENTS_OFFSET_KEY, pagination_from_key=INCIDENTS_PAGINATION_FROM_KEY, fetch_config_key="incidents_fetch_config", fetch_config=incidents_fetch_config, ) incidents = _fetch_vega_entity_batch( batch_last_run, next_run, offset_key=INCIDENTS_OFFSET_KEY, pagination_from_key=INCIDENTS_PAGINATION_FROM_KEY, default_from_time=incidents_from_time, limit=limit, fetch_func=client.get_incidents, entities_key="incidents", severities=incident_severities, statuses=incident_statuses, verdicts=incident_verdicts, ) previous_ids = set(incidents_last_ids) new_ids: list[str] = [] duplicate_ids: list[str] = [] ingested: list[dict] = [] parsed_last_fetch = _parse_entity_created_at(incidents_last_fetch) max_created_at_dt = _normalize_fetch_datetime(parsed_last_fetch) if parsed_last_fetch else None current_cycle_ids = list(next_run.get("incidents_last_ids", [])) for incident in incidents: incident_id = _normalize_entity_id(incident) if not incident_id: continue current_cycle_ids.append(incident_id) created_at_raw = _parse_entity_created_at(incident.get("createdAt")) if created_at_raw: dt = _normalize_fetch_datetime(created_at_raw) if max_created_at_dt is None or dt > max_created_at_dt: max_created_at_dt = dt if incident_id in previous_ids: duplicate_ids.append(incident_id) continue new_ids.append(incident_id) timeline_events = _fetch_incident_timeline_events(client, incident_id) if incident_id else [] xsoar_incidents.append(incident_to_xsoar_incident(incident, timeline_events=timeline_events)) ingested.append(incident) next_run["incidents_last_ids"] = list(set(current_cycle_ids)) if new_ids and max_created_at_dt: next_run["incidents_last_fetch"] = _format_fetch_timestamp(max_created_at_dt) elif not new_ids: next_run["incidents_last_fetch"] = _current_fetch_timestamp() next_run["incidents_fetch_config"] = incidents_fetch_config return len(ingested) except Exception as exc: _handle_fetch_entity_error("incidents", exc) return 0 def _ingest_fetched_alerts( client: Client, last_run: dict, next_run: dict, xsoar_incidents: list[dict], *, alerts_from_time: str, alerts_last_fetch: str, alerts_last_ids: list[str], alerts_fetch_config: str, alert_severities: list[str] | None, alert_statuses: list[str] | None, alert_verdicts: list[str] | None, has_related_incidents: bool | None, integration_url: str | None, limit: int, backfill_days: str | int | None = None, lookback_minutes: int = DEFAULT_LOOKBACK_MINUTES, ) -> int: try: batch_last_run = _batch_fetch_last_run( last_run, next_run, offset_key=ALERTS_OFFSET_KEY, pagination_from_key=ALERTS_PAGINATION_FROM_KEY, fetch_config_key="alerts_fetch_config", fetch_config=alerts_fetch_config, ) alerts = _fetch_vega_entity_batch( batch_last_run, next_run, offset_key=ALERTS_OFFSET_KEY, pagination_from_key=ALERTS_PAGINATION_FROM_KEY, default_from_time=alerts_from_time, limit=limit, fetch_func=client.get_alerts, entities_key="alerts", severities=alert_severities, statuses=alert_statuses, verdicts=alert_verdicts, has_related_incidents=has_related_incidents, ) previous_ids = set(alerts_last_ids) new_ids: list[str] = [] duplicate_ids: list[str] = [] ingested: list[dict] = [] parsed_last_fetch = _parse_entity_created_at(alerts_last_fetch) max_created_at_dt = _normalize_fetch_datetime(parsed_last_fetch) if parsed_last_fetch else None current_cycle_ids = list(next_run.get("alerts_last_ids", [])) for alert in alerts: alert_id = _normalize_entity_id(alert) if not alert_id: continue current_cycle_ids.append(alert_id) created_at_raw = _parse_entity_created_at(alert.get("createdAt")) if created_at_raw: dt = _normalize_fetch_datetime(created_at_raw) if max_created_at_dt is None or dt > max_created_at_dt: max_created_at_dt = dt if alert_id in previous_ids: duplicate_ids.append(alert_id) continue new_ids.append(alert_id) xsoar_incidents.append(alert_to_incident(alert, integration_url=integration_url, client=client)) ingested.append(alert) next_run["alerts_last_ids"] = list(set(current_cycle_ids)) if new_ids and max_created_at_dt: next_run["alerts_last_fetch"] = _format_fetch_timestamp(max_created_at_dt) elif not new_ids: next_run["alerts_last_fetch"] = _current_fetch_timestamp() next_run["alerts_fetch_config"] = alerts_fetch_config return len(ingested) except Exception as exc: _handle_fetch_entity_error("alerts", exc) return 0 def fetch_incidents_command( client: Client, last_run: dict, fetch_alerts: bool, fetch_incidents: bool, alert_severities: list[str] | None, alert_statuses: list[str] | None, alert_verdicts: list[str] | None, has_related_incidents: bool | None, incident_severities: list[str] | None, incident_statuses: list[str] | None, incident_verdicts: list[str] | None, first_fetch_time: str, backfill_days: str | int | None = None, integration_url: str | None = None, max_fetch: int = DEFAULT_MAX_FETCH, lookback_minutes: int = DEFAULT_LOOKBACK_MINUTES, ) -> tuple[dict, list[dict]]: xsoar_incidents: list[dict] = [] next_run: dict = dict(last_run) next_run.pop("alerts_seen_ids", None) next_run.pop("incidents_seen_ids", None) next_run.pop("vega_backfill_days", None) alerts_fetch_config = _build_fetch_filter_fingerprint( alert_severities, alert_statuses, alert_verdicts, has_related_incidents, ) incidents_fetch_config = _build_fetch_filter_fingerprint(incident_severities, incident_statuses, incident_verdicts) remaining = max_fetch working_run = last_run incidents_touched = False alerts_touched = False def _get_from_time(last_fetch_key: str) -> str: last_fetch = working_run.get(last_fetch_key) if last_fetch: return _apply_lookback_to_from_time(last_fetch, lookback_minutes) return first_fetch_time incidents_last_fetch = working_run.get("incidents_last_fetch") or first_fetch_time alerts_last_fetch = working_run.get("alerts_last_fetch") or first_fetch_time if fetch_incidents and remaining > 0 and working_run.get(INCIDENTS_OFFSET_KEY) is not None: incidents_from_time = _get_from_time("incidents_last_fetch") ingested_incidents = _ingest_fetched_incidents( client, working_run, next_run, xsoar_incidents, incidents_from_time=incidents_from_time, incidents_last_fetch=incidents_last_fetch, incidents_last_ids=working_run.get("incidents_last_ids", []), incidents_fetch_config=incidents_fetch_config, incident_severities=incident_severities, incident_statuses=incident_statuses, incident_verdicts=incident_verdicts, limit=remaining, backfill_days=backfill_days, lookback_minutes=lookback_minutes, ) remaining -= ingested_incidents working_run = next_run incidents_touched = True if ( fetch_alerts and remaining > 0 and working_run.get(INCIDENTS_OFFSET_KEY) is None and working_run.get(ALERTS_OFFSET_KEY) is not None ): alerts_from_time = _get_from_time("alerts_last_fetch") ingested_alerts = _ingest_fetched_alerts( client, working_run, next_run, xsoar_incidents, alerts_from_time=alerts_from_time, alerts_last_fetch=alerts_last_fetch, alerts_last_ids=working_run.get("alerts_last_ids", []), alerts_fetch_config=alerts_fetch_config, alert_severities=alert_severities, alert_statuses=alert_statuses, alert_verdicts=alert_verdicts, has_related_incidents=has_related_incidents, integration_url=integration_url, limit=remaining, backfill_days=backfill_days, lookback_minutes=lookback_minutes, ) remaining -= ingested_alerts working_run = next_run alerts_touched = True if fetch_incidents and remaining > 0 and not incidents_touched and next_run.get(INCIDENTS_OFFSET_KEY) is None: incidents_from_time = _get_from_time("incidents_last_fetch") ingested_incidents = _ingest_fetched_incidents( client, working_run, next_run, xsoar_incidents, incidents_from_time=incidents_from_time, incidents_last_fetch=incidents_last_fetch, incidents_last_ids=working_run.get("incidents_last_ids", []), incidents_fetch_config=incidents_fetch_config, incident_severities=incident_severities, incident_statuses=incident_statuses, incident_verdicts=incident_verdicts, limit=remaining, backfill_days=backfill_days, lookback_minutes=lookback_minutes, ) remaining -= ingested_incidents working_run = next_run if ( fetch_alerts and remaining > 0 and not alerts_touched and next_run.get(INCIDENTS_OFFSET_KEY) is None and next_run.get(ALERTS_OFFSET_KEY) is None ): alerts_from_time = _get_from_time("alerts_last_fetch") _ingest_fetched_alerts( client, working_run, next_run, xsoar_incidents, alerts_from_time=alerts_from_time, alerts_last_fetch=alerts_last_fetch, alerts_last_ids=working_run.get("alerts_last_ids", []), alerts_fetch_config=alerts_fetch_config, alert_severities=alert_severities, alert_statuses=alert_statuses, alert_verdicts=alert_verdicts, has_related_incidents=has_related_incidents, integration_url=integration_url, limit=remaining, backfill_days=backfill_days, lookback_minutes=lookback_minutes, ) return next_run, xsoar_incidents def test_module( client: Client, backfill_days: str | int | None = None, max_fetch: int | str | None = None, lookback_minutes: int | str | None = None, ): try: validate_max_fetch(max_fetch) validate_lookback_minutes(lookback_minutes) client.test_connection(backfill_days) return "ok" except Exception as e: return str(e) def _parse_vega_integration_params(params: dict[str, Any]) -> dict[str, Any]: """Parse Vega integration instance parameters used across commands.""" vega_entities = argToList(params.get("vega_entities") if params.get("vega_entities") is not None else ["Alerts", "Incidents"]) backfill_days = params.get("backfill_days") return { "base_url": params.get("url"), "access_key": params.get("access_key", {}).get("password").strip(), "access_key_id": params.get("access_key_id", {}).get("password").strip(), "verify_certificate": not argToBoolean(params.get("insecure", False)), "proxy": argToBoolean(params.get("proxy", False)), "fetch_alerts": "Alerts" in vega_entities, "fetch_incidents": "Incidents" in vega_entities, "alert_severities": filter_alert_severities(argToList(params.get("alert_severities")) or None), "alert_statuses": filter_alert_statuses(argToList(params.get("alert_statuses")) or None), "alert_verdicts": filter_alert_verdicts(argToList(params.get("alert_verdicts")) or None), "has_related_incidents": resolve_has_related_incidents(argToList(params.get("alert_has_related_incidents"))), "incident_severities": filter_incident_severities(argToList(params.get("incident_severities")) or None), "incident_statuses": filter_incident_statuses(argToList(params.get("incident_statuses")) or None), "incident_verdicts": filter_incident_verdicts(argToList(params.get("incident_verdicts")) or None), "backfill_days": backfill_days, "first_fetch_time": parse_backfill_days(backfill_days), "max_fetch": _resolve_max_fetch(params.get("max_fetch")), "lookback_minutes": _parse_lookback_minutes(params.get("lookback_minutes")), } def _build_vega_client(config: dict[str, Any]) -> Client: """Create a Vega API client from parsed integration parameters.""" return Client( base_url=config["base_url"], verify=config["verify_certificate"], proxy=config["proxy"], access_key=config["access_key"], access_key_id=config["access_key_id"], ) def _dispatch_vega_command(client: Client, command: str, config: dict[str, Any]) -> None: """Route a Vega integration command to its handler.""" command_handlers: dict[str, Callable[..., None]] = { "test-module": lambda: return_results( test_module( client, config["backfill_days"], demisto.params().get("max_fetch"), demisto.params().get("lookback_minutes") ) ), "vega-get-alert-events": lambda: return_results(fetch_alert_events_command(client, demisto.args())), "vega-set-detections-state": lambda: return_results(set_detections_state_command(client, demisto.args())), "vega-update-detections": lambda: return_results(update_detections_command(client, demisto.args())), "vega-update-alert": lambda: return_results(update_alert_command(client, demisto.args())), "vega-update-incident": lambda: return_results(update_incident_command(client, demisto.args())), "get-remote-data": lambda: return_results( get_remote_data_command(client, demisto.args(), integration_url=config["base_url"]) ), "get-modified-remote-data": lambda: return_results(get_modified_remote_data_command(client, demisto.args())), "update-remote-system": lambda: return_results(update_remote_system_command(client, demisto.args())), "get-mapping-fields": lambda: return_results(get_mapping_fields_command()), } if command == "fetch-incidents": last_run = demisto.getLastRun() next_run, xsoar_incidents = fetch_incidents_command( client=client, last_run=last_run, fetch_alerts=config["fetch_alerts"], fetch_incidents=config["fetch_incidents"], alert_severities=config["alert_severities"], alert_statuses=config["alert_statuses"], alert_verdicts=config["alert_verdicts"], has_related_incidents=config["has_related_incidents"], incident_severities=config["incident_severities"], incident_statuses=config["incident_statuses"], incident_verdicts=config["incident_verdicts"], first_fetch_time=config["first_fetch_time"], backfill_days=config["backfill_days"], integration_url=config["base_url"], max_fetch=config["max_fetch"], lookback_minutes=config["lookback_minutes"], ) demisto.setLastRun(next_run) demisto.incidents(xsoar_incidents) return handler = command_handlers.get(command) if handler is None: raise NotImplementedError(f"Command {command} is not implemented") handler() def main() -> None: command = demisto.command() _suppress_noisy_http_integration_logs() try: config = _parse_vega_integration_params(demisto.params()) client = _build_vega_client(config) _dispatch_vega_command(client, command, config) except Exception as e: return_error(f"Failed to execute {command} command.\nError:\n{str(e)}") if __name__ in ("__main__", "__builtin__", "builtins"): main()