openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! Dependency and plugin security scanner.
//!
//! Checks installed plugins and MCP servers for supply-chain risks:
//! blocklisted packages, stale versions, and unofficial sources.

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();

        // Scan installed_plugins.json if present
        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) => {
            // Some frameworks store plugins as an object; iterate values
            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>");

    // Check if plugin is in a known blocklist (simple name-based check)
    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
            ),
        ));
    }

    // Check install date for staleness (>90 days without update)
    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
                    ),
                ));
            }
        }
    }

    // Check for unofficial source
    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.",
        ));
    }
}

/// Hard-coded blocklist — in a production tool this would be fetched from a
/// regularly updated remote feed, but for offline safety we ship a minimal list.
fn is_blocklisted(name: &str) -> bool {
    const BLOCKLIST: &[&str] = &[
        // Add known malicious plugin names here as they are discovered.
        // This list intentionally starts minimal to avoid false positives.
        "openclaw-backdoor",
        "mcp-exfil",
        "agent-pwn",
    ];
    BLOCKLIST.iter().any(|&b| b.eq_ignore_ascii_case(name))
}

/// Returns true if the source URL looks unofficial (not a well-known registry).
///
/// Uses proper hostname extraction to prevent bypass via deceptive URLs like
/// `npmjs.com.evil.com` (M-2 fix).
fn is_unofficial_source(source: &str) -> bool {
    // Extract hostname: strip scheme, then take everything before the first / or :
    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("");

    // GitHub is only trusted for specific organisations.
    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)))
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[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() {
        // "npmjs.com.evil.com" must NOT be treated as official (M-2 fix)
        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"
        ));
    }
}