openclaw_scan/scanner/
dependencies.rs1use std::path::Path;
7
8use anyhow::Result;
9use chrono::{DateTime, Duration, Utc};
10use serde_json::Value;
11
12use crate::finding::{Category, Finding, Severity};
13use crate::scanner::{ScanContext, Scanner};
14
15pub struct DependenciesScanner;
16
17impl Scanner for DependenciesScanner {
18 fn name(&self) -> &'static str {
19 "dependencies"
20 }
21
22 fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
23 let mut findings = Vec::new();
24
25 let plugins_path = ctx.root.join("installed_plugins.json");
27 if plugins_path.exists() {
28 if let Ok(content) = std::fs::read_to_string(&plugins_path) {
29 check_plugins(&content, &plugins_path, &mut findings);
30 }
31 }
32
33 Ok(findings)
34 }
35}
36
37fn check_plugins(content: &str, path: &Path, findings: &mut Vec<Finding>) {
38 let Ok(json): Result<Value, _> = serde_json::from_str(content) else {
39 return;
40 };
41
42 let plugins = match &json {
43 Value::Array(arr) => arr.as_slice(),
44 Value::Object(obj) => {
45 for (_, plugin) in obj {
47 check_single_plugin(plugin, path, findings);
48 }
49 return;
50 }
51 _ => return,
52 };
53
54 for plugin in plugins {
55 check_single_plugin(plugin, path, findings);
56 }
57}
58
59fn check_single_plugin(plugin: &Value, path: &Path, findings: &mut Vec<Finding>) {
60 let name = plugin
61 .get("name")
62 .or_else(|| plugin.get("id"))
63 .and_then(Value::as_str)
64 .unwrap_or("<unknown>");
65
66 if is_blocklisted(name) {
68 findings.push(Finding::new(
69 Severity::Critical,
70 Category::DependencySecurity,
71 format!("Blocklisted plugin installed: '{}'", name),
72 format!(
73 "The plugin '{}' listed in '{}' is known to be malicious or \
74 compromised. Remove it immediately.",
75 name,
76 path.display()
77 ),
78 path,
79 format!(
80 "Uninstall the '{}' plugin and audit any actions it may have taken \
81 while installed.",
82 name
83 ),
84 ));
85 }
86
87 let install_date_str = plugin
89 .get("installedAt")
90 .or_else(|| plugin.get("installed_at"))
91 .or_else(|| plugin.get("updatedAt"))
92 .and_then(Value::as_str);
93
94 if let Some(date_str) = install_date_str {
95 if let Ok(install_date) = date_str.parse::<DateTime<Utc>>() {
96 let age = Utc::now() - install_date;
97 if age > Duration::days(90) {
98 findings.push(Finding::new(
99 Severity::Low,
100 Category::DependencySecurity,
101 format!("Plugin '{}' has not been updated in over 90 days", name),
102 format!(
103 "The plugin '{}' was last updated {} days ago. Outdated plugins \
104 may contain unpatched vulnerabilities.",
105 name,
106 age.num_days()
107 ),
108 path,
109 format!(
110 "Check for updates to the '{}' plugin or consider replacing it \
111 with an actively maintained alternative.",
112 name
113 ),
114 ));
115 }
116 }
117 }
118
119 let source = plugin
121 .get("source")
122 .or_else(|| plugin.get("registry"))
123 .and_then(Value::as_str)
124 .unwrap_or("");
125
126 if !source.is_empty() && is_unofficial_source(source) {
127 findings.push(Finding::new(
128 Severity::Medium,
129 Category::DependencySecurity,
130 format!("Plugin '{}' installed from unofficial source", name),
131 format!(
132 "The plugin '{}' in '{}' was installed from '{}', which is not \
133 an officially recognised registry. Unofficial sources carry a higher \
134 risk of supply-chain attacks.",
135 name,
136 path.display(),
137 source
138 ),
139 path,
140 "Prefer plugins from official, verified registries. Review the source \
141 repository before trusting this plugin.",
142 ));
143 }
144}
145
146fn is_blocklisted(name: &str) -> bool {
149 const BLOCKLIST: &[&str] = &[
150 "openclaw-backdoor",
153 "mcp-exfil",
154 "agent-pwn",
155 ];
156 BLOCKLIST.iter().any(|&b| b.eq_ignore_ascii_case(name))
157}
158
159fn is_unofficial_source(source: &str) -> bool {
164 let after_scheme = source
166 .find("://")
167 .map(|i| &source[i + 3..])
168 .unwrap_or(source);
169 let host = after_scheme
170 .split('/')
171 .next()
172 .unwrap_or("")
173 .split(':')
174 .next()
175 .unwrap_or("");
176
177 if host == "github.com" {
179 return !after_scheme.starts_with("github.com/anthropics/")
180 && !after_scheme.starts_with("github.com/modelcontextprotocol/");
181 }
182
183 const OFFICIAL_HOSTS: &[&str] = &[
184 "npmjs.com",
185 "registry.npmjs.org",
186 "marketplace.visualstudio.com",
187 ];
188 !OFFICIAL_HOSTS
189 .iter()
190 .any(|official| host == *official || host.ends_with(&format!(".{}", official)))
191}
192
193#[cfg(test)]
196mod tests {
197 use super::*;
198 use std::path::PathBuf;
199
200 fn check(json_str: &str) -> Vec<Finding> {
201 let mut findings = Vec::new();
202 check_plugins(
203 json_str,
204 &PathBuf::from("/test/installed_plugins.json"),
205 &mut findings,
206 );
207 findings
208 }
209
210 #[test]
211 fn detects_blocklisted_plugin() {
212 let json = r#"[{"name": "mcp-exfil", "installedAt": "2025-01-01T00:00:00Z"}]"#;
213 let f = check(json);
214 assert!(f.iter().any(|x| x.severity == Severity::Critical));
215 }
216
217 #[test]
218 fn no_finding_for_clean_plugin() {
219 let json = r#"[{"name": "my-safe-plugin", "installedAt": "2025-12-01T00:00:00Z"}]"#;
220 let f = check(json);
221 assert!(!f.iter().any(|x| x.severity == Severity::Critical));
222 }
223
224 #[test]
225 fn detects_unofficial_source() {
226 let json = r#"[{"name": "some-plugin", "source": "https://random-site.xyz/plugins"}]"#;
227 let f = check(json);
228 assert!(f.iter().any(|x| x.title.contains("unofficial source")));
229 }
230
231 #[test]
232 fn no_finding_for_official_source() {
233 let json = r#"[{"name": "some-plugin", "source": "https://registry.npmjs.org"}]"#;
234 let f = check(json);
235 assert!(!f.iter().any(|x| x.title.contains("unofficial source")));
236 }
237
238 #[test]
239 fn is_blocklisted_case_insensitive() {
240 assert!(is_blocklisted("MCP-EXFIL"));
241 assert!(!is_blocklisted("legitimate-tool"));
242 }
243
244 #[test]
245 fn deceptive_url_is_unofficial() {
246 assert!(is_unofficial_source("https://npmjs.com.evil.com/package"));
248 }
249
250 #[test]
251 fn official_github_org_is_trusted() {
252 assert!(!is_unofficial_source(
253 "https://github.com/modelcontextprotocol/servers"
254 ));
255 }
256
257 #[test]
258 fn unofficial_github_org_is_flagged() {
259 assert!(is_unofficial_source(
260 "https://github.com/random-person/plugin"
261 ));
262 }
263}