Skip to main content

fd_policy/airlock/
patterns.rs

1//! Anti-RCE pattern matching
2//!
3//! Detects potentially dangerous code patterns in tool call payloads:
4//! - eval()/exec() calls
5//! - Base64 obfuscation patterns
6//! - Shell injection (pipes, redirects, command substitution)
7//! - Python injection (__import__, subprocess, os.system)
8//! - Path traversal
9
10use super::config::RceConfig;
11use super::inspector::{AirlockViolation, RiskLevel, ViolationType};
12use regex::Regex;
13use std::sync::OnceLock;
14use tracing::debug;
15
16/// Compiled pattern with metadata
17struct CompiledPattern {
18    regex: Regex,
19    name: &'static str,
20    risk_score: u8,
21    description: &'static str,
22}
23
24/// Get built-in dangerous patterns (compiled once)
25fn get_builtin_patterns() -> &'static [CompiledPattern] {
26    static PATTERNS: OnceLock<Vec<CompiledPattern>> = OnceLock::new();
27
28    PATTERNS.get_or_init(|| {
29        vec![
30            // =================================================================
31            // Python eval/exec patterns (Critical - 90+ risk)
32            // =================================================================
33            CompiledPattern {
34                regex: Regex::new(r#"(?i)\beval\s*\("#).unwrap(),
35                name: "python_eval",
36                risk_score: 90,
37                description: "Python eval() function detected - allows arbitrary code execution",
38            },
39            CompiledPattern {
40                regex: Regex::new(r#"(?i)\bexec\s*\("#).unwrap(),
41                name: "python_exec",
42                risk_score: 90,
43                description: "Python exec() function detected - allows arbitrary code execution",
44            },
45            CompiledPattern {
46                regex: Regex::new(
47                    r#"(?i)\bcompile\s*\([^)]*,\s*['"][^'"]*['"],\s*['"]exec['"]\s*\)"#,
48                )
49                .unwrap(),
50                name: "python_compile_exec",
51                risk_score: 90,
52                description: "Python compile() with exec mode detected",
53            },
54            // =================================================================
55            // Base64 obfuscation patterns (Critical - 95 risk)
56            // =================================================================
57            CompiledPattern {
58                regex: Regex::new(
59                    r#"(?i)base64\s*[\.\[].*\b(decode|b64decode)\b.*\b(eval|exec)\b"#,
60                )
61                .unwrap(),
62                name: "base64_eval_combo",
63                risk_score: 95,
64                description: "Base64 decode + eval/exec pattern (obfuscation attempt)",
65            },
66            CompiledPattern {
67                regex: Regex::new(
68                    r#"(?i)\b(eval|exec)\b.*base64\s*[\.\[].*\b(decode|b64decode)\b"#,
69                )
70                .unwrap(),
71                name: "eval_base64_combo",
72                risk_score: 95,
73                description: "Eval/exec + base64 decode pattern (obfuscation attempt)",
74            },
75            CompiledPattern {
76                regex: Regex::new(r#"(?i)atob\s*\([^)]+\)\s*\)"#).unwrap(),
77                name: "js_atob_eval",
78                risk_score: 85,
79                description: "JavaScript atob (base64 decode) detected",
80            },
81            // =================================================================
82            // Shell injection patterns (High - 70-85 risk)
83            // =================================================================
84            CompiledPattern {
85                // Matches: ; command, && command, || command
86                regex: Regex::new(r#"[;&|]{1,2}\s*\w+\s"#).unwrap(),
87                name: "shell_chaining",
88                risk_score: 75,
89                description: "Shell command chaining detected (;, &&, ||)",
90            },
91            CompiledPattern {
92                // Matches: > /path or >> /path (file redirect)
93                regex: Regex::new(r#">{1,2}\s*/[a-zA-Z]"#).unwrap(),
94                name: "file_redirect",
95                risk_score: 70,
96                description: "Shell file redirect to absolute path detected",
97            },
98            CompiledPattern {
99                // Matches: $(command) substitution
100                regex: Regex::new(r#"\$\([^)]+\)"#).unwrap(),
101                name: "command_substitution",
102                risk_score: 80,
103                description: "Shell command substitution $() detected",
104            },
105            CompiledPattern {
106                // Matches: `command` backtick substitution
107                regex: Regex::new(r#"`[^`]+`"#).unwrap(),
108                name: "backtick_substitution",
109                risk_score: 80,
110                description: "Shell backtick command substitution detected",
111            },
112            CompiledPattern {
113                // Matches: | pipe
114                regex: Regex::new(r#"\|\s*\w+"#).unwrap(),
115                name: "shell_pipe",
116                risk_score: 65,
117                description: "Shell pipe detected",
118            },
119            // =================================================================
120            // Python injection patterns (High - 75-85 risk)
121            // =================================================================
122            CompiledPattern {
123                regex: Regex::new(r#"(?i)__import__\s*\("#).unwrap(),
124                name: "python_import_injection",
125                risk_score: 85,
126                description: "Python __import__ injection pattern detected",
127            },
128            CompiledPattern {
129                regex: Regex::new(r#"(?i)subprocess\s*\.\s*(call|run|Popen|check_output)"#)
130                    .unwrap(),
131                name: "subprocess_call",
132                risk_score: 80,
133                description: "Python subprocess execution detected",
134            },
135            CompiledPattern {
136                regex: Regex::new(r#"(?i)os\s*\.\s*(system|popen|exec[lv]?[pe]?)"#).unwrap(),
137                name: "os_exec",
138                risk_score: 85,
139                description: "Python os module shell execution detected",
140            },
141            CompiledPattern {
142                regex: Regex::new(r#"(?i)commands\s*\.\s*(getoutput|getstatusoutput)"#).unwrap(),
143                name: "commands_module",
144                risk_score: 75,
145                description: "Python commands module (deprecated shell execution) detected",
146            },
147            // =================================================================
148            // Path traversal patterns (High - 80 risk)
149            // =================================================================
150            CompiledPattern {
151                // Matches: ../, ..\, ..%2f, ..%2F
152                regex: Regex::new(r#"\.\./|\.\.\\|\.\.\%2[fF]"#).unwrap(),
153                name: "path_traversal",
154                risk_score: 80,
155                description: "Path traversal pattern detected (../)",
156            },
157            CompiledPattern {
158                // Matches paths starting with /etc, /var, /root, /home
159                regex: Regex::new(r#"(?i)['"](/etc/|/var/|/root/|/home/|/proc/|/sys/)"#).unwrap(),
160                name: "sensitive_path_access",
161                risk_score: 70,
162                description: "Access to sensitive system path detected",
163            },
164            // =================================================================
165            // Environment variable exfiltration (Medium - 50-60 risk)
166            // =================================================================
167            CompiledPattern {
168                // Matches: $VAR, ${VAR}, %VAR%
169                regex: Regex::new(r#"\$\{?(API_KEY|SECRET|PASSWORD|TOKEN|PRIVATE_KEY|AWS_)\w*\}?"#)
170                    .unwrap(),
171                name: "sensitive_env_var",
172                risk_score: 70,
173                description: "Access to sensitive environment variable detected",
174            },
175            CompiledPattern {
176                regex: Regex::new(r#"(?i)os\s*\.\s*(environ|getenv)\s*\["#).unwrap(),
177                name: "env_access",
178                risk_score: 50,
179                description: "Environment variable access detected",
180            },
181            // =================================================================
182            // Network/Socket patterns (Medium-High - 60-75 risk)
183            // =================================================================
184            CompiledPattern {
185                regex: Regex::new(r#"(?i)socket\s*\.\s*(socket|connect|bind|listen)"#).unwrap(),
186                name: "raw_socket",
187                risk_score: 75,
188                description: "Raw socket operation detected",
189            },
190            CompiledPattern {
191                regex: Regex::new(r#"(?i)(urllib|requests|httplib|http\.client)\s*\.\s*\w+"#)
192                    .unwrap(),
193                name: "network_library",
194                risk_score: 50,
195                description: "Network library usage detected",
196            },
197            // =================================================================
198            // Code injection vectors (High - 75-85 risk)
199            // =================================================================
200            CompiledPattern {
201                regex: Regex::new(r#"(?i)<\s*script[^>]*>"#).unwrap(),
202                name: "script_tag",
203                risk_score: 75,
204                description: "HTML script tag detected (potential XSS)",
205            },
206            CompiledPattern {
207                regex: Regex::new(r#"(?i)javascript\s*:"#).unwrap(),
208                name: "javascript_url",
209                risk_score: 70,
210                description: "JavaScript URL protocol detected",
211            },
212            CompiledPattern {
213                regex: Regex::new(r#"(?i)data\s*:\s*text/html"#).unwrap(),
214                name: "data_url_html",
215                risk_score: 65,
216                description: "Data URL with HTML content detected",
217            },
218            // =================================================================
219            // Template injection (Medium-High - 65-75 risk)
220            // =================================================================
221            CompiledPattern {
222                regex: Regex::new(r#"\{\{[^}]*\}\}"#).unwrap(),
223                name: "template_injection",
224                risk_score: 65,
225                description: "Template injection pattern {{ }} detected",
226            },
227            CompiledPattern {
228                regex: Regex::new(r#"\$\{[^}]*\}"#).unwrap(),
229                name: "string_interpolation",
230                risk_score: 55,
231                description: "String interpolation pattern ${ } detected",
232            },
233        ]
234    })
235}
236
237/// RCE pattern matcher
238pub struct RcePatternMatcher {
239    target_tools: Vec<String>,
240    custom_patterns: Vec<(Regex, String)>,
241}
242
243impl RcePatternMatcher {
244    /// Create a new pattern matcher from config
245    pub fn new(config: &RceConfig) -> Self {
246        let custom_patterns = config
247            .custom_patterns
248            .iter()
249            .filter_map(|p| Regex::new(p).ok().map(|r| (r, p.clone())))
250            .collect();
251
252        Self {
253            target_tools: config.target_tools.clone(),
254            custom_patterns,
255        }
256    }
257
258    /// Check if this tool should be inspected
259    fn should_inspect(&self, tool_name: &str) -> bool {
260        self.target_tools.iter().any(|t| t == tool_name)
261    }
262
263    /// Extract all text content from JSON for pattern matching
264    fn extract_text_content(value: &serde_json::Value) -> String {
265        match value {
266            serde_json::Value::String(s) => s.clone(),
267            serde_json::Value::Array(arr) => arr
268                .iter()
269                .map(Self::extract_text_content)
270                .collect::<Vec<_>>()
271                .join("\n"),
272            serde_json::Value::Object(obj) => obj
273                .values()
274                .map(Self::extract_text_content)
275                .collect::<Vec<_>>()
276                .join("\n"),
277            _ => String::new(),
278        }
279    }
280
281    /// Check tool input for RCE patterns
282    pub fn check(
283        &self,
284        tool_name: &str,
285        tool_input: &serde_json::Value,
286    ) -> Option<AirlockViolation> {
287        if !self.should_inspect(tool_name) {
288            return None;
289        }
290
291        let text = Self::extract_text_content(tool_input);
292        if text.is_empty() {
293            return None;
294        }
295
296        // Check built-in patterns
297        for pattern in get_builtin_patterns() {
298            if pattern.regex.is_match(&text) {
299                debug!(
300                    tool = tool_name,
301                    pattern = pattern.name,
302                    "RCE pattern detected"
303                );
304
305                return Some(AirlockViolation {
306                    violation_type: ViolationType::RcePattern,
307                    risk_score: pattern.risk_score,
308                    risk_level: RiskLevel::from_score(pattern.risk_score),
309                    details: pattern.description.to_string(),
310                    trigger: pattern.name.to_string(),
311                });
312            }
313        }
314
315        // Check custom patterns
316        for (regex, pattern_str) in &self.custom_patterns {
317            if regex.is_match(&text) {
318                debug!(
319                    tool = tool_name,
320                    pattern = pattern_str,
321                    "Custom RCE pattern detected"
322                );
323
324                return Some(AirlockViolation {
325                    violation_type: ViolationType::RcePattern,
326                    risk_score: 80, // Default score for custom patterns
327                    risk_level: RiskLevel::High,
328                    details: format!("Custom pattern match: {}", pattern_str),
329                    trigger: format!("custom:{}", pattern_str),
330                });
331            }
332        }
333
334        None
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn create_matcher() -> RcePatternMatcher {
343        RcePatternMatcher::new(&RceConfig::default())
344    }
345
346    #[test]
347    fn test_eval_detection() {
348        let matcher = create_matcher();
349        let input = serde_json::json!({
350            "content": "result = eval(user_input)"
351        });
352
353        let result = matcher.check("write_file", &input);
354        assert!(result.is_some());
355
356        let violation = result.unwrap();
357        assert_eq!(violation.violation_type, ViolationType::RcePattern);
358        assert!(violation.risk_score >= 90);
359        assert!(violation.trigger.contains("eval"));
360    }
361
362    #[test]
363    fn test_exec_detection() {
364        let matcher = create_matcher();
365        let input = serde_json::json!({
366            "code": "exec(compile(source, '<string>', 'exec'))"
367        });
368
369        let result = matcher.check("python_repl", &input);
370        assert!(result.is_some());
371    }
372
373    #[test]
374    fn test_base64_eval_combo() {
375        let matcher = create_matcher();
376        // This pattern has base64.decode before eval, matching base64_eval_combo
377        let input = serde_json::json!({
378            "script": "exec(base64.b64decode(encoded_payload).decode())"
379        });
380
381        let result = matcher.check("bash", &input);
382        assert!(result.is_some());
383
384        let violation = result.unwrap();
385        // Either base64+eval combo (95) or exec pattern (90) is valid
386        assert!(violation.risk_score >= 90);
387        assert!(
388            violation.trigger.contains("eval")
389                || violation.trigger.contains("exec")
390                || violation.trigger.contains("base64")
391        );
392    }
393
394    #[test]
395    fn test_command_substitution() {
396        let matcher = create_matcher();
397        let input = serde_json::json!({
398            "command": "echo $(cat /etc/passwd)"
399        });
400
401        let result = matcher.check("bash", &input);
402        assert!(result.is_some());
403    }
404
405    #[test]
406    fn test_path_traversal() {
407        let matcher = create_matcher();
408        let input = serde_json::json!({
409            "path": "../../../etc/passwd"
410        });
411
412        let result = matcher.check("write_file", &input);
413        assert!(result.is_some());
414
415        let violation = result.unwrap();
416        assert!(violation.trigger.contains("path_traversal"));
417    }
418
419    #[test]
420    fn test_subprocess_detection() {
421        let matcher = create_matcher();
422        let input = serde_json::json!({
423            "code": "subprocess.run(['rm', '-rf', '/'])"
424        });
425
426        let result = matcher.check("python_repl", &input);
427        assert!(result.is_some());
428    }
429
430    #[test]
431    fn test_os_system_detection() {
432        let matcher = create_matcher();
433        let input = serde_json::json!({
434            "script": "os.system('whoami')"
435        });
436
437        let result = matcher.check("execute_command", &input);
438        assert!(result.is_some());
439    }
440
441    #[test]
442    fn test_safe_content_allowed() {
443        let matcher = create_matcher();
444        let input = serde_json::json!({
445            "content": "def hello():\n    print('Hello, World!')\n\nhello()"
446        });
447
448        let result = matcher.check("write_file", &input);
449        assert!(result.is_none());
450    }
451
452    #[test]
453    fn test_non_target_tool_skipped() {
454        let matcher = create_matcher();
455        let input = serde_json::json!({
456            "query": "eval(dangerous_code)"
457        });
458
459        // read_file is not in target tools
460        let result = matcher.check("read_file", &input);
461        assert!(result.is_none());
462    }
463
464    #[test]
465    fn test_nested_json_extraction() {
466        let matcher = create_matcher();
467        let input = serde_json::json!({
468            "outer": {
469                "inner": {
470                    "content": "eval(payload)"
471                }
472            }
473        });
474
475        let result = matcher.check("write_file", &input);
476        assert!(result.is_some());
477    }
478
479    #[test]
480    fn test_array_content_extraction() {
481        let matcher = create_matcher();
482        let input = serde_json::json!({
483            "files": [
484                {"name": "safe.txt", "content": "hello"},
485                {"name": "dangerous.py", "content": "exec(code)"}
486            ]
487        });
488
489        let result = matcher.check("create_file", &input);
490        assert!(result.is_some());
491    }
492
493    #[test]
494    fn test_shell_pipe_detection() {
495        let matcher = create_matcher();
496        let input = serde_json::json!({
497            "command": "cat file.txt | grep password"
498        });
499
500        let result = matcher.check("bash", &input);
501        assert!(result.is_some());
502    }
503
504    #[test]
505    fn test_import_injection() {
506        let matcher = create_matcher();
507        let input = serde_json::json!({
508            "code": "__import__('os').system('id')"
509        });
510
511        let result = matcher.check("python_repl", &input);
512        assert!(result.is_some());
513    }
514}