chrs_shhh/
lib.rs

1use lazy_static::lazy_static;
2/// # chrs-shhh
3///
4/// This crate provides utilities for redacting sensitive information from text.
5/// It defines a set of **redaction rules** that match secret patterns (like API keys)
6/// and replace them with a placeholder. The crate is deliberately lightweight – it
7/// only depends on `regex` and `lazy_static` – and can be embedded in any larger
8/// application that needs to scrub logs or user‑provided data before storage or
9/// transmission.
10use regex::Regex;
11
12/// Represents a single rule used to redact a secret.
13///
14/// * **WHAT** – The name of the rule (e.g. "OpenAI API Key"), the compiled
15///   regular‑expression pattern that matches the secret, and the replacement string
16///   that will be inserted.
17/// * **HOW** – The `pattern` is a `Regex` that is applied to an input string. When a
18///   match is found the `replacement` is inserted using `replace_all`.
19/// * **WHY** – Decoupling the rule definition from the redaction logic makes the
20///   sanitizer extensible; new patterns can be added without changing the core
21///   implementation.
22pub struct RedactionRule {
23    /// Human‑readable name for the rule.
24    pub name: String,
25    /// Compiled regular expression that matches the secret.
26    pub pattern: Regex,
27    /// Text that will replace the matched secret.
28    pub replacement: String,
29}
30
31/// The main entry point for secret detection and redaction.
32///
33/// * **WHAT** – Holds a collection of `RedactionRule`s.
34/// * **HOW** – Provides methods to scrub a string (`scrub_text`) and to simply
35///   check whether any secret is present (`contains_secrets`).
36/// * **WHY** – Centralising the rules in a struct enables reuse and makes testing
37///   straightforward.
38pub struct SecretSentinel {
39    rules: Vec<RedactionRule>,
40}
41
42lazy_static! {
43    /// Matches OpenAI API keys of the form `sk-<48 alphanumeric chars>`.
44    static ref OPENAI_KEY: Regex = Regex::new(r"sk-[a-zA-Z0-9]{48}").unwrap();
45    /// Matches AWS access keys that start with `AKIA` followed by 16 uppercase letters or digits.
46    static ref AWS_KEY: Regex = Regex::new(r"AKIA[0-9A-Z]{16}").unwrap();
47    /// Generic secret pattern that captures common keywords like password, secret, key or token.
48    /// The capture group (`$1`) is retained so that the surrounding identifier is preserved.
49    static ref GENERIC_SECRET: Regex = Regex::new(r"(?i)(password|secret|key|token)\s*[:=]\s*[^\s]+").unwrap();
50}
51
52impl SecretSentinel {
53    /// Constructs a `SecretSentinel` pre‑populated with a sensible default set of rules.
54    ///
55    /// * **WHAT** – Returns a sentinel containing three rules: OpenAI, AWS and a generic
56    ///   secret matcher.
57    /// * **HOW** – Instantiates `RedactionRule`s using the lazily‑initialised regexes
58    ///   above and stores them in the `rules` vector.
59    /// * **WHY** – Provides a ready‑to‑use configuration for typical development
60    ///   environments while still allowing callers to create custom instances.
61    pub fn new_default() -> Self {
62        let rules = vec![
63            RedactionRule {
64                name: "OpenAI API Key".into(),
65                pattern: OPENAI_KEY.clone(),
66                replacement: "[REDACTED OPENAI KEY]".into(),
67            },
68            RedactionRule {
69                name: "AWS Access Key".into(),
70                pattern: AWS_KEY.clone(),
71                replacement: "[REDACTED AWS KEY]".into(),
72            },
73            RedactionRule {
74                name: "Generic Secret".into(),
75                pattern: GENERIC_SECRET.clone(),
76                // $1 refers to the captured keyword (password, secret, …).
77                replacement: "$1: [REDACTED]".into(),
78            },
79        ];
80        Self { rules }
81    }
82
83    /// Redacts all secrets found in `input` according to the configured rules.
84    ///
85    /// * **WHAT** – Returns a new `String` where each match has been replaced.
86    /// * **HOW** – Iterates over the rules and applies `replace_all` for each.
87    /// * **WHY** – Performing the replacements sequentially ensures that overlapping
88    ///   patterns are handled deterministically.
89    pub fn scrub_text(&self, input: &str) -> String {
90        let mut scrubbed = input.to_string();
91        for rule in &self.rules {
92            scrubbed = rule
93                .pattern
94                .replace_all(&scrubbed, &rule.replacement)
95                .to_string();
96        }
97        scrubbed
98    }
99
100    /// Checks whether any of the configured rules match `input`.
101    ///
102    /// * **WHAT** – Returns `true` if at least one rule's pattern matches.
103    /// * **HOW** – Uses `Iter::any` over `self.rules` with `is_match`.
104    /// * **WHY** – A quick predicate useful for short‑circuiting logging or error
105    ///   handling before performing the full redaction.
106    pub fn contains_secrets(&self, input: &str) -> bool {
107        self.rules.iter().any(|rule| rule.pattern.is_match(input))
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_scrub_openai_key() {
117        let sentinel = SecretSentinel::new_default();
118        let input = "My key is sk-1234567890abcdef1234567890abcdef1234567890abcdef";
119        let output = sentinel.scrub_text(input);
120        assert!(output.contains("[REDACTED OPENAI KEY]"));
121        assert!(!output.contains("sk-1234567890"));
122    }
123
124    #[test]
125    fn test_scrub_generic_password() {
126        let sentinel = SecretSentinel::new_default();
127        let input = "login with password: my-secret-password now";
128        let output = sentinel.scrub_text(input);
129        assert!(output.contains("password: [REDACTED]"));
130    }
131
132    #[test]
133    fn test_contains_secrets() {
134        let sentinel = SecretSentinel::new_default();
135        assert!(sentinel.contains_secrets("AKIAIOSFODNN7EXAMPLE"));
136        assert!(!sentinel.contains_secrets("nothing sensitive here"));
137    }
138}