use crate::patterns::{MAX_PATTERNS, Pattern};
use crate::types::{
AnalysisError, AnalysisResult, DEFAULT_MAX_INDICATORS_PER_STRING, DEFAULT_SUSPICIOUS_ENTROPY,
};
use serde::{Deserialize, Serialize};
use std::cmp::Reverse;
use std::collections::BTreeSet;
pub(crate) const MIN_ENTROPY_INPUT_BYTES: usize = 12;
const MAX_MATCHED_TEXT_BYTES: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SuspiciousIndicator {
pub pattern_name: String,
pub description: String,
pub severity: u8,
pub matched_text: Option<String>,
pub matched_text_truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StringAnalysis {
pub entropy: f64,
pub categories: BTreeSet<String>,
pub suspicious_indicators: Vec<SuspiciousIndicator>,
pub indicators_truncated: bool,
pub is_suspicious: bool,
}
pub trait StringAnalyzer: Send + Sync {
fn analyze(&self, value: &str) -> StringAnalysis;
fn is_suspicious(&self, value: &str) -> bool {
self.analyze(value).is_suspicious
}
fn calculate_entropy(&self, value: &str) -> f64;
fn get_patterns(&self) -> &[Pattern];
fn add_pattern(&mut self, pattern: Pattern) -> AnalysisResult<()>;
}
pub struct DefaultStringAnalyzer {
patterns: Vec<Pattern>,
entropy_threshold: f64,
max_indicators: usize,
}
impl DefaultStringAnalyzer {
pub fn new() -> Self {
Self {
patterns: Vec::new(),
entropy_threshold: DEFAULT_SUSPICIOUS_ENTROPY,
max_indicators: DEFAULT_MAX_INDICATORS_PER_STRING,
}
}
pub fn with_entropy_threshold(mut self, threshold: f64) -> AnalysisResult<Self> {
validate_entropy_threshold(threshold)?;
self.entropy_threshold = threshold;
Ok(self)
}
pub fn with_max_indicators(mut self, max_indicators: usize) -> AnalysisResult<Self> {
if max_indicators == 0 {
return Err(AnalysisError::InvalidConfiguration {
field: "max_indicators_per_string",
reason: "must be greater than zero".to_string(),
});
}
self.max_indicators = max_indicators;
Ok(self)
}
pub fn with_patterns(mut self, patterns: Vec<Pattern>) -> AnalysisResult<Self> {
if patterns.len() > MAX_PATTERNS {
return Err(AnalysisError::CapacityExceeded {
resource: "analyzer patterns",
limit: MAX_PATTERNS,
});
}
let mut names = BTreeSet::new();
for pattern in &patterns {
pattern.validate()?;
if !names.insert(pattern.name.clone()) {
return Err(AnalysisError::DuplicateName {
kind: "pattern",
name: pattern.name.to_string(),
});
}
}
self.patterns = patterns.into_iter().map(Pattern::compact).collect();
Ok(self)
}
}
impl StringAnalyzer for DefaultStringAnalyzer {
fn analyze(&self, value: &str) -> StringAnalysis {
let entropy = self.calculate_entropy(value);
let mut retained_indicators = Vec::new();
let mut categories = BTreeSet::new();
let mut indicators_truncated = false;
let mut indicator_order = 0usize;
for pattern in &self.patterns {
let Some(matched) = pattern.regex.find(value) else {
continue;
};
categories.insert(pattern.category.clone());
if pattern.is_suspicious {
let (matched_text, matched_text_truncated) = bounded_excerpt(matched.as_str());
retain_indicator(
&mut retained_indicators,
&mut indicators_truncated,
self.max_indicators,
indicator_order,
SuspiciousIndicator {
pattern_name: pattern.name.clone(),
description: pattern.description.clone(),
severity: pattern.severity,
matched_text: Some(matched_text),
matched_text_truncated,
},
);
indicator_order = indicator_order.saturating_add(1);
}
}
if entropy >= self.entropy_threshold && value.len() >= MIN_ENTROPY_INPUT_BYTES {
retain_indicator(
&mut retained_indicators,
&mut indicators_truncated,
self.max_indicators,
indicator_order,
SuspiciousIndicator {
pattern_name: "high_entropy".to_string(),
description: format!(
"Byte entropy {entropy:.2} meets the configured {threshold:.2} threshold",
threshold = self.entropy_threshold,
),
severity: 6,
matched_text: None,
matched_text_truncated: false,
},
);
indicator_order = indicator_order.saturating_add(1);
}
if value
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
retain_indicator(
&mut retained_indicators,
&mut indicators_truncated,
self.max_indicators,
indicator_order,
SuspiciousIndicator {
pattern_name: "non_printable_characters".to_string(),
description: "Contains control characters other than tab or line breaks"
.to_string(),
severity: 5,
matched_text: None,
matched_text_truncated: false,
},
);
}
retained_indicators.sort_by_key(|ranked| ranked.order);
let suspicious_indicators: Vec<_> = retained_indicators
.into_iter()
.map(|ranked| ranked.indicator)
.collect();
let is_suspicious = !suspicious_indicators.is_empty() || indicators_truncated;
StringAnalysis {
entropy,
categories,
suspicious_indicators,
indicators_truncated,
is_suspicious,
}
}
fn calculate_entropy(&self, value: &str) -> f64 {
let bytes = value.as_bytes();
if bytes.is_empty() {
return 0.0;
}
let mut byte_counts = [0usize; 256];
for &byte in bytes {
byte_counts[usize::from(byte)] = byte_counts[usize::from(byte)].saturating_add(1);
}
let length = bytes.len() as f64;
byte_counts
.into_iter()
.filter(|count| *count != 0)
.fold(0.0, |entropy, count| {
let probability = count as f64 / length;
entropy - probability * probability.log2()
})
}
fn get_patterns(&self) -> &[Pattern] {
&self.patterns
}
fn add_pattern(&mut self, pattern: Pattern) -> AnalysisResult<()> {
pattern.validate()?;
if self.patterns.len() >= MAX_PATTERNS {
return Err(AnalysisError::CapacityExceeded {
resource: "analyzer patterns",
limit: MAX_PATTERNS,
});
}
if self
.patterns
.iter()
.any(|existing| existing.name == pattern.name)
{
return Err(AnalysisError::DuplicateName {
kind: "pattern",
name: pattern.name.to_string(),
});
}
self.patterns.push(pattern.compact());
Ok(())
}
}
impl Default for DefaultStringAnalyzer {
fn default() -> Self {
Self::new()
}
}
fn validate_entropy_threshold(threshold: f64) -> AnalysisResult<()> {
if threshold.is_finite() && (0.0..=8.0).contains(&threshold) {
Ok(())
} else {
Err(AnalysisError::InvalidConfiguration {
field: "min_suspicious_entropy",
reason: "must be finite and between 0 and 8 inclusive".to_string(),
})
}
}
struct RankedIndicator {
order: usize,
indicator: SuspiciousIndicator,
}
fn retain_indicator(
indicators: &mut Vec<RankedIndicator>,
truncated: &mut bool,
limit: usize,
order: usize,
indicator: SuspiciousIndicator,
) {
if indicators.len() < limit {
indicators.push(RankedIndicator { order, indicator });
return;
}
*truncated = true;
let Some((lowest_index, lowest)) = indicators
.iter()
.enumerate()
.min_by_key(|(_, ranked)| (ranked.indicator.severity, Reverse(ranked.order)))
else {
return;
};
if indicator.severity > lowest.indicator.severity {
indicators[lowest_index] = RankedIndicator { order, indicator };
}
}
fn bounded_excerpt(value: &str) -> (String, bool) {
if value.len() <= MAX_MATCHED_TEXT_BYTES {
return (value.to_string(), false);
}
let mut end = MAX_MATCHED_TEXT_BYTES;
while !value.is_char_boundary(end) {
end -= 1;
}
(value[..end].to_string(), true)
}