Skip to main content

termivar_scanner/
anomaly.rs

1//! Experimental deviation records and text-marker matching.
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 data/helper scaffold.
9//!
10//! This module does not establish baselines, calculate statistical confidence,
11//! classify a vulnerability, assign severity, or emit a finding. Hosts may
12//! validate externally computed deviation vectors and use the matcher as a
13//! literal/regex text utility. See `docs/internals/runtime-map.md`.
14
15use thiserror::Error;
16
17/// Literal and regular-expression text matcher.
18#[derive(Debug, Clone)]
19pub struct ErrorKeywordMatcher {
20    keywords: Vec<String>,
21    patterns: Vec<regex::Regex>,
22}
23
24impl ErrorKeywordMatcher {
25    /// Creates a matcher from literal strings.
26    #[must_use]
27    pub fn with_keywords(keywords: Vec<&str>) -> Self {
28        Self {
29            keywords: keywords.into_iter().map(str::to_owned).collect(),
30            patterns: Vec::new(),
31        }
32    }
33
34    /// Creates a matcher from regular expressions.
35    pub fn with_patterns(patterns: Vec<&str>) -> Result<Self, String> {
36        let patterns = patterns
37            .into_iter()
38            .map(regex::Regex::new)
39            .collect::<Result<Vec<_>, _>>()
40            .map_err(|error| error.to_string())?;
41        Ok(Self {
42            keywords: Vec::new(),
43            patterns,
44        })
45    }
46
47    /// Creates a matcher from literals and regular expressions.
48    pub fn with_keywords_and_patterns(
49        keywords: Vec<&str>,
50        patterns: Vec<&str>,
51    ) -> Result<Self, String> {
52        let mut matcher = Self::with_patterns(patterns)?;
53        matcher.keywords = keywords.into_iter().map(str::to_owned).collect();
54        Ok(matcher)
55    }
56
57    /// Returns whether any configured literal or expression matches `text`.
58    #[must_use]
59    pub fn is_match(&self, text: &str) -> bool {
60        self.keywords.iter().any(|keyword| text.contains(keyword))
61            || self.patterns.iter().any(|pattern| pattern.is_match(text))
62    }
63
64    /// Counts configured literals and expressions that match `text`.
65    #[must_use]
66    pub fn match_count(&self, text: &str) -> usize {
67        self.keywords
68            .iter()
69            .filter(|keyword| text.contains(keyword.as_str()))
70            .count()
71            + self
72                .patterns
73                .iter()
74                .filter(|pattern| pattern.is_match(text))
75                .count()
76    }
77}
78
79/// Neutral dimensions in a caller-supplied deviation vector.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum DeviationDimension {
82    Timing,
83    ResponseSize,
84    TextMarker,
85    StatusCode,
86}
87
88/// Caller-supplied normalized deviation vector.
89///
90/// Values are descriptive inputs only. No vulnerability, confidence, severity,
91/// or reporting decision is derived from them.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct ResponseDeviation {
94    pub timing: f32,
95    pub response_size: f32,
96    pub text_marker: f32,
97    pub status_code: f32,
98}
99
100impl ResponseDeviation {
101    /// Validates that every dimension is finite and normalized to `0..=1`.
102    pub fn validate(&self) -> Result<(), ResponseDeviationValidationError> {
103        for (dimension, value) in [
104            (DeviationDimension::Timing, self.timing),
105            (DeviationDimension::ResponseSize, self.response_size),
106            (DeviationDimension::TextMarker, self.text_marker),
107            (DeviationDimension::StatusCode, self.status_code),
108        ] {
109            if !value.is_finite() || !(0.0..=1.0).contains(&value) {
110                return Err(ResponseDeviationValidationError::InvalidDimension { dimension });
111            }
112        }
113        Ok(())
114    }
115
116    /// Returns the largest nonzero dimension after validation.
117    ///
118    /// Equal values use the stable enum order shown in the source.
119    pub fn dominant_dimension(
120        &self,
121    ) -> Result<Option<DeviationDimension>, ResponseDeviationValidationError> {
122        self.validate()?;
123        let mut dominant = None;
124        let mut maximum = 0.0_f32;
125        for (dimension, value) in [
126            (DeviationDimension::Timing, self.timing),
127            (DeviationDimension::ResponseSize, self.response_size),
128            (DeviationDimension::TextMarker, self.text_marker),
129            (DeviationDimension::StatusCode, self.status_code),
130        ] {
131            if value > maximum {
132                maximum = value;
133                dominant = Some(dimension);
134            }
135        }
136        Ok(dominant)
137    }
138}
139
140/// Validation failure for a caller-supplied deviation vector.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
142pub enum ResponseDeviationValidationError {
143    #[error("response deviation dimension {dimension:?} must be finite and within 0..=1")]
144    InvalidDimension { dimension: DeviationDimension },
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn matcher_reports_only_literal_and_regex_matches() {
153        let matcher = ErrorKeywordMatcher::with_keywords_and_patterns(
154            vec!["literal-marker"],
155            vec![r"code-[0-9]+"],
156        )
157        .expect("valid pattern");
158
159        assert!(matcher.is_match("literal-marker and code-42"));
160        assert_eq!(matcher.match_count("literal-marker and code-42"), 2);
161        assert!(!matcher.is_match("ordinary text"));
162    }
163
164    #[test]
165    fn invalid_regex_is_rejected() {
166        assert!(ErrorKeywordMatcher::with_patterns(vec!["("]).is_err());
167    }
168
169    #[test]
170    fn deviation_vector_rejects_nonfinite_and_out_of_range_values() {
171        for invalid in [f32::NAN, f32::INFINITY, -0.1, 1.1] {
172            let vector = ResponseDeviation {
173                timing: invalid,
174                response_size: 0.0,
175                text_marker: 0.0,
176                status_code: 0.0,
177            };
178            assert_eq!(
179                vector.validate(),
180                Err(ResponseDeviationValidationError::InvalidDimension {
181                    dimension: DeviationDimension::Timing,
182                })
183            );
184        }
185    }
186
187    #[test]
188    fn dominant_dimension_is_the_actual_maximum_with_stable_ties() {
189        let vector = ResponseDeviation {
190            timing: 0.2,
191            response_size: 0.7,
192            text_marker: 0.7,
193            status_code: 0.3,
194        };
195        assert_eq!(
196            vector.dominant_dimension(),
197            Ok(Some(DeviationDimension::ResponseSize))
198        );
199
200        let zero = ResponseDeviation {
201            timing: 0.0,
202            response_size: 0.0,
203            text_marker: 0.0,
204            status_code: 0.0,
205        };
206        assert_eq!(zero.dominant_dimension(), Ok(None));
207    }
208}