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}