Skip to main content

agentshield/adapter/
crewai.rs

1use std::path::{Path, PathBuf};
2
3use crate::analysis::cross_file::apply_cross_file_sanitization;
4use crate::config::ScanPathFilter;
5use crate::error::Result;
6use crate::ir::taint_builder::build_data_surface;
7use crate::ir::*;
8use crate::parser;
9
10/// CrewAI framework adapter.
11///
12/// Detects CrewAI projects by looking for:
13/// - `pyproject.toml` with `crewai` dependency or `[tool.crewai]` section
14/// - `requirements.txt` containing `crewai`
15/// - Python files importing `from crewai` or `from crewai_tools`
16pub struct CrewAiAdapter;
17
18impl super::Adapter for CrewAiAdapter {
19    fn framework(&self) -> Framework {
20        Framework::CrewAi
21    }
22
23    fn detect(&self, root: &Path) -> bool {
24        // Check pyproject.toml for crewai dependency or [tool.crewai] section
25        let pyproject = root.join("pyproject.toml");
26        if pyproject.exists() {
27            if let Ok(content) = std::fs::read_to_string(&pyproject) {
28                if content.contains("crewai") {
29                    return true;
30                }
31            }
32        }
33
34        // Check requirements.txt for crewai
35        let requirements = root.join("requirements.txt");
36        if requirements.exists() {
37            if let Ok(content) = std::fs::read_to_string(&requirements) {
38                if content.lines().any(|l| {
39                    let trimmed = l.trim();
40                    trimmed == "crewai"
41                        || trimmed.starts_with("crewai==")
42                        || trimmed.starts_with("crewai>=")
43                        || trimmed.starts_with("crewai[")
44                        || trimmed == "crewai-tools"
45                        || trimmed.starts_with("crewai-tools==")
46                        || trimmed.starts_with("crewai-tools>=")
47                }) {
48                    return true;
49                }
50            }
51        }
52
53        // Check Python files for crewai imports (only top-level, not recursive)
54        if let Ok(entries) = std::fs::read_dir(root) {
55            for entry in entries.flatten() {
56                let path = entry.path();
57                if path.extension().is_some_and(|e| e == "py") {
58                    if let Ok(content) = std::fs::read_to_string(&path) {
59                        if content.contains("from crewai")
60                            || content.contains("import crewai")
61                            || content.contains("from crewai_tools")
62                            || content.contains("import crewai_tools")
63                        {
64                            return true;
65                        }
66                    }
67                }
68            }
69        }
70
71        // Also check src/ directory for imports (common CrewAI layout)
72        let src_dir = root.join("src");
73        if src_dir.is_dir() {
74            if let Ok(entries) = std::fs::read_dir(&src_dir) {
75                for entry in entries.flatten() {
76                    let path = entry.path();
77                    if path.extension().is_some_and(|e| e == "py") {
78                        if let Ok(content) = std::fs::read_to_string(&path) {
79                            if content.contains("from crewai")
80                                || content.contains("import crewai")
81                                || content.contains("from crewai_tools")
82                                || content.contains("import crewai_tools")
83                            {
84                                return true;
85                            }
86                        }
87                    }
88                }
89            }
90        }
91
92        false
93    }
94
95    fn load(&self, root: &Path, ignore_tests: bool) -> Result<Vec<ScanTarget>> {
96        let filter = ScanPathFilter::for_ignore_tests(ignore_tests);
97        self.load_with_filter(root, &filter)
98    }
99
100    fn load_with_filter(&self, root: &Path, filter: &ScanPathFilter) -> Result<Vec<ScanTarget>> {
101        let name = root
102            .file_name()
103            .map(|n| n.to_string_lossy().to_string())
104            .unwrap_or_else(|| "crewai-project".into());
105
106        let mut source_files = Vec::new();
107        let mut execution = execution_surface::ExecutionSurface::default();
108
109        // Phase 0: Collect source files (reuses MCP adapter's walker)
110        super::mcp::collect_source_files_with_filter(root, filter, &mut source_files)?;
111
112        // Filter to Python-only (CrewAI is a Python framework)
113        source_files.retain(|sf| matches!(sf.language, Language::Python));
114
115        // Phase 1: Parse each Python file
116        let mut parsed_files: Vec<(PathBuf, parser::ParsedFile)> = Vec::new();
117        for sf in &source_files {
118            if let Some(parser) = parser::parser_for_language(sf.language) {
119                if let Ok(parsed) = parser.parse_file(&sf.path, &sf.content) {
120                    parsed_files.push((sf.path.clone(), parsed));
121                }
122            }
123        }
124
125        // Phase 2: Cross-file sanitizer-aware analysis
126        apply_cross_file_sanitization(&mut parsed_files);
127
128        // Phase 3: Merge into execution surface
129        for (_, parsed) in parsed_files {
130            execution.commands.extend(parsed.commands);
131            execution.file_operations.extend(parsed.file_operations);
132            execution
133                .network_operations
134                .extend(parsed.network_operations);
135            execution.env_accesses.extend(parsed.env_accesses);
136            execution.dynamic_exec.extend(parsed.dynamic_exec);
137        }
138
139        // Parse dependencies from pyproject.toml / requirements.txt
140        let dependencies = super::mcp::parse_dependencies(root, filter);
141
142        // Parse provenance from pyproject.toml
143        let provenance = super::mcp::parse_provenance(root, filter);
144
145        let tools = vec![];
146        let data = build_data_surface(&tools, &execution);
147
148        Ok(vec![ScanTarget {
149            name,
150            framework: Framework::CrewAi,
151            root_path: root.to_path_buf(),
152            tools,
153            execution,
154            data,
155            dependencies,
156            provenance,
157            source_files,
158        }])
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::adapter::Adapter;
166
167    #[test]
168    fn test_detect_crewai_via_pyproject() {
169        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
170        let adapter = CrewAiAdapter;
171        assert!(adapter.detect(&dir));
172    }
173
174    #[test]
175    fn test_detect_non_crewai_project() {
176        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
177            .join("tests/fixtures/mcp_servers/safe_calculator");
178        let adapter = CrewAiAdapter;
179        assert!(!adapter.detect(&dir));
180    }
181
182    #[test]
183    fn test_load_crewai_finds_cmd_injection() {
184        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
185        let adapter = CrewAiAdapter;
186        let targets = adapter.load(&dir, false).unwrap();
187        assert_eq!(targets.len(), 1);
188
189        let target = &targets[0];
190        assert_eq!(target.framework, Framework::CrewAi);
191        assert_eq!(target.name, "crewai_project");
192
193        // Should find command injection in vuln_tool.py
194        assert!(
195            !target.execution.commands.is_empty(),
196            "expected command execution findings from vuln_tool.py"
197        );
198        // Should find tainted command args
199        assert!(
200            target
201                .execution
202                .commands
203                .iter()
204                .any(|c| c.command_arg.is_tainted()),
205            "expected tainted command source from subprocess.run with user input"
206        );
207    }
208
209    #[test]
210    fn test_load_crewai_finds_ssrf() {
211        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
212        let adapter = CrewAiAdapter;
213        let targets = adapter.load(&dir, false).unwrap();
214        let target = &targets[0];
215
216        // Should find network operations in fetch_tool.py
217        assert!(
218            !target.execution.network_operations.is_empty(),
219            "expected network operation findings from fetch_tool.py"
220        );
221    }
222
223    #[test]
224    fn test_load_crewai_parses_dependencies() {
225        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
226        let adapter = CrewAiAdapter;
227        let targets = adapter.load(&dir, false).unwrap();
228        let target = &targets[0];
229
230        assert!(
231            target
232                .dependencies
233                .dependencies
234                .iter()
235                .any(|d| d.name == "crewai"),
236            "expected crewai in dependencies"
237        );
238    }
239
240    #[test]
241    fn test_load_crewai_only_python_files() {
242        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/crewai_project");
243        let adapter = CrewAiAdapter;
244        let targets = adapter.load(&dir, false).unwrap();
245        let target = &targets[0];
246
247        // All source files should be Python
248        for sf in &target.source_files {
249            assert_eq!(
250                sf.language,
251                Language::Python,
252                "non-Python file found: {:?}",
253                sf.path
254            );
255        }
256    }
257}