Skip to main content

zond_engine/core/models/
port.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//! # Port Discovery and Metadata
8//!
9//! This module defines the core types for identifying and detailing network services.
10//! It is architected for future-proof service fingerprinting, security analysis,
11//! and structured script execution telemetry.
12
13use std::collections::HashMap;
14
15pub mod discovery;
16pub mod security;
17pub mod service;
18pub mod set;
19
20pub use discovery::{Discovery, ScanResponse};
21pub use security::{CertificateInfo, Security};
22pub use service::Service;
23pub use set::{PortSet, PortSetParseError};
24
25/// Supported transport layer protocols.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum Protocol {
28    Tcp,
29    Udp,
30    Sctp,
31}
32
33/// The reachability state of a specific port.
34///
35/// The order of these variants is strictly defined from least definitive to
36/// most definitive. This allows `Port::merge()` to automatically upgrade
37/// ambiguous states into concrete ones using standard Ord comparisons.
38#[non_exhaustive]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum PortState {
41    /// State is ambiguous; port is either closed or filtered (e.g., IP ID idle scan).
42    ClosedFiltered,
43
44    /// Packets are being dropped silently by a firewall. We received no response.
45    Filtered,
46
47    /// Target is accessible, but we cannot determine if it is open or closed (e.g., TCP ACK scan).
48    Unfiltered,
49
50    /// Actively rejecting connections (e.g., TCP RST received).
51    Closed,
52
53    /// State is ambiguous; port might be open, or packets might be silently dropped (e.g., UDP scan).
54    OpenFiltered,
55
56    /// Actively accepting connections (e.g., TCP SYN/ACK received).
57    Open,
58}
59
60/// Structured data returned by a scanning script or vulnerability engine.
61///
62/// Replaces legacy stringly-typed script outputs, allowing modern engines
63/// to return complex nested data (e.g., parsed JSON directories, lists of CVEs).
64#[derive(Debug, Clone, PartialEq)]
65pub enum ScriptOutput {
66    String(String),
67    Integer(i64),
68    Float(f64),
69    Boolean(bool),
70    List(Vec<ScriptOutput>),
71    Map(HashMap<String, ScriptOutput>),
72}
73
74/// A comprehensive "Rich" model representing a service endpoint discovered on a host.
75///
76/// Unlike a simple port number, a `Port` captures the full lifecycle of a service:
77/// how it was found, its security posture, and its functional identity.
78#[derive(Debug, Clone, PartialEq)]
79pub struct Port {
80    /// The 16-bit port number.
81    number: u16,
82
83    /// The transport protocol (TCP/UDP/SCTP).
84    protocol: Protocol,
85
86    /// The discovered state of the port.
87    state: PortState,
88
89    /// Rich service identity (e.g., "OpenSSH 8.9", CPE strings).
90    service: Option<Service>,
91
92    /// Security/Encryption details (TLS certificate, negotiated ciphers).
93    security: Option<Security>,
94
95    /// Low-level discovery telemetry (TTL, reason for state, RTT).
96    discovery: Option<Discovery>,
97
98    /// Extensible map for scan scripts and custom detection engines.
99    /// Wrapped in an Option to avoid heap allocation for filtered/dropped ports.
100    scripts: Option<HashMap<String, ScriptOutput>>,
101}
102
103impl Port {
104    /// Creates a new, basic Port instance.
105    pub fn new(number: u16, protocol: Protocol, state: PortState) -> Self {
106        Self {
107            number,
108            protocol,
109            state,
110            service: None,
111            security: None,
112            discovery: None,
113            scripts: None,
114        }
115    }
116
117    /// Returns the port number.
118    pub fn number(&self) -> u16 {
119        self.number
120    }
121
122    /// Returns the transport protocol.
123    pub fn protocol(&self) -> Protocol {
124        self.protocol
125    }
126
127    /// Returns the current discovery state.
128    pub fn state(&self) -> PortState {
129        self.state
130    }
131
132    /// Updates the port discovery state.
133    pub fn set_state(&mut self, state: PortState) {
134        self.state = state;
135    }
136
137    /// Returns the service identification, if any.
138    pub fn service(&self) -> Option<&Service> {
139        self.service.as_ref()
140    }
141
142    /// Returns the high-level service name (e.g., "ssh"), if identified.
143    ///
144    /// This is a convenience helper for migrating from the legacy `service_info` model.
145    pub fn service_name(&self) -> Option<&str> {
146        self.service.as_ref().map(|s| s.name())
147    }
148
149    /// Sets or updates the service identification.
150    pub fn set_service(&mut self, service: Service) {
151        self.service = Some(service);
152    }
153
154    /// Returns the security/encryption telemetry, if any.
155    pub fn security(&self) -> Option<&Security> {
156        self.security.as_ref()
157    }
158
159    /// Sets or updates the security telemetry.
160    pub fn set_security(&mut self, security: Security) {
161        self.security = Some(security);
162    }
163
164    /// Returns the low-level discovery telemetry, if any.
165    pub fn discovery(&self) -> Option<&Discovery> {
166        self.discovery.as_ref()
167    }
168
169    /// Returns the script output map, if any.
170    pub fn scripts(&self) -> Option<&HashMap<String, ScriptOutput>> {
171        self.scripts.as_ref()
172    }
173
174    /// Builder method to attach service information.
175    pub fn with_service(mut self, service: Service) -> Self {
176        self.service = Some(service);
177        self
178    }
179
180    /// Builder method to attach security metadata.
181    pub fn with_security(mut self, security: Security) -> Self {
182        self.security = Some(security);
183        self
184    }
185
186    /// Builder method to attach low-level discovery telemetry.
187    pub fn with_discovery(mut self, discovery: Discovery) -> Self {
188        self.discovery = Some(discovery);
189        self
190    }
191
192    /// Builder method to insert a structured script output.
193    pub fn add_script(mut self, key: impl Into<String>, output: ScriptOutput) -> Self {
194        let scripts = self.scripts.get_or_insert_with(HashMap::new);
195        scripts.insert(key.into(), output);
196        self
197    }
198
199    /// Merges architectural findings from another Port record into this one.
200    ///
201    /// Prioritizes the most definitive port state. Merges nested `Service`,
202    /// `Security`, and `Discovery` metadata progressively.
203    pub fn merge(&mut self, mut other: Port) {
204        // 1. Merge State (Upgrades ambiguous states to definitive ones)
205        self.state = std::cmp::max(self.state, other.state);
206
207        // 2. Merge Service Info (Relies on Service's internal confidence logic)
208        if let Some(other_service) = other.service {
209            if let Some(ref mut self_service) = self.service {
210                self_service.merge(other_service);
211            } else {
212                self.service = Some(other_service);
213            }
214        }
215
216        // 3. Merge Security Info
217        if let Some(other_security) = other.security {
218            if let Some(ref mut self_security) = self.security {
219                self_security.merge(other_security);
220            } else {
221                self.security = Some(other_security);
222            }
223        }
224
225        // 4. Merge Discovery (Usually keep the first discovery, unless we upgraded to Open)
226        // If the other probe resulted in a higher confidence state, adopt its telemetry.
227        if other.state >= self.state && other.discovery.is_some() {
228            self.discovery = other.discovery;
229        }
230
231        // 5. Merge Scripts (Overwrite on key collision, assuming newer is better)
232        if let Some(other_scripts) = other.scripts.take() {
233            let self_scripts = self.scripts.get_or_insert_with(HashMap::new);
234            for (key, value) in other_scripts {
235                self_scripts.insert(key, value);
236            }
237        }
238    }
239}
240
241// ╔════════════════════════════════════════════╗
242// ║ ████████╗███████╗███████╗████████╗███████╗ ║
243// ║ ╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝██╔════╝ ║
244// ║    ██║   █████╗  ███████╗   ██║   ███████╗ ║
245// ║    ██║   ██╔══╝  ╚════██║   ██║   ╚════██║ ║
246// ║    ██║   ███████╗███████║   ██║   ███████║ ║
247// ║    ╚═╝   ╚══════╝╚══════╝   ╚═╝   ╚══════╝ ║
248// ╚════════════════════════════════════════════╝
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn port_state_ordering_upgrades_correctly() {
256        // Filtered -> Open
257        let mut p1 = Port::new(80, Protocol::Tcp, PortState::Filtered);
258        p1.merge(Port::new(80, Protocol::Tcp, PortState::Open));
259        assert_eq!(p1.state(), PortState::Open);
260
261        // OpenFiltered -> Open
262        let mut p2 = Port::new(53, Protocol::Udp, PortState::OpenFiltered);
263        p2.merge(Port::new(53, Protocol::Udp, PortState::Open));
264        assert_eq!(p2.state(), PortState::Open);
265
266        // Unfiltered -> Closed
267        let mut p3 = Port::new(443, Protocol::Tcp, PortState::Unfiltered);
268        p3.merge(Port::new(443, Protocol::Tcp, PortState::Closed));
269        assert_eq!(p3.state(), PortState::Closed);
270    }
271
272    #[test]
273    fn structured_scripts_merge_correctly() {
274        let mut port = Port::new(80, Protocol::Tcp, PortState::Open)
275            .add_script("http-title", ScriptOutput::String("Index".into()));
276
277        // Add a complex nested script result
278        let mut ssh_keys = HashMap::new();
279        ssh_keys.insert("rsa".into(), ScriptOutput::Integer(2048));
280        ssh_keys.insert("ed25519".into(), ScriptOutput::Integer(256));
281
282        let other = Port::new(80, Protocol::Tcp, PortState::Open)
283            .add_script("ssh-hostkey", ScriptOutput::Map(ssh_keys));
284
285        port.merge(other);
286
287        let scripts = port.scripts.as_ref().unwrap();
288        assert_eq!(scripts.len(), 2);
289        assert!(matches!(
290            scripts.get("http-title"),
291            Some(ScriptOutput::String(_))
292        ));
293        assert!(matches!(
294            scripts.get("ssh-hostkey"),
295            Some(ScriptOutput::Map(_))
296        ));
297    }
298
299    #[test]
300    fn discovery_telemetry_upgrades_on_better_state() {
301        let disc_filtered = Discovery::new(ScanResponse::NoResponse);
302        let mut p_filtered =
303            Port::new(22, Protocol::Tcp, PortState::Filtered).with_discovery(disc_filtered.clone());
304
305        let disc_open = Discovery::new(ScanResponse::TcpSynAck);
306        let p_open =
307            Port::new(22, Protocol::Tcp, PortState::Open).with_discovery(disc_open.clone());
308
309        // Merging should upgrade the state AND the telemetry reason
310        p_filtered.merge(p_open);
311
312        assert_eq!(p_filtered.state(), PortState::Open);
313        assert_eq!(
314            p_filtered.discovery().unwrap().reason(),
315            &ScanResponse::TcpSynAck
316        );
317    }
318}