Skip to main content

termivar_scanner/
advanced_detection.rs

1//! Experimental signal definitions and caller-scored technique catalogs.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `detection`.
6//! - **Execution:** no repository runtime caller.
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental record/catalog scaffold.
9//!
10//! This module validates and stores caller-supplied records. It does not inspect
11//! responses, execute transformations, classify vulnerabilities, or emit findings.
12//! See `docs/internals/runtime-map.md`.
13
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15use std::collections::BTreeMap;
16use thiserror::Error;
17
18/// Caller-supplied behavioral signal definition.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct BehavioralSignature {
21    pub signature_id: String,
22    pub signal_type: String,
23    pub indicators: Vec<BehaviorIndicator>,
24    /// Caller-supplied normalized threshold.
25    #[serde(with = "positive_unit_interval_f32")]
26    pub threshold: f32,
27    /// Caller-supplied normalized confidence label.
28    #[serde(with = "unit_interval_f32")]
29    pub confidence: f32,
30}
31
32impl BehavioralSignature {
33    /// Validates the record envelope without evaluating the signal.
34    pub fn validate(&self) -> Result<(), BehavioralSignatureValidationError> {
35        if self.signature_id.trim().is_empty() {
36            return Err(BehavioralSignatureValidationError::BlankSignatureId);
37        }
38        if self.signal_type.trim().is_empty() {
39            return Err(BehavioralSignatureValidationError::BlankSignalType);
40        }
41        if self.indicators.is_empty() {
42            return Err(BehavioralSignatureValidationError::NoIndicators);
43        }
44        if !normalized(self.threshold) || self.threshold == 0.0 {
45            return Err(BehavioralSignatureValidationError::InvalidThreshold);
46        }
47        if !normalized(self.confidence) {
48            return Err(BehavioralSignatureValidationError::InvalidConfidence);
49        }
50        for (index, indicator) in self.indicators.iter().enumerate() {
51            if !indicator.is_valid() {
52                return Err(BehavioralSignatureValidationError::InvalidIndicator { index });
53            }
54        }
55        Ok(())
56    }
57}
58
59/// Validation failures for caller-supplied signal records.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
61pub enum BehavioralSignatureValidationError {
62    #[error("behavioral signature ID must not be blank")]
63    BlankSignatureId,
64    #[error("behavioral signal type must not be blank")]
65    BlankSignalType,
66    #[error("behavioral signature must contain at least one indicator")]
67    NoIndicators,
68    #[error("behavioral signature threshold must be finite and within (0, 1]")]
69    InvalidThreshold,
70    #[error("behavioral signature confidence must be finite and within 0..=1")]
71    InvalidConfidence,
72    #[error("behavioral signature indicator {index} is contradictory or invalid")]
73    InvalidIndicator { index: usize },
74}
75
76/// Caller-supplied signal indicator.
77#[derive(Debug, Clone, PartialEq)]
78pub struct BehaviorIndicator {
79    pub indicator_type: IndicatorType,
80    pub metric: String,
81    pub operator: ComparisonOperator,
82    pub value: f32,
83    pub weight: f32,
84}
85
86impl BehaviorIndicator {
87    fn has_wire_safe_numbers(&self) -> bool {
88        let value_is_valid = self.value.is_finite()
89            && self.value >= 0.0
90            && (self.indicator_type != IndicatorType::Consistency || normalized(self.value));
91        value_is_valid && normalized(self.weight) && self.weight > 0.0
92    }
93
94    fn is_valid(&self) -> bool {
95        let metric_matches_type = matches!(
96            (self.indicator_type, self.metric.as_str()),
97            (IndicatorType::Timing, "response_time")
98                | (IndicatorType::Size, "response_size")
99                | (IndicatorType::Pattern, "unique_patterns")
100                | (IndicatorType::Error, "error_keywords")
101                | (IndicatorType::Consistency, "consistency")
102        );
103        metric_matches_type && self.has_wire_safe_numbers()
104    }
105}
106
107const BEHAVIOR_INDICATOR_WIRE_ERROR: &str =
108    "behavior indicator values must be finite and within their documented ranges";
109
110#[derive(Serialize)]
111struct BehaviorIndicatorRef<'a> {
112    indicator_type: IndicatorType,
113    metric: &'a str,
114    operator: ComparisonOperator,
115    value: f32,
116    weight: f32,
117}
118
119#[derive(Deserialize)]
120struct BehaviorIndicatorWire {
121    indicator_type: IndicatorType,
122    metric: String,
123    operator: ComparisonOperator,
124    value: f32,
125    weight: f32,
126}
127
128impl Serialize for BehaviorIndicator {
129    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
130    where
131        S: Serializer,
132    {
133        if !self.has_wire_safe_numbers() {
134            return Err(serde::ser::Error::custom(BEHAVIOR_INDICATOR_WIRE_ERROR));
135        }
136        BehaviorIndicatorRef {
137            indicator_type: self.indicator_type,
138            metric: &self.metric,
139            operator: self.operator,
140            value: self.value,
141            weight: self.weight,
142        }
143        .serialize(serializer)
144    }
145}
146
147impl<'de> Deserialize<'de> for BehaviorIndicator {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: Deserializer<'de>,
151    {
152        let wire = BehaviorIndicatorWire::deserialize(deserializer)?;
153        let indicator = Self {
154            indicator_type: wire.indicator_type,
155            metric: wire.metric,
156            operator: wire.operator,
157            value: wire.value,
158            weight: wire.weight,
159        };
160        if indicator.has_wire_safe_numbers() {
161            Ok(indicator)
162        } else {
163            Err(serde::de::Error::custom(BEHAVIOR_INDICATOR_WIRE_ERROR))
164        }
165    }
166}
167
168/// Supported dimensions for a caller-supplied indicator.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170pub enum IndicatorType {
171    #[serde(rename = "timing")]
172    Timing,
173    #[serde(rename = "size")]
174    Size,
175    #[serde(rename = "pattern")]
176    Pattern,
177    #[serde(rename = "error")]
178    Error,
179    #[serde(rename = "consistency")]
180    Consistency,
181}
182
183/// Numeric comparison recorded by a host. This module does not apply it.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185pub enum ComparisonOperator {
186    #[serde(rename = "greater_than")]
187    GreaterThan,
188    #[serde(rename = "less_than")]
189    LessThan,
190    #[serde(rename = "equals")]
191    Equals,
192}
193
194/// Caller-supplied observation record. No signal is evaluated from it here.
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct BehavioralAnalysisData {
197    #[serde(with = "nonnegative_f32")]
198    pub response_time_ms: f32,
199    pub response_size_bytes: u32,
200    pub error_keywords_count: u32,
201    pub unique_patterns: u32,
202    #[serde(with = "unit_interval_f32")]
203    pub consistency_score: f32,
204}
205
206/// Neutral technique category used by caller-supplied records.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208pub enum TechniqueCategory {
209    Encoding,
210    Transformation,
211    Fragmentation,
212    Normalization,
213    Timing,
214}
215
216/// Caller-supplied technique record; the scanner never executes it.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct TechniqueRecord {
219    pub technique_id: String,
220    pub technique_name: String,
221    pub category: TechniqueCategory,
222    pub description: String,
223    #[serde(with = "unit_interval_f32")]
224    pub reported_effectiveness: f32,
225    #[serde(with = "unit_interval_f32")]
226    pub reported_false_positive_rate: f32,
227    pub method_labels: Vec<String>,
228}
229
230impl TechniqueRecord {
231    pub fn validate(&self) -> Result<(), CatalogRecordError> {
232        if self.technique_id.trim().is_empty() {
233            return Err(CatalogRecordError::BlankId);
234        }
235        if self.technique_name.trim().is_empty() {
236            return Err(CatalogRecordError::BlankName);
237        }
238        if !normalized(self.reported_effectiveness)
239            || !normalized(self.reported_false_positive_rate)
240        {
241            return Err(CatalogRecordError::InvalidReportedScore);
242        }
243        Ok(())
244    }
245}
246
247/// Deterministic in-memory catalog for validated technique records.
248#[derive(Debug, Default)]
249pub struct TechniqueCatalog {
250    records: BTreeMap<String, TechniqueRecord>,
251}
252
253impl TechniqueCatalog {
254    #[must_use]
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    pub fn insert(&mut self, record: TechniqueRecord) -> Result<(), CatalogRecordError> {
260        record.validate()?;
261        if self.records.contains_key(&record.technique_id) {
262            return Err(CatalogRecordError::DuplicateId);
263        }
264        self.records.insert(record.technique_id.clone(), record);
265        Ok(())
266    }
267
268    /// Returns records ordered by reported score descending, then ID ascending.
269    #[must_use]
270    pub fn ranked(&self, category: TechniqueCategory) -> Vec<&TechniqueRecord> {
271        let mut records: Vec<_> = self
272            .records
273            .values()
274            .filter(|record| record.category == category)
275            .collect();
276        records.sort_by(|left, right| {
277            right
278                .reported_effectiveness
279                .total_cmp(&left.reported_effectiveness)
280                .then_with(|| left.technique_id.cmp(&right.technique_id))
281        });
282        records
283    }
284
285    #[must_use]
286    pub fn len(&self) -> usize {
287        self.records.len()
288    }
289
290    #[must_use]
291    pub fn is_empty(&self) -> bool {
292        self.records.is_empty()
293    }
294}
295
296/// Neutral transformation category for a caller-supplied rule record.
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
298pub enum TransformationType {
299    Encoding,
300    Manipulation,
301    Noise,
302    Other,
303}
304
305/// Caller-supplied transformation-rule record; the scanner never applies it.
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct TransformationRule {
308    pub rule_id: String,
309    pub target_label: String,
310    pub transformation_label: String,
311    pub transformation_type: TransformationType,
312    #[serde(with = "unit_interval_f32")]
313    pub reported_effectiveness: f32,
314}
315
316impl TransformationRule {
317    pub fn validate(&self) -> Result<(), CatalogRecordError> {
318        if self.rule_id.trim().is_empty() {
319            return Err(CatalogRecordError::BlankId);
320        }
321        if self.target_label.trim().is_empty() || self.transformation_label.trim().is_empty() {
322            return Err(CatalogRecordError::BlankName);
323        }
324        if !normalized(self.reported_effectiveness) {
325            return Err(CatalogRecordError::InvalidReportedScore);
326        }
327        Ok(())
328    }
329}
330
331/// Deterministic in-memory catalog for validated transformation-rule records.
332#[derive(Debug, Default)]
333pub struct TransformationRuleCatalog {
334    records: BTreeMap<String, TransformationRule>,
335}
336
337impl TransformationRuleCatalog {
338    #[must_use]
339    pub fn new() -> Self {
340        Self::default()
341    }
342
343    pub fn insert(&mut self, record: TransformationRule) -> Result<(), CatalogRecordError> {
344        record.validate()?;
345        if self.records.contains_key(&record.rule_id) {
346            return Err(CatalogRecordError::DuplicateId);
347        }
348        self.records.insert(record.rule_id.clone(), record);
349        Ok(())
350    }
351
352    #[must_use]
353    pub fn for_target(&self, target: &str) -> Vec<&TransformationRule> {
354        self.records
355            .values()
356            .filter(|record| record.target_label == target)
357            .collect()
358    }
359}
360
361/// Validation failure for a caller-supplied catalog record.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
363pub enum CatalogRecordError {
364    #[error("catalog record ID must not be blank")]
365    BlankId,
366    #[error("catalog record name/label must not be blank")]
367    BlankName,
368    #[error("reported scores must be finite and within 0..=1")]
369    InvalidReportedScore,
370    #[error("catalog record ID already exists")]
371    DuplicateId,
372}
373
374fn normalized(value: f32) -> bool {
375    value.is_finite() && (0.0..=1.0).contains(&value)
376}
377
378mod unit_interval_f32 {
379    use serde::{Deserialize, Deserializer, Serializer};
380
381    const ERROR: &str = "score must be finite and within 0..=1";
382
383    pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
384    where
385        S: Serializer,
386    {
387        if super::normalized(*value) {
388            serializer.serialize_f32(*value)
389        } else {
390            Err(serde::ser::Error::custom(ERROR))
391        }
392    }
393
394    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
395    where
396        D: Deserializer<'de>,
397    {
398        let value = f32::deserialize(deserializer)?;
399        if super::normalized(value) {
400            Ok(value)
401        } else {
402            Err(serde::de::Error::custom(ERROR))
403        }
404    }
405}
406
407mod positive_unit_interval_f32 {
408    use serde::{Deserialize, Deserializer, Serializer};
409
410    const ERROR: &str = "score must be finite and within (0, 1]";
411
412    pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
413    where
414        S: Serializer,
415    {
416        if super::normalized(*value) && *value > 0.0 {
417            serializer.serialize_f32(*value)
418        } else {
419            Err(serde::ser::Error::custom(ERROR))
420        }
421    }
422
423    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
424    where
425        D: Deserializer<'de>,
426    {
427        let value = f32::deserialize(deserializer)?;
428        if super::normalized(value) && value > 0.0 {
429            Ok(value)
430        } else {
431            Err(serde::de::Error::custom(ERROR))
432        }
433    }
434}
435
436mod nonnegative_f32 {
437    use serde::{Deserialize, Deserializer, Serializer};
438
439    const ERROR: &str = "value must be finite and non-negative";
440
441    pub fn serialize<S>(value: &f32, serializer: S) -> Result<S::Ok, S::Error>
442    where
443        S: Serializer,
444    {
445        if value.is_finite() && *value >= 0.0 {
446            serializer.serialize_f32(*value)
447        } else {
448            Err(serde::ser::Error::custom(ERROR))
449        }
450    }
451
452    pub fn deserialize<'de, D>(deserializer: D) -> Result<f32, D::Error>
453    where
454        D: Deserializer<'de>,
455    {
456        let value = f32::deserialize(deserializer)?;
457        if value.is_finite() && value >= 0.0 {
458            Ok(value)
459        } else {
460            Err(serde::de::Error::custom(ERROR))
461        }
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    fn valid_signature() -> BehavioralSignature {
470        BehavioralSignature {
471            signature_id: "signal-1".to_string(),
472            signal_type: "timing-deviation".to_string(),
473            indicators: vec![BehaviorIndicator {
474                indicator_type: IndicatorType::Timing,
475                metric: "response_time".to_string(),
476                operator: ComparisonOperator::GreaterThan,
477                value: 500.0,
478                weight: 1.0,
479            }],
480            threshold: 0.5,
481            confidence: 0.8,
482        }
483    }
484
485    #[test]
486    fn signal_validation_rejects_type_metric_mismatch() {
487        assert_eq!(valid_signature().validate(), Ok(()));
488        let mut invalid = valid_signature();
489        invalid.indicators[0].metric = "response_size".to_string();
490        assert_eq!(
491            invalid.validate(),
492            Err(BehavioralSignatureValidationError::InvalidIndicator { index: 0 })
493        );
494    }
495
496    #[test]
497    fn catalogs_reject_invalid_scores_duplicates_and_sort_ties_by_id() {
498        let record = |id: &str, score: f32| TechniqueRecord {
499            technique_id: id.to_string(),
500            technique_name: "fixture".to_string(),
501            category: TechniqueCategory::Encoding,
502            description: "caller record".to_string(),
503            reported_effectiveness: score,
504            reported_false_positive_rate: 0.1,
505            method_labels: vec!["inert-marker".to_string()],
506        };
507        let mut catalog = TechniqueCatalog::new();
508        assert_eq!(catalog.insert(record("b", 0.5)), Ok(()));
509        assert_eq!(catalog.insert(record("a", 0.5)), Ok(()));
510        assert_eq!(
511            catalog.insert(record("c", f32::NAN)),
512            Err(CatalogRecordError::InvalidReportedScore)
513        );
514        assert_eq!(
515            catalog.insert(record("a", 0.4)),
516            Err(CatalogRecordError::DuplicateId)
517        );
518        assert_eq!(
519            catalog
520                .ranked(TechniqueCategory::Encoding)
521                .into_iter()
522                .map(|item| item.technique_id.as_str())
523                .collect::<Vec<_>>(),
524            vec!["a", "b"]
525        );
526    }
527
528    #[test]
529    fn transformation_catalog_stores_only_valid_records() {
530        let mut catalog = TransformationRuleCatalog::new();
531        assert_eq!(
532            catalog.insert(TransformationRule {
533                rule_id: "rule-1".to_string(),
534                target_label: "host-marker".to_string(),
535                transformation_label: "inert-marker".to_string(),
536                transformation_type: TransformationType::Encoding,
537                reported_effectiveness: 0.4,
538            }),
539            Ok(())
540        );
541        assert_eq!(catalog.for_target("host-marker").len(), 1);
542    }
543
544    #[test]
545    fn advanced_detection_wire_rejects_unsafe_numeric_values() {
546        let mut invalid_signature = valid_signature();
547        invalid_signature.threshold = f32::NAN;
548        assert!(serde_json::to_string(&invalid_signature).is_err());
549
550        let mut signature_json =
551            serde_json::to_value(valid_signature()).expect("valid signature serializes");
552        signature_json["confidence"] = serde_json::json!(1.01);
553        assert!(serde_json::from_value::<BehavioralSignature>(signature_json).is_err());
554
555        let invalid_indicator = BehaviorIndicator {
556            indicator_type: IndicatorType::Timing,
557            metric: "response_time".to_string(),
558            operator: ComparisonOperator::GreaterThan,
559            value: -1.0,
560            weight: 0.5,
561        };
562        assert!(serde_json::to_string(&invalid_indicator).is_err());
563
564        let invalid_consistency_indicator = serde_json::json!({
565            "indicator_type": "consistency",
566            "metric": "consistency",
567            "operator": "equals",
568            "value": 1.01,
569            "weight": 0.5
570        });
571        assert!(
572            serde_json::from_value::<BehaviorIndicator>(invalid_consistency_indicator).is_err()
573        );
574
575        let invalid_analysis = BehavioralAnalysisData {
576            response_time_ms: 1.0,
577            response_size_bytes: 0,
578            error_keywords_count: 0,
579            unique_patterns: 0,
580            consistency_score: f32::INFINITY,
581        };
582        assert!(serde_json::to_string(&invalid_analysis).is_err());
583
584        let invalid_technique = TechniqueRecord {
585            technique_id: "technique-1".to_string(),
586            technique_name: "fixture".to_string(),
587            category: TechniqueCategory::Encoding,
588            description: "caller record".to_string(),
589            reported_effectiveness: 0.5,
590            reported_false_positive_rate: -0.01,
591            method_labels: Vec::new(),
592        };
593        assert!(serde_json::to_string(&invalid_technique).is_err());
594
595        let invalid_rule = serde_json::json!({
596            "rule_id": "rule-1",
597            "target_label": "host-marker",
598            "transformation_label": "inert-marker",
599            "transformation_type": "Other",
600            "reported_effectiveness": 1.01
601        });
602        assert!(serde_json::from_value::<TransformationRule>(invalid_rule).is_err());
603    }
604
605    #[test]
606    fn advanced_detection_records_round_trip_without_execution() {
607        let signature = valid_signature();
608        let encoded = serde_json::to_string(&signature).expect("signature serializes");
609        let decoded: BehavioralSignature =
610            serde_json::from_str(&encoded).expect("signature deserializes");
611        assert_eq!(decoded, signature);
612
613        let analysis = BehavioralAnalysisData {
614            response_time_ms: 12.5,
615            response_size_bytes: u32::MAX,
616            error_keywords_count: 2,
617            unique_patterns: 3,
618            consistency_score: 0.75,
619        };
620        let encoded = serde_json::to_string(&analysis).expect("analysis record serializes");
621        let decoded: BehavioralAnalysisData =
622            serde_json::from_str(&encoded).expect("analysis record deserializes");
623        assert_eq!(decoded, analysis);
624
625        let rule = TransformationRule {
626            rule_id: "rule-1".to_string(),
627            target_label: "host-marker".to_string(),
628            transformation_label: "inert-marker".to_string(),
629            transformation_type: TransformationType::Other,
630            reported_effectiveness: 0.25,
631        };
632        let encoded = serde_json::to_string(&rule).expect("rule record serializes");
633        let decoded: TransformationRule =
634            serde_json::from_str(&encoded).expect("rule record deserializes");
635        assert_eq!(decoded, rule);
636    }
637}