From 36f6aa176f62369fc240802c975b749fa955ecac Mon Sep 17 00:00:00 2001 From: Nikita Khristinin Date: Mon, 16 Mar 2026 09:57:35 +0100 Subject: [PATCH] agent builder --- .../agent-builder-server/allow_lists.ts | 8 + .../agents/threat_hunting_agent.ts | 28 +- .../tools/coverage_overview_tool.ts | 229 ++++++++++ .../tools/execution_stats_tool.ts | 398 ++++++++++++++++++ .../agent_builder/tools/find_rules_tool.ts | 144 +++++++ .../tools/get_rule_details_tool.ts | 112 +++++ .../server/agent_builder/tools/index.ts | 20 + .../tools/prebuilt_rules_status_tool.ts | 129 ++++++ .../agent_builder/tools/register_tools.ts | 16 + .../tools/rule_execution_history_tool.ts | 116 +++++ .../agent_builder/tools/rule_gaps_tool.ts | 140 ++++++ .../agent_builder/tools/rules_health_tool.ts | 188 +++++++++ 12 files changed, 1527 insertions(+), 1 deletion(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/coverage_overview_tool.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/execution_stats_tool.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/find_rules_tool.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/get_rule_details_tool.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/prebuilt_rules_status_tool.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_execution_history_tool.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_gaps_tool.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rules_health_tool.ts diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts index bec5777dfc306..77e34bedad4ff 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/allow_lists.ts @@ -38,6 +38,14 @@ export const AGENT_BUILDER_BUILTIN_TOOLS = [ `${internalNamespaces.security}.attack_discovery_search`, `${internalNamespaces.security}.security_labs_search`, `${internalNamespaces.security}.alerts`, + `${internalNamespaces.security}.find_rules`, + `${internalNamespaces.security}.get_rule_details`, + `${internalNamespaces.security}.rule_execution_history`, + `${internalNamespaces.security}.rule_gaps`, + `${internalNamespaces.security}.rules_health`, + `${internalNamespaces.security}.coverage_overview`, + `${internalNamespaces.security}.prebuilt_rules_status`, + `${internalNamespaces.security}.execution_stats`, ] as const; export type AgentBuilderBuiltinTool = (typeof AGENT_BUILDER_BUILTIN_TOOLS)[number]; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/agents/threat_hunting_agent.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/agents/threat_hunting_agent.ts index 697802d414032..a8516fc943980 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/agents/threat_hunting_agent.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/agents/threat_hunting_agent.ts @@ -14,6 +14,14 @@ import { SECURITY_LABS_SEARCH_TOOL_ID, SECURITY_ALERTS_TOOL_ID, SECURITY_ENTITY_RISK_SCORE_TOOL_ID, + SECURITY_FIND_RULES_TOOL_ID, + SECURITY_GET_RULE_DETAILS_TOOL_ID, + SECURITY_RULE_EXECUTION_HISTORY_TOOL_ID, + SECURITY_RULE_GAPS_TOOL_ID, + SECURITY_RULES_HEALTH_TOOL_ID, + SECURITY_COVERAGE_OVERVIEW_TOOL_ID, + SECURITY_PREBUILT_RULES_STATUS_TOOL_ID, + SECURITY_EXECUTION_STATS_TOOL_ID, } from '../tools'; import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; @@ -34,6 +42,14 @@ const SECURITY_TOOL_IDS = [ SECURITY_ATTACK_DISCOVERY_SEARCH_TOOL_ID, SECURITY_ENTITY_RISK_SCORE_TOOL_ID, SECURITY_LABS_SEARCH_TOOL_ID, + SECURITY_FIND_RULES_TOOL_ID, + SECURITY_GET_RULE_DETAILS_TOOL_ID, + SECURITY_RULE_EXECUTION_HISTORY_TOOL_ID, + SECURITY_RULE_GAPS_TOOL_ID, + SECURITY_RULES_HEALTH_TOOL_ID, + SECURITY_COVERAGE_OVERVIEW_TOOL_ID, + SECURITY_PREBUILT_RULES_STATUS_TOOL_ID, + SECURITY_EXECUTION_STATS_TOOL_ID, ]; export const THREAT_HUNTING_AGENT_TOOL_IDS = [...PLATFORM_TOOL_IDS, ...SECURITY_TOOL_IDS]; @@ -56,7 +72,17 @@ export const createThreatHuntingAgent = ( }, }, configuration: { - instructions: `You are a security analyst and expert in resolving security incidents. Your role is to assist by answering questions about Elastic Security.`, + instructions: `You are a security analyst and expert in resolving security incidents. Your role is to assist by answering questions about Elastic Security. + +For questions about detection rule performance, health, and execution: +- Use find_rules to search/filter/list rules by status, name, tags, or enabled state. +- Use get_rule_details for full configuration of a specific rule. +- Use execution_stats for aggregate execution metrics and to identify which specific rules are most delayed, slowest, or erroring (via top_rules_by); also supports duration percentiles, schedule delay, search/indexing duration, top errors/warnings, and time-series trends. +- Use rules_health for a summary of rule counts by outcome, execution KPIs, and gap overview. +- Use rule_execution_history for per-rule execution log entries. +- Use rule_gaps for coverage gap details. +- Use coverage_overview for MITRE ATT&CK technique coverage. +- Use prebuilt_rules_status for installed prebuilt rule counts.`, tools: [ { tool_ids: THREAT_HUNTING_AGENT_TOOL_IDS, diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/coverage_overview_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/coverage_overview_tool.ts new file mode 100644 index 0000000000000..3674540cd0b1a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/coverage_overview_tool.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_COVERAGE_OVERVIEW_TOOL_ID = securityTool('coverage_overview'); + +const coverageOverviewSchema = z.object({ + activity: z + .enum(['enabled', 'disabled']) + .optional() + .describe('Filter to only enabled or only disabled rules'), + source: z + .enum(['prebuilt', 'custom']) + .optional() + .describe('Filter by rule source: prebuilt (Elastic) or custom'), +}); + +interface ThreatEntry { + framework: string; + tactic: { id: string; name: string; reference?: string }; + technique?: Array<{ + id: string; + name: string; + reference?: string; + subtechnique?: Array<{ id: string; name: string; reference?: string }>; + }>; +} + +interface TechniqueInfo { + technique_name: string; + enabled_rules: number; + disabled_rules: number; + subtechniques: Map; +} + +interface TacticInfo { + tactic_name: string; + techniques: Map; +} + +const SIEM_RULE_FILTER = 'alert.attributes.consumer: "siem"'; + +export const coverageOverviewTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_COVERAGE_OVERVIEW_TOOL_ID, + type: ToolType.builtin, + description: + 'Get MITRE ATT&CK coverage overview showing which tactics and techniques are covered by detection rules. Returns technique counts per tactic and a coverage summary. Use to answer "what techniques are we covering?", "where are our detection gaps?", "how is our MITRE coverage?".', + schema: coverageOverviewSchema, + tags: ['security', 'detection', 'rules', 'mitre', 'coverage'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (params, { request }) => { + try { + const [, startPlugins] = await core.getStartServices(); + const rulesClient = await startPlugins.alerting.getRulesClientWithRequest(request); + + const filters: string[] = [SIEM_RULE_FILTER]; + if (params.activity === 'enabled') { + filters.push('alert.attributes.enabled: true'); + } else if (params.activity === 'disabled') { + filters.push('alert.attributes.enabled: false'); + } + if (params.source === 'prebuilt') { + filters.push('alert.attributes.params.immutable: true'); + } else if (params.source === 'custom') { + filters.push('alert.attributes.params.immutable: false'); + } + + const tacticMap = new Map(); + let totalRules = 0; + let rulesWithThreat = 0; + let page = 1; + const perPage = 1000; + + // Paginate through all rules to build the coverage map + while (true) { + const result = await rulesClient.find({ + options: { + filter: filters.join(' AND '), + perPage, + page, + sortField: 'name', + sortOrder: 'asc', + }, + excludeFromPublicApi: false, + }); + + for (const rule of result.data) { + totalRules++; + const ruleParams = rule.params as Record; + const threat = ruleParams.threat as ThreatEntry[] | undefined; + if (!threat?.length) continue; + + rulesWithThreat++; + const isEnabled = rule.enabled; + + for (const entry of threat) { + if (entry.framework !== 'MITRE ATT&CK' || !entry.tactic) continue; + + const tacticId = entry.tactic.id; + if (!tacticMap.has(tacticId)) { + tacticMap.set(tacticId, { + tactic_name: entry.tactic.name, + techniques: new Map(), + }); + } + const tacticInfo = tacticMap.get(tacticId)!; + + for (const technique of entry.technique ?? []) { + if (!tacticInfo.techniques.has(technique.id)) { + tacticInfo.techniques.set(technique.id, { + technique_name: technique.name, + enabled_rules: 0, + disabled_rules: 0, + subtechniques: new Map(), + }); + } + const techInfo = tacticInfo.techniques.get(technique.id)!; + if (isEnabled) { + techInfo.enabled_rules++; + } else { + techInfo.disabled_rules++; + } + + for (const sub of technique.subtechnique ?? []) { + if (!techInfo.subtechniques.has(sub.id)) { + techInfo.subtechniques.set(sub.id, { + name: sub.name, + enabled: 0, + disabled: 0, + }); + } + const subInfo = techInfo.subtechniques.get(sub.id)!; + if (isEnabled) { + subInfo.enabled++; + } else { + subInfo.disabled++; + } + } + } + } + } + + if (result.data.length < perPage) break; + page++; + } + + let totalTechniquesCovered = 0; + const coverage = Array.from(tacticMap.entries()).map(([tacticId, tacticInfo]) => { + const techniques = Array.from(tacticInfo.techniques.entries()).map( + ([techId, techInfo]) => { + totalTechniquesCovered++; + const subtechniques = Array.from(techInfo.subtechniques.entries()).map( + ([subId, subInfo]) => ({ + id: subId, + name: subInfo.name, + enabled_rules: subInfo.enabled, + disabled_rules: subInfo.disabled, + }) + ); + + return { + id: techId, + name: techInfo.technique_name, + enabled_rules: techInfo.enabled_rules, + disabled_rules: techInfo.disabled_rules, + total_rules: techInfo.enabled_rules + techInfo.disabled_rules, + ...(subtechniques.length > 0 ? { subtechniques } : {}), + }; + } + ); + + return { + tactic_id: tacticId, + tactic_name: tacticInfo.tactic_name, + techniques_covered: techniques.length, + techniques, + }; + }); + + return { + results: [ + { + type: ToolResultType.other, + data: { + summary: { + total_rules: totalRules, + rules_with_mitre_mapping: rulesWithThreat, + total_tactics_covered: coverage.length, + total_techniques_covered: totalTechniquesCovered, + }, + coverage, + }, + }, + ], + }; + } catch (error) { + logger.error(`coverage_overview tool failed: ${error.message}`); + return { + results: [ + { + type: ToolResultType.error, + data: { message: `Failed to get coverage overview: ${error.message}` }, + }, + ], + }; + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/execution_stats_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/execution_stats_tool.ts new file mode 100644 index 0000000000000..82e9c3e05e90c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/execution_stats_tool.ts @@ -0,0 +1,398 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import { HealthIntervalGranularity } from '../../../common/api/detection_engine/rule_monitoring'; +import { + getRuleExecutionStatsAggregation, + normalizeRuleExecutionStatsAggregationResult, +} from '../../lib/detection_engine/rule_monitoring/logic/detection_engine_health/event_log/aggregations/rule_execution_stats'; +import { + getRuleHealthAggregation, + normalizeRuleHealthAggregationResult, +} from '../../lib/detection_engine/rule_monitoring/logic/detection_engine_health/event_log/aggregations/health_stats_for_rule'; +import type { RawData } from '../../lib/detection_engine/rule_monitoring/logic/utils/normalization'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_EXECUTION_STATS_TOOL_ID = securityTool('execution_stats'); + +const EVENT_LOG_INDEX = '.kibana-event-log-*'; + +const ALERTING_PROVIDER = 'alerting'; +const RULE_EXECUTION_LOG_PROVIDER = 'securitySolution.ruleExecution'; +const RULE_ID_FIELD = 'rule.id'; +const RULE_NAME_FIELD = 'rule.name'; +const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; +const TOTAL_RUN_DURATION_MS_FIELD = 'kibana.alert.rule.execution.metrics.total_run_duration_ms'; +const TOTAL_SEARCH_DURATION_MS_FIELD = + 'kibana.alert.rule.execution.metrics.total_search_duration_ms'; +const GAP_DURATION_S_FIELD = 'kibana.alert.rule.execution.metrics.execution_gap_duration_s'; +const RULE_EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; +const NS_TO_MS = 1_000_000; + +type TopRulesByMetric = (typeof TOP_RULES_BY_VALUES)[number]; + +interface TopRuleEntry { + rule_id: string; + rule_name: string; + metric_value: number; + metric_p95?: number; + total_executions: number; +} + +function buildTopRulesAggregation(metric: TopRulesByMetric, size: number): Record { + const executeEventFilter = { + bool: { + filter: [ + { term: { 'event.provider': ALERTING_PROVIDER } }, + { term: { 'event.action': 'execute' } }, + { term: { 'event.category': 'siem' } }, + ], + }, + }; + const executionMetricsFilter = { + bool: { + filter: [ + { term: { 'event.provider': RULE_EXECUTION_LOG_PROVIDER } }, + { term: { 'event.action': 'execution-metrics' } }, + ], + }, + }; + const errorEventsFilter = { + bool: { + filter: [ + { term: { 'event.provider': RULE_EXECUTION_LOG_PROVIDER } }, + { terms: { 'event.action': ['status-change', 'message'] } }, + { term: { 'log.level': 'error' } }, + ], + }, + }; + + let metricFilterAgg: Record; + let sortPath: string; + + switch (metric) { + case 'schedule_delay': + metricFilterAgg = { + filter: executeEventFilter, + aggs: { + avgScheduleDelay: { avg: { field: SCHEDULE_DELAY_FIELD } }, + p95ScheduleDelay: { + percentiles: { field: SCHEDULE_DELAY_FIELD, percents: [95] }, + }, + totalExecutions: { + cardinality: { field: RULE_EXECUTION_UUID_FIELD }, + }, + }, + }; + sortPath = 'metricFilter.avgScheduleDelay'; + break; + case 'execution_duration': + metricFilterAgg = { + filter: executeEventFilter, + aggs: { + avgExecutionDuration: { avg: { field: TOTAL_RUN_DURATION_MS_FIELD } }, + p95ExecutionDuration: { + percentiles: { + field: TOTAL_RUN_DURATION_MS_FIELD, + percents: [95], + }, + }, + totalExecutions: { + cardinality: { field: RULE_EXECUTION_UUID_FIELD }, + }, + }, + }; + sortPath = 'metricFilter.avgExecutionDuration'; + break; + case 'search_duration': + metricFilterAgg = { + filter: executionMetricsFilter, + aggs: { + avgSearchDuration: { avg: { field: TOTAL_SEARCH_DURATION_MS_FIELD } }, + p95SearchDuration: { + percentiles: { + field: TOTAL_SEARCH_DURATION_MS_FIELD, + percents: [95], + }, + }, + totalExecutions: { + cardinality: { field: RULE_EXECUTION_UUID_FIELD }, + }, + }, + }; + sortPath = 'metricFilter.avgSearchDuration'; + break; + case 'gap_duration': + metricFilterAgg = { + filter: executionMetricsFilter, + aggs: { + sumGapDuration: { sum: { field: GAP_DURATION_S_FIELD } }, + totalExecutions: { + cardinality: { field: RULE_EXECUTION_UUID_FIELD }, + }, + }, + }; + sortPath = 'metricFilter.sumGapDuration'; + break; + case 'errors': + metricFilterAgg = { + filter: errorEventsFilter, + aggs: { + errorCount: { value_count: { field: RULE_ID_FIELD } }, + totalExecutions: { + cardinality: { field: RULE_EXECUTION_UUID_FIELD }, + }, + }, + }; + sortPath = 'metricFilter.errorCount'; + break; + default: + return {}; + } + + // Use bucket_sort pipeline to sort by nested metric; terms agg cannot order by + // a path through a filter (single-bucket) agg in some ES versions. + const BUCKET_SORT_FETCH_SIZE = 500; + return { + terms: { + field: RULE_ID_FIELD, + size: BUCKET_SORT_FETCH_SIZE, + order: { _key: 'asc' as const }, + min_doc_count: metric === 'errors' ? 1 : 1, + }, + aggs: { + ruleName: { terms: { field: RULE_NAME_FIELD, size: 1 } }, + metricFilter: metricFilterAgg, + sortBuckets: { + bucket_sort: { + sort: [{ [sortPath]: { order: 'desc' as const } }], + from: 0, + size, + }, + }, + }, + }; +} + +function normalizeTopRules( + topRulesAgg: Record | undefined, + metric: TopRulesByMetric +): TopRuleEntry[] { + const buckets = (topRulesAgg?.buckets ?? []) as Array>; + return buckets.map((bucket) => { + const ruleId = String(bucket.key ?? ''); + const ruleNameBucket = (bucket.ruleName as { buckets?: Array<{ key?: string }> })?.buckets; + const ruleName = + (ruleNameBucket?.length ?? 0) > 0 ? String(ruleNameBucket?.[0]?.key ?? '') : ''; + + const metricFilter = bucket.metricFilter as Record; + const totalExecutionsAgg = metricFilter?.totalExecutions as { value?: number }; + const totalExecutions = Number(totalExecutionsAgg?.value ?? 0); + + let metricValue = 0; + let metricP95: number | undefined; + + switch (metric) { + case 'schedule_delay': { + const avg = (metricFilter?.avgScheduleDelay as { value?: number })?.value; + const p95 = (metricFilter?.p95ScheduleDelay as { values?: { '95.0'?: number } })?.values; + metricValue = Number(avg ?? 0) / NS_TO_MS; + metricP95 = p95?.['95.0'] != null ? Number(p95['95.0']) / NS_TO_MS : undefined; + break; + } + case 'execution_duration': { + const avg = (metricFilter?.avgExecutionDuration as { value?: number })?.value; + const p95 = (metricFilter?.p95ExecutionDuration as { values?: { '95.0'?: number } }) + ?.values; + metricValue = Number(avg ?? 0); + metricP95 = p95?.['95.0']; + break; + } + case 'search_duration': { + const avg = (metricFilter?.avgSearchDuration as { value?: number })?.value; + const p95 = (metricFilter?.p95SearchDuration as { values?: { '95.0'?: number } })?.values; + metricValue = Number(avg ?? 0); + metricP95 = p95?.['95.0']; + break; + } + case 'gap_duration': { + const sum = (metricFilter?.sumGapDuration as { value?: number })?.value; + metricValue = Number(sum ?? 0); + break; + } + case 'errors': { + const errorCount = (metricFilter?.errorCount as { value?: number })?.value; + metricValue = Number(errorCount ?? 0); + break; + } + } + + return { + rule_id: ruleId, + rule_name: ruleName, + metric_value: Math.round(metricValue * 1000) / 1000, + ...(metricP95 != null && { metric_p95: Math.round(metricP95 * 1000) / 1000 }), + total_executions: totalExecutions, + }; + }); +} + +const TOP_RULES_BY_VALUES = [ + 'schedule_delay', + 'execution_duration', + 'search_duration', + 'gap_duration', + 'errors', +] as const; + +const executionStatsSchema = z.object({ + start: z + .string() + .optional() + .describe('ISO date for the start of the time range (default: 24 hours ago)'), + end: z.string().optional().describe('ISO date for the end of the time range (default: now)'), + rule_id: z + .string() + .optional() + .describe( + 'Optional rule saved-object ID to scope stats to a single rule. If omitted, stats cover all SIEM rules in the space.' + ), + include_history: z + .boolean() + .optional() + .describe('If true, include time-series buckets (hourly) of execution stats. Default: false.'), + top_rules_by: z + .enum(TOP_RULES_BY_VALUES) + .optional() + .describe( + 'Return the top N rules ranked by this metric. Useful for identifying which specific rules are most delayed, slowest, or erroring.' + ), + top_rules_count: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe('Number of top rules to return when using top_rules_by. Default: 10.'), +}); + +export const executionStatsTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_EXECUTION_STATS_TOOL_ID, + type: ToolType.builtin, + description: + 'Query the rule execution event log for detailed execution statistics: execution duration percentiles (p50/p95/p99), schedule delay percentiles, search and indexing duration percentiles, execution outcome breakdown, top error and warning messages, message counts by log level, and gap statistics. Optionally includes time-series history. When top_rules_by is specified, also returns the top N rules ranked by that metric (e.g. most delayed, slowest, most errors), enabling drill-down from aggregate stats to specific rules. Use for performance analysis, trend detection, and questions like "what are the top errors?", "what is the p95 execution time?", "is schedule delay increasing?", "which rules are most delayed?", "which rules are slowest?".', + schema: executionStatsSchema, + tags: ['security', 'detection', 'rules', 'monitoring', 'execution', 'event-log'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (params, { esClient, spaceId }) => { + try { + const now = new Date(); + const defaultStart = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const start = params.start ?? defaultStart.toISOString(); + const end = params.end ?? now.toISOString(); + + const queryFilters: Array> = [ + { range: { '@timestamp': { gte: start, lte: end } } }, + { term: { 'kibana.space_ids': spaceId } }, + ]; + if (params.rule_id) { + queryFilters.push({ term: { 'rule.id': params.rule_id } }); + } + + const aggs: Record = params.include_history + ? (getRuleHealthAggregation(HealthIntervalGranularity.hour) as Record) + : (getRuleExecutionStatsAggregation('whole-interval') as Record); + + if (params.top_rules_by) { + const topCount = params.top_rules_count ?? 10; + aggs.topRules = buildTopRulesAggregation(params.top_rules_by, topCount); + } + + const response = await esClient.asCurrentUser.search({ + index: EVENT_LOG_INDEX, + size: 0, + query: { + bool: { + filter: queryFilters, + }, + }, + aggs: aggs as Record, + }); + + const aggregations = (response.aggregations ?? {}) as Record; + + const topRules = + params.top_rules_by && aggregations.topRules + ? normalizeTopRules( + aggregations.topRules as Record, + params.top_rules_by + ) + : undefined; + + if (params.include_history) { + const healthResult = normalizeRuleHealthAggregationResult( + { aggregations }, + aggs as Record + ); + return { + results: [ + { + type: ToolResultType.other, + data: { + interval: { start, end }, + stats_over_interval: healthResult.stats_over_interval, + history_over_interval: healthResult.history_over_interval, + ...(topRules != null && { top_rules: topRules }), + }, + }, + ], + }; + } + + const stats = normalizeRuleExecutionStatsAggregationResult(aggregations, 'whole-interval'); + return { + results: [ + { + type: ToolResultType.other, + data: { + interval: { start, end }, + stats_over_interval: stats, + ...(topRules != null && { top_rules: topRules }), + }, + }, + ], + }; + } catch (error) { + logger.error(`execution_stats tool failed: ${error.message}`); + return { + results: [ + { + type: ToolResultType.error, + data: { message: `Failed to get execution stats: ${error.message}` }, + }, + ], + }; + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/find_rules_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/find_rules_tool.ts new file mode 100644 index 0000000000000..ecd964dbbe237 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/find_rules_tool.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_FIND_RULES_TOOL_ID = securityTool('find_rules'); + +const findRulesSchema = z.object({ + status: z + .enum(['succeeded', 'warning', 'failed']) + .optional() + .describe('Filter rules by their last run outcome'), + enabled: z.boolean().optional().describe('Filter by enabled/disabled state'), + name: z.string().optional().describe('Search rules by name (partial match)'), + tags: z.array(z.string()).optional().describe('Filter rules that have all of these tags'), + sort_field: z + .enum([ + 'name', + 'updatedAt', + 'executionStatus.lastExecutionDate', + ]) + .optional() + .describe('Field to sort results by'), + sort_order: z.enum(['asc', 'desc']).optional().describe('Sort direction'), + page: z.number().int().min(1).optional().describe('Page number (default: 1)'), + per_page: z.number().int().min(1).max(100).optional().describe('Results per page (default: 20)'), +}); + +const SIEM_RULE_FILTER = 'alert.attributes.consumer: "siem"'; + +const buildKqlFilter = (params: z.infer): string => { + const filters: string[] = [SIEM_RULE_FILTER]; + + if (params.status) { + filters.push(`alert.attributes.lastRun.outcome: "${params.status}"`); + } + + if (params.enabled !== undefined) { + filters.push(`alert.attributes.enabled: ${params.enabled}`); + } + + if (params.name) { + filters.push(`alert.attributes.name: "${params.name}"`); + } + + if (params.tags?.length) { + for (const tag of params.tags) { + filters.push(`alert.attributes.tags: "${tag}"`); + } + } + + return filters.join(' AND '); +}; + +export const findRulesTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_FIND_RULES_TOOL_ID, + type: ToolType.builtin, + description: + 'Search and filter security detection rules. Returns rules with their status, last execution outcome, alert counts, performance metrics, and schedule. Use to answer questions like "which rules are failing?", "show me disabled rules", "which rules are slowest?".', + schema: findRulesSchema, + tags: ['security', 'detection', 'rules', 'monitoring'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (params, { request }) => { + try { + const [, startPlugins] = await core.getStartServices(); + const rulesClient = await startPlugins.alerting.getRulesClientWithRequest(request); + + const filter = buildKqlFilter(params); + const result = await rulesClient.find({ + options: { + filter, + sortField: params.sort_field ?? 'executionStatus.lastExecutionDate', + sortOrder: params.sort_order ?? 'desc', + page: params.page ?? 1, + perPage: params.per_page ?? 20, + }, + excludeFromPublicApi: false, + }); + + const rules = result.data.map((rule) => ({ + id: rule.id, + name: rule.name, + enabled: rule.enabled, + type: rule.alertTypeId, + last_outcome: rule.lastRun?.outcome ?? 'unknown', + last_execution_date: rule.executionStatus?.lastExecutionDate?.toISOString() ?? null, + last_duration_ms: rule.executionStatus?.lastDuration ?? null, + success_ratio: rule.monitoring?.run?.calculated_metrics?.success_ratio ?? null, + p50_duration_ms: rule.monitoring?.run?.calculated_metrics?.p50 ?? null, + p95_duration_ms: rule.monitoring?.run?.calculated_metrics?.p95 ?? null, + alerts_count: { + active: rule.lastRun?.alertsCount?.active ?? 0, + new: rule.lastRun?.alertsCount?.new ?? 0, + recovered: rule.lastRun?.alertsCount?.recovered ?? 0, + }, + error_message: + rule.lastRun?.outcome === 'failed' || rule.lastRun?.outcome === 'warning' + ? rule.lastRun?.outcomeMsg?.join('; ') + : undefined, + schedule_interval: rule.schedule?.interval ?? null, + tags: rule.tags, + })); + + return { + results: [ + { + type: ToolResultType.other, + data: { total: result.total, page: result.page, per_page: result.perPage, rules }, + }, + ], + }; + } catch (error) { + logger.error(`find_rules tool failed: ${error.message}`); + return { + results: [ + { + type: ToolResultType.error, + data: { message: `Failed to search rules: ${error.message}` }, + }, + ], + }; + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/get_rule_details_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/get_rule_details_tool.ts new file mode 100644 index 0000000000000..9300bffc317fd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/get_rule_details_tool.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_GET_RULE_DETAILS_TOOL_ID = securityTool('get_rule_details'); + +const getRuleDetailsSchema = z.object({ + rule_id: z + .string() + .describe( + 'The rule ID (saved object ID) to fetch. This is the unique identifier returned by the find_rules tool.' + ), +}); + +export const getRuleDetailsTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_GET_RULE_DETAILS_TOOL_ID, + type: ToolType.builtin, + description: + 'Get full configuration details for a specific detection rule including its query, index patterns, severity, risk score, schedule, exceptions, and actions. Use after find_rules to drill into a specific rule.', + schema: getRuleDetailsSchema, + tags: ['security', 'detection', 'rules'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async ({ rule_id: ruleId }, { request }) => { + try { + const [, startPlugins] = await core.getStartServices(); + const rulesClient = await startPlugins.alerting.getRulesClientWithRequest(request); + + const rule = await rulesClient.get({ id: ruleId }); + + const ruleParams = rule.params as Record; + + const details = { + id: rule.id, + name: rule.name, + description: ruleParams.description ?? null, + enabled: rule.enabled, + type: ruleParams.type ?? rule.alertTypeId, + severity: ruleParams.severity ?? null, + risk_score: ruleParams.riskScore ?? null, + tags: rule.tags, + index_patterns: ruleParams.index ?? null, + data_view_id: ruleParams.dataViewId ?? null, + query: ruleParams.query ?? null, + language: ruleParams.language ?? null, + filters: ruleParams.filters ?? null, + threshold: ruleParams.threshold ?? null, + machine_learning_job_id: ruleParams.machineLearningJobId ?? null, + anomaly_threshold: ruleParams.anomalyThreshold ?? null, + threat_query: ruleParams.threatQuery ?? null, + threat_mapping: ruleParams.threatMapping ?? null, + new_terms_fields: ruleParams.newTermsFields ?? null, + history_window_start: ruleParams.historyWindowStart ?? null, + schedule: { interval: rule.schedule?.interval }, + from: ruleParams.from ?? null, + to: ruleParams.to ?? null, + threat: ruleParams.threat ?? [], + actions: rule.actions?.map((a) => ({ + group: a.group, + action_type_id: a.actionTypeId, + })) ?? [], + exceptions_list: ruleParams.exceptionsList ?? [], + created_at: rule.createdAt?.toISOString() ?? null, + updated_at: rule.updatedAt?.toISOString() ?? null, + created_by: rule.createdBy, + revision: rule.revision, + last_outcome: rule.lastRun?.outcome ?? 'unknown', + last_execution_date: rule.executionStatus?.lastExecutionDate?.toISOString() ?? null, + success_ratio: rule.monitoring?.run?.calculated_metrics?.success_ratio ?? null, + }; + + return { + results: [ + { + type: ToolResultType.other, + data: details, + }, + ], + }; + } catch (error) { + logger.error(`get_rule_details tool failed: ${error.message}`); + return { + results: [ + { + type: ToolResultType.error, + data: { message: `Failed to get rule details: ${error.message}` }, + }, + ], + }; + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/index.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/index.ts index d0c73b65c29f8..4b4f37b3201f4 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/index.ts @@ -16,3 +16,23 @@ export { createDetectionRuleTool, SECURITY_CREATE_DETECTION_RULE_TOOL_ID, } from './create_detection_rule_tool'; +export { findRulesTool, SECURITY_FIND_RULES_TOOL_ID } from './find_rules_tool'; +export { getRuleDetailsTool, SECURITY_GET_RULE_DETAILS_TOOL_ID } from './get_rule_details_tool'; +export { + ruleExecutionHistoryTool, + SECURITY_RULE_EXECUTION_HISTORY_TOOL_ID, +} from './rule_execution_history_tool'; +export { ruleGapsTool, SECURITY_RULE_GAPS_TOOL_ID } from './rule_gaps_tool'; +export { rulesHealthTool, SECURITY_RULES_HEALTH_TOOL_ID } from './rules_health_tool'; +export { + coverageOverviewTool, + SECURITY_COVERAGE_OVERVIEW_TOOL_ID, +} from './coverage_overview_tool'; +export { + prebuiltRulesStatusTool, + SECURITY_PREBUILT_RULES_STATUS_TOOL_ID, +} from './prebuilt_rules_status_tool'; +export { + executionStatsTool, + SECURITY_EXECUTION_STATS_TOOL_ID, +} from './execution_stats_tool'; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/prebuilt_rules_status_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/prebuilt_rules_status_tool.ts new file mode 100644 index 0000000000000..b8816459636c1 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/prebuilt_rules_status_tool.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_PREBUILT_RULES_STATUS_TOOL_ID = securityTool('prebuilt_rules_status'); + +const prebuiltRulesStatusSchema = z.object({}); + +const PREBUILT_RULES_FILTER = + 'alert.attributes.consumer: "siem" AND alert.attributes.params.immutable: true'; + +export const prebuiltRulesStatusTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_PREBUILT_RULES_STATUS_TOOL_ID, + type: ToolType.builtin, + description: + 'Get the status of prebuilt (Elastic) detection rules: how many are installed, enabled vs disabled, their health breakdown, and common tags. Use to answer "are our prebuilt rules up to date?", "how many Elastic rules are installed?", "how many prebuilt rules are enabled?".', + schema: prebuiltRulesStatusSchema, + tags: ['security', 'detection', 'rules', 'prebuilt'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (_params, { request }) => { + try { + const [, startPlugins] = await core.getStartServices(); + const rulesClient = await startPlugins.alerting.getRulesClientWithRequest(request); + + let page = 1; + const perPage = 1000; + let totalInstalled = 0; + let enabledCount = 0; + let disabledCount = 0; + const byOutcome: Record = { succeeded: 0, warning: 0, failed: 0 }; + const tagCounts = new Map(); + const failingRules: Array<{ id: string; name: string; error: string }> = []; + + while (true) { + const result = await rulesClient.find({ + options: { + filter: PREBUILT_RULES_FILTER, + perPage, + page, + sortField: 'name', + sortOrder: 'asc', + }, + excludeFromPublicApi: false, + }); + + for (const rule of result.data) { + totalInstalled++; + if (rule.enabled) { + enabledCount++; + } else { + disabledCount++; + } + + const outcome = rule.lastRun?.outcome ?? 'unknown'; + if (outcome in byOutcome) { + byOutcome[outcome]++; + } + + if (outcome === 'failed') { + failingRules.push({ + id: rule.id, + name: rule.name, + error: rule.lastRun?.outcomeMsg?.join('; ') ?? 'No error message', + }); + } + + for (const tag of rule.tags) { + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1); + } + } + + if (result.data.length < perPage) break; + page++; + } + + const topTags = Array.from(tagCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([tag, count]) => ({ tag, count })); + + return { + results: [ + { + type: ToolResultType.other, + data: { + total_installed: totalInstalled, + enabled: enabledCount, + disabled: disabledCount, + by_outcome: byOutcome, + top_tags: topTags, + failing_rules: failingRules.slice(0, 10), + }, + }, + ], + }; + } catch (error) { + logger.error(`prebuilt_rules_status tool failed: ${error.message}`); + return { + results: [ + { + type: ToolResultType.error, + data: { message: `Failed to get prebuilt rules status: ${error.message}` }, + }, + ], + }; + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/register_tools.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/register_tools.ts index a6659db166c43..fab9a12283c12 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/register_tools.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/register_tools.ts @@ -13,6 +13,14 @@ import { attackDiscoverySearchTool } from './attack_discovery_search_tool'; import { entityRiskScoreTool } from './entity_risk_score_tool'; import { alertsTool } from './alerts_tool'; import { createDetectionRuleTool } from './create_detection_rule_tool'; +import { findRulesTool } from './find_rules_tool'; +import { getRuleDetailsTool } from './get_rule_details_tool'; +import { ruleExecutionHistoryTool } from './rule_execution_history_tool'; +import { ruleGapsTool } from './rule_gaps_tool'; +import { rulesHealthTool } from './rules_health_tool'; +import { coverageOverviewTool } from './coverage_overview_tool'; +import { prebuiltRulesStatusTool } from './prebuilt_rules_status_tool'; +import { executionStatsTool } from './execution_stats_tool'; import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; /** @@ -29,4 +37,12 @@ export const registerTools = async ( agentBuilder.tools.register(securityLabsSearchTool(core)); agentBuilder.tools.register(createDetectionRuleTool(core, logger, experimentalFeatures)); agentBuilder.tools.register(alertsTool(core, logger)); + agentBuilder.tools.register(findRulesTool(core, logger)); + agentBuilder.tools.register(getRuleDetailsTool(core, logger)); + agentBuilder.tools.register(ruleExecutionHistoryTool(core, logger)); + agentBuilder.tools.register(ruleGapsTool(core, logger)); + agentBuilder.tools.register(rulesHealthTool(core, logger)); + agentBuilder.tools.register(coverageOverviewTool(core, logger)); + agentBuilder.tools.register(prebuiltRulesStatusTool(core, logger)); + agentBuilder.tools.register(executionStatsTool(core, logger)); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_execution_history_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_execution_history_tool.ts new file mode 100644 index 0000000000000..6a9a232cb8f70 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_execution_history_tool.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_RULE_EXECUTION_HISTORY_TOOL_ID = securityTool('rule_execution_history'); + +const ruleExecutionHistorySchema = z.object({ + rule_id: z.string().describe('The rule ID to get execution history for'), + start: z + .string() + .optional() + .describe('ISO date for the start of the time range (default: 24 hours ago)'), + end: z.string().optional().describe('ISO date for the end of the time range (default: now)'), + sort_field: z + .enum([ + 'timestamp', + 'execution_duration', + 'schedule_delay', + 'num_triggered_actions', + 'num_active_alerts', + 'num_new_alerts', + ]) + .optional() + .describe('Field to sort execution records by (default: timestamp)'), + sort_order: z.enum(['asc', 'desc']).optional().describe('Sort direction (default: desc)'), + page: z.number().int().min(1).optional().describe('Page number (default: 1)'), + per_page: z.number().int().min(1).max(50).optional().describe('Results per page (default: 20)'), +}); + +export const ruleExecutionHistoryTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_RULE_EXECUTION_HISTORY_TOOL_ID, + type: ToolType.builtin, + description: + 'Get the execution history for a specific detection rule. Shows per-run details including status, duration, alert counts, action counts, schedule delay, and error messages. Use to investigate rule performance, find failures, or check if a rule is producing alerts.', + schema: ruleExecutionHistorySchema, + tags: ['security', 'detection', 'rules', 'monitoring', 'execution'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (params, { request }) => { + try { + const [, startPlugins] = await core.getStartServices(); + const rulesClient = await startPlugins.alerting.getRulesClientWithRequest(request); + + const now = new Date(); + const defaultStart = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const sortField = params.sort_field ?? 'timestamp'; + const sortOrder = params.sort_order ?? 'desc'; + + const result = await rulesClient.getExecutionLogForRule({ + id: params.rule_id, + dateStart: params.start ?? defaultStart.toISOString(), + dateEnd: params.end ?? now.toISOString(), + page: params.page ?? 1, + perPage: params.per_page ?? 20, + sort: [{ [sortField]: { order: sortOrder } }], + }); + + const executions = result.data.map((exec) => ({ + id: exec.id, + timestamp: exec.timestamp, + duration_ms: exec.duration_ms, + status: exec.status, + message: exec.message, + num_active_alerts: exec.num_active_alerts, + num_new_alerts: exec.num_new_alerts, + num_recovered_alerts: exec.num_recovered_alerts, + num_triggered_actions: exec.num_triggered_actions, + num_succeeded_actions: exec.num_succeeded_actions, + num_errored_actions: exec.num_errored_actions, + total_search_duration_ms: exec.total_search_duration_ms, + es_search_duration_ms: exec.es_search_duration_ms, + schedule_delay_ms: exec.schedule_delay_ms, + timed_out: exec.timed_out, + })); + + return { + results: [ + { + type: ToolResultType.other, + data: { total: result.total, executions }, + }, + ], + }; + } catch (error) { + logger.error(`rule_execution_history tool failed: ${error.message}`); + return { + results: [ + { + type: ToolResultType.error, + data: { message: `Failed to get execution history: ${error.message}` }, + }, + ], + }; + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_gaps_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_gaps_tool.ts new file mode 100644 index 0000000000000..a3e8531f2946b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rule_gaps_tool.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_RULE_GAPS_TOOL_ID = securityTool('rule_gaps'); + +const ruleGapsSchema = z.object({ + rule_id: z + .string() + .optional() + .describe( + 'If provided, show gaps for this specific rule. If omitted, show which rules have gaps.' + ), + start: z + .string() + .optional() + .describe('ISO date for the start of the time range (default: 7 days ago)'), + end: z.string().optional().describe('ISO date for the end of the time range (default: now)'), + statuses: z + .array(z.enum(['unfilled', 'partially_filled', 'filled'])) + .optional() + .describe('Filter gaps by fill status'), + page: z.number().int().min(1).optional().describe('Page number (default: 1)'), + per_page: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe('Results per page (default: 20)'), +}); + +export const ruleGapsTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_RULE_GAPS_TOOL_ID, + type: ToolType.builtin, + description: + 'Find detection rule coverage gaps. Without a rule_id, returns which rules have gaps and a summary of total unfilled/filled durations. With a rule_id, returns individual gaps for that rule with their status, time range, and fill progress.', + schema: ruleGapsSchema, + tags: ['security', 'detection', 'rules', 'gaps', 'monitoring'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (params, { request }) => { + try { + const [, startPlugins] = await core.getStartServices(); + const rulesClient = await startPlugins.alerting.getRulesClientWithRequest(request); + + const now = new Date(); + const defaultStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const start = params.start ?? defaultStart.toISOString(); + const end = params.end ?? now.toISOString(); + + if (params.rule_id) { + const result = await rulesClient.findGaps({ + ruleId: params.rule_id, + start, + end, + page: params.page ?? 1, + perPage: params.per_page ?? 20, + statuses: params.statuses, + sortField: '@timestamp', + sortOrder: 'desc', + }); + + const gaps = result.data.map((gap) => ({ + id: gap.id, + status: gap.status, + range: gap.range, + total_gap_duration_ms: gap.totalGapDurationMs, + filled_duration_ms: gap.filledDurationMs, + unfilled_duration_ms: gap.unfilledDurationMs, + in_progress_duration_ms: gap.inProgressDurationMs, + })); + + return { + results: [ + { + type: ToolResultType.other, + data: { + rule_id: params.rule_id, + total: result.total, + page: result.page, + per_page: result.perPage, + gaps, + }, + }, + ], + }; + } + + const result = await rulesClient.getRuleIdsWithGaps({ + start, + end, + statuses: params.statuses, + }); + + return { + results: [ + { + type: ToolResultType.other, + data: { + total_rules_with_gaps: result.total, + rule_ids: result.ruleIds, + summary: result.summary, + }, + }, + ], + }; + } catch (error) { + logger.error(`rule_gaps tool failed: ${error.message}`); + return { + results: [ + { + type: ToolResultType.error, + data: { message: `Failed to get gap information: ${error.message}` }, + }, + ], + }; + } + }, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rules_health_tool.ts b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rules_health_tool.ts new file mode 100644 index 0000000000000..9bfd909b35390 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/agent_builder/tools/rules_health_tool.ts @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import { ToolType, ToolResultType } from '@kbn/agent-builder-common'; +import type { BuiltinToolDefinition } from '@kbn/agent-builder-server'; +import type { Logger } from '@kbn/logging'; +import type { RuleLastRunOutcomes } from '@kbn/alerting-types'; +import { getAgentBuilderResourceAvailability } from '../utils/get_agent_builder_resource_availability'; +import type { SecuritySolutionPluginCoreSetupDependencies } from '../../plugin_contract'; +import { securityTool } from './constants'; + +export const SECURITY_RULES_HEALTH_TOOL_ID = securityTool('rules_health'); + +const rulesHealthSchema = z.object({ + interval_start: z + .string() + .optional() + .describe('ISO date for the start of the stats interval (default: 24 hours ago)'), + interval_end: z + .string() + .optional() + .describe('ISO date for the end of the stats interval (default: now)'), +}); + +const SIEM_RULE_FILTER = 'alert.attributes.consumer: "siem"'; + +export const rulesHealthTool = ( + core: SecuritySolutionPluginCoreSetupDependencies, + logger: Logger +): BuiltinToolDefinition => { + return { + id: SECURITY_RULES_HEALTH_TOOL_ID, + type: ToolType.builtin, + description: + 'Get a health overview of all detection rules in the current space. Returns rule counts by outcome (succeeded/warning/failed), execution KPIs (success/failure/warning counts, alert totals, action totals), per-rule performance metrics (p50/p95 duration, success ratio), top failing rules with error messages, and gap summary. Use to answer questions like "how healthy are my rules?", "what are the top errors?", "are rules performing well?". If the interval has more than 10,000 execution events, pass a shorter interval_start/interval_end (e.g. last 1–6 hours).', + schema: rulesHealthSchema, + tags: ['security', 'detection', 'rules', 'health', 'monitoring'], + availability: { + cacheMode: 'space', + handler: async ({ request }) => { + return getAgentBuilderResourceAvailability({ core, request, logger }); + }, + }, + handler: async (params, { request }) => { + try { + const [, startPlugins] = await core.getStartServices(); + const rulesClient = await startPlugins.alerting.getRulesClientWithRequest(request); + + const now = new Date(); + const defaultStart = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const dateStart = params.interval_start ?? defaultStart.toISOString(); + const dateEnd = params.interval_end ?? now.toISOString(); + + const [rulesResult, kpiResult, gapsResult] = await Promise.all([ + rulesClient.find({ + options: { + filter: SIEM_RULE_FILTER, + perPage: 1000, + page: 1, + sortField: 'executionStatus.lastExecutionDate', + sortOrder: 'desc', + }, + excludeFromPublicApi: false, + }), + rulesClient.getGlobalExecutionKpiWithAuth({ + dateStart, + dateEnd, + }), + rulesClient + .getRuleIdsWithGaps({ + start: dateStart, + end: dateEnd, + }) + .catch(() => null), + ]); + + const rules = rulesResult.data; + const byOutcome: Record = { succeeded: 0, warning: 0, failed: 0 }; + let enabledCount = 0; + let disabledCount = 0; + const failingRules: Array<{ id: string; name: string; outcome: string; error: string }> = + []; + let totalSuccessRatio = 0; + let rulesWithMetrics = 0; + const durations: number[] = []; + + for (const rule of rules) { + if (rule.enabled) { + enabledCount++; + } else { + disabledCount++; + } + + const outcome = (rule.lastRun?.outcome ?? 'unknown') as RuleLastRunOutcomes | 'unknown'; + if (outcome in byOutcome) { + byOutcome[outcome]++; + } + + if (outcome === 'failed' || outcome === 'warning') { + failingRules.push({ + id: rule.id, + name: rule.name, + outcome, + error: rule.lastRun?.outcomeMsg?.join('; ') ?? 'No error message', + }); + } + + const metrics = rule.monitoring?.run?.calculated_metrics; + if (metrics) { + totalSuccessRatio += metrics.success_ratio; + rulesWithMetrics++; + if (metrics.p95 != null) { + durations.push(metrics.p95); + } + } + } + + failingRules.sort((a, b) => (a.outcome === 'failed' ? -1 : 1)); + + const avgSuccessRatio = rulesWithMetrics > 0 ? totalSuccessRatio / rulesWithMetrics : null; + + durations.sort((a, b) => a - b); + const p50Index = Math.floor(durations.length * 0.5); + const p95Index = Math.floor(durations.length * 0.95); + + const healthData = { + rules_summary: { + total: rulesResult.total, + enabled: enabledCount, + disabled: disabledCount, + by_outcome: byOutcome, + }, + execution_kpi: { + success: kpiResult.success, + failure: kpiResult.failure, + warning: kpiResult.warning, + total_active_alerts: kpiResult.activeAlerts, + total_new_alerts: kpiResult.newAlerts, + total_recovered_alerts: kpiResult.recoveredAlerts, + total_triggered_actions: kpiResult.triggeredActions, + total_errored_actions: kpiResult.erroredActions, + }, + performance: { + avg_success_ratio: avgSuccessRatio, + p50_p95_duration_ms: + durations.length > 0 ? { p50: durations[p50Index], p95: durations[p95Index] } : null, + }, + top_failing_rules: failingRules.slice(0, 10), + gap_summary: gapsResult + ? { + rules_with_gaps: gapsResult.total, + summary: gapsResult.summary, + } + : null, + }; + + return { + results: [ + { + type: ToolResultType.other, + data: healthData, + }, + ], + }; + } catch (error) { + logger.error(`rules_health tool failed: ${error.message}`); + const limitMsg = + 'Too many execution events in the selected interval. Narrow the time range by setting interval_start and interval_end (e.g. last 1–6 hours) and try again.'; + const message = error?.message?.includes('10,000 documents') + ? limitMsg + : `Failed to get rules health: ${error.message}`; + return { + results: [ + { + type: ToolResultType.error, + data: { message }, + }, + ], + }; + } + }, + }; +};