Skip to main content

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}