use super::ProbeContext;
use std::collections::HashMap;
pub fn extract_temporal_features(ctx: &ProbeContext, features: &mut HashMap<String, f64>) {
if let Some(ref samples) = ctx.timing_samples {
if samples.len() >= 2 {
let mut sorted: Vec<u64> = samples.clone();
sorted.sort();
let mean = sorted.iter().sum::<u64>() as f64 / sorted.len() as f64;
let variance = sorted
.iter()
.map(|&x| {
let diff = x as f64 - mean;
diff * diff
})
.sum::<f64>()
/ sorted.len() as f64;
let stddev = variance.sqrt();
let mut max_gap = 0u64;
for w in sorted.windows(2) {
let gap = w[1] - w[0];
if gap > max_gap {
max_gap = gap;
}
}
let range = sorted.last().unwrap_or(&1) - sorted.first().unwrap_or(&0);
if range > 0 && max_gap as f64 > range as f64 * 0.5 {
features.insert("temporal:response_time_bimodal".into(), 1.0);
}
if let Some(delay) = ctx.injected_delay {
let expected_ms = (delay * 1000.0) as u64;
if expected_ms > 0 {
let actual = ctx.response.response_time_ms;
let ratio = actual as f64 / expected_ms as f64;
if (0.8..=1.2).contains(&ratio) {
features.insert("temporal:time_delay_proportional".into(), 1.0);
}
}
}
if sorted.len() >= 3 {
let cv = if mean > 0.0 { stddev / mean } else { 0.0 };
if cv < 0.15 && mean > 100.0 {
features.insert("temporal:time_delay_consistent".into(), 1.0);
}
}
let baseline_time = ctx.baseline.response_time_ms as f64;
if baseline_time > 0.0 {
let baseline_cv = stddev / baseline_time;
if baseline_cv < 0.10 {
features.insert("temporal:baseline_timing_stable".into(), 1.0);
}
}
if mean > 0.0 {
let cv = stddev / mean;
if cv < 0.15 {
features.insert("temporal:timing_jitter_low".into(), 1.0);
}
}
if mean > 0.0 && stddev / mean > 0.30 {
features.insert("temporal:high_variance_baseline".into(), 1.0);
}
if mean > 0.0 && stddev / mean > 0.20 && max_gap as f64 <= range as f64 * 0.3 {
features.insert("temporal:shared_infra_noise".into(), 1.0);
}
}
}
if let Some(ref sequence) = ctx.probe_sequence {
if sequence.len() >= 2 {
let sizes: Vec<usize> = sequence.iter().map(|r| r.body_bytes).collect();
let times: Vec<u64> = sequence.iter().map(|r| r.response_time_ms).collect();
let is_increasing = sizes.windows(2).all(|w| w[1] >= w[0]);
if is_increasing && sizes.first() != sizes.last() {
features.insert("temporal:escalating_response_size".into(), 1.0);
}
let is_decreasing = times.windows(2).all(|w| w[1] <= w[0]);
if is_decreasing && times.first() != times.last() {
features.insert("temporal:diminishing_response_time".into(), 1.0);
}
if sizes.len() >= 3 {
let growth = sizes.windows(2).filter(|w| w[1] > w[0]).count();
if growth == sizes.len() - 1 {
features.insert("temporal:state_accumulation".into(), 1.0);
}
}
let numbers: Vec<Option<u64>> = sequence
.iter()
.map(|r| extract_first_number(&r.body))
.collect();
if numbers.len() >= 2 {
let valid: Vec<u64> = numbers.iter().filter_map(|n| *n).collect();
if valid.len() >= 2 {
let is_incrementing = valid.windows(2).all(|w| w[1] > w[0]);
if is_incrementing {
features.insert("temporal:counter_increment_detected".into(), 1.0);
}
}
}
if sequence.len() >= 3 {
let first_body = &sequence[0].body;
let last_body = &sequence[sequence.len() - 1].body;
if first_body != last_body {
let first_status = sequence[0].status;
let last_status = sequence[sequence.len() - 1].status;
if first_status != last_status {
features.insert("temporal:sequence_dependency".into(), 1.0);
}
}
}
}
if sequence.len() >= 3 {
let first = &sequence[0].body;
let stable_3 = sequence[..3].iter().all(|r| &r.body == first);
if stable_3 {
features.insert("temporal:result_stable_3_retries".into(), 1.0);
}
if sequence.len() >= 5 {
let stable_5 = sequence[..5].iter().all(|r| &r.body == first);
if stable_5 {
features.insert("temporal:result_stable_5_retries".into(), 1.0);
}
}
}
if sequence.len() >= 3 {
let payload_lower = ctx.probe_payload.to_lowercase();
let match_counts: Vec<usize> = sequence
.iter()
.map(|r| {
r.body
.to_lowercase()
.matches(&payload_lower)
.count()
})
.collect();
let is_degrading = match_counts.windows(2).all(|w| w[1] <= w[0])
&& match_counts.first() > match_counts.last();
if is_degrading {
features.insert("temporal:degrading_over_time".into(), 1.0);
}
}
if sequence.len() >= 2 {
let payload_lower = ctx.probe_payload.to_lowercase();
let first_has = sequence[0]
.body
.to_lowercase()
.contains(&payload_lower);
let rest_have = sequence[1..]
.iter()
.any(|r| r.body.to_lowercase().contains(&payload_lower));
if first_has && !rest_have {
features.insert("temporal:only_first_request".into(), 1.0);
}
}
}
}
fn extract_first_number(body: &str) -> Option<u64> {
let mut num_str = String::new();
let mut found_digit = false;
for c in body.chars() {
if c.is_ascii_digit() {
num_str.push(c);
found_digit = true;
} else if found_digit {
break;
}
}
if found_digit {
num_str.parse().ok()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::super::tests::*;
use super::*;
#[test]
fn test_bimodal_timing() {
let response = make_response("OK", 200);
let mut ctx = make_ctx("sqli", "' AND SLEEP(5)--", response);
ctx.timing_samples = Some(vec![100, 110, 120, 5000, 5050, 5100]);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.contains_key("temporal:response_time_bimodal"));
}
#[test]
fn test_time_delay_proportional() {
let mut response = make_response("OK", 200);
response.response_time_ms = 3000;
let mut ctx = make_ctx("sqli", "' AND SLEEP(3)--", response);
ctx.injected_delay = Some(3.0);
ctx.timing_samples = Some(vec![3000, 3100, 2900]);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.contains_key("temporal:time_delay_proportional"));
}
#[test]
fn test_consistent_timing() {
let response = make_response("OK", 200);
let mut ctx = make_ctx("sqli", "' AND SLEEP(1)--", response);
ctx.timing_samples = Some(vec![1000, 1020, 1010, 990, 1005]);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.contains_key("temporal:time_delay_consistent"));
assert!(features.contains_key("temporal:timing_jitter_low"));
}
#[test]
fn test_high_variance_baseline() {
let response = make_response("OK", 200);
let mut ctx = make_ctx("sqli", "'", response);
ctx.timing_samples = Some(vec![100, 500, 200, 800, 300]);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.contains_key("temporal:high_variance_baseline"));
}
#[test]
fn test_escalating_response_size() {
let response = make_response("OK", 200);
let mut ctx = make_ctx("sqli", "'", response);
ctx.probe_sequence = Some(vec![
make_response("a", 200),
make_response("ab", 200),
make_response("abc", 200),
make_response("abcd", 200),
]);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.contains_key("temporal:escalating_response_size"));
assert!(features.contains_key("temporal:state_accumulation"));
}
#[test]
fn test_result_stable_3_retries() {
let response = make_response("OK", 200);
let mut ctx = make_ctx("sqli", "'", response);
ctx.probe_sequence = Some(vec![
make_response("same", 200),
make_response("same", 200),
make_response("same", 200),
]);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.contains_key("temporal:result_stable_3_retries"));
}
#[test]
fn test_only_first_request() {
let response = make_response("OK", 200);
let mut ctx = make_ctx("xss", "<script>", response);
ctx.probe_sequence = Some(vec![
make_response("found: <script>", 200),
make_response("not found", 200),
make_response("not found", 200),
]);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.contains_key("temporal:only_first_request"));
}
#[test]
fn test_no_features_without_data() {
let response = make_response("OK", 200);
let ctx = make_ctx("sqli", "'", response);
let mut features = HashMap::new();
extract_temporal_features(&ctx, &mut features);
assert!(features.is_empty());
}
}