Skip to main content

zond_engine/core/models/port/
service.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 Identification
8//!
9//! This module provides the [`Service`] model, detailing network services
10//! and their specific implementation details. It supports progressive
11//! fingerprinting, allowing low-confidence guesses to be upgraded as
12//! deeper script and protocol analysis finishes.
13
14/// Information about a detected service on a network port.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Service {
17    /// The high-level service protocol name (e.g., "ssh", "http", "postgresql").
18    name: String,
19
20    /// A metric from 0 to 100 representing the certainty of this identification.
21    ///
22    /// For example: `0` = Table lookup by port number. `100` = Full protocol handshake.
23    confidence: u8,
24
25    /// The specific product or daemon name (e.g., "OpenSSH", "nginx").
26    product: Option<String>,
27
28    /// The version string reported or detected (e.g., "8.9p1", "1.21.0").
29    version: Option<String>,
30
31    /// Additional metadata or environment hints (e.g., "protocol 2.0", "Debian").
32    extrainfo: Option<String>,
33
34    /// A list of Common Platform Enumeration (CPE) identifiers.
35    cpe: Vec<String>,
36}
37
38impl Service {
39    /// Creates a new service record with a baseline confidence score.
40    ///
41    /// Creates a new service identity.
42    ///
43    /// The `confidence` value is clamped to a maximum of 100.
44    pub fn new(name: impl Into<String>, confidence: u8) -> Self {
45        Self {
46            name: name.into(),
47            confidence: confidence.min(100),
48            product: None,
49            version: None,
50            extrainfo: None,
51            cpe: Vec::new(),
52        }
53    }
54
55    /// Returns the high-level service protocol name.
56    pub fn name(&self) -> &str {
57        &self.name
58    }
59
60    /// Returns the identification confidence score (0-100).
61    pub fn confidence(&self) -> u8 {
62        self.confidence
63    }
64
65    /// Returns the detected product name, if any.
66    pub fn product(&self) -> Option<&str> {
67        self.product.as_deref()
68    }
69
70    /// Returns the detected version string, if any.
71    pub fn version(&self) -> Option<&str> {
72        self.version.as_deref()
73    }
74
75    /// Returns additional environmental metadata, if any.
76    pub fn extrainfo(&self) -> Option<&str> {
77        self.extrainfo.as_deref()
78    }
79
80    /// Returns the list of CPE identifiers.
81    pub fn cpe(&self) -> &[String] {
82        &self.cpe
83    }
84
85    /// Builder method to assign a product string.
86    pub fn with_product(mut self, product: impl Into<String>) -> Self {
87        self.product = Some(product.into());
88        self
89    }
90
91    /// Builder method to assign a version string.
92    pub fn with_version(mut self, version: impl Into<String>) -> Self {
93        self.version = Some(version.into());
94        self
95    }
96
97    /// Builder method to add a single CPE identifier.
98    pub fn add_cpe(mut self, cpe: impl Into<String>) -> Self {
99        let cpe_str = cpe.into();
100        if !self.cpe.contains(&cpe_str) {
101            self.cpe.push(cpe_str);
102        }
103        self
104    }
105
106    /// Merges another service record into this one safely.
107    ///
108    /// The merge strategy is confidence-driven. If the incoming `other` service
109    /// has a strictly higher confidence score, it will overwrite the primary
110    /// identity (`name`, `product`, `version`). Otherwise, it behaves additively,
111    /// filling in `None` fields and deduplicating CPEs.
112    pub fn merge(&mut self, other: Service) {
113        let higher_confidence = other.confidence > self.confidence;
114
115        if higher_confidence {
116            self.name = other.name;
117            self.confidence = other.confidence;
118
119            // Overwrite existing data with the higher-confidence payload
120            if other.product.is_some() {
121                self.product = other.product;
122            }
123            if other.version.is_some() {
124                self.version = other.version;
125            }
126            if other.extrainfo.is_some() {
127                self.extrainfo = other.extrainfo;
128            }
129        } else {
130            // Additive merge for equal or lower confidence probes
131            if self.product.is_none() {
132                self.product = other.product;
133            }
134            if self.version.is_none() {
135                self.version = other.version;
136            }
137            if self.extrainfo.is_none() {
138                self.extrainfo = other.extrainfo;
139            }
140        }
141
142        // CPEs are always merged and deduplicated, regardless of confidence.
143        // Even a low-confidence probe might extract a valid CPE string.
144        for c in other.cpe {
145            if !self.cpe.contains(&c) {
146                self.cpe.push(c);
147            }
148        }
149    }
150}
151
152// ╔════════════════════════════════════════════╗
153// ║ ████████╗███████╗███████╗████████╗███████╗ ║
154// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
155// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
156// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
157// ║    ██║   ███████╗███████║   ██║   ███████║ ║
158// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
159// ╚════════════════════════════════════════════╝
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn service_builder_pattern() {
167        let srv = Service::new("http", 85)
168            .with_product("nginx")
169            .with_version("1.21.0")
170            .add_cpe("cpe:/a:igor_sysoev:nginx:1.21.0");
171
172        assert_eq!(srv.name(), "http");
173        assert_eq!(srv.confidence(), 85);
174        assert_eq!(srv.product(), Some("nginx"));
175        assert_eq!(srv.version(), Some("1.21.0"));
176        assert_eq!(srv.cpe().len(), 1);
177    }
178
179    #[test]
180    fn service_confidence_is_clamped_to_100() {
181        let srv = Service::new("ssh", 101);
182        assert_eq!(srv.confidence(), 100);
183    }
184
185    #[test]
186    fn service_merge_lower_confidence_does_not_overwrite_identity() {
187        let mut srv1 = Service::new("http", 85).with_product("nginx");
188        let srv2 = Service::new("unknown", 10).with_version("2.0");
189
190        srv1.merge(srv2);
191
192        // Name and product shouldn't change, but version should be adopted from lower confidence
193        // if not already present.
194        assert_eq!(srv1.name(), "http");
195        assert_eq!(srv1.confidence(), 85);
196        assert_eq!(srv1.product(), Some("nginx"));
197        assert_eq!(srv1.version(), Some("2.0"));
198    }
199
200    #[test]
201    fn service_merge_higher_confidence_overwrites_identity() {
202        let mut srv1 = Service::new("http", 50).with_product("nginx");
203        let srv2 = Service::new("http", 100)
204            .with_product("Apache")
205            .with_version("2.4");
206
207        srv1.merge(srv2);
208
209        // The higher confidence payload completely overwrites the identity
210        assert_eq!(srv1.name(), "http");
211        assert_eq!(srv1.confidence(), 100);
212        assert_eq!(srv1.product(), Some("Apache"));
213        assert_eq!(srv1.version(), Some("2.4"));
214    }
215
216    #[test]
217    fn service_merge_deduplicates_cpes() {
218        let mut srv1 = Service::new("ssh", 100).add_cpe("cpe:/a:openbsd:openssh");
219        let srv2 = Service::new("ssh", 100).add_cpe("cpe:/o:linux:linux_kernel");
220
221        srv1.merge(srv2);
222
223        assert_eq!(srv1.cpe().len(), 2);
224    }
225}