1use crate::analyzer::{
4 DefaultStringAnalyzer, MIN_ENTROPY_INPUT_BYTES, StringAnalysis, StringAnalyzer,
5 SuspiciousIndicator,
6};
7use crate::categorizer::{Categorizer, DefaultCategorizer, StringCategory, validate_category};
8use crate::patterns::{DefaultPatternProvider, PatternProvider};
9use crate::types::{
10 AnalysisConfig, AnalysisError, AnalysisResult, MAX_DESCRIPTION_BYTES, MAX_REGEX_BYTES,
11 compact_string, regex_error_reason, validate_identifier,
12};
13use chrono::{DateTime, Utc};
14use regex::{Regex, RegexBuilder};
15use serde::{Deserialize, Serialize};
16use std::cmp::{Ordering, Reverse};
17use std::collections::{BTreeMap, BTreeSet, VecDeque};
18use std::sync::{Arc, Mutex, MutexGuard};
19
20const MOST_COMMON_LIMIT: usize = 100;
21const SUSPICIOUS_SAMPLE_LIMIT: usize = 50;
22const HIGH_ENTROPY_SAMPLE_LIMIT: usize = 50;
23const FILTER_REGEX_COMPILED_SIZE_LIMIT: usize = 2 * 1024 * 1024;
24const FILTER_REGEX_DFA_SIZE_LIMIT: usize = 4 * 1024 * 1024;
25const INDICATOR_MATCHED_TEXT_LIMIT: usize = 512;
26const MIN_RELATED_SCORE: f64 = 0.30;
27
28type StringEntryMap = Arc<Mutex<BTreeMap<String, StringEntry>>>;
29
30pub type DateTimeRange = (DateTime<Utc>, DateTime<Utc>);
32
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub enum StringContext {
37 FileString {
39 offset: Option<u64>,
41 },
42 Import {
44 library: String,
46 },
47 Export {
49 symbol: String,
51 },
52 Resource {
54 resource_type: String,
56 },
57 Section {
59 section_name: String,
61 },
62 Metadata {
64 field: String,
66 },
67 Path {
69 path_type: String,
71 },
72 Url {
74 protocol: Option<String>,
76 },
77 Registry {
79 hive: Option<String>,
81 },
82 Command {
84 command_type: String,
86 },
87 Other {
89 category: String,
91 },
92}
93
94impl StringContext {
95 fn category_name(&self) -> &str {
96 match self {
97 Self::FileString { .. } => "file_string",
98 Self::Import { .. } => "import",
99 Self::Export { .. } => "export",
100 Self::Resource { .. } => "resource",
101 Self::Section { .. } => "section",
102 Self::Metadata { .. } => "metadata",
103 Self::Path { .. } => "path",
104 Self::Url { .. } => "url",
105 Self::Registry { .. } => "registry",
106 Self::Command { .. } => "command",
107 Self::Other { category } => category,
108 }
109 }
110
111 fn owned_field(&self) -> Option<(&'static str, &str)> {
112 match self {
113 Self::FileString { .. } => None,
114 Self::Import { library } => Some(("context.import.library", library)),
115 Self::Export { symbol } => Some(("context.export.symbol", symbol)),
116 Self::Resource { resource_type } => {
117 Some(("context.resource.resource_type", resource_type))
118 }
119 Self::Section { section_name } => Some(("context.section.section_name", section_name)),
120 Self::Metadata { field } => Some(("context.metadata.field", field)),
121 Self::Path { path_type } => Some(("context.path.path_type", path_type)),
122 Self::Url { protocol } => protocol
123 .as_deref()
124 .map(|value| ("context.url.protocol", value)),
125 Self::Registry { hive } => hive
126 .as_deref()
127 .map(|value| ("context.registry.hive", value)),
128 Self::Command { command_type } => Some(("context.command.command_type", command_type)),
129 Self::Other { category } => Some(("context.other.category", category)),
130 }
131 }
132
133 fn compact(&mut self) {
134 match self {
135 Self::FileString { .. } => {}
136 Self::Import { library } => compact_in_place(library),
137 Self::Export { symbol } => compact_in_place(symbol),
138 Self::Resource { resource_type } => compact_in_place(resource_type),
139 Self::Section { section_name } => compact_in_place(section_name),
140 Self::Metadata { field } => compact_in_place(field),
141 Self::Path { path_type } => compact_in_place(path_type),
142 Self::Url { protocol } => {
143 if let Some(protocol) = protocol {
144 compact_in_place(protocol);
145 }
146 }
147 Self::Registry { hive } => {
148 if let Some(hive) = hive {
149 compact_in_place(hive);
150 }
151 }
152 Self::Command { command_type } => compact_in_place(command_type),
153 Self::Other { category } => compact_in_place(category),
154 }
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct FileIdentity {
162 pub file_path: String,
164 pub file_hash: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct StringOccurrence {
172 pub file_path: String,
174 pub file_hash: String,
176 pub tool_name: String,
178 pub timestamp: DateTime<Utc>,
180 pub context: StringContext,
182}
183
184impl StringOccurrence {
185 pub fn new(
187 file_path: impl Into<String>,
188 file_hash: impl Into<String>,
189 tool_name: impl Into<String>,
190 timestamp: DateTime<Utc>,
191 context: StringContext,
192 ) -> Self {
193 let mut context = context;
194 context.compact();
195 Self {
196 file_path: compact_string(file_path.into()),
197 file_hash: compact_string(file_hash.into()),
198 tool_name: compact_string(tool_name.into()),
199 timestamp,
200 context,
201 }
202 }
203
204 fn file_identity(&self) -> FileIdentity {
205 FileIdentity {
206 file_path: self.file_path.as_str().to_string(),
207 file_hash: self.file_hash.as_str().to_string(),
208 }
209 }
210
211 fn compact(&mut self) {
212 compact_in_place(&mut self.file_path);
213 compact_in_place(&mut self.file_hash);
214 compact_in_place(&mut self.tool_name);
215 self.context.compact();
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct StringEntry {
223 pub value: String,
225 pub first_seen: DateTime<Utc>,
227 pub last_seen: DateTime<Utc>,
229 pub total_occurrences: u64,
231 pub unique_file_identities: BTreeSet<FileIdentity>,
233 pub occurrences: VecDeque<StringOccurrence>,
235 pub categories: BTreeSet<String>,
237 pub is_suspicious: bool,
239 pub entropy: f64,
241 pub suspicious_indicators: Vec<SuspiciousIndicator>,
243 pub indicators_truncated: bool,
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct StringStatistics {
251 pub total_unique_strings: u64,
253 pub total_occurrences: u64,
255 pub total_files_analyzed: u64,
257 pub most_common: Vec<(String, u64)>,
259 pub total_suspicious_strings: u64,
261 pub suspicious_strings: Vec<String>,
263 pub total_high_entropy_strings: u64,
265 pub high_entropy_strings: Vec<(String, f64)>,
267 pub category_distribution: BTreeMap<String, u64>,
269 pub length_distribution: BTreeMap<String, u64>,
271}
272
273#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
275#[serde(default, deny_unknown_fields)]
276pub struct StringFilter {
277 pub min_occurrences: Option<u64>,
279 pub max_occurrences: Option<u64>,
281 pub min_length: Option<usize>,
283 pub max_length: Option<usize>,
285 pub categories: Option<Vec<String>>,
287 pub file_paths: Option<Vec<String>>,
289 pub file_hashes: Option<Vec<String>>,
291 pub suspicious_only: Option<bool>,
293 pub regex_pattern: Option<String>,
295 pub min_entropy: Option<f64>,
297 pub max_entropy: Option<f64>,
299 pub date_range: Option<DateTimeRange>,
301}
302
303#[derive(Clone)]
305pub struct StringTracker {
306 entries: StringEntryMap,
307 analyzer: Arc<dyn StringAnalyzer>,
308 categorizer: Arc<dyn Categorizer>,
309 config: Arc<AnalysisConfig>,
310}
311
312impl Default for StringTracker {
313 fn default() -> Self {
314 Self::new()
315 }
316}
317
318impl StringTracker {
319 pub fn new() -> Self {
321 match Self::with_config(AnalysisConfig::default()) {
322 Ok(tracker) => tracker,
323 Err(error) => panic!("built-in tracker defaults must be valid: {error}"),
324 }
325 }
326
327 pub fn with_config(config: AnalysisConfig) -> AnalysisResult<Self> {
329 config.validate()?;
330 let patterns = DefaultPatternProvider::new()?.get_patterns();
331 let analyzer = DefaultStringAnalyzer::new()
332 .with_entropy_threshold(config.min_suspicious_entropy)?
333 .with_max_indicators(config.max_indicators_per_string)?
334 .with_patterns(patterns)?;
335 Self::with_components_and_config(
336 Box::new(analyzer),
337 Box::new(DefaultCategorizer::new()),
338 config,
339 )
340 }
341
342 pub fn with_components(
344 analyzer: Box<dyn StringAnalyzer>,
345 categorizer: Box<dyn Categorizer>,
346 ) -> Self {
347 match Self::with_components_and_config(analyzer, categorizer, AnalysisConfig::default()) {
348 Ok(tracker) => tracker,
349 Err(error) => panic!("built-in tracker defaults must be valid: {error}"),
350 }
351 }
352
353 pub fn with_components_and_config(
355 analyzer: Box<dyn StringAnalyzer>,
356 categorizer: Box<dyn Categorizer>,
357 config: AnalysisConfig,
358 ) -> AnalysisResult<Self> {
359 config.validate()?;
360 Ok(Self {
361 entries: Arc::new(Mutex::new(BTreeMap::new())),
362 analyzer: Arc::from(analyzer),
363 categorizer: Arc::from(categorizer),
364 config: Arc::new(config),
365 })
366 }
367
368 pub fn config(&self) -> &AnalysisConfig {
370 &self.config
371 }
372
373 pub fn track_string(
375 &self,
376 value: &str,
377 file_path: &str,
378 file_hash: &str,
379 tool_name: &str,
380 context: StringContext,
381 ) -> AnalysisResult<()> {
382 self.validate_tracking_fields(value, file_path, file_hash, tool_name, &context)?;
383 self.track_occurrence(
384 value,
385 StringOccurrence::new(file_path, file_hash, tool_name, Utc::now(), context),
386 )
387 }
388
389 pub fn track_occurrence(
391 &self,
392 value: &str,
393 occurrence: StringOccurrence,
394 ) -> AnalysisResult<()> {
395 self.track_occurrence_with_categories(value, occurrence, None)
396 }
397
398 fn track_occurrence_with_categories(
399 &self,
400 value: &str,
401 mut occurrence: StringOccurrence,
402 precomputed_categories: Option<Vec<StringCategory>>,
403 ) -> AnalysisResult<()> {
404 self.validate_tracking_input(value, &occurrence)?;
405 occurrence.compact();
406 let context_category = occurrence.context.category_name().to_string();
407
408 {
410 let mut entries = self.lock_entries();
411 if let Some(entry) = entries.get_mut(value) {
412 return self.update_existing_entry(entry, occurrence, context_category);
413 }
414 if entries.len() >= self.config.max_unique_strings {
415 return Err(AnalysisError::CapacityExceeded {
416 resource: "unique strings",
417 limit: self.config.max_unique_strings,
418 });
419 }
420 }
421
422 let categorized = match precomputed_categories {
424 Some(categories) => categories,
425 None => self.normalize_categories(self.categorizer.categorize(value))?,
426 };
427 let analysis = self.normalize_analysis(self.analyzer.analyze(value))?;
428 let categories = self.aggregate_categories(&analysis, &categorized, &context_category)?;
429
430 let mut entries = self.lock_entries();
432 if let Some(entry) = entries.get_mut(value) {
433 return self.update_existing_entry(entry, occurrence, context_category);
434 }
435 if entries.len() >= self.config.max_unique_strings {
436 return Err(AnalysisError::CapacityExceeded {
437 resource: "unique strings",
438 limit: self.config.max_unique_strings,
439 });
440 }
441
442 let timestamp = occurrence.timestamp;
443 let identity = occurrence.file_identity();
444 let mut occurrences = VecDeque::new();
447 occurrences.push_back(occurrence);
448 entries.insert(
449 value.to_string(),
450 StringEntry {
451 value: value.to_string(),
452 first_seen: timestamp,
453 last_seen: timestamp,
454 total_occurrences: 1,
455 unique_file_identities: BTreeSet::from([identity]),
456 occurrences,
457 categories,
458 is_suspicious: analysis.is_suspicious,
459 entropy: analysis.entropy,
460 suspicious_indicators: analysis.suspicious_indicators,
461 indicators_truncated: analysis.indicators_truncated,
462 },
463 );
464 Ok(())
465 }
466
467 pub fn track_strings(
472 &self,
473 strings: &[String],
474 file_path: &str,
475 file_hash: &str,
476 tool_name: &str,
477 ) -> AnalysisResult<()> {
478 for value in strings {
479 self.validate_bytes("value", value, self.config.max_input_bytes)?;
481 self.validate_bytes("file_path", file_path, self.config.max_source_bytes)?;
482 self.validate_bytes("file_hash", file_hash, self.config.max_source_bytes)?;
483 self.validate_bytes("tool_name", tool_name, self.config.max_source_bytes)?;
484 let categories = self.normalize_categories(self.categorizer.categorize(value))?;
485 let context = inferred_context(value, &categories, self.config.max_source_bytes)?;
486 self.track_occurrence_with_categories(
487 value,
488 StringOccurrence::new(file_path, file_hash, tool_name, Utc::now(), context),
489 Some(categories),
490 )?;
491 }
492 Ok(())
493 }
494
495 pub fn track_strings_from_results(
497 &self,
498 strings: &[String],
499 file_path: &str,
500 file_hash: &str,
501 tool_name: &str,
502 ) -> AnalysisResult<()> {
503 self.track_strings(strings, file_path, file_hash, tool_name)
504 }
505
506 pub fn get_statistics(
508 &self,
509 filter: Option<&StringFilter>,
510 ) -> AnalysisResult<StringStatistics> {
511 let compiled_filter = CompiledFilter::new(filter, &self.config)?;
512 let entries = self.lock_entries();
513 let mut total_unique_strings = 0u64;
517 let mut total_occurrences = 0u64;
518 let mut file_identities: BTreeSet<&FileIdentity> = BTreeSet::new();
519 let mut most_common = Vec::with_capacity(MOST_COMMON_LIMIT + 1);
520 let mut total_suspicious_strings = 0u64;
521 let mut suspicious = Vec::with_capacity(SUSPICIOUS_SAMPLE_LIMIT + 1);
522 let mut total_high_entropy_strings = 0u64;
523 let mut high_entropy = Vec::with_capacity(HIGH_ENTROPY_SAMPLE_LIMIT + 1);
524 let mut category_counts: BTreeMap<&str, u64> = BTreeMap::new();
525 let mut length_counts: BTreeMap<&'static str, u64> = BTreeMap::new();
526
527 for entry in entries
528 .values()
529 .filter(|entry| compiled_filter.matches(entry))
530 {
531 total_unique_strings = total_unique_strings.saturating_add(1);
532 total_occurrences = total_occurrences.saturating_add(entry.total_occurrences);
533 file_identities.extend(entry.unique_file_identities.iter());
534 insert_bounded_sorted(&mut most_common, entry, MOST_COMMON_LIMIT, |left, right| {
535 right
536 .total_occurrences
537 .cmp(&left.total_occurrences)
538 .then_with(|| left.value.cmp(&right.value))
539 });
540
541 if entry.is_suspicious {
542 total_suspicious_strings = total_suspicious_strings.saturating_add(1);
543 let severity = entry
544 .suspicious_indicators
545 .iter()
546 .map(|indicator| indicator.severity)
547 .max()
548 .unwrap_or(0);
549 insert_bounded_sorted(
550 &mut suspicious,
551 (entry, severity),
552 SUSPICIOUS_SAMPLE_LIMIT,
553 |left, right| {
554 right
555 .1
556 .cmp(&left.1)
557 .then_with(|| right.0.total_occurrences.cmp(&left.0.total_occurrences))
558 .then_with(|| left.0.value.cmp(&right.0.value))
559 },
560 );
561 }
562
563 if entry.value.len() >= MIN_ENTROPY_INPUT_BYTES
564 && entry.entropy >= self.config.min_suspicious_entropy
565 {
566 total_high_entropy_strings = total_high_entropy_strings.saturating_add(1);
567 insert_bounded_sorted(
568 &mut high_entropy,
569 entry,
570 HIGH_ENTROPY_SAMPLE_LIMIT,
571 |left, right| {
572 right
573 .entropy
574 .total_cmp(&left.entropy)
575 .then_with(|| left.value.cmp(&right.value))
576 },
577 );
578 }
579
580 for category in &entry.categories {
581 let count = category_counts.entry(category).or_insert(0u64);
582 *count = count.saturating_add(1);
583 }
584 let bucket = match entry.value.len() {
585 0..=10 => "0-10",
586 11..=20 => "11-20",
587 21..=50 => "21-50",
588 51..=100 => "51-100",
589 101..=200 => "101-200",
590 _ => "201+",
591 };
592 let count = length_counts.entry(bucket).or_insert(0u64);
593 *count = count.saturating_add(1);
594 }
595
596 let total_files_analyzed = usize_to_u64(file_identities.len());
597 let most_common = most_common
598 .into_iter()
599 .map(|entry| (entry.value.clone(), entry.total_occurrences))
600 .collect();
601 let suspicious_strings = suspicious
602 .into_iter()
603 .map(|(entry, _)| entry.value.clone())
604 .collect();
605 let high_entropy_strings = high_entropy
606 .into_iter()
607 .map(|entry| (entry.value.clone(), entry.entropy))
608 .collect();
609 let category_distribution = category_counts
610 .into_iter()
611 .map(|(category, count)| (category.to_string(), count))
612 .collect();
613 let length_distribution = length_counts
614 .into_iter()
615 .map(|(bucket, count)| (bucket.to_string(), count))
616 .collect();
617
618 Ok(StringStatistics {
619 total_unique_strings,
620 total_occurrences,
621 total_files_analyzed,
622 most_common,
623 total_suspicious_strings,
624 suspicious_strings,
625 total_high_entropy_strings,
626 high_entropy_strings,
627 category_distribution,
628 length_distribution,
629 })
630 }
631
632 pub fn get_string_details(&self, value: &str) -> Option<StringEntry> {
634 self.lock_entries().get(value).cloned()
635 }
636
637 pub fn search_strings(&self, query: &str, limit: usize) -> AnalysisResult<Vec<StringEntry>> {
639 self.validate_bytes("query", query, self.config.max_input_bytes)?;
640 if query.trim().is_empty() || limit == 0 {
641 return Ok(Vec::new());
642 }
643
644 let query_lower = query.to_lowercase();
645 let entries = self.lock_entries();
646 let mut matches: Vec<_> = entries
647 .values()
648 .filter(|entry| entry.value.to_lowercase().contains(&query_lower))
649 .collect();
650 matches.sort_by(|left, right| {
651 right
652 .total_occurrences
653 .cmp(&left.total_occurrences)
654 .then_with(|| left.value.cmp(&right.value))
655 });
656 matches.truncate(limit);
657 Ok(matches.into_iter().cloned().collect())
658 }
659
660 pub fn get_related_strings(
662 &self,
663 value: &str,
664 limit: usize,
665 ) -> AnalysisResult<Vec<(String, f64)>> {
666 self.validate_bytes("value", value, self.config.max_input_bytes)?;
667 if limit == 0 {
668 return Ok(Vec::new());
669 }
670
671 let entries = self.lock_entries();
672 let Some(target) = entries.get(value) else {
673 return Ok(Vec::new());
674 };
675 let mut related: Vec<_> = entries
676 .iter()
677 .filter(|(candidate, _)| candidate.as_str() != value)
678 .filter_map(|(candidate, entry)| {
679 let score = calculate_similarity(target, entry);
680 (score >= MIN_RELATED_SCORE).then_some((candidate.as_str(), score))
681 })
682 .collect();
683 related.sort_by(|left, right| right.1.total_cmp(&left.1).then_with(|| left.0.cmp(right.0)));
684 related.truncate(limit);
685 Ok(related
686 .into_iter()
687 .map(|(candidate, score)| (candidate.to_string(), score))
688 .collect())
689 }
690
691 pub fn clear(&self) {
693 self.lock_entries().clear();
694 }
695
696 fn update_existing_entry(
697 &self,
698 entry: &mut StringEntry,
699 occurrence: StringOccurrence,
700 context_category: String,
701 ) -> AnalysisResult<()> {
702 let identity = occurrence.file_identity();
703 if !entry.unique_file_identities.contains(&identity)
704 && entry.unique_file_identities.len()
705 >= self.config.max_unique_file_identities_per_string
706 {
707 return Err(AnalysisError::CapacityExceeded {
708 resource: "unique file identities per string",
709 limit: self.config.max_unique_file_identities_per_string,
710 });
711 }
712 if !entry.categories.contains(&context_category)
713 && entry.categories.len() >= self.config.max_categories_per_string
714 {
715 return Err(AnalysisError::CapacityExceeded {
716 resource: "categories per string",
717 limit: self.config.max_categories_per_string,
718 });
719 }
720
721 entry.first_seen = entry.first_seen.min(occurrence.timestamp);
722 entry.last_seen = entry.last_seen.max(occurrence.timestamp);
723 entry.total_occurrences = entry.total_occurrences.saturating_add(1);
724 entry.unique_file_identities.insert(identity);
725 entry.categories.insert(context_category);
726 entry.occurrences.push_back(occurrence);
727 while entry.occurrences.len() > self.config.max_occurrences_per_string {
728 entry.occurrences.pop_front();
729 }
730 Ok(())
731 }
732
733 fn validate_tracking_input(
734 &self,
735 value: &str,
736 occurrence: &StringOccurrence,
737 ) -> AnalysisResult<()> {
738 self.validate_tracking_fields(
739 value,
740 &occurrence.file_path,
741 &occurrence.file_hash,
742 &occurrence.tool_name,
743 &occurrence.context,
744 )
745 }
746
747 fn validate_tracking_fields(
748 &self,
749 value: &str,
750 file_path: &str,
751 file_hash: &str,
752 tool_name: &str,
753 context: &StringContext,
754 ) -> AnalysisResult<()> {
755 self.validate_bytes("value", value, self.config.max_input_bytes)?;
756 self.validate_bytes("file_path", file_path, self.config.max_source_bytes)?;
757 self.validate_bytes("file_hash", file_hash, self.config.max_source_bytes)?;
758 self.validate_bytes("tool_name", tool_name, self.config.max_source_bytes)?;
759 if let Some((field, source_value)) = context.owned_field() {
760 self.validate_bytes(field, source_value, self.config.max_source_bytes)?;
761 }
762 if let StringContext::Other { category } = context {
763 validate_identifier("context category", category)?;
764 }
765 Ok(())
766 }
767
768 fn validate_bytes(&self, field: &'static str, value: &str, limit: usize) -> AnalysisResult<()> {
769 if value.len() > limit {
770 Err(AnalysisError::InputTooLarge {
771 field,
772 actual: value.len(),
773 limit,
774 })
775 } else {
776 Ok(())
777 }
778 }
779
780 fn normalize_analysis(&self, mut analysis: StringAnalysis) -> AnalysisResult<StringAnalysis> {
781 if !analysis.entropy.is_finite() || !(0.0..=8.0).contains(&analysis.entropy) {
782 return Err(AnalysisError::InvalidComponentOutput {
783 field: "analysis.entropy",
784 reason: "must be finite and between 0 and 8 inclusive".to_string(),
785 });
786 }
787 if analysis.categories.len() > self.config.max_categories_per_string {
788 return Err(AnalysisError::CapacityExceeded {
789 resource: "analyzer categories per string",
790 limit: self.config.max_categories_per_string,
791 });
792 }
793 for category in &analysis.categories {
794 validate_identifier("analyzer category", category)?;
795 }
796 analysis.categories = analysis
797 .categories
798 .into_iter()
799 .map(compact_string)
800 .collect();
801
802 let (retained_indicators, truncated_by_limit) = retain_highest_severity_indicators(
803 analysis.suspicious_indicators,
804 self.config.max_indicators_per_string,
805 );
806 analysis.indicators_truncated |= truncated_by_limit;
807 let mut normalized_indicators = Vec::with_capacity(retained_indicators.len());
808 for mut indicator in retained_indicators {
809 validate_identifier("indicator pattern", &indicator.pattern_name)?;
810 if indicator.severity > 10 {
811 return Err(AnalysisError::InvalidSeverity {
812 severity: indicator.severity,
813 });
814 }
815 if indicator.description.len() > MAX_DESCRIPTION_BYTES {
816 return Err(AnalysisError::InputTooLarge {
817 field: "indicator.description",
818 actual: indicator.description.len(),
819 limit: MAX_DESCRIPTION_BYTES,
820 });
821 }
822 if indicator.description.trim().is_empty() {
823 return Err(AnalysisError::InvalidComponentOutput {
824 field: "indicator.description",
825 reason: "must not be empty or whitespace-only".to_string(),
826 });
827 }
828 if indicator.description.chars().any(char::is_control) {
829 return Err(AnalysisError::InvalidComponentOutput {
830 field: "indicator.description",
831 reason: "must not contain control characters".to_string(),
832 });
833 }
834 indicator.pattern_name = compact_string(indicator.pattern_name);
835 indicator.description = compact_string(indicator.description);
836 if let Some(matched_text) = indicator.matched_text.take() {
837 let (matched_text, truncated) =
838 bounded_owned_string(matched_text, INDICATOR_MATCHED_TEXT_LIMIT);
839 indicator.matched_text_truncated |= truncated;
840 indicator.matched_text = Some(matched_text);
841 } else {
842 indicator.matched_text_truncated = false;
843 }
844 normalized_indicators.push(indicator);
845 }
846 analysis.suspicious_indicators = normalized_indicators;
847 if !analysis.suspicious_indicators.is_empty() || analysis.indicators_truncated {
848 analysis.is_suspicious = true;
849 } else if analysis.is_suspicious {
850 analysis.indicators_truncated = true;
851 }
852 Ok(analysis)
853 }
854
855 fn normalize_categories(
856 &self,
857 categories: Vec<StringCategory>,
858 ) -> AnalysisResult<Vec<StringCategory>> {
859 if categories.len() > self.config.max_categories_per_string {
860 return Err(AnalysisError::CapacityExceeded {
861 resource: "categorizer categories per string",
862 limit: self.config.max_categories_per_string,
863 });
864 }
865
866 let mut normalized = Vec::with_capacity(categories.len());
867 for category in categories {
868 validate_category(&category)?;
869 normalized.push(category.compact());
870 }
871 Ok(normalized)
872 }
873
874 fn aggregate_categories(
875 &self,
876 analysis: &StringAnalysis,
877 categorized: &[StringCategory],
878 context_category: &str,
879 ) -> AnalysisResult<BTreeSet<String>> {
880 if categorized.len() > self.config.max_categories_per_string {
881 return Err(AnalysisError::CapacityExceeded {
882 resource: "categorizer categories per string",
883 limit: self.config.max_categories_per_string,
884 });
885 }
886
887 let mut categories = analysis.categories.clone();
888 validate_identifier("context category", context_category)?;
889 if !categories.contains(context_category)
890 && categories.len() >= self.config.max_categories_per_string
891 {
892 return Err(AnalysisError::CapacityExceeded {
893 resource: "categories per string",
894 limit: self.config.max_categories_per_string,
895 });
896 }
897 categories.insert(context_category.to_string());
898 for category in categorized {
899 validate_identifier("categorizer category", &category.name)?;
900 if !categories.contains(&category.name)
901 && categories.len() >= self.config.max_categories_per_string
902 {
903 return Err(AnalysisError::CapacityExceeded {
904 resource: "categories per string",
905 limit: self.config.max_categories_per_string,
906 });
907 }
908 categories.insert(category.name.clone());
909 }
910 Ok(categories)
911 }
912
913 fn lock_entries(&self) -> MutexGuard<'_, BTreeMap<String, StringEntry>> {
914 match self.entries.lock() {
915 Ok(entries) => entries,
916 Err(poisoned) => poisoned.into_inner(),
917 }
918 }
919}
920
921struct CompiledFilter<'a> {
922 filter: Option<&'a StringFilter>,
923 regex: Option<Regex>,
924}
925
926impl<'a> CompiledFilter<'a> {
927 fn new(filter: Option<&'a StringFilter>, config: &AnalysisConfig) -> AnalysisResult<Self> {
928 let Some(filter) = filter else {
929 return Ok(Self {
930 filter: None,
931 regex: None,
932 });
933 };
934
935 validate_ordered_filter(
936 "occurrences",
937 filter.min_occurrences,
938 filter.max_occurrences,
939 )?;
940 validate_ordered_filter("length", filter.min_length, filter.max_length)?;
941 validate_entropy_filter(filter.min_entropy, filter.max_entropy)?;
942 if let Some((start, end)) = &filter.date_range
943 && start > end
944 {
945 return Err(AnalysisError::InvalidFilter {
946 field: "date_range",
947 reason: "start must be earlier than or equal to end".to_string(),
948 });
949 }
950 validate_filter_values(
951 "categories",
952 filter.categories.as_deref(),
953 config.max_categories_per_string,
954 config.max_source_bytes,
955 )?;
956 validate_filter_values(
957 "file_paths",
958 filter.file_paths.as_deref(),
959 config.max_unique_file_identities_per_string,
960 config.max_source_bytes,
961 )?;
962 validate_filter_values(
963 "file_hashes",
964 filter.file_hashes.as_deref(),
965 config.max_unique_file_identities_per_string,
966 config.max_source_bytes,
967 )?;
968
969 let regex = filter
970 .regex_pattern
971 .as_ref()
972 .map(|pattern| {
973 let regex_source_limit = config.max_source_bytes.min(MAX_REGEX_BYTES);
974 if pattern.len() > regex_source_limit {
975 return Err(AnalysisError::InvalidFilter {
976 field: "regex_pattern",
977 reason: format!(
978 "is {} bytes; effective maximum is {}",
979 pattern.len(),
980 regex_source_limit
981 ),
982 });
983 }
984 RegexBuilder::new(pattern)
985 .size_limit(FILTER_REGEX_COMPILED_SIZE_LIMIT)
986 .dfa_size_limit(FILTER_REGEX_DFA_SIZE_LIMIT)
987 .build()
988 .map_err(|error| AnalysisError::InvalidRegex {
989 context: "statistics filter",
990 reason: regex_error_reason(&error),
991 })
992 })
993 .transpose()?;
994
995 Ok(Self {
996 filter: Some(filter),
997 regex,
998 })
999 }
1000
1001 fn matches(&self, entry: &StringEntry) -> bool {
1002 let Some(filter) = self.filter else {
1003 return true;
1004 };
1005
1006 if filter
1007 .min_occurrences
1008 .is_some_and(|minimum| entry.total_occurrences < minimum)
1009 || filter
1010 .max_occurrences
1011 .is_some_and(|maximum| entry.total_occurrences > maximum)
1012 || filter
1013 .min_length
1014 .is_some_and(|minimum| entry.value.len() < minimum)
1015 || filter
1016 .max_length
1017 .is_some_and(|maximum| entry.value.len() > maximum)
1018 || filter
1019 .min_entropy
1020 .is_some_and(|minimum| entry.entropy < minimum)
1021 || filter
1022 .max_entropy
1023 .is_some_and(|maximum| entry.entropy > maximum)
1024 {
1025 return false;
1026 }
1027
1028 if let Some(categories) = &filter.categories
1029 && !categories
1030 .iter()
1031 .any(|category| entry.categories.contains(category))
1032 {
1033 return false;
1034 }
1035 if let Some(paths) = &filter.file_paths
1036 && !paths.iter().any(|path| {
1037 entry
1038 .unique_file_identities
1039 .iter()
1040 .any(|identity| identity.file_path == *path)
1041 })
1042 {
1043 return false;
1044 }
1045 if let Some(hashes) = &filter.file_hashes
1046 && !hashes.iter().any(|hash| {
1047 entry
1048 .unique_file_identities
1049 .iter()
1050 .any(|identity| identity.file_hash == *hash)
1051 })
1052 {
1053 return false;
1054 }
1055 if filter.suspicious_only == Some(true) && !entry.is_suspicious {
1056 return false;
1057 }
1058 if self
1059 .regex
1060 .as_ref()
1061 .is_some_and(|regex| !regex.is_match(&entry.value))
1062 {
1063 return false;
1064 }
1065 if let Some((start, end)) = &filter.date_range
1066 && (entry.first_seen < *start || entry.first_seen > *end)
1067 {
1068 return false;
1069 }
1070 true
1071 }
1072}
1073
1074fn inferred_context(
1075 value: &str,
1076 categories: &[StringCategory],
1077 max_source_bytes: usize,
1078) -> AnalysisResult<StringContext> {
1079 if categories.iter().any(|category| category.name == "url") {
1080 let protocol = value.split_once("://").map(|(protocol, _)| protocol);
1081 if let Some(protocol) = protocol
1082 && protocol.len() > max_source_bytes
1083 {
1084 return Err(AnalysisError::InputTooLarge {
1085 field: "context.url.protocol",
1086 actual: protocol.len(),
1087 limit: max_source_bytes,
1088 });
1089 }
1090 return Ok(StringContext::Url {
1091 protocol: protocol.map(str::to_ascii_lowercase),
1092 });
1093 }
1094 if categories.iter().any(|category| category.name == "path") {
1095 let path_type =
1096 if contains_ascii_case(value, "\\windows") || contains_ascii_case(value, "/usr") {
1097 "system"
1098 } else if contains_ascii_case(value, "\\temp") || contains_ascii_case(value, "/tmp") {
1099 "temporary"
1100 } else {
1101 "general"
1102 };
1103 return Ok(StringContext::Path {
1104 path_type: path_type.to_string(),
1105 });
1106 }
1107 if categories
1108 .iter()
1109 .any(|category| category.name == "registry")
1110 {
1111 let hive = value.split('\\').next();
1112 if let Some(hive) = hive
1113 && hive.len() > max_source_bytes
1114 {
1115 return Err(AnalysisError::InputTooLarge {
1116 field: "context.registry.hive",
1117 actual: hive.len(),
1118 limit: max_source_bytes,
1119 });
1120 }
1121 return Ok(StringContext::Registry {
1122 hive: hive.map(ToString::to_string),
1123 });
1124 }
1125 if categories.iter().any(|category| category.name == "library") {
1126 if value.len() > max_source_bytes {
1127 return Err(AnalysisError::InputTooLarge {
1128 field: "context.import.library",
1129 actual: value.len(),
1130 limit: max_source_bytes,
1131 });
1132 }
1133 return Ok(StringContext::Import {
1134 library: value.to_string(),
1135 });
1136 }
1137 if categories.iter().any(|category| category.name == "command") {
1138 return Ok(StringContext::Command {
1139 command_type: "shell".to_string(),
1140 });
1141 }
1142 Ok(StringContext::FileString { offset: None })
1143}
1144
1145fn validate_ordered_filter<T>(
1146 field: &'static str,
1147 minimum: Option<T>,
1148 maximum: Option<T>,
1149) -> AnalysisResult<()>
1150where
1151 T: PartialOrd,
1152{
1153 if minimum.zip(maximum).is_some_and(|(min, max)| min > max) {
1154 Err(AnalysisError::InvalidFilter {
1155 field,
1156 reason: "minimum must be less than or equal to maximum".to_string(),
1157 })
1158 } else {
1159 Ok(())
1160 }
1161}
1162
1163fn validate_entropy_filter(minimum: Option<f64>, maximum: Option<f64>) -> AnalysisResult<()> {
1164 for (field, value) in [("min_entropy", minimum), ("max_entropy", maximum)] {
1165 if let Some(value) = value
1166 && (!value.is_finite() || !(0.0..=8.0).contains(&value))
1167 {
1168 return Err(AnalysisError::InvalidFilter {
1169 field,
1170 reason: "must be finite and between 0 and 8 inclusive".to_string(),
1171 });
1172 }
1173 }
1174 validate_ordered_filter("entropy", minimum, maximum)
1175}
1176
1177fn validate_filter_values(
1178 field: &'static str,
1179 values: Option<&[String]>,
1180 count_limit: usize,
1181 byte_limit: usize,
1182) -> AnalysisResult<()> {
1183 let Some(values) = values else {
1184 return Ok(());
1185 };
1186 if values.len() > count_limit {
1187 return Err(AnalysisError::InvalidFilter {
1188 field,
1189 reason: format!(
1190 "contains {} values; configured maximum is {count_limit}",
1191 values.len()
1192 ),
1193 });
1194 }
1195 if let Some(value) = values.iter().find(|value| value.len() > byte_limit) {
1196 return Err(AnalysisError::InvalidFilter {
1197 field,
1198 reason: format!(
1199 "contains a {}-byte value; configured maximum is {byte_limit}",
1200 value.len()
1201 ),
1202 });
1203 }
1204 Ok(())
1205}
1206
1207fn calculate_similarity(left: &StringEntry, right: &StringEntry) -> f64 {
1208 const FILE_WEIGHT: f64 = 0.55;
1209 const CATEGORY_WEIGHT: f64 = 0.25;
1210 const ENTROPY_WEIGHT: f64 = 0.10;
1211 const LENGTH_WEIGHT: f64 = 0.10;
1212
1213 let file_similarity = jaccard(&left.unique_file_identities, &right.unique_file_identities);
1214 let left_categories = meaningful_categories(&left.categories);
1215 let right_categories = meaningful_categories(&right.categories);
1216 let category_similarity = jaccard(&left_categories, &right_categories);
1217 let entropy_similarity = 1.0 - ((left.entropy - right.entropy).abs() / 8.0).min(1.0);
1218 let max_length = left.value.len().max(right.value.len());
1219 let length_similarity = if max_length == 0 {
1220 1.0
1221 } else {
1222 left.value.len().min(right.value.len()) as f64 / max_length as f64
1223 };
1224
1225 file_similarity * FILE_WEIGHT
1226 + category_similarity * CATEGORY_WEIGHT
1227 + entropy_similarity * ENTROPY_WEIGHT
1228 + length_similarity * LENGTH_WEIGHT
1229}
1230
1231fn meaningful_categories(categories: &BTreeSet<String>) -> BTreeSet<&str> {
1232 categories
1233 .iter()
1234 .map(String::as_str)
1235 .filter(|category| !matches!(*category, "generic" | "file_string"))
1236 .collect()
1237}
1238
1239fn jaccard<T>(left: &BTreeSet<T>, right: &BTreeSet<T>) -> f64
1240where
1241 T: Ord,
1242{
1243 let union = left.union(right).count();
1244 if union == 0 {
1245 0.0
1246 } else {
1247 left.intersection(right).count() as f64 / union as f64
1248 }
1249}
1250
1251fn retain_highest_severity_indicators(
1252 indicators: Vec<SuspiciousIndicator>,
1253 limit: usize,
1254) -> (Vec<SuspiciousIndicator>, bool) {
1255 let truncated = indicators.len() > limit;
1256 let mut retained: Vec<(usize, SuspiciousIndicator)> =
1257 Vec::with_capacity(indicators.len().min(limit));
1258
1259 for (order, indicator) in indicators.into_iter().enumerate() {
1260 if retained.len() < limit {
1261 retained.push((order, indicator));
1262 continue;
1263 }
1264 let Some((lowest_index, (_, lowest))) = retained
1265 .iter()
1266 .enumerate()
1267 .min_by_key(|(_, (order, indicator))| (indicator.severity, Reverse(*order)))
1268 else {
1269 continue;
1270 };
1271 if indicator.severity > lowest.severity {
1272 retained[lowest_index] = (order, indicator);
1273 }
1274 }
1275
1276 retained.sort_by_key(|(order, _)| *order);
1277 let mut normalized = Vec::with_capacity(retained.len());
1278 normalized.extend(retained.into_iter().map(|(_, indicator)| indicator));
1279 (normalized, truncated)
1280}
1281
1282fn insert_bounded_sorted<T>(
1283 values: &mut Vec<T>,
1284 value: T,
1285 limit: usize,
1286 mut compare: impl FnMut(&T, &T) -> Ordering,
1287) {
1288 let index = values
1289 .binary_search_by(|existing| compare(existing, &value))
1290 .unwrap_or_else(|index| index);
1291 values.insert(index, value);
1292 if values.len() > limit {
1293 values.pop();
1294 }
1295}
1296
1297fn bounded_owned_string(value: String, limit: usize) -> (String, bool) {
1298 if value.len() <= limit {
1299 return (compact_string(value), false);
1300 }
1301
1302 let mut end = limit;
1303 while !value.is_char_boundary(end) {
1304 end -= 1;
1305 }
1306 (value[..end].to_string(), true)
1307}
1308
1309fn compact_in_place(value: &mut String) {
1310 *value = compact_string(std::mem::take(value));
1311}
1312
1313fn contains_ascii_case(value: &str, needle: &str) -> bool {
1314 value
1315 .as_bytes()
1316 .windows(needle.len())
1317 .any(|candidate| candidate.eq_ignore_ascii_case(needle.as_bytes()))
1318}
1319
1320fn usize_to_u64(value: usize) -> u64 {
1321 u64::try_from(value).unwrap_or(u64::MAX)
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327
1328 #[test]
1329 fn normalization_rebuilds_callback_owned_allocations_to_retained_size() {
1330 let tracker = StringTracker::new();
1331 let mut category = String::with_capacity(1_000_000);
1332 category.push_str("custom");
1333 let mut pattern_name = String::with_capacity(1_000_000);
1334 pattern_name.push_str("custom_signal");
1335 let mut description = String::with_capacity(1_000_000);
1336 description.push_str("Custom signal");
1337 let mut matched_text = String::with_capacity(1_000_000);
1338 matched_text.push_str(&"é".repeat(300));
1339 let mut indicators = Vec::with_capacity(1_000_000);
1340 indicators.push(SuspiciousIndicator {
1341 pattern_name,
1342 description,
1343 severity: 5,
1344 matched_text: Some(matched_text),
1345 matched_text_truncated: false,
1346 });
1347
1348 let analysis = tracker
1349 .normalize_analysis(StringAnalysis {
1350 entropy: 1.0,
1351 categories: BTreeSet::from([category]),
1352 suspicious_indicators: indicators,
1353 indicators_truncated: false,
1354 is_suspicious: true,
1355 })
1356 .unwrap();
1357
1358 assert_eq!(analysis.suspicious_indicators.capacity(), 1);
1359 assert_eq!(
1360 analysis.categories.first().unwrap().capacity(),
1361 analysis.categories.first().unwrap().len()
1362 );
1363 let indicator = &analysis.suspicious_indicators[0];
1364 assert_eq!(
1365 indicator.pattern_name.capacity(),
1366 indicator.pattern_name.len()
1367 );
1368 assert_eq!(
1369 indicator.description.capacity(),
1370 indicator.description.len()
1371 );
1372 let matched_text = indicator.matched_text.as_ref().unwrap();
1373 assert_eq!(matched_text.len(), INDICATOR_MATCHED_TEXT_LIMIT);
1374 assert_eq!(matched_text.capacity(), matched_text.len());
1375 assert!(indicator.matched_text_truncated);
1376 }
1377
1378 #[test]
1379 fn tracking_compacts_occurrence_owned_allocations_before_retention() {
1380 let tracker = StringTracker::new();
1381 let mut file_path = String::with_capacity(1_000_000);
1382 file_path.push('p');
1383 let mut file_hash = String::with_capacity(1_000_000);
1384 file_hash.push('h');
1385 let mut tool_name = String::with_capacity(1_000_000);
1386 tool_name.push('t');
1387 let mut library = String::with_capacity(1_000_000);
1388 library.push('l');
1389
1390 tracker
1391 .track_occurrence(
1392 "value",
1393 StringOccurrence {
1394 file_path,
1395 file_hash,
1396 tool_name,
1397 timestamp: Utc::now(),
1398 context: StringContext::Import { library },
1399 },
1400 )
1401 .unwrap();
1402
1403 let entries = tracker.lock_entries();
1404 let entry = entries.get("value").unwrap();
1405 let occurrence = &entry.occurrences[0];
1406 assert_eq!(occurrence.file_path.capacity(), occurrence.file_path.len());
1407 assert_eq!(occurrence.file_hash.capacity(), occurrence.file_hash.len());
1408 assert_eq!(occurrence.tool_name.capacity(), occurrence.tool_name.len());
1409 let StringContext::Import { library } = &occurrence.context else {
1410 panic!("expected import context");
1411 };
1412 assert_eq!(library.capacity(), library.len());
1413 let identity = entry.unique_file_identities.first().unwrap();
1414 assert_eq!(identity.file_path.capacity(), identity.file_path.len());
1415 assert_eq!(identity.file_hash.capacity(), identity.file_hash.len());
1416 }
1417}