vault_audit_tools/commands/
entity_creation.rs1use 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#[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#[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>, }
93
94pub 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 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 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 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; }
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(); let mount_accessor = record.get(9).unwrap_or("").to_string(); 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 let file = File::open(path)?;
147 if let Ok(mappings) = serde_json::from_reader::<_, HashMap<String, EntityMapping>>(file) {
148 Ok(mappings)
149 } else {
150 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 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 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 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 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 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 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 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 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 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 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 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}