Skip to main content

edgecrab_plugins/
guard.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::types::TrustLevel;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8pub enum Severity {
9    Low,
10    Medium,
11    High,
12    Critical,
13}
14
15impl Severity {
16    fn weight(self) -> u8 {
17        match self {
18            Self::Low => 1,
19            Self::Medium => 2,
20            Self::High => 3,
21            Self::Critical => 4,
22        }
23    }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub enum ThreatCategory {
28    Exfiltration,
29    Injection,
30    Destructive,
31    Persistence,
32    Network,
33    Obfuscation,
34    Execution,
35    Traversal,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ScanFinding {
40    pub pattern_id: String,
41    pub severity: Severity,
42    pub category: ThreatCategory,
43    pub file: PathBuf,
44    pub line: usize,
45    pub excerpt: String,
46    pub description: String,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum ScanVerdict {
51    Safe,
52    Caution,
53    Dangerous,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ScanResult {
58    pub plugin_name: String,
59    pub source: String,
60    pub trust_level: TrustLevel,
61    pub verdict: ScanVerdict,
62    pub findings: Vec<ScanFinding>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct VerdictResult {
67    pub allowed: bool,
68    pub forced: bool,
69}
70
71struct ThreatPattern {
72    needle: &'static str,
73    pattern_id: &'static str,
74    severity: Severity,
75    category: ThreatCategory,
76    description: &'static str,
77}
78
79const THREAT_PATTERNS: &[ThreatPattern] = &[
80    ThreatPattern {
81        needle: "~/.edgecrab/.env",
82        pattern_id: "edgecrab_env_access",
83        severity: Severity::Critical,
84        category: ThreatCategory::Exfiltration,
85        description: "references the EdgeCrab environment file",
86    },
87    ThreatPattern {
88        needle: "os.getenv(",
89        pattern_id: "python_getenv_secret",
90        severity: Severity::High,
91        category: ThreatCategory::Exfiltration,
92        description: "reads process environment variables",
93    },
94    ThreatPattern {
95        needle: "process.env",
96        pattern_id: "node_process_env",
97        severity: Severity::High,
98        category: ThreatCategory::Exfiltration,
99        description: "reads Node.js environment variables",
100    },
101    ThreatPattern {
102        needle: "ignore previous instructions",
103        pattern_id: "prompt_injection_ignore",
104        severity: Severity::Critical,
105        category: ThreatCategory::Injection,
106        description: "contains a prompt-injection override",
107    },
108    ThreatPattern {
109        needle: "system prompt override",
110        pattern_id: "sys_prompt_override",
111        severity: Severity::Critical,
112        category: ThreatCategory::Injection,
113        description: "attempts to override the system prompt",
114    },
115    ThreatPattern {
116        needle: "rm -rf /",
117        pattern_id: "destructive_root_rm",
118        severity: Severity::Critical,
119        category: ThreatCategory::Destructive,
120        description: "contains a destructive root deletion command",
121    },
122    ThreatPattern {
123        needle: "chmod 777",
124        pattern_id: "insecure_perms",
125        severity: Severity::Medium,
126        category: ThreatCategory::Destructive,
127        description: "sets world-writable permissions",
128    },
129    ThreatPattern {
130        needle: "crontab",
131        pattern_id: "persistence_cron",
132        severity: Severity::Medium,
133        category: ThreatCategory::Persistence,
134        description: "installs cron persistence",
135    },
136    ThreatPattern {
137        needle: "launchctl load",
138        pattern_id: "macos_launchd",
139        severity: Severity::Medium,
140        category: ThreatCategory::Persistence,
141        description: "loads a launchd persistence job",
142    },
143    ThreatPattern {
144        needle: "socket.connect((",
145        pattern_id: "python_socket_connect",
146        severity: Severity::High,
147        category: ThreatCategory::Network,
148        description: "opens an outbound socket connection",
149    },
150    ThreatPattern {
151        needle: "curl | bash",
152        pattern_id: "curl_pipe_shell",
153        severity: Severity::Critical,
154        category: ThreatCategory::Obfuscation,
155        description: "pipes remote content directly into a shell",
156    },
157    ThreatPattern {
158        needle: "base64 -d |",
159        pattern_id: "base64_decode_pipe",
160        severity: Severity::High,
161        category: ThreatCategory::Obfuscation,
162        description: "decodes base64 into execution",
163    },
164    ThreatPattern {
165        needle: "subprocess.run(",
166        pattern_id: "python_subprocess",
167        severity: Severity::Medium,
168        category: ThreatCategory::Execution,
169        description: "spawns a subprocess from Python",
170    },
171    ThreatPattern {
172        needle: "child_process.exec(",
173        pattern_id: "node_child_process",
174        severity: Severity::High,
175        category: ThreatCategory::Execution,
176        description: "spawns a subprocess from Node.js",
177    },
178    ThreatPattern {
179        needle: "../..",
180        pattern_id: "path_traversal",
181        severity: Severity::Medium,
182        category: ThreatCategory::Traversal,
183        description: "contains a path traversal sequence",
184    },
185];
186
187pub fn scan_plugin_bundle(
188    plugin_dir: &Path,
189    plugin_name: &str,
190    source: &str,
191    trust_level: TrustLevel,
192) -> std::io::Result<ScanResult> {
193    let mut findings = Vec::new();
194    scan_dir(plugin_dir, plugin_dir, &mut findings)?;
195    let verdict = findings
196        .iter()
197        .map(|finding| finding.severity.weight())
198        .max()
199        .map(|max| {
200            if max >= Severity::High.weight() {
201                ScanVerdict::Dangerous
202            } else {
203                ScanVerdict::Caution
204            }
205        })
206        .unwrap_or(ScanVerdict::Safe);
207    Ok(ScanResult {
208        plugin_name: plugin_name.to_string(),
209        source: source.to_string(),
210        trust_level,
211        verdict,
212        findings,
213    })
214}
215
216fn scan_dir(root: &Path, dir: &Path, findings: &mut Vec<ScanFinding>) -> std::io::Result<()> {
217    for entry in std::fs::read_dir(dir)? {
218        let entry = entry?;
219        let path = entry.path();
220        if path.is_dir() {
221            scan_dir(root, &path, findings)?;
222            continue;
223        }
224        if !is_scan_candidate(&path) {
225            continue;
226        }
227        let content = match std::fs::read_to_string(&path) {
228            Ok(content) => content,
229            Err(_) => continue,
230        };
231        let relative = path
232            .strip_prefix(root)
233            .map(PathBuf::from)
234            .unwrap_or_else(|_| path.clone());
235        for (line_idx, line) in content.lines().enumerate() {
236            let lowered = line.to_ascii_lowercase();
237            for pattern in THREAT_PATTERNS {
238                if lowered.contains(&pattern.needle.to_ascii_lowercase()) {
239                    findings.push(ScanFinding {
240                        pattern_id: pattern.pattern_id.to_string(),
241                        severity: pattern.severity,
242                        category: pattern.category,
243                        file: relative.clone(),
244                        line: line_idx + 1,
245                        excerpt: line.trim().to_string(),
246                        description: pattern.description.to_string(),
247                    });
248                }
249            }
250        }
251    }
252    Ok(())
253}
254
255fn is_scan_candidate(path: &Path) -> bool {
256    matches!(
257        path.extension().and_then(|ext| ext.to_str()),
258        Some("md" | "txt" | "toml" | "json" | "yaml" | "yml" | "py" | "js" | "ts" | "rhai" | "sh")
259    )
260}
261
262pub fn should_allow_install(
263    trust_level: TrustLevel,
264    scan: &ScanResult,
265    allow_caution: bool,
266    force: bool,
267) -> VerdictResult {
268    match scan.verdict {
269        ScanVerdict::Safe => VerdictResult {
270            allowed: true,
271            forced: false,
272        },
273        ScanVerdict::Caution => {
274            let allowed = force || allow_caution || matches!(trust_level, TrustLevel::Official);
275            VerdictResult {
276                allowed,
277                forced: allowed && scan.verdict != ScanVerdict::Safe,
278            }
279        }
280        ScanVerdict::Dangerous => VerdictResult {
281            allowed: force && matches!(trust_level, TrustLevel::Official | TrustLevel::Trusted),
282            forced: force,
283        },
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn scanner_marks_dangerous_when_critical_pattern_found() {
293        let temp = tempfile::tempdir().expect("tempdir");
294        std::fs::write(
295            temp.path().join("plugin.py"),
296            "print('x')\nwith open('~/.edgecrab/.env') as fh:\n    print(fh.read())\n",
297        )
298        .expect("write plugin");
299
300        let result = scan_plugin_bundle(temp.path(), "demo", "local", TrustLevel::Unverified)
301            .expect("scan succeeds");
302        assert_eq!(result.verdict, ScanVerdict::Dangerous);
303        assert_eq!(result.findings[0].pattern_id, "edgecrab_env_access");
304    }
305
306    #[test]
307    fn caution_result_can_be_forced() {
308        let scan = ScanResult {
309            plugin_name: "demo".into(),
310            source: "local".into(),
311            trust_level: TrustLevel::Community,
312            verdict: ScanVerdict::Caution,
313            findings: Vec::new(),
314        };
315        assert!(should_allow_install(TrustLevel::Community, &scan, false, true).allowed);
316    }
317}