use crate::x::claude_code::config::Config;
use anyhow::{Context, Result};
use llm_json::{RepairOptions, loads};
use regex::Regex;
use rust_i18n::t;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SecurityWarning {
pub config_path: String,
pub config_item: String,
pub reason: String,
pub severity: SecurityWarningSeverity,
pub matched_pattern: String,
pub description: String,
pub recommendation: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SecurityWarningSeverity {
Critical, High, Medium, Low, }
impl SecurityWarningSeverity {
pub fn display_symbol(&self) -> &'static str {
match self {
SecurityWarningSeverity::Critical => "🚨",
SecurityWarningSeverity::High => "⚠️",
SecurityWarningSeverity::Medium => "⚡",
SecurityWarningSeverity::Low => "ℹ️",
}
}
pub fn display_name(&self) -> &'static str {
match self {
SecurityWarningSeverity::Critical => "CRITICAL",
SecurityWarningSeverity::High => "HIGH",
SecurityWarningSeverity::Medium => "MEDIUM",
SecurityWarningSeverity::Low => "LOW",
}
}
pub fn display_name_localized(&self) -> String {
match self {
SecurityWarningSeverity::Critical => {
t!("claude_code.security.severity.critical").to_string()
}
SecurityWarningSeverity::High => t!("claude_code.security.severity.high").to_string(),
SecurityWarningSeverity::Medium => {
t!("claude_code.security.severity.medium").to_string()
}
SecurityWarningSeverity::Low => t!("claude_code.security.severity.low").to_string(),
}
}
}
pub struct SecurityChecker {
dangerous_patterns: Vec<String>,
settings_files: Vec<String>,
enabled: bool,
}
impl SecurityChecker {
pub fn from_config(config: &Config) -> Result<Self> {
let security_config = config.security.as_ref();
let enabled = security_config.and_then(|s| s.enabled).unwrap_or(true);
let dangerous_patterns = security_config
.and_then(|s| s.dangerous_patterns.clone())
.unwrap_or_else(Self::default_dangerous_patterns)
.into_iter()
.map(|pattern| pattern.trim().to_lowercase())
.filter(|pattern| !pattern.is_empty())
.collect();
let settings_files = security_config
.and_then(|s| s.claude_settings_files.clone())
.unwrap_or_else(Self::default_settings_files);
Ok(Self {
dangerous_patterns,
settings_files,
enabled,
})
}
pub fn check_claude_settings(&self) -> Result<Vec<SecurityWarning>> {
if !self.enabled {
return Ok(vec![]);
}
let mut all_warnings = Vec::new();
for file_pattern in &self.settings_files {
if let Some(path) = self.resolve_settings_file_path(file_pattern) {
match self.check_settings_file(&path) {
Ok(mut warnings) => all_warnings.append(&mut warnings),
Err(e) => {
eprintln!(
"{}",
t!(
"claude_code.security.parse_error",
path = path.display(),
error = e.to_string()
)
);
}
}
}
}
Ok(all_warnings)
}
fn check_settings_file(&self, path: &Path) -> Result<Vec<SecurityWarning>> {
if !path.exists() {
return Ok(vec![]);
}
let content = fs::read_to_string(path).with_context(|| {
t!(
"claude_code.security.read_settings_failed",
path = path.display()
)
})?;
let settings = match serde_json::from_str::<Value>(&content) {
Ok(value) => value,
Err(_) => {
match loads(&content, &RepairOptions::default()) {
Ok(repaired_value) => repaired_value,
Err(e) => {
anyhow::bail!(
"{}",
t!("claude_code.security.parse_json_failed", error = e)
);
}
}
}
};
Ok(self.extract_dangerous_permissions(&settings, &path.display().to_string()))
}
fn extract_dangerous_permissions(
&self,
settings: &Value,
config_path: &str,
) -> Vec<SecurityWarning> {
let mut warnings = Vec::new();
if let Some(permissions) = settings.get("permissions")
&& let Some(allow) = permissions.get("allow")
&& let Some(allow_array) = allow.as_array()
{
for (index, item) in allow_array.iter().enumerate() {
if let Some(permission) = item.as_str()
&& let Some((matched_pattern, (severity, description, recommendation))) =
self.get_dangerous_pattern_info(permission)
{
warnings.push(SecurityWarning {
config_path: config_path.to_string(),
config_item: format!("permissions.allow[{}] = \"{}\"", index, permission),
reason: t!(
"claude_code.security.reason_pattern_detected",
pattern = permission
)
.to_string(),
severity,
matched_pattern,
description,
recommendation,
});
}
}
}
warnings
}
fn get_dangerous_pattern_info(
&self,
permission: &str,
) -> Option<(String, (SecurityWarningSeverity, String, String))> {
let permission_lower = permission.to_lowercase();
for pattern in &self.dangerous_patterns {
let pattern_lower = pattern.to_lowercase();
if let Some(matched) = self.matches_dangerous_pattern(&permission_lower, &pattern_lower)
{
let matched_str = matched.clone();
return Some((matched, self.get_pattern_details(&matched_str)));
}
}
None
}
fn matches_dangerous_pattern(&self, permission: &str, pattern: &str) -> Option<String> {
match pattern {
"format" => {
let re = Regex::new(r"(?i)\bformat\s+[a-zA-Z:/\\]").ok()?;
if re.is_match(permission) {
Some("format".to_string())
} else {
None
}
}
"mkfs" => {
let re = Regex::new(r"(?i)\bmkfs(?:\.|\s+)").ok()?;
if re.is_match(permission) {
Some("mkfs".to_string())
} else {
None
}
}
"rm -rf" => {
let re = Regex::new(r"(?i)\brm\s+-rf\b").ok()?;
if re.is_match(permission) {
Some("rm -rf".to_string())
} else {
None
}
}
"sudo rm" => {
let re = Regex::new(r"(?i)\bsudo\s+rm\b").ok()?;
if re.is_match(permission) {
Some("sudo rm".to_string())
} else {
None
}
}
"dd if=" => {
let re = Regex::new(r"(?i)\bdd\s+if=").ok()?;
if re.is_match(permission) {
Some("dd if=".to_string())
} else {
None
}
}
_ => {
if permission.contains(pattern) {
Some(pattern.to_string())
} else {
None
}
}
}
}
fn get_pattern_details(&self, pattern: &str) -> (SecurityWarningSeverity, String, String) {
match pattern {
p if p.contains("rm -rf") => (
SecurityWarningSeverity::Critical,
t!("claude_code.security.pattern.rm_rf.description").to_string(),
t!("claude_code.security.pattern.rm_rf.recommendation").to_string(),
),
p if p.contains("sudo rm") => (
SecurityWarningSeverity::Critical,
t!("claude_code.security.pattern.sudo_rm.description").to_string(),
t!("claude_code.security.pattern.sudo_rm.recommendation").to_string(),
),
p if p.contains("dd if=") => (
SecurityWarningSeverity::Critical,
t!("claude_code.security.pattern.dd_if.description").to_string(),
t!("claude_code.security.pattern.dd_if.recommendation").to_string(),
),
p if p.contains("mkfs") || p.contains("format") => (
SecurityWarningSeverity::Critical,
t!("claude_code.security.pattern.mkfs_or_format.description").to_string(),
t!("claude_code.security.pattern.mkfs_or_format.recommendation").to_string(),
),
p if p.contains("chmod 777") => (
SecurityWarningSeverity::High,
t!("claude_code.security.pattern.chmod_777.description").to_string(),
t!("claude_code.security.pattern.chmod_777.recommendation").to_string(),
),
p if p.contains("chown root") => (
SecurityWarningSeverity::High,
t!("claude_code.security.pattern.chown_root.description").to_string(),
t!("claude_code.security.pattern.chown_root.recommendation").to_string(),
),
p if p.contains("curl | sh") || p.contains("wget | sh") => (
SecurityWarningSeverity::High,
t!("claude_code.security.pattern.curl_or_wget_sh.description").to_string(),
t!("claude_code.security.pattern.curl_or_wget_sh.recommendation").to_string(),
),
p if p.contains("eval $(") || p.contains("exec $(") => (
SecurityWarningSeverity::High,
t!("claude_code.security.pattern.eval_or_exec.description").to_string(),
t!("claude_code.security.pattern.eval_or_exec.recommendation").to_string(),
),
p if p.contains("system(") => (
SecurityWarningSeverity::Medium,
t!("claude_code.security.pattern.system_call.description").to_string(),
t!("claude_code.security.pattern.system_call.recommendation").to_string(),
),
p if p.contains("crontab") => (
SecurityWarningSeverity::Medium,
t!("claude_code.security.pattern.crontab.description").to_string(),
t!("claude_code.security.pattern.crontab.recommendation").to_string(),
),
p if p.contains("systemctl") || p.contains("service") => (
SecurityWarningSeverity::Medium,
t!("claude_code.security.pattern.system_service.description").to_string(),
t!("claude_code.security.pattern.system_service.recommendation").to_string(),
),
p if p.contains("iptables") || p.contains("ufw") || p.contains("firewall") => (
SecurityWarningSeverity::Medium,
t!("claude_code.security.pattern.firewall.description").to_string(),
t!("claude_code.security.pattern.firewall.recommendation").to_string(),
),
p if p.contains("registry") || p.contains("reg add") => (
SecurityWarningSeverity::High,
t!("claude_code.security.pattern.registry.description").to_string(),
t!("claude_code.security.pattern.registry.recommendation").to_string(),
),
p if p.contains("net user") => (
SecurityWarningSeverity::High,
t!("claude_code.security.pattern.net_user.description").to_string(),
t!("claude_code.security.pattern.net_user.recommendation").to_string(),
),
p if p.contains("powershell -c") || p.contains("cmd /c") => (
SecurityWarningSeverity::Medium,
t!("claude_code.security.pattern.shell_exec.description").to_string(),
t!("claude_code.security.pattern.shell_exec.recommendation").to_string(),
),
p if p.contains("__import__('os').system") || p.contains("subprocess.call") => (
SecurityWarningSeverity::Medium,
t!("claude_code.security.pattern.python_system.description").to_string(),
t!("claude_code.security.pattern.python_system.recommendation").to_string(),
),
_ => (
SecurityWarningSeverity::Medium,
t!("claude_code.security.pattern.default.description").to_string(),
t!("claude_code.security.pattern.default.recommendation").to_string(),
),
}
}
fn resolve_settings_file_path(&self, file_pattern: &str) -> Option<PathBuf> {
let path = if let Some(stripped) = file_pattern.strip_prefix("~/") {
if let Some(home_dir) = crate::config::try_home_dir() {
home_dir.join(stripped)
} else {
PathBuf::from(file_pattern)
}
} else {
PathBuf::from(file_pattern)
};
Some(path)
}
fn default_dangerous_patterns() -> Vec<String> {
vec![
"rm -rf".to_string(),
"sudo rm".to_string(),
"dd if=".to_string(),
"mkfs".to_string(),
"format".to_string(),
"chmod 777".to_string(),
"chown root".to_string(),
">:|".to_string(),
"curl | sh".to_string(),
"wget | sh".to_string(),
"eval $(".to_string(),
"exec $(".to_string(),
"system(".to_string(),
"__import__('os').system".to_string(),
"subprocess.call".to_string(),
"powershell -c".to_string(),
"cmd /c".to_string(),
"registry".to_string(),
"reg add".to_string(),
"net user".to_string(),
"crontab".to_string(),
"systemctl".to_string(),
"service".to_string(),
"iptables".to_string(),
"ufw".to_string(),
"firewall".to_string(),
]
}
fn default_settings_files() -> Vec<String> {
vec![
".claude/settings.local.json".to_string(),
".claude/settings.json".to_string(),
"~/.claude/settings.json".to_string(),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_dangerous_pattern_info() {
let checker = SecurityChecker {
dangerous_patterns: vec!["rm -rf".to_string(), "curl | sh".to_string()],
settings_files: vec![],
enabled: true,
};
assert!(
checker
.get_dangerous_pattern_info("Bash(rm -rf /tmp/*)")
.is_some()
);
assert!(
checker
.get_dangerous_pattern_info("Bash(curl | sh script.sh)")
.is_some()
);
assert!(checker.get_dangerous_pattern_info("Bash(ls -la)").is_none());
assert!(
checker
.get_dangerous_pattern_info("Bash(sudo rm -rf /file)")
.is_some()
);
}
#[test]
fn test_get_dangerous_pattern_info_normalizes_pattern_case() {
let checker = SecurityChecker {
dangerous_patterns: vec!["RM -RF".to_string()],
settings_files: vec![],
enabled: true,
};
assert!(
checker
.get_dangerous_pattern_info("Bash(rm -rf /tmp/*)")
.is_some()
);
}
#[test]
fn test_extract_dangerous_permissions() {
let checker = SecurityChecker {
dangerous_patterns: vec!["rm -rf".to_string()],
settings_files: vec![],
enabled: true,
};
let settings_json = r#"
{
"permissions": {
"allow": [
"Bash(ls -la)",
"Bash(rm -rf /tmp/*)",
"WebSearch"
]
}
}
"#;
let settings: Value = serde_json::from_str(settings_json).unwrap();
let warnings = checker.extract_dangerous_permissions(&settings, "/test/path");
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].config_item,
"permissions.allow[1] = \"Bash(rm -rf /tmp/*)\""
);
assert_eq!(warnings[0].config_path, "/test/path");
assert_eq!(warnings[0].matched_pattern, "rm -rf");
assert_eq!(warnings[0].severity, SecurityWarningSeverity::Critical);
}
#[test]
fn test_severity_display() {
assert_eq!(SecurityWarningSeverity::Critical.display_symbol(), "🚨");
assert_eq!(SecurityWarningSeverity::Critical.display_name(), "CRITICAL");
assert_eq!(SecurityWarningSeverity::High.display_symbol(), "⚠️");
assert_eq!(SecurityWarningSeverity::High.display_name(), "HIGH");
assert_eq!(SecurityWarningSeverity::Medium.display_symbol(), "⚡");
assert_eq!(SecurityWarningSeverity::Medium.display_name(), "MEDIUM");
assert_eq!(SecurityWarningSeverity::Low.display_symbol(), "ℹ️");
assert_eq!(SecurityWarningSeverity::Low.display_name(), "LOW");
}
#[test]
fn test_pattern_details() {
let checker = SecurityChecker {
dangerous_patterns: vec![
"rm -rf".to_string(),
"curl | sh".to_string(),
"chmod 777".to_string(),
],
settings_files: vec![],
enabled: true,
};
let (severity, description, recommendation) = checker.get_pattern_details("rm -rf");
assert_eq!(severity, SecurityWarningSeverity::Critical);
assert!(!description.is_empty());
assert!(!recommendation.is_empty());
let (severity, description, recommendation) = checker.get_pattern_details("curl | sh");
assert_eq!(severity, SecurityWarningSeverity::High);
assert!(!description.is_empty());
assert!(!recommendation.is_empty());
let (severity, description, recommendation) = checker.get_pattern_details("chmod 777");
assert_eq!(severity, SecurityWarningSeverity::High);
assert!(!description.is_empty());
assert!(!recommendation.is_empty());
}
#[test]
fn test_format_pattern_precision() {
let checker = SecurityChecker {
dangerous_patterns: vec!["format".to_string(), "mkfs".to_string()],
settings_files: vec![],
enabled: true,
};
assert!(
checker
.get_dangerous_pattern_info("Bash(LOGX_FORMAT=console just run-python:*)")
.is_none(),
"LOGX_FORMAT should not be matched as dangerous"
);
assert!(
checker
.get_dangerous_pattern_info("Bash(OUTPUT_FORMAT=json command)")
.is_none(),
"OUTPUT_FORMAT should not be matched as dangerous"
);
assert!(
checker
.get_dangerous_pattern_info("Bash(format C: /q)")
.is_some(),
"format C: should be matched as dangerous"
);
assert!(
checker
.get_dangerous_pattern_info("Bash(format /dev/sda)")
.is_some(),
"format /dev/sda should be matched as dangerous"
);
assert!(
checker
.get_dangerous_pattern_info("Bash(mkfs.ext4 /dev/sda1)")
.is_some(),
"mkfs.ext4 should be matched as dangerous"
);
assert!(
checker
.get_dangerous_pattern_info("Bash(mkfs -t ext4 /dev/sda1)")
.is_some(),
"mkfs -t should be matched as dangerous"
);
assert!(
checker
.get_dangerous_pattern_info("Bash(formatted=123)")
.is_none(),
"formatted= should not be matched as dangerous"
);
}
}