vault_audit_tools/commands/
entity_creation.rs

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