1use super::config::RceConfig;
11use super::inspector::{AirlockViolation, RiskLevel, ViolationType};
12use regex::Regex;
13use std::sync::OnceLock;
14use tracing::debug;
15
16struct CompiledPattern {
18 regex: Regex,
19 name: &'static str,
20 risk_score: u8,
21 description: &'static str,
22}
23
24fn get_builtin_patterns() -> &'static [CompiledPattern] {
26 static PATTERNS: OnceLock<Vec<CompiledPattern>> = OnceLock::new();
27
28 PATTERNS.get_or_init(|| {
29 vec![
30 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 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 CompiledPattern {
85 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 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 regex: Regex::new(r#"\$\([^)]+\)"#).unwrap(),
101 name: "command_substitution",
102 risk_score: 80,
103 description: "Shell command substitution $() detected",
104 },
105 CompiledPattern {
106 regex: Regex::new(r#"`[^`]+`"#).unwrap(),
108 name: "backtick_substitution",
109 risk_score: 80,
110 description: "Shell backtick command substitution detected",
111 },
112 CompiledPattern {
113 regex: Regex::new(r#"\|\s*\w+"#).unwrap(),
115 name: "shell_pipe",
116 risk_score: 65,
117 description: "Shell pipe detected",
118 },
119 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 CompiledPattern {
151 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 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 CompiledPattern {
168 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 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 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 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
237pub struct RcePatternMatcher {
239 target_tools: Vec<String>,
240 custom_patterns: Vec<(Regex, String)>,
241}
242
243impl RcePatternMatcher {
244 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 fn should_inspect(&self, tool_name: &str) -> bool {
260 self.target_tools.iter().any(|t| t == tool_name)
261 }
262
263 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 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 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 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, 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 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 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 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}