vault_audit_tools/commands/
token_lookup_abuse.rs

1//! Token lookup abuse detection.
2//!
3//! Identifies entities performing excessive token lookup operations,
4//! which can indicate misconfigured applications or potential security issues.
5//! Supports multi-file analysis for pattern detection over time.
6//!
7//! # Usage
8//!
9//! ```bash
10//! # Single file with default threshold (100 lookups per entity)
11//! vault-audit token-lookup-abuse audit.log
12//!
13//! # Multi-day analysis with custom threshold
14//! vault-audit token-lookup-abuse logs/*.log --threshold 500
15//! ```
16//!
17//! # Output
18//!
19//! Displays entities exceeding the lookup threshold with:
20//! - Entity ID and display name
21//! - Total lookup operations
22//! - Time range (first seen to last seen)
23//! - Rate (lookups per hour)
24//!
25//! Helps identify:
26//! - Applications polling tokens too frequently
27//! - Misconfigured token renewal logic
28//! - Potential reconnaissance activity
29
30use crate::audit::types::AuditEntry;
31use crate::utils::progress::ProgressBar;
32use crate::utils::time::parse_timestamp;
33use anyhow::Result;
34use std::collections::HashMap;
35use std::fs::File;
36use std::io::{BufRead, BufReader};
37
38/// Tracks token lookup statistics for an entity
39#[derive(Debug)]
40struct TokenData {
41    lookups: usize,
42    first_seen: String,
43    last_seen: String,
44}
45
46impl TokenData {
47    fn new(timestamp: String) -> Self {
48        Self {
49            lookups: 1,
50            first_seen: timestamp.clone(),
51            last_seen: timestamp,
52        }
53    }
54}
55
56fn calculate_time_span_hours(first_seen: &str, last_seen: &str) -> f64 {
57    match (parse_timestamp(first_seen), parse_timestamp(last_seen)) {
58        (Ok(first), Ok(last)) => {
59            let duration = last.signed_duration_since(first);
60            duration.num_seconds() as f64 / 3600.0
61        }
62        _ => 0.0,
63    }
64}
65
66fn format_number(n: usize) -> String {
67    let s = n.to_string();
68    let mut result = String::new();
69    for (i, c) in s.chars().rev().enumerate() {
70        if i > 0 && i % 3 == 0 {
71            result.push(',');
72        }
73        result.push(c);
74    }
75    result.chars().rev().collect()
76}
77
78pub fn run(log_files: &[String], threshold: usize) -> Result<()> {
79    // entity_id -> accessor -> TokenData
80    let mut patterns: HashMap<String, HashMap<String, TokenData>> = HashMap::new();
81    let mut total_lines = 0;
82    let mut lookup_lines = 0;
83
84    // Process each log file sequentially
85    for (file_idx, log_file) in log_files.iter().enumerate() {
86        eprintln!(
87            "[{}/{}] Processing: {}",
88            file_idx + 1,
89            log_files.len(),
90            log_file
91        );
92
93        // Get file size for progress tracking
94        let file_size = std::fs::metadata(log_file).ok().map(|m| m.len() as usize);
95        let mut progress = if let Some(size) = file_size {
96            ProgressBar::new(size, "Processing")
97        } else {
98            ProgressBar::new_spinner("Processing")
99        };
100
101        let file = File::open(log_file)?;
102        let reader = BufReader::new(file);
103
104        let mut file_lines = 0;
105        let mut bytes_read = 0;
106
107        for line in reader.lines() {
108            file_lines += 1;
109            total_lines += 1;
110            let line = line?;
111            bytes_read += line.len() + 1; // +1 for newline
112
113            // Update progress every 10k lines for smooth animation
114            if file_lines % 10_000 == 0 {
115                if let Some(size) = file_size {
116                    progress.update(bytes_read.min(size)); // Cap at file size
117                } else {
118                    progress.update(file_lines);
119                }
120            }
121
122            let entry: AuditEntry = match serde_json::from_str(&line) {
123                Ok(e) => e,
124                Err(_) => continue,
125            };
126
127            // Filter for token lookup-self operations
128            let request = match &entry.request {
129                Some(r) => r,
130                None => continue,
131            };
132
133            let path = match &request.path {
134                Some(p) => p.as_str(),
135                None => continue,
136            };
137
138            if path != "auth/token/lookup-self" {
139                continue;
140            }
141
142            let auth = match &entry.auth {
143                Some(a) => a,
144                None => continue,
145            };
146
147            let entity_id = match &auth.entity_id {
148                Some(id) => id.as_str(),
149                None => continue,
150            };
151
152            let accessor = match &auth.accessor {
153                Some(a) => a.clone(),
154                None => continue,
155            };
156
157            lookup_lines += 1;
158
159            let entity_map = patterns.entry(entity_id.to_string()).or_default();
160
161            entity_map
162                .entry(accessor)
163                .and_modify(|data| {
164                    data.lookups += 1;
165                    data.last_seen = entry.time.clone();
166                })
167                .or_insert_with(|| TokenData::new(entry.time.clone()));
168        }
169
170        // Ensure 100% progress for this file
171        if let Some(size) = file_size {
172            progress.update(size);
173        }
174
175        progress.finish_with_message(&format!(
176            "Processed {} lines from this file",
177            format_number(file_lines)
178        ));
179    }
180
181    eprintln!(
182        "\nTotal: Processed {} lines, found {} lookup-self operations",
183        format_number(total_lines),
184        format_number(lookup_lines)
185    );
186
187    // Find entities with excessive lookups
188    let mut excessive_patterns = Vec::new();
189
190    for (entity_id, tokens) in &patterns {
191        for (accessor, data) in tokens {
192            if data.lookups >= threshold {
193                let time_span = calculate_time_span_hours(&data.first_seen, &data.last_seen);
194                let lookups_per_hour = if time_span > 0.0 {
195                    data.lookups as f64 / time_span
196                } else {
197                    0.0
198                };
199
200                // Truncate accessor for display
201                let accessor_display = if accessor.len() > 23 {
202                    format!("{}...", &accessor[..20])
203                } else {
204                    accessor.clone()
205                };
206
207                excessive_patterns.push((
208                    entity_id.clone(),
209                    accessor_display,
210                    data.lookups,
211                    time_span,
212                    lookups_per_hour,
213                    data.first_seen.clone(),
214                    data.last_seen.clone(),
215                ));
216            }
217        }
218    }
219
220    // Sort by number of lookups (descending)
221    excessive_patterns.sort_by(|a, b| b.2.cmp(&a.2));
222
223    // Print summary
224    println!("\n{}", "=".repeat(120));
225    println!("Token Lookup Pattern Analysis");
226    println!("{}", "=".repeat(120));
227    println!("\nTotal Entities: {}", format_number(patterns.len()));
228    println!(
229        "Entities with ≥{} lookups on same token: {}",
230        threshold,
231        format_number(excessive_patterns.len())
232    );
233
234    if !excessive_patterns.is_empty() {
235        let top = 20;
236        println!("\nTop {} Entities with Excessive Token Lookups:", top);
237        println!("{}", "-".repeat(120));
238        println!(
239            "{:<40} {:<25} {:>10} {:>12} {:>15}",
240            "Entity ID", "Token Accessor", "Lookups", "Time Span", "Rate"
241        );
242        println!(
243            "{:<40} {:<25} {:>10} {:>12} {:>15}",
244            "", "", "", "(hours)", "(lookups/hr)"
245        );
246        println!("{}", "-".repeat(120));
247
248        for (entity_id, accessor, lookups, time_span, rate, _first, _last) in
249            excessive_patterns.iter().take(top)
250        {
251            println!(
252                "{:<40} {:<25} {:>10} {:>12.1} {:>15.1}",
253                entity_id,
254                accessor,
255                format_number(*lookups),
256                time_span,
257                rate
258            );
259        }
260
261        // Statistics
262        let total_excessive_lookups: usize = excessive_patterns.iter().map(|p| p.2).sum();
263        let avg_lookups = total_excessive_lookups as f64 / excessive_patterns.len() as f64;
264        let max_lookups = excessive_patterns[0].2;
265
266        println!("\n{}", "-".repeat(120));
267        println!(
268            "Total Excessive Lookups: {}",
269            format_number(total_excessive_lookups)
270        );
271        println!("Average Lookups per Entity: {:.1}", avg_lookups);
272        println!(
273            "Maximum Lookups (single token): {}",
274            format_number(max_lookups)
275        );
276
277        // Find highest rate
278        let mut by_rate = excessive_patterns.clone();
279        by_rate.sort_by(|a, b| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal));
280
281        if by_rate[0].4 > 0.0 {
282            println!("\nHighest Rate: {:.1} lookups/hour", by_rate[0].4);
283            println!("  Entity: {}", by_rate[0].0);
284            println!("  Lookups: {}", format_number(by_rate[0].2));
285        }
286    }
287
288    println!("{}", "=".repeat(120));
289
290    Ok(())
291}