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