1use 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 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
76fn 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
100fn 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 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 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 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 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
214fn 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#[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}