parlov-analysis 0.5.0

Analysis engine trait and signal detection for parlov.
Documentation
//! Header differential signal extractor.

use std::collections::BTreeSet;

use parlov_core::{DifferentialSet, Signal, SignalKind};

/// Headers worth flagging for existence oracle detection.
const NOTABLE_HEADERS: &[&str] = &[
    "etag",
    "last-modified",
    "content-range",
    "accept-ranges",
    "www-authenticate",
    "allow",
];

/// Extracts header differential signals from baseline vs probe responses.
///
/// Compares the most recent baseline and probe response headers. For each header present in one
/// but not the other, produces a `HeaderPresence` signal. For each header with different values,
/// produces a `HeaderValue` signal. Only flags notable security-relevant headers.
pub fn extract(data: &DifferentialSet) -> Vec<Signal> {
    let Some(baseline) = data.baseline.last() else {
        return vec![];
    };
    let Some(probe) = data.probe.last() else {
        return vec![];
    };

    let b_headers = &baseline.response.headers;
    let p_headers = &probe.response.headers;

    let names: BTreeSet<&str> = b_headers
        .keys()
        .chain(p_headers.keys())
        .map(http::HeaderName::as_str)
        .filter(|n| NOTABLE_HEADERS.contains(n))
        .collect();

    let mut signals = Vec::new();
    for name in names {
        let b_val = b_headers.get(name).and_then(|v| v.to_str().ok());
        let p_val = p_headers.get(name).and_then(|v| v.to_str().ok());

        match (b_val, p_val) {
            (Some(_), None) => signals.push(Signal {
                kind: SignalKind::HeaderPresence,
                evidence: format!("{name} present in baseline, absent in probe"),
                rfc_basis: None,
            }),
            (None, Some(_)) => signals.push(Signal {
                kind: SignalKind::HeaderPresence,
                evidence: format!("{name} absent in baseline, present in probe"),
                rfc_basis: None,
            }),
            (Some(b), Some(p)) if b != p => signals.push(Signal {
                kind: SignalKind::HeaderValue,
                evidence: format!("{name}: \"{b}\" (baseline) vs \"{p}\" (probe)"),
                rfc_basis: None,
            }),
            _ => {}
        }
    }
    signals
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::signals::tests::single_diff_set_with_headers;
    use http::{HeaderMap, HeaderName, HeaderValue};

    fn headers_with(pairs: &[(&str, &str)]) -> HeaderMap {
        let mut map = HeaderMap::new();
        for &(name, value) in pairs {
            map.insert(
                HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
                HeaderValue::from_str(value).expect("valid header value"),
            );
        }
        map
    }

    #[test]
    fn etag_present_in_baseline_only() {
        let b = headers_with(&[("etag", "\"abc\"")]);
        let p = HeaderMap::new();
        let ds = single_diff_set_with_headers(200, 404, b, p);
        let signals = extract(&ds);
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].kind, SignalKind::HeaderPresence);
        assert!(signals[0].evidence.contains("etag"));
        assert!(signals[0].evidence.contains("baseline"));
    }

    #[test]
    fn www_authenticate_present_in_probe_only() {
        let b = HeaderMap::new();
        let p = headers_with(&[("www-authenticate", "Bearer")]);
        let ds = single_diff_set_with_headers(200, 401, b, p);
        let signals = extract(&ds);
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].kind, SignalKind::HeaderPresence);
        assert!(signals[0].evidence.contains("www-authenticate"));
        assert!(signals[0].evidence.contains("probe"));
    }

    #[test]
    fn allow_header_different_values() {
        let b = headers_with(&[("allow", "GET, HEAD")]);
        let p = headers_with(&[("allow", "GET, HEAD, POST")]);
        let ds = single_diff_set_with_headers(405, 405, b, p);
        let signals = extract(&ds);
        assert_eq!(signals.len(), 1);
        assert_eq!(signals[0].kind, SignalKind::HeaderValue);
        assert!(signals[0].evidence.contains("allow"));
    }

    #[test]
    fn identical_notable_headers_produce_no_signal() {
        let b = headers_with(&[("etag", "\"abc\"")]);
        let p = headers_with(&[("etag", "\"abc\"")]);
        let ds = single_diff_set_with_headers(200, 200, b, p);
        assert!(extract(&ds).is_empty());
    }

    #[test]
    fn non_notable_headers_ignored() {
        let b = headers_with(&[("x-custom", "foo")]);
        let p = headers_with(&[("x-custom", "bar")]);
        let ds = single_diff_set_with_headers(200, 200, b, p);
        assert!(extract(&ds).is_empty());
    }

    #[test]
    fn empty_exchanges_produce_no_signals() {
        let ds = crate::signals::tests::diff_set_with_statuses(&[], &[]);
        assert!(extract(&ds).is_empty());
    }
}