Elasticsearch v2

Search for and analyze data in real time. Supports version 6 and later.

Database · Elasticsearch

Details

IDElasticsearch v2
ProviderElastic
CategoryDatabase
From Version5.0.0
Docker Imagedemisto/elasticsearch:1.0.0.11195141
Supported ModulesAgentix Cloud Runtime Security XSIAM EDR Cortex Cloud Attack Surface Management Cloud Posture Security Exposure Management

README

Elasticsearch_v2 integration is used to search for and analyze data in real time.
Supports version 6 and later.
This integration was integrated and tested with versions 6.6.2, 7.3, 8.4.1 of Elasticsearch.

Permissions

The permissions required to use this integration depends on which operations you need to perform. The API user should have the same permissions a regular user would have in order to access the data via the UI. Following are the permissions needed for certain commands:

  • !es-eql-search/search/es-search/fetch-incidents - If the Elasticsearch security features are enabled, you must have the read index privilege for the target data stream, index, or alias.
  • !get-mapping-fields - If the Elasticsearch security features are enabled, you must have the view_index_metadata or manage index privilege for the target data stream, index, or alias.
  • !es-index - If the Elasticsearch security features are enabled, you must have the write index privilege for the target data stream, index, or alias.
  • !es-kibana-* commands - To execute Kibana commands, you must have the necessary privileges for the applicable resource. The privilege required depends on the command you are using. Kibana API endpoints are gated by feature privileges granted at one of two levels: Read (GET / list / view operations) and All (POST / PUT / PATCH / DELETE operations). For rules and cases, the privilege is scoped to the feature that owns the object — a Security-owned object requires the Security feature privilege, an Observability-owned object requires the Observability feature privilege, and a stack-owned object requires the Management / Stack Rules feature privilege.

Configure Elasticsearch v2 in Cortex

Parameter Description Required
Server URL The Elasticsearch server to which the integration connects. Ensure that the URL includes the correct Elasticsearch port. The default port for Elasticsearch v7 and below is 9200. Use the Server URL for on-premises deployments. False
Kibana Server URL The Kibana server used by the es-kibana-* commands and by fetching and mirroring security alerts and cases. Required for on-premises (self-managed) deployments, where Kibana is usually hosted separately from Elasticsearch (for example, https://kibana.example.com:5601). If left empty, the Kibana URL is derived from the Server URL, which is supported only for Elastic Cloud deployments whose hostname contains “.es.” (the segment is replaced with “.kb.”). False
Authorization type Select the authentication type and enter the appropriate credentials:
- Basic Auth: Enter Username and Password.
- Bearer Auth: Enter Username and Password.
- API Key Auth: Enter the API Key ID and API Key.
True
API key ID Use for API key auth False
API Key Use for API key auth False
Username Use for API Key auth. Optionally you can use Username as an API key ID and Password as an API key for Basic auth. False
Password Use for API Key auth. Optionally you can use Username as an API key ID and Password as an API key for Basic auth. False
Trust any certificate (not secure)   False
Use system proxy settings   False
Client type In some hosted ElasticSearch environments, the standard ElasticSearch client is not supported. If you encounter any related client issues, please consider using the OpenSearch client type. False
Index to fetch incidents from CSV False
Query String Query string uses the Lucene syntax.
For more information about the Lucene syntax see here.
The query will be used when fetching incidents. Index time field will be used as a filter in the query.

The integration test button doesn’t fully test the fetch incidents validity. To verify that the instance is set up correctly for fetching incidents, run the ‘!es-integration-health-check’ command.
False
Fields to fetch (_source already included) A comma-separated list of Elasticsearch fields to retrieve in addition to the default _source fields. For example: host.hostname, host.id. False
Index time field The time field used for sorting and limiting results. If using a nested field, separate field names with dot notation. False
Raw Query Raw Query allows raw DSL queries and will override the ‘Query String’ Lucene syntax string.
Results will not be filtered or sorted. Use additional parameters in the raw query for these purposes.
For more information about Query DSL see here.

The integration test button doesn’t fully test the fetch incidents validity. To verify that the instance is set up correctly for fetching incidents, run the ‘!es-integration-health-check’ command.
False
Time field type 3 formats supported:
Simple-Date - A plain date string. You must specify the format in which the date is stored.
For more information about time formatting, see here.
Timestamp-Second - A numeric value representing the number of seconds since the Unix epoch (00:00:00 UTC on 1 January 1970). Example: ‘1572164838’
* Timestamp-Milliseconds - A numeric value representing the number of milliseconds since the Unix epoch. Example: ‘1572164838123’
False
Map JSON fields into labels   False
First fetch timestamp <number> <time unit>, e.g., 12 hours, 7 days False
The maximum number of results per fetch   False
Request timeout (in seconds).   False
Incident type   False
Fetch incidents   False
Space ID The default Kibana space ID to use for es-kibana-* commands. Used to derive the Kibana base URL from the Server URL. If a space_id argument is provided to a command, it overrides this value. See Spaces for more information. False
Fetch incident types The type of incident to fetch from Elasticsearch (Elasticsearch Entity, Elasticsearch Security Alert, or Elasticsearch Case). False
Incident Mirroring Direction Mirroring works only for the Elasticsearch Security Alert and Elasticsearch Case incident types. False
Fetch cases by Severity Case severities to fetch. If none are chosen, all severity levels will be returned. Relevant only for incident type Elasticsearch Case. False
Fetch cases by Status Case statuses to fetch. If none are chosen, in-progress and open will be returned. Relevant only for incident type Elasticsearch Case. False
Fetch alerts for case When enabled, fetches alerts associated with each Elasticsearch Case. You must have read privileges for the Cases feature in the Management, Observability, or Security section of the Kibana feature privileges. False
Close Mirrored XSOAR Incident Whether closing the Elasticsearch alert or case is mirrored in Cortex XSOAR. False
Close Mirrored Elasticsearch Incident Whether closing the Cortex XSOAR incident is mirrored in Elasticsearch. False

Fetch

Use the Fetch incident types parameter to select what to fetch from Elasticsearch: the default Elasticsearch Entity, Elasticsearch Security Alert, or Elasticsearch Case.

Mirroring is only available for Elasticsearch Security Alerts and Cases.

Fetch

The default fetch behavior. Configure the Index to fetch incidents from, Query String, and/or Raw Query fields to fetch generic Elasticsearch documents as incidents.

Fetch Security Alerts

To fetch security alerts, use the Raw Query field (DSL query). The index must follow one of these patterns: .internal.alerts-security.alerts-* or .siem-signals-*.

Fetch Cases

Use the Fetch cases by Severity parameter to filter cases by the required severity and the Fetch cases by Status parameter to filter cases by the required status. Enable Fetch alerts for case to also retrieve the alerts associated with each case.

Incident Mirroring

You can enable incident mirroring between Cortex XSOAR incidents and Elasticsearch (Kibana) Security Alerts and Cases.

Mirroring is only available for Elasticsearch Security Alert and Elasticsearch Case incident types.

To set up mirroring:

  1. Enable Fetch incidents in your instance configuration.
  2. In the Fetch incident types parameter, select Elasticsearch Security Alert or Elasticsearch Case.
  3. In the Incident Mirroring Direction parameter, select in which direction the incidents should be mirrored:
    • Incoming - Changes in Elasticsearch (status, tags, workflow reason, case fields) are reflected in Cortex XSOAR.
    • Outgoing - Changes in Cortex XSOAR are reflected in Elasticsearch.
    • Incoming And Outgoing - Changes are mirrored in both directions.
  4. Optionally, select Close Mirrored XSOAR Incident to close the Cortex XSOAR incident when the Elasticsearch alert/case is closed, and Close Mirrored Elasticsearch Incident to close the Elasticsearch alert/case when the Cortex XSOAR incident is closed.

Notes:

  • The mirroring is affected by the following integration parameters: Incident Mirroring Direction, Close Mirrored XSOAR Incident, and Close Mirrored Elasticsearch Incident.
  • To ensure mirroring works as expected, mappers are required so that the fields are properly mapped to and from Elasticsearch. Select the appropriate incoming and outgoing mappers per incident type (Elasticsearch Security Alert - Incoming/Outgoing Mapper and Elasticsearch Case - Incoming/Outgoing Mapper).
  • Set the Fetch incident types parameter even when mirroring only. It is used to resolve the incident type when the remote lookup is unavailable; without it, changes may not be mirrored out.
  • Mirroring out always uses the Kibana API, because the security alert indices are Kibana system indices and writing to them directly would skip Kibana’s bookkeeping (such as kibana.alert.workflow_status_updated_at, which mirroring in relies on). The configured credentials therefore need the Security → Alerts: All Kibana privilege, and the Space ID must match the space that owns the alerts. If either is wrong, the fetch keeps working while mirroring out reports that 0 alerts were updated.

Which Security Alert fields can be mirrored

Elasticsearch only allows a detection alert’s workflow status, status reason, and workflow tags to be changed. These are the only fields mirrored out:

Cortex XSOAR incident field Elasticsearch field
Elasticsearch Workflow Alert Status kibana.alert.workflow_status
Elasticsearch Workflow Alert Status Reason kibana.alert.workflow_reason
Tags kibana.alert.workflow_tags

Other fields, such as severity, risk score, or the detection rule, cannot be pushed back to Elasticsearch because no API exists to change them on an existing alert. Editing those fields in Cortex XSOAR updates the local incident only, and an error is written to the log explaining that nothing was mirrored out. These fields are still mirrored in (from Elasticsearch to Cortex XSOAR).

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.

es-search


Queries an index.

Base Command

es-search

Input

Argument Name Description Required
index The index in which to perform a search. Required
query The string to query (in Lucene syntax). Possible values are: . Optional
fields A comma-separated list of document fields to fetch. If empty, the entire document is fetched. Optional
explain Calculates an explanation of a score for a query. For example, “value:1.6943597”. Possible values are: true, false. Default is false. Optional
page The page number from which to start a search. Default is 0. Optional
size The number of documents displayed per page. Can be an integer between “1” and “10,000”. Default is 100. Optional
sort-field The field by which to sort the results table. The supported result types are boolean, numeric, date, and keyword fields. Keyword fields require the doc_values parameter to be set to “true” from the Elasticsearch server. Possible values are: . Optional
sort-order The order by which to sort the results table. The results tables can only be sorted if a sort-field is defined. Possible values are: asc, desc. Default is asc. Optional
query_dsl Will overwrite the ‘query’ arguments. Optional
timestamp_range_start The starting time of the time range. Optional
timestamp_range_end The ending time of the time range. Optional
timestamp_field Timestamp field name. Default is @timestamp. Optional

Context Output

Path Type Description
Elasticsearch.Search.Results._index String The index to which the document belongs.
Elasticsearch.Search.Results._id String The ID of the document.
Elasticsearch.Search.Results._type String The mapping type of the document.
Elasticsearch.Search.max_score Number The maximum relevance score of a query.
Elasticsearch.Search.Query String The query performed in the search.
Elasticsearch.Search.total.value Number The number of search results.
Elasticsearch.Search.Results._score Number The relevance score of the search result.
Elasticsearch.Search.Index String The index in which the search was performed.
Elasticsearch.Search.Server String The server on which the search was performed.
Elasticsearch.Search.timed_out Boolean Whether the search stopped due to a timeout.
Elasticsearch.Search.took Number The time in milliseconds taken for the search to complete.
Elasticsearch.Search.Page Number The page number from which the search started.
Elasticsearch.Search.Size Number The maximum number of scores that a search can return.

search


Searches an index.

Base Command

search

Input

Argument Name Description Required
index The index in which to perform a search. Required
query The string to query (in Lucene syntax). Possible values are: . Optional
fields A comma-separated list of document fields to fetch. If empty, fetches the entire document. Optional
explain Calculates an explanation of a score for a query. For example, “value:1.6943597”. Possible values are: true, false. Default is false. Optional
page The page number from which to start a search. Default is 0. Optional
size The number of documents displayed per page. Can be an integer between “1” and “10,000”. Default is 100. Optional
sort-field The field by which to sort the results table. The supported result types are boolean, numeric, date, and keyword fields. Keyword fields require the doc_values parameter to be set to “true” from the Elasticsearch server. Possible values are: . Optional
sort-order The order by which to sort the results table. The results tables can only be sorted if a sort-field is defined. Possible values are: asc, desc. Default is asc. Optional
timestamp_field Timestamp field name. Default is @timestamp. Optional

Context Output

Path Type Description
Elasticsearch.Search.Results._index String The index to which the document belongs.
Elasticsearch.Search.Results._id String The ID of the document.
Elasticsearch.Search.Results._type String The mapping type of the document.
Elasticsearch.Search.max_score Number The maximum relevance score of a query.
Elasticsearch.Search.Query String The query performed in the search.
Elasticsearch.Search.total.value Number The number of search results.
Elasticsearch.Search.Results._score Number The relevance score of the search result.
Elasticsearch.Search.Index String The index in which the search was performed.
Elasticsearch.Search.Server String The server on which the search was performed.
Elasticsearch.Search.timed_out Boolean Whether the search stopped due to a time out.
Elasticsearch.Search.took Number The time in milliseconds taken for the search to complete.
Elasticsearch.Search.Page Number The page number from which the search started.
Elasticsearch.Search.Size Number The maximum number of scores that a search can return.

get-mapping-fields


Returns the schema of the index to fetch from. This commmand should be used for debugging purposes.

Base Command

get-mapping-fields

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

es-eql-search


Search using EQL query

Base Command

es-eql-search

Input

Argument Name Description Required
index The index in which to perform a search. Required
query The string to query (in Lucene syntax). Required
fields A comma-separated list of document fields to fetch. If empty, fetches the entire document. Optional
sort-tiebreaker If two or more events share the same timestamp, Elasticsearch uses a tiebreaker field value to sort the events in ascending order. Optional
filter Filter using query DSL. Optional
event_category_field The event category field. Default is event.category. Optional
size The number of documents displayed per page. Can be an integer between “1” and “10,000”. Default is 100. Optional
timestamp_range_start The starting time of the time range. Optional
timestamp_range_end The ending time of the time range. Optional
timestamp_field Timestamp field name. Default is @timestamp. Optional

Context Output

Path Type Description
Elasticsearch.Search.Results._index String The index to which the document belongs.
Elasticsearch.Search.Results._id String The ID of the document.
Elasticsearch.Search.Results._type String The mapping type of the document.
Elasticsearch.Search.max_score Number The maximum relevance score of a query.
Elasticsearch.Search.Query String The query performed in the search.
Elasticsearch.Search.total.value Number The number of search results.
Elasticsearch.Search.Results._score Number The relevance score of the search result.
Elasticsearch.Search.Index String The index in which the search was performed.
Elasticsearch.Search.Server String The server on which the search was performed.
Elasticsearch.Search.timed_out Boolean Whether the search stopped due to a timeout.
Elasticsearch.Search.took Number The time in milliseconds taken for the search to complete.
Elasticsearch.Search.Page Number The page number from which the search started.
Elasticsearch.Search.Size Number The maximum number of scores that a search can return.

es-index


Indexes a document into an Elasticsearch index.

Base Command

es-index

Input

Argument Name Description Required
index_name The name of the index to ingest into. Required
document The document object (JSON format) to be indexed. See Elasticsearch documentation for further information about indexing documents via the Elasticsearch client. Required
id The ID of the indexed document (will be generated if empty). The document will be updated if one with a corresponding ID exists. Optional

Context Output

Path Type Description
Elasticsearch.Index.id String The ID of the indexed document.
Elasticsearch.Index.index String The name of the index which the document was ingested to.
Elasticsearch.Index.version Number The version number of the indexed document.
Elasticsearch.Index.result String The result of the index operation.

Command Example

!es-index index_name=test-xsoar document="{\"name\":\"test\"}" id=1234

Context Example

{
    "Elasticsearch": {
        "Index": {
            "id": "1234",
            "index": "test-xsoar",
            "version": 1,
            "result": "created"
        }
    }
}

Human Readable Output

Indexed document

ID Index name Version Result
1234 test-xsoar 1 created

es-integration-health-check


Returns the health status of the integration. This commmand should be used for debugging purposes.

Base Command

es-integration-health-check

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

es-get-indices-statistics


Returns Elasticsearch indices statistics and information. This command is not supported for client type OpenSearch.

Base Command

es-get-indices-statistics

Input

Argument Name Description Required
limit The maximum number of indices to return. Default is 50. Optional
all_results Whether to retrieve all the Elasticsearch indices. If true, the “limit” argument will be ignored. Optional

Context Output

Path Type Description
Elasticsearch.IndexStatistics.Name String The name of the index.
Elasticsearch.IndexStatistics.Status String The status of the index.
Elasticsearch.IndexStatistics.Health String The health status of the index.
Elasticsearch.IndexStatistics.UUID String The UUID of the index.
Elasticsearch.IndexStatistics.DocumentsCount Number The number of documents that are indexed in the index.
Elasticsearch.IndexStatistics.DocumentsDeleted Number The number of documents that were deleted from the index.

es-esql-search


Search using ES|QL query (Elasticsearch 8.11 and above).

Base Command

es-esql-search

Input

Argument Name Description Required  
query The ES|QL query string to execute using piped syntax (for example, FROM index WHERE field == “value”). Required
limit Maximum number of results to return. Optional  

Context Output

Path Description Type
Elasticsearch.ESQLSearch ES|QL search result unknown

Command Example

!es-esql-search query="FROM logs-* | WHERE host.name == \"web-01\" | LIMIT 5"

Context Example

{
    "Elasticsearch": {
        "ESQLSearch": [
            {
                "@timestamp": "2024-01-15T10:23:45.000Z",
                "host.name": "web-01",
                "message": "Connection established"
            }
        ]
    }
}

Human Readable Output

Search query

Query Total
FROM logs-* | WHERE host.name == “web-01” | LIMIT 5 1

Results

@timestamp host.name message
2024-01-15T10:23:45.000Z web-01 Connection established

es-kibana-alerting-health-get


Retrieves the health of the Kibana alerting framework.

Base Command

es-kibana-alerting-health-get

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.AlertingHealth.is_sufficiently_secure Boolean Whether the alerting framework is sufficiently secure.
Elasticsearch.Kibana.AlertingHealth.has_permanent_encryption_key Boolean Whether a permanent encryption key is configured.
Elasticsearch.Kibana.AlertingHealth.alerting_framework_health.decryption_health.status String The decryption health status.
Elasticsearch.Kibana.AlertingHealth.alerting_framework_health.execution_health.status String The execution health status.
Elasticsearch.Kibana.AlertingHealth.alerting_framework_health.read_health.status String The read health status.

Command Example


#### Context Example

```json
{
    "Elasticsearch": {
        "Kibana": {
            "AlertingHealth": {
                "is_sufficiently_secure": true,
                "has_permanent_encryption_key": true,
                "alerting_framework_health": {
                    "decryption_health": {"status": "ok"},
                    "execution_health": {"status": "ok"},
                    "read_health": {"status": "ok"}
                }
            }
        }
    }
}

Human Readable Output

Kibana Alerting Framework Health

Is sufficiently secure Has permanent encryption key Decryption status Execution status Read status
true true ok ok ok

es-kibana-rule-types-list


Retrieves all rule types available in Kibana.

Base Command

es-kibana-rule-types-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.RuleType.id String The rule type ID.
Elasticsearch.Kibana.RuleType.name String The rule type name.
Elasticsearch.Kibana.RuleType.category String The rule type category.
Elasticsearch.Kibana.RuleType.producer String The rule type producer.
Elasticsearch.Kibana.RuleType.action_groups.id String The action group ID.

Command Example


#### Context Example

```json
{
    "Elasticsearch": {
        "Kibana": {
            "RuleType": [
                {
                    "id": ".index-threshold",
                    "name": "Index threshold",
                    "category": "management",
                    "producer": "stackAlerts",
                    "action_groups": [{"id": "threshold met"}, {"id": "recovered"}]
                }
            ]
        }
    }
}

Human Readable Output

Kibana Rule Types

Rule type ID Name Category Producer Action Group Id
.index-threshold Index threshold management stackAlerts threshold met, recovered

es-kibana-rule-list


Retrieves information about rules.

Base Command

es-kibana-rule-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
rule_id The identifier for the rule. Optional
search An Elasticsearch simple_query_string query that filters the objects in the response. Optional
default_search_operator The default operator to use for the simple_query_string. Optional
search_fields The fields to perform the simple_query_string parsed query against. Optional
sort_field Determines which field is used to sort the results. Optional
sort_order Determines the sort order. Optional
has_reference_id Filters the rules that have a relation with the reference objects with a specific identifier. Optional
has_reference_type Filters the rules that have a relation with the reference objects with a specific type. Optional
fields The fields to return in the attributes key of the response. Optional
filter A KQL string that you filter with an attribute from your saved object. Optional
filter_consumers List of consumers to filter. Optional
page The page number from which to start a search. Optional
size The number of rules to return per page. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Rule.id String The rule ID.
Elasticsearch.Kibana.Rule.enabled Boolean Whether the rule is enabled.
Elasticsearch.Kibana.Rule.name String The rule name.
Elasticsearch.Kibana.Rule.rule_type_id String The rule type ID.
Elasticsearch.Kibana.Rule.created_at Date The creation date of the rule.

Command Example

!es-kibana-rule-list rule_id="1234"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Rule": {
                "id": "1234",
                "enabled": true,
                "name": "CPU threshold alert",
                "rule_type_id": ".index-threshold",
                "created_at": "2024-01-10T08:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Rules

Rule ID Enabled Name Type ID Creation date
1234 true CPU threshold alert .index-threshold 2024-01-10T08:00:00.000Z

es-kibana-rule-enable


Enable a rule.

Base Command

es-kibana-rule-enable

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
rule_id The identifier for the rule. Required

Context Output

There is no context output for this command.

Command Example

!es-kibana-rule-enable rule_id="1234"

Context Example

{}

Human Readable Output

The rule 1234 has been successfully enabled.

es-kibana-rule-disable


Disable a rule.

Base Command

es-kibana-rule-disable

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
rule_id The identifier for the rule. Required

Context Output

There is no context output for this command.

Command Example

!es-kibana-rule-disable rule_id="1234"

Context Example

{}

Human Readable Output

The rule 1234 has been successfully disabled.

es-kibana-rule-update


Update a rule.

Base Command

es-kibana-rule-update

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
rule_id The identifier for the rule. Required
alert_delay_active The number of consecutive runs that must meet the rule conditions. Optional
artifacts_dashboards_id Not more than 10 elements. Optional
artifacts_investigation_guide_blob Maximum length is 10000. Optional
consumer The name of the application or feature that owns the rule. Optional
enabled Indicates whether you want the rule to run on an interval basis after it is created. Optional
flapping_enabled Determines whether the rule can enter the flapping state. Optional
flapping_look_back_window The minimum number of runs in which the threshold must be met. Minimum value is 2, maximum value is 20. Optional
flapping_status_change_threshold The minimum number of times an alert must switch states within the defined look back window time. Optional
name The name of the rule. Optional
notify_when Indicates how frequently rule actions are triggered. Optional
schedule_interval The interval is specified in seconds, minutes, hours, or days. Optional
tags The tags for the rule. Optional
entry_id Entry ID for the file containing the request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Rule.id String The rule ID.

Command Example

!es-kibana-rule-update rule_id="1234" name="Updated CPU alert" schedule_interval="5m"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Rule": {
                "id": "1234",
                "name": "Updated CPU alert",
                "enabled": true,
                "rule_type_id": ".index-threshold",
                "created_at": "2024-01-10T08:00:00.000Z"
            }
        }
    }
}

Human Readable Output

The rule 1234 has been successfully changed.

Rule ID Changed fields
1234 name, schedule

es-kibana-rule-alert-mute


Mute an alert, or mute all alerts for a rule.

Base Command

es-kibana-rule-alert-mute

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
rule_id The identifier for the rule. Required
alert_id The identifier for the alert. Optional
validate_alerts_existence Whether to validate the existence of the alert. Optional
mute_all Whether to mute all alerts. Optional

Context Output

There is no context output for this command.

Command Example

!es-kibana-rule-alert-mute rule_id="1234" mute_all="true"

Context Example

{}

Human Readable Output

The alerts have been successfully muted.

es-kibana-rule-alert-unmute


Unmute an alert, or unmute all alerts for a rule.

Base Command

es-kibana-rule-alert-unmute

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
rule_id The identifier for the rule. Required
alert_id The identifier for the alert. Optional
unmute_all Whether to unmute all alerts. Optional

Context Output

There is no context output for this command.

Command Example

!es-kibana-rule-alert-unmute rule_id="1234" unmute_all="true"

Context Example

{}

Human Readable Output

The alerts have been successfully unmuted.

es-kibana-detection-alert-status-set


Set the status of one or more detection alerts.

Base Command

es-kibana-detection-alert-status-set

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
status The new status of the detection alert(s). Required
signal_ids List of alert IDs. Use field _id on alert document or kibana.alert.uuid. Optional
query An Elasticsearch query used to select which alerts to update, as an alternative to signal_ids. Optional
reason The reason for the status change. Optional
conflicts Determines how version conflicts should be handled. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.SecurityAlertSetStatus.total Number The total number of alerts matched by the request.
Elasticsearch.Kibana.SecurityAlertSetStatus.updated Number The number of alerts that were updated.

Command Example

!es-kibana-detection-alert-status-set status="closed" signal_ids="1234,5678"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "SecurityAlertSetStatus": {
                "total": 2,
                "updated": 2
            }
        }
    }
}

Human Readable Output

Kibana Detection Alert Status Update

Total Updated
2 2

es-kibana-case-create


Creates a new case in Kibana.

Base Command

es-kibana-case-create

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
assignee_uid An array containing users that are assigned to the case. Not more than 10 elements. Optional
category A word or phrase that categorizes the case. Maximum length is 50. Optional
connector_fields A JSON object containing the connector fields. To create a case without a connector, specify null. Optional
connector_id The identifier for the connector. To create a case without a connector, use none. Optional
connector_name The name of the connector. To create a case without a connector, use none. Optional
connector_type The type of connector. To create a case without a connector, use none. Optional
connector_fields_issue_type_jira The type of issue. Optional
connector_fields_parent_jira The key of the parent issue, when the issue type is sub-task. Optional
connector_fields_priority_jira The priority of the issue. Optional
connector_fields_issue_types_resilient The type of incident. Optional
connector_fields_severity_code_resilient The severity code of the incident. Optional
connector_fields_category_servicenow The category of the incident. Optional
connector_fields_impact_servicenow The effect an incident had on business. Optional
connector_fields_severity_servicenow The severity of the incident. Optional
connector_fields_subcategory_servicenow The subcategory of the incident. Optional
connector_fields_urgency_servicenow The extent to which the incident resolution can be delayed. Optional
connector_fields_dest_ip_servicenow Indicates whether cases will send a comma-separated list of destination IPs. Optional
connector_fields_malware_hash_servicenow Indicates whether cases will send a comma-separated list of malware hashes. Optional
connector_fields_malware_url_servicenow Indicates whether cases will send a comma-separated list of malware URLs. Optional
connector_fields_priority_servicenow The priority of the issue. Optional
connector_fields_source_ip_servicenow Indicates whether cases will send a comma-separated list of source IPs. Optional
connector_fields_case_id_swimlane The case identifier for Swimlane connectors. Optional
custom_key The unique identifier for the custom field. The key value must exist in the case configuration settings. Optional
custom_type The custom field type. It must match the type specified in the case configuration settings. Optional
custom_value The custom field value (string or boolean). Optional
description The description for the case. Maximum length is 30000. Optional
owner The application that owns the cases: Stack Management, Observability, or Elastic Security. Required
extract_observables When true, observables (e.g. IPs, hashes, URLs) are automatically extracted from case comments. Optional
sync_alerts Turns alert syncing on or off. Optional
severity The severity of the case. Optional
tags The words and phrases that help categorize cases. Not more than 200 elements. Optional
title A title for the case. Maximum length is 160. Optional
entry_id Entry ID for the file containing the full request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Case.title String The title of the case.
Elasticsearch.Kibana.Case.id String The case ID.
Elasticsearch.Kibana.Case.description String The description of the case.
Elasticsearch.Kibana.Case.owner String The owner of the case.
Elasticsearch.Kibana.Case.severity String The severity of the case.
Elasticsearch.Kibana.Case.status String The status of the case.
Elasticsearch.Kibana.Case.created_at Date The creation date of the case.
Elasticsearch.Kibana.Case.connector.type String The connector type of the case.

Command Example

!es-kibana-case-create owner="securitySolution" title="Suspicious login" description="Multiple failed logins detected" severity="medium"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Case": {
                "id": "1234",
                "title": "Suspicious login",
                "description": "Multiple failed logins detected",
                "owner": "securitySolution",
                "severity": "medium",
                "status": "open",
                "created_at": "2024-01-15T10:00:00.000Z",
                "connector": {"type": ".none"}
            }
        }
    }
}

Human Readable Output

Kibana Case

Title ID Description Owner Severity Status Created at Connector type
Suspicious login 1234 Multiple failed logins detected securitySolution medium open 2024-01-15T10:00:00.000Z .none

es-kibana-case-update


Update cases in Kibana.

Base Command

es-kibana-case-update

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The identifier for the case. Required
version The current version of the case. To determine this value, use es-kibana-case-list. Required
assignee_uid An array containing users that are assigned to the case. Not more than 10 elements. Optional
category A word or phrase that categorizes the case. Maximum length is 50. Optional
close_reason The close reason to sync to attached alerts when closing the case. Optional
connector_fields A JSON object containing the connector fields. To remove a connector, specify null. Optional
connector_id The identifier for the connector. To remove a connector, specify none. Optional
connector_name The name of the connector. To remove a connector, specify none. Optional
connector_type The type of connector. To remove a connector, specify none. Optional
connector_fields_issue_type_jira The type of issue. Optional
connector_fields_parent_jira The key of the parent issue, when the issue type is sub-task. Optional
connector_fields_priority_jira The priority of the issue. Optional
connector_fields_issue_types_resilient The type of incident. Optional
connector_fields_severity_code_resilient The severity code of the incident. Optional
connector_fields_category_servicenow The category of the incident. Optional
connector_fields_impact_servicenow The effect an incident had on business. Optional
connector_fields_severity_servicenow The severity of the incident. Optional
connector_fields_subcategory_servicenow The subcategory of the incident. Optional
connector_fields_urgency_servicenow The extent to which the incident resolution can be delayed. Optional
connector_fields_dest_ip_servicenow Indicates whether cases will send a comma-separated list of destination IPs. Optional
connector_fields_malware_hash_servicenow Indicates whether cases will send a comma-separated list of malware hashes. Optional
connector_fields_malware_url_servicenow Indicates whether cases will send a comma-separated list of malware URLs. Optional
connector_fields_priority_servicenow The priority of the issue. Optional
connector_fields_source_ip_servicenow Indicates whether cases will send a comma-separated list of source IPs. Optional
connector_fields_case_id_swimlane The case identifier for Swimlane connectors. Optional
custom_key The unique identifier for the custom field. The key value must exist in the case configuration settings. Optional
custom_type The custom field type. It must match the type specified in the case configuration settings. Optional
custom_value The custom field value (string or boolean). Optional
description The description for the case. Maximum length is 30000. Optional
extract_observables When true, observables (e.g. IPs, hashes, URLs) are automatically extracted from case comments. Optional
sync_alerts Turns alert syncing on or off. Optional
severity The severity of the case. Optional
status The status of the case. Optional
tags The words and phrases that help categorize cases. Not more than 200 elements. Optional
title A title for the case. Maximum length is 160. Optional
entry_id Entry ID for the file containing the request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Case.title String The title of the case.
Elasticsearch.Kibana.Case.id String The case ID.
Elasticsearch.Kibana.Case.description String The description of the case.
Elasticsearch.Kibana.Case.owner String The owner of the case.
Elasticsearch.Kibana.Case.severity String The severity of the case.
Elasticsearch.Kibana.Case.status String The status of the case.
Elasticsearch.Kibana.Case.created_at Date The creation date of the case.
Elasticsearch.Kibana.Case.connector.type String The connector type of the case.

Command Example

!es-kibana-case-update case_id="1234" version="WzEsMV0=" title="Suspicious login - updated" severity="high"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Case": {
                "id": "1234",
                "title": "Suspicious login - updated",
                "description": "Multiple failed logins detected",
                "owner": "securitySolution",
                "severity": "high",
                "status": "open",
                "created_at": "2024-01-15T10:00:00.000Z",
                "connector": {"type": ".none"}
            }
        }
    }
}

Human Readable Output

Kibana Case(s) Updated

Title ID Description Owner Severity Status Created at Connector type
Suspicious login - updated 1234 Multiple failed logins detected securitySolution high open 2024-01-15T10:00:00.000Z .none

es-kibana-case-delete


Deletes one or more cases by ID.

Base Command

es-kibana-case-delete

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The cases that you want to remove. To get the case identifiers, use es-kibana-case-list. Required

Context Output

There is no context output for this command.

Command Example

!es-kibana-case-delete case_id="1234"

Context Example

{}

Human Readable Output

The cases 1234 have been successfully deleted.

es-kibana-case-list


Retrieves the details of Kibana cases.

Base Command

es-kibana-case-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The identifier for the case. To retrieve case IDs, use the search cases API (leave empty). Optional
assignees Filters the returned cases by assignees. Valid values are none or unique identifiers for the user profiles. Optional
category Filters the returned cases by category. Optional
default_search_operator The default operator to use for the simple_query_string. Optional
search An Elasticsearch simple_query_string query that filters the objects in the response. Optional
from Returns only cases that were created after a specific date (KQL data range or date match expression). Optional
to Returns only cases that were created before a specific date (KQL data range or date match expression). Optional
owner A filter to limit the response to a specific set of applications. Optional
reporters Filters the returned cases by the user name of the reporter. Optional
search_fields The fields to perform the simple_query_string parsed query against. Optional
severity The severity of the case. Optional
sort_field Determines which field is used to sort the results. Optional
sort_order Determines the sort order. Optional
status Filters the returned cases by state. Optional
tags Filters the returned cases by tags. Optional
page The page number from which to start a search. Optional
size The number of items to return. Limited to 100 items. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Case.title String The title of the case.
Elasticsearch.Kibana.Case.id String The case ID.
Elasticsearch.Kibana.Case.description String The description of the case.
Elasticsearch.Kibana.Case.owner String The owner of the case.
Elasticsearch.Kibana.Case.severity String The severity of the case.
Elasticsearch.Kibana.Case.status String The status of the case.
Elasticsearch.Kibana.Case.created_at Date The creation date of the case.
Elasticsearch.Kibana.Case.connector.type String The connector type of the case.

Command Example


#### Context Example

```json
{
    "Elasticsearch": {
        "Kibana": {
            "Case": [
                {
                    "id": "1234",
                    "title": "Suspicious login",
                    "description": "Multiple failed logins detected",
                    "owner": "securitySolution",
                    "severity": "medium",
                    "status": "open",
                    "created_at": "2024-01-15T10:00:00.000Z",
                    "connector": {"type": ".none"}
                }
            ]
        }
    }
}

Human Readable Output

Kibana Cases

Title ID Description Owner Severity Status Created at Connector type
Suspicious login 1234 Multiple failed logins detected securitySolution medium open 2024-01-15T10:00:00.000Z .none

es-kibana-case-alerts-list


Retrieves all alerts for a case.

Base Command

es-kibana-case-alerts-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The identifier for the case. To retrieve case IDs, use es-kibana-case-list. Required
limit Limit on the number of keys to return. Optional
offset Starting record index to begin retrieving records from. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Case.Alert.id String The alert ID.
Elasticsearch.Kibana.Case.Alert.index String The alert index.
Elasticsearch.Kibana.Case.Alert.attached_at Date The date the alert was attached to the case.

Command Example

!es-kibana-case-alerts-list case_id="1234"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Case": {
                "1234": {
                    "Alert": [
                        {
                            "id": "5678",
                            "index": ".alerts-security.alerts-default",
                            "attached_at": "2024-01-15T11:00:00.000Z"
                        }
                    ]
                }
            }
        }
    }
}

Human Readable Output

Alerts for Case 1234

Case id Attached at Alert id Index
1234 2024-01-15T11:00:00.000Z 5678 .alerts-security.alerts-default

es-kibana-case-comment-add


Add a case comment or alert.

Base Command

es-kibana-case-comment-add

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The identifier for the case. To retrieve case IDs, use es-kibana-case-list. Required
alert_id The alert identifiers. Required only when type is alert. Optional
index The alert indices. Required only when type is alert. Optional
owner The application that owns the cases: Stack Management, Observability, or Elastic Security. Required
rule_id The rule identifier. Optional
rule_name The rule name. Optional
type The type of comment. Required
comment The new comment. Required only when type is user. Maximum length is 30000. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Case.id String The case ID.
Elasticsearch.Kibana.Case.comments.comment String The comment text.
Elasticsearch.Kibana.Case.comments.created_by.username String The user who created the comment.

Command Example

!es-kibana-case-comment-add case_id="1234" type="user" owner="securitySolution" comment="Investigated and confirmed malicious activity."

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Case": {
                "id": "1234",
                "comments": [
                    {
                        "comment": "Investigated and confirmed malicious activity.",
                        "created_by": {"username": "analyst"}
                    }
                ]
            }
        }
    }
}

Human Readable Output

Kibana Case Comment Added

Case id Comment Created by
1234 Investigated and confirmed malicious activity. analyst

es-kibana-case-comment-update


Update a case comment or alert.

Base Command

es-kibana-case-comment-update

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The identifier for the case. To retrieve case IDs, use es-kibana-case-list. Required
comment_id The identifier for the comment. Optional
comment The new comment. Required only when type is user. Maximum length is 30000. Optional
alert_id The alert identifiers. Required only when type is alert. Optional
index The alert indices. Required only when type is alert. Optional
owner The application that owns the cases: Stack Management, Observability, or Elastic Security. Required
rule_id The rule identifier. Optional
rule_name The rule name. Optional
type The type of comment. Required
version The current comment version. To retrieve version values, use the get comments API. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Case.id String The case ID.
Elasticsearch.Kibana.Case.comments.comment String The comment text.
Elasticsearch.Kibana.Case.comments.updated_by.username String The user who updated the comment.
Elasticsearch.Kibana.Case.comments.updated_at Date The date the comment was updated.

Command Example

!es-kibana-case-comment-update case_id="1234" comment_id="5678" type="user" owner="securitySolution" comment="Updated: confirmed false positive." version="WzEsMV0="

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Case": {
                "id": "1234",
                "comments": [
                    {
                        "id": "5678",
                        "comment": "Updated: confirmed false positive.",
                        "updated_by": {"username": "analyst"},
                        "updated_at": "2024-01-15T12:00:00.000Z"
                    }
                ]
            }
        }
    }
}

Human Readable Output

Kibana Case Comment Updated

Case id Comment Updated by Updated at
1234 Updated: confirmed false positive. analyst 2024-01-15T12:00:00.000Z

es-kibana-case-comment-delete


Deletes all comments and alerts from a case.

Base Command

es-kibana-case-comment-delete

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The identifier for the case. To retrieve case IDs, use es-kibana-case-list. Required

Context Output

There is no context output for this command.

Command Example

!es-kibana-case-comment-delete case_id="1234"

Context Example

{}

Human Readable Output

The comments and alerts for the case 1234 have been successfully deleted.

es-kibana-case-file-attach


Attach a file to a case.

Base Command

es-kibana-case-file-attach

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
case_id The identifier for the case. To retrieve case IDs, use es-kibana-case-list. Required
entry_id Entry ID for the file that needs to be attached. Required
file_name The desired name of the file being attached to the case (without file extension). Optional

Context Output

Path Type Description
Elasticsearch.Kibana.Case.id String The case ID.
Elasticsearch.Kibana.Case.comments.updated_by.username String The user who attached the file.

Command Example

!es-kibana-case-file-attach case_id="1234" entry_id="1@1"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "Case": {
                "id": "1234",
                "comments": [
                    {
                        "updated_by": {"username": "analyst"}
                    }
                ]
            }
        }
    }
}

Human Readable Output

File successfully attached to case 1234.

es-kibana-endpoint-exception-list-item-create


Create an Elastic Endpoint exception list item, and associate it with the Elastic Endpoint exception list.

Base Command

es-kibana-endpoint-exception-list-item-create

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
description Describes the exception list. Optional
entries_field A string that does not contain only whitespace characters. Optional
entries_list_id Value list’s identifier. Optional
entries_list_type Specifies the Elasticsearch data type of excludes the list container holds. Optional
entries_operator The exception item entry operator. Optional
entries_type The exception item entry type. Optional
item_id Human readable string identifier, e.g. trusted-linux-processes. Optional
meta Additional properties are allowed (JSON object). Optional
name Exception list name. Minimum length is 1. Optional
os_types Use this field to specify the operating system. Optional
tags String array containing words and phrases to help categorize exception items. Optional
entry_id Entry ID for the file containing the full request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.EndpointExceptionListItem.id String The exception list item ID.
Elasticsearch.Kibana.EndpointExceptionListItem.item_id String The exception list item human-readable ID.
Elasticsearch.Kibana.EndpointExceptionListItem.list_id String The exception list ID.
Elasticsearch.Kibana.EndpointExceptionListItem.name String The exception list item name.
Elasticsearch.Kibana.EndpointExceptionListItem.description String The exception list item description.
Elasticsearch.Kibana.EndpointExceptionListItem.created_at Date The creation date of the exception list item.

Command Example

!es-kibana-endpoint-exception-list-item-create name="Trusted process" description="Allow known safe process" entries_field="process.name" entries_type="match" entries_operator="included" entries_value="safe_process.exe" os_types="windows"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "EndpointExceptionListItem": {
                "id": "1234",
                "item_id": "trusted-process-1",
                "list_id": "endpoint_list",
                "name": "Trusted process",
                "description": "Allow known safe process",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Endpoint Exception List Item

ID Item ID List ID Name Description Creation date
1234 trusted-process-1 endpoint_list Trusted process Allow known safe process 2024-01-15T10:00:00.000Z

es-kibana-endpoint-exception-list-item-update


Update an Elastic Endpoint exception list item.

Base Command

es-kibana-endpoint-exception-list-item-update

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
exception_list_item_id Exception’s identifier. Optional
item_id Human readable string identifier, e.g. trusted-linux-processes. Optional
_version The version id, normally returned by the API when the item is retrieved. Optional
description Describes the exception list. Optional
entries_field A string that does not contain only whitespace characters. Optional
entries_list_id Value list’s identifier. Optional
entries_list_type Specifies the Elasticsearch data type of excludes the list container holds. Optional
entries_operator The exception item entry operator. Optional
entries_type The exception item entry type. Optional
entries_value A string that does not contain only whitespace characters. Optional
meta Additional properties are allowed (JSON object). Optional
name Exception list name. Minimum length is 1. Optional
os_types Use this field to specify the operating system. Optional
tags String array containing words and phrases to help categorize exception items. Optional
entry_id Entry ID for the file containing the full request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.EndpointExceptionListItem.id String The exception list item ID.
Elasticsearch.Kibana.EndpointExceptionListItem.item_id String The exception list item human-readable ID.
Elasticsearch.Kibana.EndpointExceptionListItem.list_id String The exception list ID.
Elasticsearch.Kibana.EndpointExceptionListItem.name String The exception list item name.
Elasticsearch.Kibana.EndpointExceptionListItem.description String The exception list item description.
Elasticsearch.Kibana.EndpointExceptionListItem.created_at Date The creation date of the exception list item.

Command Example

!es-kibana-endpoint-exception-list-item-update item_id="trusted-process-1" name="Trusted process updated" description="Updated description" entries_field="process.name" entries_type="match" entries_operator="included" entries_value="safe_process.exe"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "EndpointExceptionListItem": {
                "id": "1234",
                "item_id": "trusted-process-1",
                "list_id": "endpoint_list",
                "name": "Trusted process updated",
                "description": "Updated description",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Endpoint Exception List Item

ID Item ID List ID Name Description Creation date
1234 trusted-process-1 endpoint_list Trusted process updated Updated description 2024-01-15T10:00:00.000Z

es-kibana-endpoint-exception-list-item-delete


Delete an Elastic Endpoint exception list item.

Base Command

es-kibana-endpoint-exception-list-item-delete

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
item_id Either id or item_id must be specified. Required

Context Output

There is no context output for this command.

Command Example

!es-kibana-endpoint-exception-list-item-delete item_id="trusted-process-1"

Context Example

{}

Human Readable Output

The item trusted-process-1 has been successfully deleted.

es-kibana-endpoint-exception-list-item-list


Retrieves Elastic Endpoint exception list items.

Base Command

es-kibana-endpoint-exception-list-item-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
filter Filters the returned results according to the value of the specified field, using the <field name>:<field value> syntax. Optional
item_id Either id or item_id must be specified. Optional
sort_field Determines which field is used to sort the results. Optional
sort_order Determines the sort order. Optional
page The page number to return. Minimum value is 0. Optional
size The number of exception list items to return per page. Minimum value is 0. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.EndpointExceptionListItem.id String The exception list item ID.
Elasticsearch.Kibana.EndpointExceptionListItem.item_id String The exception list item human-readable ID.
Elasticsearch.Kibana.EndpointExceptionListItem.list_id String The exception list ID.
Elasticsearch.Kibana.EndpointExceptionListItem.name String The exception list item name.
Elasticsearch.Kibana.EndpointExceptionListItem.description String The exception list item description.
Elasticsearch.Kibana.EndpointExceptionListItem.created_at Date The creation date of the exception list item.

Command Example


#### Context Example

```json
{
    "Elasticsearch": {
        "Kibana": {
            "EndpointExceptionListItem": [
                {
                    "id": "1234",
                    "item_id": "trusted-process-1",
                    "list_id": "endpoint_list",
                    "name": "Trusted process",
                    "description": "Allow known safe process",
                    "created_at": "2024-01-15T10:00:00.000Z"
                }
            ]
        }
    }
}

Human Readable Output

Kibana Endpoint Exception List Items

ID Item ID List ID Name Description Creation date
1234 trusted-process-1 endpoint_list Trusted process Allow known safe process 2024-01-15T10:00:00.000Z

es-kibana-exception-list-list


Get a list of all exception list containers.

Base Command

es-kibana-exception-list-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
exception_list_id Exception list’s identifier. Either id or list_id must be specified. Optional
list_id Human readable exception list string identifier. Either id or list_id must be specified. Optional
filter Filters the returned results according to the value of the specified field. Optional
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional
sort_field Determines which field is used to sort the results. Optional
sort_order Determines the sort order. Optional
page The page number to return. Minimum value is 1. Optional
size The number of exception lists to return per page. Minimum value is 1. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ExceptionList.id String The exception list ID.
Elasticsearch.Kibana.ExceptionList.list_id String The human-readable exception list ID.
Elasticsearch.Kibana.ExceptionList.name String The exception list name.
Elasticsearch.Kibana.ExceptionList.description String The exception list description.
Elasticsearch.Kibana.ExceptionList.created_at Date The creation date of the exception list.

Command Example


#### Context Example

```json
{
    "Elasticsearch": {
        "Kibana": {
            "ExceptionList": [
                {
                    "id": "1234",
                    "list_id": "my-exception-list",
                    "name": "My Exception List",
                    "description": "Exceptions for trusted processes",
                    "created_at": "2024-01-15T10:00:00.000Z"
                }
            ]
        }
    }
}

Human Readable Output

Kibana Exception Lists

Exception list ID List ID Name Description Creation date
1234 my-exception-list My Exception List Exceptions for trusted processes 2024-01-15T10:00:00.000Z

es-kibana-exception-list-create


Create an exception list.

Base Command

es-kibana-exception-list-create

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
description Describes the exception list. Optional
list_id The exception list’s human-readable string identifier. Optional
meta Placeholder for metadata about the list container (JSON object). Optional
name The name of the exception list. Optional
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional
os_types Use this field to specify the operating system. Only enter one value. Optional
tags String array containing words and phrases to help categorize exception containers. Optional
type The type of exception list to be created. Required
version The document version automatically increased on updates. Optional
entry_id Entry ID for the file containing the request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ExceptionList.id String The exception list ID.
Elasticsearch.Kibana.ExceptionList.list_id String The human-readable exception list ID.
Elasticsearch.Kibana.ExceptionList.name String The exception list name.
Elasticsearch.Kibana.ExceptionList.description String The exception list description.
Elasticsearch.Kibana.ExceptionList.created_at Date The creation date of the exception list.

Command Example

!es-kibana-exception-list-create type="detection" name="My Exception List" description="Exceptions for trusted processes" list_id="my-exception-list"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ExceptionList": {
                "id": "1234",
                "list_id": "my-exception-list",
                "name": "My Exception List",
                "description": "Exceptions for trusted processes",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Exception List

Exception list ID List ID Name Description Creation date
1234 my-exception-list My Exception List Exceptions for trusted processes 2024-01-15T10:00:00.000Z

es-kibana-exception-list-update


Updates an existing exception list.

Base Command

es-kibana-exception-list-update

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
_version The version id, normally returned by the API when the item was retrieved. Optional
description Describes the exception list. Required
exception_list_id Exception list’s identifier. Optional
list_id The exception list’s human-readable string identifier. Optional
meta Placeholder for metadata about the list container (JSON object). Optional
name The name of the exception list. Required
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional
os_types Use this field to specify the operating system. Only enter one value. Optional
tags String array containing words and phrases to help categorize exception containers. Optional
type The type of exception list to be created. Required
version The document version automatically increased on updates. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ExceptionList.id String The exception list ID.
Elasticsearch.Kibana.ExceptionList.list_id String The human-readable exception list ID.
Elasticsearch.Kibana.ExceptionList.name String The exception list name.
Elasticsearch.Kibana.ExceptionList.description String The exception list description.
Elasticsearch.Kibana.ExceptionList.created_at Date The creation date of the exception list.

Command Example

!es-kibana-exception-list-update exception_list_id="1234" name="My Exception List Updated" description="Updated description" type="detection"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ExceptionList": {
                "id": "1234",
                "list_id": "my-exception-list",
                "name": "My Exception List Updated",
                "description": "Updated description",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Exception List

Exception list ID List ID Name Description Creation date
1234 my-exception-list My Exception List Updated Updated description 2024-01-15T10:00:00.000Z

es-kibana-exception-list-delete


Delete an exception list using the id or list_id field.

Base Command

es-kibana-exception-list-delete

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
exception_list_id Exception list’s identifier. Either id or list_id must be specified. Optional
list_id Human readable exception list string identifier. Either id or list_id must be specified. Optional
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional

Context Output

There is no context output for this command.

Command Example

!es-kibana-exception-list-delete list_id="my-exception-list"

Context Example

{}

Human Readable Output

The exception list my-exception-list has been successfully deleted.

es-kibana-exception-list-item-list


Get a list of all exception list items in the specified list.

Base Command

es-kibana-exception-list-item-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
exception_list_item_id Exception list item’s identifier. Either id or item_id must be specified. Optional
item_id Human readable exception item string identifier. Either id or item_id must be specified. Optional
exception_list_id The list_ids of the items to fetch. Optional
filter Filters the returned results according to the value of the specified field, using the <field name>:<field value> syntax. Optional
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional
search Free-text search term applied to exception list item fields. Optional
sort_field Determines which field is used to sort the results. Optional
sort_order Determines the sort order. Optional
page The page number to return. Minimum value is 0. Optional
size The number of exception list items to return per page. Minimum value is 0. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ExceptionListItem.id String The exception list item ID.
Elasticsearch.Kibana.ExceptionListItem.item_id String The exception list item human-readable ID.
Elasticsearch.Kibana.ExceptionListItem.list_id String The exception list ID.
Elasticsearch.Kibana.ExceptionListItem.name String The exception list item name.
Elasticsearch.Kibana.ExceptionListItem.description String The exception list item description.
Elasticsearch.Kibana.ExceptionListItem.created_at Date The creation date of the exception list item.

Command Example

!es-kibana-exception-list-item-list exception_list_id="my-exception-list"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ExceptionListItem": [
                {
                    "id": "1234",
                    "item_id": "my-item-1",
                    "list_id": "my-exception-list",
                    "name": "Trusted IP",
                    "description": "Known safe IP address",
                    "created_at": "2024-01-15T10:00:00.000Z"
                }
            ]
        }
    }
}

Human Readable Output

Kibana Exception List Items

ID Item ID List ID Name Description Creation date
1234 my-item-1 my-exception-list Trusted IP Known safe IP address 2024-01-15T10:00:00.000Z

es-kibana-exception-list-item-create


Create an exception item and associate it with the specified exception list.

Base Command

es-kibana-exception-list-item-create

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
comment A string that does not contain only whitespace characters. Optional
description Describes the exception list. Optional
expire_time The exception item’s expiration date, in ISO format. Optional
item_id Human readable string identifier, e.g. trusted-linux-processes. Optional
meta Additional properties are allowed (JSON object). Optional
name Exception list name. Optional
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional
type The type of exception list to be created. Optional
entries_field A string that does not contain only whitespace characters. Optional
entries_operator The exception item entry operator. Optional
entries_type The exception item entry type. Optional
entries_value The exception item entry value. Optional
entries_list_id Value list’s identifier. Optional
entries_list_type Specifies the Elasticsearch data type of excludes the list container holds. Optional
list_id The exception list’s human-readable string identifier. Optional
os_types Use this field to specify the operating system. Only enter one value. Optional
tags String array containing words and phrases to help categorize exception containers. Optional
entry_id Entry ID for the file containing the request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ExceptionListItem.id String The exception list item ID.
Elasticsearch.Kibana.ExceptionListItem.item_id String The exception list item human-readable ID.
Elasticsearch.Kibana.ExceptionListItem.list_id String The exception list ID.
Elasticsearch.Kibana.ExceptionListItem.name String The exception list item name.
Elasticsearch.Kibana.ExceptionListItem.description String The exception list item description.
Elasticsearch.Kibana.ExceptionListItem.created_at Date The creation date of the exception list item.

Command Example

!es-kibana-exception-list-item-create list_id="my-exception-list" name="Trusted IP" description="Known safe IP address" entries_field="source.ip" entries_type="match" entries_operator="included" entries_value="192.168.1.1"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ExceptionListItem": {
                "id": "1234",
                "item_id": "my-item-1",
                "list_id": "my-exception-list",
                "name": "Trusted IP",
                "description": "Known safe IP address",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Exception List Item

ID Item ID List ID Name Description Creation date
1234 my-item-1 my-exception-list Trusted IP Known safe IP address 2024-01-15T10:00:00.000Z

es-kibana-exception-item-list-update


Updates an existing exception list item.

Base Command

es-kibana-exception-item-list-update

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
_version The version ID, normally returned by the API when the item is retrieved. Optional
comment A string that does not contain only whitespace characters. Optional
comment_id A string that does not contain only whitespace characters. Optional
description Describes the exception list. Optional
expire_time The exception item’s expiration date, in ISO format. Optional
exception_list_item_id Exception’s identifier. Optional
item_id Human readable string identifier, e.g. trusted-linux-processes. Optional
meta Additional properties are allowed (JSON object). Optional
name Exception list name. Optional
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional
type The type of exception list to be created. Optional
entries_field A string that does not contain only whitespace characters. Optional
entries_operator The exception item entry operator. Optional
entries_type The exception item entry type. Optional
entries_value The exception item entry value. Optional
entries_list_id Value list’s identifier. Optional
entries_list_type Specifies the Elasticsearch data type of excludes the list container holds. Optional
list_id The exception list’s human-readable string identifier. Optional
os_types Use this field to specify the operating system. Only enter one value. Optional
tags String array containing words and phrases to help categorize exception containers. Optional
entry_id Entry ID for the file containing the request JSON. If provided, other parameters won’t be considered. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ExceptionListItem.id String The exception list item ID.
Elasticsearch.Kibana.ExceptionListItem.item_id String The exception list item human-readable ID.
Elasticsearch.Kibana.ExceptionListItem.list_id String The exception list ID.
Elasticsearch.Kibana.ExceptionListItem.name String The exception list item name.
Elasticsearch.Kibana.ExceptionListItem.description String The exception list item description.
Elasticsearch.Kibana.ExceptionListItem.updated_at Date The update date of the exception list item.

Command Example

!es-kibana-exception-item-list-update item_id="my-item-1" name="Trusted IP updated" description="Updated safe IP" entries_field="source.ip" entries_type="match" entries_operator="included" entries_value="192.168.1.1"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ExceptionListItem": {
                "id": "1234",
                "item_id": "my-item-1",
                "list_id": "my-exception-list",
                "name": "Trusted IP updated",
                "description": "Updated safe IP",
                "updated_at": "2024-01-15T12:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Exception List Item Updated

Exception list item ID Item Id List ID Name Description Update date
1234 my-item-1 my-exception-list Trusted IP updated Updated safe IP 2024-01-15T12:00:00.000Z

es-kibana-exception-list-item-delete


Deletes an exception list item.

Base Command

es-kibana-exception-list-item-delete

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
exception_list_item_id Exception list item’s identifier. Either id or item_id must be specified. Optional
item_id Human readable exception item string identifier. Either id or item_id must be specified. Optional
namespace_type Determines whether the returned containers are Kibana associated with a Kibana space or available in all spaces. Optional

Context Output

There is no context output for this command.

Command Example

!es-kibana-exception-list-item-delete item_id="my-item-1"

Context Example

{}

Human Readable Output

The exception list item my-item-1 has been successfully deleted.

es-kibana-value-lists-list


Retrieves details of a value list (the list container).

Base Command

es-kibana-value-lists-list

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
value_list_id Value list identifier (id) returned when the list was created. Optional
filter Filters the returned results according to the value of the specified field, using the <field name>:<field value> syntax. Optional
cursor Returns the lists that come after the last lists returned in the previous call. Optional
sort_field Determines which field is used to sort the results. Optional
sort_order Determines the sort order. Optional
page The page number to return. Optional
size The number of value lists to return per page. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ValueList.id String The value list ID.
Elasticsearch.Kibana.ValueList.name String The value list name.
Elasticsearch.Kibana.ValueList.description String The value list description.
Elasticsearch.Kibana.ValueList.created_at Date The creation date of the value list.

Command Example


#### Context Example

```json
{
    "Elasticsearch": {
        "Kibana": {
            "ValueList": [
                {
                    "id": "1234",
                    "name": "trusted-ips",
                    "description": "List of trusted IP addresses",
                    "created_at": "2024-01-15T10:00:00.000Z"
                }
            ]
        }
    }
}

Human Readable Output

Kibana Value Lists

Value list ID Name Description Creation date
1234 trusted-ips List of trusted IP addresses 2024-01-15T10:00:00.000Z

es-kibana-value-list-item-get


Retrieves value list items.

Base Command

es-kibana-value-list-item-get

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
value_list_item_id Value list item identifier. Required if list_id and value are not specified. Optional
value_list_id Parent value list’s id to page through items for. Optional
value The value used to evaluate exceptions. Required if id is not specified. Optional
filter Filters the returned results according to the value of the specified field, using the <field name>:<field value> syntax. Optional
cursor Opaque cursor returned in a previous response; pass it to continue listing from the next page. Optional
sort_field Determines which field is used to sort the results. Optional
sort_order Determines the sort order. Optional
page The page number to return. Optional
size The number of list items to return per page. Optional

Context Output

Path Type Description
Elasticsearch.Kibana.ValueListItem.id String The value list item ID.
Elasticsearch.Kibana.ValueListItem.list_id String The value list ID.
Elasticsearch.Kibana.ValueListItem.name String The value list item name.
Elasticsearch.Kibana.ValueListItem.description String The value list item description.
Elasticsearch.Kibana.ValueListItem.created_at Date The creation date of the value list item.

Command Example

!es-kibana-value-list-item-get value_list_id="1234"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ValueListItem": [
                {
                    "id": "5678",
                    "list_id": "1234",
                    "value": "192.168.1.1",
                    "created_at": "2024-01-15T10:00:00.000Z"
                }
            ]
        }
    }
}

Human Readable Output

Kibana Value List Items

Value list item ID List ID Value Creation date
5678 1234 192.168.1.1 2024-01-15T10:00:00.000Z

es-kibana-value-list-item-create


Adds a new item to a value list.

Base Command

es-kibana-value-list-item-create

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
value_list_id Value list’s identifier. Required
meta Placeholder for metadata about the value list item (JSON object). Example {“source”:”threatfeed”,”priority”:3,”active”:true}. Optional
refresh Determines when changes made by the request are made visible to search. Optional
value The value used to evaluate exceptions. Required

Context Output

Path Type Description
Elasticsearch.Kibana.ValueListItem.id String The value list item ID.
Elasticsearch.Kibana.ValueListItem.list_id String The value list ID.
Elasticsearch.Kibana.ValueListItem.name String The value list item name.
Elasticsearch.Kibana.ValueListItem.description String The value list item description.
Elasticsearch.Kibana.ValueListItem.created_at Date The creation date of the value list item.

Command Example

!es-kibana-value-list-item-create value_list_id="1234" value="10.0.0.1"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ValueListItem": {
                "id": "5678",
                "list_id": "1234",
                "value": "10.0.0.1",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Value List Item

Value list item ID List ID Value Creation date
5678 1234 10.0.0.1 2024-01-15T10:00:00.000Z

es-kibana-value-list-item-update


Updates an existing value list item.

Base Command

es-kibana-value-list-item-update

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
_version The version id, normally returned by the API when the document is retrieved. Optional
value_list_item_id Value list item identifier. Required
meta Placeholder for metadata about the value list item (JSON object). Example {“source”:”threatfeed”,”priority”:3,”active”:true}. Optional
value The value used to evaluate exceptions. Required

Context Output

Path Type Description
Elasticsearch.Kibana.ValueListItem.id String The value list item ID.
Elasticsearch.Kibana.ValueListItem.list_id String The value list ID.
Elasticsearch.Kibana.ValueListItem.name String The value list item name.
Elasticsearch.Kibana.ValueListItem.description String The value list item description.
Elasticsearch.Kibana.ValueListItem.created_at Date The creation date of the value list item.

Command Example

!es-kibana-value-list-item-update value_list_item_id="5678" value="10.0.0.2"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ValueListItem": {
                "id": "5678",
                "list_id": "1234",
                "value": "10.0.0.2",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Value List Item

Value list item ID List ID Value Creation date
5678 1234 10.0.0.2 2024-01-15T10:00:00.000Z

es-kibana-value-list-item-delete


Deletes a value list item.

Base Command

es-kibana-value-list-item-delete

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
value_list_item_id Value list item’s identifier. Required if list_id and value are not specified. Optional
value_list_id Value list’s identifier. Optional
value The value used to evaluate exceptions. Required if id is not specified. Optional
refresh Determines when changes made by the request are made visible to search. Optional

Context Output

There is no context output for this command.

Command Example

!es-kibana-value-list-item-delete value_list_item_id="5678"

Context Example

{}

Human Readable Output

The value list item 5678 has been successfully deleted.

es-kibana-value-list-item-export


Exports all items of a value list as a file (returned to the War Room).

Base Command

es-kibana-value-list-item-export

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
value_list_id Value list’s ID to export. Optional

Context Output

There is no context output for this command.

Command Example

!es-kibana-value-list-item-export value_list_id="1234"

Context Example

{}

Human Readable Output

The value list 1234 has been exported successfully.

es-kibana-value-list-item-import


Import value list items from a TXT or CSV file.

Base Command

es-kibana-value-list-item-import

Input

Argument Name Description Required
space_id Refer to https://www.elastic.co/docs/deploy-manage/manage-spaces for more information. Optional
value_list_id List’s ID to import. Optional
type Type of the importing list. Optional
refresh Determines when changes made by the request are made visible to search. Optional
entry_id Entry ID for the file containing the items to import. Required

Context Output

Path Type Description
Elasticsearch.Kibana.ValueListItem.id String The value list item ID.
Elasticsearch.Kibana.ValueListItem.list_id String The value list ID.
Elasticsearch.Kibana.ValueListItem.name String The value list item name.
Elasticsearch.Kibana.ValueListItem.description String The value list item description.
Elasticsearch.Kibana.ValueListItem.created_at Date The creation date of the value list item.

Command Example

!es-kibana-value-list-item-import value_list_id="1234" entry_id="1@1"

Context Example

{
    "Elasticsearch": {
        "Kibana": {
            "ValueListItem": {
                "id": "5678",
                "list_id": "1234",
                "value": "10.0.0.1",
                "created_at": "2024-01-15T10:00:00.000Z"
            }
        }
    }
}

Human Readable Output

Kibana Value List Item

Value list item ID List ID Value Creation date
5678 1234 10.0.0.1 2024-01-15T10:00:00.000Z

<~PLATFORM>

License Requirements

The following configuration parameters require one of these licenses: Cortex XSIAM or Agentix:

  • Fetch incidents

</~PLATFORM>

Configuration parameters

  • url — Server URL
  • kibana_url — Kibana Server URL
  • auth_type — Authorization type (required)
  • api_key_auth_credentials — API key ID
  • credentials — Username
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • client_type — Client type
  • fetch_index — Index to fetch incidents from
  • fetch_query — Query String
  • fetch_fields — Fields to Fetch
  • fetch_time_field — Index time field
  • raw_query — Raw Query
  • time_method — Time field type
  • map_labels — Map JSON fields into labels
  • fetch_time — First fetch timestamp
  • fetch_size — The maximum number of results per fetch
  • timeout — Request timeout (in seconds).
  • incidentType — Incident type
  • isFetch — Fetch incidents
  • incidentFetchInterval — Incidents Fetch Interval
  • space_id — Space ID
  • fetch_incident_type — Fetch incident types
  • mirror_direction — Incident Mirroring Direction
  • fetch_severity — Fetch cases by Severity
  • fetch_status — Fetch cases by Status
  • fetch_alerts_for_case — Fetch alerts for case
  • close_incident — Close Mirrored XSOAR Incident
  • close_elastic_incident — Close Mirrored Elasticsearch Incident

Commands (48)

  • es-eql-search

    Search using EQL query.

  • es-esql-search

    Search using ES|QL query. (Elastic >=8.11).

  • es-get-indices-statistics

    Returns Elasticsearch indices statistics and information. This command is not supported for client type OpenSearch.

  • es-index

    Indexes a document into an Elasticsearch index.

  • es-integration-health-check

    Returns the health status of the integration. This commmand should be used for debugging purposes.

  • es-kibana-alerting-health-get

    Retrieves the health of the Kibana alerting framework.

  • es-kibana-case-alerts-list

    Retrieves all alerts for a case.

  • es-kibana-case-comment-add

    Add a case comment or alert.

  • es-kibana-case-comment-delete

    Deletes all comments and alerts from a case.

  • es-kibana-case-comment-update

    Update a case comment or alert.

  • es-kibana-case-create

    Creates a new case in Kibana.

  • es-kibana-case-delete

    Deletes one or more cases by ID.

  • es-kibana-case-file-attach

    Attach a file to a case.

  • es-kibana-case-list

    Retrieves the details of Kibana cases.

  • es-kibana-case-update

    Update cases in Kibana.

  • es-kibana-detection-alert-status-set

    Set the status of one or more detection alerts.

  • es-kibana-endpoint-exception-list-item-create

    Create an Elastic Endpoint exception list item, and associate it with the Elastic Endpoint exception list.

  • es-kibana-endpoint-exception-list-item-delete

    Delete an Elastic Endpoint exception list item.

  • es-kibana-endpoint-exception-list-item-list

    Retrieves Elastic Endpoint exception list items.

  • es-kibana-endpoint-exception-list-item-update

    Update an Elastic Endpoint exception list item.

  • es-kibana-exception-item-list-update

    Updates an existing exception list item.

  • es-kibana-exception-list-create

    Create an exception list.

  • es-kibana-exception-list-delete

    Delete an exception list using the id or list_id field.

  • es-kibana-exception-list-item-create

    Create an exception item and associate it with the specified exception list.

  • es-kibana-exception-list-item-delete

    Deletes an exception list item.

  • es-kibana-exception-list-item-list

    Get a list of all exception list items in the specified list.

  • es-kibana-exception-list-list

    Get a list of all exception list containers.

  • es-kibana-exception-list-update

    Updates an existing exception list.

  • es-kibana-rule-alert-mute

    Mute an alert, or mute all alerts for a rule.

  • es-kibana-rule-alert-unmute

    Unmute an alert, or unmute all alerts for a rule.

  • es-kibana-rule-disable

    Disable a rule.

  • es-kibana-rule-enable

    Enable a rule.

  • es-kibana-rule-list

    Retrieves information about rules.

  • es-kibana-rule-types-list

    Retrieves all rule types available in Kibana.

  • es-kibana-rule-update

    Update a rule.

  • es-kibana-value-list-item-create

    Adds a new item to a value list.

  • es-kibana-value-list-item-delete

    Deletes a value list item.

  • es-kibana-value-list-item-export

    Exports all items of a value list as a file (returned to the War Room).

  • es-kibana-value-list-item-get

    Retrieves value list items.

  • es-kibana-value-list-item-import

    Import value list items from a TXT or CSV file.

  • es-kibana-value-list-item-update

    Updates an existing value list item.

  • es-kibana-value-lists-list

    Retrieves details of a value list (the list container).

  • es-search

    Queries an index.

  • get-mapping-fields

    Returns the schema of the index to fetch from. This commmand should be used for debugging purposes.

  • get-modified-remote-data

    Gets the list of incidents that were modified since the last update time. This command is used for mirroring in and should be used for debugging purposes.

  • get-remote-data

    Gets remote data of a specific incident. This command is used for mirroring in and should be used for debugging purposes.

  • search

    Searches an index.

  • update-remote-system

    Updates the remote system with local incident changes. This command is used for mirroring out and should be used for debugging purposes.

import re

import demistomock as demisto  # noqa: F401

from CommonServerPython import *  # noqa: F401
from CommonServerUserPython import *

"""IMPORTS"""
import json
import mimetypes
import traceback
import warnings
from datetime import datetime, timedelta, UTC

import requests
import urllib3
from dateutil.parser import parse

# Disable insecure warnings
urllib3.disable_warnings()
warnings.filterwarnings(action="ignore", message=".*using SSL with verify_certs=False is insecure.")

PARAMS = demisto.params()

# .ymla values
BASIC_AUTH = "Basic auth"
BEARER_AUTH = "Bearer auth"
API_KEY_AUTH = "API key auth"

API_KEY_PREFIX = "_api_key_id:"

AUTH_TYPE = PARAMS.get("auth_type", "Basic auth")
USERNAME: str = PARAMS.get("credentials", {}).get("identifier")
PASSWORD: str = PARAMS.get("credentials", {}).get("password")
API_KEY_ID: str = PARAMS.get("api_key_auth_credentials", {}).get("identifier")
API_KEY_SECRET: str = PARAMS.get("api_key_auth_credentials", {}).get("password")
API_KEY = None

# Using API key auth by username and password fields for backward compatibility.
if AUTH_TYPE == BASIC_AUTH:
    if USERNAME and USERNAME.startswith(API_KEY_PREFIX):
        AUTH_TYPE = API_KEY_AUTH
        API_KEY_ID = USERNAME[len(API_KEY_PREFIX) :]
        API_KEY = (API_KEY_ID, PASSWORD)

elif AUTH_TYPE == API_KEY_AUTH:
    API_KEY = (API_KEY_ID, API_KEY_SECRET)

ELASTICSEARCH_V8 = "Elasticsearch_v8"
ELASTICSEARCH_V9 = "Elasticsearch_v9"
OPEN_SEARCH = "OpenSearch"
ELASTIC_SEARCH_CLIENT = PARAMS.get("client_type")
if ELASTIC_SEARCH_CLIENT == OPEN_SEARCH:
    from opensearch_dsl import Search
    from opensearch_dsl.query import QueryString
    from opensearchpy import NotFoundError, RequestsHttpConnection
    from opensearchpy import OpenSearch as Elasticsearch
elif ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V8, ELASTICSEARCH_V9]:
    from elastic_transport import RequestsHttpNode
    from elasticsearch import Elasticsearch, NotFoundError  # type: ignore[assignment]
    from elasticsearch.dsl import Search
    from elasticsearch.dsl.query import QueryString
else:  # Elasticsearch (<= v7)
    from elasticsearch7 import Elasticsearch, NotFoundError, RequestsHttpConnection  # type: ignore[assignment,misc]
    from elasticsearch.dsl import Search
    from elasticsearch.dsl.query import QueryString


ES_DEFAULT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss.SSSSSS"
PYTHON_DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f"
SERVER = PARAMS.get("url", "").rstrip("/")
PROXY = PARAMS.get("proxy")
HTTP_ERRORS = {
    400: "400 Bad Request - Incorrect or invalid parameters",
    401: "401 Unauthorized - Incorrect or invalid username or password",
    403: "403 Forbidden - The account does not support performing this task",
    404: "404 Not Found - Elasticsearch server was not found",
    408: "408 Timeout - Check port number or Elasticsearch server credentials",
    410: "410 Gone - Elasticsearch server no longer exists in the service",
    500: "500 Internal Server Error - Internal error",
    503: "503 Service Unavailable",
}

"""VARIABLES FOR FETCH INCIDENTS"""
TIME_FIELD = PARAMS.get("fetch_time_field", "")
FETCH_INDEX = PARAMS.get("fetch_index", "")
FETCH_QUERY_PARM = PARAMS.get("fetch_query", "")
RAW_QUERY = PARAMS.get("raw_query", "")
FETCH_TIME = PARAMS.get("fetch_time", "3 days")
FETCH_SIZE = int(PARAMS.get("fetch_size", 50))
INSECURE = not PARAMS.get("insecure", False)
TIME_METHOD = PARAMS.get("time_method", "Simple-Date")
TIMEOUT = int(PARAMS.get("timeout") or 60)
MAP_LABELS = PARAMS.get("map_labels", True)
FIELDS_LIST = argToList(PARAMS.get("fetch_fields", ""))

FETCH_QUERY = RAW_QUERY or FETCH_QUERY_PARM

"""VARIABLES FOR MIRRORING"""
MIRROR_LOG_PREFIX = "[ES-MIRROR]"


def _with_active_traceback(message: str) -> str:
    """Appends the traceback of the exception currently being handled, when there is one."""
    tb = traceback.format_exc()
    # format_exc() returns "NoneType: None\n" when no exception is being handled.
    if tb and not tb.startswith("NoneType: None"):
        return f"{message}\n{tb}"
    return message


def mirror_debug(message: str) -> None:
    demisto.debug(f"{MIRROR_LOG_PREFIX} {message}")


def mirror_error(message: str) -> None:
    demisto.error(f"{MIRROR_LOG_PREFIX} {_with_active_traceback(message)}")


def get_incident_type() -> str:
    """Returns the current incident's type, or "" when the incident context is unavailable."""
    try:
        incident = demisto.incident()
    except Exception as e:
        mirror_debug(f"Incident context unavailable ({e}); resolving type from the remote system.")
        return ""

    if not isinstance(incident, dict):
        return ""

    return incident.get("type") or ""


MIRROR_DIRECTION = PARAMS.get("mirror_direction", "None")
FETCH_SEVERITY = argToList(PARAMS.get("fetch_severity", ""))
FETCH_STATUS = argToList(PARAMS.get("fetch_status", "open,in-progress"))
FETCH_ALERTS_FOR_CASE = PARAMS.get("fetch_alerts_for_case", False)
CLOSE_INCIDENT = PARAMS.get("close_incident", False)
CLOSE_ELASTIC_INCIDENT = PARAMS.get("close_elastic_incident", False)

MIRROR_DIRECTION_MAP = {
    "None": None,
    "Incoming": "In",
    "Outgoing": "Out",
    "Incoming And Outgoing": "Both",
}

INCIDENT_TYPE_SECURITY_ALERT = "Elasticsearch Security Alert"
INCIDENT_TYPE_CASE = "Elasticsearch Case"

ELASTIC_ENTITY_KIND_FIELD = "elastic_entity_kind"
ENTITY_KIND_SECURITY_ALERT = "signal"
ENTITY_KIND_CASE = "securitySolution"

# The only detection-alert fields Kibana can update; severity/risk/rule cannot be mirrored out.
MIRRORABLE_ALERT_FIELDS = ("status", "reason", "tags")

ELASTIC_CLOSE_REASON_TO_XSOAR: Dict[str, str] = {
    "false_positive": "false_positive",
    "duplicate": "duplicate",
    "true_positive": "resolved",
    "benign_positive": "resolved",
    "automated_closure": "resolved",
    "other": "other",
}

# Not the inverse of the map above: several Elastic reasons collapse onto "resolved", and Kibana
# rejects values outside its own enum, so unknown reasons fall back to "other".
XSOAR_CLOSE_REASON_TO_ELASTIC: Dict[str, str] = {
    "False Positive": "false_positive",
    "false_positive": "false_positive",
    "Duplicate": "duplicate",
    "duplicate": "duplicate",
    "Resolved": "true_positive",
    "resolved": "true_positive",
    "Other": "other",
    "other": "other",
}

# Keys XSOAR adds to the delta on close; used to detect closures when inc_status is still Active.
XSOAR_CLOSE_DELTA_KEYS = {"closeReason", "closeNotes", "closingUserId"}


def is_incident_closing(inc_status: Optional[int], delta: Optional[Dict[str, Any]]) -> bool:
    """Returns True when the local incident is being closed (by status or close-only delta keys)."""
    if inc_status == IncidentStatus.DONE:
        return True
    return bool((delta or {}).keys() & XSOAR_CLOSE_DELTA_KEYS)


"""VARIABLES FOR KIBANA COMMANDS (es-kibana-*)"""
KIBANA_LOG_PREFIX = "[ES-KIBANA]"
DEFAULT_SPACE_ID = PARAMS.get("space_id", "")
KIBANA_XSRF_HEADER = {"kbn-xsrf": "true"}
# Kibana write operations (POST/PUT/PATCH/DELETE) require the kbn-xsrf header.
KIBANA_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}


def get_value_by_dot_notation(dictionary, key):
    """
    Get dictionary value by key using dot notation.

    Args:
        dictionary (dict): The dictionary to search within.
        key (str): The key in dot notation.

    Returns:
        The value corresponding to the key if found, otherwise None.
    """
    value = dictionary
    demisto.debug("Trying to get value by dot notation")
    for k in key.split("."):
        if isinstance(value, dict):
            value = value.get(k)
        else:
            demisto.debug(f"Last value is not a dict, returning None. {value=}")
            return None
    return value


def get_alert_source_value(source: Any, key: str) -> Any:
    """Reads a field from an alert ``_source``, supporting both flat-dotted and nested key layouts.

    Alerts-as-data indices store keys flat (``{"kibana.alert.uuid": ...}``) while older
    ``.siem-signals-*`` documents use a nested layout. The flat key is checked first, then the
    nested walk via ``get_value_by_dot_notation``.
    """
    if not isinstance(source, dict):
        return None
    if key in source:
        return source[key]
    return get_value_by_dot_notation(source, key)


def convert_date_to_timestamp(date):
    """converts datetime to the relevant timestamp format.

    Args:
        date(datetime): A datetime object setting up the last fetch time

    Returns:
        (num | str): The formatted timestamp
    """
    demisto.debug(f"Converting date to timestamp: {date}")
    # this theoretically shouldn't happen but just in case
    if str(date).isdigit():
        return int(date)

    if TIME_METHOD == "Timestamp-Seconds":
        return int(date.timestamp())

    if TIME_METHOD == "Timestamp-Milliseconds":
        return int(date.timestamp() * 1000)

    # In case of 'Simple-Date'.
    return datetime.strftime(date, PYTHON_DEFAULT_DATETIME_FORMAT)


def timestamp_to_date(timestamp_string):
    """Converts a timestamp string to a datetime object.

    Args:
        timestamp_string(string): A string with a timestamp in it.

    Returns:
        (datetime).represented by the timestamp in the format '%Y-%m-%d %H:%M:%S.%f'
    """
    timestamp_number: float
    # find timestamp in form of more than seconds since epoch: 1572164838000
    if TIME_METHOD == "Timestamp-Milliseconds":
        timestamp_number = float(int(timestamp_string) / 1000)

    # find timestamp in form of seconds since epoch: 1572164838
    else:  # TIME_METHOD == 'Timestamp-Seconds':
        demisto.debug(f"{TIME_METHOD=}. Should be Timestamp-Seconds.")
        timestamp_number = float(timestamp_string)

    # convert timestamp (a floating point number representing time since epoch) to datetime
    return datetime.utcfromtimestamp(timestamp_number)


def get_api_key_header_val(api_key):
    """
    Check the type of the passed api_key and return the correct header value
    for the `API Key authentication
    <https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html>`
    :arg api_key, either a tuple or a base64 encoded string
    """
    if isinstance(api_key, tuple | list):
        s = f"{api_key[0]}:{api_key[1]}".encode()
        return "ApiKey " + base64.b64encode(s).decode("utf-8")
    return "ApiKey " + api_key


def is_access_token_expired(expires_in: str) -> bool:
    """Check if access token is expired.

    Args:
        expires_in: ISO format datetime string representing when the token expires (UTC)

    Returns:
        bool: True if token is expired or will expire within 1 minute, False otherwise
    """
    try:
        # Parse the expires_in string to a UTC datetime object
        expiration_time = datetime.strptime(expires_in, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)

        # Subtract 1 min to refresh slightly early and avoid expiration issues.
        current_time_with_buffer = datetime.now(UTC) + timedelta(minutes=1)

        is_not_expired = expiration_time > current_time_with_buffer
        if is_not_expired:
            demisto.debug(
                f"is_access_token_expired - using existing Access token from integration context (expires in {expires_in})."
            )
            return False
        else:
            demisto.debug("is_access_token_expired - Access token expired.")
            return True
    except (ValueError, TypeError) as e:
        demisto.debug(f"is_access_token_expired - Error parsing expiration time: {e}. Treating as expired.")
        return True


def get_elastic_token():
    """
    Authenticates and retrieves an OAuth 2.0 access token from Elasticsearch.

    Returns an access token either by refreshing an existing token or performing a new token request.
        1. Check if existing access token is valid (with 1min buffer).
        2. If not, try to use refresh token if it exists and is valid.
        3. If not, perform a full password grant authentication for receiving initial access token.
    """
    try:
        url = urljoin(SERVER, "_security/oauth2/token")
        headers = {"Content-Type": "application/json"}

        integration_context = get_integration_context()
        access_token = integration_context.get("access_token", "")
        access_token_expires_in = integration_context.get("access_token_expires_in", "")
        refresh_token = integration_context.get("refresh_token", "")
        refresh_token_expires_in = integration_context.get("refresh_token_expires_in", "")

        # 1. Check if token exists and if it is still valid
        if access_token and not is_access_token_expired(access_token_expires_in):
            demisto.debug("get_elastic_token - Using existing access token from integration context.")
            return access_token

        if not USERNAME or not PASSWORD:
            demisto.debug("get_elastic_token - username or password fields are missing.")
            raise DemistoException("username or password fields are missing.")

        # 2. Token exists but expired, and refresh token is valid
        if refresh_token and not is_access_token_expired(refresh_token_expires_in):
            demisto.debug(
                "get_elastic_token - Access token expired, but Refresh token valid. Attempting to get token using refresh token"
            )

            payload = {"grant_type": "refresh_token", "refresh_token": refresh_token}
            response = requests.post(url, headers=headers, json=payload, verify=INSECURE, auth=(USERNAME, PASSWORD))

            if response.status_code == 200:
                now = datetime.now(UTC)
                token_data = response.json()
                access_token_expires_in = (now + timedelta(seconds=token_data.get("expires_in"))).strftime("%Y-%m-%dT%H:%M:%SZ")
                refresh_token_expires_in = (now + timedelta(hours=24)).strftime(
                    "%Y-%m-%dT%H:%M:%SZ"
                )  # refresh token has a lifetime of 24 hours

                integration_context.update(
                    {
                        "access_token": token_data.get("access_token"),
                        "refresh_token": token_data.get("refresh_token"),
                        "access_token_expires_in": access_token_expires_in,
                        "refresh_token_expires_in": refresh_token_expires_in,
                    }
                )
                set_integration_context(integration_context)
                demisto.debug(
                    "get_elastic_token - Access token received successfully by refresh token and set to integration context."
                )
                return integration_context["access_token"]

            # If refresh fails, clear the refresh token to force generating of new token
            demisto.debug("get_elastic_token - refresh fails, a new token will be generated via password grant.")
            integration_context.update({"refresh_token": None, "refresh_token_expires_in": None})
            set_integration_context(integration_context)

        # Generate a new access vi password grant
        demisto.debug("get_elastic_token - Attempting to get token using grant_type:password")

        payload = {"grant_type": "password", "username": USERNAME, "password": PASSWORD}
        response = requests.post(url, headers=headers, auth=(USERNAME, PASSWORD), json=payload, verify=INSECURE)
        if response.status_code == 200:
            now = datetime.now(UTC)
            token_data = response.json()
            access_token_expires_in = (now + timedelta(seconds=token_data.get("expires_in"))).strftime("%Y-%m-%dT%H:%M:%SZ")
            refresh_token_expires_in = (now + timedelta(hours=24)).strftime(
                "%Y-%m-%dT%H:%M:%SZ"
            )  # refresh token has a lifetime of 24 hours

            integration_context.update(
                {
                    "access_token": token_data.get("access_token"),
                    "refresh_token": token_data.get("refresh_token"),
                    "access_token_expires_in": access_token_expires_in,
                    "refresh_token_expires_in": refresh_token_expires_in,
                }
            )
            set_integration_context(integration_context)
            demisto.debug(
                "get_elastic_token - Access token received successfully via password grant and set to integration context."
            )
            return integration_context["access_token"]

        demisto.debug(f"Failed to authenticate: {response.status_code}\n{response.text}")
        try:
            reason = json.loads(response.text).get("error", {}).get("reason")
        except Exception:
            reason = response.reason or response.text
        raise DemistoException(f"{response.status_code}, {reason}")

    except Exception as e:
        demisto.debug(f"get_elastic_token error: \n{str(e)}")
        raise DemistoException(f"{str(e)}")


def get_kibana_base_url() -> str:
    """
    Resolves the Kibana base URL.

    The explicitly configured "Kibana Server URL" parameter always wins. It is the only option that
    works for on-premises (self-managed) deployments, where Kibana is typically hosted separately
    from Elasticsearch (for example, https://kibana.example.com:5601).

    When that parameter is empty, the URL is derived from the Elasticsearch Server URL. Elastic Cloud
    deployments expose Elasticsearch and Kibana on the same domain, differentiated only by the
    ".es." / ".kb." subdomain segment, e.g.:
        https://my-deployment-af38b6.es.us-central1.gcp.cloud.es.io
        https://my-deployment-af38b6.kb.us-central1.gcp.cloud.es.io

    Returns:
        str: The Kibana base URL (no trailing slash).

    Raises:
        DemistoException: If no Kibana Server URL is configured and the Server URL does not contain
            the ".es." segment, so a Kibana URL cannot be derived from it.
    """
    kibana_server = (PARAMS.get("kibana_url") or "").rstrip("/")
    if kibana_server:
        demisto.debug(f"{KIBANA_LOG_PREFIX} Using the configured Kibana Server URL: {kibana_server}")
        return kibana_server

    if ".es." in SERVER:
        derived_url = SERVER.replace(".es.", ".kb.", 1)
        demisto.debug(f"{KIBANA_LOG_PREFIX} Derived the Kibana URL from the Elastic Cloud Server URL: {derived_url}")
        return derived_url

    raise DemistoException(
        "Could not determine the Kibana URL. "
        'Set the "Kibana Server URL" parameter in the integration instance configuration '
        '(for example, "https://kibana.example.com:5601"). '
        'It can be omitted only for Elastic Cloud deployments, whose Server URL contains ".es." '
        '(e.g. "https://my-deployment.es.us-central1.gcp.cloud.es.io") and is therefore used to derive it. '
        f"Configured Server URL: {SERVER}"
    )


def get_kibana_auth_headers() -> Dict[str, str]:
    """
    Builds the Authorization header for Kibana REST API requests, reusing the
    integration's configured authentication (Basic auth, Bearer auth or API key auth).

    Returns:
        Dict[str, str]: A dict containing the "Authorization" header value.
    """
    if AUTH_TYPE == API_KEY_AUTH and API_KEY:
        return {"Authorization": get_api_key_header_val(API_KEY)}

    if AUTH_TYPE == BEARER_AUTH:
        return {"Authorization": f"Bearer {get_elastic_token()}"}

    if AUTH_TYPE == BASIC_AUTH and USERNAME and PASSWORD:
        basic_token = base64.b64encode(f"{USERNAME}:{PASSWORD}".encode()).decode("utf-8")
        return {"Authorization": f"Basic {basic_token}"}

    raise DemistoException(f"Missing or unsupported credentials for authentication type: {AUTH_TYPE}")


def build_kibana_path(path: str, space_id: Optional[str] = None) -> str:
    """
    Prefixes a Kibana API path with the space, if a space_id is provided.

    Args:
        path: The Kibana API path, e.g. "/api/cases".
        space_id: Optional Kibana space ID.

    Returns:
        str: The (optionally space-prefixed) path, e.g. "/s/my-space/api/cases".
    """
    path = path if path.startswith("/") else f"/{path}"
    if space_id:
        return f"/s/{space_id}{path}"
    return path


def kibana_http_request(
    method: str,
    path: str,
    space_id: Optional[str] = None,
    params: Optional[Dict[str, Any]] = None,
    json_data: Optional[Any] = None,
    files: Optional[Dict[str, Any]] = None,
    proxies: Optional[Dict[str, str]] = None,
    ok_codes: Optional[tuple] = None,
    allow_not_found: bool = False,
) -> Any:
    """
    Performs an HTTP request against the Kibana REST API.

    Reuses the integration's Elasticsearch authentication configuration and automatically:
      - Derives the Kibana base URL from the Server URL.
      - Prefixes the path with the space ID, when provided.
      - Adds the "kbn-xsrf" header required by Kibana for write operations (POST/PUT/PATCH/DELETE).

    Args:
        method: HTTP method, e.g. "GET", "POST", "PUT", "DELETE".
        path: The Kibana API path, e.g. "/api/cases".
        space_id: Optional Kibana space ID. Falls back to the "Space ID" configuration parameter when not provided.
        params: Optional query-string parameters.
        json_data: Optional JSON request body.
        files: Optional dict of files for multipart/form-data requests (e.g. file attachments).
        proxies: Optional proxies dict, as returned by handle_proxy().
        ok_codes: Optional tuple of HTTP status codes considered successful. Defaults to (200, 201, 204).
        allow_not_found: When True, a 404 response is treated as "no entries found" and None is
            returned instead of raising a DemistoException. Intended for read (GET) commands where
            a missing resource should be surfaced to the user as an empty result rather than an error.

    Returns:
        Any: The parsed JSON response, an empty dict for empty (e.g. 204) responses, or None when
            allow_not_found is True and the response status code is 404.

    Raises:
        DemistoException: If the request fails or returns an unexpected status code.
    """
    ok_codes = ok_codes or (200, 201, 204)
    space_id = space_id or DEFAULT_SPACE_ID
    method = method.upper()

    url = urljoin(get_kibana_base_url(), build_kibana_path(path, space_id))
    headers = get_kibana_auth_headers()
    if method in KIBANA_WRITE_METHODS:
        headers.update(KIBANA_XSRF_HEADER)

    demisto.debug(f"Sending Kibana {method} request to {url}")
    try:
        response = requests.request(
            method=method,
            url=url,
            headers=headers,
            params=params,
            json=json_data if not files else None,
            data=json_data if files else None,
            files=files,
            verify=INSECURE,
            proxies=proxies,
            timeout=TIMEOUT,
        )
    except requests.exceptions.RequestException as e:
        raise DemistoException(f"Failed connecting to Kibana at {url}: {e}")

    if allow_not_found and response.status_code == 404:
        demisto.debug(f"Kibana API request to {url} returned 404, treating as no entries found.")
        return None

    if response.status_code not in ok_codes:
        error_message = response.text
        try:
            error_json = response.json()
            error_message = error_json.get("message") or error_json.get("error") or error_message
        except ValueError:
            pass
        raise DemistoException(f"Kibana API request to {url} failed with status {response.status_code}: {error_message}")

    if not response.content:
        return {}
    try:
        return response.json()
    except ValueError:
        return response.text


def get_json_body_from_entry_id(entry_id: str) -> Any:
    """
    Reads a war-room file attachment referenced by entry_id and parses its content as JSON.

    Used by es-kibana-* commands that support an "entry_id" argument allowing the full
    request body to be supplied as an uploaded JSON file, overriding individual arguments.

    Args:
        entry_id: The war-room file entry ID.

    Returns:
        Any: The parsed JSON content of the file.

    Raises:
        DemistoException: If the file cannot be found, read, or parsed as JSON.
    """
    try:
        file_info = demisto.getFilePath(entry_id)
    except Exception as e:
        raise DemistoException(f"Failed to retrieve file info for entry_id={entry_id}: {e}")

    file_path = file_info.get("path") if file_info else None
    if not file_path:
        raise DemistoException(f"Could not resolve file path for entry_id={entry_id}")

    try:
        with open(file_path, encoding="utf-8") as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        raise DemistoException(f"The file for entry_id={entry_id} does not contain valid JSON: {e}")
    except OSError as e:
        raise DemistoException(f"Failed reading file for entry_id={entry_id}: {e}")


def elasticsearch_builder(proxies):
    """Builds an Elasticsearch obj with the necessary credentials, proxy settings and secure connection."""

    connection_args: Dict[str, Union[bool, int, str, list, tuple[str, str], RequestsHttpConnection]] = {
        "hosts": [SERVER],
        "verify_certs": INSECURE,
        "timeout": TIMEOUT,
    }
    demisto.debug(f"Building Elasticsearch client with args: {connection_args}")
    if ELASTIC_SEARCH_CLIENT not in [ELASTICSEARCH_V9, ELASTICSEARCH_V8]:
        # Adding the proxy related parameters to the Elasticsearch client v7 and below or OpenSearch (BC)
        connection_args["connection_class"] = RequestsHttpConnection  # type: ignore[assignment]
        connection_args["proxies"] = proxies

    else:
        # Adding the proxy related parameter to the Elasticsearch client v8
        # Reference- https://github.com/elastic/elastic-transport-python/issues/53#issuecomment-1447903214
        class CustomHttpNode(RequestsHttpNode):  # pylint: disable=E0601
            def __init__(self, *args, **kwargs):
                super().__init__(*args, **kwargs)
                self.session.proxies = proxies

        connection_args["node_class"] = CustomHttpNode  # type: ignore[assignment]

    if AUTH_TYPE == API_KEY_AUTH and API_KEY:
        connection_args["api_key"] = API_KEY

    elif AUTH_TYPE == BASIC_AUTH and USERNAME and PASSWORD:
        if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8]:
            connection_args["basic_auth"] = (USERNAME, PASSWORD)
        else:  # Elasticsearch version v7 and below or OpenSearch (BC)
            connection_args["http_auth"] = (USERNAME, PASSWORD)

    elif AUTH_TYPE == BEARER_AUTH:
        connection_args["bearer_auth"] = get_elastic_token()

    es = Elasticsearch(**connection_args)  # type: ignore[arg-type]

    # Ensuring api_key will be set correctly in case the authentication type is API key auth.
    # this should be passed as api_key via Elasticsearch init, but this code ensures it'll be set correctly
    # In some versions of the ES library, the transport object does not have a get_session func
    if AUTH_TYPE == API_KEY_AUTH and hasattr(es, "transport") and hasattr(es.transport, "get_connection"):
        es.transport.get_connection().session.headers["authorization"] = get_api_key_header_val(  # type: ignore[attr-defined]
            API_KEY
        )

    return es


def get_hit_table(hit):
    """Create context for a single hit in the search.

    Args:
        hit(Dict): a dictionary representing a single hit in the search.

    Returns:
        (dict).The hit context.
        (list).the headers of the hit.
    """
    table_context = {
        "_index": hit.get("_index"),
        "_id": hit.get("_id"),
        "_type": hit.get("_type"),
        "_score": hit.get("_score"),
    }
    headers = ["_index", "_id", "_type", "_score"]

    if hit.get("_source") is not None:
        for source_field in hit.get("_source"):
            table_context[str(source_field)] = hit.get("_source").get(str(source_field))
            headers.append(source_field)

    # Add normalized Elasticsearch fields to the table context
    if hit.get("fields") is not None:
        for field_name, field_value in hit.get("fields").items():
            normalized_key = f"fields.{field_name}"
            # Elasticsearch always returns field values as lists,
            # we unwrap single-element lists for readability
            if isinstance(field_value, list):
                table_context[normalized_key] = field_value[0] if len(field_value) == 1 else field_value
            else:
                table_context[normalized_key] = field_value
            headers.append(normalized_key)

    return table_context, headers


def results_to_context(index, query, base_page, size, total_dict, response, event=False):
    """Creates context for the full results of a search.

    Args:
        index(str): the index in which the search was made.
        query(str): the query of the search.
        base_page(int): the base page from which the search is made.
        size(int): the amount of results to return.
        total_dict(dict): a dictionary containing the info about thenumber of total results found
        response(Dict): the raw response of the results.

    Returns:
        (dict).The full context for the search results.
        (list).The metadata headers of the search.
        (list).the context for the hits.
        (list).the headers of the hits.
    """
    search_context = {
        "Server": SERVER,
        "Index": index,
        "Query": query,
        "Page": base_page,
        "Size": size,
        "total": total_dict,
        "max_score": response.get("hits").get("max_score"),
        "took": response.get("took"),
        "timed_out": response.get("timed_out"),
    }

    if aggregations := response.get("aggregations"):
        search_context["aggregations"] = aggregations

    hit_headers = []  # type: List
    hit_tables = []
    if total_dict.get("value") > 0:
        if not event:
            results = response.get("hits").get("hits", [])
        else:
            results = response.get("hits").get("events", [])

        for hit in results:
            single_hit_table, single_header = get_hit_table(hit)
            hit_tables.append(single_hit_table)
            hit_headers = list(set(single_header + hit_headers) - {"_id", "_type", "_index", "_score"})
        hit_headers = ["_id", "_index", "_type", "_score"] + hit_headers

    search_context["Results"] = response.get("hits").get("hits")
    meta_headers = ["Query", "took", "timed_out", "total", "max_score", "Server", "Page", "Size", "aggregations"]
    return search_context, meta_headers, hit_tables, hit_headers


def get_total_results(response_dict):
    """Creates a dictionary with all for the number of total results found

    Args:
        response_dict(dict): the raw response from elastic search.

    Returns:
        (dict).The total results info for the context.
        (num).The number of total results.
    """
    total_results = response_dict.get("hits", {}).get("total")
    if not str(total_results).isdigit():
        # if in version 7 - total number of hits has value field
        total_results = total_results.get("value")
        total_dict = response_dict.get("hits").get("total")

    else:
        total_dict = {
            "value": total_results,
        }

    return total_dict, total_results


def search_command(proxies):
    """Performs a search in Elasticsearch."""
    index = demisto.args().get("index")
    query = demisto.args().get("query")
    fields = demisto.args().get("fields")  # fields to display
    explain = demisto.args().get("explain", "false").lower() == "true"
    base_page = int(demisto.args().get("page"))
    size = int(demisto.args().get("size"))
    sort_field = demisto.args().get("sort-field")
    sort_order = demisto.args().get("sort-order")
    query_dsl = demisto.args().get("query_dsl")
    timestamp_field = demisto.args().get("timestamp_field")
    timestamp_range_start = demisto.args().get("timestamp_range_start")
    timestamp_range_end = demisto.args().get("timestamp_range_end")

    if query and query_dsl:
        return_error("Both query and query_dsl are configured. Please choose between query or query_dsl.")

    es = elasticsearch_builder(proxies)
    time_range_dict = None
    if timestamp_range_end or timestamp_range_start:
        time_range_dict = get_time_range(
            time_range_start=timestamp_range_start,
            time_range_end=timestamp_range_end,
            time_field=timestamp_field,
        )
    demisto.debug(f"Executing search with index={index}, query={query}, query_dsl={query_dsl}")

    if query_dsl:
        query_dsl = query_string_to_dict(query_dsl)
        if query_dsl.get("size", False) or query_dsl.get("page", False):
            response = execute_raw_query(es, query_dsl, index)
        else:
            response = execute_raw_query(es, query_dsl, index, size, base_page)

    else:
        que = QueryString(query=query)
        search = Search(using=es, index=index).query(que)[base_page : base_page + size]
        if explain:
            # if 'explain parameter is set to 'true' - adds explanation section to search results
            search = search.extra(explain=True)

        if time_range_dict:
            search = search.filter(time_range_dict)

        if fields is not None:
            fields = fields.split(",")
            search = search.source(fields)

        if sort_field is not None:
            search = search.sort({sort_field: {"order": sort_order}})

        if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
            response = search.execute().to_dict()

        else:  # Elasticsearch v7 and below
            # maintain BC by using the ES client directly (avoid using the elasticsearch_dsl library here)
            response = es.search(index=search._index, body=search.to_dict(), **search._params)

    demisto.debug(f"Search response: {response}")
    total_dict, total_results = get_total_results(response)
    search_context, meta_headers, hit_tables, hit_headers = results_to_context(
        index, query_dsl or query, base_page, size, total_dict, response
    )
    search_human_readable = tableToMarkdown("Search Metadata:", search_context, meta_headers, removeNull=True)
    hits_human_readable = tableToMarkdown("Hits:", hit_tables, hit_headers, removeNull=True)
    total_human_readable = search_human_readable + "\n" + hits_human_readable
    full_context = {
        "Elasticsearch.Search(val.Query == obj.Query && val.Index == obj.Index "
        "&& val.Server == obj.Server && val.Page == obj.Page && val.Size == obj.Size)": search_context
    }

    return_outputs(total_human_readable, full_context, response)


def fetch_params_check():
    """If is_fetch is ticked, this function checks that all the necessary parameters for the fetch are entered."""
    str_error = []  # type:List
    if (TIME_FIELD == "" or TIME_FIELD is None) and not RAW_QUERY:
        str_error.append("Index time field is not configured.")

    if not FETCH_QUERY:
        str_error.append("Query by which to fetch incidents is not configured.")

    if RAW_QUERY and FETCH_QUERY_PARM:
        str_error.append("Both Query and Raw Query are configured. Please choose between Query or Raw Query.")

    if len(str_error) > 0:
        return_error("Got the following errors in test:\nFetches incidents is enabled.\n" + "\n".join(str_error))


def test_query_to_fetch_incident_index(es):
    """Test executing query in fetch index.

    Notes:
        if is_fetch it ticked, this function runs a general query to Elasticsearch just to make sure we get a response
        from the FETCH_INDEX.

    Args:
        es(Elasticsearch): an Elasticsearch object to which we run the test.
    """
    try:
        query = QueryString(query="*")
        search = Search(using=es, index=FETCH_INDEX).query(query)[0:1]

        if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8]:
            response = search.execute().to_dict()

        else:  # Elasticsearch v7 and below or OpenSearch
            # maintain BC by using the ES client directly (avoid using the elasticsearch_dsl library here)
            response = es.search(index=search._index, body=search.to_dict(), **search._params)

        demisto.debug(f"Test query to fetch incident index response: {response}")
        _, total_results = get_total_results(response)

    except NotFoundError as e:
        return_error("Fetch incidents test failed.\nError message: {}.".format(str(e).split(",")[2][2:-1]))


def test_general_query(es):
    """Test executing query to all available indexes.

    Args:
        es(Elasticsearch): an Elasticsearch object to which we run the test.
    """
    try:
        query = QueryString(query="*")
        search = Search(using=es, index="*").query(query)[0:1]

        if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
            response = search.execute().to_dict()

        else:  # Elasticsearch v7 and below
            # maintain BC by using the ES client directly (avoid using the elasticsearch_dsl library here)
            response = es.search(index=search._index, body=search.to_dict(), **search._params)

        demisto.debug(f"Test general query response: {response}")
        get_total_results(response)

    except NotFoundError as e:
        return_error(
            f"Failed executing general search command - please check the Server URL and port number "
            f"and the supplied credentials.\nError message: {e!s}."
        )


def test_time_field_query(es):
    """Test executing query of fetch time field.

    Notes:
        if is_fetch is ticked, this function checks if the entered TIME_FIELD returns results.

    Args:
        es(Elasticsearch): an Elasticsearch object to which we run the test.

    Returns:
        (dict).The results of the query if they are returned.
    """
    query = QueryString(query=TIME_FIELD + ":*")
    search = Search(using=es, index=FETCH_INDEX).query(query)[0:1]

    if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
        response = search.execute().to_dict()

    else:  # Elasticsearch v7 and below
        # maintain BC by using the ES client directly (avoid using the elasticsearch_dsl library here)
        response = es.search(index=search._index, body=search.to_dict(), **search._params)

    demisto.debug(f"Test time field query response: {response}")
    _, total_results = get_total_results(response)

    if total_results == 0:
        # failed in getting the TIME_FIELD
        raise Exception(f"Fetch incidents test failed.\nDate field value incorrect [{TIME_FIELD}].")

    else:
        return response


def test_fetch_query(es):
    """Test executing fetch query.

    Notes:
        if is_fetch is ticked, this function checks if the FETCH_QUERY returns results.

    Args:
        es(Elasticsearch): an Elasticsearch object to which we run the test.

    Returns:
        (dict).The results of the query if they are returned.
    """
    query = QueryString(query=str(TIME_FIELD) + ":* AND " + FETCH_QUERY)
    search = Search(using=es, index=FETCH_INDEX).query(query)[0:1]

    if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
        response = search.execute().to_dict()

    else:  # Elasticsearch v7 and below
        # maintain BC by using the ES client directly (avoid using the elasticsearch_dsl library here)
        response = es.search(index=search._index, body=search.to_dict(), **search._params)

    demisto.debug(f"Test fetch query response: {response}")
    return response


def test_timestamp_format(timestamp):
    """if is_fetch is ticked and the TIME_METHOD chosen is a type of timestamp - this function checks that
        the timestamp is in the correct format.

    Args:
        timestamp(sting): a timestamp string.
    """
    timestamp_in_seconds_len = len(str(int(time.time())))

    if TIME_METHOD == "Timestamp-Seconds":
        if not timestamp.isdigit():
            return_error(f"The time field does not contain a standard timestamp.\nFetched: {timestamp}")

        elif len(timestamp) > timestamp_in_seconds_len:
            return_error(f"Fetched timestamp is not in seconds since epoch.\nFetched: {timestamp}")

    elif TIME_METHOD == "Timestamp-Milliseconds":
        if not timestamp.isdigit():
            return_error(f"The timestamp fetched is not in milliseconds.\nFetched: {timestamp}")

        elif len(timestamp) <= timestamp_in_seconds_len:
            return_error(f"Fetched timestamp is not in milliseconds since epoch.\nFetched: {timestamp}")


def test_connectivity_auth(proxies) -> tuple[bool, str]:
    """
    Test connectivity and authentication with Elasticsearch server
    Args:
        proxies (dict): Dictionary of proxy settings

    Returns:
        tuple[bool, str]: (success status, message)
    """

    demisto.debug("test_connectivity_auth started")
    headers = {"Content-Type": "application/json"}
    res = None

    try:
        if AUTH_TYPE == BASIC_AUTH:
            demisto.debug("test_connectivity_auth - Basic auth setting authorization header and sending request")
            res = requests.get(SERVER, auth=(USERNAME, PASSWORD), verify=INSECURE, headers=headers)

        elif AUTH_TYPE == API_KEY_AUTH:
            demisto.debug("test_connectivity_auth - API key auth setting authorization header and sending request")
            headers["authorization"] = get_api_key_header_val(API_KEY)
            res = requests.get(SERVER, verify=INSECURE, headers=headers)

        elif AUTH_TYPE == BEARER_AUTH:
            demisto.debug("test_connectivity_auth - Bearer auth setting authorization header and sending request")
            headers["Authorization"] = f"Bearer {get_elastic_token()}"
            res = requests.get(SERVER, verify=INSECURE, headers=headers)

        if res is not None:
            if res.status_code >= 400:
                demisto.debug(f"test_connectivity_auth - Failed to connect.\n{res.status_code=}, {res.text=}")
                return False, f"Failed to connect.\nStatus:{res.status_code}, {res.reason}"

            elif res.status_code == 200:
                demisto.debug("test_connectivity_auth - Connectivity test successful")
                verify_es_server_version(res.json())
                return True, "Connectivity test successful"

        return False, "No response received from server"

    except Exception as e:
        demisto.debug(f"test_connectivity_auth - Failed to connect.\nError message: {e}")
        return False, f"Failed to connect.\n{e}"


def verify_es_server_version(res):
    """
    Gets the requests.get raw response, extracts the elasticsearch server version,
    and verifies that the client type parameter is configured accordingly.
    Raises exceptions for server version miss configuration issues.

    Args:
        res(dict): requests.models.Response object including information regarding the elasticsearch server.
    """
    es_server_version = res.get("version", {}).get("number", "")
    demisto.debug(f"Elasticsearch server version is: {es_server_version}")
    if es_server_version:
        major_version = es_server_version.split(".")[0]
        if major_version:
            if int(major_version) >= 8 and ELASTIC_SEARCH_CLIENT not in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
                raise ValueError(
                    f"Configuration Error: Your Elasticsearch server is version {es_server_version}. "
                    f"Please ensure that the client type is set to {ELASTICSEARCH_V9}, {ELASTICSEARCH_V8} or {OPEN_SEARCH}. "
                    f"For more information please see the integration documentation."
                )
            elif int(major_version) <= 7 and ELASTIC_SEARCH_CLIENT not in [OPEN_SEARCH, "Elasticsearch"]:
                raise ValueError(
                    f"Configuration Error: Your Elasticsearch server is version {es_server_version}. "
                    f"Please ensure that the client type is set to Elasticsearch or {OPEN_SEARCH}. "
                    f"For more information please see the integration documentation."
                )


def test_func(proxies):
    """
    Tests API connectivity to the Elasticsearch server.
    Tests the existence of all necessary fields for fetch.

    Due to load considerations, the test module doesn't check the validity of the fetch-incident - to test that the fetch works
    as excepted the user should run the es-integration-health-check command.

    """
    success, message = test_connectivity_auth(proxies)
    if not success:
        return message
    if demisto.params().get("isFetch"):
        # check the existence of all necessary fields for fetch
        fetch_params_check()
    return "ok"


def integration_health_check(proxies):
    success, message = test_connectivity_auth(proxies)
    if not success:
        raise DemistoException(message)
    # build general Elasticsearch class
    es = elasticsearch_builder(proxies)

    if demisto.params().get("isFetch"):
        # check the existence of all necessary fields for fetch
        fetch_params_check()

        try:
            # test if FETCH_INDEX exists
            test_query_to_fetch_incident_index(es)

            # test if TIME_FIELD in index exists
            response = test_time_field_query(es)

            # get the value in the time field
            source = response.get("hits", {}).get("hits")[0].get("_source", {})
            hit_date = str(get_value_by_dot_notation(source, str(TIME_FIELD)))

            demisto.debug(f"Hit date received: {hit_date}")
            # if not a timestamp test the conversion to datetime object
            if "Timestamp" not in TIME_METHOD:
                parse(str(hit_date))

            # test timestamp format and conversion to date
            else:
                test_timestamp_format(hit_date)
                timestamp_to_date(hit_date)

        except ValueError as e:
            return_error("Inserted time format is incorrect.\n" + str(e) + "\n" + TIME_FIELD + " fetched: " + hit_date)

        # try to get response from FETCH_QUERY or RAW_QUERY
        try:
            if RAW_QUERY:
                fetch_result = execute_raw_query(es, RAW_QUERY)
            else:
                fetch_result = test_fetch_query(es)

            # validate that the response actually returned results and did not time out
            if fetch_result and isinstance(fetch_result.get("timed_out"), bool):
                if fetch_result.get("timed_out"):
                    return_error(f"Elasticsearch fetching has timed out. Fetching response was:\n{str(fetch_result)}")
                _, total_results = get_total_results(fetch_result)
                if total_results == 0:
                    demisto.info("Elasticsearch fetching test returned 0 hits, but this might be expected.")
            else:
                return_error(
                    "Elasticsearch fetching was unsuccessful. Fetching returned the following invalid object:\n"
                    + str(fetch_result)
                )
        except Exception as ex:
            return_error(f"An exception has been thrown trying to test Elasticsearch fetching:\n{str(ex)}", error=str(ex))

    else:
        # check that we can reach any indexes in the supplied server URL
        test_general_query(es)
    return "Testing was successful."


def normalize_es_value(value):
    if isinstance(value, list):
        value = value[0] if len(value) == 1 else value

    if isinstance(value, dict | list):
        return json.dumps(value)

    return str(value)


def incident_label_maker(source, fields=None):
    """Creates labels for the created incident.

    Args:
        source(dict): the _source fields of a hit.
        fields (dict): the normalized fields returned by Elasticsearch.

    Returns:
        (list).The labels.
    """
    labels = []

    for field, value in source.items():
        labels.append({"type": str(field), "value": normalize_es_value(value)})

    if fields:
        for field, value in fields.items():
            labels.append({"type": f"fields.{field}", "value": normalize_es_value(value)})

    return labels


def results_to_incidents_timestamp(response, last_fetch, last_fetch_ids=None):
    """Converts the current results into incidents.

    To avoid silently dropping documents that share an identical timestamp with the
    high-water-mark (last_fetch) - which happens when several documents have the same
    timestamp down to the sub-second and span a fetch boundary - the time query uses an
    inclusive lower bound (gte) and de-duplication is done by document ``_id`` rather than
    by discarding every hit that equals the boundary timestamp. Only the ``_id``s that were
    already ingested at the boundary timestamp are skipped; all other hits are ingested.

    Args:
        response(dict): the raw search results from Elasticsearch.
        last_fetch(num): the date or timestamp of the last fetch before this fetch
        - this will hold the last date of the incident brought by this fetch.
        last_fetch_ids(list): the ``_id``s of the documents already ingested at the
        ``last_fetch`` boundary timestamp in the previous fetch.

    Returns:
        (list).The incidents.
        (num).The date of the last incident brought by this fetch.
        (list).The ``_id``s of the documents ingested at the new boundary timestamp.
    """
    current_fetch = last_fetch
    already_fetched_ids = set(last_fetch_ids or [])
    incidents = []
    # tracks the _ids seen at the maximum timestamp so far in this fetch.
    # seeded with the previously-persisted boundary ids so that ids already ingested at the
    # boundary are carried forward and not re-ingested on the next fetch. It is reset whenever
    # a strictly newer timestamp is encountered.
    new_fetch_ids: list = list(dict.fromkeys(last_fetch_ids or []))
    for hit in response.get("hits", {}).get("hits"):
        source = hit.get("_source")

        # Retrieve normalized fields returned by Elasticsearch.
        # These may contain runtime fields or normalized date values
        # not present in _source.
        fields = hit.get("fields")

        if source is not None:
            time_field_value = get_value_by_dot_notation(source, str(TIME_FIELD))

            # Fallback: if TIME_FIELD is not found in _source,
            # try to retrieve it from normalized fields.
            # This handles cases where TIME_FIELD is a runtime field.
            if time_field_value is None and fields:
                field_value = fields.get(TIME_FIELD)

                if isinstance(field_value, list):
                    field_value = field_value[0] if field_value else None

                if field_value is not None:
                    time_field_value = field_value

            if time_field_value is not None:
                # if timestamp convert to iso format date and save the timestamp
                hit_date = timestamp_to_date(str(time_field_value))
                hit_timestamp = int(time_field_value)
                hit_id = hit.get("_id")

                if hit_timestamp > last_fetch:
                    # a strictly newer timestamp resets the boundary id tracking
                    last_fetch = hit_timestamp
                    new_fetch_ids = []

                # remember every id seen at the current maximum timestamp - including ids
                # that are skipped below because they were already ingested - so the boundary
                # id set is preserved across fetches and does not drift.
                if hit_timestamp == last_fetch and hit_id and hit_id not in new_fetch_ids:
                    new_fetch_ids.append(hit_id)

                # Skip only documents already ingested at the boundary timestamp,
                # instead of dropping every hit that equals the boundary timestamp.
                if hit_timestamp < current_fetch or (hit_id and hit_id in already_fetched_ids):
                    demisto.debug(f"Skipping already-fetched hit ID: {hit_id} with {hit_timestamp=}.")
                    continue

                inc = {
                    "name": "Elasticsearch: Index: " + str(hit.get("_index")) + ", ID: " + str(hit_id),
                    "rawJSON": json.dumps(hit),
                    "occurred": hit_date.isoformat() + "Z",
                }
                if hit_id:
                    inc["dbotMirrorId"] = hit_id

                if MAP_LABELS:
                    inc["labels"] = incident_label_maker(hit.get("_source"))

                incidents.append(inc)

    return incidents, last_fetch, new_fetch_ids


def results_to_incidents_datetime(response, last_fetch, last_fetch_ids=None):
    """Converts the current results into incidents.

    To avoid silently dropping documents that share an identical timestamp with the
    high-water-mark (last_fetch) - which happens when several documents have the same
    timestamp down to the sub-second and span a fetch boundary - the time query uses an
    inclusive lower bound (gte) and de-duplication is done by document ``_id`` rather than
    by discarding every hit that equals the boundary timestamp. Only the ``_id``s that were
    already ingested at the boundary timestamp are skipped; all other hits are ingested.

    Args:
        response(dict): the raw search results from Elasticsearch.
        last_fetch(datetime): the date or timestamp of the last fetch before this fetch or parameter default fetch time
        - this will hold the last date of the incident brought by this fetch.
        last_fetch_ids(list): the ``_id``s of the documents already ingested at the
        ``last_fetch`` boundary timestamp in the previous fetch.

    Returns:
        (list).The incidents.
        (datetime).The date of the last incident brought by this fetch.
        (list).The ``_id``s of the documents ingested at the new boundary timestamp.
    """
    last_fetch = dateparser.parse(last_fetch)
    last_fetch_timestamp = int(last_fetch.timestamp() * 1000)  # type:ignore[union-attr]
    current_fetch = last_fetch_timestamp
    already_fetched_ids = set(last_fetch_ids or [])
    incidents = []
    # tracks the _ids seen at the maximum timestamp so far in this fetch.
    # seeded with the previously-persisted boundary ids so that ids already ingested at the
    # boundary are carried forward and not re-ingested on the next fetch. It is reset whenever
    # a strictly newer timestamp is encountered.
    new_fetch_ids: list = list(dict.fromkeys(last_fetch_ids or []))

    for hit in response.get("hits", {}).get("hits"):
        source = hit.get("_source")

        # Retrieve normalized fields returned by Elasticsearch.
        # These may contain runtime fields or normalized date values
        # not present in _source.
        fields = hit.get("fields")

        if source is not None:
            time_field_value = get_value_by_dot_notation(source, str(TIME_FIELD))

            # Fallback: if TIME_FIELD is not found in _source,
            # try to retrieve it from normalized fields.
            # This handles cases where TIME_FIELD is a runtime field.
            if time_field_value is None and fields:
                field_value = fields.get(TIME_FIELD)

                if isinstance(field_value, list):
                    field_value = field_value[0] if field_value else None

                if field_value is not None:
                    time_field_value = field_value

            if time_field_value is not None:
                hit_date = parse(str(time_field_value))
                hit_timestamp = int(hit_date.timestamp() * 1000)
                hit_id = hit.get("_id")

                if hit_timestamp > last_fetch_timestamp:
                    # a strictly newer timestamp resets the boundary id tracking
                    last_fetch = hit_date
                    last_fetch_timestamp = hit_timestamp
                    new_fetch_ids = []

                # remember every id seen at the current maximum timestamp - including ids
                # that are skipped below because they were already ingested - so the boundary
                # id set is preserved across fetches and does not drift.
                if hit_timestamp == last_fetch_timestamp and hit_id and hit_id not in new_fetch_ids:
                    new_fetch_ids.append(hit_id)

                # Skip only documents already ingested at the boundary timestamp,
                # instead of dropping every hit that equals the boundary timestamp.
                if hit_timestamp < current_fetch or (hit_id and hit_id in already_fetched_ids):
                    demisto.debug(
                        f"Skipping hit ID: {hit_id} since {hit_timestamp=} was already fetched (id previously ingested)"
                    )
                    continue

                inc = {
                    "name": "Elasticsearch: Index: " + str(hit.get("_index")) + ", ID: " + str(hit_id),
                    "rawJSON": json.dumps(hit),
                    # parse function returns iso format sometimes as YYYY-MM-DDThh:mm:ss+00:00
                    # and sometimes as YYYY-MM-DDThh:mm:ss
                    # we want to return format: YYYY-MM-DDThh:mm:ssZ in our incidents
                    "occurred": format_to_iso(hit_date.isoformat()),
                }
                if hit_id:
                    inc["dbotMirrorId"] = hit_id

                if MAP_LABELS:
                    # Pass both _source and normalized fields to label maker
                    inc["labels"] = incident_label_maker(hit.get("_source"), fields=fields)

                incidents.append(inc)

    return incidents, last_fetch.isoformat(), new_fetch_ids  # type:ignore[union-attr]


def format_to_iso(date_string):
    """Formatting function to make sure the date string is in YYYY-MM-DDThh:mm:ssZ format.

    Args:
        date_string(str): a date string in ISO format could be like: YYYY-MM-DDThh:mm:ss+00:00 or: YYYY-MM-DDThh:mm:ss

    Returns:
        str. A date string in the format: YYYY-MM-DDThh:mm:ssZ
    """
    if "." in date_string:
        date_string = date_string.split(".")[0]

    if len(date_string) > 19 and not date_string.endswith("Z"):
        date_string = date_string[:-6]

    if not date_string.endswith("Z"):
        date_string = date_string + "Z"

    return date_string


def get_time_range(
    last_fetch: Union[str, None] = None, time_range_start=FETCH_TIME, time_range_end=None, time_field=TIME_FIELD
) -> Dict:
    """
    Creates the time range filter's dictionary based on the last fetch and given params.
    The filter is using timestamps with the following logic:
        start date (gte) - if this is the first fetch: use time_range_start param if provided, else use fetch time param.
                          if this is not the fetch: use the last fetch provided.
                          Note: an inclusive lower bound (gte) is used so documents that share the exact
                          high-water-mark timestamp are not permanently skipped by the query. De-duplication
                          of documents already ingested at the boundary timestamp is handled by _id in
                          results_to_incidents_datetime / results_to_incidents_timestamp.
        end date (lt) - use the given time range end param.
        When the `time_method` parameter is set to `Simple-Date` in order to avoid being related to the field datetime format,
            we add the format key to the query dict.
    Args:

        last_fetch (str): last fetch time stamp
        time_range_start (str): start of time range
        time_range_end (str): end of time range
        time_field (str): The field on which the filter the results


    Returns:
        dictionary (Ex. {"range":{'gte': 1000 'lt': 1001}})
    """
    range_dict = {}
    if not last_fetch and time_range_start:  # this is the first fetch
        start_date = dateparser.parse(time_range_start)

        start_time = convert_date_to_timestamp(start_date)
    else:
        start_time = last_fetch

    demisto.debug(f"Time range start time: {start_time}")
    if start_time:
        range_dict["gte"] = start_time

    if time_range_end:
        end_date = dateparser.parse(time_range_end)
        end_time = convert_date_to_timestamp(end_date)
        range_dict["lt"] = end_time

    if TIME_METHOD == "Simple-Date":
        range_dict["format"] = ES_DEFAULT_DATETIME_FORMAT

    if utc_offset := re.search(r"([+-]\d{2}:\d{2})$", time_range_start):
        range_dict["time_zone"] = utc_offset.group(1)

    demisto.debug(f"Time range dictionary created: {range_dict}")
    return {"range": {time_field: range_dict}}


def query_string_to_dict(raw_query) -> Dict:
    """Parses a query_dsl string or bytearray into a Dict to make its fields accessible"""
    try:
        if not isinstance(raw_query, Dict):
            raw_query = json.loads(raw_query)
        if raw_query.get("query"):
            demisto.debug("Query provided already has a query field. Sending as is.")
            body = raw_query
        else:
            body = {"query": raw_query}
    except (ValueError, TypeError) as e:
        body = {"query": raw_query}
        demisto.info(f"unable to convert raw query to dictionary, use it as a string\n{e}")
    return body


def execute_raw_query(es, raw_query, index=None, size=None, page=None):
    body = query_string_to_dict(raw_query)

    requested_index = index or FETCH_INDEX

    # update parameters if given
    if isinstance(size, int):
        body["size"] = size
    if isinstance(page, int):
        body["from"] = page

    search = Search(using=es, index=requested_index).update_from_dict(body)

    if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
        response = search.execute().to_dict()
    else:  # Elasticsearch v7 and below
        # maintain BC by using the ES client directly (avoid using the elasticsearch_dsl library here)
        response = es.search(index=search._index, body=search.to_dict(), **search._params)

    demisto.debug(f"Raw query response: {response}")
    return response


def build_fetch_extra_params(fields_list: list) -> dict:
    """Builds the extra request-body parameters for the fetch Search request.

    The "Fields to Fetch" parameter is optional. When it is left blank,
    ``fields_list`` is an empty list and the "fields" key must be omitted from the
    request body entirely. Sending ``"fields": []`` causes Elasticsearch to fail with:
    ``ParsingException 400 'Unknown key for a START_ARRAY in [fields]'``.

    Args:
        fields_list (list): The list of fields to fetch (may be empty).

    Returns:
        dict: The kwargs to pass to ``Search.extra``. Always requests ``_source``,
            and includes ``fields`` only when ``fields_list`` is non-empty.
    """
    extra_params: dict = {"_source": True}
    if fields_list:
        extra_params["fields"] = fields_list
    return extra_params


def fetch_incidents(proxies):
    last_run = demisto.getLastRun()
    last_fetch = last_run.get("time") or FETCH_TIME
    # _ids of documents already ingested at the last_fetch boundary timestamp,
    # used to de-duplicate hits that share an identical timestamp across fetches.
    last_fetch_ids = last_run.get("last_fetch_ids") or []

    es = elasticsearch_builder(proxies)
    time_range_dict = get_time_range(time_range_start=last_fetch)

    if RAW_QUERY:
        response = execute_raw_query(es, RAW_QUERY)
    else:
        query = QueryString(query="(" + FETCH_QUERY + ") AND " + TIME_FIELD + ":*")
        # Elastic search can use epoch timestamps (in milliseconds) as date representation regardless of date format.
        search = Search(using=es, index=FETCH_INDEX).filter(time_range_dict)
        search = search.sort({TIME_FIELD: {"order": "asc"}})[0:FETCH_SIZE].query(query)
        # Only add the "fields" key to the request body when there are fields to fetch.
        # Passing an empty list results in "fields": [] in the body, which Elasticsearch
        # rejects with: ParsingException 400 'Unknown key for a START_ARRAY in [fields]'.
        search = search.extra(**build_fetch_extra_params(FIELDS_LIST))

        if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
            response = search.execute().to_dict()

        else:  # Elasticsearch v7 and below
            # maintain BC by using the ES client directly (avoid using the elasticsearch_dsl library here)
            response = es.search(index=search._index, body=search.to_dict(), **search._params)

    demisto.debug(f"Fetch incidents response: {response}")
    _, total_results = get_total_results(response)

    incidents = []  # type: List

    if total_results > 0:
        if "Timestamp" in TIME_METHOD:
            incidents, last_fetch, last_fetch_ids = results_to_incidents_timestamp(response, last_fetch, last_fetch_ids)
            demisto.setLastRun({"time": last_fetch, "last_fetch_ids": last_fetch_ids})

        else:
            incidents, last_fetch, last_fetch_ids = results_to_incidents_datetime(
                response, last_fetch or FETCH_TIME, last_fetch_ids
            )
            demisto.setLastRun({"time": str(last_fetch), "last_fetch_ids": last_fetch_ids})

        demisto.info(f"Extracted {len(incidents)} incidents.")
    demisto.incidents(incidents)


def parse_subtree(my_map):
    """
    param: my_map - tree element for the schema
    return: tree elements under each branch
    """
    # Recursive search in order to retrieve the elements under the branches in the schema
    res = {}
    for k in my_map:
        if "properties" in my_map[k]:
            res[k] = parse_subtree(my_map[k]["properties"])
        else:
            res[k] = "type: " + my_map[k].get("type", "")
    return res


def update_elastic_mapping(res_json, elastic_mapping, key):
    """
    A helper function for get_mapping_fields_command, updates the elastic mapping.
    """
    my_map = res_json[key]["mappings"]["properties"]
    elastic_mapping[key] = {"_id": "doc_id", "_index": key}
    elastic_mapping[key]["_source"] = parse_subtree(my_map)


def get_mapping_fields_command():
    """
    Maps a schema from a given index
    return: Elasticsearch schema structure
    """
    indexes = FETCH_INDEX.split(",")
    elastic_mapping = {}  # type:ignore[var-annotated]
    for index in indexes:
        if index == "":
            res = requests.get(SERVER + "/_mapping", auth=(USERNAME, PASSWORD), verify=INSECURE)
        else:
            res = requests.get(SERVER + "/" + index + "/_mapping", auth=(USERNAME, PASSWORD), verify=INSECURE)
        res_json = res.json()

        # To get mappings for all data streams and indices in a cluster,
        # use _all or * for <target> or omit the <target> parameter - from Elastic API
        if index in ["*", "_all", ""]:
            for key in res_json:
                if "mappings" in res_json[key] and "properties" in res_json[key]["mappings"]:
                    update_elastic_mapping(res_json, elastic_mapping, key)

        elif index.endswith("*"):
            prefix_index = re.compile(index.rstrip("*"))
            for key in res_json:
                if prefix_index.match(key):
                    update_elastic_mapping(res_json, elastic_mapping, key)

        else:
            update_elastic_mapping(res_json, elastic_mapping, index)

    return elastic_mapping


def build_eql_body(query, fields, size, tiebreaker_field, timestamp_field, event_category_field, filter):
    body = {}
    if query is not None:
        body["query"] = query
    if event_category_field is not None:
        body["event_category_field"] = event_category_field
    if fields is not None:
        body["fields"] = fields
    if filter is not None:
        body["filter"] = filter
    if size is not None:
        body["size"] = size
    if tiebreaker_field is not None:
        body["tiebreaker_field"] = tiebreaker_field
    if timestamp_field is not None:
        body["timestamp_field"] = timestamp_field
    return body


def search_eql_command(args, proxies):
    index = args.get("index")
    query = args.get("query")
    fields = args.get("fields")  # fields to display
    size = int(args.get("size", "10"))
    timestamp_field = args.get("timestamp_field")
    event_category_field = args.get("event_category_field")
    sort_tiebreaker = args.get("sort_tiebreaker")
    query_filter = args.get("filter")

    es = elasticsearch_builder(proxies)
    body = build_eql_body(
        query=query,
        fields=fields,
        size=size,
        tiebreaker_field=sort_tiebreaker,
        timestamp_field=timestamp_field,
        event_category_field=event_category_field,
        filter=query_filter,
    )

    demisto.debug(f"EQL search body: {body}")
    response = es.eql.search(index=index, body=body)

    total_dict, _ = get_total_results(response)
    search_context, meta_headers, hit_tables, hit_headers = results_to_context(
        index, query, 0, size, total_dict, response, event=True
    )
    search_human_readable = tableToMarkdown("Search Metadata:", search_context, meta_headers, removeNull=True)
    hits_human_readable = tableToMarkdown("Hits:", hit_tables, hit_headers, removeNull=True)
    total_human_readable = search_human_readable + "\n" + hits_human_readable

    return CommandResults(readable_output=total_human_readable, outputs_prefix="Elasticsearch.Search", outputs=search_context)


def search_esql_command(args, proxies):
    query = args.get("query")
    limit = args.get("limit")

    es = elasticsearch_builder(proxies)

    if limit:
        query = {"query": query + f"| LIMIT {limit}"}
    else:
        query = {"query": query}

    if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V8, ELASTICSEARCH_V9]:
        compatible_with = 8 if ELASTIC_SEARCH_CLIENT == ELASTICSEARCH_V8 else 9
        headers = {
            "Content-Type": f"application/vnd.elasticsearch+json; compatible-with={compatible_with}",
            "Accept": f"application/vnd.elasticsearch+json; compatible-with={compatible_with}",
        }
    else:
        return_error("ES|QL Search is only supported in Elasticsearch 8.11 and above.")
        return None

    demisto.debug(f"ES|QL search body: {query}")
    res = es.perform_request(method="POST", path="/_query?format=json", headers=headers, body=query)

    human_output_columns = [col["name"] for col in res["columns"]]
    human_output_rows = res["values"]
    human_output = []

    for row in human_output_rows:
        row_dict = {}
        for i in range(len(human_output_columns)):
            row_dict[human_output_columns[i]] = row[i]
        human_output.append(row_dict)

    search_human_readable = tableToMarkdown(
        "Search query:", [{"Query": query.get("query"), "Total": str(len(human_output_rows))}], removeNull=True
    )
    hits_human_readable = tableToMarkdown("Results:", human_output, removeNull=True)
    total_human_readable = search_human_readable + "\n" + hits_human_readable

    return CommandResults(
        readable_output=total_human_readable,
        outputs_prefix="Elasticsearch.ESQLSearch",
        outputs=human_output,
        raw_response=res.body,
    )


def index_document(args, proxies):
    """
    Indexes a given document into an Elasticsearch index.
    return: Result returned from elasticsearch lib
    """
    index = args.get("index_name")
    doc = args.get("document")
    doc_id = args.get("id", "")
    es = elasticsearch_builder(proxies)

    demisto.debug(f"Indexing document in index {index} with ID {doc_id}")
    if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8]:
        if doc_id:
            response = es.index(index=index, id=doc_id, document=doc)  # pylint: disable=E1123,E1120,E1125
        else:
            response = es.index(index=index, document=doc)  # pylint: disable=E1123,E1120,E1125

    else:  # Elasticsearch version v7 or below, OpenSearch (BC)
        # In elasticsearch lib <8 'document' param is called 'body'
        if doc_id:
            response = es.index(index=index, id=doc_id, body=doc)
        else:
            response = es.index(index=index, body=doc)

    demisto.debug(f"Index document response: {response}")
    return response


def index_document_command(args, proxies):
    resp = index_document(args, proxies)
    index_context = {
        "id": resp.get("_id", ""),
        "index": resp.get("_index", ""),
        "version": resp.get("_version", ""),
        "result": resp.get("result", ""),
    }
    human_readable = {
        "ID": index_context.get("id"),
        "Index name": index_context.get("index"),
        "Version": index_context.get("version"),
        "Result": index_context.get("result"),
    }
    headers = [str(k) for k in human_readable]
    readable_output = tableToMarkdown(name="Indexed document", t=human_readable, removeNull=True, headers=headers)

    if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8]:
        resp = resp.body

    result = CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Index",
        outputs=index_context,
        raw_response=resp,
        outputs_key_field="id",
    )
    return result


def get_indices_statistics(client):
    """
    Returns raw statistics and information of all the Elasticsearch indices.
    Args:
        client : The Elasticsearch client

    Returns:
        dict: raw statistics and information of all the Elasticsearch indices.
    """
    stats = client.indices.stats()
    raw_indices_data = stats.get("indices")

    return raw_indices_data


def get_indices_statistics_command(args, proxies):
    """
    Returns statistics and information of the Elasticsearch indices.

    return: A List with Elasticsearch indices info and statistics.
    API reference: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-stats.html
    """
    limit = arg_to_number(args.get("limit", 50))
    all_results = argToBoolean(args.get("all_results", False))
    indices = []
    es = elasticsearch_builder(proxies)

    demisto.debug("Retrieving indices statistics")
    # Fetch the statistics for all indices
    raw_indices_data = get_indices_statistics(es)
    for index, index_data in raw_indices_data.items():
        index_stats = {
            "Name": index,
            "Status": index_data.get("status", ""),
            "Health": index_data.get("health", ""),
            "UUID": index_data.get("uuid", ""),
            "Documents Count": index_data.get("total", {}).get("docs", {}).get("count", ""),
            "Documents Deleted": index_data.get("total", {}).get("docs", {}).get("deleted", ""),
        }
        indices.append(index_stats)

    if not all_results:
        indices = indices[:limit]

    readable_output = tableToMarkdown(
        name="Indices Statistics:", t=indices, removeNull=True, headers=[str(k) for k in indices[0]]
    )

    result = CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.IndexStatistics",
        outputs=indices,
        outputs_key_field="UUID",
        raw_response=raw_indices_data,
    )
    return result


"""KIBANA CASE MANAGEMENT COMMANDS (es-kibana-case-*)"""

CONNECTOR_FIELD_ARG_MAP = {
    "connector_fields_issue_type_jira": "issueType",
    "connector_fields_parent_jira": "parent",
    "connector_fields_priority_jira": "priority",
    "connector_fields_severity_code_resilient": "severityCode",
    "connector_fields_category_servicenow": "category",
    "connector_fields_impact_servicenow": "impact",
    "connector_fields_severity_servicenow": "severity",
    "connector_fields_subcategory_servicenow": "subcategory",
    "connector_fields_urgency_servicenow": "urgency",
    "connector_fields_priority_servicenow": "priority",
    "connector_fields_case_id_swimlane": "caseId",
}
CONNECTOR_FIELD_BOOLEAN_ARG_MAP = {
    "connector_fields_dest_ip_servicenow": "destIp",
    "connector_fields_malware_hash_servicenow": "malwareHash",
    "connector_fields_malware_url_servicenow": "malwareUrl",
    "connector_fields_source_ip_servicenow": "sourceIp",
}


def build_case_connector_fields(args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    """
    Builds the Kibana case connector "fields" object from the flattened connector_fields_* arguments.

    If the raw "connector_fields" argument is provided, it takes precedence and is used as-is
    (parsed as JSON if given as a string).
    """
    raw_connector_fields = args.get("connector_fields")
    if raw_connector_fields:
        return json.loads(raw_connector_fields) if isinstance(raw_connector_fields, str) else raw_connector_fields

    fields: Dict[str, Any] = {}
    for arg_name, field_name in CONNECTOR_FIELD_ARG_MAP.items():
        if args.get(arg_name):
            fields[field_name] = args[arg_name]
    for arg_name, field_name in CONNECTOR_FIELD_BOOLEAN_ARG_MAP.items():
        if arg_name in args:
            fields[field_name] = argToBoolean(args[arg_name])
    if args.get("connector_fields_issue_types_resilient"):
        fields["incidentTypes"] = argToList(args["connector_fields_issue_types_resilient"])

    return fields or None


def build_case_connector(args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    """Builds the Kibana case "connector" object from the case command arguments."""
    connector_id = args.get("connector_id")
    connector_name = args.get("connector_name")
    connector_type = args.get("connector_type")
    fields = build_case_connector_fields(args)

    if not any([connector_id, connector_name, connector_type, fields]):
        return None

    connector: Dict[str, Any] = {"fields": fields}
    if connector_id:
        connector["id"] = connector_id
    if connector_name:
        connector["name"] = connector_name
    if connector_type:
        connector["type"] = connector_type
    return connector


def build_case_custom_fields(args: Dict[str, Any]) -> Optional[list]:
    """Builds the Kibana case "customFields" array from the custom_key/custom_type/custom_value arguments."""
    key = args.get("custom_key")
    if not key:
        return None
    custom_type = args.get("custom_type")
    value = args.get("custom_value")
    if custom_type == "toggle":
        value = argToBoolean(value)
    return [{"key": key, "type": custom_type, "value": value}]


def build_case_body(args: Dict[str, Any], require_owner: bool = False) -> Dict[str, Any]:
    """
    Builds a Kibana case request body from the command arguments shared by
    es-kibana-case-create and es-kibana-case-update.
    """
    body: Dict[str, Any] = {}
    if args.get("title"):
        body["title"] = args["title"]
    if args.get("description"):
        body["description"] = args["description"]
    if args.get("tags") is not None:
        body["tags"] = argToList(args.get("tags"))
    if args.get("category"):
        body["category"] = args["category"]
    if args.get("severity"):
        body["severity"] = args["severity"]

    owner = args.get("owner")
    if owner:
        body["owner"] = owner
    elif require_owner:
        raise DemistoException('The "owner" argument is required.')

    assignee_uids = argToList(args.get("assignee_uid"))
    if assignee_uids:
        body["assignees"] = [{"uid": uid} for uid in assignee_uids]

    connector = build_case_connector(args)
    if connector:
        body["connector"] = connector
    else:
        # The Kibana Cases API requires "connector" on create; default to the no-op connector.
        body["connector"] = {"fields": None, "id": "none", "name": "none", "type": ".none"}

    settings: Dict[str, Any] = {}
    if "sync_alerts" in args:
        settings["syncAlerts"] = argToBoolean(args["sync_alerts"])
    if "extract_observables" in args:
        settings["extractObservables"] = argToBoolean(args["extract_observables"])
    if settings:
        body["settings"] = settings
    else:
        # The Kibana Cases API requires "settings" on create; default to syncing alerts.
        body["settings"] = {"syncAlerts": True}

    custom_fields = build_case_custom_fields(args)
    if custom_fields:
        body["customFields"] = custom_fields

    return body


def case_to_hr(case: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the human-readable row for a single Kibana case."""
    return {
        "Title": case.get("title"),
        "Case id": case.get("id"),
        "Description": case.get("description"),
        "Owner": case.get("owner"),
        "Severity": case.get("severity"),
        "Status": case.get("status"),
        "Creation date": case.get("created_at"),
        "Type": get_value_by_dot_notation(case, "connector.type"),
    }


def es_kibana_case_create_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    body = get_json_body_from_entry_id(entry_id) if entry_id else build_case_body(args, require_owner=True)
    space_id = args.get("space_id")

    response = kibana_http_request("POST", "/api/cases", space_id=space_id, json_data=body, proxies=proxies)

    hr = case_to_hr(response)
    readable_output = tableToMarkdown("Kibana Case", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Case",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_case_update_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    if entry_id:
        payload = get_json_body_from_entry_id(entry_id)
    else:
        case_id = args.get("case_id")
        version = args.get("version")
        if not case_id:
            raise DemistoException('The "case_id" argument is required.')
        if not version:
            raise DemistoException('The "version" argument is required.')

        case_fields = build_case_body(args)
        case_fields["id"] = case_id
        case_fields["version"] = version
        if args.get("status"):
            case_fields["status"] = args["status"]
        if args.get("close_reason"):
            case_fields["closeReason"] = args["close_reason"]
        payload = {"cases": [case_fields]}

    space_id = args.get("space_id")
    response = kibana_http_request("PATCH", "/api/cases", space_id=space_id, json_data=payload, proxies=proxies)

    cases = response if isinstance(response, list) else [response]
    hr_rows = [case_to_hr(case) for case in cases]
    readable_output = tableToMarkdown(
        "Kibana Case(s) Updated", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    outputs = cases[0] if len(cases) == 1 else cases
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Case",
        outputs=outputs,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_case_delete_command(args: Dict[str, Any], proxies) -> CommandResults:
    case_ids = argToList(args.get("case_id"))
    if not case_ids:
        raise DemistoException('The "case_id" argument is required.')
    space_id = args.get("space_id")

    kibana_http_request("DELETE", "/api/cases", space_id=space_id, params={"ids": json.dumps(case_ids)}, proxies=proxies)

    ids_str = ", ".join(case_ids)
    return CommandResults(readable_output=f"The cases {ids_str} have been successfully deleted.")


def es_kibana_case_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    case_id = args.get("case_id")

    if case_id:
        response = kibana_http_request("GET", f"/api/cases/{case_id}", space_id=space_id, proxies=proxies, allow_not_found=True)
        cases = [response] if response else []
    else:
        params: Dict[str, Any] = {}
        param_arg_map = {
            "assignees": "assignees",
            "category": "category",
            "default_search_operator": "defaultSearchOperator",
            "search": "search",
            "from": "from",
            "to": "to",
            "owner": "owner",
            "reporters": "reporters",
            "search_fields": "searchFields",
            "severity": "severity",
            "sort_field": "sortField",
            "sort_order": "sortOrder",
            "status": "status",
            "tags": "tags",
            "page": "page",
            "size": "perPage",
        }
        for arg_name, param_name in param_arg_map.items():
            if args.get(arg_name) is not None:
                params[param_name] = args[arg_name]

        response = kibana_http_request("GET", "/api/cases/_find", space_id=space_id, params=params, proxies=proxies)
        cases = response.get("cases", []) if isinstance(response, dict) else response

    hr_rows = [case_to_hr(case) for case in cases]
    readable_output = tableToMarkdown(
        "Kibana Cases", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Case",
        outputs=cases,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_case_alerts_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    case_id = args.get("case_id")
    if not case_id:
        raise DemistoException('The "case_id" argument is required.')
    space_id = args.get("space_id")

    params = {}
    if args.get("limit") is not None:
        params["limit"] = args["limit"]
    if args.get("offset") is not None:
        params["offset"] = args["offset"]

    response = kibana_http_request(
        "GET", f"/api/cases/{case_id}/alerts", space_id=space_id, params=params, proxies=proxies, allow_not_found=True
    )
    alerts = response if isinstance(response, list) else []

    hr_rows = [
        {
            "Case id": case_id,
            "Attached at": alert.get("attached_at"),
            "Alert id": alert.get("id"),
            "Index": alert.get("index"),
        }
        for alert in alerts
    ]
    readable_output = tableToMarkdown(
        f"Alerts for Case {case_id}", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix=f"Elasticsearch.Kibana.Case.{case_id}.Alert",
        outputs=alerts,
        outputs_key_field="id",
        raw_response=response,
    )


def build_case_comment_body(args: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the Kibana case comment/alert request body shared by comment-add and comment-update."""
    comment_type = args.get("type")
    if not comment_type:
        raise DemistoException('The "type" argument is required.')
    owner = args.get("owner")
    if not owner:
        raise DemistoException('The "owner" argument is required.')

    body: Dict[str, Any] = {"type": comment_type, "owner": owner}

    if comment_type == "alert":
        alert_ids = argToList(args.get("alert_id"))
        indices = argToList(args.get("index"))
        body["alertId"] = alert_ids if len(alert_ids) > 1 else (alert_ids[0] if alert_ids else None)
        body["index"] = indices if len(indices) > 1 else (indices[0] if indices else None)
        if args.get("rule_id") or args.get("rule_name"):
            body["rule"] = {"id": args.get("rule_id"), "name": args.get("rule_name")}
    else:
        body["comment"] = args.get("comment")

    return body


def es_kibana_case_comment_add_command(args: Dict[str, Any], proxies) -> CommandResults:
    case_id = args.get("case_id")
    if not case_id:
        raise DemistoException('The "case_id" argument is required.')
    space_id = args.get("space_id")

    body = build_case_comment_body(args)
    response = kibana_http_request("POST", f"/api/cases/{case_id}/comments", space_id=space_id, json_data=body, proxies=proxies)

    comments = response.get("comments", [])
    last_comment = comments[-1] if comments else {}
    hr = {
        "Case id": response.get("id"),
        "Comment": last_comment.get("comment"),
        "Created by": get_value_by_dot_notation(last_comment, "created_by.username"),
    }
    readable_output = tableToMarkdown("Kibana Case Comment Added", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Case",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_case_comment_update_command(args: Dict[str, Any], proxies) -> CommandResults:
    case_id = args.get("case_id")
    if not case_id:
        raise DemistoException('The "case_id" argument is required.')
    space_id = args.get("space_id")

    body = build_case_comment_body(args)
    if args.get("comment_id"):
        body["id"] = args["comment_id"]
    if args.get("version"):
        body["version"] = args["version"]

    response = kibana_http_request("PATCH", f"/api/cases/{case_id}/comments", space_id=space_id, json_data=body, proxies=proxies)

    comments = response.get("comments", [])
    updated_comment = next((c for c in comments if c.get("id") == args.get("comment_id")), comments[-1] if comments else {})
    hr = {
        "Case id": response.get("id"),
        "Comment": updated_comment.get("comment"),
        "Updated by": get_value_by_dot_notation(updated_comment, "updated_by.username"),
        "Updated at": updated_comment.get("updated_at"),
    }
    readable_output = tableToMarkdown("Kibana Case Comment Updated", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Case",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_case_comment_delete_command(args: Dict[str, Any], proxies) -> CommandResults:
    case_id = args.get("case_id")
    if not case_id:
        raise DemistoException('The "case_id" argument is required.')
    space_id = args.get("space_id")

    kibana_http_request("DELETE", f"/api/cases/{case_id}/comments", space_id=space_id, proxies=proxies)

    return CommandResults(readable_output=f"The comments and alerts for the case {case_id} have been successfully deleted.")


def es_kibana_case_file_attach_command(args: Dict[str, Any], proxies) -> CommandResults:
    case_id = args.get("case_id")
    entry_id = args.get("entry_id")
    if not case_id:
        raise DemistoException('The "case_id" argument is required.')
    if not entry_id:
        raise DemistoException('The "entry_id" argument is required.')
    space_id = args.get("space_id")

    try:
        file_info = demisto.getFilePath(entry_id)
    except Exception as e:
        raise DemistoException(f"Failed to retrieve file info for entry_id={entry_id}: {e}")

    file_path = file_info.get("path") if file_info else None
    if not file_path:
        raise DemistoException(f"Could not resolve file path for entry_id={entry_id}")

    file_name = args.get("file_name") or file_info.get("name")

    mime_type, _ = mimetypes.guess_type(file_name or file_path)
    if not mime_type:
        demisto.debug(f"Could not determine MIME type for file {file_name or file_path}, defaulting to text/plain")
        mime_type = "text/plain"  # Default to text/plain if MIME type cannot be determined

    root, ext = os.path.splitext(file_name)
    if not ext:
        guessed_ext = mimetypes.guess_extension(mime_type)
        if guessed_ext:
            demisto.debug(f"File {file_name} has no extension, but MIME type {mime_type} suggests extension {guessed_ext}")
            file_name += guessed_ext
        else:
            demisto.debug(
                f"File {file_name} has no extension and MIME type {mime_type} does not suggest an extension, defaulting to .txt"
            )
            file_name += ".txt"

    with open(file_path, "rb") as f:
        files = {"file": (file_name, f, mime_type)}
        response = kibana_http_request(
            "POST",
            f"/api/cases/{case_id}/files",
            space_id=space_id,
            files=files,
            proxies=proxies,
            json_data={"filename": file_name},
        )

    comments = response.get("comments", []) if isinstance(response, dict) else []
    last_comment = comments[-1] if comments else {}
    hr = {
        "Case id": response.get("id") if isinstance(response, dict) else case_id,
        "Updated by": get_value_by_dot_notation(last_comment, "updated_by.username"),
    }
    readable_output = tableToMarkdown("Kibana Case File Attached", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Case",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


"""KIBANA ALERTING / RULES FRAMEWORK COMMANDS (es-kibana-alerting-*, es-kibana-rule-*, es-kibana-detection-*)"""


def es_kibana_alerting_health_get_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    response = kibana_http_request("GET", "/api/alerting/_health", space_id=space_id, proxies=proxies)

    hr = {
        "Is sufficiently secure": response.get("is_sufficiently_secure"),
        "Has permanent encryption key": response.get("has_permanent_encryption_key"),
        "Decryption status": get_value_by_dot_notation(response, "alerting_framework_health.decryption_health.status"),
        "Execution status": get_value_by_dot_notation(response, "alerting_framework_health.execution_health.status"),
        "Read status": get_value_by_dot_notation(response, "alerting_framework_health.read_health.status"),
    }
    readable_output = tableToMarkdown("Kibana Alerting Framework Health", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.AlertingHealth",
        outputs=response,
        raw_response=response,
    )


def es_kibana_rule_types_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    response = kibana_http_request("GET", "/api/alerting/rule_types", space_id=space_id, proxies=proxies)
    rule_types = response if isinstance(response, list) else []

    hr_rows = [
        {
            "Rule type ID": rule_type.get("id"),
            "Name": rule_type.get("name"),
            "Category": rule_type.get("category"),
            "Producer": rule_type.get("producer"),
            "Action Group Id": [ag.get("id") for ag in rule_type.get("action_groups", [])],
        }
        for rule_type in rule_types
    ]
    readable_output = tableToMarkdown(
        "Kibana Rule Types", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.RuleTypes",
        outputs=rule_types,
        outputs_key_field="id",
        raw_response=response,
    )


def rule_to_hr(rule: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the human-readable row for a single Kibana rule."""
    return {
        "Rule ID": rule.get("id"),
        "Enabled": rule.get("enabled"),
        "Name": rule.get("name"),
        "Type ID": rule.get("rule_type_id"),
        "Creation date": rule.get("created_at"),
    }


def es_kibana_rule_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    rule_id = args.get("rule_id")

    if rule_id:
        response = kibana_http_request(
            "GET", f"/api/alerting/rule/{rule_id}", space_id=space_id, proxies=proxies, allow_not_found=True
        )
        rules = [response] if response else []
    else:
        params: Dict[str, Any] = {}
        param_arg_map = {
            "search": "search",
            "default_search_operator": "default_search_operator",
            "search_fields": "search_fields",
            "sort_field": "sort_field",
            "sort_order": "sort_order",
            "has_reference_id": "has_reference",
            "has_reference_type": "has_reference",
            "fields": "fields",
            "filter": "filter",
            "filter_consumers": "filter_consumers",
            "page": "page",
            "size": "per_page",
        }
        for arg_name, param_name in param_arg_map.items():
            if args.get(arg_name) is not None:
                params[param_name] = args[arg_name]

        response = kibana_http_request("GET", "/api/alerting/rules/_find", space_id=space_id, params=params, proxies=proxies)
        rules = response.get("data", []) if isinstance(response, dict) else response

    hr_rows = [rule_to_hr(rule) for rule in rules]
    readable_output = tableToMarkdown(
        "Kibana Rules", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Rule",
        outputs=rules,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_rule_enable_command(args: Dict[str, Any], proxies) -> CommandResults:
    rule_id = args.get("rule_id")
    if not rule_id:
        raise DemistoException('The "rule_id" argument is required.')
    space_id = args.get("space_id")

    kibana_http_request("POST", f"/api/alerting/rule/{rule_id}/_enable", space_id=space_id, proxies=proxies)

    return CommandResults(readable_output=f"The rule {rule_id} has been successfully enabled.")


def es_kibana_rule_disable_command(args: Dict[str, Any], proxies) -> CommandResults:
    rule_id = args.get("rule_id")
    if not rule_id:
        raise DemistoException('The "rule_id" argument is required.')
    space_id = args.get("space_id")

    kibana_http_request("POST", f"/api/alerting/rule/{rule_id}/_disable", space_id=space_id, proxies=proxies)

    return CommandResults(readable_output=f"The rule {rule_id} has been successfully disabled.")


def build_rule_update_body(args: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the Kibana rule update request body from es-kibana-rule-update arguments."""
    body: Dict[str, Any] = {}

    if args.get("name"):
        body["name"] = args["name"]
    if args.get("schedule_interval"):
        body["schedule"] = {"interval": args["schedule_interval"]}
    if args.get("consumer"):
        body["consumer"] = args["consumer"]
    if args.get("notify_when"):
        body["notifyWhen"] = args["notify_when"]
    if args.get("tags") is not None:
        body["tags"] = argToList(args.get("tags"))
    if args.get("alert_delay_active") is not None:
        body["alertDelay"] = {"active": arg_to_number(args["alert_delay_active"])}

    flapping_enabled = args.get("flapping_enabled")
    flapping_look_back_window = args.get("flapping_look_back_window")
    flapping_status_change_threshold = args.get("flapping_status_change_threshold")

    if flapping_enabled is not None:
        # When the flapping object is provided, look_back_window and status_change_threshold are required by the API.
        if flapping_look_back_window is None:
            raise DemistoException('"flapping_look_back_window" is required when configuring flapping settings.')
        if flapping_status_change_threshold is None:
            raise DemistoException('"flapping_status_change_threshold" is required when configuring flapping settings.')
        flapping: Dict[str, Any] = {
            "look_back_window": arg_to_number(flapping_look_back_window),
            "status_change_threshold": arg_to_number(flapping_status_change_threshold),
        }
        flapping["enabled"] = argToBoolean(flapping_enabled)
        body["flapping"] = flapping

    artifacts: Dict[str, Any] = {}
    if args.get("artifacts_dashboards_id"):
        artifacts["dashboards"] = [{"id": dashboard_id} for dashboard_id in argToList(args["artifacts_dashboards_id"])]
    if args.get("artifacts_investigation_guide_blob"):
        artifacts["investigation_guide"] = {"blob": args["artifacts_investigation_guide_blob"]}
    if artifacts:
        body["artifacts"] = artifacts

    return body


def es_kibana_rule_update_command(args: Dict[str, Any], proxies) -> CommandResults:
    rule_id = args.get("rule_id")
    if not rule_id:
        raise DemistoException('The "rule_id" argument is required.')
    space_id = args.get("space_id")

    entry_id = args.get("entry_id")
    body = get_json_body_from_entry_id(entry_id) if entry_id else build_rule_update_body(args)

    # The Kibana PUT /api/alerting/rule/{id} endpoint requires the rule's `params` field to be
    # present in every update request (it does not preserve existing params on partial updates).
    existing_rule = kibana_http_request("GET", f"/api/alerting/rule/{rule_id}", space_id=space_id, proxies=proxies)
    existing_params = existing_rule.get("params")
    if existing_params and "params" not in body:
        body["params"] = existing_params

    response = kibana_http_request("PUT", f"/api/alerting/rule/{rule_id}", space_id=space_id, json_data=body, proxies=proxies)

    hr = {"Rule ID": response.get("id"), "Changed fields": list(body.keys())}
    readable_output = f"The rule {rule_id} has been successfully changed.\n" + tableToMarkdown(
        "", hr, removeNull=True, headers=list(hr.keys())
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.Rule",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_rule_alert_mute_command(args: Dict[str, Any], proxies) -> CommandResults:
    rule_id = args.get("rule_id")
    if not rule_id:
        raise DemistoException('The "rule_id" argument is required.')
    space_id = args.get("space_id")
    alert_id = args.get("alert_id")
    mute_all = argToBoolean(args.get("mute_all", False))

    if mute_all:
        kibana_http_request("POST", f"/api/alerting/rule/{rule_id}/_mute_all", space_id=space_id, proxies=proxies)
        return CommandResults(readable_output="The alerts have been successfully muted.")

    if not alert_id:
        raise DemistoException('The "alert_id" argument is required when "mute_all" is not true.')

    params = {}
    if "validate_alerts_existence" in args:
        params["validate_alerts_existence"] = argToBoolean(args["validate_alerts_existence"])

    kibana_http_request(
        "POST",
        f"/api/alerting/rule/{rule_id}/alert/{alert_id}/_mute",
        space_id=space_id,
        params=params,
        proxies=proxies,
    )
    return CommandResults(readable_output=f"The alerts {alert_id}s have been successfully muted.")


def es_kibana_rule_alert_unmute_command(args: Dict[str, Any], proxies) -> CommandResults:
    rule_id = args.get("rule_id")
    if not rule_id:
        raise DemistoException('The "rule_id" argument is required.')
    space_id = args.get("space_id")
    alert_id = args.get("alert_id")
    unmute_all = argToBoolean(args.get("unmute_all", False))

    if unmute_all:
        kibana_http_request("POST", f"/api/alerting/rule/{rule_id}/_unmute_all", space_id=space_id, proxies=proxies)
        return CommandResults(readable_output="The alerts have been successfully unmuted.")

    if not alert_id:
        raise DemistoException('The "alert_id" argument is required when "unmute_all" is not true.')

    kibana_http_request("POST", f"/api/alerting/rule/{rule_id}/alert/{alert_id}/_unmute", space_id=space_id, proxies=proxies)
    return CommandResults(readable_output=f"The alerts {alert_id}s have been successfully unmuted.")


def es_kibana_detection_alert_status_set_command(args: Dict[str, Any], proxies) -> CommandResults:
    status = args.get("status")
    if not status:
        raise DemistoException('The "status" argument is required.')
    space_id = args.get("space_id")

    body: Dict[str, Any] = {"status": status}
    signal_ids = argToList(args.get("signal_ids"))
    if signal_ids:
        body["signal_ids"] = signal_ids
    query_dict = safe_load_json(args.get("query")) if args.get("query") else None
    if query_dict:
        body["query"] = query_dict
    if args.get("reason"):
        body["reason"] = args["reason"]
    if args.get("conflicts"):
        body["conflicts"] = args["conflicts"]

    response = kibana_http_request(
        "POST", "/api/detection_engine/signals/status", space_id=space_id, json_data=body, proxies=proxies
    )

    hr = {"Total": response.get("total"), "Updated": response.get("updated")}
    readable_output = tableToMarkdown("Kibana Detection Alert Status Update", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.SecurityAlertSetStatus",
        outputs=response,
        raw_response=response,
    )


"""SHARED EXCEPTION ENTRY HELPERS (used by endpoint exception list items and exception list items)"""


def build_exception_entry(args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    """
    Builds a single Kibana exception list item "entry" object from the flattened entries_* arguments.

    Returns:
        Optional[Dict[str, Any]]: The entry object, or None if no entries_* arguments were provided.
    """
    field = args.get("entries_field")
    entry_type = args.get("entries_type")
    if not field and not entry_type:
        return None

    entry: Dict[str, Any] = {}
    if field:
        entry["field"] = field
    if entry_type:
        entry["type"] = entry_type
    if args.get("entries_operator"):
        entry["operator"] = args["entries_operator"]

    if entry_type == "list":
        entry["list"] = {"id": args.get("entries_list_id"), "type": args.get("entries_list_type")}
    elif args.get("entries_value") is not None:
        entry["value"] = args["entries_value"]

    return entry


def exception_list_item_to_hr(item: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the human-readable row for a single exception list item (endpoint or regular)."""
    return {
        "ID": item.get("id"),
        "Item ID": item.get("item_id"),
        "List ID": item.get("list_id"),
        "Name": item.get("name"),
        "Description": item.get("description"),
        "Creation date": item.get("created_at"),
    }


"""KIBANA SECURITY ELASTIC ENDPOINT EXCEPTIONS COMMANDS (es-kibana-endpoint-exception-list-item-*)"""


def build_endpoint_exception_item_body(args: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the Kibana endpoint exception list item request body shared by create and update."""
    body: Dict[str, Any] = {"type": "simple"}

    for arg_name, field_name in {
        "description": "description",
        "item_id": "item_id",
        "meta": "meta",
        "name": "name",
    }.items():
        if args.get(arg_name):
            body[field_name] = args[arg_name]

    if args.get("os_types"):
        body["os_types"] = argToList(args.get("os_types"))
    if args.get("tags") is not None:
        body["tags"] = argToList(args.get("tags"))

    entry = build_exception_entry(args)
    if entry:
        body["entries"] = [entry]

    return body


def es_kibana_endpoint_exception_list_item_create_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    body = get_json_body_from_entry_id(entry_id) if entry_id else build_endpoint_exception_item_body(args)
    space_id = args.get("space_id")

    response = kibana_http_request("POST", "/api/endpoint_list/items", space_id=space_id, json_data=body, proxies=proxies)

    hr = exception_list_item_to_hr(response)
    readable_output = tableToMarkdown("Kibana Endpoint Exception List Item", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.EndpointExceptionListItem",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_endpoint_exception_list_item_update_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    if entry_id:
        body = get_json_body_from_entry_id(entry_id)
    else:
        body = build_endpoint_exception_item_body(args)
        if args.get("exception_list_item_id"):
            body["id"] = args["exception_list_item_id"]
        if args.get("_version"):
            body["_version"] = args["_version"]
        if args.get("entries_value") is not None and "entries" in body:
            body["entries"][0]["value"] = args["entries_value"]
    space_id = args.get("space_id")

    response = kibana_http_request("PUT", "/api/endpoint_list/items", space_id=space_id, json_data=body, proxies=proxies)

    hr = exception_list_item_to_hr(response)
    readable_output = tableToMarkdown("Kibana Endpoint Exception List Item", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.EndpointExceptionListItem",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_endpoint_exception_list_item_delete_command(args: Dict[str, Any], proxies) -> CommandResults:
    item_id = args.get("item_id")
    if not item_id:
        raise DemistoException('Either "id" or "item_id" must be specified.')
    space_id = args.get("space_id")

    kibana_http_request("DELETE", "/api/endpoint_list/items", space_id=space_id, params={"item_id": item_id}, proxies=proxies)

    return CommandResults(readable_output=f"The item {item_id} has been successfully deleted.")


def es_kibana_endpoint_exception_list_item_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    item_id = args.get("item_id")

    if item_id:
        response = kibana_http_request(
            "GET",
            "/api/endpoint_list/items",
            space_id=space_id,
            params={"item_id": item_id},
            proxies=proxies,
            allow_not_found=True,
        )
        items = [response] if response else []
    else:
        params: Dict[str, Any] = {}
        param_arg_map = {
            "filter": "filter",
            "sort_field": "sort_field",
            "sort_order": "sort_order",
            "page": "page",
            "size": "per_page",
        }
        for arg_name, param_name in param_arg_map.items():
            if args.get(arg_name) is not None:
                params[param_name] = args[arg_name]

        response = kibana_http_request("GET", "/api/endpoint_list/items/_find", space_id=space_id, params=params, proxies=proxies)
        items = response.get("data", []) if isinstance(response, dict) else response

    hr_rows = [exception_list_item_to_hr(item) for item in items]
    readable_output = tableToMarkdown(
        "Kibana Endpoint Exception List Items", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.EndpointExceptionListItem",
        outputs=items,
        outputs_key_field="id",
        raw_response=response,
    )


"""KIBANA SECURITY EXCEPTION LISTS COMMANDS (es-kibana-exception-list-*, es-kibana-exception-list-item-*)"""


def exception_list_to_hr(exception_list: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the human-readable row for a single exception list container."""
    return {
        "Exception list ID": exception_list.get("id"),
        "List ID": exception_list.get("list_id"),
        "Name": exception_list.get("name"),
        "Description": exception_list.get("description"),
        "Creation date": exception_list.get("created_at"),
    }


def es_kibana_exception_list_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    exception_list_id = args.get("exception_list_id")
    list_id = args.get("list_id")

    if exception_list_id or list_id:
        params = {}
        if exception_list_id:
            params["id"] = exception_list_id
        if list_id:
            params["list_id"] = list_id
        response = kibana_http_request(
            "GET", "/api/exception_lists", space_id=space_id, params=params, proxies=proxies, allow_not_found=True
        )
        lists_ = [response] if response else []
    else:
        params = {}
        param_arg_map = {
            "filter": "filter",
            "namespace_type": "namespace_type",
            "sort_field": "sort_field",
            "sort_order": "sort_order",
            "page": "page",
            "size": "per_page",
        }
        for arg_name, param_name in param_arg_map.items():
            if args.get(arg_name) is not None:
                params[param_name] = args[arg_name]

        response = kibana_http_request("GET", "/api/exception_lists/_find", space_id=space_id, params=params, proxies=proxies)
        lists_ = response.get("data", []) if isinstance(response, dict) else response

    hr_rows = [exception_list_to_hr(exc_list) for exc_list in lists_]
    readable_output = tableToMarkdown(
        "Kibana Exception Lists", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ExceptionList",
        outputs=lists_,
        outputs_key_field="id",
        raw_response=response,
    )


def build_exception_list_body(args: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the Kibana exception list container request body shared by create and update."""
    body: Dict[str, Any] = {}

    for arg_name in ("description", "list_id", "meta", "name"):
        if args.get(arg_name):
            body[arg_name] = args[arg_name]

    if args.get("os_types"):
        body["os_types"] = argToList(args.get("os_types"))
    if args.get("tags") is not None:
        body["tags"] = argToList(args.get("tags"))
    if args.get("namespace_type"):
        body["namespace_type"] = args["namespace_type"]
    if args.get("type"):
        body["type"] = args["type"]
    if args.get("version") is not None:
        body["version"] = arg_to_number(args["version"])

    return body


def es_kibana_exception_list_create_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    if entry_id:
        body = get_json_body_from_entry_id(entry_id)
    else:
        exc_type = args.get("type")
        if not exc_type:
            raise DemistoException('The "type" argument is required.')
        body = build_exception_list_body(args)
    space_id = args.get("space_id")

    response = kibana_http_request("POST", "/api/exception_lists", space_id=space_id, json_data=body, proxies=proxies)

    hr = exception_list_to_hr(response)
    readable_output = tableToMarkdown("Kibana Exception List", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ExceptionList",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_exception_list_update_command(args: Dict[str, Any], proxies) -> CommandResults:
    if not args.get("description"):
        raise DemistoException('The "description" argument is required.')
    if not args.get("name"):
        raise DemistoException('The "name" argument is required.')
    if not args.get("type"):
        raise DemistoException('The "type" argument is required.')
    space_id = args.get("space_id")

    body = build_exception_list_body(args)
    if args.get("exception_list_id"):
        body["id"] = args["exception_list_id"]
    if args.get("_version"):
        body["_version"] = args["_version"]

    response = kibana_http_request("PUT", "/api/exception_lists", space_id=space_id, json_data=body, proxies=proxies)

    hr = exception_list_to_hr(response)
    readable_output = tableToMarkdown("Kibana Exception List", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ExceptionList",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_exception_list_delete_command(args: Dict[str, Any], proxies) -> CommandResults:
    exception_list_id = args.get("exception_list_id")
    list_id = args.get("list_id")
    if not exception_list_id and not list_id:
        raise DemistoException('Either "exception_list_id" or "list_id" must be specified.')
    space_id = args.get("space_id")

    params = {}
    if exception_list_id:
        params["id"] = exception_list_id
    if list_id:
        params["list_id"] = list_id
    if args.get("namespace_type"):
        params["namespace_type"] = args["namespace_type"]

    kibana_http_request("DELETE", "/api/exception_lists", space_id=space_id, params=params, proxies=proxies)

    identifier = exception_list_id or list_id
    return CommandResults(readable_output=f"The exception list {identifier} has been successfully deleted.")


def es_kibana_exception_list_item_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    exception_list_item_id = args.get("exception_list_item_id")
    item_id = args.get("item_id")

    if exception_list_item_id or item_id:
        params = {}
        if exception_list_item_id:
            params["id"] = exception_list_item_id
        if item_id:
            params["item_id"] = item_id
        response = kibana_http_request(
            "GET", "/api/exception_lists/items", space_id=space_id, params=params, proxies=proxies, allow_not_found=True
        )
        items = [response] if response else []
    else:
        params = {}
        param_arg_map = {
            "exception_list_id": "list_id",
            "filter": "filter",
            "namespace_type": "namespace_type",
            "search": "search",
            "sort_field": "sort_field",
            "sort_order": "sort_order",
            "page": "page",
            "size": "per_page",
        }
        for arg_name, param_name in param_arg_map.items():
            if args.get(arg_name) is not None:
                params[param_name] = args[arg_name]

        response = kibana_http_request(
            "GET", "/api/exception_lists/items/_find", space_id=space_id, params=params, proxies=proxies
        )
        items = response.get("data", []) if isinstance(response, dict) else response

    hr_rows = [exception_list_item_to_hr(item) for item in items]
    readable_output = tableToMarkdown(
        "Kibana Exception List Items", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ExceptionListItem",
        outputs=items,
        outputs_key_field="id",
        raw_response=response,
    )


def build_exception_list_item_body(args: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the Kibana exception list item request body shared by create and update."""
    body: Dict[str, Any] = {}

    for arg_name in ("comment", "description", "expire_time", "item_id", "meta", "name", "list_id"):
        if args.get(arg_name):
            body[arg_name] = args[arg_name]

    if args.get("namespace_type"):
        body["namespace_type"] = args["namespace_type"]
    if args.get("type"):
        body["type"] = args["type"]
    if args.get("os_types"):
        body["os_types"] = argToList(args.get("os_types"))
    if args.get("tags") is not None:
        body["tags"] = argToList(args.get("tags"))

    entry = build_exception_entry(args)
    if entry:
        body["entries"] = [entry]

    return body


def es_kibana_exception_list_item_create_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    body = get_json_body_from_entry_id(entry_id) if entry_id else build_exception_list_item_body(args)
    space_id = args.get("space_id")

    response = kibana_http_request("POST", "/api/exception_lists/items", space_id=space_id, json_data=body, proxies=proxies)

    hr = exception_list_item_to_hr(response)
    readable_output = tableToMarkdown("Kibana Exception List Item", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ExceptionListItem",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_exception_item_list_update_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    if entry_id:
        body = get_json_body_from_entry_id(entry_id)
    else:
        body = build_exception_list_item_body(args)
        if args.get("exception_list_item_id"):
            body["id"] = args["exception_list_item_id"]
        if args.get("_version"):
            body["_version"] = args["_version"]
        if args.get("comment_id"):
            body["comment_id"] = args["comment_id"]
    space_id = args.get("space_id")

    response = kibana_http_request("PUT", "/api/exception_lists/items", space_id=space_id, json_data=body, proxies=proxies)

    hr = {
        "Exception list item ID": response.get("id"),
        "Item Id": response.get("item_id"),
        "List ID": response.get("list_id"),
        "Name": response.get("name"),
        "Description": response.get("description"),
        "Update date": response.get("updated_at"),
    }
    readable_output = tableToMarkdown("Kibana Exception List Item Updated", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ExceptionListItem",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_exception_list_item_delete_command(args: Dict[str, Any], proxies) -> CommandResults:
    exception_list_item_id = args.get("exception_list_item_id")
    item_id = args.get("item_id")
    if not exception_list_item_id and not item_id:
        raise DemistoException('Either "exception_list_item_id" or "item_id" must be specified.')
    space_id = args.get("space_id")

    params = {}
    if exception_list_item_id:
        params["id"] = exception_list_item_id
    if item_id:
        params["item_id"] = item_id
    if args.get("namespace_type"):
        params["namespace_type"] = args["namespace_type"]

    kibana_http_request("DELETE", "/api/exception_lists/items", space_id=space_id, params=params, proxies=proxies)

    identifier = exception_list_item_id or item_id
    return CommandResults(readable_output=f"The exception list item {identifier} has been successfully deleted.")


"""KIBANA VALUE LISTS COMMANDS (es-kibana-value-list*)"""


def value_list_to_hr(value_list: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the human-readable row for a single value list container."""
    return {
        "Value list ID": value_list.get("id"),
        "Name": value_list.get("name"),
        "Description": value_list.get("description"),
        "Creation date": value_list.get("created_at"),
    }


def value_list_item_to_hr(item: Dict[str, Any]) -> Dict[str, Any]:
    """Builds the human-readable row for a single value list item."""
    return {
        "Value list item ID": item.get("id"),
        "Value list ID": item.get("list_id"),
        "Name": item.get("name"),
        "Description": item.get("description"),
        "Creation date": item.get("created_at"),
    }


def es_kibana_value_lists_list_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    value_list_id = args.get("value_list_id")

    if value_list_id:
        response = kibana_http_request(
            "GET", "/api/lists", space_id=space_id, params={"id": value_list_id}, proxies=proxies, allow_not_found=True
        )
        lists_ = [response] if response else []
    else:
        params: Dict[str, Any] = {}
        param_arg_map = {
            "filter": "filter",
            "cursor": "cursor",
            "sort_field": "sort_field",
            "sort_order": "sort_order",
            "page": "page",
            "size": "per_page",
        }
        for arg_name, param_name in param_arg_map.items():
            if args.get(arg_name) is not None:
                params[param_name] = args[arg_name]

        response = kibana_http_request("GET", "/api/lists/_find", space_id=space_id, params=params, proxies=proxies)
        lists_ = response.get("data", []) if isinstance(response, dict) else response

    hr_rows = [value_list_to_hr(value_list) for value_list in lists_]
    readable_output = tableToMarkdown(
        "Kibana Value Lists", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ValueList",
        outputs=lists_,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_value_list_item_get_command(args: Dict[str, Any], proxies) -> CommandResults:
    space_id = args.get("space_id")
    value_list_item_id = args.get("value_list_item_id")
    value = args.get("value")
    value_list_id = args.get("value_list_id")

    if value_list_item_id or (value and not value_list_id):
        params = {}
        if value_list_item_id:
            params["id"] = value_list_item_id
        if value:
            params["value"] = value
        response = kibana_http_request(
            "GET", "/api/lists/items", space_id=space_id, params=params, proxies=proxies, allow_not_found=True
        )
        items = [response] if response else []
    else:
        params = {}
        param_arg_map = {
            "value_list_id": "list_id",
            "value": "value",
            "filter": "filter",
            "cursor": "cursor",
            "sort_field": "sort_field",
            "sort_order": "sort_order",
            "page": "page",
            "size": "per_page",
        }
        for arg_name, param_name in param_arg_map.items():
            if args.get(arg_name) is not None:
                params[param_name] = args[arg_name]

        response = kibana_http_request("GET", "/api/lists/items/_find", space_id=space_id, params=params, proxies=proxies)
        items = response.get("data", []) if isinstance(response, dict) else response

    hr_rows = [value_list_item_to_hr(item) for item in items]
    readable_output = tableToMarkdown(
        "Kibana Value List Items", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ValueListItem",
        outputs=items,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_value_list_item_create_command(args: Dict[str, Any], proxies) -> CommandResults:
    value_list_id = args.get("value_list_id")
    value = args.get("value")
    if not value_list_id:
        raise DemistoException('The "value_list_id" argument is required.')
    if not value:
        raise DemistoException('The "value" argument is required.')
    space_id = args.get("space_id")

    body: Dict[str, Any] = {"list_id": value_list_id, "value": value}
    if args.get("meta"):
        body["meta"] = json.loads(args["meta"]) if isinstance(args["meta"], str) else args["meta"]

    params = {}
    if args.get("refresh") is not None:
        params["refresh"] = args["refresh"]

    response = kibana_http_request("POST", "/api/lists/items", space_id=space_id, json_data=body, params=params, proxies=proxies)

    hr = value_list_item_to_hr(response)
    readable_output = tableToMarkdown("Kibana Value List Item", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ValueListItem",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_value_list_item_update_command(args: Dict[str, Any], proxies) -> CommandResults:
    value_list_item_id = args.get("value_list_item_id")
    value = args.get("value")
    if not value_list_item_id:
        raise DemistoException('The "value_list_item_id" argument is required.')
    if not value:
        raise DemistoException('The "value" argument is required.')
    space_id = args.get("space_id")

    body: Dict[str, Any] = {"id": value_list_item_id, "value": value}
    if args.get("meta"):
        body["meta"] = json.loads(args["meta"]) if isinstance(args["meta"], str) else args["meta"]
    if args.get("_version"):
        body["_version"] = args["_version"]

    response = kibana_http_request("PUT", "/api/lists/items", space_id=space_id, json_data=body, proxies=proxies)

    hr = value_list_item_to_hr(response)
    readable_output = tableToMarkdown("Kibana Value List Item", hr, removeNull=True, headers=list(hr.keys()))
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ValueListItem",
        outputs=response,
        outputs_key_field="id",
        raw_response=response,
    )


def es_kibana_value_list_item_delete_command(args: Dict[str, Any], proxies) -> CommandResults:
    value_list_item_id = args.get("value_list_item_id")
    value_list_id = args.get("value_list_id")
    value = args.get("value")
    if not value_list_item_id and not (value_list_id and value):
        raise DemistoException('Either "value_list_item_id" or both "value_list_id" and "value" must be specified.')
    space_id = args.get("space_id")

    params = {}
    if value_list_item_id:
        params["id"] = value_list_item_id
    if value_list_id:
        params["list_id"] = value_list_id
    if value:
        params["value"] = value
    if args.get("refresh") is not None:
        params["refresh"] = args["refresh"]

    kibana_http_request("DELETE", "/api/lists/items", space_id=space_id, params=params, proxies=proxies)

    identifier = value_list_item_id or value
    return CommandResults(readable_output=f"The value list item {identifier} has been successfully deleted.")


def es_kibana_value_list_item_export_command(args: Dict[str, Any], proxies) -> list:
    value_list_id = args.get("value_list_id")
    space_id = args.get("space_id")

    params = {}
    if value_list_id:
        params["list_id"] = value_list_id

    response = kibana_http_request("POST", "/api/lists/items/_export", space_id=space_id, params=params, proxies=proxies)

    file_content = response if isinstance(response, str) else json.dumps(response)
    file_name = f"{value_list_id or 'value-list'}-items.txt"
    file_result = fileResult(file_name, file_content)
    return [CommandResults(readable_output="Successful response", raw_response=response), file_result]


def es_kibana_value_list_item_import_command(args: Dict[str, Any], proxies) -> CommandResults:
    entry_id = args.get("entry_id")
    if not entry_id:
        raise DemistoException('The "entry_id" argument is required.')
    space_id = args.get("space_id")

    try:
        file_info = demisto.getFilePath(entry_id)
    except Exception as e:
        raise DemistoException(f"Failed to retrieve file info for entry_id={entry_id}: {e}")
    file_path = file_info.get("path") if file_info else None
    if not file_path:
        raise DemistoException(f"Could not resolve file path for entry_id={entry_id}")

    params: Dict[str, Any] = {}
    if args.get("value_list_id"):
        params["list_id"] = args["value_list_id"]
    if args.get("type"):
        params["type"] = args["type"]
    if args.get("refresh") is not None:
        params["refresh"] = args["refresh"]

    with open(file_path, "rb") as f:
        files = {"file": (file_info.get("name"), f)}
        response = kibana_http_request(
            "POST", "/api/lists/items/_import", space_id=space_id, params=params, files=files, proxies=proxies
        )

    items = response if isinstance(response, list) else [response]
    hr_rows = [value_list_item_to_hr(item) for item in items]
    readable_output = tableToMarkdown(
        "Kibana Value List Items Imported", hr_rows, removeNull=True, headers=list(hr_rows[0].keys()) if hr_rows else None
    )
    return CommandResults(
        readable_output=readable_output,
        outputs_prefix="Elasticsearch.Kibana.ValueListItem",
        outputs=items,
        outputs_key_field="id",
        raw_response=response,
    )


"""MIRRORING FUNCTIONS"""

# The server does not dedup by dbotMirrorId, so the integration must remember ingested IDs to
# avoid re-creating incidents. IDs are pruned after this window to bound the lastRun object.
FETCHED_IDS_RETENTION_DAYS = 30


def prune_fetched_ids(fetched_ids: Dict[str, str], now: Optional[datetime] = None) -> Dict[str, str]:
    """Returns a copy of the ingested-ID map with entries older than FETCHED_IDS_RETENTION_DAYS removed."""
    reference = now or datetime.now(UTC)
    cutoff = reference - timedelta(days=FETCHED_IDS_RETENTION_DAYS)
    pruned: Dict[str, str] = {}

    for remote_id, fetched_at in (fetched_ids or {}).items():
        parsed = parse_to_utc(fetched_at)
        # Keep entries we cannot parse - dropping them would risk re-ingesting duplicates.
        if not parsed or parsed >= cutoff:
            pruned[remote_id] = fetched_at

    if len(pruned) != len(fetched_ids or {}):
        demisto.debug(f"Pruned {len(fetched_ids or {}) - len(pruned)} expired fetched IDs from last run.")

    return pruned


def fetch_security_alerts(proxies: dict) -> List[Dict[str, Any]]:
    """Fetches Elasticsearch Security Alerts as XSOAR incidents using the standard search mechanism."""
    es = elasticsearch_builder(proxies)
    last_run = demisto.getLastRun()
    last_fetch = last_run.get("alert_time") or FETCH_TIME
    time_range_dict = get_time_range(time_range_start=last_fetch)

    if RAW_QUERY:
        response = execute_raw_query(es, RAW_QUERY)
    else:
        query = QueryString(query="(" + FETCH_QUERY + ") AND " + TIME_FIELD + ":*") if FETCH_QUERY else QueryString(query="*")
        search = Search(using=es, index=FETCH_INDEX).filter(time_range_dict)
        search = search.sort({TIME_FIELD: {"order": "asc"}})[0:FETCH_SIZE].query(query)
        # Only include the "fields" key when there are fields to fetch; an empty list
        # would produce "fields": [] in the body and cause a 400 from Elasticsearch.
        search = search.extra(**build_fetch_extra_params(FIELDS_LIST))

        if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
            response = search.execute().to_dict()
        else:
            response = es.search(index=search._index, body=search.to_dict(), **search._params)

    hits = response.get("hits", {}).get("hits", [])
    incidents = []
    new_last_fetch_dt = parse_to_utc(last_fetch)

    # Authoritative dedup: the timestamp cursor is only a coarse pre-filter.
    fetched_alert_ids: Dict[str, str] = prune_fetched_ids(last_run.get("fetched_alert_ids") or {})
    ingested_at = datetime.now(UTC).isoformat()

    for hit in hits:
        source = hit.get("_source", {})
        index = hit.get("_index", "")
        hit_id = hit.get("_id", "")
        alert_uuid = get_alert_source_value(source, "kibana.alert.uuid") or hit_id

        time_val = get_alert_source_value(source, "@timestamp") or get_alert_source_value(source, str(TIME_FIELD))
        occurred = format_to_iso(parse(str(time_val)).isoformat()) if time_val else None
        if occurred:
            occurred_dt = parse_to_utc(occurred)
            if occurred_dt and (new_last_fetch_dt is None or occurred_dt > new_last_fetch_dt):
                new_last_fetch_dt = occurred_dt

        if alert_uuid and alert_uuid in fetched_alert_ids:
            demisto.debug(f"Skipping already-fetched alert ID: {alert_uuid}")
            continue

        severity = convert_severity(get_alert_source_value(source, "kibana.alert.severity") or "low")

        mirror_direction = MIRROR_DIRECTION_MAP.get(MIRROR_DIRECTION)
        mirror_instance = demisto.integrationInstance()

        # The incoming mapper reads root-level paths, so the hit is flattened to match the shape
        # get_remote_data_command returns; otherwise mirroring-in never starts for this incident.
        raw_data = flatten_alert_hit(hit)
        raw_data.update(
            {
                "mirror_id": alert_uuid,
                "mirror_instance": mirror_instance,
                "mirror_direction": mirror_direction,
                "severity": severity,  # XSOAR severity is numeric, not the Kibana name.
                ELASTIC_ENTITY_KIND_FIELD: get_alert_source_value(source, "event.kind") or ENTITY_KIND_SECURITY_ALERT,
            }
        )

        inc: Dict[str, Any] = {
            "name": f"Elasticsearch: {index} {alert_uuid}",
            "rawJSON": json.dumps(raw_data),
            "type": INCIDENT_TYPE_SECURITY_ALERT,
            "severity": severity,
            "dbotMirrorId": alert_uuid,
            "dbotMirrorInstance": mirror_instance,
            "dbotMirrorDirection": mirror_direction,
        }
        if occurred:
            inc["occurred"] = occurred

        if MAP_LABELS:
            inc["labels"] = incident_label_maker(source)

        # dbotMirrorId is logged so it can be matched against get-modified-remote-data output.
        mirror_debug(
            f"Ingesting security alert with dbotMirrorId={str(alert_uuid)!r} "
            f"(document _id={hit_id!r}, direction={mirror_direction!r}, instance={mirror_instance!r})."
        )

        incidents.append(inc)
        if alert_uuid:
            fetched_alert_ids[alert_uuid] = ingested_at

    last_run["alert_time"] = new_last_fetch_dt.isoformat() if new_last_fetch_dt else str(last_fetch)
    last_run["fetched_alert_ids"] = fetched_alert_ids
    demisto.setLastRun(last_run)
    return incidents


def fetch_cases(proxies: dict) -> List[Dict[str, Any]]:
    """Fetches Elasticsearch Cases as XSOAR incidents via the Kibana Cases API.

    Filters by FETCH_SEVERITY and FETCH_STATUS, and optionally fetches per-case alerts when
    FETCH_ALERTS_FOR_CASE is enabled.
    """
    last_run = demisto.getLastRun()
    last_fetch_str = last_run.get("case_time") or FETCH_TIME
    last_fetch_dt = parse_to_utc(last_fetch_str)

    # Authoritative dedup: once ingested a case is excluded from fetch, so remote edits do not
    # re-create it. The cursor sorts on createdAt (not updatedAt) so remote changes are delivered
    # via mirroring-in rather than re-fetched as new incidents.
    fetched_case_ids: Dict[str, str] = prune_fetched_ids(last_run.get("fetched_case_ids") or {})
    ingested_at = datetime.now(UTC).isoformat()

    params: Dict[str, Any] = {"perPage": FETCH_SIZE, "sortField": "createdAt", "sortOrder": "asc"}

    all_cases: List[Dict[str, Any]] = []
    statuses_to_fetch = FETCH_STATUS if FETCH_STATUS else ["open", "in-progress"]

    for status in statuses_to_fetch:
        status_params = dict(params)
        status_params["status"] = status
        if FETCH_SEVERITY:
            for sev in FETCH_SEVERITY:
                sev_params = dict(status_params)
                sev_params["severity"] = sev
                response = kibana_http_request("GET", "/api/cases/_find", params=sev_params, proxies=proxies)
                all_cases.extend(response.get("cases", []) if isinstance(response, dict) else [])
        else:
            response = kibana_http_request("GET", "/api/cases/_find", params=status_params, proxies=proxies)
            all_cases.extend(response.get("cases", []) if isinstance(response, dict) else [])
    incidents = []
    new_last_fetch = last_fetch_dt

    for case in all_cases:
        created_at_str = case.get("created_at") or case.get("updated_at", "")
        try:
            created_at = dateparser.parse(created_at_str, settings={"RETURN_AS_TIMEZONE_AWARE": True})
            if created_at and created_at.tzinfo is None:
                created_at = created_at.replace(tzinfo=UTC)
        except Exception:
            created_at = None

        case_id = case.get("id", "")

        # Authoritative dedup: an ID is only ever ingested once.
        if case_id and case_id in fetched_case_ids:
            mirror_debug(f"Skipping already-fetched case ID: {case_id}")
            continue

        # Coarse pre-filter on the creation-time cursor.
        if created_at and last_fetch_dt and created_at < last_fetch_dt:
            continue

        alerts_data: List[Dict[str, Any]] = []
        if FETCH_ALERTS_FOR_CASE and case_id:
            try:
                alerts_resp = kibana_http_request("GET", f"/api/cases/{case_id}/alerts", proxies=proxies, allow_not_found=True)
                alerts_data = alerts_resp if isinstance(alerts_resp, list) else []
            except Exception as e:
                demisto.debug(f"Failed to fetch alerts for case {case_id}: {e}")

        severity = convert_severity(case.get("severity", "low"))

        mirror_direction = MIRROR_DIRECTION_MAP.get(MIRROR_DIRECTION)
        mirror_instance = demisto.integrationInstance()

        raw_data = dict(case)
        if alerts_data:
            raw_data["_alerts"] = alerts_data

        # Mirror values also go inside rawJSON so the incoming mapper can resolve the instance ID.
        raw_data.update(
            {
                "mirror_id": case_id,
                "mirror_instance": mirror_instance,
                "mirror_direction": mirror_direction,
                "severity": severity,  # XSOAR severity is numeric, not the Kibana name.
                ELASTIC_ENTITY_KIND_FIELD: case.get("owner") or ENTITY_KIND_CASE,
            }
        )

        inc: Dict[str, Any] = {
            "name": f"Elasticsearch: {case.get('title', case_id)}",
            "rawJSON": json.dumps(raw_data),
            "type": INCIDENT_TYPE_CASE,
            "severity": severity,
            "dbotMirrorId": case_id,
            "dbotMirrorInstance": mirror_instance,
            "dbotMirrorDirection": mirror_direction,
        }
        if created_at_str:
            inc["occurred"] = format_to_iso(created_at_str)

        incidents.append(inc)
        if case_id:
            fetched_case_ids[case_id] = ingested_at

        if created_at and (new_last_fetch is None or created_at > new_last_fetch):
            new_last_fetch = created_at

    if new_last_fetch:
        last_run["case_time"] = new_last_fetch.isoformat()
    last_run["fetched_case_ids"] = fetched_case_ids
    demisto.setLastRun(last_run)

    mirror_debug(f"fetch_cases ingested {len(incidents)} new case(s); remembering {len(fetched_case_ids)} ID(s).")
    return incidents


# Index pattern used for security-alert lookups when no fetch index is configured.
SECURITY_ALERT_DEFAULT_INDEX = ".internal.alerts-security.alerts-*,.alerts-security.alerts-*,.siem-signals-*"


def get_configured_fetch_incident_type() -> str:
    """Returns the mirroring incident type this instance is configured to fetch, or "".

    A reliable, network-free fallback for when both the incident context and the remote lookup
    come back empty during a mirroring run.
    """
    configured = PARAMS.get("fetch_incident_type") or PARAMS.get("incidentType", "")
    if configured in (INCIDENT_TYPE_SECURITY_ALERT, INCIDENT_TYPE_CASE):
        return configured
    return ""


def search_security_alerts(body: Dict[str, Any], proxies: dict) -> Dict[str, Any]:
    """Searches security alert documents via the Elasticsearch client, falling back to the Kibana API.

    The Elasticsearch client is preferred so mirroring works wherever fetch works; the Kibana API
    is a fallback for deployments where the alert indices are not directly readable.
    """
    index = FETCH_INDEX or SECURITY_ALERT_DEFAULT_INDEX
    try:
        es = elasticsearch_builder(proxies)
        search = Search(using=es, index=index).update_from_dict(body)
        if ELASTIC_SEARCH_CLIENT in [ELASTICSEARCH_V9, ELASTICSEARCH_V8, OPEN_SEARCH]:
            response = search.execute().to_dict()
        else:
            response = es.search(index=search._index, body=search.to_dict(), **search._params)
        hit_count = len(response.get("hits", {}).get("hits", []))
        mirror_debug(f"Security alert search against index {index!r} returned {hit_count} hit(s).")
        return response
    except Exception as es_error:
        mirror_debug(f"Elasticsearch alert search on {index!r} failed ({es_error}); falling back to the Kibana API.")

    try:
        response = kibana_http_request(
            "POST",
            "/api/detection_engine/signals/search",
            json_data=body,
            proxies=proxies,
        )
        return response if isinstance(response, dict) else {}
    except Exception as kibana_error:
        mirror_error(f"Security alert search failed on both Elasticsearch and Kibana: {kibana_error}")
        raise


def build_alert_lookup_body(remote_id: str, size: int = 1) -> Dict[str, Any]:
    """Builds a search body matching a security alert by either kibana.alert.uuid or document _id.

    Both ID forms are matched because fetch_security_alerts stores whichever one is available.
    """
    return {
        "query": {
            "bool": {
                "should": [
                    {"term": {"kibana.alert.uuid": remote_id}},
                    {"ids": {"values": [remote_id]}},
                ],
                "minimum_should_match": 1,
            }
        },
        "size": size,
    }


def resolve_remote_incident_type(remote_id: str, proxies: dict) -> str:
    """Resolves a remote ID to a Security Alert or Case by probing the remote system.

    Used when the incident context is unavailable. Returns the configured fetch type when the
    probes cannot reach the remote system, and "" only when the ID matches neither.
    """
    if not remote_id:
        return ""

    # Cases are cheaper to check (a single GET by ID).
    try:
        case = kibana_http_request("GET", f"/api/cases/{remote_id}", proxies=proxies, allow_not_found=True)
        if case:
            mirror_debug(f"Resolved remote id {remote_id} to an Elasticsearch Case.")
            return INCIDENT_TYPE_CASE
    except Exception as e:
        mirror_debug(f"Could not resolve remote id {remote_id} as a case: {e}")

    try:
        response = search_security_alerts(build_alert_lookup_body(remote_id), proxies)
        hits = (response or {}).get("hits", {}).get("hits", []) if isinstance(response, dict) else []
        if hits:
            mirror_debug(f"Resolved remote id {remote_id} to an Elasticsearch Security Alert.")
            return INCIDENT_TYPE_SECURITY_ALERT
    except Exception as e:
        mirror_debug(f"Could not resolve remote id {remote_id} as a security alert: {e}")

    # Neither probe matched - usually a connectivity/permission issue, so fall back to the
    # configured fetch type rather than giving up and mirroring nothing.
    configured = get_configured_fetch_incident_type()
    if configured:
        mirror_debug(f"Remote id {remote_id} could not be probed; assuming {configured!r} based on the instance configuration.")
        return configured

    mirror_debug(f"Remote id {remote_id} matched neither a case nor a security alert.")
    return ""


# Kibana severity name -> XSOAR numeric severity.
ELASTIC_SEVERITY_TO_XSOAR = {"critical": 4, "high": 3, "medium": 2, "low": 1}


def convert_severity(severity: Any) -> int:
    """Converts a Kibana severity name to the XSOAR numeric severity."""
    return ELASTIC_SEVERITY_TO_XSOAR.get(str(severity).lower(), 0)


def convert_severity_to_elastic(severity: Any) -> Optional[str]:
    """Converts an XSOAR numeric severity to a Kibana severity name, or None when unmappable."""
    if isinstance(severity, str) and severity.lower() in ELASTIC_SEVERITY_TO_XSOAR:
        return severity.lower()

    try:
        numeric = int(float(severity))
    except (TypeError, ValueError):
        return None

    for name, value in ELASTIC_SEVERITY_TO_XSOAR.items():
        if value == numeric:
            return name
    return None


def flatten_alert_hit(hit: Dict[str, Any]) -> Dict[str, Any]:
    """Merges an Elasticsearch hit's ``_source`` fields into the root, keeping metadata keys.

    The incoming mapper reads fields from the root, so the raw hit must be flattened.
    """
    flattened = {key: value for key, value in hit.items() if key != "_source"}
    source = hit.get("_source") or {}
    if isinstance(source, dict):
        flattened.update(source)
    return flattened


def get_remote_data_command(args: Dict[str, Any], proxies: dict) -> GetRemoteDataResponse:
    """Mirroring-in: fetches the latest state of a remote alert/case and returns it for XSOAR."""
    remote_id = args.get("id", "")
    last_update = args.get("lastUpdate", "")

    # The incident context is only a hint, so fall back to probing the remote system by ID.
    incident_type = get_incident_type() or resolve_remote_incident_type(remote_id, proxies)

    mirror_debug(f"get-remote-data called for id={remote_id}, type={incident_type}, lastUpdate={last_update}")

    updated_incident: Dict[str, Any] = {}
    entries: List[Dict[str, Any]] = []

    if incident_type == INCIDENT_TYPE_SECURITY_ALERT:
        try:
            response = search_security_alerts(build_alert_lookup_body(remote_id), proxies)
            hits = (response or {}).get("hits", {}).get("hits", []) if isinstance(response, dict) else []
            if hits:
                hit = hits[0]
                source = hit.get("_source", {})
                workflow_status = get_alert_source_value(source, "kibana.alert.workflow_status") or "open"

                # The mirrored object goes through the incoming mapper, so it must keep the raw
                # document key space, exactly like a fetched incident.
                updated_incident = flatten_alert_hit(hit)
                updated_incident["severity"] = convert_severity(get_alert_source_value(source, "kibana.alert.severity") or "low")
                updated_incident["kibana_alert_workflow_tags"] = (
                    get_alert_source_value(source, "kibana.alert.workflow_tags") or []
                )
                updated_incident[ELASTIC_ENTITY_KIND_FIELD] = (
                    get_alert_source_value(source, "event.kind") or ENTITY_KIND_SECURITY_ALERT
                )
                updated_incident["rawJSON"] = json.dumps(updated_incident)

                if CLOSE_INCIDENT and workflow_status == "closed":
                    reason = get_alert_source_value(source, "kibana.alert.workflow_reason") or "other"
                    xsoar_reason = ELASTIC_CLOSE_REASON_TO_XSOAR.get(reason, "other")
                    entries.append(
                        {
                            "Type": EntryType.NOTE,
                            "Contents": {
                                "dbotIncidentClose": True,
                                "closeReason": xsoar_reason,
                                "closeNotes": f"Closed by Elasticsearch mirroring. Reason: {reason}",
                            },
                            "ContentsFormat": EntryFormat.JSON,
                        }
                    )
        except Exception as e:
            demisto.debug(f"Failed to fetch security alert {remote_id}: {e}")

    elif incident_type == INCIDENT_TYPE_CASE:
        try:
            response = kibana_http_request("GET", f"/api/cases/{remote_id}", proxies=proxies, allow_not_found=True)
            if response:
                case_status = response.get("status", "open")

                # Keep the raw case document so the incoming mapper can map it.
                updated_incident = dict(response)
                updated_incident["severity"] = convert_severity(response.get("severity", "low"))
                updated_incident[ELASTIC_ENTITY_KIND_FIELD] = response.get("owner") or ENTITY_KIND_CASE
                updated_incident["rawJSON"] = json.dumps(updated_incident)

                if FETCH_ALERTS_FOR_CASE:
                    try:
                        alerts_resp = kibana_http_request(
                            "GET", f"/api/cases/{remote_id}/alerts", proxies=proxies, allow_not_found=True
                        )
                        if alerts_resp:
                            updated_incident["_alerts"] = alerts_resp
                    except Exception as e:
                        demisto.debug(f"Failed to fetch alerts for case {remote_id}: {e}")

                if CLOSE_INCIDENT and case_status == "closed":
                    close_reason = response.get("closeReason") or "other"
                    xsoar_reason = ELASTIC_CLOSE_REASON_TO_XSOAR.get(close_reason, "other")
                    entries.append(
                        {
                            "Type": EntryType.NOTE,
                            "Contents": {
                                "dbotIncidentClose": True,
                                "closeReason": xsoar_reason,
                                "closeNotes": f"Closed by Elasticsearch mirroring. Reason: {close_reason}",
                            },
                            "ContentsFormat": EntryFormat.JSON,
                        }
                    )
        except Exception as e:
            if _is_rate_limit_error(e):
                # Return an error with "API rate limit" so the sync loop restarts from this incident.
                return_error(f"API rate limit reached while fetching case {remote_id}. Error: {e}")
            demisto.debug(f"Failed to fetch case {remote_id}: {e}")

    # Re-assert the mirror instance on every incoming sync (self-healing), but only when there is
    # already something to update, to avoid forcing spurious incident updates.
    if updated_incident:
        updated_incident["dbotMirrorInstance"] = demisto.integrationInstance()
        mirror_debug(f"get-remote-data returning {len(updated_incident)} field(s) and {len(entries)} entry/entries.")
    else:
        mirror_debug("get-remote-data found no changes; returning an empty mirrored object.")

    return GetRemoteDataResponse(mirrored_object=updated_incident, entries=entries)


def update_remote_system_command(args: Dict[str, Any], proxies: dict) -> str:
    """Mirroring-out: pushes local XSOAR incident changes back to Elasticsearch/Kibana."""
    parsed_args = UpdateRemoteSystemArgs(args)
    remote_id = parsed_args.remote_incident_id
    delta = parsed_args.delta or {}
    inc_status = parsed_args.inc_status
    entries = parsed_args.entries or []

    # The type is resolved from the local incident (falling back to the remote lookup and finally
    # the instance configuration), since the mapper output does not carry "type".
    incident_type = (
        get_incident_type()
        or (parsed_args.data or {}).get("type", "")
        or resolve_remote_incident_type(remote_id, proxies)
        or get_configured_fetch_incident_type()
    )

    mirror_debug(
        f"update-remote-system called for id={remote_id}, type={incident_type}, status={inc_status}, "
        f"incident_changed={parsed_args.incident_changed}, delta_keys={list(delta.keys())}, entries={len(entries)}"
    )

    if not remote_id:
        mirror_debug("No remote_id provided, skipping update.")
        return remote_id or ""

    if incident_type == INCIDENT_TYPE_SECURITY_ALERT:
        if parsed_args.incident_changed:
            _mirror_out_security_alert(remote_id, delta, inc_status, proxies)
        else:
            mirror_debug(f"Security alert {remote_id} unchanged, nothing to mirror out.")

    elif incident_type == INCIDENT_TYPE_CASE:
        if parsed_args.incident_changed:
            _mirror_out_case(remote_id, delta, inc_status, proxies, parsed_args.data or {})
        else:
            mirror_debug(f"Case {remote_id} unchanged, only entries will be mirrored out.")
        _mirror_out_case_entries(remote_id, entries, proxies)

    else:
        mirror_error(
            f"Unexpected incident type {incident_type!r} for remote id {remote_id} - nothing was mirrored out. "
            f"Available mapper output keys: {list((parsed_args.data or {}).keys())}. "
            f"Set the 'Fetch incident types' parameter to {INCIDENT_TYPE_SECURITY_ALERT!r} or {INCIDENT_TYPE_CASE!r} "
            "so the type can be resolved even when the remote lookup fails."
        )

    return remote_id


def _mirror_out_case_entries(remote_id: str, entries: List[dict], proxies: dict) -> None:
    """Mirrors tagged War Room entries out to Elasticsearch as Kibana case comments."""
    for entry in entries:
        tags = entry.get("Tags") or []
        if "comment" not in tags:
            mirror_debug(f"Entry {entry.get('ID')} has no mirror tag, skipping.")
            continue

        contents = entry.get("Contents", "")
        comment = contents if isinstance(contents, str) else json.dumps(contents)
        if not comment:
            mirror_debug(f"Entry {entry.get('ID')} has empty contents, skipping.")
            continue

        try:
            kibana_http_request(
                "POST",
                f"/api/cases/{remote_id}/comments",
                json_data={"type": "user", "comment": comment, "owner": "cases"},
                proxies=proxies,
            )
            mirror_debug(f"Mirrored entry {entry.get('ID')} to case {remote_id} as a comment.")
        except Exception as e:
            mirror_error(f"Failed to mirror entry {entry.get('ID')} to case {remote_id}: {e}")


def _mirror_out_security_alert(
    remote_id: str,
    delta: Dict[str, Any],
    inc_status: Optional[int],
    proxies,
) -> None:
    """Pushes a Security Alert incident's status/reason/tags changes back to Kibana.

    Writes go through Kibana (not the Elasticsearch client) because the alert indices are Kibana
    system indices; a direct write would skip Kibana bookkeeping and break mirroring-in.
    """
    new_status = delta.get("status")
    new_reason = delta.get("reason")
    new_tags = delta.get("tags")

    mirror_debug(
        f"_mirror_out_security_alert {remote_id}: status={new_status}, reason={new_reason}, "
        f"tags={new_tags}, inc_status={inc_status}"
    )

    is_closing = is_incident_closing(inc_status, delta)

    # Kibana can only change status/reason/tags on a detection alert; any other edited field is
    # dropped by the mapper and cannot be mirrored out.
    if new_status is None and new_reason is None and new_tags is None and not is_closing:
        mirror_error(
            f"Nothing to mirror out for alert {remote_id}: the change contains none of the fields "
            f"Elasticsearch accepts for a detection alert ({MIRRORABLE_ALERT_FIELDS}). Fields such as "
            "severity cannot be pushed back to Elasticsearch - only the workflow status, its reason "
            f"and the workflow tags are mirrored out. Received delta keys: {list(delta.keys())}."
        )
        return

    if is_closing and not CLOSE_ELASTIC_INCIDENT:
        mirror_debug(
            f"Alert {remote_id} incident was closed but 'Close Mirrored Elasticsearch Incident' is "
            "disabled, so the alert status is left unchanged."
        )

    if CLOSE_ELASTIC_INCIDENT and is_closing:
        new_status = new_status or "closed"
        # Translate the XSOAR close reason into Kibana's vocabulary.
        xsoar_close_reason = delta.get("closeReason") or delta.get("closeNotes")
        new_reason = new_reason or XSOAR_CLOSE_REASON_TO_ELASTIC.get(str(xsoar_close_reason), "other")

    if new_status:
        try:
            # Update by query (matching either identifier) rather than signal_ids, which only
            # matches the document _id and would silently update nothing when a UUID is stored.
            body: Dict[str, Any] = {
                "status": new_status,
                "query": build_alert_lookup_body(remote_id)["query"],
            }
            if new_reason:
                body["reason"] = new_reason
            response = kibana_http_request(
                "POST",
                "/api/detection_engine/signals/status",
                json_data=body,
                proxies=proxies,
            )
            updated = (response or {}).get("updated") if isinstance(response, dict) else None
            if updated == 0:
                mirror_error(
                    f"Elasticsearch reported 0 alerts updated for {remote_id}; the status was NOT changed. "
                    "The alert may live in an index this user cannot write to, or the remote ID no longer exists."
                )
            else:
                mirror_debug(f"Updated alert {remote_id} status to {new_status} (updated={updated}).")
        except Exception as e:
            mirror_error(f"Failed to update alert status for {remote_id}: {e}")

    if new_tags is not None:
        try:
            search_resp = search_security_alerts(build_alert_lookup_body(remote_id), proxies)
            hits = (search_resp or {}).get("hits", {}).get("hits", []) if isinstance(search_resp, dict) else []
            current_tags: List[str] = []
            if hits:
                current_tags = get_alert_source_value(hits[0].get("_source", {}), "kibana.alert.workflow_tags") or []
            else:
                mirror_error(
                    f"Alert {remote_id} was not found, so its tags cannot be updated. "
                    "Verify the remote ID still exists and is readable by this user."
                )

            new_tags_list = argToList(new_tags)
            tags_to_add = [t for t in new_tags_list if t not in current_tags]
            tags_to_remove = [t for t in current_tags if t not in new_tags_list]

            if hits and (tags_to_add or tags_to_remove):
                # The tags API addresses alerts by document _id, so use the fetched hit's _id.
                document_id = hits[0].get("_id") or remote_id
                tags_body: Dict[str, Any] = {
                    "ids": [document_id],
                    "tags": {"tags_to_add": tags_to_add, "tags_to_remove": tags_to_remove},
                }
                kibana_http_request(
                    "POST",
                    "/api/detection_engine/signals/tags",
                    json_data=tags_body,
                    proxies=proxies,
                )
                mirror_debug(f"Updated tags for alert {remote_id}: add={tags_to_add}, remove={tags_to_remove}")
        except Exception as e:
            mirror_error(f"Failed to update tags for alert {remote_id}: {e}")


def _mirror_out_case(
    remote_id: str,
    delta: Dict[str, Any],
    inc_status: Optional[int],
    proxies,
    incident_data: Dict[str, Any],
) -> None:
    """Pushes a Case incident's field changes back to Kibana, optionally closing it and its alerts."""
    # The current case is needed for the version required by the PATCH API.
    try:
        current_case = kibana_http_request("GET", f"/api/cases/{remote_id}", proxies=proxies, allow_not_found=True)
    except Exception as e:
        mirror_error(f"Failed to fetch case {remote_id} for update: {e}")
        return

    if not current_case:
        mirror_debug(f"Case {remote_id} not found in Elasticsearch, skipping update.")
        return

    version = current_case.get("version")
    if not version:
        mirror_debug(f"No version found for case {remote_id}, skipping update.")
        return

    case_fields: Dict[str, Any] = {"id": remote_id, "version": version}

    # delta keys are already Kibana Cases API field names; forward only the accepted ones.
    supported_kibana_fields = ("title", "description", "severity", "status", "tags")
    for kibana_field in supported_kibana_fields:
        if kibana_field in delta:
            case_fields[kibana_field] = delta[kibana_field]

    # The Cases API expects a severity name, not the XSOAR numeric value.
    if "severity" in case_fields:
        elastic_severity = convert_severity_to_elastic(case_fields["severity"])
        if elastic_severity:
            case_fields["severity"] = elastic_severity
        else:
            mirror_debug(f"Dropping unmappable severity {case_fields['severity']!r} for case {remote_id}.")
            case_fields.pop("severity")

    is_closing = CLOSE_ELASTIC_INCIDENT and is_incident_closing(inc_status, delta)
    if is_closing:
        case_fields["status"] = "closed"
        case_fields["closeReason"] = delta.get("closeReason") or incident_data.get("closeReason") or "other"

    if len(case_fields) > 2:  # more than just id + version
        try:
            kibana_http_request(
                "PATCH",
                "/api/cases",
                json_data={"cases": [case_fields]},
                proxies=proxies,
            )
            mirror_debug(f"Updated case {remote_id} with fields: {list(case_fields.keys())}")
        except Exception as e:
            mirror_error(f"Failed to update case {remote_id}: {e}")
    else:
        mirror_debug(f"No supported case fields changed for {remote_id}; delta keys were {list(delta.keys())}.")

    if is_closing:
        try:
            alerts_resp = kibana_http_request("GET", f"/api/cases/{remote_id}/alerts", proxies=proxies, allow_not_found=True)
            alert_ids = [a.get("id") for a in (alerts_resp or []) if a.get("id")]
            if alert_ids:
                kibana_http_request(
                    "POST",
                    "/api/detection_engine/signals/status",
                    json_data={"signal_ids": alert_ids, "status": "closed"},
                    proxies=proxies,
                )
                demisto.debug(f"Closed {len(alert_ids)} alerts related to case {remote_id}")
        except Exception as e:
            demisto.debug(f"Failed to close alerts for case {remote_id}: {e}")


def _is_rate_limit_error(error: Exception) -> bool:
    """Returns True if the given error represents an API rate limit (HTTP 429)."""
    message = str(error).lower()
    return "429" in message or "rate limit" in message or "too many requests" in message


# Maximum number of modified alerts/cases pulled per sync run.
MODIFIED_PAGE_SIZE = 100

# Timestamp fields that indicate a security alert changed. All are listed because no single field
# covers every change type (e.g. updated_at is the only one that moves on a tag-only edit). A range
# query against a missing field simply does not match, so a broad `should` is safe.
ALERT_MODIFICATION_TIME_FIELDS = (
    "kibana.alert.updated_at",
    "kibana.alert.workflow_status_updated_at",
    "kibana.alert.last_detected",
    "@timestamp",
)


def parse_to_utc(value: Any) -> Optional[datetime]:
    """Parses a timestamp into a timezone-aware datetime (for total comparisons), or None."""
    if not value:
        return None
    parsed = dateparser.parse(str(value), settings={"RETURN_AS_TIMEZONE_AWARE": True})
    # dateparser can still return a naive datetime for some inputs; force UTC so callers can
    # always perform timezone-aware comparisons without raising a TypeError.
    if parsed and parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=UTC)
    return parsed


def _get_modified_alert_ids(last_update: str, proxies: dict) -> List[str]:
    """Returns the UUIDs of security alerts modified since ``last_update``."""
    if not last_update:
        # Without a cursor there is nothing meaningful to filter on; {"gte": ""} is also rejected.
        mirror_debug("No lastUpdate provided; skipping the modified-alerts lookup.")
        return []

    body: Dict[str, Any] = {
        "query": {
            "bool": {
                "should": [{"range": {field: {"gte": last_update}}} for field in ALERT_MODIFICATION_TIME_FIELDS],
                "minimum_should_match": 1,
            }
        },
        "size": MODIFIED_PAGE_SIZE,
        # Oldest-first so truncation at MODIFIED_PAGE_SIZE is deterministic (dropped hits are
        # picked up on a later sync).
        "sort": [{"@timestamp": {"order": "asc"}}],
        # Full _source is required: source filtering on flat dotted keys breaks the UUID lookup
        # below, which must match the dbotMirrorId stored by fetch_security_alerts.
        "_source": True,
    }
    # Errors are not swallowed here - a failure must surface rather than look like "nothing changed".
    response = search_security_alerts(body, proxies)

    hits = (response or {}).get("hits", {}).get("hits", []) if isinstance(response, dict) else []
    mirror_debug(f"get-modified-remote-data fetched {len(hits)} modified security alert(s).")

    alert_ids: List[str] = []
    fell_back_to_doc_id = 0
    for hit in hits:
        source = hit.get("_source", {})
        # Fall back to _id like fetch_security_alerts, so the IDs match the stored dbotMirrorId.
        alert_uuid = get_alert_source_value(source, "kibana.alert.uuid")
        if not alert_uuid:
            alert_uuid = hit.get("_id")
            fell_back_to_doc_id += 1
        if alert_uuid:
            alert_ids.append(str(alert_uuid))
        else:
            mirror_debug(f"Skipping modified alert hit without a resolvable ID: {hit.get('_index')}")

    if fell_back_to_doc_id:
        mirror_debug(
            f"{fell_back_to_doc_id} of {len(hits)} modified alert(s) fell back to the document _id; "
            "these will not match incidents ingested with a UUID."
        )

    if hits and not alert_ids:
        mirror_error(
            f"Fetched {len(hits)} modified alert(s) but could not resolve an ID from any of them - "
            "no updates will be mirrored in. This usually means the alert documents have an unexpected shape."
        )

    return alert_ids


def _get_modified_case_ids(last_update: str, proxies: dict) -> List[str]:
    """Returns the IDs of Kibana cases modified since ``last_update``."""
    last_update_dt = parse_to_utc(last_update)
    if not last_update_dt:
        mirror_debug(f"Could not parse lastUpdate {last_update!r}; skipping the modified-cases lookup.")
        return []

    params: Dict[str, Any] = {
        "perPage": MODIFIED_PAGE_SIZE,
        "sortField": "updatedAt",
        "sortOrder": "desc",
    }
    response = kibana_http_request("GET", "/api/cases/_find", params=params, proxies=proxies)
    cases = (response or {}).get("cases", []) if isinstance(response, dict) else []

    case_ids: List[str] = []
    for case in cases:
        case_id = case.get("id")
        updated_at = parse_to_utc(case.get("updated_at"))
        if not updated_at:
            mirror_debug(f"Case {case_id} has an unparsable updated_at {case.get('updated_at')!r}; skipping.")
            continue
        if updated_at > last_update_dt and case_id:
            case_ids.append(str(case_id))

    mirror_debug(f"get-modified-remote-data found {len(case_ids)} modified case(s) out of {len(cases)} returned.")
    return case_ids


def get_modified_remote_data_command(args: Dict[str, Any], proxies: dict) -> GetModifiedRemoteDataResponse:
    """Returns the remote IDs of alerts/cases modified since lastUpdate, for XSOAR to pull."""
    last_update = args.get("lastUpdate", "")
    mirror_debug(f"get-modified-remote-data called with lastUpdate={last_update}")

    modified_ids: List[str] = []

    try:
        modified_ids.extend(_get_modified_alert_ids(last_update, proxies))
    except Exception as e:
        if _is_rate_limit_error(e):
            # Signal the server to stop the sync loop and skip the get-remote-data run.
            return_error(f"API rate limit reached while fetching modified alerts, skip update. Error: {e}", error=e)
        mirror_error(f"Failed to fetch modified security alerts: {e}")

    try:
        modified_ids.extend(_get_modified_case_ids(last_update, proxies))
    except Exception as e:
        if _is_rate_limit_error(e):
            # Signal the server to stop the sync loop and skip the get-remote-data run.
            return_error(f"API rate limit reached while fetching modified cases, skip update. Error: {e}", error=e)
        mirror_error(f"Failed to fetch modified cases: {e}")

    # The same ID can legitimately be reported by both lookups; XSOAR would then sync it twice.
    deduped_ids = list(dict.fromkeys(modified_ids))

    mirror_debug(f"get-modified-remote-data returning {len(deduped_ids)} modified ID(s): {deduped_ids}")
    return GetModifiedRemoteDataResponse(deduped_ids)


def main():  # pragma: no cover
    proxies = handle_proxy() or {}
    args = demisto.args()
    try:
        LOG(f"command is {demisto.command()}")
        if demisto.command() == "test-module":
            return_results(test_func(proxies))
        elif demisto.command() == "fetch-incidents":
            fetch_incident_type = PARAMS.get("fetch_incident_type") or PARAMS.get("incidentType", "")
            if fetch_incident_type == INCIDENT_TYPE_SECURITY_ALERT:
                demisto.incidents(fetch_security_alerts(proxies))
            elif fetch_incident_type == INCIDENT_TYPE_CASE:
                demisto.incidents(fetch_cases(proxies))
            else:
                fetch_incidents(proxies)
        elif demisto.command() == "get-remote-data":
            return_results(get_remote_data_command(args, proxies))
        elif demisto.command() == "update-remote-system":
            return_results(update_remote_system_command(args, proxies))
        elif demisto.command() == "get-modified-remote-data":
            return_results(get_modified_remote_data_command(args, proxies))
        elif demisto.command() in ["search", "es-search"]:
            search_command(proxies)
        elif demisto.command() == "get-mapping-fields":
            return_results(get_mapping_fields_command())
        elif demisto.command() == "es-eql-search":
            return_results(search_eql_command(args, proxies))
        elif demisto.command() == "es-esql-search":
            return_results(search_esql_command(args, proxies))
        elif demisto.command() == "es-index":
            return_results(index_document_command(args, proxies))
        elif demisto.command() == "es-integration-health-check":
            return_results(integration_health_check(proxies))
        elif demisto.command() == "es-get-indices-statistics":
            return_results(get_indices_statistics_command(args, proxies))
        elif demisto.command() == "es-kibana-case-create":
            return_results(es_kibana_case_create_command(args, proxies))
        elif demisto.command() == "es-kibana-case-update":
            return_results(es_kibana_case_update_command(args, proxies))
        elif demisto.command() == "es-kibana-case-delete":
            return_results(es_kibana_case_delete_command(args, proxies))
        elif demisto.command() == "es-kibana-case-list":
            return_results(es_kibana_case_list_command(args, proxies))
        elif demisto.command() == "es-kibana-case-alerts-list":
            return_results(es_kibana_case_alerts_list_command(args, proxies))
        elif demisto.command() == "es-kibana-case-comment-add":
            return_results(es_kibana_case_comment_add_command(args, proxies))
        elif demisto.command() == "es-kibana-case-comment-update":
            return_results(es_kibana_case_comment_update_command(args, proxies))
        elif demisto.command() == "es-kibana-case-comment-delete":
            return_results(es_kibana_case_comment_delete_command(args, proxies))
        elif demisto.command() == "es-kibana-case-file-attach":
            return_results(es_kibana_case_file_attach_command(args, proxies))
        elif demisto.command() == "es-kibana-alerting-health-get":
            return_results(es_kibana_alerting_health_get_command(args, proxies))
        elif demisto.command() == "es-kibana-rule-types-list":
            return_results(es_kibana_rule_types_list_command(args, proxies))
        elif demisto.command() == "es-kibana-rule-list":
            return_results(es_kibana_rule_list_command(args, proxies))
        elif demisto.command() == "es-kibana-rule-enable":
            return_results(es_kibana_rule_enable_command(args, proxies))
        elif demisto.command() == "es-kibana-rule-disable":
            return_results(es_kibana_rule_disable_command(args, proxies))
        elif demisto.command() == "es-kibana-rule-update":
            return_results(es_kibana_rule_update_command(args, proxies))
        elif demisto.command() == "es-kibana-rule-alert-mute":
            return_results(es_kibana_rule_alert_mute_command(args, proxies))
        elif demisto.command() == "es-kibana-rule-alert-unmute":
            return_results(es_kibana_rule_alert_unmute_command(args, proxies))
        elif demisto.command() == "es-kibana-detection-alert-status-set":
            return_results(es_kibana_detection_alert_status_set_command(args, proxies))
        elif demisto.command() == "es-kibana-endpoint-exception-list-item-create":
            return_results(es_kibana_endpoint_exception_list_item_create_command(args, proxies))
        elif demisto.command() == "es-kibana-endpoint-exception-list-item-update":
            return_results(es_kibana_endpoint_exception_list_item_update_command(args, proxies))
        elif demisto.command() == "es-kibana-endpoint-exception-list-item-delete":
            return_results(es_kibana_endpoint_exception_list_item_delete_command(args, proxies))
        elif demisto.command() == "es-kibana-endpoint-exception-list-item-list":
            return_results(es_kibana_endpoint_exception_list_item_list_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-list-list":
            return_results(es_kibana_exception_list_list_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-list-create":
            return_results(es_kibana_exception_list_create_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-list-update":
            return_results(es_kibana_exception_list_update_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-list-delete":
            return_results(es_kibana_exception_list_delete_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-list-item-list":
            return_results(es_kibana_exception_list_item_list_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-list-item-create":
            return_results(es_kibana_exception_list_item_create_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-item-list-update":
            return_results(es_kibana_exception_item_list_update_command(args, proxies))
        elif demisto.command() == "es-kibana-exception-list-item-delete":
            return_results(es_kibana_exception_list_item_delete_command(args, proxies))
        elif demisto.command() == "es-kibana-value-lists-list":
            return_results(es_kibana_value_lists_list_command(args, proxies))
        elif demisto.command() == "es-kibana-value-list-item-get":
            return_results(es_kibana_value_list_item_get_command(args, proxies))
        elif demisto.command() == "es-kibana-value-list-item-create":
            return_results(es_kibana_value_list_item_create_command(args, proxies))
        elif demisto.command() == "es-kibana-value-list-item-update":
            return_results(es_kibana_value_list_item_update_command(args, proxies))
        elif demisto.command() == "es-kibana-value-list-item-delete":
            return_results(es_kibana_value_list_item_delete_command(args, proxies))
        elif demisto.command() == "es-kibana-value-list-item-export":
            return_results(es_kibana_value_list_item_export_command(args, proxies))
        elif demisto.command() == "es-kibana-value-list-item-import":
            return_results(es_kibana_value_list_item_import_command(args, proxies))

    except Exception as e:
        if "The client noticed that the server is not a supported distribution of Elasticsearch" in str(e):
            return_error(
                f"Failed executing {demisto.command()}. Seems that the client does not support the server's "
                f"distribution, Please try using the Open Search client in the instance configuration."
                f"\nError message: {e!s}",
                error=str(e),
            )
        if "failed to parse date field" in str(e):
            return_error(
                f"Failed to execute the {demisto.command()} command. Make sure the `Time field type` is correctly set.",
                error=str(e),
            )
        return_error(f"Failed executing {demisto.command()}.\nError message: {e}", error=str(e))


if __name__ in ("__main__", "builtin", "builtins"):
    main()