Skip to main content

chio_guards/
secret_leak.rs

1//! Secret leak guard -- detects potential secret exposure in file writes.
2//!
3//! Adapted from ClawdStrike's `guards/secret_leak.rs`. Uses regex patterns
4//! to detect common API keys, tokens, passwords, and private keys in file
5//! write content. This is a critical security guard.
6
7use regex::Regex;
8use thiserror::Error;
9
10use chio_kernel::{GuardContext, KernelError, Verdict};
11
12use crate::action::{extract_action, ToolAction};
13
14/// Pattern definition for secret detection.
15pub struct SecretPattern {
16    /// Pattern name (e.g. "aws_access_key").
17    pub name: &'static str,
18    /// Regex pattern string.
19    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
105/// Compiled pattern for matching.
106struct CompiledPattern {
107    name: String,
108    regex: Regex,
109}
110
111/// A detected secret match.
112#[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
150/// Guard configuration.
151pub struct SecretLeakConfig {
152    /// Enable/disable this guard.
153    pub enabled: bool,
154    /// File path patterns to skip (e.g. test fixtures).
155    pub skip_paths: Vec<String>,
156    /// Additional operator-defined secret patterns.
157    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
191/// Guard that detects potential secret exposure in file writes.
192pub 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    /// Scan content for secrets. Returns a list of matches.
253    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![], // Skip binary content.
257        };
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    /// Check if a path should be skipped (e.g. test fixtures).
277    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        // File write containing an OpenAI key.
453        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        // Normal file write should be allowed.
484        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        // Even though content contains a secret, test paths are skipped.
535        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}