1use regex::Regex;
8use thiserror::Error;
9
10use chio_kernel::{GuardContext, KernelError, Verdict};
11
12use crate::action::{extract_action, ToolAction};
13
14pub struct SecretPattern {
16 pub name: &'static str,
18 pub pattern: &'static str,
20}
21
22#[derive(Clone, Debug)]
23pub struct CustomSecretPattern {
24 pub name: String,
25 pub pattern: String,
26}
27
28fn default_patterns() -> Vec<SecretPattern> {
29 vec![
30 SecretPattern {
31 name: "aws_access_key",
32 pattern: r"AKIA[0-9A-Z]{16}",
33 },
34 SecretPattern {
35 name: "aws_secret_key",
36 pattern: r#"(?i)aws[_\-]?secret[_\-]?access[_\-]?key['"]?\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}"#,
37 },
38 SecretPattern {
39 name: "github_token",
40 pattern: r"gh[ps]_[A-Za-z0-9]{36}",
41 },
42 SecretPattern {
43 name: "github_pat",
44 pattern: r"github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}",
45 },
46 SecretPattern {
47 name: "openai_key",
48 pattern: r"sk-[A-Za-z0-9]{48}",
49 },
50 SecretPattern {
51 name: "openai_project_key",
52 pattern: r"sk-proj-[A-Za-z0-9]{48,}",
53 },
54 SecretPattern {
55 name: "anthropic_key",
56 pattern: r"sk-ant-[A-Za-z0-9\-]{95}",
57 },
58 SecretPattern {
59 name: "anthropic_api03_key",
60 pattern: r"sk-ant-api03-[A-Za-z0-9_\-]{93}",
61 },
62 SecretPattern {
63 name: "private_key",
64 pattern: r"-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----",
65 },
66 SecretPattern {
67 name: "npm_token",
68 pattern: r"npm_[A-Za-z0-9]{36}",
69 },
70 SecretPattern {
71 name: "slack_token",
72 pattern: r"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*",
73 },
74 SecretPattern {
75 name: "stripe_secret_key",
76 pattern: r"sk_live_[A-Za-z0-9]{24,}",
77 },
78 SecretPattern {
79 name: "stripe_restricted_key",
80 pattern: r"rk_live_[A-Za-z0-9]{24,}",
81 },
82 SecretPattern {
83 name: "gcp_service_account",
84 pattern: r#""type"\s*:\s*"service_account""#,
85 },
86 SecretPattern {
87 name: "azure_key_vault_token",
88 pattern: r#"(?i)azure[_\-]?(?:key[_\-]?vault|kv)[_\-]?(?:secret|token|key)['"]?\s*[:=]\s*['"]?[A-Za-z0-9+/=_\-]{32,}"#,
89 },
90 SecretPattern {
91 name: "gitlab_pat",
92 pattern: r#"glpat-[A-Za-z0-9_\-]{20,}"#,
93 },
94 SecretPattern {
95 name: "generic_api_key",
96 pattern: r#"(?i)(api[_\-]?key|apikey)[\x27"]?\s*[:=]\s*[\x27"]?[A-Za-z0-9]{32,}"#,
97 },
98 SecretPattern {
99 name: "generic_secret",
100 pattern: r#"(?i)(secret|password|passwd|pwd)['"]?\s*[:=]\s*['"]?[A-Za-z0-9!@#$%^&*]{8,}"#,
101 },
102 ]
103}
104
105struct CompiledPattern {
107 name: String,
108 regex: Regex,
109}
110
111#[derive(Clone, Debug)]
113pub struct SecretMatch {
114 pub pattern_name: String,
115 pub offset: usize,
116 pub length: usize,
117 pub redacted: String,
118}
119
120fn mask_value(s: &str) -> String {
121 let len = s.chars().count();
122 let first = 4usize;
123 let last = 4usize;
124
125 if s.is_empty() {
126 return String::new();
127 }
128
129 if first + last >= len {
130 return "*".repeat(len);
131 }
132
133 let first_chars: String = s.chars().take(first).collect();
134 let last_chars: String = s
135 .chars()
136 .rev()
137 .take(last)
138 .collect::<String>()
139 .chars()
140 .rev()
141 .collect();
142 format!(
143 "{}{}{}",
144 first_chars,
145 "*".repeat(len - first - last),
146 last_chars
147 )
148}
149
150pub struct SecretLeakConfig {
152 pub enabled: bool,
154 pub skip_paths: Vec<String>,
156 pub custom_patterns: Vec<CustomSecretPattern>,
158}
159
160impl Default for SecretLeakConfig {
161 fn default() -> Self {
162 Self {
163 enabled: true,
164 skip_paths: vec![
165 "**/test/**".to_string(),
166 "**/tests/**".to_string(),
167 "**/*_test.*".to_string(),
168 "**/*.test.*".to_string(),
169 ],
170 custom_patterns: Vec::new(),
171 }
172 }
173}
174
175#[derive(Debug, Error)]
176pub enum SecretLeakConfigError {
177 #[error("invalid built-in secret pattern `{name}`: {source}")]
178 InvalidBuiltInPattern {
179 name: String,
180 #[source]
181 source: regex::Error,
182 },
183 #[error("invalid custom secret pattern `{name}`: {source}")]
184 InvalidCustomPattern {
185 name: String,
186 #[source]
187 source: regex::Error,
188 },
189}
190
191pub struct SecretLeakGuard {
193 enabled: bool,
194 patterns: Vec<CompiledPattern>,
195 skip_paths: Vec<glob::Pattern>,
196}
197
198impl SecretLeakGuard {
199 pub fn new() -> Self {
200 match Self::with_config(SecretLeakConfig::default()) {
201 Ok(guard) => guard,
202 Err(error) => panic!("default secret leak config must be valid: {error}"),
203 }
204 }
205
206 pub fn with_config(config: SecretLeakConfig) -> Result<Self, SecretLeakConfigError> {
207 let mut patterns: Vec<CompiledPattern> = default_patterns()
208 .into_iter()
209 .map(|pattern| {
210 Regex::new(pattern.pattern)
211 .map(|regex| CompiledPattern {
212 name: pattern.name.to_string(),
213 regex,
214 })
215 .map_err(|source| SecretLeakConfigError::InvalidBuiltInPattern {
216 name: pattern.name.to_string(),
217 source,
218 })
219 })
220 .collect::<Result<_, _>>()?;
221 patterns.extend(
222 config
223 .custom_patterns
224 .iter()
225 .map(|pattern| {
226 Regex::new(&pattern.pattern)
227 .map(|regex| CompiledPattern {
228 name: pattern.name.clone(),
229 regex,
230 })
231 .map_err(|source| SecretLeakConfigError::InvalidCustomPattern {
232 name: pattern.name.clone(),
233 source,
234 })
235 })
236 .collect::<Result<Vec<_>, _>>()?,
237 );
238
239 let skip_paths = config
240 .skip_paths
241 .iter()
242 .filter_map(|p| glob::Pattern::new(p).ok())
243 .collect();
244
245 Ok(Self {
246 enabled: config.enabled,
247 patterns,
248 skip_paths,
249 })
250 }
251
252 pub fn scan(&self, content: &[u8]) -> Vec<SecretMatch> {
254 let content = match std::str::from_utf8(content) {
255 Ok(s) => s,
256 Err(_) => return vec![], };
258
259 let mut matches = Vec::new();
260 for pattern in &self.patterns {
261 for m in pattern.regex.find_iter(content) {
262 let matched = m.as_str();
263 let redacted = mask_value(matched);
264
265 matches.push(SecretMatch {
266 pattern_name: pattern.name.clone(),
267 offset: m.start(),
268 length: m.len(),
269 redacted,
270 });
271 }
272 }
273 matches
274 }
275
276 pub fn should_skip_path(&self, path: &str) -> bool {
278 for pattern in &self.skip_paths {
279 if pattern.matches(path) {
280 return true;
281 }
282 }
283 false
284 }
285}
286
287impl Default for SecretLeakGuard {
288 fn default() -> Self {
289 Self::new()
290 }
291}
292
293impl chio_kernel::Guard for SecretLeakGuard {
294 fn name(&self) -> &str {
295 "secret-leak"
296 }
297
298 fn evaluate(&self, ctx: &GuardContext) -> Result<Verdict, KernelError> {
299 if !self.enabled {
300 return Ok(Verdict::Allow);
301 }
302
303 let action = extract_action(&ctx.request.tool_name, &ctx.request.arguments);
304
305 let (path, content) = match &action {
306 ToolAction::FileWrite(p, c) => (p.as_str(), c.as_slice()),
307 ToolAction::Patch(p, diff) => (p.as_str(), diff.as_bytes()),
308 _ => return Ok(Verdict::Allow),
309 };
310
311 if self.should_skip_path(path) {
312 return Ok(Verdict::Allow);
313 }
314
315 let matches = self.scan(content);
316
317 if matches.is_empty() {
318 Ok(Verdict::Allow)
319 } else {
320 Ok(Verdict::Deny)
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use chio_kernel::Guard;
329
330 #[test]
331 fn detects_aws_access_key() {
332 let guard = SecretLeakGuard::new();
333 let content = b"aws_key = AKIAIOSFODNN7EXAMPLE";
334 let matches = guard.scan(content);
335 assert!(!matches.is_empty());
336 assert_eq!(matches[0].pattern_name, "aws_access_key");
337 }
338
339 #[test]
340 fn detects_github_token() {
341 let guard = SecretLeakGuard::new();
342 let content = b"token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
343 let matches = guard.scan(content);
344 assert!(!matches.is_empty());
345 assert_eq!(matches[0].pattern_name, "github_token");
346 }
347
348 #[test]
349 fn detects_private_key() {
350 let guard = SecretLeakGuard::new();
351 let content = b"-----BEGIN RSA PRIVATE KEY-----\nMIIE...";
352 let matches = guard.scan(content);
353 assert!(!matches.is_empty());
354 assert_eq!(matches[0].pattern_name, "private_key");
355 }
356
357 #[test]
358 fn detects_openai_project_key() {
359 let guard = SecretLeakGuard::new();
360 let content = format!("key = sk-proj-{}", "a".repeat(48));
361 let matches = guard.scan(content.as_bytes());
362 assert!(!matches.is_empty());
363 assert!(matches
364 .iter()
365 .any(|m| m.pattern_name == "openai_project_key"));
366 }
367
368 #[test]
369 fn detects_anthropic_api03_key() {
370 let guard = SecretLeakGuard::new();
371 let content = format!("key = sk-ant-api03-{}", "a".repeat(93));
372 let matches = guard.scan(content.as_bytes());
373 assert!(!matches.is_empty());
374 assert!(matches
375 .iter()
376 .any(|m| m.pattern_name == "anthropic_api03_key"));
377 }
378
379 #[test]
380 fn detects_stripe_secret_key() {
381 let guard = SecretLeakGuard::new();
382 let content = format!("key = sk_live_{}", "a".repeat(24));
383 let matches = guard.scan(content.as_bytes());
384 assert!(!matches.is_empty());
385 assert!(matches
386 .iter()
387 .any(|m| m.pattern_name == "stripe_secret_key"));
388 }
389
390 #[test]
391 fn detects_gcp_service_account() {
392 let guard = SecretLeakGuard::new();
393 let content = br#"{"type": "service_account", "project_id": "test"}"#;
394 let matches = guard.scan(content);
395 assert!(!matches.is_empty());
396 assert!(matches
397 .iter()
398 .any(|m| m.pattern_name == "gcp_service_account"));
399 }
400
401 #[test]
402 fn detects_gitlab_pat() {
403 let guard = SecretLeakGuard::new();
404 let content = format!("token = glpat-{}", "a".repeat(20));
405 let matches = guard.scan(content.as_bytes());
406 assert!(!matches.is_empty());
407 assert!(matches.iter().any(|m| m.pattern_name == "gitlab_pat"));
408 }
409
410 #[test]
411 fn no_false_positive_on_normal_code() {
412 let guard = SecretLeakGuard::new();
413 let content = b"This is just normal code\nfn main() { }";
414 let matches = guard.scan(content);
415 assert!(matches.is_empty());
416 }
417
418 #[test]
419 fn redaction() {
420 assert_eq!(mask_value("short"), "*****");
421 assert_eq!(mask_value("AKIAIOSFODNN7EXAMPLE"), "AKIA************MPLE");
422 }
423
424 #[test]
425 fn skip_paths() {
426 let guard = SecretLeakGuard::new();
427 assert!(guard.should_skip_path("/app/tests/fixtures/sample.json"));
428 assert!(guard.should_skip_path("/app/src/main_test.rs"));
429 assert!(!guard.should_skip_path("/app/src/main.rs"));
430 }
431
432 #[test]
433 fn evaluate_blocks_file_write_with_secret() {
434 let guard = SecretLeakGuard::new();
435
436 let kp = chio_core::crypto::Keypair::generate();
437 let scope = chio_core::capability::ChioScope::default();
438 let agent_id = kp.public_key().to_hex();
439 let server_id = "srv-test".to_string();
440
441 let cap_body = chio_core::capability::CapabilityTokenBody {
442 id: "cap-test".to_string(),
443 issuer: kp.public_key(),
444 subject: kp.public_key(),
445 scope: scope.clone(),
446 issued_at: 0,
447 expires_at: u64::MAX,
448 delegation_chain: vec![],
449 };
450 let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
451
452 let secret_content = format!("api_key = sk-{}", "x".repeat(48));
454 let request = chio_kernel::ToolCallRequest {
455 request_id: "req-test".to_string(),
456 capability: cap.clone(),
457 tool_name: "write_file".to_string(),
458 server_id: server_id.clone(),
459 agent_id: agent_id.clone(),
460 arguments: serde_json::json!({
461 "path": "/app/config.py",
462 "content": secret_content,
463 }),
464 dpop_proof: None,
465 governed_intent: None,
466 approval_token: None,
467 model_metadata: None,
468 federated_origin_kernel_id: None,
469 };
470
471 let ctx = chio_kernel::GuardContext {
472 request: &request,
473 scope: &scope,
474 agent_id: &agent_id,
475 server_id: &server_id,
476 session_filesystem_roots: None,
477 matched_grant_index: None,
478 };
479
480 let result = guard.evaluate(&ctx).expect("evaluate should not error");
481 assert_eq!(result, Verdict::Deny);
482
483 let request2 = chio_kernel::ToolCallRequest {
485 request_id: "req-test-2".to_string(),
486 capability: cap,
487 tool_name: "write_file".to_string(),
488 server_id: server_id.clone(),
489 agent_id: agent_id.clone(),
490 arguments: serde_json::json!({
491 "path": "/app/main.rs",
492 "content": "fn main() { println!(\"Hello\"); }",
493 }),
494 dpop_proof: None,
495 governed_intent: None,
496 approval_token: None,
497 model_metadata: None,
498 federated_origin_kernel_id: None,
499 };
500
501 let ctx2 = chio_kernel::GuardContext {
502 request: &request2,
503 scope: &scope,
504 agent_id: &agent_id,
505 server_id: &server_id,
506 session_filesystem_roots: None,
507 matched_grant_index: None,
508 };
509
510 let result2 = guard.evaluate(&ctx2).expect("evaluate should not error");
511 assert_eq!(result2, Verdict::Allow);
512 }
513
514 #[test]
515 fn evaluate_allows_write_to_test_path() {
516 let guard = SecretLeakGuard::new();
517
518 let kp = chio_core::crypto::Keypair::generate();
519 let scope = chio_core::capability::ChioScope::default();
520 let agent_id = kp.public_key().to_hex();
521 let server_id = "srv-test".to_string();
522
523 let cap_body = chio_core::capability::CapabilityTokenBody {
524 id: "cap-test".to_string(),
525 issuer: kp.public_key(),
526 subject: kp.public_key(),
527 scope: scope.clone(),
528 issued_at: 0,
529 expires_at: u64::MAX,
530 delegation_chain: vec![],
531 };
532 let cap = chio_core::capability::CapabilityToken::sign(cap_body, &kp).expect("sign cap");
533
534 let secret_content = format!("api_key = sk-{}", "x".repeat(48));
536 let request = chio_kernel::ToolCallRequest {
537 request_id: "req-test".to_string(),
538 capability: cap,
539 tool_name: "write_file".to_string(),
540 server_id: server_id.clone(),
541 agent_id: agent_id.clone(),
542 arguments: serde_json::json!({
543 "path": "/app/tests/fixtures/sample.json",
544 "content": secret_content,
545 }),
546 dpop_proof: None,
547 governed_intent: None,
548 approval_token: None,
549 model_metadata: None,
550 federated_origin_kernel_id: None,
551 };
552
553 let ctx = chio_kernel::GuardContext {
554 request: &request,
555 scope: &scope,
556 agent_id: &agent_id,
557 server_id: &server_id,
558 session_filesystem_roots: None,
559 matched_grant_index: None,
560 };
561
562 let result = guard.evaluate(&ctx).expect("evaluate should not error");
563 assert_eq!(result, Verdict::Allow);
564 }
565
566 #[test]
567 fn with_config_rejects_invalid_custom_regex() {
568 let result = SecretLeakGuard::with_config(SecretLeakConfig {
569 enabled: true,
570 skip_paths: Vec::new(),
571 custom_patterns: vec![CustomSecretPattern {
572 name: "broken".to_string(),
573 pattern: "(".to_string(),
574 }],
575 });
576
577 match result {
578 Ok(_) => panic!("invalid custom regex should fail configuration"),
579 Err(error) => {
580 assert!(error.to_string().contains("broken"));
581 }
582 }
583 }
584}