use std::path::Path;
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use serde_json::Value;
use crate::finding::{Category, Finding, Severity};
use crate::scanner::{ScanContext, Scanner};
pub struct DependenciesScanner;
impl Scanner for DependenciesScanner {
fn name(&self) -> &'static str {
"dependencies"
}
fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
let mut findings = Vec::new();
let plugins_path = ctx.root.join("installed_plugins.json");
if plugins_path.exists() {
if let Ok(content) = std::fs::read_to_string(&plugins_path) {
check_plugins(&content, &plugins_path, &mut findings);
}
}
Ok(findings)
}
}
fn check_plugins(content: &str, path: &Path, findings: &mut Vec<Finding>) {
let Ok(json): Result<Value, _> = serde_json::from_str(content) else {
return;
};
let plugins = match &json {
Value::Array(arr) => arr.as_slice(),
Value::Object(obj) => {
for (_, plugin) in obj {
check_single_plugin(plugin, path, findings);
}
return;
}
_ => return,
};
for plugin in plugins {
check_single_plugin(plugin, path, findings);
}
}
fn check_single_plugin(plugin: &Value, path: &Path, findings: &mut Vec<Finding>) {
let name = plugin
.get("name")
.or_else(|| plugin.get("id"))
.and_then(Value::as_str)
.unwrap_or("<unknown>");
if is_blocklisted(name) {
findings.push(Finding::new(
Severity::Critical,
Category::DependencySecurity,
format!("Blocklisted plugin installed: '{}'", name),
format!(
"The plugin '{}' listed in '{}' is known to be malicious or \
compromised. Remove it immediately.",
name,
path.display()
),
path,
format!(
"Uninstall the '{}' plugin and audit any actions it may have taken \
while installed.",
name
),
));
}
let install_date_str = plugin
.get("installedAt")
.or_else(|| plugin.get("installed_at"))
.or_else(|| plugin.get("updatedAt"))
.and_then(Value::as_str);
if let Some(date_str) = install_date_str {
if let Ok(install_date) = date_str.parse::<DateTime<Utc>>() {
let age = Utc::now() - install_date;
if age > Duration::days(90) {
findings.push(Finding::new(
Severity::Low,
Category::DependencySecurity,
format!("Plugin '{}' has not been updated in over 90 days", name),
format!(
"The plugin '{}' was last updated {} days ago. Outdated plugins \
may contain unpatched vulnerabilities.",
name,
age.num_days()
),
path,
format!(
"Check for updates to the '{}' plugin or consider replacing it \
with an actively maintained alternative.",
name
),
));
}
}
}
let source = plugin
.get("source")
.or_else(|| plugin.get("registry"))
.and_then(Value::as_str)
.unwrap_or("");
if !source.is_empty() && is_unofficial_source(source) {
findings.push(Finding::new(
Severity::Medium,
Category::DependencySecurity,
format!("Plugin '{}' installed from unofficial source", name),
format!(
"The plugin '{}' in '{}' was installed from '{}', which is not \
an officially recognised registry. Unofficial sources carry a higher \
risk of supply-chain attacks.",
name,
path.display(),
source
),
path,
"Prefer plugins from official, verified registries. Review the source \
repository before trusting this plugin.",
));
}
}
fn is_blocklisted(name: &str) -> bool {
const BLOCKLIST: &[&str] = &[
"openclaw-backdoor",
"mcp-exfil",
"agent-pwn",
];
BLOCKLIST.iter().any(|&b| b.eq_ignore_ascii_case(name))
}
fn is_unofficial_source(source: &str) -> bool {
let after_scheme = source
.find("://")
.map(|i| &source[i + 3..])
.unwrap_or(source);
let host = after_scheme
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
if host == "github.com" {
return !after_scheme.starts_with("github.com/anthropics/")
&& !after_scheme.starts_with("github.com/modelcontextprotocol/");
}
const OFFICIAL_HOSTS: &[&str] = &[
"npmjs.com",
"registry.npmjs.org",
"marketplace.visualstudio.com",
];
!OFFICIAL_HOSTS
.iter()
.any(|official| host == *official || host.ends_with(&format!(".{}", official)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn check(json_str: &str) -> Vec<Finding> {
let mut findings = Vec::new();
check_plugins(
json_str,
&PathBuf::from("/test/installed_plugins.json"),
&mut findings,
);
findings
}
#[test]
fn detects_blocklisted_plugin() {
let json = r#"[{"name": "mcp-exfil", "installedAt": "2025-01-01T00:00:00Z"}]"#;
let f = check(json);
assert!(f.iter().any(|x| x.severity == Severity::Critical));
}
#[test]
fn no_finding_for_clean_plugin() {
let json = r#"[{"name": "my-safe-plugin", "installedAt": "2025-12-01T00:00:00Z"}]"#;
let f = check(json);
assert!(!f.iter().any(|x| x.severity == Severity::Critical));
}
#[test]
fn detects_unofficial_source() {
let json = r#"[{"name": "some-plugin", "source": "https://random-site.xyz/plugins"}]"#;
let f = check(json);
assert!(f.iter().any(|x| x.title.contains("unofficial source")));
}
#[test]
fn no_finding_for_official_source() {
let json = r#"[{"name": "some-plugin", "source": "https://registry.npmjs.org"}]"#;
let f = check(json);
assert!(!f.iter().any(|x| x.title.contains("unofficial source")));
}
#[test]
fn is_blocklisted_case_insensitive() {
assert!(is_blocklisted("MCP-EXFIL"));
assert!(!is_blocklisted("legitimate-tool"));
}
#[test]
fn deceptive_url_is_unofficial() {
assert!(is_unofficial_source("https://npmjs.com.evil.com/package"));
}
#[test]
fn official_github_org_is_trusted() {
assert!(!is_unofficial_source(
"https://github.com/modelcontextprotocol/servers"
));
}
#[test]
fn unofficial_github_org_is_flagged() {
assert!(is_unofficial_source(
"https://github.com/random-person/plugin"
));
}
}