Skip to main content

aprender_shell/
security.rs

1//! Security filtering for aprender-shell
2//!
3//! Follows Toyota Way principle *Andon* (Stop the line):
4//! Halt immediately when quality issues arise.
5//!
6//! Prevents sensitive commands (passwords, tokens, keys) from being
7//! suggested or learned into the model.
8
9/// Patterns that indicate sensitive commands.
10///
11/// These patterns match credentials, authentication commands,
12/// and environment variable exports with secrets.
13const SENSITIVE_PATTERNS: &[&str] = &[
14    // Credentials in arguments
15    "password=",
16    "passwd=",
17    "pwd=",
18    "secret=",
19    "token=",
20    "api_key=",
21    "apikey=",
22    // Environment variables (uppercase)
23    "AWS_SECRET",
24    "GITHUB_TOKEN",
25    "API_KEY",
26    "PRIVATE_KEY",
27    "ACCESS_TOKEN",
28    "AUTH_TOKEN",
29    "SECRET_KEY",
30    // Authentication commands
31    "curl -u",
32    "curl --user",
33    "wget --password",
34    // Key generation (might expose paths)
35    "ssh-keygen",
36    "gpg --gen-key",
37    // Database credentials
38    "mysql -p",
39    "psql -W",
40    "mongo --password",
41    // Cloud credentials
42    "aws configure",
43    "gcloud auth",
44    "az login",
45];
46
47/// Check if a command contains sensitive information.
48///
49/// Returns `true` if the command should be filtered out.
50///
51/// # Arguments
52/// * `cmd` - The command to check
53///
54/// # Example
55/// ```
56/// use aprender_shell::security::is_sensitive_command;
57///
58/// assert!(is_sensitive_command("export AWS_SECRET_ACCESS_KEY=abc123"));
59/// assert!(is_sensitive_command("curl -u admin:password123 https://api.example.com"));
60/// assert!(!is_sensitive_command("git status"));
61/// ```
62pub fn is_sensitive_command(cmd: &str) -> bool {
63    check_known_patterns(cmd)
64        || check_export_secrets(cmd)
65        || check_database_inline_password(cmd)
66        || check_inline_key_value(cmd)
67}
68
69/// Check against known sensitive patterns (credentials, auth commands, etc.)
70fn check_known_patterns(cmd: &str) -> bool {
71    let upper = cmd.to_uppercase();
72    SENSITIVE_PATTERNS
73        .iter()
74        .any(|pattern| upper.contains(&pattern.to_uppercase()))
75}
76
77/// Check for export commands with secret-like variable names.
78fn check_export_secrets(cmd: &str) -> bool {
79    let upper = cmd.to_uppercase();
80    if !upper.contains("EXPORT") || !upper.contains('=') {
81        return false;
82    }
83
84    const SECRET_KEYWORDS: &[&str] = &["SECRET", "TOKEN", "KEY", "PASSWORD", "CREDENTIAL", "AUTH"];
85    SECRET_KEYWORDS.iter().any(|kw| upper.contains(kw))
86}
87
88/// Check for database clients with inline passwords (e.g., mysql -pMyPassword).
89fn check_database_inline_password(cmd: &str) -> bool {
90    let lower = cmd.to_lowercase();
91    let is_db_command =
92        lower.contains("mysql") || lower.contains("psql") || lower.contains("mongo");
93
94    if !is_db_command {
95        return false;
96    }
97
98    // Check for -p followed directly by non-space characters
99    lower.find("-p").is_some_and(|pos| {
100        let after_p = &lower[pos + 2..];
101        !after_p.is_empty() && !after_p.starts_with(' ') && !after_p.starts_with('\t')
102    })
103}
104
105/// Check for inline key=value patterns with suspicious keys (password=, token=, etc.)
106fn check_inline_key_value(cmd: &str) -> bool {
107    const SUSPICIOUS_KEYS: &[&str] = &[
108        "password",
109        "passwd",
110        "secret",
111        "token",
112        "key",
113        "credential",
114        "auth",
115    ];
116    let lower = cmd.to_lowercase();
117
118    SUSPICIOUS_KEYS.iter().any(|key| {
119        let pattern = format!("{key}=");
120        let pattern_spaced = format!("{key} =");
121
122        if !lower.contains(&pattern) && !lower.contains(&pattern_spaced) {
123            return false;
124        }
125
126        // Check if there's an actual value after the =
127        lower.find(&pattern).is_some_and(|pos| {
128            let after_eq = &cmd[pos + pattern.len()..];
129            has_actual_value(after_eq)
130        })
131    })
132}
133
134/// Check if the string after `=` contains an actual value (not empty or just quotes).
135fn has_actual_value(after_eq: &str) -> bool {
136    let trimmed = after_eq.trim();
137    !trimmed.is_empty() && !trimmed.starts_with('-') && trimmed != "\"" && trimmed != "'"
138}
139
140/// Filter sensitive commands from a list.
141///
142/// Returns a new vector with sensitive commands removed.
143pub fn filter_sensitive_commands(commands: &[String]) -> Vec<String> {
144    commands
145        .iter()
146        .filter(|cmd| !is_sensitive_command(cmd))
147        .cloned()
148        .collect()
149}
150
151/// Filter sensitive suggestions before display.
152pub fn filter_sensitive_suggestions(suggestions: Vec<(String, f32)>) -> Vec<(String, f32)> {
153    suggestions
154        .into_iter()
155        .filter(|(cmd, _)| !is_sensitive_command(cmd))
156        .collect()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    // =========================================================================
164    // Credential Detection Tests
165    // =========================================================================
166
167    #[test]
168    fn test_detect_password_in_curl() {
169        assert!(is_sensitive_command(
170            "curl -u admin:password123 https://api.example.com"
171        ));
172        assert!(is_sensitive_command(
173            "curl --user admin:pass https://api.example.com"
174        ));
175    }
176
177    #[test]
178    fn test_detect_env_export() {
179        assert!(is_sensitive_command("export AWS_SECRET_ACCESS_KEY=abc123"));
180        assert!(is_sensitive_command("export GITHUB_TOKEN=ghp_xxxx"));
181        assert!(is_sensitive_command("export API_KEY=sk-xxxxx"));
182        assert!(is_sensitive_command("export AUTH_TOKEN=bearer_xxx"));
183    }
184
185    #[test]
186    fn test_detect_mysql_password() {
187        assert!(is_sensitive_command("mysql -u root -pMyPassword"));
188        assert!(is_sensitive_command("mysql -p'secret'"));
189    }
190
191    #[test]
192    fn test_detect_psql_password() {
193        assert!(is_sensitive_command("psql -W"));
194    }
195
196    #[test]
197    fn test_detect_inline_secrets() {
198        assert!(is_sensitive_command("password=hunter2"));
199        assert!(is_sensitive_command("secret=mysecret"));
200        assert!(is_sensitive_command("token=abc123"));
201        assert!(is_sensitive_command("api_key=sk-xxxx"));
202    }
203
204    #[test]
205    fn test_detect_cloud_auth() {
206        assert!(is_sensitive_command("aws configure"));
207        assert!(is_sensitive_command("gcloud auth login"));
208        assert!(is_sensitive_command("az login"));
209    }
210
211    #[test]
212    fn test_detect_key_generation() {
213        assert!(is_sensitive_command("ssh-keygen -t rsa"));
214        assert!(is_sensitive_command("gpg --gen-key"));
215    }
216
217    // =========================================================================
218    // Non-Sensitive Command Tests (False Positive Prevention)
219    // =========================================================================
220
221    #[test]
222    fn test_allow_normal_commands() {
223        assert!(!is_sensitive_command("git status"));
224        assert!(!is_sensitive_command("git commit -m 'message'"));
225        assert!(!is_sensitive_command("docker ps"));
226        assert!(!is_sensitive_command("cargo build --release"));
227        assert!(!is_sensitive_command("kubectl get pods"));
228        assert!(!is_sensitive_command("npm install"));
229    }
230
231    #[test]
232    fn test_allow_curl_without_auth() {
233        assert!(!is_sensitive_command("curl https://api.example.com"));
234        assert!(!is_sensitive_command(
235            "curl -X POST https://api.example.com"
236        ));
237        assert!(!is_sensitive_command(
238            "curl -H 'Content-Type: application/json' https://api.example.com"
239        ));
240    }
241
242    #[test]
243    fn test_allow_git_config() {
244        // These don't contain actual secrets
245        assert!(!is_sensitive_command("git config user.name"));
246        assert!(!is_sensitive_command("git config user.email"));
247    }
248
249    #[test]
250    fn test_allow_export_without_secrets() {
251        assert!(!is_sensitive_command("export PATH=/usr/bin:$PATH"));
252        assert!(!is_sensitive_command("export EDITOR=vim"));
253        assert!(!is_sensitive_command("export LANG=en_US.UTF-8"));
254    }
255
256    #[test]
257    fn test_case_insensitive() {
258        assert!(is_sensitive_command("PASSWORD=secret"));
259        assert!(is_sensitive_command("Token=abc123"));
260        assert!(is_sensitive_command("EXPORT github_token=xxx"));
261    }
262
263    // =========================================================================
264    // Filter Function Tests
265    // =========================================================================
266
267    #[test]
268    fn test_filter_sensitive_commands() {
269        let commands = vec![
270            "git status".to_string(),
271            "export SECRET=xxx".to_string(),
272            "cargo build".to_string(),
273            "curl -u user:pass http://localhost".to_string(),
274        ];
275
276        let filtered = filter_sensitive_commands(&commands);
277
278        assert_eq!(filtered.len(), 2);
279        assert!(filtered.contains(&"git status".to_string()));
280        assert!(filtered.contains(&"cargo build".to_string()));
281        assert!(!filtered.contains(&"export SECRET=xxx".to_string()));
282    }
283
284    #[test]
285    fn test_filter_sensitive_suggestions() {
286        let suggestions = vec![
287            ("git status".to_string(), 0.9),
288            ("export TOKEN=abc".to_string(), 0.8),
289            ("docker ps".to_string(), 0.7),
290        ];
291
292        let filtered = filter_sensitive_suggestions(suggestions);
293
294        assert_eq!(filtered.len(), 2);
295        assert_eq!(filtered[0].0, "git status");
296        assert_eq!(filtered[1].0, "docker ps");
297    }
298}