holger-intel 2.0.0

Holger offline vulnerability intelligence: OSV / NVD / CISA KEV normalized into one advisory set, carried across the airgap as a signed holger.update-bundle/v1 (product holger.intel/v1), verified with anti-rollback, and kept in a persistent local advisory database the scanners read. Network-free unless the `fetch` feature is on.
Documentation
//! The network gate. Every byte this crate could ever pull from outside goes
//! through [`IntelNet::get`], and an offline gate refuses every URL before any
//! socket exists.

use std::sync::atomic::{AtomicU64, Ordering};

/// A network refusal or failure.
#[derive(Debug, thiserror::Error)]
pub enum NetError {
    /// The gate is offline (`intel.offline = true`, or a build with no fetcher):
    /// the URL was refused, and nothing was sent.
    #[error("network refused: intel is offline, and {url} was not fetched")]
    Refused { url: String },
    /// The fetch was attempted and failed: no route, DNS, TLS, a non-200.
    #[error("network unavailable for {url}: {why}")]
    Unavailable { url: String, why: String },
}

impl NetError {
    pub fn is_refused(&self) -> bool {
        matches!(self, NetError::Refused { .. })
    }
}

/// Something that can GET a URL.
pub trait Fetch: Send + Sync {
    fn get(&self, url: &str) -> Result<Vec<u8>, NetError>;
}

/// The gate itself: offline, or online over one [`Fetch`].
pub struct IntelNet {
    inner: Option<Box<dyn Fetch>>,
    refused: AtomicU64,
}

impl IntelNet {
    /// A gate that refuses every URL. There is no fetcher behind it at all, so
    /// a refusal is not a policy check that could be skipped: there is nothing
    /// to call.
    pub fn offline() -> Self {
        Self { inner: None, refused: AtomicU64::new(0) }
    }

    /// A gate over `fetch`.
    pub fn online(fetch: Box<dyn Fetch>) -> Self {
        Self { inner: Some(fetch), refused: AtomicU64::new(0) }
    }

    /// The online gate over `ureq` (feature `fetch`).
    #[cfg(feature = "fetch")]
    pub fn http() -> Self {
        Self::online(Box::new(UreqFetch::new()))
    }

    pub fn is_offline(&self) -> bool {
        self.inner.is_none()
    }

    /// How many URLs this gate has refused.
    pub fn refused_count(&self) -> u64 {
        self.refused.load(Ordering::Relaxed)
    }

    pub fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
        match &self.inner {
            None => {
                self.refused.fetch_add(1, Ordering::Relaxed);
                Err(NetError::Refused { url: url.to_string() })
            }
            Some(f) => f.get(url),
        }
    }
}

/// The online fetcher: https only, bounded time and size, no redirects, no
/// proxy from the environment.
#[cfg(feature = "fetch")]
pub struct UreqFetch {
    agent: ureq::Agent,
}

#[cfg(feature = "fetch")]
impl UreqFetch {
    /// The largest body accepted (an OSV PyPI dump is well under this).
    pub const MAX_BODY: u64 = 1024 * 1024 * 1024;

    pub fn new() -> Self {
        use std::time::Duration;
        let agent = ureq::Agent::config_builder()
            .timeout_connect(Some(Duration::from_secs(15)))
            .timeout_recv_response(Some(Duration::from_secs(60)))
            .timeout_recv_body(Some(Duration::from_secs(60 * 60)))
            .max_redirects(0)
            .http_status_as_error(false)
            .proxy(None)
            .build()
            .new_agent();
        Self { agent }
    }
}

#[cfg(feature = "fetch")]
impl Default for UreqFetch {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(feature = "fetch")]
impl Fetch for UreqFetch {
    fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
        let unavailable = |why: String| NetError::Unavailable { url: url.to_string(), why };
        if !url.starts_with("https://") && !is_loopback_http(url) {
            return Err(unavailable("only https:// feeds are fetched (plain http on loopback only)".into()));
        }
        let mut resp = self.agent.get(url).call().map_err(|e| unavailable(e.to_string()))?;
        let status = resp.status().as_u16();
        if status != 200 {
            return Err(unavailable(format!("HTTP {status}")));
        }
        resp.body_mut()
            .with_config()
            .limit(Self::MAX_BODY)
            .read_to_vec()
            .map_err(|e| unavailable(e.to_string()))
    }
}

/// `http://127.0.0.1…`, `http://[::1]…` or `http://localhost…`: a feed mirror on
/// the box itself (`intel.feed_mirror`), the one plain-http source fetched.
pub fn is_loopback_http(url: &str) -> bool {
    let Some(rest) = url.strip_prefix("http://") else { return false };
    let host = rest.split('/').next().unwrap_or("");
    let host = host.rsplit_once(':').map(|(h, p)| if p.chars().all(|c| c.is_ascii_digit()) { h } else { host }).unwrap_or(host);
    matches!(host, "127.0.0.1" | "[::1]" | "localhost")
}

#[cfg(test)]
mod loopback_tests {
    #[test]
    fn only_a_loopback_host_may_be_plain_http() {
        for ok in ["http://127.0.0.1:8080/index.txt", "http://localhost/kev.json", "http://[::1]:9/x"] {
            assert!(super::is_loopback_http(ok), "{ok}");
        }
        for no in ["http://127.0.0.1.evil.example/x", "http://10.0.0.1/x", "https://127.0.0.1/x", "http://localhost.example:80/x", "ftp://localhost/x"] {
            assert!(!super::is_loopback_http(no), "{no}");
        }
    }
}