scour_secrets/processor/profile.rs
1//! File-type profiles for structured processors.
2//!
3//! A [`FileTypeProfile`] tells the processing pipeline which processor
4//! to use and which fields/keys within the file should be sanitized.
5
6use crate::category::Category;
7use glob::Pattern;
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10
11// ---------------------------------------------------------------------------
12// FieldNameSignal
13// ---------------------------------------------------------------------------
14
15/// Default Shannon entropy threshold (bits per character) for built-in field-name signals.
16///
17/// Values whose entropy is **below** this threshold are left unchanged even
18/// when their key name matches a sensitive keyword, preventing false positives
19/// on enum-like values such as `token_type: Bearer` or `auth: basic`.
20///
21/// Override per signal in the secrets file with `threshold: <f64>`, or disable
22/// the heuristic entirely with `--no-field-signal`:
23///
24/// ```yaml
25/// # Lower threshold: catch more, including weaker secrets
26/// - kind: field-name
27/// pattern: "^(password|secret)$"
28/// threshold: 3.0
29///
30/// # Higher threshold: only flag high-entropy tokens
31/// - kind: field-name
32/// pattern: "^(token|key)$"
33/// threshold: 4.0
34/// ```
35pub const DEFAULT_FIELD_SIGNAL_THRESHOLD: f64 = 3.5;
36
37/// A field-name–based heuristic signal used during structured processing.
38///
39/// When no explicit [`FieldRule`] covers a key, the processor checks the bare
40/// key name against all active signals. If a signal matches **and** the
41/// value's Shannon entropy meets or exceeds `threshold`, the value is replaced
42/// using `category` — as if an explicit rule had been defined.
43///
44/// # Entropy threshold guidance
45///
46/// | Threshold | Behaviour |
47/// |-----------|-----------|
48/// | **3.0** | Catches most secrets including moderately weak ones; recommended for high-confidence keywords (`password`, `secret`) |
49/// | **3.5** | Balanced default — skips plain enum values like `Bearer`, `basic`, `true` |
50/// | **4.0** | Conservative — only high-entropy tokens; use when false-positive rate matters |
51///
52/// # Configuring via secrets file
53///
54/// Add `kind: field-name` entries to your secrets file. The `pattern` field
55/// is a case-insensitive regex matched against the **bare key name** (not the
56/// full dot-path). `threshold` defaults to [`DEFAULT_FIELD_SIGNAL_THRESHOLD`]
57/// when omitted.
58///
59/// ```yaml
60/// # Strong signal: flag any `password`/`secret`/`private_key` with entropy ≥ 3.0
61/// - kind: field-name
62/// pattern: "^(password|passwd|secret|private_key|client_secret)$"
63/// category: custom:credential
64/// label: my-strong-signals
65/// threshold: 3.0
66///
67/// # Medium signal: flag `token`/`api_key` only when value looks like a real token
68/// - kind: field-name
69/// pattern: "^(token|api_key|access_key)$"
70/// category: custom:credential
71/// threshold: 3.5
72/// ```
73///
74/// Suppress false positives on specific values with `kind: allow`:
75///
76/// ```yaml
77/// - kind: allow
78/// values: ["Bearer", "basic", "oauth2", "true", "false"]
79/// ```
80///
81/// # Built-in defaults
82///
83/// When default patterns or `--app` is active, two built-in signals are
84/// injected automatically (unless `--no-field-signal` is passed):
85///
86/// - **Strong** (`threshold: 3.0`): `password`, `passwd`, `secret`,
87/// `private_key`, `api_secret`, `client_secret`
88/// - **Medium** (`threshold: 3.5`): `api_key`, `access_key`, `auth_token`,
89/// `token`, `signing_key`, `encryption_key`, `credential`, `cert`
90#[derive(Debug, Clone)]
91pub struct FieldNameSignal {
92 /// Original pattern string — shown in error messages and log output.
93 pub key_pattern: String,
94 /// Case-insensitive regex compiled from `key_pattern`.
95 pub(crate) key_regex: Regex,
96 /// Replacement category applied to values that pass the entropy gate.
97 pub category: Category,
98 /// Label used in findings and reports.
99 /// Defaults to `"field-signal:<key_pattern>"`.
100 pub label: String,
101 /// Shannon entropy threshold in bits per character.
102 ///
103 /// Values **below** this threshold are left unchanged.
104 /// See the table above and [`DEFAULT_FIELD_SIGNAL_THRESHOLD`].
105 pub threshold: f64,
106}
107
108impl FieldNameSignal {
109 /// Construct a new signal, compiling `key_pattern` as a case-insensitive regex.
110 ///
111 /// # Errors
112 ///
113 /// Returns a human-readable error string if `key_pattern` is not a valid regex.
114 pub fn new(
115 key_pattern: impl Into<String>,
116 category: Category,
117 label: Option<String>,
118 threshold: f64,
119 ) -> Result<Self, String> {
120 let key_pattern = key_pattern.into();
121 let key_regex = regex::RegexBuilder::new(&key_pattern)
122 .case_insensitive(true)
123 .build()
124 .map_err(|e| format!("field-name signal pattern {:?}: {e}", key_pattern))?;
125 let label = label.unwrap_or_else(|| format!("field-signal:{}", key_pattern));
126 Ok(Self {
127 key_pattern,
128 key_regex,
129 category,
130 label,
131 threshold,
132 })
133 }
134
135 /// Returns `true` if `key` (bare field name, not a dot-path) matches this signal.
136 #[inline]
137 #[must_use]
138 pub fn matches_key(&self, key: &str) -> bool {
139 self.key_regex.is_match(key)
140 }
141}
142
143// ---------------------------------------------------------------------------
144// FieldRule
145// ---------------------------------------------------------------------------
146
147/// A rule describing a single field/key to sanitize.
148///
149/// # Pattern Syntax
150///
151/// - Exact key: `"password"`, `"db_host"`.
152/// - Dotted path: `"database.password"`, `"smtp.user"`.
153/// - Glob suffix: `"*.password"` — matches any key ending in `.password`.
154/// - Glob prefix: `"db.*"` — matches any key starting with `db.`.
155/// - Wildcard: `"*"` — matches every field.
156///
157/// # Sub-processor
158///
159/// When a field's value is itself a structured document (e.g. YAML embedded
160/// in a Ruby heredoc), set `sub_processor` to the processor name and provide
161/// `sub_fields` with rules for the nested content. The parent processor
162/// extracts the value and delegates it to the named sub-processor.
163///
164/// ```yaml
165/// - pattern: "*['ldap_servers']"
166/// sub_processor: yaml
167/// sub_fields:
168/// - pattern: "*.password"
169/// category: custom:password
170/// - pattern: "*.bind_dn"
171/// category: custom:dn
172/// ```
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[non_exhaustive]
175pub struct FieldRule {
176 /// Key pattern to match (see Pattern Syntax above).
177 pub pattern: String,
178
179 /// Category for replacement generation. Defaults to `Custom("field")`
180 /// if not specified. Ignored when `sub_processor` is set.
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub category: Option<Category>,
183
184 /// Optional human-readable label for reporting.
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub label: Option<String>,
187
188 /// Minimum byte length a value must reach before it is replaced.
189 ///
190 /// Values shorter than this threshold pass through unchanged. Use this
191 /// to avoid redacting obviously non-secret values matched by broad glob
192 /// patterns (e.g. `"false"`, `"0"`, `"nil"` matched by `*secret*`).
193 ///
194 /// A value of `8` is a reasonable default for token/password fields.
195 /// Omit (or set to `0`) to replace all matching values regardless of length.
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub min_length: Option<usize>,
198
199 /// Name of the processor to use for the field's value when it contains
200 /// an embedded structured document (e.g. `"yaml"`, `"json"`, `"toml"`).
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub sub_processor: Option<String>,
203
204 /// Field rules applied by `sub_processor` to the nested content.
205 /// Ignored when `sub_processor` is `None`.
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub sub_fields: Vec<FieldRule>,
208}
209
210impl FieldRule {
211 /// Create a new field rule with just a pattern.
212 #[must_use]
213 pub fn new(pattern: impl Into<String>) -> Self {
214 Self {
215 pattern: pattern.into(),
216 category: None,
217 label: None,
218 min_length: None,
219 sub_processor: None,
220 sub_fields: Vec::new(),
221 }
222 }
223
224 /// Set the minimum value length required for replacement.
225 #[must_use]
226 pub fn with_min_length(mut self, min: usize) -> Self {
227 self.min_length = Some(min);
228 self
229 }
230
231 /// Set the category for this rule.
232 #[must_use]
233 pub fn with_category(mut self, category: Category) -> Self {
234 self.category = Some(category);
235 self
236 }
237
238 /// Set the label for this rule.
239 #[must_use]
240 pub fn with_label(mut self, label: impl Into<String>) -> Self {
241 self.label = Some(label.into());
242 self
243 }
244
245 /// Set the sub-processor name for embedded structured content.
246 #[must_use]
247 pub fn with_sub_processor(mut self, name: impl Into<String>) -> Self {
248 self.sub_processor = Some(name.into());
249 self
250 }
251
252 /// Set the field rules applied by the sub-processor.
253 #[must_use]
254 pub fn with_sub_fields(mut self, fields: Vec<FieldRule>) -> Self {
255 self.sub_fields = fields;
256 self
257 }
258}
259
260// ---------------------------------------------------------------------------
261// FileTypeProfile
262// ---------------------------------------------------------------------------
263
264/// Specifies which processor to use and what fields to sanitize.
265///
266/// # File matching
267///
268/// A file is processed by this profile when **all** of the following hold:
269///
270/// 1. Its name ends with one of the `extensions` (required — an empty list
271/// matches nothing).
272/// 2. If `include` is non-empty, the filename matches **at least one** of
273/// those glob patterns.
274/// 3. The filename does **not** match any `exclude` glob pattern.
275///
276/// Glob patterns use `*` (any chars within a path component) and `**`
277/// (any chars including path separators).
278///
279/// # Example (YAML)
280///
281/// ```yaml
282/// - processor: json
283/// extensions: [".json"]
284/// # Only apply to files whose names start with "config"
285/// include: ["config*.json"]
286/// # Never apply to log files
287/// exclude: ["*.log.json", "logs/**"]
288/// fields:
289/// - pattern: "*.password"
290/// category: "custom:password"
291/// ```
292#[derive(Debug, Clone, Serialize, Deserialize)]
293#[non_exhaustive]
294pub struct FileTypeProfile {
295 /// Name of the processor to use (e.g. `"key_value"`, `"json"`).
296 pub processor: String,
297
298 /// File extensions this profile applies to (e.g. `[".rb", ".conf"]`).
299 #[serde(default)]
300 pub extensions: Vec<String>,
301
302 /// If non-empty, the filename must match at least one of these glob
303 /// patterns in addition to the extension check.
304 #[serde(default)]
305 pub include: Vec<String>,
306
307 /// Filenames matching any of these glob patterns are excluded from
308 /// structured processing even if they match the extension (and include).
309 #[serde(default)]
310 pub exclude: Vec<String>,
311
312 /// Field rules: which keys/paths to sanitize.
313 pub fields: Vec<FieldRule>,
314
315 /// Free-form options passed to the processor (e.g. delimiter, comment chars).
316 #[serde(default)]
317 pub options: std::collections::HashMap<String, String>,
318
319 /// Field-name signals injected at runtime from `kind: field-name` secrets
320 /// entries and from built-in defaults when default patterns or `--app` is
321 /// active. Never serialized to or deserialized from the profile file on
322 /// disk — configure signals in your secrets file instead.
323 #[serde(skip)]
324 pub field_name_signals: Vec<FieldNameSignal>,
325}
326
327impl FileTypeProfile {
328 /// Create a minimal profile for a given processor.
329 #[must_use]
330 pub fn new(processor: impl Into<String>, fields: Vec<FieldRule>) -> Self {
331 Self {
332 processor: processor.into(),
333 extensions: Vec::new(),
334 include: Vec::new(),
335 exclude: Vec::new(),
336 fields,
337 options: std::collections::HashMap::new(),
338 field_name_signals: Vec::new(),
339 }
340 }
341
342 /// Add an extension to this profile.
343 #[must_use]
344 pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
345 self.extensions.push(ext.into());
346 self
347 }
348
349 /// Add a free-form option.
350 #[must_use]
351 pub fn with_option(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
352 self.options.insert(key.into(), value.into());
353 self
354 }
355
356 /// Check whether a filename should be processed by this profile.
357 ///
358 /// Returns `true` when all three conditions hold:
359 ///
360 /// 1. The filename ends with one of `extensions` (an empty list → `false`).
361 /// 2. If `include` is non-empty, the filename matches at least one glob.
362 /// 3. The filename does **not** match any `exclude` glob.
363 ///
364 /// Invalid glob patterns in `include`/`exclude` are silently skipped.
365 ///
366 /// # Examples
367 ///
368 /// ```
369 /// use scour_secrets::processor::profile::FieldRule;
370 /// use scour_secrets::processor::profile::FileTypeProfile;
371 ///
372 /// let profile = FileTypeProfile::new("json", vec![])
373 /// .with_extension(".json");
374 ///
375 /// assert!(profile.matches_filename("config.json"));
376 /// assert!(profile.matches_filename("logs/app.json"));
377 /// assert!(!profile.matches_filename("config.yml"));
378 ///
379 /// // Exclude log-formatted JSON files.
380 /// let profile = FileTypeProfile::new("json", vec![])
381 /// .with_extension(".json")
382 /// .with_exclude("*.log.json")
383 /// .with_exclude("logs/**");
384 ///
385 /// assert!(profile.matches_filename("config.json"));
386 /// assert!(!profile.matches_filename("app.log.json"));
387 /// assert!(!profile.matches_filename("logs/events.json"));
388 ///
389 /// // Include only config files.
390 /// let profile = FileTypeProfile::new("json", vec![])
391 /// .with_extension(".json")
392 /// .with_include("config*.json");
393 ///
394 /// assert!(profile.matches_filename("config.json"));
395 /// assert!(profile.matches_filename("config-prod.json"));
396 /// assert!(!profile.matches_filename("events.json"));
397 /// ```
398 pub fn matches_filename(&self, filename: &str) -> bool {
399 // 1. Extension must match.
400 if self.extensions.is_empty() {
401 return false;
402 }
403 if !self
404 .extensions
405 .iter()
406 .any(|ext| filename.ends_with(ext.as_str()))
407 {
408 return false;
409 }
410
411 // Extract the basename for patterns that don't contain a path separator.
412 // This lets users write `config*.json` and have it match
413 // `/any/path/config-prod.json` without needing a `**/` prefix.
414 let basename: &str = std::path::Path::new(filename)
415 .file_name()
416 .and_then(|n| n.to_str())
417 .unwrap_or(filename);
418
419 let glob_matches =
420 |pat: &str| Pattern::new(pat).is_ok_and(|p| p.matches(filename) || p.matches(basename));
421
422 // 2. Include filter (opt-in narrowing): must match at least one pattern.
423 if !self.include.is_empty() && !self.include.iter().any(|pat| glob_matches(pat)) {
424 return false;
425 }
426
427 // 3. Exclude filter: must not match any pattern.
428 if self.exclude.iter().any(|pat| glob_matches(pat)) {
429 return false;
430 }
431
432 true
433 }
434
435 /// Add a glob pattern to the `include` list.
436 #[must_use]
437 pub fn with_include(mut self, pat: impl Into<String>) -> Self {
438 self.include.push(pat.into());
439 self
440 }
441
442 /// Add a glob pattern to the `exclude` list.
443 #[must_use]
444 pub fn with_exclude(mut self, pat: impl Into<String>) -> Self {
445 self.exclude.push(pat.into());
446 self
447 }
448}
449
450// ---------------------------------------------------------------------------
451// Serde support for Category (as string)
452// ---------------------------------------------------------------------------
453
454impl Serialize for Category {
455 fn serialize<S: serde::Serializer>(
456 &self,
457 serializer: S,
458 ) -> std::result::Result<S::Ok, S::Error> {
459 serializer.serialize_str(&self.to_string())
460 }
461}
462
463impl<'de> Deserialize<'de> for Category {
464 fn deserialize<D: serde::Deserializer<'de>>(
465 deserializer: D,
466 ) -> std::result::Result<Self, D::Error> {
467 let s = String::deserialize(deserializer)?;
468 // Single source of truth for category-string parsing; keeps profile
469 // deserialization and secrets-file parsing in lockstep.
470 Ok(crate::secrets::parse_category(&s))
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 // ---- FieldRule builders ----
479
480 #[test]
481 fn field_rule_with_min_length() {
482 let rule = FieldRule::new("*.password").with_min_length(8);
483 assert_eq!(rule.min_length, Some(8));
484 }
485
486 #[test]
487 fn field_rule_with_category() {
488 let rule = FieldRule::new("*.email").with_category(Category::Email);
489 assert_eq!(rule.category, Some(Category::Email));
490 }
491
492 #[test]
493 fn field_rule_with_label() {
494 let rule = FieldRule::new("*.token").with_label("my-token");
495 assert_eq!(rule.label.as_deref(), Some("my-token"));
496 }
497
498 // ---- FileTypeProfile builders ----
499
500 #[test]
501 fn profile_with_include_narrows_match() {
502 let profile = FileTypeProfile::new("json", vec![])
503 .with_extension(".json")
504 .with_include("config*.json");
505
506 assert!(profile.matches_filename("config.json"));
507 assert!(profile.matches_filename("config-prod.json"));
508 assert!(!profile.matches_filename("events.json"));
509 }
510
511 #[test]
512 fn profile_with_exclude_blocks_match() {
513 let profile = FileTypeProfile::new("json", vec![])
514 .with_extension(".json")
515 .with_exclude("*.log.json");
516
517 assert!(profile.matches_filename("config.json"));
518 assert!(!profile.matches_filename("server.log.json"));
519 }
520
521 #[test]
522 fn profile_include_and_exclude_combined() {
523 let profile = FileTypeProfile::new("json", vec![])
524 .with_extension(".json")
525 .with_include("config*.json")
526 .with_exclude("config-secret.json");
527
528 assert!(profile.matches_filename("config-prod.json"));
529 assert!(!profile.matches_filename("config-secret.json"));
530 assert!(!profile.matches_filename("events.json"));
531 }
532
533 #[test]
534 fn profile_no_extensions_matches_nothing() {
535 let profile = FileTypeProfile::new("json", vec![]);
536 assert!(!profile.matches_filename("anything.json"));
537 }
538
539 // ---- Category serde roundtrip ----
540
541 #[test]
542 fn category_serialize_deserialize_roundtrip() {
543 let cases: &[(&str, Category)] = &[
544 ("email", Category::Email),
545 ("ipv4", Category::IpV4),
546 ("custom:my_key", Category::Custom("my_key".into())),
547 ];
548 for (s, expected) in cases {
549 let json = format!("\"{}\"", s);
550 let got: Category = serde_json::from_str(&json).unwrap();
551 assert_eq!(got, *expected, "deserializing {s}");
552 let serialized = serde_json::to_string(&got).unwrap();
553 assert_eq!(serialized, json, "serializing {s}");
554 }
555 }
556}