huginn-net-http 2.1.0

HTTP fingerprinting (p0f-style) analysis for huginn-net
Documentation
pub mod common;
pub mod languages;
pub mod observable;
pub mod ua_os;

pub use common::{
    build_params, HeaderSource, HttpCookie, HttpHeader, HttpParser, HttpProcessor,
    MatchedSignatureNotes, ParsingMetadata,
};
pub use languages::get_highest_quality_language;
pub use observable::*;
pub use ua_os::{
    check_ua_os_agreement, NotCheckedReason, ObservedOs, ObservedOsInput, ObservedOsSource,
    UaOsAgreement,
};

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Version {
    /// HTTP/1.0
    V10,
    /// HTTP/1.1
    V11,
    /// HTTP/2
    V20,
    /// HTTP/3
    V30,
    /// Matches any HTTP version (used in database signatures).
    Any,
}

impl Version {
    pub fn parse(version_str: &str) -> Option<Self> {
        match version_str {
            "HTTP/1.0" => Some(Version::V10),
            "HTTP/1.1" => Some(Version::V11),
            "HTTP/2" | "HTTP/2.0" => Some(Version::V20),
            "HTTP/3" | "HTTP/3.0" => Some(Version::V30),
            _ => None,
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            Version::V10 => "HTTP/1.0",
            Version::V11 => "HTTP/1.1",
            Version::V20 => "HTTP/2",
            Version::V30 => "HTTP/3",
            Version::Any => "Any",
        }
    }
}

impl std::str::FromStr for Version {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::parse(s).ok_or(())
    }
}

impl core::fmt::Display for Version {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(match self {
            Version::V10 => "0",
            Version::V11 => "1",
            Version::V20 => "2",
            Version::V30 => "3",
            Version::Any => "*",
        })
    }
}

/// A header name (and optional value) used in p0f-style HTTP signatures.
///
/// Headers may be marked optional (`?`) to signal "match if present, ignore
/// if absent".
#[derive(Clone, Debug, PartialEq)]
pub struct Header {
    pub optional: bool,
    pub name: String,
    pub value: Option<String>,
}

impl Header {
    pub fn new<S: AsRef<str>>(name: S) -> Self {
        Header { optional: false, name: name.as_ref().to_owned(), value: None }
    }

    pub fn with_value<S: AsRef<str>>(mut self, value: S) -> Self {
        self.value = Some(value.as_ref().to_owned());
        self
    }

    pub fn with_optional_value<S: AsRef<str>>(mut self, value: Option<S>) -> Self {
        self.value = value.map(|v| v.as_ref().to_owned());
        self
    }

    pub fn optional(mut self) -> Self {
        self.optional = true;
        self
    }
}

impl core::fmt::Display for Header {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        if self.optional {
            f.write_str("?")?;
        }
        f.write_str(&self.name)?;
        if let Some(ref value) = self.value {
            write!(f, "=[{value}]")?;
        }
        Ok(())
    }
}

/// Notes about a matched HTTP signature, mirroring p0f's `params` field.
///
/// These are independent observations, not a single verdict: a host can be
/// dishonest *and* have matched a generic signature, and p0f reports both.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HttpParams {
    /// The `User-Agent`/`Server` string does not back up the software the
    /// matched signature declares, so the host is claiming to be something
    /// its header layout says it is not.
    pub dishonest: bool,
    /// The traffic carried no `User-Agent` (request) or `Server` (response)
    /// at all, so there was no claim to check.
    pub anonymous: bool,
    /// The signature that matched is a catch-all, so the label names a family
    /// rather than a precise product.
    pub generic: bool,
}

impl HttpParams {
    /// Whether nothing worth reporting was found.
    pub fn is_empty(&self) -> bool {
        !self.dishonest && !self.anonymous && !self.generic
    }
}

impl core::fmt::Display for HttpParams {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        if self.is_empty() {
            return f.write_str("none");
        }

        let mut first = true;
        for (flag, name) in [
            (self.dishonest, "dishonest"),
            (self.anonymous, "anonymous"),
            (self.generic, "generic"),
        ] {
            if flag {
                if !first {
                    f.write_str(" ")?;
                }
                f.write_str(name)?;
                first = false;
            }
        }
        Ok(())
    }
}

pub fn request_optional_headers() -> Vec<&'static str> {
    vec![
        "Cookie",
        "Referer",
        "Origin",
        "Range",
        "If-Modified-Since",
        "If-None-Match",
        "Via",
        "X-Forwarded-For",
        "Authorization",
        "Proxy-Authorization",
        "Cache-Control",
    ]
}

pub fn response_optional_headers() -> Vec<&'static str> {
    vec![
        "Set-Cookie",
        "Last-Modified",
        "ETag",
        "Content-Length",
        "Content-Disposition",
        "Cache-Control",
        "Expires",
        "Pragma",
        "Location",
        "Refresh",
        "Content-Range",
        "Vary",
    ]
}

pub fn request_skip_value_headers() -> Vec<&'static str> {
    vec!["Host", "User-Agent"]
}

pub fn response_skip_value_headers() -> Vec<&'static str> {
    vec!["Date", "Content-Type", "Server"]
}

pub fn request_common_headers() -> Vec<&'static str> {
    vec![
        "Host",
        "User-Agent",
        "Connection",
        "Accept",
        "Accept-Encoding",
        "Accept-Language",
        "Accept-Charset",
        "Keep-Alive",
    ]
}

pub fn response_common_headers() -> Vec<&'static str> {
    vec!["Content-Type", "Connection", "Keep-Alive", "Accept-Ranges", "Date"]
}