elasticctl-api 0.8.2

Typed detection-rule model and endpoint wrappers for Elastic Security.
Documentation
//! Triage context for one alert: the alert, the rule that fired it, the
//! rule's exception lists, the cases that contain the alert, and the rule's
//! alert volume in a window.
use super::{
    AlertActivity, ExceptionCoverage, MAX_RELATED_CASES, Part, RuleSnapshot, SkipReason,
    alert_activity, degrade, exception_coverage, is_fatal, is_path_segment, snapshot_by_id,
    validate_since,
};
use crate::{
    alerts::AlertHit,
    alerts_ops,
    cases::{self, Case},
};
use elasticctl_core::{Error, ErrorKind, Result, Transport};
use serde_json::Value;

/// One case that contains the alert. `detail` is the case read by id.
#[derive(Debug)]
pub struct RelatedCase {
    pub id: String,
    pub title: Option<String>,
    pub detail: std::result::Result<Case, Error>,
}

/// The cases route answer: `total` rows, of which the first five are read.
#[derive(Debug)]
pub struct RelatedCases {
    pub total: usize,
    pub truncated: bool,
    pub cases: Vec<RelatedCase>,
}

#[derive(Debug)]
pub struct AlertContext {
    pub alert: AlertHit,
    pub rule: Part<RuleSnapshot>,
    pub exceptions: Part<ExceptionCoverage>,
    pub cases: Part<RelatedCases>,
    pub activity: Part<AlertActivity>,
}

/// Static findings, in the order a report lists them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AlertFinding {
    AlertNotOpen,
    RuleMissing,
    RuleDisabled,
    RuleLastExecutionFailed,
    ExceptionListMissing,
    HasRelatedCase,
}

/// A source field read flat first, then as a nested object path.
fn source_str<'a>(source: &'a Value, key: &str) -> Option<&'a str> {
    let pointer = format!("/{}", key.replace('.', "/"));
    match source.get(key) {
        Some(v) => v.as_str(),
        None => source.pointer(&pointer).and_then(Value::as_str),
    }
}

fn unavailable<T>(part: &Part<T>) -> bool {
    matches!(part, Part::Unavailable(_))
}

impl AlertContext {
    pub fn findings(&self) -> Vec<AlertFinding> {
        let mut out = Vec::new();
        if source_str(&self.alert.source, "kibana.alert.workflow_status")
            .is_some_and(|s| s != "open")
        {
            out.push(AlertFinding::AlertNotOpen);
        }
        if matches!(&self.rule, Part::Unavailable(e) if e.kind == ErrorKind::NotFound) {
            out.push(AlertFinding::RuleMissing);
        }
        if let Part::Ok(snapshot) = &self.rule {
            if snapshot
                .rule
                .as_map()
                .get("enabled")
                .and_then(Value::as_bool)
                == Some(false)
            {
                out.push(AlertFinding::RuleDisabled);
            }
            if snapshot
                .execution
                .as_ref()
                .is_some_and(|e| e.status == "failed")
            {
                out.push(AlertFinding::RuleLastExecutionFailed);
            }
        }
        if let Part::Ok(coverage) = &self.exceptions
            && coverage
                .lists
                .iter()
                .any(|l| matches!(&l.facts, Err(e) if e.kind == ErrorKind::NotFound))
        {
            out.push(AlertFinding::ExceptionListMissing);
        }
        if matches!(&self.cases, Part::Ok(c) if c.total > 0) {
            out.push(AlertFinding::HasRelatedCase);
        }
        out
    }

    /// True when any part or row is unavailable.
    pub fn partial(&self) -> bool {
        let list_rows =
            matches!(&self.exceptions, Part::Ok(c) if c.lists.iter().any(|l| l.facts.is_err()));
        let case_rows =
            matches!(&self.cases, Part::Ok(c) if c.cases.iter().any(|r| r.detail.is_err()));
        unavailable(&self.rule)
            || unavailable(&self.exceptions)
            || unavailable(&self.cases)
            || unavailable(&self.activity)
            || list_rows
            || case_rows
    }
}

async fn related_cases(t: &Transport, alert_id: &str) -> Result<RelatedCases> {
    let refs = cases::by_alert(t, alert_id).await?;
    let total = refs.len();
    let mut rows = Vec::new();
    for r in refs.into_iter().take(MAX_RELATED_CASES) {
        let detail = cases::get(t, &r.id).await;
        if let Err(e) = &detail
            && is_fatal(e.kind)
        {
            return Err(detail.unwrap_err());
        }
        rows.push(RelatedCase {
            id: r.id,
            title: r.title,
            detail,
        });
    }
    Ok(RelatedCases {
        total,
        truncated: total > MAX_RELATED_CASES,
        cases: rows,
    })
}

fn validate_rule_context(snapshot: RuleSnapshot) -> Result<RuleSnapshot> {
    let invalid = || Error::new(ErrorKind::Http, "invalid rule context field");
    fn optional<T: serde::de::DeserializeOwned>(
        map: &serde_json::Map<String, Value>,
        key: &str,
    ) -> Result<()> {
        if let Some(value) = map.get(key).filter(|v| !v.is_null()) {
            serde_json::from_value::<T>(value.clone())
                .map_err(|_| Error::new(ErrorKind::Http, "invalid rule context field"))?;
        }
        Ok(())
    }
    let map = snapshot.rule.as_map();
    if !map.get("rule_id").is_some_and(Value::is_string) {
        return Err(invalid());
    }
    for key in [
        "name",
        "type",
        "severity",
        "description",
        "language",
        "query",
        "from",
        "interval",
    ] {
        optional::<String>(map, key)?;
    }
    optional::<bool>(map, "enabled")?;
    optional::<serde_json::Number>(map, "risk_score")?;
    for key in ["tags", "index"] {
        optional::<Vec<String>>(map, key)?;
    }
    if let Some(value) = map.get("exceptions_list").filter(|v| !v.is_null()) {
        for entry in value.as_array().ok_or_else(invalid)? {
            let entry = entry.as_object().ok_or_else(invalid)?;
            for key in ["list_id", "namespace_type", "type"] {
                optional::<String>(entry, key)?;
            }
        }
    }
    Ok(snapshot)
}

/// Read the alert, then its rule, the rule's exception coverage, the cases
/// that contain the alert, and the rule's alert activity, one request at a
/// time. The alert read is primary; every later read is a part.
pub async fn alert_context(t: &Transport, alert_id: &str, since: &str) -> Result<AlertContext> {
    validate_since(since)?;
    if !is_path_segment(alert_id) {
        return Err(Error::new(ErrorKind::Error, "invalid alert id"));
    }
    let alert = alerts_ops::get_one(t, alert_id).await?;
    let rule_id = source_str(&alert.source, "kibana.alert.rule.rule_id").map(str::to_owned);
    let rule = match &rule_id {
        Some(id) => degrade(snapshot_by_id(t, id).await.and_then(validate_rule_context))?,
        None => Part::Skipped(SkipReason::NotApplicable),
    };
    let exceptions = match &rule {
        Part::Ok(snapshot) => degrade(exception_coverage(t, &snapshot.rule).await)?,
        _ => Part::Skipped(SkipReason::DependencyUnavailable),
    };
    let cases = degrade(related_cases(t, alert_id).await)?;
    let activity = match (&rule, &rule_id) {
        (Part::Ok(_), Some(id)) => degrade(alert_activity(t, id, since, 0).await)?,
        _ => Part::Skipped(SkipReason::DependencyUnavailable),
    };
    Ok(AlertContext {
        alert,
        rule,
        exceptions,
        cases,
        activity,
    })
}