Skip to main content

security_client_rs/
security.rs

1//! Security detection rules. These are distinct from generic Kibana alerting rules.
2//!
3//! [`QueryRule`] creates KQL or Lucene rules; other rule types can use JSON with
4//! [`Security::create_rule`]. Use [`RuleSelector`] to distinguish a saved-object
5//! ID from the stable rule ID. Imports can succeed for rules while failing for
6//! their dependencies; inspect [`RuleImportResult`] before treating the import
7//! as complete.
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value};
12
13use crate::{
14    Error, ImportFailure, Kibana, Result, Scope, SortOrder,
15    exceptions::ListReference,
16    http::{Method, Raw},
17    pagination::paginated,
18    request::endpoint,
19};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Severity {
24    Low,
25    Medium,
26    High,
27    Critical,
28}
29
30impl Severity {
31    pub(crate) fn as_str(self) -> &'static str {
32        match self {
33            Self::Low => "low",
34            Self::Medium => "medium",
35            Self::High => "high",
36            Self::Critical => "critical",
37        }
38    }
39}
40
41/// A detection rule's risk score, from 0 to 100.
42#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
43#[serde(transparent)]
44pub struct RiskScore(u8);
45
46impl RiskScore {
47    /// Fails with [`Error::InvalidRequest`] above 100.
48    pub fn new(score: u8) -> Result<Self> {
49        if score > 100 {
50            return Err(Error::InvalidRequest(format!(
51                "risk score {score} is above 100"
52            )));
53        }
54        Ok(Self(score))
55    }
56
57    pub fn get(self) -> u8 {
58        self.0
59    }
60}
61
62impl TryFrom<u8> for RiskScore {
63    type Error = Error;
64
65    fn try_from(score: u8) -> Result<Self> {
66        Self::new(score)
67    }
68}
69
70/// When a rule runs and how far back each run searches.
71///
72/// Kibana configures these separately: `interval` is how often the rule runs
73/// and `from` is the start of each run's search window. A window no longer
74/// than the interval leaves gaps between runs whenever a run starts late or
75/// events arrive late, so this type derives `from` as the interval plus an
76/// additional lookback by which consecutive windows overlap.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub struct RuleSchedule {
79    interval: u64,
80    additional_lookback: u64,
81}
82
83impl RuleSchedule {
84    /// Fails with [`Error::InvalidRequest`] unless both durations are whole
85    /// seconds and the interval is at least one second.
86    pub fn new(interval: Duration, additional_lookback: Duration) -> Result<Self> {
87        if interval.subsec_nanos() != 0 || additional_lookback.subsec_nanos() != 0 {
88            return Err(Error::InvalidRequest(
89                "rule schedules use whole seconds".into(),
90            ));
91        }
92        if interval.is_zero() {
93            return Err(Error::InvalidRequest(
94                "a rule interval must be at least one second".into(),
95            ));
96        }
97        Ok(Self {
98            interval: interval.as_secs(),
99            additional_lookback: additional_lookback.as_secs(),
100        })
101    }
102
103    /// Runs every `interval`, with windows overlapping by one minute.
104    pub fn every(interval: Duration) -> Result<Self> {
105        Self::new(interval, Duration::from_secs(60))
106    }
107
108    pub fn interval(self) -> Duration {
109        Duration::from_secs(self.interval)
110    }
111
112    /// How far back each run searches: the interval plus the additional lookback.
113    pub fn lookback(self) -> Duration {
114        Duration::from_secs(self.interval.saturating_add(self.additional_lookback))
115    }
116
117    fn interval_value(self) -> String {
118        date_math(self.interval)
119    }
120
121    fn lookback_value(self) -> String {
122        format!("now-{}", date_math(self.lookback().as_secs()))
123    }
124}
125
126impl Default for RuleSchedule {
127    /// Every five minutes, searching the last six.
128    fn default() -> Self {
129        Self {
130            interval: 300,
131            additional_lookback: 60,
132        }
133    }
134}
135
136/// Seconds in the largest whole Elastic time unit.
137fn date_math(seconds: u64) -> String {
138    match seconds {
139        s if s % 3600 == 0 => format!("{}h", s / 3600),
140        s if s % 60 == 0 => format!("{}m", s / 60),
141        s => format!("{s}s"),
142    }
143}
144
145/// A detection rule returned by Kibana. Type-specific fields are retained in `extra`.
146#[derive(Clone, Debug, Serialize, Deserialize)]
147#[non_exhaustive]
148pub struct DetectionRule {
149    /// Kibana saved-object ID, used by [`RuleSelector::Id`].
150    pub id: String,
151    /// Stable rule identifier used by [`RuleSelector::RuleId`] and exports/imports.
152    pub rule_id: String,
153    pub name: String,
154    pub description: String,
155    pub enabled: bool,
156    pub severity: String,
157    pub risk_score: f64,
158    #[serde(rename = "type")]
159    pub rule_type: String,
160    #[serde(default)]
161    pub query: Option<String>,
162    #[serde(default)]
163    pub tags: Vec<String>,
164    #[serde(default)]
165    pub updated_at: Option<String>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub execution_summary: Option<RuleExecutionSummary>,
168    /// Type-specific and newer fields, including `exceptions_list`.
169    #[serde(flatten)]
170    pub extra: Map<String, Value>,
171}
172
173#[derive(Clone, Debug, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct RuleExecutionSummary {
176    pub last_execution: RuleExecution,
177    #[serde(flatten)]
178    pub extra: Map<String, Value>,
179}
180
181/// The latest execution health. A successful execution need not have produced alerts.
182#[derive(Clone, Debug, Serialize, Deserialize)]
183#[non_exhaustive]
184pub struct RuleExecution {
185    pub status: String,
186    pub date: String,
187    pub message: String,
188    #[serde(flatten)]
189    pub extra: Map<String, Value>,
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "lowercase")]
194pub enum QueryLanguage {
195    Kuery,
196    Lucene,
197}
198
199/// A KQL or Lucene query rule. Rules start disabled unless explicitly enabled.
200///
201/// Other rule types can be created by passing their JSON definition to
202/// [`Security::create_rule`].
203#[derive(Clone, Debug, Serialize)]
204pub struct QueryRule {
205    #[serde(rename = "type")]
206    rule_type: &'static str,
207    name: String,
208    description: String,
209    query: String,
210    language: QueryLanguage,
211    index: Vec<String>,
212    severity: Severity,
213    risk_score: RiskScore,
214    enabled: bool,
215    interval: String,
216    from: String,
217    tags: Vec<String>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    rule_id: Option<String>,
220    #[serde(skip_serializing_if = "Vec::is_empty")]
221    exceptions_list: Vec<ListReference>,
222}
223
224impl QueryRule {
225    /// A disabled KQL rule over `logs-*` with medium severity, on the default
226    /// [`RuleSchedule`]: every 5 minutes, searching the last 6.
227    ///
228    /// ```
229    /// use security_client_rs::security::{QueryRule, RuleSchedule};
230    /// use serde_json::to_value;
231    /// use std::time::Duration;
232    ///
233    /// let rule = QueryRule::new("Failed logins", "Repeated failures", "event.outcome: failure")
234    ///     .schedule(RuleSchedule::every(Duration::from_secs(600))?);
235    /// let body = to_value(rule)?;
236    /// assert_eq!(body["enabled"], false);
237    /// assert_eq!(body["interval"], "10m");
238    /// assert_eq!(body["from"], "now-11m");
239    /// # Ok::<(), Box<dyn std::error::Error>>(())
240    /// ```
241    pub fn new(
242        name: impl Into<String>,
243        description: impl Into<String>,
244        query: impl Into<String>,
245    ) -> Self {
246        Self {
247            rule_type: "query",
248            name: name.into(),
249            description: description.into(),
250            query: query.into(),
251            language: QueryLanguage::Kuery,
252            index: vec!["logs-*".into()],
253            severity: Severity::Medium,
254            risk_score: RiskScore(47),
255            enabled: false,
256            interval: RuleSchedule::default().interval_value(),
257            from: RuleSchedule::default().lookback_value(),
258            tags: Vec::new(),
259            rule_id: None,
260            exceptions_list: Vec::new(),
261        }
262    }
263
264    pub fn language(mut self, language: QueryLanguage) -> Self {
265        self.language = language;
266        self
267    }
268
269    pub fn index<I: IntoIterator<Item = S>, S: Into<String>>(mut self, patterns: I) -> Self {
270        self.index = patterns.into_iter().map(Into::into).collect();
271        self
272    }
273
274    pub fn severity(mut self, severity: Severity) -> Self {
275        self.severity = severity;
276        self
277    }
278
279    pub fn risk_score(mut self, score: RiskScore) -> Self {
280        self.risk_score = score;
281        self
282    }
283
284    pub fn enabled(mut self, enabled: bool) -> Self {
285        self.enabled = enabled;
286        self
287    }
288
289    /// Sets how often the rule runs and how far back each run searches.
290    pub fn schedule(mut self, schedule: RuleSchedule) -> Self {
291        self.interval = schedule.interval_value();
292        self.from = schedule.lookback_value();
293        self
294    }
295
296    /// Sets Kibana's `interval`, such as `15m`, and `from`, such as `now-20m`
297    /// or other date math, directly. Keep `from` at least one interval back,
298    /// or runs leave gaps; prefer [`schedule`](Self::schedule).
299    pub fn custom_schedule(mut self, interval: impl Into<String>, from: impl Into<String>) -> Self {
300        self.interval = interval.into();
301        self.from = from.into();
302        self
303    }
304
305    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(mut self, tags: I) -> Self {
306        self.tags = tags.into_iter().map(Into::into).collect();
307        self
308    }
309
310    /// A stable identifier, generated by Kibana when omitted.
311    pub fn rule_id(mut self, rule_id: impl Into<String>) -> Self {
312        self.rule_id = Some(rule_id.into());
313        self
314    }
315
316    pub fn exceptions_list(mut self, lists: Vec<ListReference>) -> Self {
317        self.exceptions_list = lists;
318        self
319    }
320}
321
322/// Selects a rule by saved-object `id` or by its stable `rule_id`.
323#[derive(Clone, Copy, Debug)]
324pub enum RuleSelector<'a> {
325    Id(&'a str),
326    RuleId(&'a str),
327}
328
329impl<'a> RuleSelector<'a> {
330    fn pair(self) -> (&'static str, &'a str) {
331        match self {
332            Self::Id(v) => ("id", v),
333            Self::RuleId(v) => ("rule_id", v),
334        }
335    }
336}
337
338#[derive(Clone, Debug, Deserialize, Serialize)]
339#[non_exhaustive]
340pub struct RulePage {
341    pub data: Vec<DetectionRule>,
342    pub page: u32,
343    #[serde(rename = "perPage", alias = "per_page")]
344    pub per_page: u32,
345    pub total: u64,
346}
347
348/// HTTP 200 can contain failed imports. `success` covers only rules; inspect
349/// exception and connector outcomes separately.
350#[derive(Clone, Debug, Deserialize, Serialize)]
351#[non_exhaustive]
352pub struct RuleImportResult {
353    pub success: bool,
354    pub success_count: u64,
355    #[serde(default)]
356    pub errors: Vec<ImportFailure>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub exceptions_success: Option<bool>,
359    #[serde(default)]
360    pub exceptions_errors: Vec<ImportFailure>,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub action_connectors_success: Option<bool>,
363    #[serde(default)]
364    pub action_connectors_errors: Vec<ImportFailure>,
365    #[serde(flatten)]
366    pub extra: Map<String, Value>,
367}
368
369#[derive(Clone, Copy, Debug)]
370pub struct Security<'a>(pub(crate) &'a Kibana);
371
372impl<'a> Security<'a> {
373    /// The current user's detection-engine privileges.
374    pub fn privileges(&self) -> GetPrivileges<'a> {
375        GetPrivileges(self.0.request(
376            Method::GET,
377            Scope::Space,
378            &["api", "detection_engine", "privileges"],
379        ))
380    }
381
382    /// Creates the space's alert index and mappings if they do not exist.
383    pub fn create_alerts_index(&self) -> CreateAlertsIndex<'a> {
384        CreateAlertsIndex(self.0.request(
385            Method::POST,
386            Scope::Space,
387            &["api", "detection_engine", "index"],
388        ))
389    }
390
391    /// One page of rules. Page through `total` to read the whole collection.
392    pub fn find_rules(&self) -> FindRules<'a> {
393        FindRules(self.0.request(
394            Method::GET,
395            Scope::Space,
396            &["api", "detection_engine", "rules", "_find"],
397        ))
398    }
399
400    pub fn get_rule(&self, rule: RuleSelector<'_>) -> GetRule<'a> {
401        GetRule(
402            self.0
403                .request(
404                    Method::GET,
405                    Scope::Space,
406                    &["api", "detection_engine", "rules"],
407                )
408                .selector(rule.pair()),
409        )
410    }
411
412    /// Creates a rule from a [`QueryRule`] or any rule definition serializable as JSON.
413    pub fn create_rule<B: Serialize + ?Sized>(&self, rule: &B) -> CreateRule<'a> {
414        CreateRule(
415            self.0
416                .request(
417                    Method::POST,
418                    Scope::Space,
419                    &["api", "detection_engine", "rules"],
420                )
421                .json(rule),
422        )
423    }
424
425    /// Changes only the fields set on the returned builder. The selector is
426    /// sent in the body and protected by checked setters. Deliberate overrides
427    /// use [`PatchRule::unchecked_field`] or a replacement raw request body.
428    pub fn patch_rule(&self, rule: RuleSelector<'_>) -> PatchRule<'a> {
429        let (key, value) = rule.pair();
430        PatchRule(
431            self.0
432                .request(
433                    Method::PATCH,
434                    Scope::Space,
435                    &["api", "detection_engine", "rules"],
436                )
437                .nonempty(key, value)
438                .field(key, value),
439        )
440    }
441
442    pub fn delete_rule(&self, rule: RuleSelector<'_>) -> DeleteRule<'a> {
443        DeleteRule(
444            self.0
445                .request(
446                    Method::DELETE,
447                    Scope::Space,
448                    &["api", "detection_engine", "rules"],
449                )
450                .selector(rule.pair()),
451        )
452    }
453
454    /// Exports rules as NDJSON. Without [`ExportRules::rule_ids`], every rule is exported.
455    pub fn export_rules(&self) -> ExportRules<'a> {
456        ExportRules(self.0.request(
457            Method::POST,
458            Scope::Space,
459            &["api", "detection_engine", "rules", "_export"],
460        ))
461    }
462
463    /// Imports an NDJSON export. Partial failures are reported with HTTP 200.
464    pub fn import_rules(&self, ndjson: impl Into<Vec<u8>>) -> ImportRules<'a> {
465        ImportRules(
466            self.0
467                .request(
468                    Method::POST,
469                    Scope::Space,
470                    &["api", "detection_engine", "rules", "_import"],
471                )
472                .file("rules.ndjson", "application/x-ndjson", ndjson.into()),
473        )
474    }
475}
476
477endpoint! {
478    /// `GET /api/detection_engine/privileges`
479    GetPrivileges => Value
480}
481
482endpoint! {
483    /// `POST /api/detection_engine/index`
484    CreateAlertsIndex => Value
485}
486
487endpoint! {
488    /// `GET /api/detection_engine/rules/_find`
489    FindRules => RulePage
490}
491
492impl FindRules<'_> {
493    /// One-based page number.
494    pub fn page(self, page: u32) -> Self {
495        Self(self.0.positive_param("page", page))
496    }
497
498    pub fn per_page(self, per_page: u32) -> Self {
499        Self(self.0.positive_param("per_page", per_page))
500    }
501
502    /// A KQL filter over rule attributes, for example `alert.attributes.enabled: true`.
503    pub fn filter(self, filter: &str) -> Self {
504        Self(self.0.param("filter", filter))
505    }
506
507    pub fn sort_field(self, field: &str) -> Self {
508        Self(self.0.param("sort_field", field))
509    }
510
511    pub fn sort_order(self, order: SortOrder) -> Self {
512        Self(self.0.param("sort_order", order.as_str()))
513    }
514}
515
516endpoint! {
517    /// `GET /api/detection_engine/rules`
518    GetRule => DetectionRule
519}
520
521endpoint! {
522    /// `POST /api/detection_engine/rules`
523    CreateRule => DetectionRule
524}
525
526endpoint! {
527    /// `PATCH /api/detection_engine/rules`
528    PatchRule => DetectionRule
529}
530
531/// Fields [`PatchRule::field`] refuses: the selectors, and values with validated setters.
532const RESERVED_PATCH_FIELDS: &[&str] = &[
533    "id",
534    "rule_id",
535    "risk_score",
536    "severity",
537    "interval",
538    "from",
539];
540
541impl PatchRule<'_> {
542    pub fn name(self, name: &str) -> Self {
543        Self(self.0.field("name", name))
544    }
545
546    pub fn description(self, description: &str) -> Self {
547        Self(self.0.field("description", description))
548    }
549
550    pub fn enabled(self, enabled: bool) -> Self {
551        Self(self.0.field("enabled", enabled))
552    }
553
554    pub fn query(self, query: &str) -> Self {
555        Self(self.0.field("query", query))
556    }
557
558    pub fn severity(self, severity: Severity) -> Self {
559        Self(self.0.field("severity", severity))
560    }
561
562    pub fn risk_score(self, score: RiskScore) -> Self {
563        Self(self.0.field("risk_score", score))
564    }
565
566    pub fn tags<I: IntoIterator<Item = S>, S: Into<String>>(self, tags: I) -> Self {
567        let tags: Vec<String> = tags.into_iter().map(Into::into).collect();
568        Self(self.0.field("tags", tags))
569    }
570
571    /// Replaces every list association. An empty vector detaches all lists.
572    pub fn exceptions_list(self, lists: Vec<ListReference>) -> Self {
573        Self(self.0.field("exceptions_list", lists))
574    }
575
576    /// Changes how often the rule runs and how far back each run searches.
577    pub fn schedule(self, schedule: RuleSchedule) -> Self {
578        Self(
579            self.0
580                .field("interval", schedule.interval_value())
581                .field("from", schedule.lookback_value()),
582        )
583    }
584
585    /// Sets any other patchable rule field. The selectors `id` and `rule_id`,
586    /// and `risk_score`, `severity`, `interval` and `from`, which have typed setters, fail
587    /// with [`Error::InvalidRequest`]; use [`unchecked_field`](Self::unchecked_field)
588    /// to send them anyway.
589    /// Errors are returned by `send`, before HTTP, even if later setters replace
590    /// the offending field.
591    ///
592    /// ```
593    /// use security_client_rs::{Error, Kibana, http::Transport, security::RuleSelector};
594    ///
595    /// # #[tokio::main]
596    /// # async fn main() -> security_client_rs::Result<()> {
597    /// let client = Kibana::new(Transport::single_node("http://127.0.0.1:9")?);
598    /// let patch = client.security().patch_rule(RuleSelector::RuleId("failed-logins"));
599    /// let error = patch.field("rule_id", "another-rule").send().await.unwrap_err();
600    /// assert!(matches!(error, Error::InvalidRequest(_))); // No request is sent.
601    /// # Ok(())
602    /// # }
603    /// ```
604    pub fn field(self, name: &str, value: impl Serialize) -> Self {
605        if RESERVED_PATCH_FIELDS.contains(&name) {
606            return Self(self.0.invalid(format!(
607                "{name:?} is set by the selector or a typed setter; use unchecked_field to override it"
608            )));
609        }
610        Self(self.0.field(name, value))
611    }
612
613    /// Sets any field without checks, including one that replaces the selector
614    /// and changes which rule is patched.
615    pub fn unchecked_field(self, name: &str, value: impl Serialize) -> Self {
616        Self(self.0.field(name, value))
617    }
618}
619
620endpoint! {
621    /// `DELETE /api/detection_engine/rules`
622    DeleteRule => DetectionRule
623}
624
625endpoint! {
626    /// `POST /api/detection_engine/rules/_export`
627    ExportRules => Raw
628}
629
630impl ExportRules<'_> {
631    /// Exports only these stable `rule_id` values.
632    pub fn rule_ids<I: IntoIterator<Item = S>, S: AsRef<str>>(self, rule_ids: I) -> Self {
633        let objects: Vec<Value> = rule_ids
634            .into_iter()
635            .map(|id| serde_json::json!({ "rule_id": id.as_ref() }))
636            .collect();
637        Self(self.0.field("objects", objects))
638    }
639
640    /// Omits the trailing export summary line.
641    pub fn exclude_export_details(self, exclude: bool) -> Self {
642        Self(self.0.param("exclude_export_details", exclude))
643    }
644
645    pub fn file_name(self, name: &str) -> Self {
646        Self(self.0.param("file_name", name))
647    }
648}
649
650endpoint! {
651    /// `POST /api/detection_engine/rules/_import`
652    ImportRules => RuleImportResult
653}
654
655impl ImportRules<'_> {
656    /// Replaces existing rules with the same `rule_id`.
657    pub fn overwrite(self, overwrite: bool) -> Self {
658        Self(self.0.param("overwrite", overwrite))
659    }
660
661    pub fn overwrite_exceptions(self, overwrite: bool) -> Self {
662        Self(self.0.param("overwrite_exceptions", overwrite))
663    }
664
665    pub fn overwrite_action_connectors(self, overwrite: bool) -> Self {
666        Self(self.0.param("overwrite_action_connectors", overwrite))
667    }
668
669    /// Imports referenced exception lists under new list IDs.
670    pub fn as_new_list(self, enabled: bool) -> Self {
671        Self(self.0.param("as_new_list", enabled))
672    }
673}
674
675paginated!(FindRules => RulePage);