vault_audit_tools/commands/
entity_creation.rs

1//! Entity creation analysis command.
2//!
3//! Identifies when entities first appear in audit logs, grouped by
4//! authentication method and mount path.
5//! Supports multi-file analysis for tracking entity creation over time.
6//!
7//! # Usage
8//!
9//! ```bash
10//! # Single file analysis
11//! vault-audit entity-creation audit.log
12//!
13//! # Multi-day analysis
14//! vault-audit entity-creation logs/*.log --output entity-creation.json
15//!
16//! # With entity mappings from entity-list (CSV or JSON)
17//! vault-audit entity-creation logs/*.log --entity-map entities.csv
18//! vault-audit entity-creation logs/*.log --entity-map entities.json
19//! ```
20//!
21//! # Output
22//!
23//! Displays entity creation events grouped by authentication path:
24//! - Entity ID
25//! - Display name
26//! - Mount path (authentication method)
27//! - First seen timestamp
28//! - Creation count by auth method
29//!
30//! Use `--json` to output structured data for further processing.
31
32use crate::audit::types::AuditEntry;
33use crate::utils::progress::ProgressBar;
34use anyhow::{Context, Result};
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37use std::collections::{HashMap, HashSet};
38use std::fs::File;
39use std::io::{BufRead, BufReader};
40
41/// Entity mapping data structure for JSON output
42#[derive(Debug, Serialize, Deserialize)]
43pub struct EntityMapping {
44    pub display_name: String,
45    pub mount_path: String,
46    #[allow(dead_code)]
47    pub mount_accessor: String,
48    #[allow(dead_code)]
49    pub username: Option<String>,
50    #[allow(dead_code)]
51    pub login_count: usize,
52    #[allow(dead_code)]
53    pub first_seen: String,
54    #[allow(dead_code)]
55    pub last_seen: String,
56}
57
58/// Represents a single entity creation event
59#[derive(Debug)]
60struct EntityCreation {
61    entity_id: String,
62    display_name: String,
63    mount_path: String,
64    mount_type: String,
65    first_seen: DateTime<Utc>,
66    login_count: usize,
67}
68
69#[derive(Debug)]
70struct MountStats {
71    mount_path: String,
72    mount_type: String,
73    entities_created: usize,
74    total_logins: usize,
75    sample_entities: Vec<String>, // Store up to 5 sample display names
76}
77
78fn format_number(n: usize) -> String {
79    let s = n.to_string();
80    let mut result = String::new();
81    for (i, c) in s.chars().rev().enumerate() {
82        if i > 0 && i % 3 == 0 {
83            result.push(',');
84        }
85        result.push(c);
86    }
87    result.chars().rev().collect()
88}
89
90/// Load entity mappings from either JSON or CSV format
91pub fn load_entity_mappings(path: &str) -> Result<HashMap<String, EntityMapping>> {
92    let file =
93        File::open(path).with_context(|| format!("Failed to open entity map file: {}", path))?;
94
95    // Auto-detect format based on file extension or content
96    let path_lower = path.to_lowercase();
97    if path_lower.ends_with(".json") {
98        // JSON format (from preprocess-entities)
99        serde_json::from_reader(file)
100            .with_context(|| format!("Failed to parse entity map JSON: {}", path))
101    } else if path_lower.ends_with(".csv") {
102        // CSV format (from entity-list)
103        let mut reader = csv::Reader::from_reader(file);
104        let mut mappings = HashMap::new();
105
106        for result in reader.records() {
107            let record = result?;
108            if record.len() < 8 {
109                continue; // Skip malformed rows
110            }
111
112            let entity_id = record.get(0).unwrap_or("").to_string();
113            let display_name = record.get(1).unwrap_or("").to_string();
114            let mount_path = record.get(7).unwrap_or("").to_string(); // mount_path column
115            let mount_accessor = record.get(9).unwrap_or("").to_string(); // mount_accessor column
116
117            if !entity_id.is_empty() {
118                mappings.insert(
119                    entity_id,
120                    EntityMapping {
121                        display_name,
122                        mount_path,
123                        mount_accessor,
124                        username: None,
125                        login_count: 0,
126                        first_seen: String::new(),
127                        last_seen: String::new(),
128                    },
129                );
130            }
131        }
132
133        Ok(mappings)
134    } else {
135        // Try JSON first, fall back to CSV
136        let file = File::open(path)?;
137        match serde_json::from_reader::<_, HashMap<String, EntityMapping>>(file) {
138            Ok(mappings) => Ok(mappings),
139            Err(_) => {
140                // Try CSV
141                let file = File::open(path)?;
142                let mut reader = csv::Reader::from_reader(file);
143                let mut mappings = HashMap::new();
144
145                for result in reader.records() {
146                    let record = result?;
147                    if record.len() < 8 {
148                        continue;
149                    }
150
151                    let entity_id = record.get(0).unwrap_or("").to_string();
152                    let display_name = record.get(1).unwrap_or("").to_string();
153                    let mount_path = record.get(7).unwrap_or("").to_string();
154                    let mount_accessor = record.get(9).unwrap_or("").to_string();
155
156                    if !entity_id.is_empty() {
157                        mappings.insert(
158                            entity_id,
159                            EntityMapping {
160                                display_name,
161                                mount_path,
162                                mount_accessor,
163                                username: None,
164                                login_count: 0,
165                                first_seen: String::new(),
166                                last_seen: String::new(),
167                            },
168                        );
169                    }
170                }
171
172                Ok(mappings)
173            }
174        }
175    }
176}
177
178pub fn run(
179    log_files: &[String],
180    entity_map_file: Option<&str>,
181    output: Option<&str>,
182) -> Result<()> {
183    eprintln!("Analyzing entity creation by authentication path...\n");
184
185    // Load entity mappings if provided (supports both JSON and CSV)
186    let entity_mappings: HashMap<String, EntityMapping> = if let Some(map_file) = entity_map_file {
187        eprintln!("Loading entity mappings from: {}", map_file);
188        load_entity_mappings(map_file)?
189    } else {
190        HashMap::new()
191    };
192
193    if !entity_mappings.is_empty() {
194        eprintln!(
195            "Loaded {} entity mappings for display name enrichment\n",
196            format_number(entity_mappings.len())
197        );
198    }
199
200    let mut entity_creations: HashMap<String, EntityCreation> = HashMap::new();
201    let mut seen_entities: HashSet<String> = HashSet::new();
202    let mut lines_processed = 0;
203    let mut login_events = 0;
204    let mut new_entities_found = 0;
205
206    // Process each log file sequentially
207    for (file_idx, log_file) in log_files.iter().enumerate() {
208        eprintln!(
209            "[{}/{}] Processing: {}",
210            file_idx + 1,
211            log_files.len(),
212            log_file
213        );
214
215        // Get file size for progress tracking
216        let file_size = std::fs::metadata(log_file).ok().map(|m| m.len() as usize);
217
218        let file = File::open(log_file)
219            .with_context(|| format!("Failed to open audit log file: {}", log_file))?;
220        let reader = BufReader::new(file);
221
222        let mut progress = if let Some(size) = file_size {
223            ProgressBar::new(size, "Processing")
224        } else {
225            ProgressBar::new_spinner("Processing")
226        };
227        let mut bytes_read = 0;
228        let mut file_lines = 0;
229
230        for line in reader.lines() {
231            file_lines += 1;
232            lines_processed += 1;
233            let line = line?;
234            bytes_read += line.len() + 1;
235
236            if file_lines % 10_000 == 0 {
237                if let Some(size) = file_size {
238                    progress.update(bytes_read.min(size));
239                } else {
240                    progress.update(file_lines);
241                }
242            }
243
244            let entry: AuditEntry = match serde_json::from_str(&line) {
245                Ok(e) => e,
246                Err(_) => continue,
247            };
248
249            // Look for login events in auth paths
250            let request = match &entry.request {
251                Some(r) => r,
252                None => continue,
253            };
254
255            let path = match &request.path {
256                Some(p) => p.as_str(),
257                None => continue,
258            };
259
260            if !path.starts_with("auth/") || !path.contains("/login") {
261                continue;
262            }
263
264            let auth = match &entry.auth {
265                Some(a) => a,
266                None => continue,
267            };
268
269            let entity_id = match &auth.entity_id {
270                Some(id) if !id.is_empty() => id.clone(),
271                _ => continue,
272            };
273
274            login_events += 1;
275
276            // Check if this is the first time we've seen this entity
277            let is_new_entity = seen_entities.insert(entity_id.clone());
278
279            if is_new_entity {
280                new_entities_found += 1;
281
282                let display_name = auth
283                    .display_name
284                    .clone()
285                    .or_else(|| {
286                        entity_mappings
287                            .get(&entity_id)
288                            .map(|m| m.display_name.clone())
289                    })
290                    .unwrap_or_else(|| "unknown".to_string());
291
292                let mount_path = path
293                    .trim_end_matches("/login")
294                    .trim_end_matches(&format!("/{}", display_name))
295                    .to_string();
296
297                let mount_type = request
298                    .mount_type
299                    .clone()
300                    .unwrap_or_else(|| "unknown".to_string());
301
302                let first_seen = match chrono::DateTime::parse_from_rfc3339(&entry.time) {
303                    Ok(dt) => dt.with_timezone(&Utc),
304                    Err(_) => Utc::now(),
305                };
306
307                entity_creations.insert(
308                    entity_id.clone(),
309                    EntityCreation {
310                        entity_id,
311                        display_name,
312                        mount_path,
313                        mount_type,
314                        first_seen,
315                        login_count: 1,
316                    },
317                );
318            } else {
319                // Increment login count for existing entity
320                if let Some(creation) = entity_creations.get_mut(&entity_id) {
321                    creation.login_count += 1;
322                }
323            }
324        }
325
326        if let Some(size) = file_size {
327            progress.update(size);
328        } else {
329            progress.update(file_lines);
330        }
331
332        progress.finish_with_message(&format!("Processed {} lines from this file", file_lines));
333    }
334
335    eprintln!(
336        "\nTotal: Processed {} lines, {} login events, {} new entities created",
337        format_number(lines_processed),
338        format_number(login_events),
339        format_number(new_entities_found)
340    );
341
342    // Aggregate by mount path
343    let mut mount_stats: HashMap<String, MountStats> = HashMap::new();
344
345    for creation in entity_creations.values() {
346        let key = creation.mount_path.clone();
347        mount_stats
348            .entry(key.clone())
349            .and_modify(|stats| {
350                stats.entities_created += 1;
351                stats.total_logins += creation.login_count;
352                if stats.sample_entities.len() < 5 {
353                    stats.sample_entities.push(creation.display_name.clone());
354                }
355            })
356            .or_insert_with(|| MountStats {
357                mount_path: creation.mount_path.clone(),
358                mount_type: creation.mount_type.clone(),
359                entities_created: 1,
360                total_logins: creation.login_count,
361                sample_entities: vec![creation.display_name.clone()],
362            });
363    }
364
365    // Sort by entities created
366    let mut sorted_mounts: Vec<_> = mount_stats.values().collect();
367    sorted_mounts.sort_by(|a, b| b.entities_created.cmp(&a.entities_created));
368
369    // Print report
370    eprintln!("\n{}", "=".repeat(100));
371    eprintln!("ENTITY CREATION ANALYSIS BY AUTHENTICATION PATH");
372    eprintln!("{}", "=".repeat(100));
373    eprintln!();
374    eprintln!("Summary:");
375    eprintln!("  Total login events: {}", format_number(login_events));
376    eprintln!(
377        "  Unique entities discovered: {}",
378        format_number(new_entities_found)
379    );
380    eprintln!(
381        "  Authentication methods: {}",
382        format_number(mount_stats.len())
383    );
384    eprintln!();
385    eprintln!("{}", "-".repeat(100));
386    eprintln!(
387        "{:<50} {:<15} {:<15} {:<20}",
388        "Authentication Path", "Mount Type", "Entities", "Total Logins"
389    );
390    eprintln!("{}", "-".repeat(100));
391
392    for stats in &sorted_mounts {
393        eprintln!(
394            "{:<50} {:<15} {:>15} {:>15}",
395            if stats.mount_path.len() > 49 {
396                format!("{}...", &stats.mount_path[..46])
397            } else {
398                stats.mount_path.clone()
399            },
400            if stats.mount_type.len() > 14 {
401                format!("{}...", &stats.mount_type[..11])
402            } else {
403                stats.mount_type.clone()
404            },
405            format_number(stats.entities_created),
406            format_number(stats.total_logins)
407        );
408    }
409
410    eprintln!("{}", "-".repeat(100));
411    eprintln!();
412
413    // Show top 10 with sample entities
414    eprintln!("Top 10 Authentication Paths with Sample Entities:");
415    eprintln!("{}", "=".repeat(100));
416    for (i, stats) in sorted_mounts.iter().take(10).enumerate() {
417        eprintln!();
418        eprintln!("{}. {} ({})", i + 1, stats.mount_path, stats.mount_type);
419        eprintln!(
420            "   Entities created: {} | Total logins: {}",
421            format_number(stats.entities_created),
422            format_number(stats.total_logins)
423        );
424        eprintln!("   Sample entities:");
425        for (j, name) in stats.sample_entities.iter().enumerate() {
426            eprintln!("      {}. {}", j + 1, name);
427        }
428    }
429    eprintln!();
430    eprintln!("{}", "=".repeat(100));
431
432    // Write detailed output if requested
433    if let Some(output_file) = output {
434        eprintln!(
435            "\nWriting detailed entity creation data to: {}",
436            output_file
437        );
438
439        let mut entities: Vec<_> = entity_creations.values().collect();
440        entities.sort_by(|a, b| a.first_seen.cmp(&b.first_seen));
441
442        #[derive(Serialize)]
443        struct EntityCreationOutput {
444            entity_id: String,
445            display_name: String,
446            mount_path: String,
447            mount_type: String,
448            first_seen: String,
449            login_count: usize,
450        }
451
452        let output_data: Vec<EntityCreationOutput> = entities
453            .into_iter()
454            .map(|e| EntityCreationOutput {
455                entity_id: e.entity_id.clone(),
456                display_name: e.display_name.clone(),
457                mount_path: e.mount_path.clone(),
458                mount_type: e.mount_type.clone(),
459                first_seen: e.first_seen.to_rfc3339(),
460                login_count: e.login_count,
461            })
462            .collect();
463
464        let output_file_handle = File::create(output_file)
465            .with_context(|| format!("Failed to create output file: {}", output_file))?;
466        serde_json::to_writer_pretty(output_file_handle, &output_data)
467            .with_context(|| format!("Failed to write JSON output: {}", output_file))?;
468
469        eprintln!(
470            "✓ Wrote {} entity records to {}",
471            format_number(output_data.len()),
472            output_file
473        );
474    }
475
476    Ok(())
477}