Skip to main content

cleansh_core/
config.rs

1//! Configuration management for `CleanSH-core`.
2//!
3//! This module defines the core data structures for redaction rules and engine configurations.
4//! It handles serialization/deserialization of YAML configurations and provides utilities
5//! for loading, merging, and validating these configs.
6//!
7//! License: MIT OR Apache-2.0
8
9use anyhow::{anyhow, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13use log::{debug, info, warn};
14use std::fmt;
15use regex::Regex;
16use std::hash::{Hash, Hasher};
17
18/// Maximum allowed length for a regex pattern string.
19pub const MAX_PATTERN_LENGTH: usize = 500;
20
21/// Represents a single redaction rule used by the Regex engine.
22#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
23#[serde(default)]
24pub struct RedactionRule {
25    /// Unique identifier for the rule (e.g., "aws_access_key").
26    pub name: String,
27    /// Human-readable description of what the rule targets.
28    pub description: Option<String>,
29    /// The regex pattern string.
30    pub pattern: Option<String>,
31    /// The type of pattern (e.g., "regex", "entropy").
32    pub pattern_type: String,
33    /// The string to replace matches with.
34    pub replace_with: String,
35    pub version: String,
36    pub created_at: String,
37    pub author: String,
38    pub updated_at: String,
39    /// If true, enables multiline mode for the regex engine.
40    pub multiline: bool,
41    /// If true, the dot character `.` in regex will match newlines.
42    pub dot_matches_new_line: bool,
43    /// If true, the rule is disabled unless explicitly enabled in the profile.
44    pub opt_in: bool,
45    /// If true, requires external programmatic validation (e.g., SSN checksum).
46    pub programmatic_validation: bool,
47    /// Explicit override for enabling/disabling the rule.
48    pub enabled: Option<bool>,
49    /// Security severity level (e.g., "high", "medium").
50    pub severity: Option<String>,
51    /// Metadata tags for categorization.
52    pub tags: Option<Vec<String>>,
53}
54
55impl Hash for RedactionRule {
56    fn hash<H: Hasher>(&self, state: &mut H) {
57        self.name.hash(state);
58        self.description.hash(state);
59        self.pattern.hash(state);
60        self.pattern_type.hash(state);
61        self.replace_with.hash(state);
62        self.version.hash(state);
63        self.created_at.hash(state);
64        self.author.hash(state);
65        self.updated_at.hash(state);
66        self.multiline.hash(state);
67        self.dot_matches_new_line.hash(state);
68        self.opt_in.hash(state);
69        self.programmatic_validation.hash(state);
70        self.enabled.hash(state);
71        self.severity.hash(state);
72    }
73}
74
75impl Default for RedactionRule {
76    fn default() -> Self {
77        Self {
78            name: String::new(),
79            description: None,
80            pattern: None,
81            pattern_type: "regex".to_string(),
82            replace_with: "[REDACTED]".to_string(),
83            version: "1.0.0".to_string(),
84            created_at: "1970-01-01T00:00:00Z".to_string(),
85            updated_at: "1970-01-01T00:00:00Z".to_string(),
86            author: "Relay Team".to_string(),
87            multiline: false,
88            dot_matches_new_line: false,
89            opt_in: false,
90            programmatic_validation: false,
91            enabled: None,
92            severity: None,
93            tags: None,
94        }
95    }
96}
97
98/// Configuration settings specific to the Entropy Engine.
99#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
100pub struct EntropyConfig {
101    /// The confidence threshold for flagging a token as a secret (default: 0.5).
102    pub threshold: Option<f64>,
103    /// The size of the scanning window in bytes (default: 24).
104    /// Smaller windows are more aggressive; larger windows reduce noise.
105    pub window_size: Option<usize>,
106}
107
108impl Hash for EntropyConfig {
109    fn hash<H: Hasher>(&self, state: &mut H) {
110        if let Some(t) = self.threshold {
111            t.to_bits().hash(state);
112        } else {
113            0u64.hash(state);
114        }
115        self.window_size.hash(state);
116    }
117}
118
119/// Container for all engine-specific configurations.
120#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq, Hash)]
121#[serde(default)]
122pub struct EngineConfig {
123    pub entropy: EntropyConfig,
124}
125
126/// Represents the top-level configuration structure for CleanSH.
127#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
128pub struct RedactionConfig {
129    /// A list of regex-based redaction rules.
130    pub rules: Vec<RedactionRule>,
131    /// Engine-specific settings (e.g., entropy thresholds and windows).
132    #[serde(default)]
133    pub engines: EngineConfig,
134}
135
136/// Represents a single item in the redaction summary for the UI.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct RedactionSummaryItem {
139    pub rule_name: String,
140    pub occurrences: usize,
141    pub original_texts: Vec<String>,
142    pub sanitized_texts: Vec<String>,
143}
144
145/// Error type for missing rule configurations.
146#[derive(Debug)]
147pub struct RuleConfigNotFoundError {
148    pub config_name: String,
149}
150
151impl fmt::Display for RuleConfigNotFoundError {
152    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
153        write!(f, "Rule configuration '{}' not found.", self.config_name)
154    }
155}
156
157impl std::error::Error for RuleConfigNotFoundError {}
158
159impl RedactionConfig {
160    /// Loads redaction rules from a YAML file.
161    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
162        let path = path.as_ref();
163        info!("Loading custom rules from: {}", path.display());
164        let text = std::fs::read_to_string(path)
165            .with_context(|| format!("Failed to read config file {}", path.display()))?;
166        let config: RedactionConfig = serde_yml::from_str(&text)
167            .with_context(|| format!("Failed to parse config file {}", path.display()))?;
168
169        validate_rules(&config.rules)?;
170        info!("Loaded {} rules from file {}.", config.rules.len(), path.display());
171        
172        Ok(config)
173    }
174
175    /// Loads default redaction rules from the embedded configuration.
176    pub fn load_default_rules() -> Result<Self> {
177        debug!("Loading default rules from embedded string...");
178        let default_yaml = include_str!("../config/default_rules.yaml");
179        let config: RedactionConfig = serde_yml::from_str(default_yaml)
180            .context("Failed to parse default rules")?;
181
182        debug!("Loaded {} default rules.", config.rules.len());
183        Ok(config)
184    }
185
186    /// Filters active rules based on enable/disable lists provided via CLI.
187    pub fn set_active_rules(&mut self, enable_rules: &[String], disable_rules: &[String]) {
188        let enable_set: HashSet<&str> = enable_rules.iter().map(String::as_str).collect();
189        let disable_set: HashSet<&str> = disable_rules.iter().map(String::as_str).collect();
190
191        debug!("Initial rules count before filtering: {}", self.rules.len());
192        
193        let all_rule_names: HashSet<&str> = self.rules.iter().map(|r| r.name.as_str()).collect();
194
195        for rule_name in enable_set.difference(&all_rule_names) {
196            warn!("Rule '{}' in `enable_rules` list does not exist.", rule_name);
197        }
198
199        for rule_name in disable_set.difference(&all_rule_names) {
200            warn!("Rule '{}' in `disable_rules` list does not exist.", rule_name);
201        }
202
203        self.rules.retain(|rule| {
204            let rule_name_str = rule.name.as_str();
205            !disable_set.contains(rule_name_str) && (!rule.opt_in || enable_set.contains(rule_name_str))
206        });
207
208        debug!("Final active rules count after filtering: {}", self.rules.len());
209    }
210}
211
212/// Merges user-defined rules and engine settings with defaults.
213pub fn merge_rules(
214    default_config: RedactionConfig,
215    user_config: Option<RedactionConfig>,
216) -> RedactionConfig {
217    debug!("merge_rules called. Initial default rules count: {}", default_config.rules.len());
218    
219    let mut final_rules_map: HashMap<String, RedactionRule> = default_config.rules.into_iter()
220        .map(|rule| (rule.name.clone(), rule))
221        .collect();
222
223    let mut final_engines = default_config.engines;
224
225    if let Some(user_cfg) = user_config {
226        debug!("User config provided. Merging {} user rules.", user_cfg.rules.len());
227        for user_rule in user_cfg.rules {
228            final_rules_map.insert(user_rule.name.clone(), user_rule);
229        }
230        
231        if let Some(user_threshold) = user_cfg.engines.entropy.threshold {
232             debug!("Overriding entropy threshold with user value: {}", user_threshold);
233             final_engines.entropy.threshold = Some(user_threshold);
234        }
235
236        if let Some(user_window) = user_cfg.engines.entropy.window_size {
237            debug!("Overriding entropy window size with user value: {}", user_window);
238            final_engines.entropy.window_size = Some(user_window);
239        }
240    }
241
242    let final_rules: Vec<RedactionRule> = final_rules_map.into_values().collect();
243    debug!("Final total rules after merge: {}", final_rules.len());
244
245    RedactionConfig { 
246        rules: final_rules,
247        engines: final_engines,
248    }
249}
250
251/// Validates rule integrity (regex compilation, capture groups).
252fn validate_rules(rules: &[RedactionRule]) -> Result<()> {
253    let mut rule_names = HashSet::new();
254    let mut errors = Vec::new();
255    let capture_group_regex = Regex::new(r"\$(\d+)").unwrap();
256
257    for rule in rules {
258        if rule.name.is_empty() {
259            errors.push("A rule has an empty `name` field.".to_string());
260        } else if !rule_names.insert(rule.name.clone()) {
261            errors.push(format!("Duplicate rule name found: '{}'.", rule.name));
262        }
263
264        if rule.pattern_type == "regex" {
265            let pattern = match &rule.pattern {
266                Some(p) => p,
267                None => {
268                    errors.push(format!("Rule '{}' is missing the `pattern` field.", rule.name));
269                    continue;
270                }
271            };
272
273            if pattern.is_empty() {
274                errors.push(format!("Rule '{}' has an empty `pattern` field.", rule.name));
275            }
276            
277            if let Err(e) = Regex::new(pattern) {
278                errors.push(format!("Rule '{}' has an invalid regex pattern: {}", rule.name, e));
279                continue;
280            }
281            
282            let mut group_count = 0;
283            let mut is_escaped = false;
284            for c in pattern.chars() {
285                match c {
286                    '\\' => is_escaped = !is_escaped,
287                    '(' if !is_escaped => group_count += 1,
288                    _ => is_escaped = false,
289                }
290            }
291
292            for cap in capture_group_regex.captures_iter(&rule.replace_with) {
293                if let Some(group_num_str) = cap.get(1) {
294                    if let Ok(group_num) = group_num_str.as_str().parse::<usize>() {
295                        if group_num > group_count {
296                            errors.push(format!(
297                                "Rule '{}': replacement references non-existent capture group '${}'.",
298                                rule.name, group_num
299                            ));
300                        }
301                    }
302                }
303            }
304        }
305    }
306
307    if !errors.is_empty() {
308        let full_error_message = format!("Rule validation failed:\n{}", errors.join("\n"));
309        Err(anyhow!(full_error_message))
310    } else {
311        Ok(())
312    }
313}