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}