ijima_server/
redaction.rs1use serde::Serialize;
27
28#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
30pub struct Redaction {
31 pub category: &'static str,
33 pub count: usize,
35}
36
37#[derive(Debug, Clone)]
39pub struct RedactionResult {
40 pub text: String,
42 pub redactions: Vec<Redaction>,
44}
45
46pub struct Redactor {
48 rules: Vec<Rule>,
49}
50
51struct Rule {
52 category: &'static str,
53 pattern: regex::Regex,
54 replacement: String,
55}
56
57impl Default for Redactor {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl Redactor {
64 #[allow(clippy::needless_pass_by_value)]
66 pub fn new() -> Self {
67 let rules = vec![
68 Rule {
69 category: "private_key",
70 pattern: regex::Regex::new(
71 r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----",
72 )
73 .expect("private_key regex"),
74 replacement: "[REDACTED:private_key]".into(),
75 },
76 Rule {
77 category: "bearer_token",
78 pattern: regex::Regex::new(r"(?i)bearer [a-zA-Z0-9._\-]{20,}")
79 .expect("bearer regex"),
80 replacement: "[REDACTED:bearer_token]".into(),
81 },
82 Rule {
83 category: "api_key",
84 pattern: regex::Regex::new(r"(sk-[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}|[a-f0-9]{40})")
85 .expect("api_key regex"),
86 replacement: "[REDACTED:api_key]".into(),
87 },
88 Rule {
89 category: "email",
90 pattern: regex::Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}")
91 .expect("email regex"),
92 replacement: "[REDACTED:email]".into(),
93 },
94 Rule {
95 category: "ipv4",
96 pattern: regex::Regex::new(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")
97 .expect("ipv4 regex"),
98 replacement: "[REDACTED:ipv4]".into(),
99 },
100 ];
101 Self { rules }
102 }
103
104 pub fn redact(&self, text: &str) -> RedactionResult {
108 let mut scrubbed = text.to_string();
109 let mut redactions = Vec::new();
110 for rule in &self.rules {
111 let count = rule.pattern.find_iter(&scrubbed.clone()).count();
112 if count > 0 {
113 scrubbed = rule
114 .pattern
115 .replace_all(&scrubbed, &rule.replacement)
116 .into_owned();
117 redactions.push(Redaction {
118 category: rule.category,
119 count,
120 });
121 }
122 }
123 RedactionResult {
124 text: scrubbed,
125 redactions,
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn clean_text_passes_through() {
136 let r = Redactor::new();
137 let result = r.redact("The cat sat on the mat");
138 assert_eq!(result.text, "The cat sat on the mat");
139 assert!(result.redactions.is_empty());
140 }
141
142 #[test]
143 fn email_is_redacted() {
144 let r = Redactor::new();
145 let result = r.redact("contact elliott@example.com for details");
146 assert!(result.text.contains("[REDACTED:email]"));
147 assert!(!result.text.contains("elliott@example.com"));
148 assert_eq!(
149 result.redactions,
150 vec![Redaction {
151 category: "email",
152 count: 1
153 }]
154 );
155 }
156
157 #[test]
158 fn openai_api_key_is_redacted() {
159 let r = Redactor::new();
160 let result = r.redact("key: sk-abcdefghijklmnopqrstuvwxyz1234567890");
161 assert!(result.text.contains("[REDACTED:api_key]"));
162 assert!(!result.text.contains("sk-abcdef"));
163 }
164
165 #[test]
166 fn aws_key_is_redacted() {
167 let r = Redactor::new();
168 let result = r.redact("creds: AKIAIOSFODNN7EXAMPLE");
169 assert!(result.text.contains("[REDACTED:api_key]"));
170 }
171
172 #[test]
173 fn hex_sha1_is_redacted() {
174 let r = Redactor::new();
175 let result = r.redact("commit: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2");
176 assert!(result.text.contains("[REDACTED:api_key]"));
177 }
178
179 #[test]
180 fn bearer_token_is_redacted() {
181 let r = Redactor::new();
182 let result = r.redact("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig");
183 assert!(result.text.contains("[REDACTED:bearer_token]"));
184 assert!(!result.text.contains("eyJhbG"));
185 }
186
187 #[test]
188 fn private_key_block_is_redacted() {
189 let r = Redactor::new();
190 let text =
191 "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----";
192 let result = r.redact(text);
193 assert!(result.text.contains("[REDACTED:private_key]"));
194 assert!(!result.text.contains("MIIEpA"));
195 }
196
197 #[test]
198 fn ipv4_is_redacted() {
199 let r = Redactor::new();
200 let result = r.redact("server at 10.0.0.5 is down");
201 assert!(result.text.contains("[REDACTED:ipv4]"));
202 assert!(!result.text.contains("10.0.0.5"));
203 }
204
205 #[test]
206 fn multiple_categories_in_one_text() {
207 let r = Redactor::new();
208 let text = "email alice@test.com and use key sk-abcdefghijklmnopqrstuvwxyz1234567890";
209 let result = r.redact(text);
210 let cats: Vec<&str> = result.redactions.iter().map(|x| x.category).collect();
211 assert!(cats.contains(&"email"));
212 assert!(cats.contains(&"api_key"));
213 assert!(!result.text.contains("alice@test.com"));
214 assert!(!result.text.contains("sk-abc"));
215 }
216
217 #[test]
218 fn version_numbers_are_not_redacted_as_api_keys() {
219 let r = Redactor::new();
221 let result = r.redact("version 0.1.0 released");
222 assert_eq!(result.text, "version 0.1.0 released");
223 assert!(!result.redactions.iter().any(|x| x.category == "api_key"));
226 }
227}