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
}
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()),
})
}
}
}
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();
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,
});
}
}
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)
}
}