
Security Detection Rule Management
- 2 installs
- 31 repo stars
- Updated May 28, 2026
- elastic/cursor-plugins
This is a copy of security-detection-rule-management by elastic - installs and ranking accrue to the original listing.
Helps with security tasks.
About
security-detection-rule-management is a Claude Code skill for security. It helps solo builders move faster with AI-assisted development.
- security-detection-rule-management
- Security
- AI-coding skill
Security Detection Rule Management by the numbers
- 2 all-time installs (skills.sh)
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/elastic/cursor-plugins --skill security-detection-rule-managementAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 31 |
| Last updated | May 28, 2026 |
| Repository | elastic/cursor-plugins ↗ |
What it does
Helps with security tasks.
Files
Detection Rule Management
Create new detection rules for emerging threats and coverage gaps, and tune existing rules to reduce false positives. All operations use the Kibana Detection Engine API via rule-manager.js.
Execution rules
- Start executing tools immediately — do not read SKILL.md, browse the workspace, or list files first.
- Report tool output faithfully. Copy rule IDs, names, alert counts, exception IDs, and error messages exactly as
returned by the API. Do not abbreviate rule UUIDs, invent rule names, or round alert counts.
- When a tool returns an error (rule not found, API failure), report the exact error — do not guess at alternatives.
Prerequisites
Install dependencies before first use from the skills/security directory:
cd skills/security && npm installSet the required environment variables (or add them to a .env file in the workspace root):
export ELASTICSEARCH_URL="https://your-cluster.es.cloud.example.com:443"
export ELASTICSEARCH_API_KEY="your-api-key"
export KIBANA_URL="https://your-cluster.kb.cloud.example.com:443"
export KIBANA_API_KEY="your-kibana-api-key"Common multi-step workflows
| Task | Tools to call (in order) |
|---|---|
| Tune noisy SIEM rule | rule_manager find/noisy-rules → run_query (investigate FPs) → rule_manager patch or add-exception |
| Add endpoint behavior exception | fetch_endpoint_rule (get rule definition from GitHub) → add_endpoint_exception (scoped to rule.id) |
| Create new detection rule | run_query (test query against data) → rule_manager create |
| Investigate rule alert volume | rule_manager get → run_query (query alerts index) |
For endpoint behavior rules, always fetch the rule definition first to understand query logic and existing exclusions before adding an exception. For SIEM rules, always investigate alert patterns with run_query before tuning.
Critical: For endpoint behavior rules, always use fetch_endpoint_rule (not shell or direct script calls) to get the rule definition, then use add_endpoint_exception to add the exception. These are dedicated tools — do not invoke the underlying scripts manually.
Workflow: Tune a rule for false positives
Steps 1–2: Identify noisy rules and analyze false positives
Find noisy rules with noisy-rules or find, then get the rule definition and investigate alerts:
node skills/security/detection-rule-management/scripts/rule-manager.js noisy-rules --days 7 --top 20
node skills/security/detection-rule-management/scripts/rule-manager.js find --filter "alert.attributes.name:*Suspicious*" --brief
node skills/security/detection-rule-management/scripts/rule-manager.js get --id <rule_uuid>
node skills/security/alert-triage/scripts/run-query.js "kibana.alert.rule.name:\"<rule_name>\"" --index ".alerts-security.alerts-*" --days 7 --fullLook for patterns: same process/user/host → exception candidate; broad pattern → tighten query; legitimate software → exception; too broad → rewrite or adjust threshold.
Step 3: Choose a tuning strategy
In order of preference:
1. Add exception — Best for specific known-good processes, users, or hosts. Does not modify the rule query. Use when the rule is correct in general but fires on known-legitimate activity.
2. Tighten the query — Patch the rule's query to exclude the FP pattern. Best when the false positives stem from the query being too broad.
3. Adjust threshold / alert suppression — For threshold rules, increase the threshold value. For any rule type, enable alert suppression to reduce duplicate alerts on the same entity.
4. Reduce risk score / severity — Downgrade the rule's priority if it generates many low-value alerts but still has some detection value.
5. Disable the rule — Last resort. Only if the rule provides no value or is completely redundant with another rule.
Steps 4–5: Apply tuning, verify, and document
Add exception (single/multi-condition, wildcard via matches):
node skills/security/detection-rule-management/scripts/rule-manager.js add-exception \
--rule-uuid <rule_uuid> \
--entries "process.executable:is:C:\\Program Files\\SCCM\\CcmExec.exe" "process.parent.name:is:CcmExec.exe" \
--name "Exclude SCCM" --comment "FP: SCCM deployment" --tags "tuning:fp" "source:soc" --yesPatch query, threshold, severity, or disable:
node skills/security/detection-rule-management/scripts/rule-manager.js patch --id <rule_uuid> --query "process.name:powershell.exe AND NOT process.parent.name:CcmExec.exe" --yes
node skills/security/detection-rule-management/scripts/rule-manager.js patch --id <rule_uuid> --max-signals 50 --yes
node skills/security/detection-rule-management/scripts/rule-manager.js patch --id <rule_uuid> --severity low --risk-score 21 --yes
node skills/security/detection-rule-management/scripts/rule-manager.js disable --id <rule_uuid> --yesWrite operations (patch, enable, disable, delete, add-exception, bulk-action) prompt for confirmation by default. Pass --yes to skip the prompt (required when called by an agent).
Verify with rule-manager.js get --id <rule_uuid>. Update triage cases via the case-management skill.
---
Workflow: Create new detection rule
Steps 1–2: Define the threat, data sources, and fields
Specify MITRE ATT&CK technique(s), required data sources (Endpoint, Network, Cloud), and malicious vs legitimate behavior. Common indexes: logs-endpoint.events.process-*, logs-endpoint.events.network-*, .alerts-security.alerts-*, logs-windows.*, logs-aws.*. Key fields: process.name, process.command_line, process.parent.name, destination.ip, winlog.event_id, event.action. Verify data with run-query.js:
node skills/security/alert-triage/scripts/run-query.js "process.name:certutil.exe" --index "logs-endpoint.events.process-*" --days 30 --size 5Step 3: Write and test the query
Rule types: query (KQL field matching), eql (event sequences), esql (aggregations), threshold (volume-based), threat_match (IOC correlation), new_terms (first-seen). Test against Elasticsearch before creating:
node skills/security/alert-triage/scripts/run-query.js "process.name:certutil.exe AND process.command_line:(*urlcache* OR *decode*)" \
--index "logs-endpoint.events.process-*" --days 30For EQL, use --query-file to avoid shell escaping issues.
Validate query syntax before creating or patching a rule. The validate-query command catches common errors locally — escaped backslashes, mismatched parentheses, unbalanced quotes, and duplicate boolean operators:
node skills/security/detection-rule-management/scripts/rule-manager.js validate-query \
--query "process.name:taskkill.exe AND process.command_line:(*chrome.exe* OR *msedge.exe*)" --language kueryThe create and patch commands also run validation automatically and reject invalid queries. Pass --skip-validation only if you are certain the query is correct despite triggering a check.
Common KQL syntax mistakes:
- Escaped forward-slashes — KQL wildcards use plain text. Write
*/IM chrome.exe*, not*\/IM chrome.exe*. - Mismatched parentheses — every
(must have a matching). - Unbalanced quotes — every
"must be paired. - Duplicate operators —
AND ANDorOR ORis always an error.
Step 4: Create the rule
node skills/security/detection-rule-management/scripts/rule-manager.js create \
--name "Certutil URL Download or Decode" \
--description "Detects certutil.exe used to download files or decode Base64 payloads, a common LOLBin technique." \
--type query \
--query "process.name:certutil.exe AND process.command_line:(*urlcache* OR *decode*)" \
--index "logs-endpoint.events.process-*" \
--severity medium --risk-score 47 \
--tags "OS:Windows" "Tactic:Defense Evasion" "Tactic:Command and Control" \
--false-positives "IT administrators using certutil for legitimate certificate operations" \
--references "https://attack.mitre.org/techniques/T1140/" \
--interval 5m --disabledFor complex rules (EQL sequences, MITRE mappings, alert suppression), use create --from-file rule_definition.json and --threat-file. See references/detection-api-reference.md for schema.
Step 5: Monitor and iterate
Monitor alert volume with noisy-rules --days 3 --top 10 and tune false positives as needed.
---
Workflow: Endpoint behavior rules tuning
Tune Elastic Endpoint behavior rules by adding Endpoint exceptions scoped to specific rules. Endpoint exceptions live in Security → Exceptions → Endpoint Security Exception List, not under individual SIEM rules.
Key principles: Always fetch the rule definition from protections-artifacts first. Always scope exceptions to the rule (rule.id or rule.name). Use full paths over process names. Run the mandatory entity cross-check (Step 4b) before any exception. Simulate impact (Step 5b) and aim for ≥60% noise reduction.
Scripts: fetch-endpoint-rule-from-github.js (get rule TOML by id), add-endpoint-exception.js (add to Endpoint Exception List; rule.id/rule.name required), check-exclusion-best-practices.js.
For the full step-by-step workflow (Steps 1–6), queries, and simulation templates, see references/endpoint-behavior-tuning-workflow.md. For exclusion best practices, see references/endpoint-rule-exclusion-best-practices.md.
---
Tool reference
rule-manager.js
All commands are run from the workspace root. All output is JSON unless noted.
| Command | Description |
|---|---|
find | Search/list rules with optional KQL filter |
get | Get a rule by --id or --rule-id |
create | Create a rule (inline flags or --from-file) |
patch | Patch specific fields on a rule |
enable | Enable a rule |
disable | Disable a rule |
delete | Delete a rule |
export | Export rules as NDJSON |
bulk-action | Bulk enable/disable/delete/duplicate/edit |
add-exception | Add an exception item to a rule |
list-exceptions | List items on an exception list |
create-shared-list | Create a shared exception list |
noisy-rules | Find noisiest rules by alert volume |
validate-query | Check query syntax before create/patch |
Endpoint behavior tuning: fetch-endpoint-rule-from-github.js (get rule TOML by id), add-endpoint-exception.js (add to Endpoint Exception List; rule.id/rule.name required), check-exclusion-best-practices.js.
Exception entry format
Pass entries as field:operator:value. Operators: is, is_not, is_one_of, is_not_one_of, exists, does_not_exist, matches, does_not_match. Example: process.name:is:svchost.exe, file.path:matches:C:\\Program Files\\*.
Additional resources
- For full API schema details, see references/detection-api-reference.md
- For endpoint behavior tuning: references/endpoint-exceptions-guide.md,
references/endpoint-rule-exclusion-best-practices.md
- For alert investigation during tuning, use the
alert-triageskill - For documenting tuning actions in cases, use the
case-managementskill
Examples
- "Find the noisiest detection rules from the last 7 days and help me tune one"
- "Add an exception to exclude SCCM from the suspicious PowerShell rule"
- "Create a new detection rule for certutil URL download or decode"
Guidelines
- Report only tool output. When summarizing results, quote or paraphrase only what the tools returned. Do not invent
IDs, hostnames, IPs, scores, process trees, or other details not present in the tool response.
- Preserve identifiers from the request. If the user provides specific hostnames, agent IDs, case IDs, or other
values, use those exact values in tool calls and responses — do not substitute different identifiers.
- Confirm actions concisely. After executing a tool, confirm what was done using the tool's return data. Do not
fabricate internal IDs, metadata, or status details unless they appear in the tool response.
- Distinguish facts from inference. If you draw conclusions beyond what the tools returned (e.g., suggesting a MITRE
technique based on observed behavior), clearly label those as your assessment rather than presenting them as tool output.
- Start executing tools immediately. Do not read SKILL.md, browse directories, or list files before acting.
- Report tool output verbatim. Copy rule IDs, names, alert counts, and error messages exactly as returned. Do not
abbreviate UUIDs or round numbers.
Production use
- All write operations (
create,patch,enable,disable,delete,add-exception,bulk-action,
add-endpoint-exception) prompt for confirmation. Pass --yes or -y to skip when called by an agent.
- Endpoint exceptions suppress detections globally. Always scope exceptions to a specific rule using
rule.idor
rule.name in the entries. A broad, unscoped exception can silently reduce detection coverage.
- Verify environment variables point to the intended cluster before running any script.
- Use
--dry-runwithbulk-actionto preview impact before executing bulk changes.
Environment variables
| Variable | Required | Description |
|---|---|---|
ELASTICSEARCH_URL | Yes | Elasticsearch URL (for noisy-rules aggregation) |
ELASTICSEARCH_API_KEY | Yes | Elasticsearch API key |
KIBANA_URL | Yes | Kibana URL (for rules API) |
KIBANA_API_KEY | Yes | Kibana API key |
Kibana Detection Engine API Reference
Quick reference for the detection rules and exceptions APIs used by rule_manager.py.
Detection rules
| Operation | Method | Path |
|---|---|---|
| Find / list rules | GET | /api/detection_engine/rules/_find |
| Get single rule | GET | /api/detection_engine/rules?id=<uuid> or ?rule_id=<stable_id> |
| Create rule | POST | /api/detection_engine/rules |
| Patch rule (partial) | PATCH | /api/detection_engine/rules |
| Update rule (full) | PUT | /api/detection_engine/rules |
| Delete rule | DELETE | /api/detection_engine/rules?id=<uuid> |
| Bulk action | POST | /api/detection_engine/rules/_bulk_action |
| Export rules (NDJSON) | POST | /api/detection_engine/rules/_export |
| Import rules (NDJSON) | POST | /api/detection_engine/rules/_import |
| Preview rule | POST | /api/detection_engine/rules/preview |
| Get tags | GET | /api/detection_engine/tags |
Rule types
type value | Language | Description |
|---|---|---|
query | kuery or lucene | Custom KQL / Lucene query |
eql | eql | Event Query Language (sequences, joins) |
esql | esql | ES\ |
threshold | kuery | Alert when field value count exceeds threshold |
machine_learning | — | Anomaly-based (requires ML job) |
threat_match | kuery | Indicator match / threat intel |
new_terms | kuery | Alert on previously unseen field values |
Key create/patch body fields
| Field | Type | Notes |
|---|---|---|
name | string | Required |
description | string | Required |
type | string | See rule types above |
query | string | Detection query |
language | string | kuery, lucene, eql, esql |
index | string[] | Index patterns (not for ES\ |
severity | string | low, medium, high, critical |
risk_score | int | 0-100 |
interval | string | e.g. 5m, 1h |
from | string | Lookback, e.g. now-6m |
tags | string[] | Categorization tags |
enabled | bool | Default true |
threat | object[] | MITRE ATT&CK mapping |
false_positives | string[] | Known FP descriptions |
note | string | Investigation guide (markdown) |
max_signals | int | Max alerts per run (default 100) |
exceptions_list | object[] | Attached exception lists |
alert_suppression | object | Suppress duplicate alerts |
building_block_type | string | "default" for building blocks |
MITRE ATT&CK threat field structure
[
{
"framework": "MITRE ATT&CK",
"tactic": {
"id": "TA0003",
"name": "Persistence",
"reference": "https://attack.mitre.org/tactics/TA0003/"
},
"technique": [
{
"id": "T1547",
"name": "Boot or Logon Autostart Execution",
"reference": "https://attack.mitre.org/techniques/T1547/",
"subtechnique": [
{
"id": "T1547.001",
"name": "Registry Run Keys / Startup Folder",
"reference": "https://attack.mitre.org/techniques/T1547/001/"
}
]
}
]
}
]Bulk action types
| Action | Description |
|---|---|
enable | Enable matching rules |
disable | Disable matching rules |
delete | Delete matching rules |
duplicate | Duplicate matching rules |
export | Export matching rules |
edit | Edit tags, index patterns, actions, or schedules |
Bulk edit sub-actions (edit field):
[
{ "type": "add_tags", "value": ["tuned"] },
{ "type": "delete_tags", "value": ["needs-review"] },
{ "type": "set_tags", "value": ["production", "tuned"] },
{ "type": "add_index_patterns", "value": ["logs-newdata-*"] },
{ "type": "delete_index_patterns", "value": ["logs-old-*"] },
{ "type": "set_index_patterns", "value": ["logs-endpoint.*"] },
{ "type": "set_schedule", "value": { "interval": "10m", "lookback": "5m" } }
]Exceptions
| Operation | Method | Path |
|---|---|---|
| Add exception to rule | POST | /api/detection_engine/rules/{id}/exceptions |
| Create exception list | POST | /api/exception_lists |
| Create shared list | POST | /api/exceptions/shared |
| Find exception lists | GET | /api/exception_lists/_find |
| Find exception items | GET | /api/exception_lists/items/_find |
| Create exception item | POST | /api/exception_lists/items |
| Update exception item | PUT | /api/exception_lists/items |
| Delete exception item | DELETE | /api/exception_lists/items?id=<id> |
Exception entry operators
| Shorthand | type | operator | Value format |
|---|---|---|---|
is | match | included | single string |
is_not | match | excluded | single string |
is_one_of | match_any | included | comma-separated |
is_not_one_of | match_any | excluded | comma-separated |
exists | exists | included | — |
does_not_exist | exists | excluded | — |
matches | wildcard | included | wildcard pattern |
does_not_match | wildcard | excluded | wildcard pattern |
Exception item structure
{
"type": "simple",
"name": "Exclude SCCM deployments",
"description": "SCCM pushes trigger this rule; confirmed benign",
"entries": [
{
"field": "process.parent.name",
"type": "match",
"operator": "included",
"value": "CcmExec.exe"
}
],
"tags": ["tuning:fp", "source:soc"],
"comments": [{ "comment": "Added after triage case #1234. SCCM deploys via CcmExec." }]
}Environment variables
| Variable | Required | Description |
|---|---|---|
ELASTICSEARCH_URL | Yes | Elasticsearch URL |
ELASTICSEARCH_API_KEY | Yes | Elasticsearch API key |
KIBANA_URL | Yes | Kibana URL |
KIBANA_API_KEY | Yes | Kibana API key |
Endpoint Behavior Rules Tuning Workflow
Analyze alerts from Elastic Endpoint behavior rules and add Endpoint exceptions to reduce false positives. Endpoint exceptions live in Security → Exceptions → Endpoint Security Exception List (/app/security/exceptions), not under individual SIEM rules. Use the scripts in this skill for rule lookup, GitHub rule fetch, and adding endpoint list items.
Reference: Add and manage exceptions (Endpoint). Rule logic: protections-artifacts/behavior/rules or Kibana rule get by ID.
Critical principles (endpoint behavior)
- Start with rule definition from protections-artifacts (Step 1b) to understand query logic and exclusions; avoid
broad exclusions.
- When in doubt, do not add an exception — return your conclusion and let the user confirm.
- Always scope to the rule: Include
rule.id:is:<uuid>orrule.name:is:<name>first; otherwise the exception
applies to all rules.
- Use full path over process name (e.g.
C:\Python39\python.exe); wildcards only for variable segments. See
endpoint-rule-exclusion-best-practices.md.
- Step 4b is mandatory: Run entity cross-check before any endpoint exception; skipping can exclude true positives.
- Validate before applying: Simulate (Step 5b); aim for ≥60% noise reduction.
Endpoint tuning progress
- [ ] Step 1: Identify the noisy rule and get definition (Kibana)
- [ ] Step 1b: Pull rule content from GitHub (query + exclusions)
- [ ] Step 2: FP likelihood (host/user spread, 24h)
- [ ] Step 3: Top noisy patterns (ES|QL)
- [ ] Step 4: Single-host entity_id check
- [ ] Step 4b: Single-host deep investigation (entity cross-check) — MANDATORY; do not skip
- [ ] Step 5: Design exception (aligned with rule logic; use full path for resilience)
- [ ] Step 5b: Simulate exception impact (before/after)
- [ ] Step 6: Add endpoint exception and verifyStep 1 & 1b: Identify rule and pull from GitHub
Endpoint behavior rule definitions live in the protections-artifacts repo, not in Kibana. To get the rule id (UUID) and the rule definition:
1. Get rule.id from the alerts index by querying with the rule name (from noisy-rules or the UI). Replace <rule_name> with the exact behavior rule name (e.g. Suspicious PowerShell Execution):
FROM .alerts-security.alerts-*
| WHERE rule.name == "<rule_name>"
| STATS count = COUNT(*) BY rule.id
| SORT count DESC
| LIMIT 11. Fetch the rule definition from protections-artifacts using the rule id:
node skills/security/detection-rule-management/scripts/fetch-endpoint-rule-from-github.js --rule-id <rule_id>Use the printed query from GitHub to see fields and existing exclusions; then proceed to Step 2–5b. For SIEM rules (not endpoint behavior), use rule-manager.js find --filter "alert.attributes.name: ..." to look up by name on the stack.
Step 2–4b: FP likelihood, patterns, entity cross-check
When searching the alerts index for endpoint rules, use `rule.name` (e.g. in ES|QL rule.name == "Suspicious PowerShell Execution" or in KQL rule.name:<name>). Use rule.id in aggregations once you have it from a sample alert.
- Step 2: ES|QL on
.alerts-security.alerts-*withrule.name == "<rule_name>"(orrule.id == "<rule_id>") and
@timestamp >= now() - 24 hours; STATS n_hosts = COUNT_DISTINCT(host.id), n_users = COUNT_DISTINCT(user.name) BY rule.id. ≥10 hosts or ≥5 users → likely broad FP.
- Step 3: Top patterns by
process.executable,process.parent.executable,user.name,host.name(filter by
rule.name or rule.id).
- Step 4: For single host, check
process.entity_idacross alerts:WHERE process.entity_id == "<id>"and
STATS ... BY rule.id, event.code.
- Step 4b (mandatory — do not skip): Single-host deep investigation. Take one sample alert; run entity cross-check
by rule.name and event.code. If the same process.entity_id or process.parent.entity_id appears in other rules or event types (e.g. memory_signature, shellcode_thread, ransomware) → treat as TP, do not add exception. You must complete this step before adding any endpoint exception. Entity cross-check query:
FROM .alerts-security.alerts-*
| WHERE (process.entity_id == "<entity_id>" OR process.parent.entity_id == "<entity_id>")
AND @timestamp >= NOW() - 7 days
| STATS alert_count = COUNT(*) BY rule.name, event.code
| SORT alert_count DESCStep 5 & 5b: Design exception and simulate impact
Match the rule's event type and exclusion style. Use full path when known; wildcards only for variable segments. For LOLBins use path + args. Simulate before applying: baseline count, then same query with AND NOT (process.executable LIKE "..."). Aim for ≥60% reduction.
Baseline query:
FROM .alerts-security.alerts-*
| WHERE rule.name == "<rule_name>"
AND @timestamp >= NOW() - 24 hours
| STATS alert_count = COUNT(*), distinct_hosts = COUNT_DISTINCT(host.id)After-exception query (add your exclusion condition):
FROM .alerts-security.alerts-*
| WHERE rule.name == "<rule_name>"
AND @timestamp >= NOW() - 24 hours
AND NOT (process.executable LIKE "<exception_pattern>")
| STATS alert_count = COUNT(*), distinct_hosts = COUNT_DISTINCT(host.id)Step 6: Add endpoint exception
Only after Step 5b shows meaningful reduction and you are confident the pattern is FP. Always include rule scope (rule.id or rule.name first):
node skills/security/detection-rule-management/scripts/add-endpoint-exception.js \
--name "Exclude <short description> (<rule name>)" \
--entries "rule.id:is:<rule_uuid>" "process.executable:matches:C:\\Program Files\\Vendor\\*\\agent.exe" \
--comment "FP: Legitimate agent; path + parent verified" \
--os-types windowsVerify in Kibana: Security → Exceptions → Endpoint Security Exception List.
For full workflow detail, event-type strategies (single-event vs API/call stack), and LOLBin guidance, see endpoint-exceptions-guide.md and endpoint-rule-exclusion-best-practices.md.
Endpoint Exceptions – Quick Reference
- Docs:
Add and manage exceptions – Endpoint rule exceptions
- Where they live: Endpoint exceptions are in Security → Exceptions → Endpoint Security Exception List
(/app/security/exceptions). They are not the same as SIEM/detection rule exceptions (which are tied to a specific rule).
- API: Add items via
POST /api/endpoint_list/items. The list haslist_id: endpoint_list,
namespace_type: agnostic. Items apply to Elastic Endpoint (and to detection rules that use the Endpoint exception list).
Nested fields (use in conditions)
For exceptions that need code signature or token details, use nested conditions in the Kibana UI. Supported nested objects include:
process.Ext.code_signatureprocess.parent.Ext.code_signatureprocess.Ext.token.privilegesfile.Ext.code_signatureTarget.process.Ext.code_signature- (See full list in
Exceptions with nested conditions.)
Example: exclude processes with trusted code signature – add a condition on process.Ext.code_signature with nested condition subject_name or use the trusted/status field as required by your Kibana version.
Resilient exception design
| Do | Avoid |
|---|---|
| Always include `rule.id` or `rule.name` so the exception applies only to that rule | Adding only process/parent/file conditions (applies to all rules using the list) |
Use full path when known (e.g. C:\Python39\python.exe) | Generic patterns like *\python.exe or *\binary.exe |
Use process.executable (path) | Rely only on process.name (evadable by rename) |
| Wildcards only for variable path segments (e.g. version dirs) | Broad wildcards (e.g. *\*.exe) |
| Combine path + parent path + code_signature when possible | Single-field exceptions for high-risk rules |
| Run single-host deep (entity cross-check) before adding — mandatory | Skipping Step 4b; excluding a process that appears in other rules/event types (e.g. memory_signature, shellcode) |
Searching the alerts index for endpoint rules
When querying .alerts-security.alerts-* for endpoint behavior rules, use `rule.name` (e.g. in ES|QL rule.name == "Suspicious PowerShell Execution" or in KQL rule.name:<name>). Use rule.id in aggregations once you have it from a sample alert.
Rule logic and existing exclusions
- Prepackaged behavior rules:
elastic/protections-artifacts/behavior/rules (windows, linux, macos, cross-platform).
- Rule content (query and built-in exclusions) is not stored in Elasticsearch; use
fetch_endpoint_rule_from_github.py
or Kibana Detection Engine rule get by rule ID to see existing exclusion patterns.
Endpoint rule exclusion best practices
Best practices for designing evasion-resilient exclusions when tuning Elastic Defend behavior rules—whether you add Endpoint exceptions (Security → Exceptions) or contribute rule changes (e.g. to a rule repo). These improve detection quality and EQL performance.
Reference: EQL syntax. Endpoint rules use EQL in the rule query; exceptions use the same field semantics.
---
Paths and wildcards
- Use full path for resilience: When the path is known, use the full process path (e.g.
C:\Python39\python.exe, C:\Program Files\Vendor\agent.exe). Do not use generic patterns like *\python.exe or *\binary.exe—they are less resilient and can match unintended locations.
- Use `?:\\` for drive-agnostic paths (e.g.
?:\\Program Files\\Vendor\\*\\bin\\agent.exe), not fixedC:\\. - Case-insensitive matching: Use the `:` operator for paths (process executable, file path, DLL path)—attackers
can control casing. Prefer process.executable : ("path1", "path2") over == or in.
- Wildcards only where necessary: Use wildcards only for variable path segments (e.g. version number, GUID). Do
not wildcard non-variable segments (e.g. Program Files is not user-writable; use literal Program Files unless you have observed the same pattern from different folders). Overuse of wildcards can hurt EQL execution performance and weakens specificity.
- User-writable paths (e.g.
?:\\Users\\*\\AppData\\*): Do not exclude unconditionally—an attacker can drop a
binary there to evade. Either avoid excluding those paths or gate them behind trust signals (e.g. process.code_signature.trusted == true and process.code_signature.subject_name : ("Expected Vendor")).
---
Scripting utilities / LOLBins (cmd, powershell, bash, wscript, mshta, etc.)
- Never exclude by path only or by cmdline/args only. Attackers use the same binaries. Always combine
process.name or process.parent.name or executable path with args or cmdline.
- Prefer `process.args` / `process.parent.args` + path when the pattern is not random or variable:
- Good:
not (process.args : "a" and process.args : "b" and process.parent.executable : "path") - Avoid when possible:
not (process.command_line like~ "*a*b*" and process.parent.executable : "path") - Overuse of wildcards on `command_line` when unnecessary can hurt endpoint EQL execution performance. Use
cmdline with wildcards only when you truly need substring or variable patterns (e.g. random tokens). Otherwise use args + path.
- Windows LOLBins: Prefer
process.pe.original_file_namefor resilience; use `:` for case-insensitivity (e.g.
process.pe.original_file_name : "curl.exe").
---
API rules (events from api where)
- Do not base exclusions on process-level fields only (e.g.
process.code_signature.trusted+
process.code_signature.subject_name). An attacker can sideload a malicious DLL into a signed process and trigger the API from that DLL, bypassing the rule.
- Do not exclude solely on `process.thread.Ext.call_stack_final_user_module.path` or `.hash` for the same
reason (the “final user module” could be the injected DLL).
- Prefer `process.thread.Ext.call_stack_final_user_module.path` or `.hash` when they give a good, narrow
option (e.g. known benign DLL). If no good option, then use `process.thread.Ext.call_stack_summary` (and _arraysearch(process.thread.Ext.call_stack, ...) on symbol_info) to exclude known benign call patterns (e.g. specific DLL sequences), not a single module path/hash or the process signature.
---
Code signature and context
- Code signature subject_name: Use `:` (case-insensitive) for vendor names—casing varies across deployments
(e.g. "TeamViewer" vs "Teamviewer"). process.code_signature.subject_name : ("Vendor Name", ...).
- Resilient exclusions: When the alert includes code signature and rule-relevant context (e.g.
process.Ext.desktop_name for hidden-window rules), combine them: e.g. exclude when process.code_signature.trusted == true and process.code_signature.subject_name : ("Vendor", ...) and process.Ext.desktop_name : ("WinSta0\\Winlogon", ...) rather than path alone. Use the same fields the rule already relies on to narrow the exclusion to the benign scenario.
---
Rule query hygiene (when editing rule source)
- Do not add comments in the rule query that duplicate the PR or ticket (e.g.
/* resilient FP exclusions */). Keep
rationale in the PR or change description.
- Limit changes to excluding false positives; do not extend detection scope (e.g. adding new “or” conditions that
broaden what the rule fires on).
---
PR / change description (when contributing rule changes)
- Title format:
[Rule Tuning] <rule_name>. - updated_date: Set to the current date (YYYY-MM-DD) when you change the rule.
- Sample alerts for review: When the number of excluded FP patterns is low (≤ 3–5), include **one sample alert
per FP pattern in the PR/description, not as a separate file. Use <details> / <summary>** so the JSON is collapsed by default (e.g. under "## FP sample alerts", wrap the ``json block in <details><summary>…</summary> … </details>`). Strip PII (host, user, cluster, agent id, policy/artifacts); keep fields that justify the exclusion (process, rule, code signature, command_line/args, parent, rule-specific context).
---
Checklist (exclusions)
- [ ] Paths:
?:\\where appropriate; `:` for case-insensitivity; wildcards only for variable segments. - [ ] LOLBins/scripting: combined name or path with args or cmdline; prefer args + path over command_line
wildcards when pattern is not variable.
- [ ] API rules: no process-only exclusions; use call_stack_final_user_module or call_stack_summary for benign
call patterns.
- [ ] User-writable paths: not excluded unconditionally; gated by trust/code signature when used.
- [ ] No in-query comments that duplicate the PR/description.
- [ ] When contributing rule changes: updated_date set; sample alerts (if ≤ 3–5 patterns) in description with
<details>/<summary>; PII stripped.
#!/usr/bin/env node
/**
* Add an exception item to the Endpoint Security Exception List.
*
* Endpoint exceptions are stored in the Endpoint Security Exception List
* (Security → Exceptions → Endpoint Security Exception List, /app/security/exceptions).
* They are separate from SIEM/detection rule exceptions. This script uses
* POST /api/endpoint_list/items to add an item directly to that list.
*
* See: https://www.elastic.co/docs/solutions/security/detect-and-alert/add-manage-exceptions#endpoint-rule-exceptions
* API: https://www.elastic.co/docs/api/doc/kibana/operation/operation-createexceptionlistitem
*/
import { kibanaPost, kibanaDelete as kibanaDeleteReq } from "./kibana-client.js";
import { createInterface } from "readline";
const ENDPOINT_LIST_ITEMS_API = "/api/endpoint_list/items";
function promptConfirm(message) {
const rl = createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {
rl.question(`${message} [y/N] `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}
const OP_MAP = {
is: ["match", "included"],
is_not: ["match", "excluded"],
is_one_of: ["match_any", "included"],
is_not_one_of: ["match_any", "excluded"],
exists: ["exists", "included"],
does_not_exist: ["exists", "excluded"],
matches: ["wildcard", "included"],
does_not_match: ["wildcard", "excluded"],
};
function parseEntry(entryStr) {
const parts = entryStr.split(":");
let field, operator, value;
if (parts.length >= 3) {
field = parts[0];
operator = parts[1];
value = parts.slice(2).join(":");
} else if (parts.length === 2) {
[field, value] = parts;
operator = "is";
} else {
throw new Error(`Invalid entry format '${entryStr}'. Use field:operator:value or field:value`);
}
const mapped = OP_MAP[operator];
if (!mapped) {
throw new Error(`Unknown operator '${operator}'. Use: ${Object.keys(OP_MAP).join(", ")}`);
}
const [entryType, listOperator] = mapped;
const entry = { field, type: entryType, operator: listOperator };
if (entryType === "match") entry.value = value;
else if (entryType === "match_any") entry.value = value.split(",").map((v) => v.trim());
else if (entryType === "wildcard") entry.value = value;
return entry;
}
function makeItemId(name) {
const slug = name
.toLowerCase()
.replace(/[^\w-]/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
const rand = crypto.randomUUID().slice(0, 8);
return slug ? `${slug}-${rand}` : `endpoint-exception-${rand}`;
}
function parseArgs(argv) {
const result = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "-y") {
result.yes = true;
} else if (arg.startsWith("--")) {
const key = arg.slice(2).replace(/-/g, "_");
const values = [];
let j = i + 1;
while (j < argv.length && !argv[j].startsWith("--") && argv[j] !== "-y") {
values.push(argv[j]);
j++;
}
result[key] = values.length === 0 ? true : values.length === 1 ? values[0] : values;
i = j - 1;
}
}
return result;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const yes = args.yes === true;
if (args.delete) {
if (!args.item_id && !args.id) {
console.error("Error: provide --item-id or --id to delete");
process.exit(1);
}
if (!yes) {
const ok = await promptConfirm(`Delete endpoint exception ${args.item_id || args.id}? This cannot be undone.`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
const qs = args.item_id ? `?item_id=${args.item_id}` : `?id=${args.id}`;
const result = await kibanaDeleteReq(`${ENDPOINT_LIST_ITEMS_API}${qs}`, args.space);
console.log(JSON.stringify(result, null, 2));
return;
}
if (!args.name || !args.entries) {
console.error("Error: --name and --entries are required when adding an exception");
console.error("");
console.error('Usage: node add-endpoint-exception.js --name "Exception name" --entries field:operator:value ...');
console.error("");
console.error("IMPORTANT: To limit the exception to a specific rule, always include rule.id or rule.name");
console.error("as one of the entries: rule.id:is:<uuid> or rule.name:is:<exact rule name>.");
console.error("");
console.error("Operators: is, is_not, is_one_of, is_not_one_of, matches, does_not_match, exists, does_not_exist");
process.exit(1);
}
const entryStrs = Array.isArray(args.entries) ? args.entries : [args.entries];
const parsed = entryStrs.map(parseEntry);
let desc = args.description || "";
if (args.comment) {
desc = desc ? `${desc}\nComment: ${args.comment}` : args.comment;
}
const item = {
name: args.name,
item_id: args.item_id || makeItemId(args.name),
description: desc,
type: "simple",
entries: parsed,
namespace_type: "agnostic",
list_id: "endpoint_list",
};
if (args.tags) {
item.tags = Array.isArray(args.tags) ? args.tags : [args.tags];
}
if (args.os_types) {
const types = Array.isArray(args.os_types) ? args.os_types : [args.os_types];
item.os_types = types.map((t) => t.trim().toLowerCase());
}
if (!yes) {
const ok = await promptConfirm(`Add endpoint exception "${args.name}"?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
const result = await kibanaPost(ENDPOINT_LIST_ITEMS_API, item, args.space);
console.log(JSON.stringify(result, null, 2));
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
#!/usr/bin/env node
/**
* Print the endpoint rule exclusion best-practices checklist.
* Use when designing Endpoint exceptions or contributing rule changes to ensure
* evasion-resilient, performant exclusions. Full guide: references/endpoint-rule-exclusion-best-practices.md
*/
import { readFileSync, existsSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REF_DIR = join(SCRIPT_DIR, "..", "references");
const BEST_PRACTICES_FILE = join(REF_DIR, "endpoint-rule-exclusion-best-practices.md");
const EMBEDDED_CHECKLIST = `\
- [ ] Paths: ?:\\\\ for drive-agnostic; : for case-insensitivity; wildcards ONLY for variable segments
- [ ] LOLBins/scripting: combine name or path WITH args or cmdline; prefer args + path over command_line wildcards
- [ ] API rules: no process-only exclusions; use call_stack_final_user_module or call_stack_summary for benign patterns
- [ ] User-writable paths: not excluded unconditionally; gate by trust/code signature when used
- [ ] No in-query comments that duplicate the PR/description
- [ ] When contributing rule changes: updated_date set; sample alerts (if ≤3–5 patterns) in description with <details>/<summary>; PII stripped`;
const fullMode = process.argv.includes("--full");
if (!existsSync(BEST_PRACTICES_FILE)) {
console.log("Checklist (see references/endpoint-rule-exclusion-best-practices.md for full guide):\n");
console.log(EMBEDDED_CHECKLIST);
process.exit(0);
}
const content = readFileSync(BEST_PRACTICES_FILE, "utf8");
if (fullMode) {
console.log(content);
process.exit(0);
}
let inChecklist = false;
let found = false;
for (const line of content.split("\n")) {
if (line.trim().startsWith("## Checklist")) {
inChecklist = true;
found = true;
console.log(line);
console.log();
continue;
}
if (inChecklist) {
if (line.startsWith("## ") && !line.startsWith("## Checklist")) break;
console.log(line);
}
}
if (!found) {
console.log(EMBEDDED_CHECKLIST);
console.log("\nFull guide: references/endpoint-rule-exclusion-best-practices.md");
}
/**
* Elasticsearch client factory for SOC skills.
* Supports Cloud ID, direct URL, API key, and basic auth.
*/
import { Client } from "@elastic/elasticsearch";
try {
process.loadEnvFile();
} catch {}
/**
* Create and return an Elasticsearch client using environment variables.
*/
export function createClient() {
const cloudId = process.env.ELASTICSEARCH_CLOUD_ID;
const apiKey = process.env.ELASTICSEARCH_API_KEY;
const url = process.env.ELASTICSEARCH_URL;
const username = process.env.ELASTICSEARCH_USERNAME;
const password = process.env.ELASTICSEARCH_PASSWORD;
const insecure = process.env.ELASTICSEARCH_INSECURE === "true";
const config = {};
if (cloudId) {
config.cloud = { id: cloudId };
} else if (url) {
config.node = url;
} else {
console.error("Error: No Elasticsearch connection configured.");
console.error("Set ELASTICSEARCH_CLOUD_ID or ELASTICSEARCH_URL environment variable.");
process.exit(1);
}
if (apiKey) {
config.auth = { apiKey };
} else if (username && password) {
config.auth = { username, password };
} else if (username || password) {
console.error("Error: Both ELASTICSEARCH_USERNAME and ELASTICSEARCH_PASSWORD must be set for basic auth.");
process.exit(1);
}
if (insecure) {
config.tls = { rejectUnauthorized: false };
}
config.headers = { "User-Agent": "elastic-agentic" };
return new Client(config);
}
export async function testConnection() {
try {
const client = createClient();
const info = await client.info();
console.log(`Connected to Elasticsearch cluster: ${info.cluster_name}`);
console.log(`Version: ${info.version.number}`);
await client.close();
return true;
} catch (error) {
console.error(`Connection failed: ${error.message}`);
return false;
}
}
#!/usr/bin/env node
/**
* Fetch endpoint behavior rule content from elastic/protections-artifacts by rule_id.
* Rule logic is not stored in Elasticsearch; the repo holds the TOML source
* (description, EQL query with built-in exclusions, threat mapping).
* Use this after identifying a noisy endpoint rule to understand what it detects and
* how existing exclusions are expressed before adding an endpoint exception.
*/
import { writeFileSync } from "fs";
const REPO = "elastic/protections-artifacts";
const BRANCH = "main";
const BASE_URL = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/`;
const SEARCH_URL = "https://api.github.com/search/code";
const CONTENTS_URL = `https://api.github.com/repos/${REPO}/contents`;
function headers() {
const h = { Accept: "application/vnd.github.v3+json", "User-Agent": "elastic-agentic" };
const token = process.env.GITHUB_TOKEN;
if (token) h.Authorization = `Bearer ${token}`;
return h;
}
async function apiGet(url) {
const resp = await fetch(url, { headers: headers(), signal: AbortSignal.timeout(15000) });
if (!resp.ok) throw new Error(`GitHub API ${resp.status}: ${resp.statusText}`);
return resp.json();
}
async function fetchFile(path) {
const resp = await fetch(BASE_URL + path, { headers: headers(), signal: AbortSignal.timeout(15000) });
if (!resp.ok) throw new Error(`Failed to fetch ${path}: ${resp.status}`);
return resp.text();
}
async function walkBehaviorRulesFind(ruleId) {
const top = await apiGet(`${CONTENTS_URL}/behavior/rules`);
const dirs = top.filter((x) => x?.type === "dir");
for (const d of dirs) {
let files;
try {
files = await apiGet(`${CONTENTS_URL}/${encodeURIComponent(d.path)}`);
} catch {
continue;
}
for (const f of files) {
if (f?.type !== "file" || !f.name?.endsWith(".toml")) continue;
const filePath = f.path || `${d.path}/${f.name}`;
try {
const raw = await fetchFile(filePath);
if (raw.includes(ruleId)) return filePath;
} catch {
continue;
}
}
}
throw new Error(
`No rule file found with rule_id=${ruleId} in ${REPO} behavior/rules. ` +
"Check that the UUID is the rule's id (TOML id / rule.id in alerts), not the rule's internal id.",
);
}
async function searchRuleFile(ruleId) {
const q = `"${ruleId}" repo:${REPO} path:behavior/rules`;
const url = `${SEARCH_URL}?${new URLSearchParams({ q })}`;
let data;
try {
data = await apiGet(url);
} catch (e) {
if (e.message.includes("401") || e.message.includes("403")) {
return walkBehaviorRulesFind(ruleId);
}
throw e;
}
const items = data.items || [];
if (!items.length) return walkBehaviorRulesFind(ruleId);
const path = items[0].path;
if (!path?.endsWith(".toml")) return walkBehaviorRulesFind(ruleId);
return path;
}
function parseArgs(argv) {
const result = {};
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith("--")) {
const key = argv[i].slice(2).replace(/-/g, "_");
const next = argv[i + 1];
result[key] = next && !next.startsWith("--") ? (i++, next) : true;
}
}
return result;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.rule_id) {
console.error("Usage: node fetch-endpoint-rule-from-github.js --rule-id <uuid>");
console.error("");
console.error("For endpoint behavior rules use the rule's id (the UUID in the TOML and rule.id");
console.error("in alerts), not kibana.alert.rule.rule_id.");
process.exit(1);
}
const ruleId = args.rule_id.trim();
const path = await searchRuleFile(ruleId);
const content = await fetchFile(path);
if (args.output) {
writeFileSync(args.output, content, "utf8");
console.error(`Written to ${args.output}`);
} else {
console.log(content);
}
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
/**
* Lightweight HTTP client for the Kibana REST API.
* Uses native fetch() with auth, retry on 429, and space support.
*/
try {
process.loadEnvFile();
} catch {}
const RETRY_DELAYS = [5, 10, 20];
export function getKibanaConfig() {
const url = process.env.KIBANA_URL;
const apiKey = process.env.KIBANA_API_KEY;
const username = process.env.KIBANA_USERNAME || process.env.ELASTICSEARCH_USERNAME;
const password = process.env.KIBANA_PASSWORD || process.env.ELASTICSEARCH_PASSWORD;
const spaceId = process.env.KIBANA_SPACE_ID;
const insecure = process.env.KIBANA_INSECURE === "true";
if (!url) {
console.error("Error: No Kibana connection configured.");
console.error("Set KIBANA_URL environment variable.");
process.exit(1);
}
if (!apiKey && !username && !password && process.env.KIBANA_NO_AUTH !== "true") {
console.error("Error: No Kibana authentication configured.");
console.error("Set KIBANA_API_KEY or KIBANA_USERNAME + KIBANA_PASSWORD.");
console.error("Or set KIBANA_NO_AUTH=true for clusters with security disabled.");
process.exit(1);
}
if (!apiKey && ((username && !password) || (!username && password))) {
console.error("Error: Both username and password must be set for basic auth.");
console.error("Set KIBANA_USERNAME + KIBANA_PASSWORD (or ELASTICSEARCH_USERNAME + ELASTICSEARCH_PASSWORD).");
process.exit(1);
}
return { url, apiKey, username, password, spaceId, insecure };
}
function getHeaders(config) {
const headers = {
"Content-Type": "application/json",
"kbn-xsrf": "true",
"User-Agent": "elastic-agentic",
};
if (config.apiKey) {
headers["Authorization"] = `ApiKey ${config.apiKey}`;
} else if (config.username && config.password) {
const auth = Buffer.from(`${config.username}:${config.password}`).toString("base64");
headers["Authorization"] = `Basic ${auth}`;
}
return headers;
}
function getBasePath(config, space) {
let basePath = config.url.replace(/\/$/, "");
const effectiveSpace = space || config.spaceId;
if (effectiveSpace && effectiveSpace !== "default") {
basePath += `/s/${effectiveSpace}`;
}
return basePath;
}
/**
* Make an HTTP request to the Kibana API with automatic 429 retry.
*
* @param {string} path - API path (e.g. "/api/cases")
* @param {object} [options] - fetch options (method, body, headers, params)
* @param {string} [options.space] - Override Kibana space for this request
* @returns {{ success: boolean, data?: any, status?: number, error?: string }}
*/
export async function kibanaFetch(path, options = {}) {
const config = getKibanaConfig();
const { space, params, ...fetchOpts } = options;
const basePath = getBasePath(config, space);
let url = `${basePath}${path}`;
if (params) {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
for (const v of value) searchParams.append(key, v);
} else {
searchParams.append(key, String(value));
}
}
}
const qs = searchParams.toString();
if (qs) url += `?${qs}`;
}
const requestOptions = {
...fetchOpts,
headers: {
...getHeaders(config),
...fetchOpts.headers,
},
};
if (config.insecure) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
try {
const response = await fetch(url, requestOptions);
if (response.status === 429 && attempt < RETRY_DELAYS.length) {
const delay = RETRY_DELAYS[attempt];
console.error(`Rate limited, retrying in ${delay}s (attempt ${attempt + 1}/${RETRY_DELAYS.length + 1})...`);
await new Promise((r) => setTimeout(r, delay * 1000));
continue;
}
const contentType = response.headers.get("content-type");
let data;
if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
return {
success: false,
status: response.status,
error: data?.message || data?.error || `HTTP ${response.status}`,
details: data,
};
}
return { success: true, data };
} catch (error) {
if (attempt < RETRY_DELAYS.length && error.message?.includes("429")) {
const delay = RETRY_DELAYS[attempt];
console.error(`Rate limited, retrying in ${delay}s...`);
await new Promise((r) => setTimeout(r, delay * 1000));
continue;
}
return { success: false, error: error.message, details: error };
}
}
}
/**
* Convenience wrappers matching the Python KibanaClient interface.
* These throw on HTTP errors (matching the old behavior where scripts
* relied on exceptions for error handling).
*/
export async function kibanaGet(path, params, space) {
const result = await kibanaFetch(path, { method: "GET", params, space });
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaPost(path, body, space) {
const result = await kibanaFetch(path, {
method: "POST",
body: body !== undefined ? JSON.stringify(body) : undefined,
space,
});
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaPatch(path, body, space) {
const result = await kibanaFetch(path, {
method: "PATCH",
body: body !== undefined ? JSON.stringify(body) : undefined,
space,
});
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaPut(path, body, space) {
const result = await kibanaFetch(path, {
method: "PUT",
body: body !== undefined ? JSON.stringify(body) : undefined,
space,
});
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function kibanaDelete(path, space) {
const result = await kibanaFetch(path, { method: "DELETE", space });
if (!result.success) throw new Error(result.error || `HTTP ${result.status}`);
return result.data;
}
export async function testConnection(space) {
try {
const status = await kibanaGet("/api/status", undefined, space);
const version = status?.version;
const versionStr = typeof version === "object" ? version?.number : version;
console.log(`Connected to Kibana: ${status?.name || "unknown"}`);
console.log(`Version: ${versionStr || "unknown"}`);
return true;
} catch (error) {
console.error(`Connection failed: ${error.message}`);
return false;
}
}
#!/usr/bin/env node
/**
* Detection rule management CLI wrapping the Kibana Detection Engine REST API.
* Supports listing, searching, creating, patching, enabling/disabling rules,
* and managing rule exceptions for false-positive tuning.
*/
import { readFileSync } from "fs";
import { createInterface } from "readline";
import { kibanaDelete, kibanaFetch, kibanaGet, kibanaPatch, kibanaPost } from "./kibana-client.js";
const RULES_API = "/api/detection_engine/rules";
const EXCEPTIONS_API = "/api/exception_lists";
function promptConfirm(message) {
const rl = createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {
rl.question(`${message} [y/N] `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}
function parseArgs(argv) {
const result = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "-y") {
result.yes = true;
} else if (arg.startsWith("--")) {
const key = arg.slice(2).replace(/-/g, "_");
const next = argv[i + 1];
if (next !== undefined && !next.startsWith("--")) {
const values = [];
let j = i + 1;
while (j < argv.length && !argv[j].startsWith("--")) {
values.push(argv[j]);
j++;
}
result[key] = values.length === 1 ? values[0] : values;
i = j - 1;
} else {
result[key] = true;
}
}
}
return result;
}
function parseIntArg(val, def) {
if (val === undefined || val === null) return def;
const n = Number(val);
return Number.isNaN(n) ? def : n;
}
function ensureArray(val) {
if (val === undefined || val === null) return [];
return Array.isArray(val) ? val : [val];
}
// ---------------------------------------------------------------------------
// Query syntax validation
// ---------------------------------------------------------------------------
const KQL_SYNTAX_CHECKS = [
{
pattern: /\\\//,
id: "escaped-forward-slash",
message: "KQL does not use backslash escaping. Use plain text (e.g. */IM chrome.exe* not *\\/IM chrome.exe*).",
},
{
pattern: /(?:^|\s)AND\s+AND(?:\s|$)|(?:^|\s)OR\s+OR(?:\s|$)/,
id: "duplicate-operator",
message: "Duplicate boolean operator (AND AND or OR OR).",
},
{
pattern: /(?:^|\s)AND\s+OR(?:\s|$)|(?:^|\s)OR\s+AND(?:\s|$)/,
id: "conflicting-operators",
message: "Conflicting adjacent boolean operators (AND OR or OR AND).",
},
];
const EQL_SYNTAX_CHECKS = [
{
pattern: /\\\//,
id: "escaped-forward-slash",
message:
"Unexpected escaped forward-slash. EQL uses double-backslash (\\\\) for literal backslashes in Windows paths.",
},
{
pattern: /\bwhere\b.*\bwhere\b/i,
id: "duplicate-where",
message: "Duplicate 'where' clause in the same event filter.",
},
];
const ESQL_SYNTAX_CHECKS = [
{
pattern: /(?:^|\s)AND\s+AND(?:\s|$)|(?:^|\s)OR\s+OR(?:\s|$)/,
id: "duplicate-operator",
message: "Duplicate boolean operator (AND AND or OR OR).",
},
];
function validateQuerySyntax(query, language) {
const errors = [];
let checks;
if (language === "eql") {
checks = EQL_SYNTAX_CHECKS;
} else if (language === "esql") {
checks = ESQL_SYNTAX_CHECKS;
} else {
checks = KQL_SYNTAX_CHECKS;
}
for (const check of checks) {
if (check.pattern.test(query)) {
errors.push({ id: check.id, message: check.message });
}
}
const openParens = (query.match(/\(/g) || []).length;
const closeParens = (query.match(/\)/g) || []).length;
if (openParens !== closeParens) {
errors.push({
id: "mismatched-parens",
message: `Mismatched parentheses: ${openParens} open vs ${closeParens} close.`,
});
}
const quoteCount = (query.match(/"/g) || []).length;
if (quoteCount % 2 !== 0) {
errors.push({ id: "unbalanced-quotes", message: `Unbalanced double-quotes: ${quoteCount} found (must be even).` });
}
return errors;
}
async function validateQuery(args) {
const query = args.query;
if (!query) {
console.error("Error: --query is required");
process.exit(1);
}
const language = args.language || "kuery";
const errors = validateQuerySyntax(query, language);
if (errors.length === 0) {
console.log(JSON.stringify({ valid: true, query, language, errors: [] }, null, 2));
} else {
console.log(JSON.stringify({ valid: false, query, language, errors }, null, 2));
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Rules CRUD
// ---------------------------------------------------------------------------
async function findRules(args, space) {
const params = {
per_page: parseIntArg(args.per_page, 20),
page: parseIntArg(args.page, 1),
sort_field: args.sort_field || "name",
sort_order: args.sort_order || "asc",
};
if (args.filter) params.filter = args.filter;
if (args.fields) params.fields = args.fields;
const result = await kibanaGet(`${RULES_API}/_find`, params, space);
if (args.brief) {
const rules = result.data || [];
const brief = rules.map((r) => ({
id: r.id,
rule_id: r.rule_id,
name: r.name,
type: r.type,
enabled: r.enabled,
severity: r.severity,
risk_score: r.risk_score,
tags: r.tags || [],
immutable: r.immutable,
}));
console.log(JSON.stringify({ total: result.total || 0, rules: brief }, null, 2));
} else {
console.log(JSON.stringify(result, null, 2));
}
return result;
}
async function getRule(args, space) {
const params = {};
if (args.id) params.id = args.id;
else if (args.rule_id) params.rule_id = args.rule_id;
else {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
const result = await kibanaGet(RULES_API, params, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
async function createRule(args, space) {
let body;
if (args.from_file) {
body = JSON.parse(readFileSync(args.from_file, "utf8"));
} else {
body = {
name: args.name,
description: args.description || "",
type: args.type,
risk_score: parseIntArg(args.risk_score, 50),
severity: args.severity || "medium",
enabled: !args.disabled,
};
if (args.query) body.query = args.query;
if (args.language) body.language = args.language;
else if (args.type === "query" || args.type === "saved_query") body.language = "kuery";
else if (args.type === "eql") body.language = "eql";
else if (args.type === "esql") body.language = "esql";
if (args.index) body.index = ensureArray(args.index);
if (args.interval) body.interval = args.interval;
if (args.from_time) body.from = args.from_time;
if (args.tags) body.tags = ensureArray(args.tags);
if (args.threat_file) body.threat = JSON.parse(readFileSync(args.threat_file, "utf8"));
if (args.false_positives) body.false_positives = ensureArray(args.false_positives);
if (args.references) body.references = ensureArray(args.references);
if (args.note) body.note = args.note;
if (args.rule_id) body.rule_id = args.rule_id;
if (args.max_signals !== undefined) body.max_signals = parseIntArg(args.max_signals, undefined);
if (args.type === "threshold" && args.threshold_field) {
body.threshold = {
field: args.threshold_field,
value: parseIntArg(args.threshold_value, 1),
};
}
}
if (body.query && !args.skip_validation) {
const lang = body.language || "kuery";
const syntaxErrors = validateQuerySyntax(body.query, lang);
if (syntaxErrors.length > 0) {
console.error("Query syntax validation failed:");
for (const err of syntaxErrors) {
console.error(` - [${err.id}] ${err.message}`);
}
console.error(`\nQuery: ${body.query}`);
console.error(`Language: ${lang}`);
console.error("\nFix the query before creating the rule. Pass --skip-validation to bypass.");
process.exit(1);
}
}
const result = await kibanaPost(RULES_API, body, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
async function patchRule(args, space) {
const body = {};
if (args.id) body.id = args.id;
else if (args.rule_id) body.rule_id = args.rule_id;
else {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
if (args.name) body.name = args.name;
if (args.description) body.description = args.description;
if (args.query) body.query = args.query;
if (args.language) body.language = args.language;
if (args.index) body.index = ensureArray(args.index);
if (args.interval) body.interval = args.interval;
if (args.from_time) body.from = args.from_time;
if (args.tags) body.tags = ensureArray(args.tags);
if (args.severity) body.severity = args.severity;
if (args.risk_score !== undefined) body.risk_score = parseIntArg(args.risk_score, 50);
if (args.false_positives) body.false_positives = ensureArray(args.false_positives);
if (args.note) body.note = args.note;
if (args.max_signals !== undefined) body.max_signals = parseIntArg(args.max_signals, undefined);
if (args.enabled !== undefined) body.enabled = args.enabled === "true";
if (body.query && !args.skip_validation) {
const lang = body.language || args.language || "kuery";
const syntaxErrors = validateQuerySyntax(body.query, lang);
if (syntaxErrors.length > 0) {
console.error("Query syntax validation failed:");
for (const err of syntaxErrors) {
console.error(` - [${err.id}] ${err.message}`);
}
console.error(`\nQuery: ${body.query}`);
console.error(`Language: ${lang}`);
console.error("\nFix the query before patching the rule. Pass --skip-validation to bypass.");
process.exit(1);
}
}
const result = await kibanaPatch(RULES_API, body, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
async function toggleRule(args, space, enable) {
const body = { enabled: enable };
if (args.id) body.id = args.id;
else if (args.rule_id) body.rule_id = args.rule_id;
else {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
const result = await kibanaPatch(RULES_API, body, space);
const state = enable ? "enabled" : "disabled";
console.log(`Rule ${result.name || "?"} is now ${state}`);
return result;
}
async function deleteRule(args, space) {
const params = {};
if (args.id) params.id = args.id;
else if (args.rule_id) params.rule_id = args.rule_id;
else {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
const qs = new URLSearchParams(params).toString();
const result = await kibanaDelete(`${RULES_API}?${qs}`, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
async function exportRules(args, space) {
const params = { exclude_export_details: "false" };
if (args.file_name) params.file_name = args.file_name;
let body = undefined;
if (args.rule_ids) {
const ids = Array.isArray(args.rule_ids) ? args.rule_ids : [args.rule_ids];
body = { objects: ids.map((rid) => ({ rule_id: rid })) };
}
const result = await kibanaFetch(`${RULES_API}/_export`, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
params,
space,
});
if (!result.success) {
throw new Error(result.error || `HTTP ${result.status}`);
}
console.log(result.data);
return result.data;
}
async function bulkAction(args, space) {
const body = { action: args.action };
if (args.ids) body.ids = Array.isArray(args.ids) ? args.ids : [args.ids];
else if (args.query) body.query = args.query;
else body.query = "";
if (args.action === "edit" && args.edit_file) {
body.edit = JSON.parse(readFileSync(args.edit_file, "utf8"));
}
const path = args.dry_run ? `${RULES_API}/_bulk_action?dry_run=true` : `${RULES_API}/_bulk_action`;
const result = await kibanaPost(path, body, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
// ---------------------------------------------------------------------------
// Rule exceptions (false-positive tuning)
// ---------------------------------------------------------------------------
const OP_MAP = {
is: ["match", "included"],
is_not: ["match", "excluded"],
is_one_of: ["match_any", "included"],
is_not_one_of: ["match_any", "excluded"],
exists: ["exists", "included"],
does_not_exist: ["exists", "excluded"],
matches: ["wildcard", "included"],
does_not_match: ["wildcard", "excluded"],
};
function parseExceptionEntry(entryStr) {
const parts = entryStr.split(":");
let field, operator, value;
if (parts.length >= 3) {
field = parts[0];
operator = parts[1];
value = parts.slice(2).join(":");
} else if (parts.length === 2) {
field = parts[0];
value = parts[1];
operator = "is";
} else {
throw new Error(`Invalid entry format '${entryStr}'. Use field:operator:value or field:value`);
}
const mapped = OP_MAP[operator];
if (!mapped) {
throw new Error(`Unknown operator '${operator}'. Use: ${Object.keys(OP_MAP).join(", ")}`);
}
const [entryType, listOperator] = mapped;
const entry = { field, type: entryType, operator: listOperator };
if (entryType === "match") entry.value = value;
else if (entryType === "match_any") entry.value = value.split(",").map((v) => v.trim());
else if (entryType === "wildcard") entry.value = value;
return entry;
}
async function addException(args, space) {
const entries = [];
const entryStrs = Array.isArray(args.entries) ? args.entries : [args.entries];
for (const entryStr of entryStrs) {
entries.push(parseExceptionEntry(entryStr));
}
const item = {
type: "simple",
name: args.name || `Exception ${crypto.randomUUID().slice(0, 8)}`,
description: args.description || "",
entries,
};
if (args.tags) item.tags = ensureArray(args.tags);
if (args.expire) item.expire_time = args.expire;
if (args.comment) item.comments = [{ comment: args.comment }];
const body = { items: [item] };
const result = await kibanaPost(`${RULES_API}/${args.rule_uuid}/exceptions`, body, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
async function listExceptions(args, space) {
const params = {
list_id: args.list_id,
namespace_type: args.namespace_type || "single",
per_page: parseIntArg(args.per_page, 20),
page: parseIntArg(args.page, 1),
};
const result = await kibanaGet(`${EXCEPTIONS_API}/items/_find`, params, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
async function createSharedExceptionList(args, space) {
const body = {
name: args.name,
description: args.description || "",
};
if (args.tags) body.tags = ensureArray(args.tags);
const result = await kibanaPost("/api/exceptions/shared", body, space);
console.log(JSON.stringify(result, null, 2));
return result;
}
// ---------------------------------------------------------------------------
// Alert volume analysis (for tuning decisions)
// ---------------------------------------------------------------------------
async function noisyRules(args) {
const { createClient } = await import("./es-client.js");
const es = createClient();
const days = parseIntArg(args.days, 7);
const size = parseIntArg(args.top, 20);
const query = {
bool: {
must: [{ range: { "@timestamp": { gte: `now-${days}d` } } }],
},
};
const aggs = {
by_rule: {
terms: {
field: "kibana.alert.rule.name",
size,
order: { _count: "desc" },
},
aggs: {
rule_id: { terms: { field: "kibana.alert.rule.uuid", size: 1 } },
severity: { terms: { field: "kibana.alert.severity", size: 1 } },
unique_agents: { cardinality: { field: "agent.id" } },
},
},
};
const result = await es.search({
index: ".alerts-security.alerts-*",
query,
aggs,
size: 0,
});
const buckets = result.aggregations?.by_rule?.buckets || [];
const output = buckets.map((b) => {
const ruleIds = b.rule_id?.buckets || [];
const sevs = b.severity?.buckets || [];
return {
rule_name: b.key,
alert_count: b.doc_count,
rule_uuid: ruleIds[0]?.key ?? null,
severity: sevs[0]?.key ?? null,
unique_agents: b.unique_agents?.value ?? 0,
};
});
console.log(JSON.stringify(output, null, 2));
return output;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function main() {
const cmd = process.argv[2];
const rawArgs = parseArgs(process.argv.slice(3));
const space = rawArgs.space;
const yes = rawArgs.yes === true;
const requireArg = (name, msg) => {
const val = rawArgs[name];
if (val === undefined || val === null || val === "") {
console.error(`Error: ${msg}`);
process.exit(1);
}
return val;
};
try {
switch (cmd) {
case "find":
await findRules(rawArgs, space);
break;
case "get":
if (!rawArgs.id && !rawArgs.rule_id) {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
await getRule(rawArgs, space);
break;
case "create":
if (!yes) {
const label = rawArgs.name || rawArgs.from_file || "new rule";
const ok = await promptConfirm(`Create rule "${label}"?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await createRule(rawArgs, space);
break;
case "patch":
if (!rawArgs.id && !rawArgs.rule_id) {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
if (!yes) {
const ok = await promptConfirm(`Patch rule ${rawArgs.id || rawArgs.rule_id}?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await patchRule(rawArgs, space);
break;
case "enable":
if (!rawArgs.id && !rawArgs.rule_id) {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
if (!yes) {
const ok = await promptConfirm(`Enable rule ${rawArgs.id || rawArgs.rule_id}?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await toggleRule(rawArgs, space, true);
break;
case "disable":
if (!rawArgs.id && !rawArgs.rule_id) {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
if (!yes) {
const ok = await promptConfirm(`Disable rule ${rawArgs.id || rawArgs.rule_id}?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await toggleRule(rawArgs, space, false);
break;
case "delete":
if (!rawArgs.id && !rawArgs.rule_id) {
console.error("Error: provide --id or --rule-id");
process.exit(1);
}
if (!yes) {
const ok = await promptConfirm(`Delete rule ${rawArgs.id || rawArgs.rule_id}? This cannot be undone.`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await deleteRule(rawArgs, space);
break;
case "export":
await exportRules(rawArgs, space);
break;
case "bulk-action":
requireArg("action", "--action is required");
if (!yes && !rawArgs.dry_run) {
const ok = await promptConfirm(`Execute bulk ${rawArgs.action}?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await bulkAction(rawArgs, space);
break;
case "add-exception":
requireArg("rule_uuid", "--rule-uuid is required");
requireArg("entries", "--entries is required");
if (!yes) {
const ok = await promptConfirm(`Add exception to rule ${rawArgs.rule_uuid}?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await addException(rawArgs, space);
break;
case "list-exceptions":
requireArg("list_id", "--list-id is required");
await listExceptions(rawArgs, space);
break;
case "create-shared-list":
requireArg("name", "--name is required");
if (!yes) {
const ok = await promptConfirm(`Create shared exception list "${rawArgs.name}"?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
await createSharedExceptionList(rawArgs, space);
break;
case "noisy-rules":
await noisyRules(rawArgs);
break;
case "validate-query":
await validateQuery(rawArgs);
break;
default:
console.error(
"Error: unknown command. Use: find, get, create, patch, enable, disable, delete, export, bulk-action, add-exception, list-exceptions, create-shared-list, noisy-rules, validate-query",
);
process.exit(1);
}
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
}
main().catch((err) => {
console.error(`Error: ${err.message}`);
process.exit(1);
});