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