truestack 0.2.0

Security-aware technology fingerprinting — detects what is really running, not what the version string claims
Documentation
//! Behavioral HTTP fingerprinting — identifies servers by HOW they respond
//! to malformed/unusual requests, not what they claim in headers.
//!
//! This works when version strings are stripped, behind CDNs, and with
//! custom error pages. No other fingerprinting tool does this.
//!
//! Probes:
//! 1. Invalid HTTP method → error format reveals server
//! 2. Overlong URI → 414 response shape varies by server
//! 3. Missing Host header → response reveals HTTP/1.0 vs 1.1 handling
//! 4. Invalid Content-Length → error handling reveals framework
//! 5. HTTP/0.9 request → only some servers support it

use crate::{TechCategory, Technology};

/// Result of behavioral probing.
#[derive(Debug, Clone)]
pub struct BehaviorFingerprint {
    /// Detected server software from behavioral analysis.
    pub server: Option<String>,
    /// Confidence in the behavioral detection (0-100).
    pub confidence: u8,
    /// Raw probe results for debugging.
    pub probes: Vec<ProbeResult>,
}

#[derive(Debug, Clone)]
pub struct ProbeResult {
    pub probe_name: &'static str,
    pub status: u16,
    pub body_prefix: String,
    pub has_server_header: bool,
    pub content_type: Option<String>,
}

/// Known behavioral signatures.
/// (probe_name, status_code, body_contains, content_type_contains) → server
const SIGNATURES: &[(&str, u16, &str, &str, &str)] = &[
    // nginx returns 405 for invalid methods with "Not Allowed" in a minimal HTML page
    ("invalid_method", 405, "<center>nginx", "", "nginx"),
    // Apache returns 501 "Method Not Implemented" with a detailed error page
    (
        "invalid_method",
        501,
        "Method Not Implemented",
        "",
        "apache",
    ),
    // IIS returns 405 with "Method Not Allowed" and an ASP.NET marker
    (
        "invalid_method",
        405,
        "Method Not Allowed",
        "text/html",
        "iis",
    ),
    // Caddy returns 405 with no body
    ("invalid_method", 405, "", "", "caddy"),
    // Express/Node returns 404 with "Cannot XYZMETHOD /"
    ("invalid_method", 404, "Cannot ", "", "express"),
    // nginx 414 says "Request-URI Too Large"
    ("overlong_uri", 414, "Request-URI Too Large", "", "nginx"),
    // Apache 414 says "Request-URI Too Long"
    ("overlong_uri", 414, "Request-URI Too Long", "", "apache"),
    // nginx returns 400 for missing Host header
    ("missing_host", 400, "400 Bad Request", "", "nginx"),
    // Apache returns 400 differently
    ("missing_host", 400, "Bad Request", "text/html", "apache"),
];

/// Execute behavioral probes against `base_url` and append any identified
/// server technology to `technologies`.
pub async fn identify(
    client: &reqwest::Client,
    base_url: &str,
    technologies: &mut Vec<Technology>,
) -> anyhow::Result<()> {
    let mut probes_results = Vec::new();
    for (probe_name, method_str, url, extra_headers) in probes(base_url) {
        let method = reqwest::Method::from_bytes(method_str.as_bytes())?;
        let mut req = client.request(method, &url);
        for (k, v) in extra_headers {
            req = req.header(k, v);
        }
        match req.send().await {
            Ok(resp) => {
                let status = resp.status().as_u16();
                let has_server_header = resp.headers().contains_key("server");
                let content_type = resp
                    .headers()
                    .get("content-type")
                    .and_then(|v| v.to_str().ok())
                    .map(|s| s.to_string());
                let body = resp.text().await.unwrap_or_default();
                let body_prefix = body.chars().take(512).collect();
                probes_results.push(ProbeResult {
                    probe_name,
                    status,
                    body_prefix,
                    has_server_header,
                    content_type,
                });
            }
            Err(_) => {
                // Probe failed — record a placeholder so signatures that
                // expect connection failure can be added later.
                probes_results.push(ProbeResult {
                    probe_name,
                    status: 0,
                    body_prefix: String::new(),
                    has_server_header: false,
                    content_type: None,
                });
            }
        }
    }
    if let Some(tech) = identify_from_probes(&probes_results) {
        technologies.push(tech);
    }
    Ok(())
}

/// Analyze behavioral probe results to identify the server.
fn identify_from_probes(probes: &[ProbeResult]) -> Option<Technology> {
    let mut scores: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();

    for probe in probes {
        for &(probe_name, status, body_sig, ct_sig, server) in SIGNATURES {
            if probe.probe_name == probe_name
                && probe.status == status
                && (body_sig.is_empty() || probe.body_prefix.contains(body_sig))
                && (ct_sig.is_empty()
                    || probe.content_type.as_deref().unwrap_or("").contains(ct_sig))
            {
                *scores.entry(server).or_insert(0) += 1;
            }
        }
    }

    let (best, &count) = scores.iter().max_by_key(|(_, &v)| v)?;
    let total_probes = probes.len() as u32;
    let confidence = ((count as f64 / total_probes.max(1) as f64) * 100.0).min(100.0) as u8;

    if confidence < 30 {
        return None;
    }

    Some(Technology {
        name: best.to_string(),
        version: None,
        category: TechCategory::Server,
        confidence,
    })
}

/// Generate the malformed request probes.
///
/// Returns (probe_name, method, path, headers) tuples.
/// The caller is responsible for actually sending the requests.
pub fn probes(
    base_url: &str,
) -> Vec<(
    &'static str,
    &'static str,
    String,
    Vec<(&'static str, &'static str)>,
)> {
    vec![
        // Probe 1: Invalid HTTP method
        ("invalid_method", "XYZMETHOD", base_url.to_string(), vec![]),
        // Probe 2: Overlong URI (8KB path)
        (
            "overlong_uri",
            "GET",
            format!("{}/{}", base_url, "A".repeat(8192)),
            vec![],
        ),
        // Probe 3: Unusual but valid method
        ("trace_method", "TRACE", base_url.to_string(), vec![]),
    ]
}