openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! Configuration security scanner.
//!
//! Analyses `settings.json` and `settings.local.json` for overly broad
//! permission grants, dangerous flags, and weak MCP server configurations.

use std::path::Path;

use anyhow::Result;
use serde_json::Value;

use crate::finding::{Category, Finding, Severity};
use crate::scanner::{ScanContext, Scanner};

pub struct ConfigScanner;

impl Scanner for ConfigScanner {
    fn name(&self) -> &'static str {
        "config"
    }

    fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
        let mut findings = Vec::new();

        for name in &["settings.json", "settings.local.json"] {
            let path = ctx.root.join(name);
            if path.exists() {
                if let Ok(content) = std::fs::read_to_string(&path) {
                    check_settings(&content, &path, &mut findings);
                }
            }
        }

        // Also scan any project-level settings found one level up.
        // (agents sometimes create nested .claude/ dirs inside project roots)
        for entry in walkdir::WalkDir::new(&ctx.root)
            .max_depth(4)
            .into_iter()
            .filter_map(|e| e.ok())
            .filter(|e| {
                e.file_type().is_file() && e.file_name().to_str() == Some("settings.local.json")
            })
        {
            let p = entry.path();
            if p != ctx.root.join("settings.local.json") {
                if let Ok(content) = std::fs::read_to_string(p) {
                    check_settings(&content, p, &mut findings);
                }
            }
        }

        Ok(findings)
    }
}

fn check_settings(content: &str, path: &Path, findings: &mut Vec<Finding>) {
    let Ok(json): Result<Value, _> = serde_json::from_str(content) else {
        findings.push(Finding::new(
            Severity::Low,
            Category::ConfigSecurity,
            "Settings file is not valid JSON",
            format!(
                "'{}' could not be parsed as JSON. The file may be corrupted.",
                path.display()
            ),
            path,
            "Validate and repair the JSON file.",
        ));
        return;
    };

    check_dangerous_skip_permissions(&json, path, findings);
    check_allow_rules(&json, path, findings);
    check_mcp_servers(&json, path, findings);
}

/// Flag `dangerouslySkipPermissions: true`.
fn check_dangerous_skip_permissions(json: &Value, path: &Path, findings: &mut Vec<Finding>) {
    if json
        .get("dangerouslySkipPermissions")
        .and_then(Value::as_bool)
        == Some(true)
    {
        findings.push(Finding::new(
            Severity::Critical,
            Category::ConfigSecurity,
            "dangerouslySkipPermissions is enabled",
            format!(
                "'{}' has `dangerouslySkipPermissions: true`. This disables ALL \
                 permission checks and allows agents to execute any command without \
                 confirmation — a severe privilege escalation risk.",
                path.display()
            ),
            path,
            "Set `dangerouslySkipPermissions` to `false` or remove the key entirely. \
             Never enable this setting in production.",
        ));
    }
}

/// Inspect the `permissions.allow` array for dangerous entries.
fn check_allow_rules(json: &Value, path: &Path, findings: &mut Vec<Finding>) {
    let Some(allow) = json
        .pointer("/permissions/allow")
        .or_else(|| json.get("allow"))
        .and_then(Value::as_array)
    else {
        return;
    };

    let mut has_critical = false;

    for rule in allow {
        let rule_str = match rule.as_str() {
            Some(s) => s,
            None => continue,
        };

        // Wildcard Bash allow — most dangerous
        if rule_str == "Bash(*)" || rule_str == "Bash" {
            has_critical = true;
            findings.push(
                Finding::new(
                    Severity::Critical,
                    Category::ConfigSecurity,
                    "Unrestricted Bash execution allowed",
                    format!(
                        "'{}' grants `{}` — agents can run ANY shell command without \
                     restriction. This is the most dangerous permission possible.",
                        path.display(),
                        rule_str
                    ),
                    path,
                    "Remove the wildcard Bash allow rule. Use specific, narrow allow \
                 rules such as `Bash(git status)` instead.",
                )
                .with_evidence(rule_str.to_string()),
            );
            continue;
        }

        // Bash with shell metacharacters — use .get() to avoid panic on short rules (H-5)
        if rule_str.starts_with("Bash(") {
            let inner = rule_str
                .get(5..rule_str.len().saturating_sub(1))
                .unwrap_or("");
            let dangerous_chars = ['*', '|', ';', '`', '$', '>', '<', '&'];
            if inner.chars().any(|c| dangerous_chars.contains(&c)) {
                findings.push(
                    Finding::new(
                        Severity::High,
                        Category::ConfigSecurity,
                        "Bash allow rule contains shell metacharacters",
                        format!(
                            "'{}' has allow rule `{}`. Shell metacharacters in Bash \
                         rules can enable command injection or unintended side effects.",
                            path.display(),
                            rule_str
                        ),
                        path,
                        "Tighten the allow rule to use only literal, safe commands without \
                     wildcards or shell operators.",
                    )
                    .with_evidence(rule_str.to_string()),
                );
            }
        }

        // Wildcard file write
        if rule_str == "Write(*)" || rule_str == "Edit(*)" {
            findings.push(
                Finding::new(
                    Severity::High,
                    Category::ConfigSecurity,
                    "Unrestricted file write permission",
                    format!(
                        "'{}' grants `{}` — agents can overwrite any file on disk.",
                        path.display(),
                        rule_str
                    ),
                    path,
                    "Restrict write/edit permissions to specific directories or file patterns.",
                )
                .with_evidence(rule_str.to_string()),
            );
        }
    }

    // Skip the "no deny rules" warning when a Critical was already raised —
    // the more severe finding already demands immediate action (M-4).
    if has_critical {
        return;
    }
    let deny = json
        .pointer("/permissions/deny")
        .or_else(|| json.get("deny"))
        .and_then(Value::as_array);
    if !allow.is_empty() && deny.map(|d| d.is_empty()).unwrap_or(true) {
        findings.push(Finding::new(
            Severity::Medium,
            Category::ConfigSecurity,
            "No deny rules configured",
            format!(
                "'{}' has allow rules but no deny rules. Without explicit deny rules, \
                 there is no safety net to block dangerous operations.",
                path.display()
            ),
            path,
            "Add deny rules for sensitive operations such as \
             `Bash(rm -rf*)`, `Bash(curl*)`, and `Write(/etc/*)` to limit agent blast radius.",
        ));
    }
}

/// Check MCP server configurations for weak settings.
fn check_mcp_servers(json: &Value, path: &Path, findings: &mut Vec<Finding>) {
    let Some(servers) = json
        .pointer("/mcpServers")
        .or_else(|| json.get("mcp_servers"))
        .and_then(Value::as_object)
    else {
        return;
    };

    for (server_name, server_cfg) in servers {
        if server_cfg.get("alwaysAllow").and_then(Value::as_bool) == Some(true) {
            findings.push(Finding::new(
                Severity::Medium,
                Category::ConfigSecurity,
                format!("MCP server '{}' has alwaysAllow enabled", server_name),
                format!(
                    "The MCP server '{}' in '{}' has `alwaysAllow: true`. This means \
                     all tool calls from this server are auto-approved without user review.",
                    server_name,
                    path.display()
                ),
                path,
                format!(
                    "Set `alwaysAllow: false` for the '{}' MCP server and review \
                     which specific tools actually need auto-approval.",
                    server_name
                ),
            ));
        }
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn check(json_str: &str) -> Vec<Finding> {
        let mut findings = Vec::new();
        check_settings(
            json_str,
            &PathBuf::from("/test/settings.json"),
            &mut findings,
        );
        findings
    }

    #[test]
    fn detects_dangerous_skip_permissions() {
        let f = check(r#"{"dangerouslySkipPermissions": true}"#);
        assert!(!f.is_empty());
        assert_eq!(f[0].severity, Severity::Critical);
        assert!(f[0].title.contains("dangerouslySkipPermissions"));
    }

    #[test]
    fn no_finding_for_false_skip_permissions() {
        let f = check(r#"{"dangerouslySkipPermissions": false}"#);
        assert!(f.is_empty());
    }

    #[test]
    fn detects_wildcard_bash() {
        let f = check(r#"{"permissions": {"allow": ["Bash(*)"]}}"#);
        assert!(f
            .iter()
            .any(|x| x.severity == Severity::Critical && x.title.contains("Unrestricted Bash")));
    }

    #[test]
    fn detects_bare_bash_allow() {
        let f = check(r#"{"permissions": {"allow": ["Bash"]}}"#);
        assert!(f.iter().any(|x| x.severity == Severity::Critical));
    }

    #[test]
    fn detects_bash_with_metachar() {
        let f = check(r#"{"permissions": {"allow": ["Bash(echo $HOME)"]}}"#);
        assert!(f
            .iter()
            .any(|x| x.severity == Severity::High && x.title.contains("metacharacter")));
    }

    #[test]
    fn no_finding_for_safe_bash_rule() {
        let f =
            check(r#"{"permissions": {"allow": ["Bash(git status)"], "deny": ["Bash(rm*)"] }}"#);
        assert!(
            f.is_empty(),
            "safe rule should produce no findings: {:?}",
            f
        );
    }

    #[test]
    fn detects_wildcard_write() {
        let f = check(r#"{"permissions": {"allow": ["Write(*)"]}}"#);
        assert!(f
            .iter()
            .any(|x| x.severity == Severity::High && x.title.contains("write")));
    }

    #[test]
    fn warns_no_deny_rules() {
        let f = check(r#"{"permissions": {"allow": ["Bash(git log)"], "deny": []}}"#);
        assert!(f
            .iter()
            .any(|x| x.severity == Severity::Medium && x.title.contains("deny")));
    }

    #[test]
    fn detects_mcp_always_allow() {
        let json = r#"{
            "mcpServers": {
                "my-server": {"command": "node", "alwaysAllow": true}
            }
        }"#;
        let f = check(json);
        assert!(f.iter().any(|x| x.title.contains("alwaysAllow")));
    }

    #[test]
    fn invalid_json_produces_low_finding() {
        let f = check("{not valid json}");
        assert!(f.iter().any(|x| x.severity == Severity::Low));
    }
}