1use crate::core::models::fingerprint::ServiceDefinition;
14use crate::core::models::port::{Port, Protocol, Service};
15use regex::Regex;
16use std::collections::HashMap;
17use std::sync::OnceLock;
18use std::time::Duration;
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use tokio::net::TcpStream;
21use tokio::time::timeout;
22
23pub struct CompiledMatch {
25 pub name: Option<String>,
26 pub pattern: Regex,
27 pub version_group: Option<u8>,
28 pub product: Option<String>,
29}
30
31pub struct CompiledService {
33 pub def: ServiceDefinition,
34 pub matches: Vec<CompiledMatch>,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct Identification {
40 pub service_name: String,
42 pub product: String,
44 pub version: Option<String>,
46}
47
48pub struct FingerprintEngine {
50 services: Vec<CompiledService>,
51 by_port: HashMap<u16, Vec<usize>>,
52}
53
54static ENGINE: OnceLock<FingerprintEngine> = OnceLock::new();
55
56pub fn get_engine() -> &'static FingerprintEngine {
58 ENGINE.get_or_init(|| {
59 let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/fingerprints.bin"));
60 let defs: Vec<ServiceDefinition> = bincode::deserialize(bytes)
61 .expect("Failed to natively deserialize bincode fingerprints");
62
63 let compiled = defs
64 .into_iter()
65 .map(|def| {
66 let matches = def
67 .r#match
68 .iter()
69 .filter_map(|m| {
70 Regex::new(&m.pattern).ok().map(|re| CompiledMatch {
71 name: m.name.clone(),
72 pattern: re,
73 version_group: m.version_group,
74 product: m.product.clone(),
75 })
76 })
77 .collect();
78
79 CompiledService { def, matches }
80 })
81 .collect();
82
83 FingerprintEngine::new(compiled)
84 })
85}
86
87impl FingerprintEngine {
88 pub fn new(services: Vec<CompiledService>) -> Self {
90 let mut by_port: HashMap<u16, Vec<usize>> = HashMap::new();
91
92 for (idx, srv) in services.iter().enumerate() {
93 for &port in &srv.def.service.default_ports {
94 by_port.entry(port).or_default().push(idx);
95 }
96 }
97
98 Self { services, by_port }
99 }
100
101 pub fn identify_by_banner(&self, port: u16, banner: &str) -> Option<Identification> {
107 if banner.is_empty() {
108 return None;
109 }
110
111 if let Some(indices) = self.by_port.get(&port) {
113 for &idx in indices {
114 if let Some(id) = self.match_service(&self.services[idx], banner) {
115 return Some(id);
116 }
117 }
118 }
119
120 for (idx, srv) in self.services.iter().enumerate() {
122 if let Some(indices) = self.by_port.get(&port)
123 && indices.contains(&idx)
124 {
125 continue;
126 }
127
128 if let Some(id) = self.match_service(srv, banner) {
129 return Some(id);
130 }
131 }
132
133 None
134 }
135
136 pub fn get_probes_for_port(&self, port: u16) -> Vec<&ServiceDefinition> {
138 if let Some(indices) = self.by_port.get(&port) {
139 indices.iter().map(|&idx| &self.services[idx].def).collect()
140 } else {
141 Vec::new()
142 }
143 }
144
145 fn match_service(&self, srv: &CompiledService, response: &str) -> Option<Identification> {
146 for m in &srv.matches {
147 if let Some(caps) = m.pattern.captures(response) {
148 let product = m
149 .product
150 .clone()
151 .unwrap_or_else(|| srv.def.service.name.clone());
152 let mut version = None;
153
154 if let Some(group_idx) = m.version_group
155 && let Some(ver) = caps.get(group_idx as usize)
156 {
157 version = Some(ver.as_str().to_string());
158 }
159
160 return Some(Identification {
161 service_name: srv.def.service.name.clone(),
162 product,
163 version,
164 });
165 }
166 }
167 None
168 }
169
170 pub fn format_identification(id: Identification) -> String {
172 if let Some(ver) = id.version {
173 format!("{} ({})", id.product, ver)
174 } else {
175 id.product
176 }
177 }
178}
179
180pub fn lookup_service_name(port: u16, _proto: Protocol) -> Option<String> {
182 get_engine()
183 .get_probes_for_port(port)
184 .first()
185 .map(|srv| srv.service.name.clone())
186}
187
188pub async fn fingerprint_tcp(mut stream: TcpStream, mut port: Port) -> Port {
190 let engine = get_engine();
191 let mut buffer = [0u8; 4096];
192 let mut responses = String::new();
193
194 if let Ok(Ok(n)) = timeout(Duration::from_millis(500), stream.read(&mut buffer)).await
196 && n > 0
197 {
198 responses.push_str(&String::from_utf8_lossy(&buffer[..n]));
199 if let Some(id) = engine.identify_by_banner(port.number(), &responses) {
200 let mut srv = Service::new(id.service_name, 100);
201 srv = srv.with_product(id.product);
202 if let Some(ver) = id.version {
203 srv = srv.with_version(ver);
204 }
205 port.set_service(srv);
206 return port;
207 }
208 }
209
210 for def in engine.get_probes_for_port(port.number()) {
212 for probe in &def.probe {
213 if probe.protocol != "tcp" {
214 continue;
215 }
216
217 let _ = stream.write_all(probe.payload.as_bytes()).await;
218 if let Ok(Ok(n)) = timeout(Duration::from_millis(1000), stream.read(&mut buffer)).await
219 && n > 0
220 {
221 let chunk = String::from_utf8_lossy(&buffer[..n]);
222 responses.push_str(&chunk);
223
224 for m in &def.r#match {
225 if let Ok(re) = Regex::new(&m.pattern)
226 && let Some(caps) = re.captures(&responses)
227 {
228 let mut srv = Service::new(def.service.name.clone(), 100);
229 if let Some(prod) = m.product.clone() {
230 srv = srv.with_product(prod);
231 } else {
232 srv = srv.with_product(def.service.name.clone());
233 }
234
235 if let Some(group_idx) = m.version_group
236 && let Some(ver) = caps.get(group_idx as usize)
237 {
238 srv = srv.with_version(ver.as_str());
239 }
240
241 port.set_service(srv);
242 return port;
243 }
244 }
245 }
246 }
247 }
248
249 if port.service().is_none() && !responses.is_empty() {
251 let clean: String = responses
252 .chars()
253 .filter(|c| c.is_ascii_graphic() || *c == ' ')
254 .take(32)
255 .collect();
256 if !clean.is_empty() {
257 port.set_service(Service::new(format!("banner: {}", clean), 0));
258 }
259 }
260
261 port
262}
263
264#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::core::models::fingerprint::{MatchRule, ServiceSignature};
277
278 fn mock_service(
279 name: &str,
280 ports: Vec<u16>,
281 patterns: Vec<(&str, Option<u8>)>,
282 ) -> CompiledService {
283 let def = ServiceDefinition {
284 service: ServiceSignature {
285 name: name.to_string(),
286 default_ports: ports,
287 description: None,
288 attribution: None,
289 },
290 probe: Vec::new(),
291 r#match: patterns
292 .into_iter()
293 .map(|(p, v)| MatchRule {
294 name: None,
295 pattern: p.to_string(),
296 version_group: v,
297 vendor: None,
298 product: None,
299 context: None,
300 example: None,
301 metadata: None,
302 })
303 .collect(),
304 };
305
306 let matches = def
307 .r#match
308 .iter()
309 .map(|m| CompiledMatch {
310 name: None,
311 pattern: Regex::new(&m.pattern).unwrap(),
312 version_group: m.version_group,
313 product: None,
314 })
315 .collect();
316
317 CompiledService { def, matches }
318 }
319
320 #[test]
321 fn engine_indexing() {
322 let services = vec![
323 mock_service("http", vec![80, 8080], vec![("HTTP", None)]),
324 mock_service("ssh", vec![22], vec![("SSH", None)]),
325 ];
326 let engine = FingerprintEngine::new(services);
327
328 assert_eq!(engine.by_port.get(&80).unwrap().len(), 1);
329 assert_eq!(engine.by_port.get(&22).unwrap().len(), 1);
330 assert!(!engine.by_port.contains_key(&443));
331 }
332
333 #[test]
334 fn identity_by_banner_tiered() {
335 let services = vec![
336 mock_service("http", vec![80], vec![("^HTTP/1.1", None)]),
337 mock_service(
338 "ssh",
339 vec![22],
340 vec![("^SSH-2.0-OpenSSH_([\\d.]+)", Some(1))],
341 ),
342 ];
343 let engine = FingerprintEngine::new(services);
344
345 let id = engine
347 .identify_by_banner(22, "SSH-2.0-OpenSSH_9.0")
348 .unwrap();
349 assert_eq!(id.service_name, "ssh");
350 assert_eq!(id.version, Some("9.0".to_string()));
351
352 let id = engine
354 .identify_by_banner(4444, "SSH-2.0-OpenSSH_9.0")
355 .unwrap();
356 assert_eq!(id.service_name, "ssh");
357 }
358
359 #[test]
360 fn match_priority() {
361 let services = vec![
362 mock_service("service1", vec![80], vec![("match1", None)]),
363 mock_service("service2", vec![80], vec![("match2", None)]),
364 ];
365 let engine = FingerprintEngine::new(services);
366
367 let id = engine.identify_by_banner(80, "match2").unwrap();
368 assert_eq!(id.service_name, "service2");
369 }
370
371 #[test]
372 fn format_identification() {
373 let id = Identification {
374 service_name: "ssh".into(),
375 product: "OpenSSH".into(),
376 version: Some("9.0".into()),
377 };
378 assert_eq!(
379 FingerprintEngine::format_identification(id),
380 "OpenSSH (9.0)"
381 );
382
383 let id_no_ver = Identification {
384 service_name: "ssh".into(),
385 product: "ssh".into(),
386 version: None,
387 };
388 assert_eq!(FingerprintEngine::format_identification(id_no_ver), "ssh");
389 }
390}