Skip to main content

sparrow/
security.rs

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}