vault_audit_tools/commands/
system_overview.rs

1//! System-wide audit log overview.
2//!
3//! Provides high-level statistics and insights about Vault usage
4//! across the entire audit log. Supports analyzing multiple log files
5//! for long-term trend analysis.
6//!
7//! # Usage
8//!
9//! ```bash
10//! # Single file
11//! vault-audit system-overview audit.log
12//!
13//! # Multiple files for week-long analysis
14//! vault-audit system-overview day1.log day2.log day3.log
15//!
16//! # Using shell globbing
17//! vault-audit system-overview logs/vault_audit.2025-10-*.log
18//! ```
19//!
20//! # Output
21//!
22//! Displays comprehensive statistics:
23//! - Total entries processed
24//! - Unique entities
25//! - Unique paths accessed
26//! - Operation breakdown (read, write, list, delete)
27//! - Top paths by access count
28//! - Mount point usage
29//! - Authentication method breakdown
30//! - Time range covered
31//! - Error rate
32//!
33//! Useful for:
34//! - Understanding overall Vault usage
35//! - Capacity planning
36//! - Identifying hotspots
37//! - Security audits
38
39use crate::audit::types::AuditEntry;
40use crate::utils::progress::ProgressBar;
41use anyhow::Result;
42use std::collections::{HashMap, HashSet};
43use std::fs::File;
44use std::io::{BufRead, BufReader};
45
46/// Path access statistics
47#[derive(Debug)]
48struct PathData {
49    count: usize,
50    operations: HashMap<String, usize>,
51    entities: HashSet<String>,
52}
53
54impl PathData {
55    fn new() -> Self {
56        Self {
57            count: 0,
58            operations: HashMap::new(),
59            entities: HashSet::new(),
60        }
61    }
62}
63
64fn format_number(n: usize) -> String {
65    let s = n.to_string();
66    let mut result = String::new();
67    for (i, c) in s.chars().rev().enumerate() {
68        if i > 0 && i % 3 == 0 {
69            result.push(',');
70        }
71        result.push(c);
72    }
73    result.chars().rev().collect()
74}
75
76pub fn run(log_files: &[String], top: usize, min_operations: usize) -> Result<()> {
77    let mut path_operations: HashMap<String, PathData> = HashMap::new();
78    let mut operation_types: HashMap<String, usize> = HashMap::new();
79    let mut path_prefixes: HashMap<String, usize> = HashMap::new();
80    let mut entity_paths: HashMap<String, HashMap<String, usize>> = HashMap::new();
81    let mut entity_names: HashMap<String, String> = HashMap::new();
82    let mut total_lines = 0;
83
84    // Process each log file sequentially
85    for (file_idx, log_file) in log_files.iter().enumerate() {
86        eprintln!(
87            "[{}/{}] Processing: {}",
88            file_idx + 1,
89            log_files.len(),
90            log_file
91        );
92
93        // Get file size for progress tracking
94        let file_size = std::fs::metadata(log_file).ok().map(|m| m.len() as usize);
95        let mut progress = if let Some(size) = file_size {
96            ProgressBar::new(size, "Processing")
97        } else {
98            ProgressBar::new_spinner("Processing")
99        };
100
101        let file = File::open(log_file)?;
102        let reader = BufReader::new(file);
103
104        let mut file_lines = 0;
105        let mut bytes_read = 0;
106
107        for line in reader.lines() {
108            file_lines += 1;
109            total_lines += 1;
110            let line = line?;
111            bytes_read += line.len() + 1; // +1 for newline
112
113            // Update progress every 10k lines for smooth animation
114            if file_lines % 10_000 == 0 {
115                if let Some(size) = file_size {
116                    progress.update(bytes_read.min(size)); // Cap at file size
117                } else {
118                    progress.update(file_lines);
119                }
120            }
121
122            let entry: AuditEntry = match serde_json::from_str(&line) {
123                Ok(e) => e,
124                Err(_) => continue,
125            };
126
127            let request = match &entry.request {
128                Some(r) => r,
129                None => continue,
130            };
131
132            let path = match &request.path {
133                Some(p) => p.as_str(),
134                None => continue,
135            };
136
137            let operation = match &request.operation {
138                Some(o) => o.as_str(),
139                None => continue,
140            };
141
142            let entity_id = entry
143                .auth
144                .as_ref()
145                .and_then(|a| a.entity_id.as_deref())
146                .unwrap_or("no-entity");
147
148            let display_name = entry
149                .auth
150                .as_ref()
151                .and_then(|a| a.display_name.as_deref())
152                .unwrap_or("N/A");
153
154            if path.is_empty() || operation.is_empty() {
155                continue;
156            }
157
158            // Track by full path
159            let path_data = path_operations
160                .entry(path.to_string())
161                .or_insert_with(PathData::new);
162            path_data.count += 1;
163            *path_data
164                .operations
165                .entry(operation.to_string())
166                .or_insert(0) += 1;
167            // Track all entities including "no-entity" to match Python behavior
168            path_data.entities.insert(entity_id.to_string());
169
170            // Track by operation type
171            *operation_types.entry(operation.to_string()).or_insert(0) += 1;
172
173            // Track by path prefix
174            let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
175            let prefix = if parts.len() >= 2 {
176                format!("{}/{}", parts[0], parts[1])
177            } else if !parts.is_empty() {
178                parts[0].to_string()
179            } else {
180                "root".to_string()
181            };
182            *path_prefixes.entry(prefix).or_insert(0) += 1;
183
184            // Track entity usage for all entities (including "no-entity")
185            let entity_map = entity_paths.entry(entity_id.to_string()).or_default();
186            *entity_map.entry(path.to_string()).or_insert(0) += 1;
187            entity_names
188                .entry(entity_id.to_string())
189                .or_insert_with(|| display_name.to_string());
190        }
191
192        // Ensure 100% progress for this file
193        if let Some(size) = file_size {
194            progress.update(size);
195        }
196
197        progress.finish_with_message(&format!(
198            "Processed {} lines from this file",
199            format_number(file_lines)
200        ));
201    }
202
203    eprintln!("\nTotal: Processed {} lines", format_number(total_lines));
204
205    let total_operations: usize = operation_types.values().sum();
206
207    // Print results
208    println!("\n{}", "=".repeat(100));
209    println!("High-Volume Vault Operations Analysis");
210    println!("{}", "=".repeat(100));
211
212    // 1. Operation Types Summary
213    println!("\n1. Operation Types (Overall)");
214    println!("{}", "-".repeat(100));
215    println!("{:<20} {:>15} {:>12}", "Operation", "Count", "Percentage");
216    println!("{}", "-".repeat(100));
217
218    let mut sorted_ops: Vec<_> = operation_types.iter().collect();
219    sorted_ops.sort_by(|a, b| b.1.cmp(a.1));
220
221    for (op, count) in sorted_ops {
222        let pct = if total_operations > 0 {
223            (*count as f64 / total_operations as f64) * 100.0
224        } else {
225            0.0
226        };
227        println!("{:<20} {:>15} {:>11.2}%", op, format_number(*count), pct);
228    }
229
230    println!("{}", "-".repeat(100));
231    println!(
232        "{:<20} {:>15} {:>11.2}%",
233        "TOTAL",
234        format_number(total_operations),
235        100.0
236    );
237
238    // 2. Top Path Prefixes
239    println!("\n2. Top Path Prefixes (First 2 components)");
240    println!("{}", "-".repeat(100));
241    println!(
242        "{:<40} {:>15} {:>12}",
243        "Path Prefix", "Operations", "Percentage"
244    );
245    println!("{}", "-".repeat(100));
246
247    let mut sorted_prefixes: Vec<_> = path_prefixes.iter().collect();
248    sorted_prefixes.sort_by(|a, b| b.1.cmp(a.1));
249
250    for (prefix, count) in sorted_prefixes.iter().take(top) {
251        let pct = if total_operations > 0 {
252            (**count as f64 / total_operations as f64) * 100.0
253        } else {
254            0.0
255        };
256        println!(
257            "{:<40} {:>15} {:>11.2}%",
258            prefix,
259            format_number(**count),
260            pct
261        );
262    }
263
264    // 3. Top Individual Paths
265    println!("\n3. Top {} Individual Paths (Highest Volume)", top);
266    println!("{}", "-".repeat(100));
267    println!(
268        "{:<60} {:>10} {:>10} {:>15}",
269        "Path", "Ops", "Entities", "Top Op"
270    );
271    println!("{}", "-".repeat(100));
272
273    let mut sorted_paths: Vec<_> = path_operations.iter().collect();
274    sorted_paths.sort_by(|a, b| b.1.count.cmp(&a.1.count));
275
276    for (path, data) in sorted_paths.iter().take(top) {
277        if data.count < min_operations {
278            break;
279        }
280        let top_op = data
281            .operations
282            .iter()
283            .max_by_key(|x| x.1)
284            .map(|x| x.0.as_str())
285            .unwrap_or("N/A");
286        let path_display = if path.len() > 60 {
287            format!("{}...", &path[..58])
288        } else {
289            path.to_string()
290        };
291        println!(
292            "{:<60} {:>10} {:>10} {:>15}",
293            path_display,
294            format_number(data.count),
295            format_number(data.entities.len()),
296            top_op
297        );
298    }
299
300    // 4. Top Entities by Total Operations
301    println!("\n4. Top {} Entities by Total Operations", top);
302    println!("{}", "-".repeat(100));
303    println!(
304        "{:<50} {:<38} {:>10}",
305        "Display Name", "Entity ID", "Total Ops"
306    );
307    println!("{}", "-".repeat(100));
308
309    let mut entity_totals: HashMap<String, usize> = HashMap::new();
310    for (entity_id, paths) in &entity_paths {
311        let total: usize = paths.values().sum();
312        entity_totals.insert(entity_id.clone(), total);
313    }
314
315    let mut sorted_entities: Vec<_> = entity_totals.iter().collect();
316    sorted_entities.sort_by(|a, b| b.1.cmp(a.1));
317
318    for (entity_id, total) in sorted_entities.iter().take(top) {
319        let name = entity_names
320            .get(*entity_id)
321            .map(|s| s.as_str())
322            .unwrap_or("N/A");
323        let name_display = if name.len() > 48 { &name[..48] } else { name };
324        let entity_short = if entity_id.len() > 36 {
325            &entity_id[..36]
326        } else {
327            entity_id
328        };
329        println!(
330            "{:<50} {:<38} {:>10}",
331            name_display,
332            entity_short,
333            format_number(**total)
334        );
335    }
336
337    // 5. Potential Stress Points
338    println!("\n5. Potential System Stress Points");
339    println!("{}", "-".repeat(100));
340
341    #[derive(Debug)]
342    struct StressPoint {
343        path: String,
344        entity_name: String,
345        operations: usize,
346    }
347
348    let mut stress_points = Vec::new();
349
350    for (path, data) in &path_operations {
351        if data.count >= min_operations {
352            for entity_id in &data.entities {
353                if let Some(entity_ops_map) = entity_paths.get(entity_id) {
354                    if let Some(&entity_ops) = entity_ops_map.get(path) {
355                        if entity_ops >= min_operations {
356                            stress_points.push(StressPoint {
357                                path: path.clone(),
358                                entity_name: entity_names
359                                    .get(entity_id)
360                                    .cloned()
361                                    .unwrap_or_else(|| "N/A".to_string()),
362                                operations: entity_ops,
363                            });
364                        }
365                    }
366                }
367            }
368        }
369    }
370
371    stress_points.sort_by(|a, b| b.operations.cmp(&a.operations));
372
373    println!("{:<40} {:<40} {:>10}", "Entity", "Path", "Ops");
374    println!("{}", "-".repeat(100));
375
376    for sp in stress_points.iter().take(top) {
377        let entity_display = if sp.entity_name.len() > 38 {
378            &sp.entity_name[..38]
379        } else {
380            &sp.entity_name
381        };
382        let path_display = if sp.path.len() > 38 {
383            &sp.path[..38]
384        } else {
385            &sp.path
386        };
387        println!(
388            "{:<40} {:<40} {:>10}",
389            entity_display,
390            path_display,
391            format_number(sp.operations)
392        );
393    }
394
395    println!("{}", "=".repeat(100));
396    println!("\nTotal Lines Processed: {}", format_number(total_lines));
397    println!("Total Operations: {}", format_number(total_operations));
398    println!("{}", "=".repeat(100));
399
400    Ok(())
401}