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);
}
}
}
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);
}
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.",
));
}
}
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,
};
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;
}
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()),
);
}
}
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()),
);
}
}
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.",
));
}
}
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
),
));
}
}
}
#[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));
}
}