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
11pub 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
72pub 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 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 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}