1pub mod audit;
2pub mod baseline;
3pub mod cli;
4#[cfg(feature = "mcp")]
5pub mod mcp;
6pub mod output;
7pub mod probe;
8pub mod scanner;
9
10use serde::{Deserialize, Serialize};
11use std::fmt;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum TlsVersion {
17 Tls13,
18 Tls12,
19 Tls11,
20 Tls10,
21 Unknown(u16),
22}
23
24impl fmt::Display for TlsVersion {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 Self::Tls13 => write!(f, "TLS 1.3"),
28 Self::Tls12 => write!(f, "TLS 1.2"),
29 Self::Tls11 => write!(f, "TLS 1.1"),
30 Self::Tls10 => write!(f, "TLS 1.0"),
31 Self::Unknown(v) => write!(f, "Unknown(0x{v:04x})"),
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct NamedGroup {
39 pub code_point: u16,
40 pub name: String,
41 pub is_pqc: bool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub struct CipherSuite {
47 pub id: u16,
48 pub name: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub enum KeyInfo {
54 Rsa { bits: u32 },
55 Ec { curve: String },
56 Ed25519,
57 Ed448,
58 MlDsa { level: u8 },
59 Unknown,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub enum ChainPosition {
65 Leaf,
66 Intermediate { depth: u8 },
67 Root,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72pub enum AlgorithmId {
73 Rsa { min_bits: u32 },
74 EcP256,
75 EcP384,
76 EcP521,
77 X25519,
78 X448,
79 Ed25519,
80 Ed448,
81 Dh { min_bits: u32 },
82 MlKem768,
83 MlKem1024,
84 MlDsa65,
85 MlDsa87,
86 Aes128,
87 Aes256,
88 Sha256,
89 Sha384,
90 Sha512,
91 ChaCha20Poly1305,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct PqcHandshakeResult {
99 pub negotiated_version: TlsVersion,
100 pub negotiated_suite: CipherSuite,
101 pub negotiated_group: NamedGroup,
102 pub hrr_required: bool,
103 pub cert_chain_der: Vec<Vec<u8>>,
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct CipherInventory {
109 pub tls13_suites: Vec<CipherSuite>,
110 pub tls12_suites: Vec<CipherSuite>,
111 #[serde(default)]
113 pub kyber_draft_accepted: bool,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub enum DowngradeResult {
119 Accepted { negotiated_version: TlsVersion },
120 Rejected,
121 Timeout,
122 Error(String),
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ProbeResults {
128 pub target: String,
129 pub port: u16,
130 pub pqc_handshake: Result<PqcHandshakeResult, String>,
131 pub cipher_inventory: Option<CipherInventory>,
132 pub downgrade: DowngradeResult,
133}
134
135#[derive(Debug, thiserror::Error)]
138pub enum ProbeError {
139 #[error("connection refused to {host}:{port}")]
140 ConnectionRefused { host: String, port: u16 },
141 #[error("DNS resolution failed for {host}")]
142 DnsResolutionFailed { host: String },
143 #[error("TLS handshake failed: {reason}")]
144 TlsHandshakeFailed { reason: String },
145 #[error("certificate validation failed: {reason}")]
146 CertificateValidationFailed { reason: String },
147 #[error("SNI mismatch: presented={presented}, expected={expected}")]
148 SniMismatch { presented: String, expected: String },
149 #[error("timeout after {after_ms}ms")]
150 Timeout { after_ms: u64 },
151 #[error("STARTTLS upgrade failed for {protocol:?}: {reason}")]
152 StarttlsUpgradeFailed {
153 protocol: StarttlsProtocol,
154 reason: String,
155 },
156 #[error("certificate parse error: {reason}")]
157 CertificateParseError { reason: String },
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub enum StarttlsProtocol {
162 Smtp,
163 Imap,
164 Pop3,
165 Ldap,
166 Other(String),
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct TargetReport {
173 pub target: String,
174 pub port: u16,
175 pub score: audit::scoring::model::ScoringResult,
176 pub hndl: audit::hndl::HndlAssessment,
177 pub findings: Vec<audit::findings::Finding>,
178 pub cert_chain: Option<audit::cert_chain::CertChainReport>,
179 pub cipher_inventory: Option<CipherInventory>,
180 pub downgrade: DowngradeResult,
181 pub error: Option<String>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub negotiated_group: Option<NamedGroup>,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub negotiated_suite: Option<CipherSuite>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ScanReport {
192 pub schema_version: String,
193 pub scanned_at: String,
194 pub compliance_mode: cli::ComplianceMode,
195 pub targets: Vec<TargetReport>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub comparison: Option<output::compare::ComparisonReport>,
198}
199
200#[cfg(test)]
202pub mod tests_common {
203 use crate::audit::{
204 cert_chain::CertChainReport,
205 findings::Finding,
206 hndl::{HndlAssessment, HndlRating},
207 scoring::model::{CategoryScore, ScoringResult},
208 };
209 use crate::cli::ComplianceMode;
210 use crate::{CipherInventory, DowngradeResult, ScanReport, TargetReport, TlsVersion};
211
212 fn zero_category(name: &str) -> CategoryScore {
213 CategoryScore {
214 name: name.into(),
215 points: 0,
216 max_points: 0,
217 notes: vec![],
218 }
219 }
220
221 pub fn stub_target_report(score: u8) -> TargetReport {
222 use crate::CipherSuite;
223 TargetReport {
224 target: "example.com".into(),
225 port: 443,
226 score: ScoringResult {
227 total: score,
228 key_exchange: zero_category("key_exchange"),
229 tls_version: zero_category("tls_version"),
230 cipher_suite: zero_category("cipher_suite"),
231 cert_chain: zero_category("cert_chain"),
232 downgrade_posture: zero_category("downgrade_posture"),
233 },
234 hndl: HndlAssessment {
235 rating: HndlRating::None,
236 exposure_window_years: 0.0,
237 cert_expires_before_q_day: false,
238 notes: vec![],
239 },
240 findings: vec![],
241 cert_chain: Some(CertChainReport {
242 entries: vec![],
243 findings: vec![],
244 }),
245 cipher_inventory: Some(CipherInventory {
246 tls13_suites: vec![CipherSuite {
247 id: 0x1302,
248 name: "TLS_AES_256_GCM_SHA384".into(),
249 }],
250 tls12_suites: vec![],
251 kyber_draft_accepted: false,
252 }),
253 downgrade: DowngradeResult::Rejected,
254 error: None,
255 negotiated_group: Some(crate::NamedGroup {
256 code_point: 0x11EC,
257 name: "X25519MLKEM768".into(),
258 is_pqc: true,
259 }),
260 negotiated_suite: Some(CipherSuite {
261 id: 0x1302,
262 name: "TLS_AES_256_GCM_SHA384".into(),
263 }),
264 }
265 }
266
267 pub fn stub_scan_report() -> ScanReport {
268 ScanReport {
269 schema_version: "1.0".into(),
270 scanned_at: "2026-01-01T00:00:00Z".into(),
271 compliance_mode: ComplianceMode::Nist,
272 targets: vec![stub_target_report(80)],
273 comparison: None,
274 }
275 }
276
277 pub fn stub_scan_report_with_all_findings() -> ScanReport {
279 use crate::audit::findings::{Finding, FindingKind, Severity};
280 use crate::NamedGroup;
281
282 let mut target = stub_target_report(30);
283 target.findings = vec![
284 Finding {
285 kind: FindingKind::ClassicalKeyExchangeOnly {
286 group: NamedGroup {
287 code_point: 0x001D,
288 name: "x25519".into(),
289 is_pqc: false,
290 },
291 },
292 severity: Severity::Error,
293 },
294 Finding {
295 kind: FindingKind::DowngradeAccepted,
296 severity: Severity::Warning,
297 },
298 ];
299 ScanReport {
300 schema_version: "1.0".into(),
301 scanned_at: "2026-01-01T00:00:00Z".into(),
302 compliance_mode: ComplianceMode::Nist,
303 targets: vec![target],
304 comparison: None,
305 }
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn tls_version_display() {
315 assert_eq!(TlsVersion::Tls13.to_string(), "TLS 1.3");
316 assert_eq!(TlsVersion::Tls12.to_string(), "TLS 1.2");
317 }
318
319 #[test]
320 fn probe_error_is_send_sync() {
321 fn assert_send_sync<T: Send + Sync>() {}
322 assert_send_sync::<ProbeError>();
323 }
324}