Skip to main content

roboticus_cli/cli/admin/misc/
local_security.rs

1fn resolve_security_audit_config_path(config_path: &str) -> std::path::PathBuf {
2    if config_path == "roboticus.toml" {
3        return roboticus_core::resolve_config_path(None)
4            .unwrap_or_else(|| std::path::PathBuf::from("roboticus.toml"));
5    }
6    std::path::PathBuf::from(config_path)
7}
8
9pub fn cmd_security_audit(config_path: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
10    let mut findings: Vec<serde_json::Value> = Vec::new();
11    let mut pass_count = 0u32;
12    let mut warn_count = 0u32;
13    #[cfg_attr(not(unix), allow(unused_mut))]
14    let mut fail_count = 0u32;
15
16    let resolved_config_path = resolve_security_audit_config_path(config_path);
17    let config_file = resolved_config_path.as_path();
18
19    // 1. Check config file permissions
20    if config_file.exists() {
21        #[cfg(unix)]
22        {
23            use std::os::unix::fs::PermissionsExt;
24            let meta = std::fs::metadata(config_file)?;
25            let mode = meta.permissions().mode();
26            if mode & 0o077 != 0 {
27                findings.push(serde_json::json!({
28                    "check": "config_permissions",
29                    "status": "fail",
30                    "detail": format!("Config file is world/group-readable (mode {:o})", mode & 0o777),
31                    "fix": format!("chmod 600 {}", config_file.display()),
32                }));
33                fail_count += 1;
34            } else {
35                findings.push(serde_json::json!({
36                    "check": "config_permissions",
37                    "status": "pass",
38                    "detail": format!("mode {:o}", mode & 0o777),
39                }));
40                pass_count += 1;
41            }
42        }
43        #[cfg(not(unix))]
44        {
45            findings.push(serde_json::json!({
46                "check": "config_permissions",
47                "status": "warn",
48                "detail": "non-Unix platform",
49            }));
50            warn_count += 1;
51        }
52    } else {
53        findings.push(serde_json::json!({
54            "check": "config_permissions",
55            "status": "warn",
56            "detail": format!("Config file not found: {}", config_file.display()),
57        }));
58        warn_count += 1;
59    }
60
61    // 2. Check for API keys in config
62    if config_file.exists() {
63        let content = std::fs::read_to_string(config_file)?;
64        let has_plaintext_key =
65            content.contains("api_key") && !content.contains("${") && !content.contains("env(");
66        if has_plaintext_key {
67            findings.push(serde_json::json!({
68                "check": "plaintext_api_keys",
69                "status": "warn",
70                "detail": "Plaintext API keys found in config",
71                "fix": "Use environment variables instead",
72            }));
73            warn_count += 1;
74        } else {
75            findings.push(serde_json::json!({
76                "check": "plaintext_api_keys",
77                "status": "pass",
78            }));
79            pass_count += 1;
80        }
81    }
82
83    // 3. Check bind address
84    if config_file.exists() {
85        let content = std::fs::read_to_string(config_file)?;
86        if content.contains("bind = \"0.0.0.0\"") {
87            findings.push(serde_json::json!({
88                "check": "bind_address",
89                "status": "warn",
90                "detail": "Server bound to 0.0.0.0 (all interfaces)",
91                "fix": "Bind to 127.0.0.1 unless external access is needed",
92            }));
93            warn_count += 1;
94        } else {
95            findings.push(serde_json::json!({
96                "check": "bind_address",
97                "status": "pass",
98            }));
99            pass_count += 1;
100        }
101    }
102
103    // 4. Check wallet file permissions
104    let wallet_path = roboticus_core::home_dir()
105        .join(".roboticus")
106        .join("wallet.json");
107    if wallet_path.exists() {
108        #[cfg(unix)]
109        {
110            use std::os::unix::fs::PermissionsExt;
111            let meta = std::fs::metadata(&wallet_path)?;
112            let mode = meta.permissions().mode();
113            if mode & 0o077 != 0 {
114                findings.push(serde_json::json!({
115                    "check": "wallet_permissions",
116                    "status": "fail",
117                    "detail": format!("Wallet file is world/group-readable (mode {:o})", mode & 0o777),
118                    "fix": format!("chmod 600 {}", wallet_path.display()),
119                }));
120                fail_count += 1;
121            } else {
122                findings.push(serde_json::json!({
123                    "check": "wallet_permissions",
124                    "status": "pass",
125                    "detail": format!("mode {:o}", mode & 0o777),
126                }));
127                pass_count += 1;
128            }
129        }
130        #[cfg(not(unix))]
131        {
132            findings.push(serde_json::json!({
133                "check": "wallet_permissions",
134                "status": "warn",
135                "detail": "non-Unix platform",
136            }));
137            warn_count += 1;
138        }
139    }
140
141    // 5. Check database file permissions
142    let db_path = roboticus_core::home_dir().join(".roboticus").join("state.db");
143    if db_path.exists() {
144        #[cfg(unix)]
145        {
146            use std::os::unix::fs::PermissionsExt;
147            let meta = std::fs::metadata(&db_path)?;
148            let mode = meta.permissions().mode();
149            if mode & 0o077 != 0 {
150                findings.push(serde_json::json!({
151                    "check": "database_permissions",
152                    "status": "warn",
153                    "detail": format!("Database is world/group-readable (mode {:o})", mode & 0o777),
154                    "fix": format!("chmod 600 {}", db_path.display()),
155                }));
156                warn_count += 1;
157            } else {
158                findings.push(serde_json::json!({
159                    "check": "database_permissions",
160                    "status": "pass",
161                }));
162                pass_count += 1;
163            }
164        }
165        #[cfg(not(unix))]
166        {
167            findings.push(serde_json::json!({
168                "check": "database_permissions",
169                "status": "warn",
170                "detail": "non-Unix platform",
171            }));
172            warn_count += 1;
173        }
174    }
175
176    // 6. Check CORS settings
177    if config_file.exists() {
178        let content = std::fs::read_to_string(config_file)?;
179        if content.contains("cors") && content.contains("\"*\"") {
180            findings.push(serde_json::json!({
181                "check": "cors",
182                "status": "warn",
183                "detail": "CORS allows all origins (\"*\")",
184                "fix": "Restrict CORS to specific origins in production",
185            }));
186            warn_count += 1;
187        } else {
188            findings.push(serde_json::json!({
189                "check": "cors",
190                "status": "pass",
191            }));
192            pass_count += 1;
193        }
194    }
195
196    // 7. Check PID file
197    let pid_path = roboticus_core::home_dir()
198        .join(".roboticus")
199        .join("roboticus.pid");
200    if pid_path.exists() {
201        findings.push(serde_json::json!({
202            "check": "pid_file",
203            "status": "pass",
204        }));
205        pass_count += 1;
206    }
207
208    let total = pass_count + warn_count + fail_count;
209
210    if json {
211        println!(
212            "{}",
213            serde_json::to_string_pretty(&serde_json::json!({
214                "pass": pass_count,
215                "warn": warn_count,
216                "fail": fail_count,
217                "total": total,
218                "findings": findings,
219            }))?
220        );
221        return Ok(());
222    }
223
224    // Human-readable output
225    let (DIM, BOLD, ACCENT, GREEN, YELLOW, RED, CYAN, RESET, MONO) = colors();
226    let (OK, ACTION, WARN, DETAIL, ERR) = icons();
227    println!("\n  {BOLD}Roboticus Security Audit{RESET}\n");
228
229    for f in &findings {
230        let check = f["check"].as_str().unwrap_or("");
231        let status = f["status"].as_str().unwrap_or("");
232        let detail = f["detail"].as_str().unwrap_or("");
233        let fix = f["fix"].as_str().unwrap_or("");
234        match status {
235            "fail" => {
236                println!("  {RED}{ERR} FAIL{RESET} {detail}");
237                if !fix.is_empty() {
238                    println!("         Fix: {fix}");
239                }
240            }
241            "warn" => {
242                println!("  {WARN} {detail}");
243                if !fix.is_empty() {
244                    println!("         Recommendation: {fix}");
245                }
246            }
247            "pass" => {
248                let label = check.replace('_', " ");
249                if detail.is_empty() {
250                    println!("  {OK} {label}");
251                } else {
252                    println!("  {OK} {label} ({detail})");
253                }
254            }
255            _ => {}
256        }
257    }
258
259    println!();
260    if fail_count > 0 {
261        println!(
262            "  {RED}{ERR}{RESET} {fail_count} failure(s), {warn_count} warning(s), {pass_count} passed out of {total} checks"
263        );
264    } else if warn_count > 0 {
265        println!("  {WARN} {warn_count} warning(s), {pass_count} passed out of {total} checks");
266    } else {
267        println!("  {OK} All {total} checks passed");
268    }
269    println!();
270
271    Ok(())
272}