roboticus_cli/cli/admin/misc/
local_security.rs1fn 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 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 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 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 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 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 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 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 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}