vault_audit_tools/commands/
kv_compare.rs

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