Skip to main content

openclaw_scan/scanner/
dependencies.rs

1//! Dependency and plugin security scanner.
2//!
3//! Checks installed plugins and MCP servers for supply-chain risks:
4//! blocklisted packages, stale versions, and unofficial sources.
5
6use 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        // Scan installed_plugins.json if present
26        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            // Some frameworks store plugins as an object; iterate values
46            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    // Check if plugin is in a known blocklist (simple name-based check)
67    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    // Check install date for staleness (>90 days without update)
88    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    // Check for unofficial source
120    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
146/// Hard-coded blocklist — in a production tool this would be fetched from a
147/// regularly updated remote feed, but for offline safety we ship a minimal list.
148fn is_blocklisted(name: &str) -> bool {
149    const BLOCKLIST: &[&str] = &[
150        // Add known malicious plugin names here as they are discovered.
151        // This list intentionally starts minimal to avoid false positives.
152        "openclaw-backdoor",
153        "mcp-exfil",
154        "agent-pwn",
155    ];
156    BLOCKLIST.iter().any(|&b| b.eq_ignore_ascii_case(name))
157}
158
159/// Returns true if the source URL looks unofficial (not a well-known registry).
160///
161/// Uses proper hostname extraction to prevent bypass via deceptive URLs like
162/// `npmjs.com.evil.com` (M-2 fix).
163fn is_unofficial_source(source: &str) -> bool {
164    // Extract hostname: strip scheme, then take everything before the first / or :
165    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    // GitHub is only trusted for specific organisations.
178    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// ── Tests ─────────────────────────────────────────────────────────────────────
194
195#[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        // "npmjs.com.evil.com" must NOT be treated as official (M-2 fix)
247        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}