vault_audit_tools/commands/
token_lookup_abuse.rs

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