1use 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
35const SEED_GENERATION_SALT: &[u8] = b"cleansh-run-seed-generation-v1-salt";
38
39#[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 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
136fn 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
243pub 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
302fn 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 let normalized_engine_version = normalize_input(engine_version, Some("default"));
322
323 let mut mac = HmacSha256::new_from_slice(SEED_GENERATION_SALT)
326 .map_err(|e| anyhow!("Failed to create HMAC: {}", e))?;
327
328 mac.update(normalized_version.as_bytes());
330 mac.update(normalized_run_id.as_bytes());
331 mac.update(normalized_engine_version.as_bytes()); 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
427impl 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#[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
460pub 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}