Skip to main content

avirn_server/
adapters.rs

1use avirn_core::{async_trait, Adapter, AvirnError, Confidence, Finding, Input};
2use reqwest::header::{self, HeaderMap};
3
4pub fn default_registry() -> avirn_core::Registry {
5    let mut registry = avirn_core::Registry::new();
6    registry.register(GitHubUsername);
7    registry.register(HttpProbe);
8    registry
9}
10
11/// GitHub public username lookup. Unauthenticated requests are rate-limited
12/// to 60 per hour per IP, so this is fine for a demo but should be cached or
13/// authenticated in production.
14pub struct GitHubUsername;
15
16#[async_trait]
17impl Adapter for GitHubUsername {
18    fn name(&self) -> &'static str {
19        "github_username"
20    }
21
22    fn accepts(&self, input: &Input) -> bool {
23        matches!(input, Input::Username(_))
24    }
25
26    async fn check(&self, input: &Input) -> Result<Vec<Finding>, AvirnError> {
27        let username = input.value();
28        let client = reqwest::Client::new();
29        let mut headers = HeaderMap::new();
30        headers.insert(header::USER_AGENT, "avirn/0.1.0".parse().unwrap());
31
32        let resp = client
33            .get(format!("https://api.github.com/users/{}", username))
34            .headers(headers)
35            .send()
36            .await
37            .map_err(|e| AvirnError::Network(e.to_string()))?;
38
39        if resp.status().is_success() {
40            let body: serde_json::Value = resp.json().await.map_err(|e| AvirnError::Network(e.to_string()))?;
41            let profile_url = body["html_url"].as_str().map(|s| s.to_string());
42            let name = body["name"].as_str().unwrap_or(username);
43            let evidence = format!("GitHub account exists for {} ({}).", username, name);
44            Ok(vec![Finding {
45                source_type: self.name().to_string(),
46                identifier: username.to_string(),
47                url: profile_url,
48                evidence,
49                confidence: Confidence::Confirmed,
50                observed_at: Some(chrono::Utc::now()),
51                raw: Some(body),
52            }])
53        } else if resp.status().as_u16() == 404 {
54            Ok(vec![Finding {
55                source_type: self.name().to_string(),
56                identifier: username.to_string(),
57                url: None,
58                evidence: format!("No GitHub account found for {}.", username),
59                confidence: Confidence::Unrelated,
60                observed_at: Some(chrono::Utc::now()),
61                raw: None,
62            }])
63        } else {
64            Err(AvirnError::Adapter {
65                src: self.name().to_string(),
66                message: format!("GitHub returned status {}", resp.status()),
67            })
68        }
69    }
70}
71
72/// HTTP probe and DNS resolution for a URL or domain.
73pub struct HttpProbe;
74
75#[async_trait]
76impl Adapter for HttpProbe {
77    fn name(&self) -> &'static str {
78        "http_probe"
79    }
80
81    fn accepts(&self, input: &Input) -> bool {
82        matches!(input, Input::Url(_))
83    }
84
85    async fn check(&self, input: &Input) -> Result<Vec<Finding>, AvirnError> {
86        let url_str = input.value();
87        let parsed = url::Url::parse(url_str)
88            .map_err(|_| AvirnError::InvalidInput(format!("invalid URL: {}", url_str)))?;
89        let host = parsed
90            .host_str()
91            .ok_or_else(|| AvirnError::InvalidInput(format!("no host in URL: {}", url_str)))?;
92
93        let mut findings = Vec::new();
94
95        // DNS resolution.
96        match tokio::net::lookup_host(format!("{}:80", host)).await {
97            Ok(addrs) => {
98                let addrs: Vec<_> = addrs.collect();
99                if addrs.is_empty() {
100                    findings.push(Finding {
101                        source_type: self.name().to_string(),
102                        identifier: host.to_string(),
103                        url: None,
104                        evidence: format!("{} did not resolve to any IP address.", host),
105                        confidence: Confidence::Likely,
106                        observed_at: Some(chrono::Utc::now()),
107                        raw: None,
108                    });
109                } else {
110                    findings.push(Finding {
111                        source_type: self.name().to_string(),
112                        identifier: host.to_string(),
113                        url: None,
114                        evidence: format!("{} resolves to {} IP address(es).", host, addrs.len()),
115                        confidence: Confidence::Confirmed,
116                        observed_at: Some(chrono::Utc::now()),
117                        raw: Some(serde_json::json!({ "ips": addrs.iter().map(|a| a.ip().to_string()).collect::<Vec<_>>() })),
118                    });
119                }
120            }
121            Err(e) => {
122                findings.push(Finding {
123                    source_type: self.name().to_string(),
124                    identifier: host.to_string(),
125                    url: None,
126                    evidence: format!("{} could not be resolved: {}.", host, e),
127                    confidence: Confidence::Likely,
128                    observed_at: Some(chrono::Utc::now()),
129                    raw: None,
130                });
131            }
132        }
133
134        // HTTP probe.
135        let client = reqwest::Client::builder()
136            .timeout(std::time::Duration::from_secs(10))
137            .redirect(reqwest::redirect::Policy::limited(3))
138            .build()
139            .map_err(|e| AvirnError::Network(e.to_string()))?;
140
141        match client.head(url_str).send().await {
142            Ok(resp) => {
143                let status = resp.status();
144                let final_url = resp.url().to_string();
145                let mut evidence = format!("HTTP probe returned {}.", status);
146                if final_url != url_str {
147                    evidence.push_str(&format!(" Redirected to {}.", final_url));
148                }
149                findings.push(Finding {
150                    source_type: self.name().to_string(),
151                    identifier: url_str.to_string(),
152                    url: Some(final_url.clone()),
153                    evidence,
154                    confidence: if status.is_success() {
155                        Confidence::Confirmed
156                    } else if status.is_redirection() {
157                        Confidence::Inconclusive
158                    } else {
159                        Confidence::Likely
160                    },
161                    observed_at: Some(chrono::Utc::now()),
162                    raw: Some(serde_json::json!({ "status": status.as_u16(), "final_url": final_url })),
163                });
164            }
165            Err(e) => {
166                findings.push(Finding {
167                    source_type: self.name().to_string(),
168                    identifier: url_str.to_string(),
169                    url: None,
170                    evidence: format!("HTTP probe failed: {}.", e),
171                    confidence: Confidence::Likely,
172                    observed_at: Some(chrono::Utc::now()),
173                    raw: None,
174                });
175            }
176        }
177
178        Ok(findings)
179    }
180}