1pub(crate) mod smb;
24
25use serde::Serialize;
26
27use crate::scan::PortResult;
28use smb::SmbInfo;
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct OsHint {
33 pub family: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
39 pub detail: Option<String>,
40 pub evidence: Vec<String>,
43}
44
45impl OsHint {
46 pub fn summary(&self) -> String {
50 match &self.detail {
51 Some(detail) if detail.contains(&self.family) => detail.clone(),
52 Some(detail) => format!("{}, {detail}", self.family),
53 None => self.family.clone(),
54 }
55 }
56}
57
58pub(crate) struct Clues<'a> {
60 pub(crate) ttl: Option<u8>,
61 pub(crate) ports: &'a [PortResult],
62 pub(crate) vendor: Option<&'a str>,
63 pub(crate) smb: Option<&'a SmbInfo>,
64}
65
66struct Clue {
67 strength: u8,
68 family: &'static str,
69 detail: Option<String>,
70 evidence: String,
71}
72
73pub(crate) fn hint(clues: &Clues) -> Option<OsHint> {
75 let mut found: Vec<Clue> = Vec::new();
76 found.extend(clues.smb.and_then(from_smb));
77 for port in clues.ports.iter().filter(|port| port.open) {
78 if let Some(banner) = port.banner.as_deref().filter(|b| b.starts_with("SSH-")) {
79 found.extend(from_ssh_banner(banner));
80 }
81 let server = port.http.as_ref().and_then(|http| {
82 http.headers
83 .iter()
84 .find(|h| h.name.eq_ignore_ascii_case("server"))
85 });
86 if let Some(server) = server {
87 found.extend(from_server_header(&server.value));
88 }
89 }
90 found.extend(from_ports(clues.ports));
91 found.extend(clues.vendor.and_then(from_vendor));
92 found.extend(clues.ttl.and_then(from_ttl));
93
94 found.sort_by_key(|clue| std::cmp::Reverse(clue.strength));
96 let best = found.first()?;
97 let detail = best.detail.clone().or_else(|| {
98 found
99 .iter()
100 .filter(|clue| clue.family == best.family)
101 .find_map(|clue| clue.detail.clone())
102 });
103 let mut evidence: Vec<String> = Vec::new();
104 for clue in &found {
105 if !evidence.contains(&clue.evidence) {
106 evidence.push(clue.evidence.clone());
107 }
108 }
109 Some(OsHint {
110 family: best.family.to_string(),
111 detail,
112 evidence,
113 })
114}
115
116fn clue(strength: u8, family: &'static str, detail: Option<&str>, evidence: String) -> Clue {
117 Clue {
118 strength,
119 family,
120 detail: detail.map(str::to_string),
121 evidence,
122 }
123}
124
125fn from_smb(smb: &SmbInfo) -> Option<Clue> {
128 if smb.build == 0 {
129 return None;
130 }
131 let mut evidence = format!(
132 "SMB: Windows {}.{} build {}",
133 smb.major, smb.minor, smb.build
134 );
135 if let Some(name) = &smb.computer {
136 evidence.push_str(&format!(", name {name}"));
137 }
138 let detail = format!(
139 "{} (build {})",
140 windows_name(smb.major, smb.minor, smb.build),
141 smb.build
142 );
143 Some(clue(100, "Windows", Some(&detail), evidence))
144}
145
146fn windows_name(major: u8, minor: u8, build: u16) -> String {
150 match (major, minor) {
151 (10, 0) if build >= 22000 => "Windows 11 or Server 2025".to_string(),
152 (10, 0) => "Windows 10 or Server 2016-2022".to_string(),
153 (6, 3) => "Windows 8.1 or Server 2012 R2".to_string(),
154 (6, 2) => "Windows 8 or Server 2012".to_string(),
155 (6, 1) => "Windows 7 or Server 2008 R2".to_string(),
156 (6, 0) => "Windows Vista or Server 2008".to_string(),
157 _ => format!("Windows {major}.{minor}"),
158 }
159}
160
161fn from_ssh_banner(banner: &str) -> Option<Clue> {
164 const DISTROS: &[(&str, &str, &str)] = &[
165 ("Ubuntu", "Linux", "Ubuntu"),
166 ("Debian", "Linux", "Debian"),
167 ("Raspbian", "Linux", "Raspberry Pi OS"),
168 ("FreeBSD", "FreeBSD", "FreeBSD"),
169 ];
170 if banner.contains("for_Windows") {
171 return Some(clue(
172 70,
173 "Windows",
174 None,
175 "SSH banner: OpenSSH for Windows".to_string(),
176 ));
177 }
178 DISTROS.iter().find_map(|&(token, family, name)| {
179 banner
180 .contains(token)
181 .then(|| clue(80, family, Some(name), format!("SSH banner: {name}")))
182 })
183}
184
185fn from_server_header(value: &str) -> Option<Clue> {
188 const MARKERS: &[(&str, &str, Option<&str>)] = &[
189 ("(Ubuntu)", "Linux", Some("Ubuntu")),
190 ("(Debian)", "Linux", Some("Debian")),
191 ("(Raspbian)", "Linux", Some("Raspberry Pi OS")),
192 ("(CentOS)", "Linux", Some("CentOS")),
193 ("(Red Hat)", "Linux", Some("Red Hat")),
194 ("(Fedora)", "Linux", Some("Fedora")),
195 ("(Win64)", "Windows", None),
196 ("(Win32)", "Windows", None),
197 ("Microsoft-IIS", "Windows", None),
198 ("Microsoft-HTTPAPI", "Windows", None),
199 ];
200 MARKERS.iter().find_map(|&(marker, family, detail)| {
201 value.contains(marker).then(|| {
202 let seen = marker.trim_matches(|c| c == '(' || c == ')');
203 clue(60, family, detail, format!("HTTP server: {seen}"))
204 })
205 })
206}
207
208fn from_ports(ports: &[PortResult]) -> Option<Clue> {
210 let open = |n: u16| ports.iter().any(|p| p.port == n && p.open);
211 (open(135) && open(445))
212 .then(|| clue(50, "Windows", None, "ports 135 and 445 open".to_string()))
213}
214
215fn from_vendor(vendor: &str) -> Option<Clue> {
216 if vendor.contains("Apple") {
217 return Some(clue(
218 40,
219 "macOS or iOS",
220 None,
221 format!("MAC vendor: {vendor}"),
222 ));
223 }
224 if vendor.contains("Raspberry Pi") {
225 return Some(clue(
226 40,
227 "Linux",
228 Some("Raspberry Pi OS"),
229 format!("MAC vendor: {vendor}"),
230 ));
231 }
232 None
233}
234
235fn from_ttl(ttl: u8) -> Option<Clue> {
236 let family = match ttl {
237 0 => return None,
238 1..=64 => "Unix-like",
239 65..=128 => "Windows",
240 _ => "Network device",
241 };
242 Some(clue(20, family, None, format!("TTL {ttl}")))
243}
244
245#[cfg(test)]
246mod tests;