Skip to main content

zond_engine/plugins/
fingerprint.rs

1// Copyright (c) 2026 Erik Lening (hollowpointer) and Contributors
2//
3// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4// If a copy of the MPL was not distributed with this file, You can obtain one at
5// https://mozilla.org/MPL/2.0/.
6
7//! Service fingerprinting engine for identification of network services.
8//!
9//! This module provides the core logic for identifying services based on network banners
10//! and active probing. It uses a tiered identification strategy and port-based indexing
11//! to ensure high performance even with large signature datasets.
12
13use 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
23/// A compiled regex match rule for a service.
24pub struct CompiledMatch {
25    pub name: Option<String>,
26    pub pattern: Regex,
27    pub version_group: Option<u8>,
28    pub product: Option<String>,
29}
30
31/// A service definition with its compiled match rules.
32pub struct CompiledService {
33    pub def: ServiceDefinition,
34    pub matches: Vec<CompiledMatch>,
35}
36
37/// The result of a successful service identification.
38#[derive(Debug, Clone, PartialEq)]
39pub struct Identification {
40    /// The canonical name of the service (e.g., "ssh").
41    pub service_name: String,
42    /// The specific product identified (e.g., "OpenSSH").
43    pub product: String,
44    /// The version string, if captured.
45    pub version: Option<String>,
46}
47
48/// High-performance engine for matching network responses against service signatures.
49pub struct FingerprintEngine {
50    services: Vec<CompiledService>,
51    by_port: HashMap<u16, Vec<usize>>,
52}
53
54static ENGINE: OnceLock<FingerprintEngine> = OnceLock::new();
55
56/// Returns a shared reference to the global fingerprint engine, initializing it if necessary.
57pub 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    /// Creates a new engine and builds the port-based index for efficient lookups.
89    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    /// Attempts to identify a service from a banner string.
102    ///
103    /// This uses a two-tier strategy:
104    /// 1. Check services typically found on the specified port.
105    /// 2. If no match, check all other available signatures (handles non-standard ports).
106    pub fn identify_by_banner(&self, port: u16, banner: &str) -> Option<Identification> {
107        if banner.is_empty() {
108            return None;
109        }
110
111        // Tier 1: Targeted matches
112        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        // Tier 2: Global fallback
121        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    /// Returns the list of service definitions that define probes for the given port.
137    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    /// Formats an identification into a human-readable string.
171    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
180/// Returns the primary service name associated with a port based on known definitions.
181pub 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
188/// High-level entry point for fingerprinting a TCP stream.
189pub 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    // Stage 1: Banner Grab
195    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    // Stage 2: Active Probing
211    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    // Stage 3: Banner Fallback
250    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// ╔════════════════════════════════════════════╗
265// ║ ████████╗███████╗███████╗████████╗███████╗ ║
266// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
267// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
268// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
269// ║    ██║   ███████╗███████║   ██║   ███████║ ║
270// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
271// ╚════════════════════════════════════════════╝
272
273#[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        // Tier 1: Correct port
346        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        // Tier 2: Random port
353        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}