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}