Skip to main content

threatflux_string_analysis/
types.rs

1//! Shared configuration and error types.
2
3use thiserror::Error;
4
5/// Default entropy threshold used to emit a high-entropy indicator.
6pub const DEFAULT_SUSPICIOUS_ENTROPY: f64 = 4.5;
7/// Default number of detailed occurrences retained for each distinct value.
8pub const DEFAULT_MAX_OCCURRENCES_PER_STRING: usize = 1_000;
9/// Default number of distinct string values retained by a tracker.
10pub const DEFAULT_MAX_UNIQUE_STRINGS: usize = 100_000;
11/// Default maximum UTF-8 byte length of an analyzed value.
12pub const DEFAULT_MAX_INPUT_BYTES: usize = 1_048_576;
13/// Default maximum UTF-8 byte length of each source or context field.
14pub const DEFAULT_MAX_SOURCE_BYTES: usize = 16_384;
15/// Default number of distinct file identities retained for each value.
16pub const DEFAULT_MAX_UNIQUE_FILE_IDENTITIES_PER_STRING: usize = 1_024;
17/// Default number of aggregate category names retained for each value.
18pub const DEFAULT_MAX_CATEGORIES_PER_STRING: usize = 64;
19/// Default number of suspicious indicators retained for each value.
20pub const DEFAULT_MAX_INDICATORS_PER_STRING: usize = 64;
21
22/// Configuration for analysis and bounded in-memory tracking.
23///
24/// Byte limits are measured using [`str::len`], so they refer to UTF-8 encoded
25/// bytes rather than Unicode scalar values. Every limit is enforced before a
26/// tracking mutation is applied.
27#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct AnalysisConfig {
30    /// Entropy threshold at or above which a sufficiently long value is flagged.
31    pub min_suspicious_entropy: f64,
32    /// Maximum detailed occurrence records retained for one distinct value.
33    pub max_occurrences_per_string: usize,
34    /// Maximum number of distinct values retained by the tracker.
35    pub max_unique_strings: usize,
36    /// Maximum UTF-8 byte length of a value passed to the analyzer.
37    pub max_input_bytes: usize,
38    /// Maximum UTF-8 byte length of each source and context string field.
39    pub max_source_bytes: usize,
40    /// Maximum distinct `(file_path, file_hash)` pairs retained for one value.
41    pub max_unique_file_identities_per_string: usize,
42    /// Maximum aggregate category names retained for one value.
43    pub max_categories_per_string: usize,
44    /// Maximum suspicious indicators retained for one value.
45    pub max_indicators_per_string: usize,
46}
47
48impl AnalysisConfig {
49    /// Validate that all thresholds and limits are usable.
50    pub fn validate(&self) -> AnalysisResult<()> {
51        if !self.min_suspicious_entropy.is_finite()
52            || !(0.0..=8.0).contains(&self.min_suspicious_entropy)
53        {
54            return Err(AnalysisError::InvalidConfiguration {
55                field: "min_suspicious_entropy",
56                reason: "must be finite and between 0 and 8 inclusive".to_string(),
57            });
58        }
59
60        for (field, value) in [
61            (
62                "max_occurrences_per_string",
63                self.max_occurrences_per_string,
64            ),
65            ("max_unique_strings", self.max_unique_strings),
66            ("max_input_bytes", self.max_input_bytes),
67            ("max_source_bytes", self.max_source_bytes),
68            (
69                "max_unique_file_identities_per_string",
70                self.max_unique_file_identities_per_string,
71            ),
72            ("max_categories_per_string", self.max_categories_per_string),
73            ("max_indicators_per_string", self.max_indicators_per_string),
74        ] {
75            if value == 0 {
76                return Err(AnalysisError::InvalidConfiguration {
77                    field,
78                    reason: "must be greater than zero".to_string(),
79                });
80            }
81        }
82
83        Ok(())
84    }
85}
86
87impl Default for AnalysisConfig {
88    fn default() -> Self {
89        Self {
90            min_suspicious_entropy: DEFAULT_SUSPICIOUS_ENTROPY,
91            max_occurrences_per_string: DEFAULT_MAX_OCCURRENCES_PER_STRING,
92            max_unique_strings: DEFAULT_MAX_UNIQUE_STRINGS,
93            max_input_bytes: DEFAULT_MAX_INPUT_BYTES,
94            max_source_bytes: DEFAULT_MAX_SOURCE_BYTES,
95            max_unique_file_identities_per_string: DEFAULT_MAX_UNIQUE_FILE_IDENTITIES_PER_STRING,
96            max_categories_per_string: DEFAULT_MAX_CATEGORIES_PER_STRING,
97            max_indicators_per_string: DEFAULT_MAX_INDICATORS_PER_STRING,
98        }
99    }
100}
101
102/// Errors returned by validation, analysis configuration, and bounded tracking.
103#[derive(Debug, Error)]
104#[non_exhaustive]
105pub enum AnalysisError {
106    /// A tracker or analyzer configuration value is invalid.
107    #[error("invalid configuration `{field}`: {reason}")]
108    InvalidConfiguration {
109        /// Configuration field that failed validation.
110        field: &'static str,
111        /// Human-readable validation failure.
112        reason: String,
113    },
114
115    /// A statistics filter is malformed or internally contradictory.
116    #[error("invalid filter `{field}`: {reason}")]
117    InvalidFilter {
118        /// Filter field that failed validation.
119        field: &'static str,
120        /// Human-readable validation failure.
121        reason: String,
122    },
123
124    /// A custom analyzer or categorizer returned data outside API invariants.
125    #[error("invalid component output `{field}`: {reason}")]
126    InvalidComponentOutput {
127        /// Component output field that failed validation.
128        field: &'static str,
129        /// Human-readable validation failure.
130        reason: String,
131    },
132
133    /// A caller-provided field exceeds its configured UTF-8 byte limit.
134    #[error("`{field}` is {actual} bytes; configured maximum is {limit}")]
135    InputTooLarge {
136        /// Name of the oversized input field.
137        field: &'static str,
138        /// Actual UTF-8 byte length.
139        actual: usize,
140        /// Configured UTF-8 byte limit.
141        limit: usize,
142    },
143
144    /// Adding a new retained item would exceed a configured collection limit.
145    #[error("capacity exceeded for {resource}: configured maximum is {limit}")]
146    CapacityExceeded {
147        /// Bounded resource that reached capacity.
148        resource: &'static str,
149        /// Configured maximum number of items.
150        limit: usize,
151    },
152
153    /// A pattern, rule, or category identifier is invalid.
154    #[error("invalid {kind} identifier: {reason}")]
155    InvalidIdentifier {
156        /// Kind of identifier being validated.
157        kind: &'static str,
158        /// Invalid identifier value.
159        name: String,
160        /// Human-readable validation failure.
161        reason: &'static str,
162    },
163
164    /// Suspicious indicator severity lies outside the documented 0-10 range.
165    #[error("invalid severity {severity}; expected a value from 0 through 10")]
166    InvalidSeverity {
167        /// Invalid severity value.
168        severity: u8,
169    },
170
171    /// A named pattern or category rule already exists.
172    #[error("duplicate {kind} name")]
173    DuplicateName {
174        /// Kind of named object.
175        kind: &'static str,
176        /// Duplicate name.
177        name: String,
178    },
179
180    /// A named pattern or category rule could not be found.
181    #[error("{kind} was not found")]
182    NotFound {
183        /// Kind of named object.
184        kind: &'static str,
185        /// Missing name.
186        name: String,
187    },
188
189    /// A regular expression could not be compiled.
190    #[error("invalid regular expression for {context}: {reason}")]
191    InvalidRegex {
192        /// Static description of the pattern or filter being compiled.
193        context: &'static str,
194        /// Sanitized failure class that never contains the expression source.
195        reason: &'static str,
196    },
197}
198
199/// Result type for string analysis operations.
200pub type AnalysisResult<T> = Result<T, AnalysisError>;
201
202pub(crate) const MAX_IDENTIFIER_BYTES: usize = 256;
203pub(crate) const MAX_DESCRIPTION_BYTES: usize = 4_096;
204pub(crate) const MAX_REGEX_BYTES: usize = 65_536;
205
206pub(crate) fn validate_identifier(kind: &'static str, value: &str) -> AnalysisResult<()> {
207    if value.len() > MAX_IDENTIFIER_BYTES {
208        return Err(AnalysisError::InputTooLarge {
209            field: kind,
210            actual: value.len(),
211            limit: MAX_IDENTIFIER_BYTES,
212        });
213    }
214
215    let reason = if value.trim().is_empty() {
216        Some("must not be empty or whitespace-only")
217    } else if value.chars().any(char::is_control) {
218        Some("must not contain control characters")
219    } else {
220        None
221    };
222
223    if let Some(reason) = reason {
224        return Err(AnalysisError::InvalidIdentifier {
225            kind,
226            name: value.to_string(),
227            reason,
228        });
229    }
230
231    Ok(())
232}
233
234pub(crate) fn compact_string(value: String) -> String {
235    value.into_boxed_str().into_string()
236}
237
238pub(crate) fn regex_error_reason(error: &regex::Error) -> &'static str {
239    match error {
240        regex::Error::Syntax(_) => "syntax error",
241        regex::Error::CompiledTooBig(_) => "compiled expression exceeds the size limit",
242        _ => "regular expression compilation failed",
243    }
244}