Skip to main content

termivar_scanner/
ml.rs

1//! Experimental research records for externally computed model output.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `ml`.
6//! - **Execution:** no repository runtime caller (not on any default path).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental data-model scaffold only.
9//!
10//! The repository does not train models, cluster observations, classify anomalies,
11//! estimate exploitation success, or execute a recorded stage. Hosts may use these
12//! serializable records to carry results computed by their own reviewed systems.
13//! See `docs/internals/runtime-map.md`.
14
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeSet;
17use thiserror::Error;
18
19/// Caller-supplied pattern record. No claim is inferred from this record.
20#[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/// Caller-supplied clustering record from an external model.
57#[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    /// Returns the derived member count in a target-independent wire width.
82    #[must_use]
83    pub fn member_count(&self) -> u64 {
84        u64::try_from(self.members.len()).unwrap_or(u64::MAX)
85    }
86}
87
88/// Caller-supplied research-chain record. The scanner never executes its stages.
89#[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/// Caller-supplied stage record containing inert host data.
118#[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/// Caller-supplied anomaly-pattern record from an external model.
146#[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/// Caller-assigned anomaly dimension.
174#[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/// Validation failure for externally computed research records.
187#[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}