
Security Alert Triage
- 2 installs
- 31 repo stars
- Updated May 28, 2026
- elastic/cursor-plugins
This is a copy of security-alert-triage by elastic - installs and ranking accrue to the original listing.
Helps with security tasks.
About
security-alert-triage is a Claude Code skill for security. It helps solo builders move faster with AI-assisted development.
- security-alert-triage
- Security
- AI-coding skill
Security Alert Triage 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-alert-triageAdd 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
Alert Triage
Analyze Elastic Security alerts one at a time: gather context, classify, create a case, and acknowledge. This skill depends on the case-management skill for case creation.
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"Quick start
All commands from workspace root. Always fetch → investigate → document → acknowledge. Call the tools directly — do not read the skill file or explore the workspace first.
node skills/security/alert-triage/scripts/fetch-next-alert.js
node skills/security/case-management/scripts/case-manager.js find --tags "agent_id:<id>"
node skills/security/alert-triage/scripts/run-query.js --query-file query.esql --type esql
node skills/security/case-management/scripts/case-manager.js create --title "..." --description "..." --tags "classification:..." "agent_id:<id>" --severity <level> --yes
node skills/security/case-management/scripts/case-manager.js attach-alert --case-id <id> --alert-id <id> --alert-index <index> --rule-id <uuid> --rule-name "<name>" --yes
node skills/security/alert-triage/scripts/acknowledge-alert.js --related --agent <id> --timestamp <ts> --window 60 --yesCommon multi-step workflows
| Task | Tools to call (in order) |
|---|---|
| End-to-end triage | fetch_next_alert → run_query (context) → case_manager create (case) → acknowledge_alert |
| Gather context | run_query (process tree, network, related alerts) |
| Create case after classification | case_manager create → case_manager attach-alert |
| Acknowledge after triage | acknowledge_alert (related mode for batch) |
Always complete the full workflow: fetch → investigate → document → acknowledge. Do not stop after gathering context — create or update a case with findings before acknowledging.
Critical execution rules:
- Start executing tools immediately — do not read SKILL.md, browse the workspace, or list files first.
- For ES|QL queries, write the query to a temporary
.esqlfile then pass it via--query-file. Do not useedit_file
— use a single shell call with echo "..." > query.esql && node ... --query-file query.esql.
- Keep context gathering focused: run 2-4 targeted queries (process tree, network, related alerts), not 10+.
- Report only what tools return. Copy identifiers verbatim — do not paraphrase IDs, timestamps, or hostnames.
Critical principles
- Do NOT classify prematurely. Gather ALL context before deciding benign/unknown/malicious.
- Most alerts are false positives, even if they look alarming. Rule names like "Malicious Behavior" or severity
"critical" are NOT evidence.
- "Unknown" is acceptable and often correct when evidence is insufficient.
- MALICIOUS requires strong corroborating evidence: persistence + C2, credential theft, lateral movement — not only
suspicious API calls.
- Report tool output verbatim. Copy IDs, hostnames, timestamps, and counts exactly as returned by tools. Do not
round numbers, abbreviate IDs, or paraphrase error messages.
Workflow
When triaging multiple alerts, group first, then triage each group:
- [ ] Step 0: Group alerts by agent/host and time window
- [ ] Step 1: Check existing cases
- [ ] Step 2: Gather full context (DO NOT SKIP)
- [ ] Step 3: Create or update case (only AFTER context gathered)
- [ ] Step 4: Acknowledge alert and all related alerts
- [ ] Step 5: Fetch next alert group and repeatStep 0: Group alerts before triaging
When the user asks about multiple open alerts, group them first to avoid redundant investigation: query open alerts, group by agent.id, sub-group by time window (~5 min = likely one incident), triage each group as a single unit.
Use ES|QL for an overview (write to file first for PowerShell):
FROM .alerts-security.alerts-*
| WHERE kibana.alert.workflow_status == "open" AND @timestamp >= "<start>"
| STATS alert_count=COUNT(*), rules=VALUES(kibana.alert.rule.name) BY agent.id
| SORT alert_count DESCFor full query templates, see references/classification-guide.md.
Step 1: Check existing cases
Before creating a new case, check if this alert belongs to an existing one. Use the case-management skill:
node skills/security/case-management/scripts/case-manager.js find --tags "agent_id:<agent_id>"
node skills/security/case-management/scripts/case-manager.js cases-for-alert --alert-id <alert_id>Look for cases with the same agent ID, user, or related detection rule within a similar time window.
Note:find --searchmay return 500 errors on Serverless. Usefind --tagsorlistinstead.
Step 2: Gather context
This is the most important step. Do not skip or shortcut it. Complete ALL substeps before forming any classification opinion.
Time range warning: Alerts may be days or weeks old. NEVER use relative time like NOW() - 1 HOUR. Extract the alert's @timestamp and build queries around that time with +/- 1 hour window.
Substeps: (2a) Related alerts on same agent/user; (2b) Rule frequency across env (high = FP-prone); (2c) Entity context — process tree, network, registry, files; (2d) Behavior investigation — persistence, C2, lateral movement, credential access.
Example — process tree (use ES|QL with KEEP; avoid --full which produces 10K+ lines):
FROM logs-endpoint.events.process-*
| WHERE agent.id == "<agent_id>" AND @timestamp >= "<alert_time - 5min>" AND @timestamp <= "<alert_time + 10min>"
AND process.parent.name IS NOT NULL
AND process.name NOT IN ("svchost.exe", "conhost.exe", "agentbeat.exe")
| KEEP @timestamp, process.name, process.command_line, process.pid, process.parent.name, process.parent.pid
| SORT @timestamp | LIMIT 80| Data type | Index pattern |
|---|---|
| Alerts | .alerts-security.alerts-* |
| Processes | logs-endpoint.events.process-* |
| Network | logs-endpoint.events.network-* |
| Logs | logs-* |
For full query templates and classification criteria, see references/classification-guide.md.
Step 3: Create or update case
After gathering context, create a case and attach alert(s). Use --rule-id and --rule-name (required; 400 error without them):
node skills/security/case-management/scripts/case-manager.js create \
--title "<concise summary>" \
--description "<findings, IOCs, attack chain, MITRE techniques>" \
--tags "classification:<benign|unknown|malicious>" "confidence:<0-100>" "mitre:<technique>" "agent_id:<id>" \
--severity <low|medium|high|critical>
node skills/security/case-management/scripts/case-manager.js attach-alert \
--case-id <case_id> --alert-id <alert_id> --alert-index <index> \
--rule-id <rule_uuid> --rule-name "<rule name>"
# Multiple alerts: attach-alerts --alert-ids <id1> <id2>
# Add notes: add-comment --case-id <id> --comment "Findings..."Case description: Summary (1-2 sentences); Attack chain; IOCs (hashes, IPs, paths); MITRE techniques; Behavioral findings; Response context (remediation, credentials at risk).
Step 4: Acknowledge alerts
Acknowledge ALL related alerts together. Use --dry-run first to confirm scope, then run without it:
# By host name — preferred when triaging a host
node skills/security/alert-triage/scripts/acknowledge-alert.js --query --host <hostname> --dry-run
node skills/security/alert-triage/scripts/acknowledge-alert.js --query --host <hostname> --yes
# By agent ID — preferred when agent.id is known
node skills/security/alert-triage/scripts/acknowledge-alert.js --related --agent <id> --timestamp <ts> --window 60 --dry-run
node skills/security/alert-triage/scripts/acknowledge-alert.js --related --agent <id> --timestamp <ts> --window 60 --yesIncrease --window for longer attack chains (e.g., 300 for 5 minutes). Report the exact count of acknowledged alerts from the tool output. Pass --yes to skip the confirmation prompt (required when called by an agent).
Step 5: Repeat
node skills/security/alert-triage/scripts/fetch-next-alert.jsTool reference
fetch-next-alert.js
Fetches the oldest unacknowledged Elastic Security alert.
node skills/security/alert-triage/scripts/fetch-next-alert.js [--days <n>] [--json] [--full] [--verbose]run-query.js
Runs KQL or ES|QL queries against Elasticsearch.
PowerShell warning: ES|QL queries contain pipe characters (|) which PowerShell interprets as shell pipes. ALWAYS use --query-file for ES|QL:
# Write query to file, then run
node skills/security/alert-triage/scripts/run-query.js --query-file query.esql --type esqlKQL queries without pipes can be passed directly:
node skills/security/alert-triage/scripts/run-query.js "agent.id:<id>" --index "logs-*" --days 7| Arg | Description |
|---|---|
query | KQL query (positional) |
--query-file, -q | Read query from file (required for ES\ |
--type, -t | kql or esql (default: kql) |
--index, -i | Index pattern (default: logs-*) |
--size, -s | Max results (default: 100) |
--days, -d | Limit to last N days |
--json | Raw JSON output |
--full | Full document source |
acknowledge-alert.js
Acknowledges alerts by updating workflow_status to acknowledged.
| Mode | Command |
|---|---|
| Single | node skills/security/alert-triage/scripts/acknowledge-alert.js <alert_id> --index <index> --yes |
| Related | node skills/security/alert-triage/scripts/acknowledge-alert.js --related --agent <id> --timestamp <ts> [--window 60] --yes |
| By host | node skills/security/alert-triage/scripts/acknowledge-alert.js --query --host <hostname> [--time-start <ts>] [--time-end <ts>] --yes |
| Query | node skills/security/alert-triage/scripts/acknowledge-alert.js --query --agent <id> [--time-start <ts>] [--time-end <ts>] --yes |
| Dry run | Add --dry-run to any mode (no confirmation needed) |
| Confirm | All write modes prompt for confirmation; pass --yes to skip |
Examples
- "Fetch the next unacknowledged alert and triage it"
- "Investigate alert ID abc-123 — gather context, classify, and create a case if malicious"
- "Process the top 5 critical alerts from the last 24 hours"
Guidelines
- Report only tool output — do not invent IDs, hostnames, IPs, or details not present in the tool response.
- Preserve identifiers from the request — use exact values the user provides in tool calls and responses.
- Confirm actions concisely using the tool's return data.
- Distinguish facts from inference — label conclusions beyond tool output as your assessment.
Production use
- All write operations (
acknowledge-alert.js) prompt for confirmation. Pass--yesor-yto skip when called by an
agent.
- Use
--dry-runbefore bulk acknowledgments to preview scope without modifying data. - The acknowledge script uses the Kibana Detection Engine API, which is compatible with both self-managed and Serverless
deployments.
- Verify environment variables point to the intended cluster before running any script — no undo for acknowledgments.
Environment variables
| Variable | Required | Description |
|---|---|---|
ELASTICSEARCH_URL | Yes | Elasticsearch URL |
ELASTICSEARCH_API_KEY | Yes | Elasticsearch API key |
KIBANA_URL | Yes | Kibana URL (for case management) |
KIBANA_API_KEY | Yes | Kibana API key (for case management) |
Alert Classification Guide
Detailed criteria for classifying alerts as benign, unknown, or malicious.
Contents
- Fundamental principle
- Pre-classification checklist
- Classification: Benign
- Classification: Unknown
- Classification: Malicious
- Behavioral weight table
- Common false positive sources
- Sandbox and testing environments
Fundamental principle
Most alerts are false positives. Your job is to find EVIDENCE, not to confirm suspicions. When in doubt, classify as "unknown" -- this is better than a wrong malicious classification that wastes IR resources, or a wrong benign classification that misses a threat.
Pre-classification checklist
Before making ANY classification, confirm you have:
- [ ] Searched for related alerts on the same agent/user
- [ ] Checked rule frequency across the environment
- [ ] Investigated process tree and parent-child relationships
- [ ] Reviewed network activity (DNS, connections, lateral movement)
- [ ] Checked for persistence mechanisms (registry, scheduled tasks, services)
- [ ] Looked for defense evasion behaviors
- [ ] Verified code signing status of executables involved
- [ ] Identified environment context (production vs sandbox/test)
- [ ] Considered if this matches a known false positive pattern
If you cannot check most of the above, either gather more context or classify as "unknown."
Classification: Benign (score 0-19)
Confirmed false positive or legitimate activity.
Use when you have positive evidence of legitimacy:
- Recognized enterprise software performing expected functions
- Known IT management/deployment activity (SCCM, Group Policy, Intune)
- Security testing with clear test environment indicators
- User-initiated installation of legitimate software
- Rule known to have high FP rate for this specific scenario
- No malicious behaviors observed (no persistence, no C2, no credential theft)
Examples:
- SCCM pushing software update via PowerShell
- User installing Discord (large Electron app) from official source
- Atomic Red Team test in designated testing environment
- IT admin using PSExec for legitimate remote management
Classification: Unknown (score 20-60)
Insufficient information to determine. Needs further investigation.
Use when:
- Suspicious indicators BUT lack corroborating evidence of malicious INTENT
- Activity COULD be malicious OR legitimate, and you can't tell which
- Need more context that isn't available in the current data
- First time seeing this pattern with no baseline
Examples:
- Unsigned installer running from Temp folder, but no C2/persistence observed
- PowerShell script with obfuscation, but no malicious payload identified
- Process injection APIs used, but target is a child process it spawned itself
- Large binary with WriteProcessMemory, but appears to be Electron/Node.js app
Classification: Malicious (score 61-100)
Confirmed or highly suspected malicious activity.
Requires at least ONE high-confidence indicator:
- Confirmed C2 communication (beaconing to known bad IP/domain)
- Persistence mechanisms established (registry Run keys, scheduled tasks, services)
- Credential theft (LSASS access, credential file access, keylogging)
- Lateral movement (RDP/SMB/WinRM to other internal hosts)
- Active defense evasion (disabling AV, clearing logs, timestomping)
- Known malware hash match
- Data exfiltration observed
NOT sufficient alone (these require corroboration):
- Unsigned binary (many legitimate apps are unsigned)
- Large file size (Electron apps are routinely 100MB+)
- Running from Temp folder (installers commonly do this)
- WriteProcessMemory API (used by legitimate apps for child process creation)
- VirtualAlloc RWX (used by JIT compilers in .NET, Java, Node.js)
- Alert severity is "critical" (severity is rule author's opinion, not evidence)
- Rule name contains "Malicious" (rule names are often sensationalized)
Behavioral weight table
Strong indicators (can support MALICIOUS)
| Behavior | Score range |
|---|---|
| Persistence + C2 together | 75+ |
| Credential access (actual LSASS memory read) | 80+ |
| Confirmed C2 beaconing to known-bad infrastructure | 75+ |
| Lateral movement (connections to other internal hosts) | 75+ |
| Active AV/EDR disabling | 80+ |
| Known malware hash match | 90+ |
Weak indicators (alone = UNKNOWN)
| Behavior | Score range |
|---|---|
| Process injection APIs without confirmed malicious target | 35-50 |
| Binary padding / large file alone | 25-40 |
| WriteProcessMemory to own child process | 20-30 |
| VirtualAlloc RWX | 20-35 |
| Unsigned executable | 25-40 |
| Running from Temp/AppData | 25-40 |
| PowerShell execution | 20-35 |
| Network connection alone (without C2 indicators) | 30-45 |
Rule: Single suspicious behaviors without corroborating evidence = UNKNOWN, not MALICIOUS.
Common false positive sources
Enterprise management tools
Legitimate IT management often looks suspicious. Examples: SCCM/MECM, Group Policy, Intune/MDM, Ansible/Puppet/Chef, remote monitoring tools (ConnectWise, Datto, NinjaRMM).
Legitimacy indicators: Parent process is a known management agent; activity correlates with IT maintenance windows; same activity seen across many managed endpoints.
Security software
Security tools use techniques that look like malware. Examples: DLP agents, EDR agents themselves, vulnerability scanners, password managers, PAM tools.
Legitimacy indicators: Signed by known security vendor; running from Program Files with proper installation; part of approved security stack.
Software protection and DRM
Copy protection uses evasive techniques by design. Examples: Denuvo, VMProtect, Themida, game anti-cheat (EasyAntiCheat, BattlEye, Vanguard), license enforcement.
Legitimacy indicators: Associated with known commercial software; no network C2 activity; no persistence beyond the protected application.
Large application frameworks
Modern apps are often large and spawn many processes. Examples: Electron apps (Slack, Discord, VS Code, Teams at 100MB+), Node.js apps, game launchers, IDEs.
Legitimacy indicators: User intentionally installed; from known vendor; process tree shows normal application behavior.
Security testing
Intentional tests trigger real alerts. Examples: Atomic Red Team, Caldera, penetration testing tools, phishing simulations, malware detonation sandboxes.
Legitimacy indicators: Running in designated test environment; parent process is test harness; policy name includes "test", "detonate", "simulation".
Sandbox and testing environments
How to identify
- Policy names containing "detonate", "sandbox", "test", "simulation"
- Parent process patterns like "detonate.py", "sandbox.exe"
data_stream.namespacevalues like "benign", "test", "simulation"- Hostnames matching "sandbox-_", "detonation-_", "test-\*"
Classification rules for sandboxes
Apply the SAME evidence standards as production:
1. MALICIOUS requires strong evidence: actual C2, persistence created, credentials stolen 2. UNKNOWN when behavior is suspicious but lacks corroborating evidence 3. BENIGN when no malicious indicators observed 4. Document the sandbox context in case notes
Being in a sandbox does NOT mean everything detonated is malicious. Many samples are benign/grayware. Suspicious-looking behavior without outcomes is not malicious.
#!/usr/bin/env node
/**
* Acknowledge Elastic Security alerts by updating workflow status.
*
* Uses the Kibana Detection Engine API (POST /api/detection_engine/signals/status)
* instead of direct Elasticsearch updates, which is required for Serverless
* deployments where direct writes to data streams are rejected.
*
* Supports:
* - Single alert acknowledgment by ID
* - Bulk acknowledgment of related alerts (same agent + time window)
* - Bulk acknowledgment by query (agent, time range, rule)
*/
import { createClient } from "./es-client.js";
import { kibanaPost } from "./kibana-client.js";
import { createInterface } from "readline";
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 args = {
related: false,
query: false,
alertId: null,
index: ".alerts-security.alerts-*",
agent: null,
host: null,
timestamp: null,
window: 60,
timeStart: null,
timeEnd: null,
rule: null,
dryRun: false,
yes: false,
};
const positional = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--related" || a === "-r") args.related = true;
else if (a === "--query" || a === "-q") args.query = true;
else if (a === "--index" || a === "-i") args.index = argv[++i] ?? args.index;
else if (a === "--agent" || a === "-a") args.agent = argv[++i] ?? null;
else if (a === "--host" || a === "-h") args.host = argv[++i] ?? null;
else if (a === "--timestamp" || a === "-t") args.timestamp = argv[++i] ?? null;
else if (a === "--window" || a === "-w") args.window = parseInt(argv[++i], 10) || 60;
else if (a === "--time-start") args.timeStart = argv[++i] ?? null;
else if (a === "--time-end") args.timeEnd = argv[++i] ?? null;
else if (a === "--rule") args.rule = argv[++i] ?? null;
else if (a === "--dry-run" || a === "--dryRun") args.dryRun = true;
else if (a === "--yes" || a === "-y") args.yes = true;
else if (!a.startsWith("-")) positional.push(a);
}
args.alertId = positional[0] ?? null;
return args;
}
/**
* Set workflow status to "acknowledged" for alerts matching the given IDs
* via the Kibana Detection Engine API.
*/
async function acknowledgeByIds(alertIds) {
return kibanaPost("/api/detection_engine/signals/status", {
signal_ids: alertIds,
status: "acknowledged",
});
}
/**
* Build an ES query for matching open alerts by agent/host/rule/time.
*/
function buildMatchQuery(opts = {}) {
const mustClauses = [{ term: { "kibana.alert.workflow_status": "open" } }];
if (opts.agentId) mustClauses.push({ term: { "agent.id": opts.agentId } });
if (opts.hostName) mustClauses.push({ term: { "host.name": opts.hostName } });
if (opts.ruleName) mustClauses.push({ match_phrase: { "kibana.alert.rule.name": opts.ruleName } });
if (opts.timeStart || opts.timeEnd) {
const timeRange = {};
if (opts.timeStart) timeRange.gte = opts.timeStart;
if (opts.timeEnd) timeRange.lte = opts.timeEnd;
mustClauses.push({ range: { "@timestamp": timeRange } });
}
return { bool: { must: mustClauses } };
}
/**
* Count matching alerts (used for dry-run and preview).
*/
async function countMatchingAlerts(query, index) {
const client = createClient();
try {
const response = await client.count({ index, query });
return response.count;
} finally {
await client.close();
}
}
/**
* Find alert IDs matching the query (used to collect IDs for the Kibana API).
*/
async function findMatchingAlertIds(query, index) {
const client = createClient();
try {
const response = await client.search({
index,
query,
size: 10000,
_source: false,
});
return (response.hits?.hits || []).map((h) => h._id);
} finally {
await client.close();
}
}
async function acknowledgeByQuery(opts = {}) {
const { index = ".alerts-security.alerts-*", dryRun = false } = opts;
const query = buildMatchQuery(opts);
if (dryRun) {
const count = await countMatchingAlerts(query, index);
return { dry_run: true, matching_alerts: count, query };
}
const alertIds = await findMatchingAlertIds(query, index);
if (!alertIds.length) {
return { total: 0, updated: 0, failures: [], query };
}
await acknowledgeByIds(alertIds);
return { total: alertIds.length, updated: alertIds.length, failures: [], query };
}
async function acknowledgeRelatedAlerts(agentId, timestamp, windowSeconds, index, dryRun) {
const ts = new Date(timestamp.replace("Z", "+00:00"));
const timeStart = new Date(ts.getTime() - windowSeconds * 1000).toISOString().replace("+00:00", "Z");
const timeEnd = new Date(ts.getTime() + windowSeconds * 1000).toISOString().replace("+00:00", "Z");
return acknowledgeByQuery({
agentId,
timeStart,
timeEnd,
index,
dryRun,
});
}
async function main() {
const args = parseArgs(process.argv.slice(2));
try {
if (args.related) {
if (!args.agent || !args.timestamp) {
console.error("--related requires --agent and --timestamp");
process.exit(1);
}
if (!args.dryRun && !args.yes) {
const ok = await promptConfirm("Acknowledge all related alerts for this agent?");
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
const response = await acknowledgeRelatedAlerts(args.agent, args.timestamp, args.window, args.index, args.dryRun);
if (args.dryRun) {
console.log(`DRY RUN: Would acknowledge ${response.matching_alerts} related alerts`);
} else {
console.log(`Acknowledged ${response.updated} of ${response.total} related alerts`);
if (response.failures?.length) {
console.log(`Failures: ${response.failures.length}`);
}
}
} else if (args.query) {
if (!args.dryRun && !args.yes) {
const ok = await promptConfirm("Acknowledge all matching alerts?");
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
const response = await acknowledgeByQuery({
agentId: args.agent,
hostName: args.host,
timeStart: args.timeStart,
timeEnd: args.timeEnd,
ruleName: args.rule,
index: args.index,
dryRun: args.dryRun,
});
if (args.dryRun) {
console.log(`DRY RUN: Would acknowledge ${response.matching_alerts} alerts`);
} else {
console.log(`Acknowledged ${response.updated} of ${response.total} alerts`);
if (response.failures?.length) {
console.log(`Failures: ${response.failures.length}`);
}
}
} else {
if (!args.alertId) {
console.error("alert_id required for single mode (or use --related/--query)");
process.exit(1);
}
if (args.dryRun) {
const client = createClient();
try {
const response = await client.get({ index: args.index, id: args.alertId, _source: true });
const status = response._source?.["kibana.alert.workflow_status"] ?? "unknown";
console.log(`DRY RUN: Would acknowledge alert ${args.alertId} (current status: ${status})`);
console.log(JSON.stringify(response, null, 2));
} finally {
await client.close();
}
} else {
if (!args.yes) {
const ok = await promptConfirm(`Acknowledge alert ${args.alertId}?`);
if (!ok) {
console.log("Aborted.");
process.exit(0);
}
}
const response = await acknowledgeByIds([args.alertId]);
console.log("Alert acknowledged successfully");
console.log(JSON.stringify(response, null, 2));
}
}
} catch (err) {
console.error(`Failed to acknowledge alert(s): ${err.message}`);
process.exit(1);
}
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
/**
* 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 the next unacknowledged Elastic Security alert.
* Returns the oldest unacknowledged alert from the last N days.
*/
import { createClient } from "./es-client.js";
const ALERTS_INDEX = ".alerts-security.alerts-*";
/**
* Resolve a dot-notation field path from an object that may store the value
* either as a flat key ("kibana.alert.rule.name") or as nested objects
* ({ kibana: { alert: { rule: { name: "..." } } } }).
*/
function getField(obj, path) {
if (obj == null) return undefined;
if (path in obj) return obj[path];
const parts = path.split(".");
let cur = obj;
for (const part of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = cur[part];
}
return cur;
}
function parseArgs(argv) {
const args = { days: 7, verbose: false, json: false, full: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--days" || a === "-d") args.days = parseInt(argv[++i], 10) || 7;
else if (a === "--verbose" || a === "-v") args.verbose = true;
else if (a === "--json" || a === "-j") args.json = true;
else if (a === "--full") args.full = true;
}
return args;
}
async function fetchNextUnacknowledgedAlert(daysBack, verbose) {
const client = createClient();
const now = new Date();
const startTime = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000);
const query = {
bool: {
must: [{ range: { "@timestamp": { gte: startTime.toISOString(), lte: now.toISOString() } } }],
must_not: [
{ term: { "kibana.alert.workflow_status": "acknowledged" } },
{ term: { "kibana.alert.workflow_status": "closed" } },
],
},
};
if (verbose) {
console.log(`Searching index: ${ALERTS_INDEX}`);
console.log(`Time range: ${startTime.toISOString()} to ${now.toISOString()}`);
console.log(`Query: ${JSON.stringify(query, null, 2)}`);
}
const response = await client.search({
index: ALERTS_INDEX,
query,
sort: [{ "@timestamp": { order: "asc" } }],
size: 1,
});
const hits = response.hits?.hits ?? [];
const total = response.hits?.total;
const totalCount = typeof total === "object" ? (total?.value ?? 0) : (total ?? 0);
if (verbose) {
console.log(`Total unacknowledged alerts: ${totalCount}`);
}
if (hits.length > 0) {
const alert = hits[0];
return {
id: alert._id,
index: alert._index,
source: alert._source,
total_unacknowledged: totalCount,
};
}
return null;
}
function formatAlertSummary(alert) {
if (!alert) {
return "No unacknowledged alerts found.";
}
const source = alert.source;
const agentId = getField(source, "agent.id") ?? "Unknown";
const hostName = getField(source, "host.name") ?? "Unknown";
const userName = getField(source, "user.name") ?? "Unknown";
return `
================================================================================
ALERT SUMMARY
================================================================================
Alert ID: ${alert.id}
Index: ${alert.index}
Timestamp: ${getField(source, "@timestamp") ?? "Unknown"}
RULE INFORMATION
----------------
Rule Name: ${getField(source, "kibana.alert.rule.name") ?? "Unknown Rule"}
Severity: ${getField(source, "kibana.alert.severity") ?? "unknown"}
Risk Score: ${getField(source, "kibana.alert.risk_score") ?? "N/A"}
ENTITY INFORMATION
------------------
Agent ID: ${agentId}
Host: ${hostName}
User: ${userName}
ALERT REASON
------------
${getField(source, "kibana.alert.reason") ?? "No reason provided"}
REMAINING UNACKNOWLEDGED
------------------------
Total alerts pending: ${alert.total_unacknowledged}
================================================================================
`;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const alert = await fetchNextUnacknowledgedAlert(args.days, args.verbose);
if (args.json) {
if (alert && !args.full) {
const output = {
id: alert.id,
index: alert.index,
timestamp: getField(alert.source, "@timestamp"),
rule_name: getField(alert.source, "kibana.alert.rule.name"),
severity: getField(alert.source, "kibana.alert.severity"),
agent_id: getField(alert.source, "agent.id"),
host: getField(alert.source, "host.name"),
user: getField(alert.source, "user.name"),
total_unacknowledged: alert.total_unacknowledged,
};
console.log(JSON.stringify(output, null, 2));
} else {
console.log(JSON.stringify(alert, null, 2));
}
} else {
console.log(formatAlertSummary(alert));
}
}
main().catch((err) => {
console.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
/**
* Run arbitrary queries against Elasticsearch.
* Supports both KQL (Kibana Query Language) and ES|QL queries.
*/
import { readFileSync } from "fs";
import { createClient } from "./es-client.js";
function parseArgs(argv) {
const args = {
query: null,
queryFile: null,
type: "kql",
index: "logs-*",
size: 100,
days: null,
json: false,
full: false,
};
const positional = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--query-file" || a === "-q") args.queryFile = argv[++i] ?? null;
else if (a === "--type" || a === "-t") args.type = argv[++i] ?? "kql";
else if (a === "--index" || a === "-i") args.index = argv[++i] ?? "logs-*";
else if (a === "--size" || a === "-s") args.size = parseInt(argv[++i], 10) || 100;
else if (a === "--days" || a === "-d") args.days = argv[++i] ? parseInt(argv[i], 10) : null;
else if (a === "--json" || a === "-j") args.json = true;
else if (a === "--full") args.full = true;
else if (!a.startsWith("-")) positional.push(a);
}
args.query = positional[0] ?? null;
return args;
}
async function runKqlQuery(client, index, kqlQuery, opts = {}) {
const {
timeField = "@timestamp",
startTime = null,
endTime = null,
size = 100,
sortField = null,
sortOrder = "desc",
} = opts;
const queryBody = {
bool: {
must: [{ query_string: { query: kqlQuery, analyze_wildcard: true } }],
},
};
if (startTime || endTime) {
const timeRange = {};
if (startTime) timeRange.gte = startTime.toISOString();
if (endTime) timeRange.lte = endTime.toISOString();
queryBody.bool.must.push({ range: { [timeField]: timeRange } });
}
const sortBy = sortField ?? timeField;
const sort = [{ [sortBy]: { order: sortOrder } }];
return client.search({ index, query: queryBody, sort, size });
}
async function runEsqlQuery(client, esqlQuery) {
return client.esql.query({ query: esqlQuery, format: "json" });
}
function formatKqlResults(response, showSource = false) {
const hits = response.hits?.hits ?? [];
const total = response.hits?.total;
const totalCount = typeof total === "object" ? (total?.value ?? 0) : (total ?? 0);
const relation = typeof total === "object" ? (total?.relation ?? "eq") : "eq";
const took = response.took ?? 0;
const output = [
`Query took: ${took}ms`,
`Total hits: ${totalCount} (${relation})`,
`Returned: ${hits.length} documents`,
"-".repeat(80),
];
for (let i = 0; i < hits.length; i++) {
const hit = hits[i];
output.push(`\n--- Document ${i + 1} ---`);
output.push(`Index: ${hit._index}`);
output.push(`ID: ${hit._id}`);
if (showSource) {
output.push("Source:");
output.push(JSON.stringify(hit._source, null, 2));
} else {
const source = hit._source ?? {};
output.push(`Timestamp: ${source["@timestamp"] ?? "N/A"}`);
if (source.message) {
let msg = source.message;
if (msg.length > 200) msg = msg.slice(0, 200) + "...";
output.push(`Message: ${msg}`);
}
if (source.event) {
output.push(`Event: ${JSON.stringify(source.event)}`);
}
}
}
return output.join("\n");
}
function formatEsqlResults(response) {
const columns = response.columns ?? [];
const values = response.values ?? [];
const colNames = columns.map((col) => col.name);
const output = [
`Columns: ${columns.length}`,
`Rows: ${values.length}`,
"-".repeat(80),
colNames.join(" | "),
"-".repeat(80),
];
for (const row of values) {
const formatted = row.map((val) => {
if (val === null || val === undefined) return "null";
if (typeof val === "object") return JSON.stringify(val);
return String(val);
});
output.push(formatted.join(" | "));
}
return output.join("\n");
}
async function main() {
const args = parseArgs(process.argv.slice(2));
let query;
if (args.queryFile) {
query = readFileSync(args.queryFile, "utf8").trim();
} else if (args.query) {
query = args.query;
} else {
console.error("Either provide a query as argument or use --query-file");
process.exit(1);
}
const client = createClient();
let startTime = null;
let endTime = null;
if (args.days) {
endTime = new Date();
startTime = new Date(endTime.getTime() - args.days * 24 * 60 * 60 * 1000);
}
try {
if (args.type === "kql") {
const response = await runKqlQuery(client, args.index, query, {
startTime,
endTime,
size: args.size,
});
if (args.json) {
console.log(JSON.stringify(response, null, 2));
} else {
console.log(formatKqlResults(response, args.full));
}
} else if (args.type === "esql") {
const response = await runEsqlQuery(client, query);
if (args.json) {
console.log(JSON.stringify(response, null, 2));
} else {
console.log(formatEsqlResults(response));
}
} else {
console.error(`Unknown query type: ${args.type}. Use kql or esql.`);
process.exit(1);
}
} catch (err) {
console.error(`Query failed: ${err.message}`);
process.exit(1);
}
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});