Skip to main content

openclaw_scan/scanner/
config.rs

1//! Configuration security scanner.
2//!
3//! Analyses `settings.json` and `settings.local.json` for overly broad
4//! permission grants, dangerous flags, and weak MCP server configurations.
5
6use std::path::Path;
7
8use anyhow::Result;
9use serde_json::Value;
10
11use crate::finding::{Category, Finding, Severity};
12use crate::scanner::{ScanContext, Scanner};
13
14pub struct ConfigScanner;
15
16impl Scanner for ConfigScanner {
17    fn name(&self) -> &'static str {
18        "config"
19    }
20
21    fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
22        let mut findings = Vec::new();
23
24        for name in &["settings.json", "settings.local.json"] {
25            let path = ctx.root.join(name);
26            if path.exists() {
27                if let Ok(content) = std::fs::read_to_string(&path) {
28                    check_settings(&content, &path, &mut findings);
29                }
30            }
31        }
32
33        // Also scan any project-level settings found one level up.
34        // (agents sometimes create nested .claude/ dirs inside project roots)
35        for entry in walkdir::WalkDir::new(&ctx.root)
36            .max_depth(4)
37            .into_iter()
38            .filter_map(|e| e.ok())
39            .filter(|e| {
40                e.file_type().is_file() && e.file_name().to_str() == Some("settings.local.json")
41            })
42        {
43            let p = entry.path();
44            if p != ctx.root.join("settings.local.json") {
45                if let Ok(content) = std::fs::read_to_string(p) {
46                    check_settings(&content, p, &mut findings);
47                }
48            }
49        }
50
51        Ok(findings)
52    }
53}
54
55fn check_settings(content: &str, path: &Path, findings: &mut Vec<Finding>) {
56    let Ok(json): Result<Value, _> = serde_json::from_str(content) else {
57        findings.push(Finding::new(
58            Severity::Low,
59            Category::ConfigSecurity,
60            "Settings file is not valid JSON",
61            format!(
62                "'{}' could not be parsed as JSON. The file may be corrupted.",
63                path.display()
64            ),
65            path,
66            "Validate and repair the JSON file.",
67        ));
68        return;
69    };
70
71    check_dangerous_skip_permissions(&json, path, findings);
72    check_allow_rules(&json, path, findings);
73    check_mcp_servers(&json, path, findings);
74}
75
76/// Flag `dangerouslySkipPermissions: true`.
77fn check_dangerous_skip_permissions(json: &Value, path: &Path, findings: &mut Vec<Finding>) {
78    if json
79        .get("dangerouslySkipPermissions")
80        .and_then(Value::as_bool)
81        == Some(true)
82    {
83        findings.push(Finding::new(
84            Severity::Critical,
85            Category::ConfigSecurity,
86            "dangerouslySkipPermissions is enabled",
87            format!(
88                "'{}' has `dangerouslySkipPermissions: true`. This disables ALL \
89                 permission checks and allows agents to execute any command without \
90                 confirmation — a severe privilege escalation risk.",
91                path.display()
92            ),
93            path,
94            "Set `dangerouslySkipPermissions` to `false` or remove the key entirely. \
95             Never enable this setting in production.",
96        ));
97    }
98}
99
100/// Inspect the `permissions.allow` array for dangerous entries.
101fn check_allow_rules(json: &Value, path: &Path, findings: &mut Vec<Finding>) {
102    let Some(allow) = json
103        .pointer("/permissions/allow")
104        .or_else(|| json.get("allow"))
105        .and_then(Value::as_array)
106    else {
107        return;
108    };
109
110    let mut has_critical = false;
111
112    for rule in allow {
113        let rule_str = match rule.as_str() {
114            Some(s) => s,
115            None => continue,
116        };
117
118        // Wildcard Bash allow — most dangerous
119        if rule_str == "Bash(*)" || rule_str == "Bash" {
120            has_critical = true;
121            findings.push(
122                Finding::new(
123                    Severity::Critical,
124                    Category::ConfigSecurity,
125                    "Unrestricted Bash execution allowed",
126                    format!(
127                        "'{}' grants `{}` — agents can run ANY shell command without \
128                     restriction. This is the most dangerous permission possible.",
129                        path.display(),
130                        rule_str
131                    ),
132                    path,
133                    "Remove the wildcard Bash allow rule. Use specific, narrow allow \
134                 rules such as `Bash(git status)` instead.",
135                )
136                .with_evidence(rule_str.to_string()),
137            );
138            continue;
139        }
140
141        // Bash with shell metacharacters — use .get() to avoid panic on short rules (H-5)
142        if rule_str.starts_with("Bash(") {
143            let inner = rule_str
144                .get(5..rule_str.len().saturating_sub(1))
145                .unwrap_or("");
146            let dangerous_chars = ['*', '|', ';', '`', '$', '>', '<', '&'];
147            if inner.chars().any(|c| dangerous_chars.contains(&c)) {
148                findings.push(
149                    Finding::new(
150                        Severity::High,
151                        Category::ConfigSecurity,
152                        "Bash allow rule contains shell metacharacters",
153                        format!(
154                            "'{}' has allow rule `{}`. Shell metacharacters in Bash \
155                         rules can enable command injection or unintended side effects.",
156                            path.display(),
157                            rule_str
158                        ),
159                        path,
160                        "Tighten the allow rule to use only literal, safe commands without \
161                     wildcards or shell operators.",
162                    )
163                    .with_evidence(rule_str.to_string()),
164                );
165            }
166        }
167
168        // Wildcard file write
169        if rule_str == "Write(*)" || rule_str == "Edit(*)" {
170            findings.push(
171                Finding::new(
172                    Severity::High,
173                    Category::ConfigSecurity,
174                    "Unrestricted file write permission",
175                    format!(
176                        "'{}' grants `{}` — agents can overwrite any file on disk.",
177                        path.display(),
178                        rule_str
179                    ),
180                    path,
181                    "Restrict write/edit permissions to specific directories or file patterns.",
182                )
183                .with_evidence(rule_str.to_string()),
184            );
185        }
186    }
187
188    // Skip the "no deny rules" warning when a Critical was already raised —
189    // the more severe finding already demands immediate action (M-4).
190    if has_critical {
191        return;
192    }
193    let deny = json
194        .pointer("/permissions/deny")
195        .or_else(|| json.get("deny"))
196        .and_then(Value::as_array);
197    if !allow.is_empty() && deny.map(|d| d.is_empty()).unwrap_or(true) {
198        findings.push(Finding::new(
199            Severity::Medium,
200            Category::ConfigSecurity,
201            "No deny rules configured",
202            format!(
203                "'{}' has allow rules but no deny rules. Without explicit deny rules, \
204                 there is no safety net to block dangerous operations.",
205                path.display()
206            ),
207            path,
208            "Add deny rules for sensitive operations such as \
209             `Bash(rm -rf*)`, `Bash(curl*)`, and `Write(/etc/*)` to limit agent blast radius.",
210        ));
211    }
212}
213
214/// Check MCP server configurations for weak settings.
215fn check_mcp_servers(json: &Value, path: &Path, findings: &mut Vec<Finding>) {
216    let Some(servers) = json
217        .pointer("/mcpServers")
218        .or_else(|| json.get("mcp_servers"))
219        .and_then(Value::as_object)
220    else {
221        return;
222    };
223
224    for (server_name, server_cfg) in servers {
225        if server_cfg.get("alwaysAllow").and_then(Value::as_bool) == Some(true) {
226            findings.push(Finding::new(
227                Severity::Medium,
228                Category::ConfigSecurity,
229                format!("MCP server '{}' has alwaysAllow enabled", server_name),
230                format!(
231                    "The MCP server '{}' in '{}' has `alwaysAllow: true`. This means \
232                     all tool calls from this server are auto-approved without user review.",
233                    server_name,
234                    path.display()
235                ),
236                path,
237                format!(
238                    "Set `alwaysAllow: false` for the '{}' MCP server and review \
239                     which specific tools actually need auto-approval.",
240                    server_name
241                ),
242            ));
243        }
244    }
245}
246
247// ── Tests ─────────────────────────────────────────────────────────────────────
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::path::PathBuf;
253
254    fn check(json_str: &str) -> Vec<Finding> {
255        let mut findings = Vec::new();
256        check_settings(
257            json_str,
258            &PathBuf::from("/test/settings.json"),
259            &mut findings,
260        );
261        findings
262    }
263
264    #[test]
265    fn detects_dangerous_skip_permissions() {
266        let f = check(r#"{"dangerouslySkipPermissions": true}"#);
267        assert!(!f.is_empty());
268        assert_eq!(f[0].severity, Severity::Critical);
269        assert!(f[0].title.contains("dangerouslySkipPermissions"));
270    }
271
272    #[test]
273    fn no_finding_for_false_skip_permissions() {
274        let f = check(r#"{"dangerouslySkipPermissions": false}"#);
275        assert!(f.is_empty());
276    }
277
278    #[test]
279    fn detects_wildcard_bash() {
280        let f = check(r#"{"permissions": {"allow": ["Bash(*)"]}}"#);
281        assert!(f
282            .iter()
283            .any(|x| x.severity == Severity::Critical && x.title.contains("Unrestricted Bash")));
284    }
285
286    #[test]
287    fn detects_bare_bash_allow() {
288        let f = check(r#"{"permissions": {"allow": ["Bash"]}}"#);
289        assert!(f.iter().any(|x| x.severity == Severity::Critical));
290    }
291
292    #[test]
293    fn detects_bash_with_metachar() {
294        let f = check(r#"{"permissions": {"allow": ["Bash(echo $HOME)"]}}"#);
295        assert!(f
296            .iter()
297            .any(|x| x.severity == Severity::High && x.title.contains("metacharacter")));
298    }
299
300    #[test]
301    fn no_finding_for_safe_bash_rule() {
302        let f =
303            check(r#"{"permissions": {"allow": ["Bash(git status)"], "deny": ["Bash(rm*)"] }}"#);
304        assert!(
305            f.is_empty(),
306            "safe rule should produce no findings: {:?}",
307            f
308        );
309    }
310
311    #[test]
312    fn detects_wildcard_write() {
313        let f = check(r#"{"permissions": {"allow": ["Write(*)"]}}"#);
314        assert!(f
315            .iter()
316            .any(|x| x.severity == Severity::High && x.title.contains("write")));
317    }
318
319    #[test]
320    fn warns_no_deny_rules() {
321        let f = check(r#"{"permissions": {"allow": ["Bash(git log)"], "deny": []}}"#);
322        assert!(f
323            .iter()
324            .any(|x| x.severity == Severity::Medium && x.title.contains("deny")));
325    }
326
327    #[test]
328    fn detects_mcp_always_allow() {
329        let json = r#"{
330            "mcpServers": {
331                "my-server": {"command": "node", "alwaysAllow": true}
332            }
333        }"#;
334        let f = check(json);
335        assert!(f.iter().any(|x| x.title.contains("alwaysAllow")));
336    }
337
338    #[test]
339    fn invalid_json_produces_low_finding() {
340        let f = check("{not valid json}");
341        assert!(f.iter().any(|x| x.severity == Severity::Low));
342    }
343}