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 crate::utils::format::format_number;
46use anyhow::{Context, Result};
47use std::collections::HashSet;
48use std::fs::File;
49
50/// Mount point usage statistics
51struct MountData {
52    operations: usize,
53    paths: usize,
54    entities: HashSet<String>,
55}
56
57fn analyze_mount(csvfile: &str) -> Result<Option<MountData>> {
58    let Ok(file) = File::open(csvfile) else {
59        return Ok(None);
60    };
61
62    let mut reader = csv::Reader::from_reader(file);
63    let mut operations = 0;
64    let mut paths = 0;
65    let mut entities: HashSet<String> = HashSet::new();
66
67    for result in reader.records() {
68        let record = result?;
69
70        // Get operations_count (column 2)
71        if let Some(ops_str) = record.get(2) {
72            if let Ok(ops) = ops_str.parse::<usize>() {
73                operations += ops;
74            }
75        }
76
77        paths += 1;
78
79        // Get entity_ids (column 3)
80        if let Some(entity_ids_str) = record.get(3) {
81            for eid in entity_ids_str.split(',') {
82                let trimmed = eid.trim();
83                if !trimmed.is_empty() {
84                    entities.insert(trimmed.to_string());
85                }
86            }
87        }
88    }
89
90    if paths == 0 {
91        return Ok(None);
92    }
93
94    Ok(Some(MountData {
95        operations,
96        paths,
97        entities,
98    }))
99}
100
101pub fn run(csv1: &str, csv2: &str) -> Result<()> {
102    let csv_files = vec![csv1.to_string(), csv2.to_string()];
103
104    println!("{}", "=".repeat(95));
105    println!(
106        "{:<20} {:<18} {:<18} {:<20}",
107        "KV Mount", "Operations", "Unique Paths", "Unique Entities"
108    );
109    println!("{}", "=".repeat(95));
110
111    let mut results = Vec::new();
112    let mut total_ops = 0;
113    let mut total_paths = 0;
114    let mut all_entities: HashSet<String> = HashSet::new();
115
116    for csv_file in &csv_files {
117        // Extract mount name from filename
118        let mount_name = std::path::Path::new(csv_file)
119            .file_stem()
120            .and_then(|s| s.to_str())
121            .unwrap_or(csv_file);
122
123        match analyze_mount(csv_file).context(format!("Failed to analyze {}", csv_file))? {
124            Some(data) => {
125                println!(
126                    "{:<20} {:<18} {:<18} {:<20}",
127                    mount_name,
128                    format_number(data.operations),
129                    format_number(data.paths),
130                    format_number(data.entities.len())
131                );
132
133                total_ops += data.operations;
134                total_paths += data.paths;
135                all_entities.extend(data.entities.iter().cloned());
136
137                results.push((mount_name.to_string(), data));
138            }
139            None => {
140                println!("{:<20} {:<18}", mount_name, "(file not found)");
141            }
142        }
143    }
144
145    println!("{}", "=".repeat(95));
146    println!(
147        "{:<20} {:<18} {:<18} {:<20}",
148        "TOTAL",
149        format_number(total_ops),
150        format_number(total_paths),
151        format_number(all_entities.len())
152    );
153    println!("{}", "=".repeat(95));
154
155    // Show percentage breakdown
156    if !results.is_empty() {
157        println!("\nPercentage Breakdown by Operations:");
158        println!("{}", "-".repeat(50));
159
160        // Sort by operations descending
161        results.sort_by(|a, b| b.1.operations.cmp(&a.1.operations));
162
163        for (mount, data) in results {
164            let pct = if total_ops > 0 {
165                (data.operations as f64 / total_ops as f64) * 100.0
166            } else {
167                0.0
168            };
169            println!("{:<20} {:>6.2}%", mount, pct);
170        }
171    }
172
173    Ok(())
174}