assay-cli 3.13.0

CLI for Assay
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use super::model::{Entry, Policy};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct EntryFingerprint {
    count: Option<u32>,
    stability_bps: Option<i64>,
    runs_seen: Option<u32>,
    risk: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct EntryChange {
    pub(super) pattern: String,
    pub(super) old: EntryFingerprint,
    pub(super) new: EntryFingerprint,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct SectionDiff {
    pub(super) added: Vec<String>,
    pub(super) removed: Vec<String>,
    pub(super) changed: Vec<EntryChange>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct PolicyDiff {
    pub(super) files_allow: SectionDiff,
    pub(super) files_review: SectionDiff,
    pub(super) files_deny: SectionDiff,
    pub(super) network_allow: SectionDiff,
    pub(super) network_review: SectionDiff,
    pub(super) network_deny: SectionDiff,
    pub(super) processes_allow: SectionDiff,
    pub(super) processes_review: SectionDiff,
    pub(super) processes_deny: SectionDiff,
}

impl PolicyDiff {
    fn summary_counts(&self) -> (usize, usize, usize) {
        let sections = [
            &self.files_allow,
            &self.files_review,
            &self.files_deny,
            &self.network_allow,
            &self.network_review,
            &self.network_deny,
            &self.processes_allow,
            &self.processes_review,
            &self.processes_deny,
        ];
        let added = sections.iter().map(|s| s.added.len()).sum();
        let removed = sections.iter().map(|s| s.removed.len()).sum();
        let changed = sections.iter().map(|s| s.changed.len()).sum();
        (added, removed, changed)
    }

    pub(super) fn is_empty(&self) -> bool {
        self.summary_counts() == (0, 0, 0)
    }
}

pub(super) fn parse_existing_policy(path: &PathBuf) -> Result<Policy> {
    let raw = std::fs::read_to_string(path)?;
    let ext = path
        .extension()
        .and_then(|s| s.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    if ext == "json" {
        return Ok(serde_json::from_str(&raw)?);
    }
    match serde_yaml::from_str(&raw) {
        Ok(p) => Ok(p),
        Err(_) => Ok(serde_json::from_str(&raw)?),
    }
}

fn entry_pattern(entry: &Entry) -> String {
    match entry {
        Entry::Simple(s) => s.clone(),
        Entry::WithMeta { pattern, .. } => pattern.clone(),
    }
}

fn fp_stability_bps(v: Option<f64>) -> Option<i64> {
    v.map(|x| (x * 10000.0).round() as i64)
}

fn entry_fingerprint(entry: &Entry) -> EntryFingerprint {
    match entry {
        Entry::Simple(_) => EntryFingerprint {
            count: None,
            stability_bps: None,
            runs_seen: None,
            risk: None,
        },
        Entry::WithMeta {
            count,
            stability,
            runs_seen,
            risk,
            ..
        } => EntryFingerprint {
            count: *count,
            stability_bps: fp_stability_bps(*stability),
            runs_seen: *runs_seen,
            risk: risk.clone(),
        },
    }
}

fn diff_entries(old: &[Entry], new: &[Entry]) -> SectionDiff {
    let old_map: BTreeMap<String, EntryFingerprint> = old
        .iter()
        .map(|e| (entry_pattern(e), entry_fingerprint(e)))
        .collect();
    let new_map: BTreeMap<String, EntryFingerprint> = new
        .iter()
        .map(|e| (entry_pattern(e), entry_fingerprint(e)))
        .collect();

    let mut out = SectionDiff::default();
    for (pattern, new_fp) in &new_map {
        match old_map.get(pattern) {
            None => out.added.push(pattern.clone()),
            Some(old_fp) if old_fp != new_fp => out.changed.push(EntryChange {
                pattern: pattern.clone(),
                old: old_fp.clone(),
                new: new_fp.clone(),
            }),
            _ => {}
        }
    }
    for pattern in old_map.keys() {
        if !new_map.contains_key(pattern) {
            out.removed.push(pattern.clone());
        }
    }
    out
}

fn diff_string_lists(old: &[String], new: &[String]) -> SectionDiff {
    let old_set: BTreeMap<String, EntryFingerprint> = old
        .iter()
        .cloned()
        .map(|s| {
            (
                s,
                EntryFingerprint {
                    count: None,
                    stability_bps: None,
                    runs_seen: None,
                    risk: None,
                },
            )
        })
        .collect();
    let new_set: BTreeMap<String, EntryFingerprint> = new
        .iter()
        .cloned()
        .map(|s| {
            (
                s,
                EntryFingerprint {
                    count: None,
                    stability_bps: None,
                    runs_seen: None,
                    risk: None,
                },
            )
        })
        .collect();

    let mut out = SectionDiff::default();
    for pattern in new_set.keys() {
        if !old_set.contains_key(pattern) {
            out.added.push(pattern.clone());
        }
    }
    for pattern in old_set.keys() {
        if !new_set.contains_key(pattern) {
            out.removed.push(pattern.clone());
        }
    }
    out
}

pub(super) fn diff_policies(old: &Policy, new: &Policy) -> PolicyDiff {
    PolicyDiff {
        files_allow: diff_entries(&old.files.allow, &new.files.allow),
        files_review: diff_entries(&old.files.needs_review, &new.files.needs_review),
        files_deny: diff_string_lists(&old.files.deny, &new.files.deny),
        network_allow: diff_entries(
            &old.network.allow_destinations,
            &new.network.allow_destinations,
        ),
        network_review: diff_entries(&old.network.needs_review, &new.network.needs_review),
        network_deny: diff_string_lists(
            &old.network.deny_destinations,
            &new.network.deny_destinations,
        ),
        processes_allow: diff_entries(&old.processes.allow, &new.processes.allow),
        processes_review: diff_entries(&old.processes.needs_review, &new.processes.needs_review),
        processes_deny: diff_string_lists(&old.processes.deny, &new.processes.deny),
    }
}

fn print_section_diff(label: &str, diff: &SectionDiff) {
    if diff.added.is_empty() && diff.removed.is_empty() && diff.changed.is_empty() {
        return;
    }
    eprintln!("  {}:", label);
    for v in &diff.added {
        eprintln!("    + {}", v);
    }
    for v in &diff.removed {
        eprintln!("    - {}", v);
    }
    for c in &diff.changed {
        eprintln!("    ~ {}", c.pattern);
    }
}

pub(super) fn print_policy_diff(diff: &PolicyDiff, output_path: &Path) {
    eprintln!();
    eprintln!("Policy diff ({} -> generated):", output_path.display());
    if diff.is_empty() {
        eprintln!("  (no changes)");
        return;
    }
    print_section_diff("files.allow", &diff.files_allow);
    print_section_diff("files.needs_review", &diff.files_review);
    print_section_diff("files.deny", &diff.files_deny);
    print_section_diff("network.allow_destinations", &diff.network_allow);
    print_section_diff("network.needs_review", &diff.network_review);
    print_section_diff("network.deny_destinations", &diff.network_deny);
    print_section_diff("processes.allow", &diff.processes_allow);
    print_section_diff("processes.needs_review", &diff.processes_review);
    print_section_diff("processes.deny", &diff.processes_deny);
    let (added, removed, changed) = diff.summary_counts();
    eprintln!();
    eprintln!(
        "  Summary: +{} added, -{} removed, ~{} changed",
        added, removed, changed
    );
}

#[cfg(test)]
mod tests {
    use super::diff_policies;
    use crate::cli::commands::generate::model::{Entry, Policy};

    fn e(pattern: &str, count: Option<u32>, stability: Option<f64>) -> Entry {
        Entry::WithMeta {
            pattern: pattern.to_string(),
            count,
            stability,
            runs_seen: None,
            risk: None,
            reasons: None,
        }
    }

    #[test]
    fn diff_empty_to_populated() {
        let old = Policy::default();
        let mut new = Policy::default();
        new.files.allow.push(Entry::Simple("/tmp/a".into()));
        new.network
            .allow_destinations
            .push(Entry::Simple("api.example.com:443".into()));

        let diff = diff_policies(&old, &new);
        assert_eq!(diff.files_allow.added, vec!["/tmp/a".to_string()]);
        assert_eq!(
            diff.network_allow.added,
            vec!["api.example.com:443".to_string()]
        );
    }

    #[test]
    fn diff_removed_entries() {
        let mut old = Policy::default();
        old.files.allow.push(Entry::Simple("/tmp/old".into()));
        let new = Policy::default();

        let diff = diff_policies(&old, &new);
        assert_eq!(diff.files_allow.removed, vec!["/tmp/old".to_string()]);
        assert!(diff.files_allow.added.is_empty());
    }

    #[test]
    fn diff_stability_change() {
        let mut old = Policy::default();
        old.files.allow.push(e("/tmp/file", Some(3), Some(0.70)));
        let mut new = Policy::default();
        new.files.allow.push(e("/tmp/file", Some(3), Some(0.90)));

        let diff = diff_policies(&old, &new);
        assert_eq!(diff.files_allow.changed.len(), 1);
        assert_eq!(diff.files_allow.changed[0].pattern, "/tmp/file");
    }

    #[test]
    fn diff_no_changes() {
        let mut old = Policy::default();
        old.files.allow.push(Entry::Simple("/tmp/same".into()));
        let new = old.clone();

        let diff = diff_policies(&old, &new);
        assert!(diff.is_empty());
    }
}