vault_audit_tools/commands/
kv_compare.rs

1//! KV usage comparison across time periods.
2//!
3//! ⚠️ **DEPRECATED**: Use `kv-analysis compare` instead.
4//!
5//! ```bash
6//! # Old (deprecated):
7//! vault-audit kv-compare old_usage.csv new_usage.csv
8//!
9//! # New (recommended):
10//! vault-audit kv-analysis compare old_usage.csv new_usage.csv
11//! ```
12//!
13//! See [`kv_analysis`](crate::commands::kv_analysis) for the unified command.
14//!
15//! ---
16//!
17//! Compares KV secrets engine usage between two CSV exports to identify
18//! changes in access patterns over time.
19//!
20//! # Usage
21//!
22//! ```bash
23//! # Generate two CSV files from different time periods
24//! vault-audit kv-analyzer old-audit.log --output old-usage.csv
25//! vault-audit kv-analyzer new-audit.log --output new-usage.csv
26//!
27//! # Compare them
28//! vault-audit kv-compare old-usage.csv new-usage.csv
29//! ```
30//!
31//! # Output
32//!
33//! Displays comparison metrics by mount point:
34//! - Change in total operations
35//! - Change in unique secrets accessed
36//! - Change in entity count
37//! - Percentage changes
38//!
39//! Helps identify:
40//! - Growing or shrinking KV usage
41//! - New secrets being accessed
42//! - Secrets no longer used
43//! - Changes in access patterns
44
45use anyhow::{Context, Result};
46use std::collections::HashSet;
47use std::fs::File;
48
49fn format_number(n: usize) -> String {
50    let s = n.to_string();
51    let mut result = String::new();
52    for (i, c) in s.chars().rev().enumerate() {
53        if i > 0 && i % 3 == 0 {
54            result.push(',');
55        }
56        result.push(c);
57    }
58    result.chars().rev().collect()
59}
60
61/// Mount point usage statistics
62struct MountData {
63    operations: usize,
64    paths: usize,
65    entities: HashSet<String>,
66}
67
68fn analyze_mount(csvfile: &str) -> Result<Option<MountData>> {
69    let file = match File::open(csvfile) {
70        Ok(f) => f,
71        Err(_) => return Ok(None),
72    };
73
74    let mut reader = csv::Reader::from_reader(file);
75    let mut operations = 0;
76    let mut paths = 0;
77    let mut entities: HashSet<String> = HashSet::new();
78
79    for result in reader.records() {
80        let record = result?;
81
82        // Get operations_count (column 2)
83        if let Some(ops_str) = record.get(2) {
84            if let Ok(ops) = ops_str.parse::<usize>() {
85                operations += ops;
86            }
87        }
88
89        paths += 1;
90
91        // Get entity_ids (column 3)
92        if let Some(entity_ids_str) = record.get(3) {
93            for eid in entity_ids_str.split(',') {
94                let trimmed = eid.trim();
95                if !trimmed.is_empty() {
96                    entities.insert(trimmed.to_string());
97                }
98            }
99        }
100    }
101
102    if paths == 0 {
103        return Ok(None);
104    }
105
106    Ok(Some(MountData {
107        operations,
108        paths,
109        entities,
110    }))
111}
112
113pub fn run(csv1: &str, csv2: &str) -> Result<()> {
114    let csv_files = vec![csv1.to_string(), csv2.to_string()];
115
116    println!("{}", "=".repeat(95));
117    println!(
118        "{:<20} {:<18} {:<18} {:<20}",
119        "KV Mount", "Operations", "Unique Paths", "Unique Entities"
120    );
121    println!("{}", "=".repeat(95));
122
123    let mut results = Vec::new();
124    let mut total_ops = 0;
125    let mut total_paths = 0;
126    let mut all_entities: HashSet<String> = HashSet::new();
127
128    for csv_file in &csv_files {
129        // Extract mount name from filename
130        let mount_name = std::path::Path::new(csv_file)
131            .file_stem()
132            .and_then(|s| s.to_str())
133            .unwrap_or(csv_file);
134
135        match analyze_mount(csv_file).context(format!("Failed to analyze {}", csv_file))? {
136            Some(data) => {
137                println!(
138                    "{:<20} {:<18} {:<18} {:<20}",
139                    mount_name,
140                    format_number(data.operations),
141                    format_number(data.paths),
142                    format_number(data.entities.len())
143                );
144
145                total_ops += data.operations;
146                total_paths += data.paths;
147                all_entities.extend(data.entities.iter().cloned());
148
149                results.push((mount_name.to_string(), data));
150            }
151            None => {
152                println!("{:<20} {:<18}", mount_name, "(file not found)");
153            }
154        }
155    }
156
157    println!("{}", "=".repeat(95));
158    println!(
159        "{:<20} {:<18} {:<18} {:<20}",
160        "TOTAL",
161        format_number(total_ops),
162        format_number(total_paths),
163        format_number(all_entities.len())
164    );
165    println!("{}", "=".repeat(95));
166
167    // Show percentage breakdown
168    if !results.is_empty() {
169        println!("\nPercentage Breakdown by Operations:");
170        println!("{}", "-".repeat(50));
171
172        // Sort by operations descending
173        results.sort_by(|a, b| b.1.operations.cmp(&a.1.operations));
174
175        for (mount, data) in results {
176            let pct = if total_ops > 0 {
177                (data.operations as f64 / total_ops as f64) * 100.0
178            } else {
179                0.0
180            };
181            println!("{:<20} {:>6.2}%", mount, pct);
182        }
183    }
184
185    Ok(())
186}