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