rsigma 0.17.0

CLI for parsing, validating, linting and evaluating Sigma detection rules
//! The visibility ATT&CK Navigator layer (format 4.5).
//!
//! Built on the shared [`crate::commands::navigator`] structs so it cannot
//! drift from the `rule coverage` detection layer. The two are deliberately
//! distinct: this layer scores each technique by visibility level (0-4, "how
//! well you can see activity for it") rather than rule count, so loading both
//! in Navigator exposes the cells where you have data but no detection (or
//! detection but no data).

pub(crate) use crate::commands::navigator::{DOMAIN, to_pretty_json};
use crate::commands::navigator::{Gradient, Layer, NavTechnique, Versions};

use super::analysis::{VisibilityAnalysis, level_name};

/// Maximum visibility score, the gradient ceiling.
const MAX_SCORE: u64 = 4;

/// Build a visibility Navigator layer from the analysis. `name` is the layer's
/// display name in the Navigator tab.
pub(crate) fn build_layer(analysis: &VisibilityAnalysis, name: &str) -> Layer {
    let techniques: Vec<NavTechnique> = analysis
        .techniques
        .iter()
        .map(|t| NavTechnique {
            technique_id: t.technique_id.clone(),
            score: u64::from(t.score),
            comment: format!(
                "{} visibility via {}",
                level_name(t.score),
                t.data_sources.join(", ")
            ),
            enabled: true,
            show_subtechniques: false,
        })
        .collect();

    Layer {
        name: name.to_string(),
        versions: Versions::current(),
        domain: DOMAIN,
        description: format!(
            "Telemetry visibility generated by rsigma; score = DeTT&CT visibility level 0-4 per technique ({} techniques).",
            techniques.len()
        ),
        sorting: 3, // descending by score
        hide_disabled: false,
        gradient: Gradient {
            // Low visibility (red) to full visibility (green).
            colors: vec!["#ff6666", "#ffe766", "#8ec843"],
            min_value: 0,
            max_value: MAX_SCORE,
        },
        techniques,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::visibility::analysis::{TechniqueVisibility, VisibilityAnalysis};

    fn analysis_with(techniques: Vec<TechniqueVisibility>) -> VisibilityAnalysis {
        VisibilityAnalysis {
            data_sources: vec![],
            techniques,
            untapped: vec![],
            unmapped_logsources: vec![],
            rules_total: 0,
            logsources_total: 0,
            events_observed: 0,
            observed_unique_keys: 0,
            has_observed: true,
        }
    }

    #[test]
    fn layer_scores_by_visibility_level_and_pins_format() {
        let a = analysis_with(vec![TechniqueVisibility {
            technique_id: "T1059".into(),
            score: 3,
            data_sources: vec!["Process".into(), "Script".into()],
        }]);
        let layer = build_layer(&a, "test");
        let json = to_pretty_json(&layer);
        assert!(json.contains("\"layer\": \"4.5\""));
        assert!(json.contains("\"techniqueID\": \"T1059\""));
        assert!(json.contains("\"score\": 3"));
        // Gradient is pinned to the 0-4 visibility range, not the rule count.
        assert_eq!(layer.gradient.max_value, 4);
    }
}