vault_audit_tools/commands/
entity_creation.rs1use 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#[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#[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>, }
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
105pub 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 let path_lower = path.to_lowercase();
112 if path_lower.ends_with(".json") {
113 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 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; }
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(); let mount_accessor = record.get(9).unwrap_or("").to_string(); 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 let file = File::open(path)?;
152 match serde_json::from_reader::<_, HashMap<String, EntityMapping>>(file) {
153 Ok(mappings) => Ok(mappings),
154 Err(_) => {
155 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 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 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 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 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 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 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 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 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 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 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 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}