Skip to main content

chio_guards/
secret_leak.rs

1//! Secret leak guard -- detects potential secret exposure in file writes.
2//!
3//! Uses regex patterns to detect common API keys, tokens, passwords, and
4//! private keys in file write content. This is a critical security guard.
5
6use 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
17/// Pattern definition for secret detection.
18pub struct SecretPattern {
19    /// Pattern name (e.g. "aws_access_key").
20    pub name: &'static str,
21    /// Regex pattern string.
22    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/// Compiled pattern for matching.
109#[derive(Clone)]
110struct CompiledPattern {
111    name: String,
112    regex: Regex,
113}
114
115/// A detected secret match.
116#[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
154/// Guard configuration.
155pub struct SecretLeakConfig {
156    /// Enable/disable this guard.
157    pub enabled: bool,
158    /// File path patterns to skip (e.g. test fixtures).
159    pub skip_paths: Vec<String>,
160    /// Additional operator-defined secret patterns.
161    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/// Guard that detects potential secret exposure in file writes.
196#[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    /// Scan content for secrets. Returns a list of matches.
281    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![], // Skip binary content.
285        };
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    /// Check if a path should be skipped (e.g. test fixtures).
305    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        // File write containing an OpenAI key.
486        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        // Normal file write should be allowed.
521        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        // Even though content contains a secret, test paths are skipped.
578        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}