elasticctl 0.10.0

Operate Elastic Security as code with a safety-first CLI for security engineers.
mod value_lists;
pub use value_lists::{ValueListPreview, check_value_lists};

use crate::context::Context;
use elasticctl_core::Resolved;

pub(crate) const MUTATING: [&str; 34] = [
    "rules enable",
    "rules disable",
    "rules delete",
    "rules import",
    "rules prebuilt install",
    "exceptions delete",
    "exceptions import",
    "value-lists import",
    "value-lists delete",
    "value-lists init",
    "data-views import",
    "data-views delete",
    "data-views default set",
    "data-views default unset",
    "dashboards import",
    "dashboards delete",
    "dashboards bundle import",
    "fleet agent-policies import",
    "fleet agent-policies delete",
    "fleet integration-policies import",
    "fleet integration-policies delete",
    "state push",
    "config init",
    "alerts ack",
    "alerts open",
    "alerts close",
    "alerts tag",
    "alerts assign",
    "cases create",
    "cases close",
    "cases open",
    "cases delete",
    "cases attach",
    "cases comment",
];

pub struct Preview {
    pub action: String,
    pub details: Vec<String>,
}

pub fn banner(resolved: &Resolved) -> String {
    format!("({})", resolved.banner())
}

pub fn preview_text(preview: &Preview, resolved: &Resolved, applying: bool) -> String {
    let tag = banner(resolved);
    let mut out = if applying {
        format!("Applying: {} {tag}\n", preview.action)
    } else {
        format!("[DRY RUN] {} {tag}\n", preview.action)
    };
    for d in &preview.details {
        out.push_str("  ");
        out.push_str(d);
        out.push('\n');
    }
    if !applying {
        out.push_str("Pass --yes to apply.\n");
    }
    out
}

pub fn check(ctx: &Context, path: &'static str, preview: &Preview) -> bool {
    assert!(
        MUTATING.contains(&path),
        "guard::check called from \"{path}\", which is not in MUTATING — \
         a mutating command must be declared there or it ships mutates:false \
         in the command tree"
    );
    let applying = ctx.global.yes;
    eprint!("{}", preview_text(preview, &ctx.resolved, applying));
    applying
}

#[cfg(test)]
mod tests {
    use super::*;
    use elasticctl_core::{Profile, Resolved, Source};

    fn resolved(name: &str, host: &str, space: &str) -> Resolved {
        Resolved {
            name: name.into(),
            source: Source::Profile,
            profile: Profile {
                kibana_url: format!("https://{host}"),
                es_url: None,
                api_key: Some("essu_secret".into()),
                username: None,
                password: None,
                space: space.into(),
                verify: true,
                timeout_secs: 30,
            },
        }
    }

    #[test]
    fn banner_names_profile_host_and_space() {
        let b = banner(&resolved("prod", "kibana.corp.internal", "soc"));
        assert!(b.contains("prod"), "{b}");
        assert!(b.contains("kibana.corp.internal"), "{b}");
        assert!(b.contains("soc"), "{b}");
    }

    #[test]
    fn banner_never_leaks_the_credential() {
        let b = banner(&resolved("prod", "kibana.corp.internal", "default"));
        assert!(
            !b.contains("essu_secret"),
            "the banner must not print the key: {b}"
        );
    }

    #[test]
    fn preview_text_lists_the_action_and_every_detail() {
        let p = Preview {
            action: "Disable 2 rules".into(),
            details: vec![
                "a  Alpha  enabled -> disabled".into(),
                "b  Beta  enabled -> disabled".into(),
            ],
        };
        let text = preview_text(&p, &resolved("uat", "uat.example.com", "default"), false);
        assert!(text.starts_with("[DRY RUN]"), "{text}");
        assert!(text.contains("Disable 2 rules"));
        assert!(text.contains("Alpha") && text.contains("Beta"));
        assert!(text.contains("Pass --yes to apply."));
    }

    #[test]
    fn apply_text_drops_the_dry_run_marker_and_the_hint() {
        let p = Preview {
            action: "Disable 2 rules".into(),
            details: vec![],
        };
        let text = preview_text(&p, &resolved("uat", "uat.example.com", "default"), true);
        assert!(text.starts_with("Applying:"), "{text}");
        assert!(
            !text.contains("--yes"),
            "the hint is pointless once applying: {text}"
        );
    }

    #[test]
    fn both_modes_carry_the_target_banner() {
        let p = Preview {
            action: "Delete 1 rule".into(),
            details: vec![],
        };
        let r = resolved("prod", "prod.example.com", "default");
        for applying in [false, true] {
            let text = preview_text(&p, &r, applying);
            assert!(
                text.contains("prod"),
                "target must be named in both modes: {text}"
            );
            assert!(text.contains("prod.example.com"), "{text}");
        }
    }

    #[test]
    #[should_panic(expected = "rules touch")]
    fn check_rejects_an_undeclared_mutating_path() {
        let dir = tempfile::tempdir().unwrap();
        let config = dir.path().join("config.toml");
        std::fs::write(
            &config,
            "current = \"test\"\n\n\
             [profiles.test]\n\
             kibana_url = \"https://kb.example.com\"\n\
             space = \"default\"\n\
             verify = true\n\
             timeout_secs = 30\n",
        )
        .unwrap();
        let global = crate::cli::GlobalArgs {
            config: Some(config),
            ..Default::default()
        };
        let ctx = crate::context::Context::build(&global).unwrap();
        let preview = Preview {
            action: "Touch 1 rule".into(),
            details: vec![],
        };
        let _ = check(&ctx, "rules touch", &preview);
    }
}