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