Skip to main content

bob_core/
secrets.rs

1//! # Secrets Management
2//!
3//! Utilities for safely loading and filtering environment variables
4//! as agent configuration secrets.
5//!
6//! The primary entry point is [`load_safe_env_vars`], which returns only
7//! non-sensitive environment variables. Sensitive variables (those matching
8//! known credential patterns) are filtered out to prevent accidental leakage
9//! into configuration files or logs.
10//!
11//! For intentionally loading specific credentials, use [`is_env_var_safe`]
12//! to verify a variable name before loading.
13
14// ── Blocked Patterns ─────────────────────────────────────────────────
15
16/// Exact environment variable names that are always blocked.
17const BLOCKED_ENV_VARS: &[&str] = &[
18    // Cloud credentials
19    "AWS_SECRET_ACCESS_KEY",
20    "AWS_SESSION_TOKEN",
21    "GOOGLE_APPLICATION_CREDENTIALS",
22    "AZURE_CLIENT_SECRET",
23    // CI/CD tokens
24    "GITHUB_TOKEN",
25    "GH_TOKEN",
26    "GITLAB_TOKEN",
27    "NPM_TOKEN",
28    // Docker / registry
29    "DOCKER_PASSWORD",
30    "REGISTRY_PASSWORD",
31    // System / user info
32    "HOME",
33    "USER",
34    "LOGNAME",
35    "SHELL",
36    "HISTFILE",
37    // Database URLs
38    "DATABASE_URL",
39    "REDIS_URL",
40    "MONGODB_URI",
41    "POSTGRES_PASSWORD",
42    "MYSQL_ROOT_PASSWORD",
43    // Generic secrets
44    "JWT_SECRET",
45    "SESSION_SECRET",
46    "ENCRYPTION_KEY",
47    "MASTER_KEY",
48    "PRIVATE_KEY",
49    "SECRET_KEY",
50];
51
52/// Prefixes that block any variable starting with them (case-insensitive).
53const BLOCKED_ENV_PREFIXES: &[&str] =
54    &["AWS_", "AZURE_", "GCP_", "GOOGLE_", "GITHUB_", "GITLAB_", "SSH_", "GPG_"];
55
56/// Substrings that block any variable containing them (case-insensitive).
57const BLOCKED_KEYWORDS: &[&str] =
58    &["PASSWORD", "SECRET", "TOKEN", "KEY", "CREDENTIAL", "AUTH", "PRIVATE"];
59
60// ── Public API ───────────────────────────────────────────────────────
61
62/// Returns `true` if the environment variable name is considered safe
63/// (i.e. does not match any blocked pattern).
64///
65/// Use this to verify a variable name before loading it.
66#[must_use]
67pub fn is_env_var_safe(name: &str) -> bool {
68    !is_blocked(name)
69}
70
71/// Collect all safe environment variables into a `HashMap`.
72///
73/// Variables matching blocked patterns are excluded.
74#[must_use]
75pub fn load_safe_env_vars() -> std::collections::HashMap<String, String> {
76    std::env::vars().filter(|(name, _)| !is_blocked(name)).collect()
77}
78
79/// Load a single environment variable by name if it is safe.
80///
81/// Returns `None` if the variable does not exist or is blocked.
82#[must_use]
83pub fn load_env_var_safe(name: &str) -> Option<String> {
84    if is_blocked(name) {
85        return None;
86    }
87    std::env::var(name).ok()
88}
89
90// ── Internal ─────────────────────────────────────────────────────────
91
92fn is_blocked(name: &str) -> bool {
93    // Exact match (case-insensitive).
94    if BLOCKED_ENV_VARS.iter().any(|blocked| blocked.eq_ignore_ascii_case(name)) {
95        return true;
96    }
97
98    // Prefix match (case-insensitive).
99    let upper = name.to_ascii_uppercase();
100    if BLOCKED_ENV_PREFIXES.iter().any(|prefix| upper.starts_with(prefix)) {
101        return true;
102    }
103
104    // Keyword substring match (case-insensitive).
105    if BLOCKED_KEYWORDS.iter().any(|keyword| upper.contains(keyword)) {
106        return true;
107    }
108
109    false
110}
111
112// ── Tests ────────────────────────────────────────────────────────────
113
114#[cfg(test)]
115#[allow(unsafe_code)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn blocks_known_sensitive_vars() {
121        assert!(!is_env_var_safe("AWS_SECRET_ACCESS_KEY"));
122        assert!(!is_env_var_safe("GITHUB_TOKEN"));
123        assert!(!is_env_var_safe("DATABASE_URL"));
124        assert!(!is_env_var_safe("HOME"));
125        assert!(!is_env_var_safe("SSH_AUTH_SOCK"));
126    }
127
128    #[test]
129    fn blocks_prefix_match() {
130        assert!(!is_env_var_safe("AWS_REGION"));
131        assert!(!is_env_var_safe("AZURE_SUBSCRIPTION_ID"));
132        assert!(!is_env_var_safe("GCP_PROJECT"));
133        assert!(!is_env_var_safe("GOOGLE_CLOUD_KEY"));
134    }
135
136    #[test]
137    fn blocks_keyword_match() {
138        assert!(!is_env_var_safe("MY_PASSWORD"));
139        assert!(!is_env_var_safe("API_SECRET"));
140        assert!(!is_env_var_safe("ACCESS_TOKEN"));
141        assert!(!is_env_var_safe("ENCRYPTION_KEY"));
142        assert!(!is_env_var_safe("CUSTOM_CREDENTIAL"));
143    }
144
145    #[test]
146    fn allows_safe_vars() {
147        assert!(is_env_var_safe("RUST_LOG"));
148        assert!(is_env_var_safe("CARGO_HOME"));
149        assert!(is_env_var_safe("LANG"));
150        assert!(is_env_var_safe("TERM"));
151        assert!(is_env_var_safe("BOB_MODEL"));
152    }
153
154    #[test]
155    fn case_insensitive() {
156        assert!(!is_env_var_safe("github_token"));
157        assert!(!is_env_var_safe("Database_Url"));
158        assert!(!is_env_var_safe("aws_secret_access_key"));
159    }
160
161    // NOTE: std::env::set_var/remove_var require unsafe blocks in Rust 2024 edition.
162    #[test]
163    fn load_safe_env_vars_filters() {
164        // SAFETY: These tests run serially and use unique variable names
165        // that do not conflict with other threads.
166        unsafe {
167            std::env::set_var("BOB_TEST_SAFE_VAR", "safe_value");
168            std::env::set_var("BOB_TEST_PASSWORD", "secret_value");
169        }
170
171        let vars = load_safe_env_vars();
172        assert!(vars.contains_key("BOB_TEST_SAFE_VAR"));
173        assert!(!vars.contains_key("BOB_TEST_PASSWORD"));
174
175        // SAFETY: Removing test-only variables.
176        unsafe {
177            std::env::remove_var("BOB_TEST_SAFE_VAR");
178            std::env::remove_var("BOB_TEST_PASSWORD");
179        }
180    }
181
182    #[test]
183    fn load_env_var_safe_blocks_sensitive() {
184        // SAFETY: Test-only variable with unique name.
185        unsafe { std::env::set_var("MY_API_TOKEN", "secret") };
186        assert!(load_env_var_safe("MY_API_TOKEN").is_none());
187        // SAFETY: Cleaning up test-only variable.
188        unsafe { std::env::remove_var("MY_API_TOKEN") };
189    }
190
191    #[test]
192    fn load_env_var_safe_returns_safe() {
193        // SAFETY: Test-only variable with unique name.
194        unsafe { std::env::set_var("BOB_TEST_LOADED", "value") };
195        assert_eq!(load_env_var_safe("BOB_TEST_LOADED"), Some("value".to_string()));
196        // SAFETY: Cleaning up test-only variable.
197        unsafe { std::env::remove_var("BOB_TEST_LOADED") };
198    }
199}