1use serde::{Deserialize, Serialize};
16use std::collections::BTreeSet;
17use thiserror::Error;
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct VulnerabilityPattern {
22 pub pattern_id: String,
23 pub pattern_name: String,
24 #[serde(with = "finite_f32_vector")]
25 pub signature: Vec<f32>,
26 #[serde(with = "unit_interval_f32")]
27 pub confidence: f32,
28 pub occurrences: u32,
29 pub severity: String,
30 pub exploit_chain: Vec<String>,
31}
32
33impl VulnerabilityPattern {
34 pub fn validate(&self) -> Result<(), MlRecordValidationError> {
35 if self.pattern_id.trim().is_empty() || self.pattern_name.trim().is_empty() {
36 return Err(MlRecordValidationError::BlankIdentity);
37 }
38 if self.signature.is_empty() || self.signature.iter().any(|value| !value.is_finite()) {
39 return Err(MlRecordValidationError::InvalidVector);
40 }
41 if !normalized(self.confidence) {
42 return Err(MlRecordValidationError::InvalidScore);
43 }
44 if self.severity.trim().is_empty()
45 || self
46 .exploit_chain
47 .iter()
48 .any(|identifier| identifier.trim().is_empty())
49 {
50 return Err(MlRecordValidationError::BlankLabel);
51 }
52 Ok(())
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct ClusterResult {
59 pub cluster_id: u32,
60 #[serde(with = "finite_f32_vector")]
61 pub centroid: Vec<f32>,
62 pub members: Vec<String>,
63 #[serde(with = "unit_interval_f32")]
64 pub similarity_score: f32,
65}
66
67impl ClusterResult {
68 pub fn validate(&self) -> Result<(), MlRecordValidationError> {
69 if self.centroid.is_empty() || self.centroid.iter().any(|value| !value.is_finite()) {
70 return Err(MlRecordValidationError::InvalidVector);
71 }
72 if self.members.iter().any(|member| member.trim().is_empty()) {
73 return Err(MlRecordValidationError::InvalidMembers);
74 }
75 if !normalized(self.similarity_score) {
76 return Err(MlRecordValidationError::InvalidScore);
77 }
78 Ok(())
79 }
80
81 #[must_use]
83 pub fn member_count(&self) -> u64 {
84 u64::try_from(self.members.len()).unwrap_or(u64::MAX)
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct ExploitationChain {
91 pub chain_id: String,
92 pub stages: Vec<ExploitStage>,
93 #[serde(with = "unit_interval_f32")]
94 pub success_rate: f32,
95 pub time_to_exploit_secs: u32,
96}
97
98impl ExploitationChain {
99 pub fn validate(&self) -> Result<(), MlRecordValidationError> {
100 if self.chain_id.trim().is_empty() {
101 return Err(MlRecordValidationError::BlankIdentity);
102 }
103 if !normalized(self.success_rate) {
104 return Err(MlRecordValidationError::InvalidScore);
105 }
106 let mut stage_ids = BTreeSet::new();
107 for stage in &self.stages {
108 stage.validate()?;
109 if !stage_ids.insert(stage.stage_id) {
110 return Err(MlRecordValidationError::DuplicateStageId);
111 }
112 }
113 Ok(())
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct ExploitStage {
120 pub stage_id: u32,
121 pub name: String,
122 pub technique: String,
123 pub payload: String,
124 pub expected_response: String,
125 pub fallback: Option<String>,
126}
127
128impl ExploitStage {
129 pub fn validate(&self) -> Result<(), MlRecordValidationError> {
130 if self.name.trim().is_empty()
131 || self.technique.trim().is_empty()
132 || self.payload.trim().is_empty()
133 || self.expected_response.trim().is_empty()
134 || self
135 .fallback
136 .as_ref()
137 .is_some_and(|fallback| fallback.trim().is_empty())
138 {
139 return Err(MlRecordValidationError::BlankLabel);
140 }
141 Ok(())
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct AnomalyPattern {
148 pub pattern_id: String,
149 #[serde(with = "finite_f32_vector")]
150 pub feature_vector: Vec<f32>,
151 #[serde(with = "unit_interval_f32")]
152 pub anomaly_score: f32,
153 pub pattern_type: AnomalyType,
154}
155
156impl AnomalyPattern {
157 pub fn validate(&self) -> Result<(), MlRecordValidationError> {
158 if self.pattern_id.trim().is_empty() {
159 return Err(MlRecordValidationError::BlankIdentity);
160 }
161 if self.feature_vector.is_empty()
162 || self.feature_vector.iter().any(|value| !value.is_finite())
163 {
164 return Err(MlRecordValidationError::InvalidVector);
165 }
166 if !normalized(self.anomaly_score) {
167 return Err(MlRecordValidationError::InvalidScore);
168 }
169 Ok(())
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175pub enum AnomalyType {
176 #[serde(rename = "timing")]
177 Timing,
178 #[serde(rename = "size")]
179 Size,
180 #[serde(rename = "error")]
181 Error,
182 #[serde(rename = "behavior")]
183 Behavior,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
188pub enum MlRecordValidationError {
189 #[error("record identity fields must not be blank")]
190 BlankIdentity,
191 #[error("record labels and markers must not be blank")]
192 BlankLabel,
193 #[error("vectors must be nonempty and contain only finite values")]
194 InvalidVector,
195 #[error("reported scores must be finite and within 0..=1")]
196 InvalidScore,
197 #[error("cluster members must not contain blank identifiers")]
198 InvalidMembers,
199 #[error("research-chain stage IDs must be unique")]
200 DuplicateStageId,
201}
202
203fn normalized(value: f32) -> bool {
204 value.is_finite() && (0.0..=1.0).contains(&value)
205}
206
207mod finite_f32_vector {
208 use serde::{Deserialize, Deserializer, Serialize, Serializer};
209
210 const ERROR: &str = "vector values must be finite";
211
212 pub fn serialize<S>(values: &[f32], serializer: S) -> Result<S::Ok, S::Error>
213 where
214 S: Serializer,
215 {
216 if values.iter().all(|value| value.is_finite()) {
217 values.serialize(serializer)
218 } else {
219 Err(serde::ser::Error::custom(ERROR))
220 }
221 }
222
223 pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<f32>, D::Error>
224 where
225 D: Deserializer<'de>,
226 {
227 let values = Vec::<f32>::deserialize(deserializer)?;
228 if values.iter().all(|value| value.is_finite()) {
229 Ok(values)
230 } else {
231 Err(serde::de::Error::custom(ERROR))
232 }
233 }
234}
235
236mod unit_interval_f32 {
237 use serde::{Deserialize, Deserializer, Serializer};
238
239 const ERROR: &str = "score must be finite and within 0..=1";
240
241 pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
242 where
243 S: Serializer,
244 {
245 if super::normalized(*value) {
246 serializer.serialize_f32(*value)
247 } else {
248 Err(serde::ser::Error::custom(ERROR))
249 }
250 }
251
252 pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
253 where
254 D: Deserializer<'de>,
255 {
256 let value = f32::deserialize(deserializer)?;
257 if super::normalized(value) {
258 Ok(value)
259 } else {
260 Err(serde::de::Error::custom(ERROR))
261 }
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn records_round_trip_without_computation() {
271 let pattern = VulnerabilityPattern {
272 pattern_id: "pattern-1".to_string(),
273 pattern_name: "host-label".to_string(),
274 signature: vec![0.1, 0.2],
275 confidence: 0.4,
276 occurrences: 2,
277 severity: "host-label".to_string(),
278 exploit_chain: vec!["chain-1".to_string()],
279 };
280
281 assert_eq!(pattern.validate(), Ok(()));
282 let encoded = serde_json::to_string(&pattern).expect("record serializes");
283 let decoded: VulnerabilityPattern =
284 serde_json::from_str(&encoded).expect("record deserializes");
285 assert_eq!(decoded, pattern);
286 }
287
288 #[test]
289 fn stage_content_remains_inert_caller_data() {
290 let chain = ExploitationChain {
291 chain_id: "chain-1".to_string(),
292 stages: vec![ExploitStage {
293 stage_id: 1,
294 name: "fixture-stage".to_string(),
295 technique: "fixture-marker".to_string(),
296 payload: "inert-input-marker".to_string(),
297 expected_response: "inert-output-marker".to_string(),
298 fallback: None,
299 }],
300 success_rate: 0.25,
301 time_to_exploit_secs: 0,
302 };
303
304 assert_eq!(chain.validate(), Ok(()));
305 let encoded = serde_json::to_string(&chain).expect("record serializes");
306 let decoded: ExploitationChain =
307 serde_json::from_str(&encoded).expect("record deserializes");
308 assert_eq!(decoded, chain);
309 }
310
311 #[test]
312 fn every_record_type_rejects_nonfinite_or_inconsistent_values() {
313 let cluster = ClusterResult {
314 cluster_id: 1,
315 centroid: vec![0.2],
316 members: vec![" ".to_string()],
317 similarity_score: 0.5,
318 };
319 assert_eq!(
320 cluster.validate(),
321 Err(MlRecordValidationError::InvalidMembers)
322 );
323
324 let anomaly = AnomalyPattern {
325 pattern_id: "anomaly".to_string(),
326 feature_vector: vec![f32::NAN],
327 anomaly_score: 0.5,
328 pattern_type: AnomalyType::Behavior,
329 };
330 assert_eq!(
331 anomaly.validate(),
332 Err(MlRecordValidationError::InvalidVector)
333 );
334 }
335
336 #[test]
337 fn ml_wire_rejects_nonfinite_vectors_and_out_of_range_scores() {
338 let invalid_pattern = VulnerabilityPattern {
339 pattern_id: "pattern-1".to_string(),
340 pattern_name: "host-label".to_string(),
341 signature: vec![f32::NAN],
342 confidence: 0.5,
343 occurrences: 1,
344 severity: "host-label".to_string(),
345 exploit_chain: Vec::new(),
346 };
347 assert!(serde_json::to_string(&invalid_pattern).is_err());
348
349 let mut pattern_json = serde_json::json!({
350 "pattern_id": "pattern-1",
351 "pattern_name": "host-label",
352 "signature": [0.25],
353 "confidence": 1.01,
354 "occurrences": 1,
355 "severity": "host-label",
356 "exploit_chain": []
357 });
358 assert!(serde_json::from_value::<VulnerabilityPattern>(pattern_json.clone()).is_err());
359 pattern_json["confidence"] = serde_json::json!(0.5);
360 assert!(serde_json::from_value::<VulnerabilityPattern>(pattern_json).is_ok());
361
362 let invalid_chain = ExploitationChain {
363 chain_id: "chain-1".to_string(),
364 stages: Vec::new(),
365 success_rate: f32::INFINITY,
366 time_to_exploit_secs: 0,
367 };
368 assert!(serde_json::to_string(&invalid_chain).is_err());
369
370 let invalid_anomaly = serde_json::json!({
371 "pattern_id": "anomaly-1",
372 "feature_vector": [0.25],
373 "anomaly_score": -0.01,
374 "pattern_type": "behavior"
375 });
376 assert!(serde_json::from_value::<AnomalyPattern>(invalid_anomaly).is_err());
377 }
378
379 #[test]
380 fn cluster_member_count_is_derived_and_wire_has_no_redundant_size() {
381 let cluster = ClusterResult {
382 cluster_id: 7,
383 centroid: vec![0.25, -0.5],
384 members: vec!["member-a".to_string(), "member-b".to_string()],
385 similarity_score: 0.75,
386 };
387
388 let encoded = serde_json::to_string(&cluster).expect("finite cluster serializes");
389 assert!(!encoded.contains("\"size\""));
390 let decoded: ClusterResult =
391 serde_json::from_str(&encoded).expect("finite cluster deserializes");
392 assert_eq!(decoded, cluster);
393 assert_eq!(decoded.member_count(), 2);
394 }
395}