Skip to main content

cleansh_core/
profiles.rs

1// File: cleansh-core/src/profiles.rs
2
3//! profiles.rs - Profile configuration, loading, and helpers for CleanSH.
4//!
5//! This module provides the data structures and logic for defining and managing redaction
6//! profiles. A profile specifies which rules are enabled, their severity, and advanced
7//! engine settings like sampling and token formatting. This allows users to create
8//! named, reusable configurations tailored for specific compliance needs (e.g., GDPR, CCPA).
9//!
10//! The module handles loading a profile from a YAML file, merging its settings with the
11//! default rule set, and providing deterministic functions for sampling and token
12//! generation to support secure, auditable reporting.
13//!
14//!
15//! license: MIT OR Apache-2.0
16
17use anyhow::{anyhow, bail, Context, Result};
18use serde::{Deserialize, Serialize};
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::collections::{HashSet, HashMap};
22use hmac::{Hmac, Mac};
23use sha2::Sha256;
24use hex;
25use tinytemplate::TinyTemplate;
26use log::{debug, warn};
27use chrono::NaiveDate;
28use serde_yml::Value; 
29
30use crate::config::{RedactionConfig, RedactionRule};
31use crate::redaction_match::RedactionMatch;
32
33type HmacSha256 = Hmac<Sha256>;
34
35// A fixed salt used to generate deterministic run seeds.
36// This replaces the usage of dynamic strings as cryptographic keys, resolving CodeQL security alerts.
37const SEED_GENERATION_SALT: &[u8] = b"cleansh-run-seed-generation-v1-salt";
38
39/// The top-level structure representing a redaction profile configuration.
40#[derive(Debug, Serialize, Deserialize, Clone, Default)]
41#[serde(rename_all = "snake_case", default)]
42pub struct ProfileConfig {
43    pub profile_name: String,
44    pub display_name: Option<String>,
45    pub description: Option<String>,
46    pub version: String,
47    pub profile_id: Option<String>,
48    pub author: Option<String>,
49    pub compliance_scope: Option<String>,
50    pub revision_date: Option<NaiveDate>,
51    pub signature: Option<String>,
52    pub signature_alg: Option<String>,
53    pub rules: Vec<ProfileRule>,
54    pub samples: Option<SamplesConfig>,
55    pub dedupe: Option<DedupeConfig>,
56    pub post_processing: Option<PostProcessingConfig>,
57    pub reporting: Option<ReportingConfig>,
58}
59
60impl ProfileConfig {
61    pub fn validate(&self, default_config: &RedactionConfig) -> Result<()> {
62        if self.version.trim().is_empty() {
63            bail!("Profile '{}' validation failed: 'version' field cannot be empty.", self.profile_name);
64        }
65
66        let default_rule_names: HashSet<&str> = default_config.rules.iter().map(|r| r.name.as_str()).collect();
67        for rule_override in &self.rules {
68            if !default_rule_names.contains(rule_override.name.as_str()) {
69                bail!("Profile '{}' validation failed: rule '{}' not found in default configuration.",
70                    self.profile_name, rule_override.name);
71            }
72        }
73
74        if let Some(samples) = &self.samples {
75            if samples.max_per_rule == 0 {
76                bail!("Profile '{}' validation failed: 'samples.max_per_rule' must be greater than 0.", self.profile_name);
77            }
78            if samples.max_total > 0 && samples.max_per_rule > samples.max_total {
79                bail!("Profile '{}' validation failed: 'samples.max_per_rule' cannot exceed 'samples.max_total'.", self.profile_name);
80            }
81        }
82
83        if let Some(dedupe) = &self.dedupe {
84            if dedupe.window_bytes == 0 && dedupe.use_hash {
85                warn!("Profile '{}': 'dedupe.window_bytes' is 0, but 'use_hash' is true. Deduplication will not use a window.", self.profile_name);
86            }
87        }
88
89        Ok(())
90    }
91
92    /// Verifies the HMAC-SHA256 signature of the profile against the provided secret key.
93    ///
94    /// This method is crucial for ensuring the integrity and authenticity of a profile
95    /// loaded from disk. It recalculates the signature from the profile's content
96    /// (excluding the signature field itself) and compares it with the stored signature.
97    /// The `raw_bytes` argument is the full content of the YAML file.
98    ///
99    /// # Arguments
100    /// * `raw_bytes` - The complete raw bytes of the YAML file, used to recompute the signature.
101    /// * `key` - The secret key used to generate the HMAC signature.
102    pub fn verify_signature(&self, raw_bytes: &[u8], key: &[u8]) -> Result<bool> {
103        if self.signature.is_none() {
104            debug!("Profile '{}' is unsigned, skipping signature verification.", self.profile_name);
105            return Ok(true);
106        }
107
108        let stored_signature = self.signature.as_ref().unwrap();
109        
110        if self.signature_alg.as_deref() != Some("hmac-sha256") {
111            bail!("Profile '{}' signature verification failed: Unsupported signature algorithm '{}'. Only 'hmac-sha256' is supported.",
112                self.profile_name, self.signature_alg.as_deref().unwrap_or("none"));
113        }
114
115        debug!("Profile '{}': Verifying signature...", self.profile_name);
116        
117        let raw_for_signing = get_raw_profile_for_signature(raw_bytes)?;
118        
119        let mut mac = HmacSha256::new_from_slice(key)
120            .map_err(|e| anyhow!("Failed to initialize HMAC-SHA256 with key: {}", e))?;
121        mac.update(&raw_for_signing);
122
123        let computed_signature = hex::encode(mac.finalize().into_bytes());
124
125        if computed_signature.eq_ignore_ascii_case(stored_signature) {
126            debug!("Profile '{}' signature verification succeeded.", self.profile_name);
127            Ok(true)
128        } else {
129            warn!("Profile '{}' signature verification failed. Stored: '{}', Computed: '{}'",
130                self.profile_name, stored_signature, computed_signature);
131            Err(anyhow!("Profile signature verification failed for profile '{}'. The profile may have been tampered with.", self.profile_name))
132        }
133    }
134}
135
136/// A helper function to parse the raw YAML bytes and re-serialize the profile
137/// with the `signature` field removed.
138fn get_raw_profile_for_signature(raw_bytes: &[u8]) -> Result<Vec<u8>> {
139    let mut profile_value: Value = serde_yml::from_slice(raw_bytes)
140        .context("Failed to parse profile YAML for signature verification.")?;
141
142    if let Value::Mapping(mapping) = &mut profile_value {
143        if mapping.contains_key(&Value::String("signature".to_string())) {
144            mapping.remove(&Value::String("signature".to_string()));
145        }
146        if mapping.contains_key(&Value::String("signature_alg".to_string())) {
147            mapping.remove(&Value::String("signature_alg".to_string()));
148        }
149    }
150
151    serde_yml::to_string(&profile_value)
152        .context("Failed to re-serialize profile for signature verification.")
153        .map(|s| s.as_bytes().to_vec())
154}
155
156
157#[derive(Debug, Serialize, Deserialize, Clone, Default)]
158#[serde(rename_all = "snake_case")]
159pub struct ProfileRule {
160    pub name: String,
161    pub enabled: Option<bool>,
162    pub severity: Option<String>,
163}
164
165#[derive(Debug, Serialize, Deserialize, Clone, Default)]
166#[serde(rename_all = "snake_case")]
167pub struct SamplesConfig {
168    pub max_per_rule: usize,
169    pub max_total: usize,
170}
171
172#[derive(Debug, Serialize, Deserialize, Clone, Default)]
173#[serde(rename_all = "snake_case")]
174pub struct DedupeConfig {
175    pub window_bytes: usize,
176    pub use_hash: bool,
177}
178
179#[derive(Debug, Serialize, Deserialize, Clone, Default)]
180#[serde(rename_all = "snake_case")]
181pub struct PostProcessingConfig {
182    pub replace_with_token: bool,
183    pub token_format: Option<String>,
184}
185
186#[derive(Debug, Serialize, Deserialize, Clone, Default)]
187#[serde(rename_all = "snake_case")]
188pub struct ReportingConfig {
189    pub include_rule_version: bool,
190    pub include_engine_version: bool,
191    pub include_byte_hash_of_input: bool,
192}
193
194pub fn profile_candidate_paths(name: &str) -> Vec<PathBuf> {
195    let base_dirs = vec![
196        dirs::home_dir().map(|p| p.join(".cleansh").join("profiles")),
197        dirs::config_dir().map(|p| p.join("cleansh").join("profiles")),
198        Some(PathBuf::from("/etc/cleansh/profiles")),
199        Some(PathBuf::from("./config")),
200        Some(PathBuf::from("../config")),
201    ];
202
203    base_dirs.into_iter()
204        .flatten()
205        .map(|dir| dir.join(format!("{}.yaml", name)))
206        .collect()
207    
208}
209
210pub fn load_profile_by_name(name_or_path: &str) -> Result<ProfileConfig> {
211    debug!("Attempting to load profile from: '{}'", name_or_path);
212    
213    let path_to_load = {
214        let path = Path::new(name_or_path);
215        if path.exists() && path.is_file() {
216            debug!("Input is a valid file path. Loading directly from: {}", path.display());
217            Some(path.to_path_buf())
218        } else {
219            profile_candidate_paths(name_or_path)
220                .into_iter()
221                .find(|p| p.exists())
222        }
223    }.context("Profile not found. It is not a valid file path, and was not found in expected locations.")?;
224    
225    let raw_bytes = fs::read(&path_to_load)
226        .with_context(|| format!("reading profile file {}", path_to_load.display()))?;
227    
228    let cfg: ProfileConfig = serde_yml::from_slice(&raw_bytes)
229        .with_context(|| format!("parsing profile YAML {}", path_to_load.display()))?;
230    
231    if let Some(key_hex) = std::env::var("CLEANSH_PROFILE_KEY").ok() {
232        let key_bytes = hex::decode(&key_hex)
233            .context("Failed to decode CLEANSH_PROFILE_KEY from hex. Make sure it's a valid hex string.")?;
234        cfg.verify_signature(&raw_bytes, &key_bytes)?;
235    } else if cfg.signature.is_some() {
236        warn!("Profile '{}' is signed, but CLEANSH_PROFILE_KEY environment variable is not set. Signature verification skipped.", cfg.profile_name);
237    }
238    
239    debug!("Successfully loaded profile '{}'.", name_or_path);
240    Ok(cfg)
241}
242
243/// Signs a profile file using an HMAC-SHA256 key and updates the file in place.
244/// This function is intended to be used by a separate command-line utility.
245///
246/// # Arguments
247/// * `path` - The path to the profile YAML file to sign.
248/// * `key` - The secret key used to generate the HMAC signature.
249pub fn sign_profile(path: &Path, key: &[u8]) -> Result<()> {
250    debug!("Signing profile file: {}", path.display());
251    
252    let raw_bytes = fs::read(path)
253        .with_context(|| format!("reading profile file {}", path.display()))?;
254    
255    let raw_for_signing = get_raw_profile_for_signature(&raw_bytes)?;
256    
257    let mut mac = HmacSha256::new_from_slice(key)
258        .map_err(|e| anyhow!("Failed to initialize HMAC-SHA256 for signing: {}", e))?;
259    mac.update(&raw_for_signing);
260    let signature = hex::encode(mac.finalize().into_bytes());
261
262    let mut cfg: ProfileConfig = serde_yml::from_slice(&raw_bytes)
263        .with_context(|| format!("parsing profile YAML for signing {}", path.display()))?;
264    cfg.signature = Some(signature);
265    cfg.signature_alg = Some("hmac-sha256".to_string());
266    
267    let updated_yaml = serde_yml::to_string(&cfg)
268        .context("Failed to re-serialize signed profile.")?;
269    fs::write(path, updated_yaml)
270        .with_context(|| format!("writing signed profile to file {}", path.display()))?;
271
272    debug!("Successfully signed profile '{}'.", cfg.profile_name);
273    Ok(())
274}
275
276pub fn apply_profile_to_config(profile: &ProfileConfig, mut default: RedactionConfig) -> RedactionConfig {
277    debug!("Applying profile '{}' to default rules.", profile.profile_name);
278
279    let mut default_rules_map: HashMap<String, &mut RedactionRule> = default.rules.iter_mut()
280        .map(|r| (r.name.clone(), r))
281        .collect();
282
283    for profile_rule_override in &profile.rules {
284        if let Some(rule_to_update) = default_rules_map.get_mut(&profile_rule_override.name) {
285            if let Some(enabled) = profile_rule_override.enabled {
286                debug!("Applying enabled={} override for rule '{}'", enabled, &profile_rule_override.name);
287                rule_to_update.enabled = Some(enabled);
288            }
289            if let Some(severity) = profile_rule_override.severity.clone() {
290                debug!("Applying severity='{}' override for rule '{}'", severity, &profile_rule_override.name);
291                rule_to_update.severity = Some(severity);
292            }
293        } else {
294            warn!("Profile rule '{}' not found in default configuration. It will be ignored.", profile_rule_override.name);
295        }
296    }
297    
298    debug!("Finished applying profile. Final rule count: {}", default.rules.len());
299    default
300}
301
302/// Normalizes an input string for consistent hashing.
303/// It trims whitespace, converts to lowercase, and can provide a default value for empty strings.
304/// 
305/// # Arguments
306/// * `s` - The string slice to normalize.
307/// * `default_value` - An optional string slice to use if `s` is empty or only contains whitespace.
308fn normalize_input(s: &str, default_value: Option<&str>) -> String {
309    let trimmed = s.trim();
310    if trimmed.is_empty() {
311        default_value.unwrap_or("").to_string()
312    } else {
313        trimmed.to_lowercase()
314    }
315}
316
317pub fn compute_run_seed(profile_version: &str, run_id: &str, engine_version: &str) -> Result<Vec<u8>> {
318    let normalized_version = normalize_input(profile_version, None);
319    let normalized_run_id = normalize_input(run_id, None);
320    // This value is no longer used as a key, but as data.
321    let normalized_engine_version = normalize_input(engine_version, Some("default"));
322    
323    // FIX: Use the constant SALT as the key.
324    // This satisfies CodeQL because we aren't using a string literal from a helper function as a key.
325    let mut mac = HmacSha256::new_from_slice(SEED_GENERATION_SALT)
326        .map_err(|e| anyhow!("Failed to create HMAC: {}", e))?;
327
328    // Mix all inputs into the data stream
329    mac.update(normalized_version.as_bytes());
330    mac.update(normalized_run_id.as_bytes());
331    mac.update(normalized_engine_version.as_bytes()); // Now treated as data
332    
333    Ok(mac.finalize().into_bytes().to_vec())
334}
335
336pub fn sample_score_hex(run_seed: &[u8], source_id: &str, start: u64, end: u64) -> Result<String> {
337    Ok(hex::encode(sample_score_bytes(run_seed, source_id, start, end)?))
338}
339
340pub fn sample_score_bytes(run_seed: &[u8], source_id: &str, start: u64, end: u64) -> Result<Vec<u8>> {
341    let mut mac = HmacSha256::new_from_slice(run_seed)
342        .map_err(|e| anyhow!("Failed to create HMAC from run seed: {}", e))?;
343    mac.update(source_id.as_bytes());
344    mac.update(start.to_string().as_bytes());
345    mac.update(end.to_string().as_bytes());
346    Ok(mac.finalize().into_bytes().to_vec())
347}
348
349pub fn select_samples_for_rule(matches: &[RedactionMatch], run_seed: &[u8], max_per_rule: usize) -> Vec<RedactionMatch> {
350    let mut scored: Vec<(Vec<u8>, &RedactionMatch)> = matches.iter()
351        .filter_map(|m| {
352            sample_score_bytes(run_seed, &m.source_id, m.start, m.end)
353                .ok()
354                .map(|s| (s, m))
355        })
356        .collect();
357    
358    scored.sort_by(|a, b| b.0.cmp(&a.0));
359    
360    let mut out: Vec<RedactionMatch> = Vec::new();
361    let mut seen_hashes = HashSet::new();
362    let mut seen_coords = HashSet::new();
363    
364    for (_score, m) in scored.into_iter() {
365        if out.len() >= max_per_rule { break; }
366        
367        let is_duplicate = if let Some(h) = &m.sample_hash {
368            !seen_hashes.insert(h.clone())
369        } else {
370            !seen_coords.insert((m.source_id.clone(), m.start, m.end))
371        };
372        
373        if !is_duplicate {
374            out.push(m.clone());
375        }
376    }
377    out
378}
379
380pub fn format_token(token_fmt: &str, rule: &str, sample_hash_hex: &str) -> Result<String> {
381    let mut tt = TinyTemplate::new();
382    tt.add_template("t", token_fmt)
383        .context("Failed to parse token template")?;
384    let shorthash = if sample_hash_hex.len() >= 8 { &sample_hash_hex[0..8] } else { sample_hash_hex };
385    let ctx = serde_json::json!({ "rule": rule, "shorthash": shorthash });
386    tt.render("t", &ctx).map_err(|e| anyhow!("Failed to render token template: {}", e))
387}
388
389#[derive(Debug, Clone, Default, Serialize, Deserialize)]
390pub struct ProfileMeta {
391    pub profile_name: String,
392    pub version: String,
393}
394
395#[derive(Debug, Clone, Default, Serialize, Deserialize)]
396pub struct EngineOptions {
397    pub post_processing: Option<PostProcessingConfig>,
398    pub samples_config: Option<SamplesConfig>,
399    pub dedupe_config: Option<DedupeConfig>,
400    pub run_seed: Option<Vec<u8>>,
401    pub engine_version: Option<String>,
402    
403    pub profile_meta: ProfileMeta,
404    
405    pub run_id: Option<String>,
406    pub input_hash: Option<String>,
407}
408
409impl From<ProfileConfig> for EngineOptions {
410    fn from(profile: ProfileConfig) -> Self {
411        Self {
412            post_processing: profile.post_processing,
413            samples_config: profile.samples,
414            dedupe_config: profile.dedupe,
415            run_seed: None,
416            engine_version: None,
417            profile_meta: ProfileMeta {
418                profile_name: profile.profile_name,
419                version: profile.version,
420            },
421            run_id: None,
422            input_hash: None,
423        }
424    }
425}
426
427// ---------- Added convenience builder methods for EngineOptions ----------
428impl EngineOptions {
429    pub fn with_run_seed(mut self, run_seed: Vec<u8>) -> Self {
430        self.run_seed = Some(run_seed);
431        self
432    }
433
434    pub fn with_run_id(mut self, run_id: String) -> Self {
435        self.run_id = Some(run_id);
436        self
437    }
438
439    pub fn with_input_hash(mut self, input_hash: String) -> Self {
440        self.input_hash = Some(input_hash);
441        self
442    }
443
444    pub fn with_engine_version(mut self, ver: String) -> Self {
445        self.engine_version = Some(ver);
446        self
447    }
448}
449// -----------------------------------------------------------------------
450
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452pub struct ProfileSummary {
453    pub profile_name: String,
454    pub display_name: Option<String>,
455    pub version: String,
456    pub description: Option<String>,
457    pub path: Option<PathBuf>,
458}
459
460/// List available profiles by scanning candidate profile directories for `*.yaml`.
461/// This is a best-effort helper used by interactive UI to show available profiles.
462pub fn list_available_profiles() -> Vec<ProfileSummary> {
463    let mut out = Vec::new();
464    let mut seen_paths: HashSet<PathBuf> = HashSet::new();
465
466    let candidate_dirs = vec![
467        dirs::home_dir().map(|p| p.join(".cleansh").join("profiles")),
468        dirs::config_dir().map(|p| p.join("cleansh").join("profiles")),
469        Some(PathBuf::from("/etc/cleansh/profiles")),
470        Some(PathBuf::from("./config")),
471        Some(PathBuf::from("../config")),
472    ];
473
474    for maybe_dir in candidate_dirs.into_iter().flatten() {
475        if let Ok(entries) = std::fs::read_dir(&maybe_dir) {
476            for entry in entries.flatten() {
477                let path = entry.path();
478                
479                if path.extension().and_then(|s| s.to_str()) == Some("yaml") && seen_paths.insert(path.clone()) {
480                    debug!("Found potential profile at: {}", path.display());
481                    match fs::read_to_string(&path) {
482                        Ok(s) => {
483                            if let Ok(cfg) = serde_yml::from_str::<ProfileConfig>(&s) {
484                                out.push(ProfileSummary {
485                                    profile_name: cfg.profile_name,
486                                    display_name: cfg.display_name,
487                                    version: cfg.version,
488                                    description: cfg.description,
489                                    path: Some(path),
490                                });
491                            } else {
492                                warn!("Failed to parse YAML for profile at: {}", path.display());
493                            }
494                        }
495                        Err(e) => {
496                            warn!("Failed to read profile file at '{}': {}", path.display(), e);
497                        }
498                    }
499                }
500            }
501        } else {
502            debug!("Candidate profile directory not found: {}", maybe_dir.display());
503        }
504    }
505    out
506}