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