1use serde::{Deserialize, Serialize};
2use std::path::Path;
3
4use crate::config::Config;
5use crate::hooks::Hook;
6use crate::permissions::PermissionMode;
7use crate::redaction::RedactionFilter;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub enum Severity {
11 Info,
12 Warning,
13 Critical,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SecurityFinding {
18 pub severity: Severity,
19 pub category: String,
20 pub message: String,
21 pub detail: String,
22 pub recommendation: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SecurityAudit {
27 pub score: u32,
28 pub findings: Vec<SecurityFinding>,
29 pub checked_at: String,
30}
31
32impl SecurityAudit {
33 pub fn run(config: &Config, hooks: &[Hook]) -> Self {
34 let mut findings = Vec::new();
35
36 Self::check_permissions(config, &mut findings);
37 Self::check_gateway_senders(config, &mut findings);
38 Self::check_dangerous_tools(config, &mut findings);
39 Self::check_plugins(config, &mut findings);
40 Self::check_hooks(config, hooks, &mut findings);
41 Self::check_secrets_in_repo(&mut findings);
42 Self::check_sandbox_exec(config, &mut findings);
43
44 let score = Self::compute_score(&findings);
45
46 SecurityAudit {
47 score,
48 findings,
49 checked_at: chrono::Utc::now().to_rfc3339(),
50 }
51 }
52
53 fn check_permissions(config: &Config, findings: &mut Vec<SecurityFinding>) {
54 let perms = &config.permissions;
55
56 if perms.paths.deny.is_empty() {
57 findings.push(SecurityFinding {
58 severity: Severity::Warning,
59 category: "permissions".into(),
60 message: "No denied paths configured".into(),
61 detail: "The permissions config has no denied paths, meaning sensitive files like .git, .env, .ssh are not explicitly blocked.".into(),
62 recommendation: "Add default denied paths: .git, .env, .ssh, id_rsa, id_ed25519".into(),
63 });
64 }
65
66 if matches!(perms.mode, PermissionMode::Autonomous) {
67 findings.push(SecurityFinding {
68 severity: Severity::Critical,
69 category: "permissions".into(),
70 message: "Autonomous mode without tool restrictions".into(),
71 detail: "Permission mode is 'autonomous' but no tools are explicitly denied. Dangerous tools like exec could run unrestricted.".into(),
72 recommendation: "Add dangerous tools to deny list or switch to a more restrictive permission mode".into(),
73 });
74 }
75 }
76
77 fn check_gateway_senders(config: &Config, findings: &mut Vec<SecurityFinding>) {
78 let surfaces = &config.surfaces;
79
80 for (name, surface) in [
81 ("telegram", surfaces.telegram.as_ref()),
82 ("discord", surfaces.discord.as_ref()),
83 ("slack", surfaces.slack.as_ref()),
84 ] {
85 if let Some(s) = surface {
86 if s.enabled && s.allow_users.is_empty() {
87 findings.push(SecurityFinding {
88 severity: Severity::Critical,
89 category: "gateway".into(),
90 message: format!("Gateway {} accepts all users", name),
91 detail: format!(
92 "The {} gateway surface is enabled but has no allow_users list, meaning any user can send messages.",
93 name
94 ),
95 recommendation: format!(
96 "Add allow_users list to {} surface config to restrict access",
97 name
98 ),
99 });
100 }
101 }
102 }
103
104 if let Some(e) = surfaces.email.as_ref() {
105 if e.enabled && e.allowed_to.is_empty() {
106 findings.push(SecurityFinding {
107 severity: Severity::Critical,
108 category: "gateway".into(),
109 message: "Gateway email accepts all recipients".into(),
110 detail: "The email surface is enabled but has no allowed_to list, meaning replies can be sent to any address.".into(),
111 recommendation: "Add allowed_to to surfaces.email config to restrict recipients".into(),
112 });
113 }
114 }
115 }
116
117 fn check_dangerous_tools(config: &Config, findings: &mut Vec<SecurityFinding>) {
118 let tools = &config.permissions.tools;
119
120 let dangerous = ["exec", "terminal", "destructive"];
121 for tool_name in &dangerous {
122 if tools.deny.iter().any(|t| t == tool_name) {
123 continue;
124 }
125 if !tools.allow.iter().any(|t| t == tool_name) {
126 findings.push(SecurityFinding {
127 severity: Severity::Warning,
128 category: "tools".into(),
129 message: format!("Dangerous tool '{}' not explicitly denied", tool_name),
130 detail: format!(
131 "Tool '{}' is classified as dangerous but is not in the deny list.",
132 tool_name
133 ),
134 recommendation: format!(
135 "Add '{}' to tools.deny or use permission rules to restrict it",
136 tool_name
137 ),
138 });
139 }
140 }
141 }
142
143 fn check_plugins(config: &Config, findings: &mut Vec<SecurityFinding>) {
144 let plugins_dir = config.config_dir.join("plugins");
145
146 if !plugins_dir.exists() {
147 return;
148 }
149
150 if let Ok(entries) = std::fs::read_dir(&plugins_dir) {
151 for entry in entries.flatten() {
152 let path = entry.path();
153 if path.is_dir() {
154 let manifest_path = path.join("plugin.toml");
155 if manifest_path.exists() {
156 if let Ok(content) = std::fs::read_to_string(&manifest_path) {
157 if !content.contains("allowlist") {
158 findings.push(SecurityFinding {
159 severity: Severity::Warning,
160 category: "plugins".into(),
161 message: format!(
162 "Plugin '{}' has no allowlist",
163 path.file_name().unwrap_or_default().to_string_lossy()
164 ),
165 detail: "Plugin manifest does not define an allowlist.".into(),
166 recommendation:
167 "Add an allowlist section to the plugin manifest".into(),
168 });
169 }
170 }
171 }
172 }
173 }
174 }
175 }
176
177 fn check_hooks(_config: &Config, hooks: &[Hook], findings: &mut Vec<SecurityFinding>) {
178 let redaction = RedactionFilter::new();
179
180 for hook in hooks {
181 if !hook.enabled {
182 continue;
183 }
184
185 let cmd = &hook.command;
186 let lower = cmd.to_lowercase();
187 let suspicious = [
188 "rm -rf",
189 "curl |",
190 "wget |",
191 "eval ",
192 "exec(",
193 "powershell -enc",
194 "base64 -d",
195 ];
196
197 for pattern in &suspicious {
198 if lower.contains(pattern) {
199 findings.push(SecurityFinding {
200 severity: Severity::Critical,
201 category: "hooks".into(),
202 message: format!("Suspicious hook command: {}", cmd),
203 detail: format!(
204 "Hook '{}' contains suspicious pattern '{}'.",
205 hook.id, pattern
206 ),
207 recommendation: "Review and sanitize hook commands".into(),
208 });
209 }
210 }
211
212 if redaction.contains_secret(cmd) {
213 findings.push(SecurityFinding {
214 severity: Severity::Critical,
215 category: "hooks".into(),
216 message: format!("Secret found in hook: {}", hook.id),
217 detail: "Hook command contains what appears to be a secret or API key.".into(),
218 recommendation:
219 "Remove secrets from hook commands and use environment variables".into(),
220 });
221 }
222 }
223 }
224
225 fn check_secrets_in_repo(findings: &mut Vec<SecurityFinding>) {
226 let repo_root = Path::new(".");
227
228 let secret_patterns = [
229 "sk-ant-", "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "xai-", "nvapi-", "hf_", "gsk_",
230 ];
231
232 Self::scan_directory_for_secrets(repo_root, &secret_patterns, findings);
233 }
234
235 fn scan_directory_for_secrets(
236 dir: &Path,
237 patterns: &[&str],
238 findings: &mut Vec<SecurityFinding>,
239 ) {
240 if let Ok(entries) = std::fs::read_dir(dir) {
241 for entry in entries.flatten() {
242 let path = entry.path();
243 if path.is_file() {
244 if let Ok(content) = std::fs::read_to_string(&path) {
245 for pattern in patterns {
246 if content.contains(pattern) {
247 findings.push(SecurityFinding {
248 severity: Severity::Critical,
249 category: "secrets".into(),
250 message: format!(
251 "Potential secret in {}",
252 path.display()
253 ),
254 detail: format!(
255 "File contains pattern '{}': {}",
256 pattern,
257 content.lines().find(|l| l.contains(pattern)).unwrap_or("")
258 ),
259 recommendation: "Remove secrets from source files and use environment variables".into(),
260 });
261 }
262 }
263 }
264 }
265 }
266 }
267 }
268
269 fn check_sandbox_exec(config: &Config, findings: &mut Vec<SecurityFinding>) {
270 let sandbox = &config.defaults.sandbox;
271 let permissions = &config.permissions;
272
273 if sandbox == "local" && permissions.mode == PermissionMode::Autonomous {
274 let tools_deny = &permissions.tools.deny;
275 if !tools_deny.iter().any(|t| t == "exec" || t == "terminal") {
276 findings.push(SecurityFinding {
277 severity: Severity::Critical,
278 category: "sandbox".into(),
279 message: "Exec tool exposed without sandbox".into(),
280 detail: "The exec tool is not denied and sandbox mode is 'local', allowing unrestricted command execution.".into(),
281 recommendation: "Add 'exec' and 'terminal' to permissions.tools.deny or enable sandbox mode".into(),
282 });
283 }
284 }
285 }
286
287 fn compute_score(findings: &[SecurityFinding]) -> u32 {
288 if findings.is_empty() {
289 return 100;
290 }
291
292 let mut score: i32 = 100;
293 for finding in findings {
294 match finding.severity {
295 Severity::Info => {}
296 Severity::Warning => score -= 5,
297 Severity::Critical => score -= 15,
298 }
299 }
300
301 score.max(0) as u32
302 }
303
304 pub fn to_json(&self) -> String {
305 serde_json::to_string_pretty(self).unwrap_or_default()
306 }
307
308 pub fn summary(&self) -> String {
309 let critical = self
310 .findings
311 .iter()
312 .filter(|f| matches!(f.severity, Severity::Critical))
313 .count();
314 let warnings = self
315 .findings
316 .iter()
317 .filter(|f| matches!(f.severity, Severity::Warning))
318 .count();
319 let info = self
320 .findings
321 .iter()
322 .filter(|f| matches!(f.severity, Severity::Info))
323 .count();
324
325 format!(
326 "Security Audit: score {}/100 | {} critical, {} warnings, {} info",
327 self.score, critical, warnings, info
328 )
329 }
330}