avirn-server 0.1.0

HTTP server and OSINT adapters for Avirn.
Documentation
use avirn_core::{async_trait, Adapter, AvirnError, Confidence, Finding, Input};
use reqwest::header::{self, HeaderMap};

pub fn default_registry() -> avirn_core::Registry {
    let mut registry = avirn_core::Registry::new();
    registry.register(GitHubUsername);
    registry.register(HttpProbe);
    registry
}

/// GitHub public username lookup. Unauthenticated requests are rate-limited
/// to 60 per hour per IP, so this is fine for a demo but should be cached or
/// authenticated in production.
pub struct GitHubUsername;

#[async_trait]
impl Adapter for GitHubUsername {
    fn name(&self) -> &'static str {
        "github_username"
    }

    fn accepts(&self, input: &Input) -> bool {
        matches!(input, Input::Username(_))
    }

    async fn check(&self, input: &Input) -> Result<Vec<Finding>, AvirnError> {
        let username = input.value();
        let client = reqwest::Client::new();
        let mut headers = HeaderMap::new();
        headers.insert(header::USER_AGENT, "avirn/0.1.0".parse().unwrap());

        let resp = client
            .get(format!("https://api.github.com/users/{}", username))
            .headers(headers)
            .send()
            .await
            .map_err(|e| AvirnError::Network(e.to_string()))?;

        if resp.status().is_success() {
            let body: serde_json::Value = resp.json().await.map_err(|e| AvirnError::Network(e.to_string()))?;
            let profile_url = body["html_url"].as_str().map(|s| s.to_string());
            let name = body["name"].as_str().unwrap_or(username);
            let evidence = format!("GitHub account exists for {} ({}).", username, name);
            Ok(vec![Finding {
                source_type: self.name().to_string(),
                identifier: username.to_string(),
                url: profile_url,
                evidence,
                confidence: Confidence::Confirmed,
                observed_at: Some(chrono::Utc::now()),
                raw: Some(body),
            }])
        } else if resp.status().as_u16() == 404 {
            Ok(vec![Finding {
                source_type: self.name().to_string(),
                identifier: username.to_string(),
                url: None,
                evidence: format!("No GitHub account found for {}.", username),
                confidence: Confidence::Unrelated,
                observed_at: Some(chrono::Utc::now()),
                raw: None,
            }])
        } else {
            Err(AvirnError::Adapter {
                src: self.name().to_string(),
                message: format!("GitHub returned status {}", resp.status()),
            })
        }
    }
}

/// HTTP probe and DNS resolution for a URL or domain.
pub struct HttpProbe;

#[async_trait]
impl Adapter for HttpProbe {
    fn name(&self) -> &'static str {
        "http_probe"
    }

    fn accepts(&self, input: &Input) -> bool {
        matches!(input, Input::Url(_))
    }

    async fn check(&self, input: &Input) -> Result<Vec<Finding>, AvirnError> {
        let url_str = input.value();
        let parsed = url::Url::parse(url_str)
            .map_err(|_| AvirnError::InvalidInput(format!("invalid URL: {}", url_str)))?;
        let host = parsed
            .host_str()
            .ok_or_else(|| AvirnError::InvalidInput(format!("no host in URL: {}", url_str)))?;

        let mut findings = Vec::new();

        // DNS resolution.
        match tokio::net::lookup_host(format!("{}:80", host)).await {
            Ok(addrs) => {
                let addrs: Vec<_> = addrs.collect();
                if addrs.is_empty() {
                    findings.push(Finding {
                        source_type: self.name().to_string(),
                        identifier: host.to_string(),
                        url: None,
                        evidence: format!("{} did not resolve to any IP address.", host),
                        confidence: Confidence::Likely,
                        observed_at: Some(chrono::Utc::now()),
                        raw: None,
                    });
                } else {
                    findings.push(Finding {
                        source_type: self.name().to_string(),
                        identifier: host.to_string(),
                        url: None,
                        evidence: format!("{} resolves to {} IP address(es).", host, addrs.len()),
                        confidence: Confidence::Confirmed,
                        observed_at: Some(chrono::Utc::now()),
                        raw: Some(serde_json::json!({ "ips": addrs.iter().map(|a| a.ip().to_string()).collect::<Vec<_>>() })),
                    });
                }
            }
            Err(e) => {
                findings.push(Finding {
                    source_type: self.name().to_string(),
                    identifier: host.to_string(),
                    url: None,
                    evidence: format!("{} could not be resolved: {}.", host, e),
                    confidence: Confidence::Likely,
                    observed_at: Some(chrono::Utc::now()),
                    raw: None,
                });
            }
        }

        // HTTP probe.
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(10))
            .redirect(reqwest::redirect::Policy::limited(3))
            .build()
            .map_err(|e| AvirnError::Network(e.to_string()))?;

        match client.head(url_str).send().await {
            Ok(resp) => {
                let status = resp.status();
                let final_url = resp.url().to_string();
                let mut evidence = format!("HTTP probe returned {}.", status);
                if final_url != url_str {
                    evidence.push_str(&format!(" Redirected to {}.", final_url));
                }
                findings.push(Finding {
                    source_type: self.name().to_string(),
                    identifier: url_str.to_string(),
                    url: Some(final_url.clone()),
                    evidence,
                    confidence: if status.is_success() {
                        Confidence::Confirmed
                    } else if status.is_redirection() {
                        Confidence::Inconclusive
                    } else {
                        Confidence::Likely
                    },
                    observed_at: Some(chrono::Utc::now()),
                    raw: Some(serde_json::json!({ "status": status.as_u16(), "final_url": final_url })),
                });
            }
            Err(e) => {
                findings.push(Finding {
                    source_type: self.name().to_string(),
                    identifier: url_str.to_string(),
                    url: None,
                    evidence: format!("HTTP probe failed: {}.", e),
                    confidence: Confidence::Likely,
                    observed_at: Some(chrono::Utc::now()),
                    raw: None,
                });
            }
        }

        Ok(findings)
    }
}