Skip to main content

openclaw_scan/scanner/
permissions.rs

1//! File permission security scanner.
2//!
3//! Checks that credential files, settings, and the installation directory
4//! itself have appropriately restrictive filesystem permissions.
5//!
6//! **Unix only** — on Windows all checks are silently skipped.
7
8use std::path::Path;
9
10use anyhow::Result;
11
12use crate::finding::{Category, Finding, Severity};
13use crate::scanner::{ScanContext, Scanner};
14
15pub struct PermissionsScanner;
16
17impl Scanner for PermissionsScanner {
18    fn name(&self) -> &'static str {
19        "permissions"
20    }
21
22    fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
23        let mut findings = Vec::new();
24        check_permissions(&ctx.root, &mut findings);
25        Ok(findings)
26    }
27}
28
29fn check_permissions(root: &Path, findings: &mut Vec<Finding>) {
30    #[cfg(unix)]
31    {
32        use std::os::unix::fs::PermissionsExt;
33
34        let sensitive_files = [
35            (".credentials.json", Severity::Critical, 0o077),
36            ("credentials.json", Severity::Critical, 0o077),
37            ("history.jsonl", Severity::High, 0o044),
38            ("settings.json", Severity::Medium, 0o022),
39        ];
40
41        for (name, severity, bad_mask) in &sensitive_files {
42            let path = root.join(name);
43            if !path.exists() {
44                continue;
45            }
46            if let Ok(meta) = std::fs::metadata(&path) {
47                let mode = meta.permissions().mode();
48                if mode & bad_mask != 0 {
49                    let actual = format!("{:o}", mode & 0o777);
50                    // L-8: 0o044 only sets read bits; 0o022 additionally sets write bits.
51                    let action = if bad_mask & 0o022 != 0 {
52                        "read or write"
53                    } else {
54                        "read"
55                    };
56                    findings.push(
57                        Finding::new(
58                            *severity,
59                            Category::FilePermissions,
60                            format!("Insecure permissions on {}", name),
61                            format!(
62                                "'{}' has permissions {:o} — other users on this system \
63                                 can {} this file, which may expose credentials or allow tampering.",
64                                path.display(),
65                                mode & 0o777,
66                                action,
67                            ),
68                            &path,
69                            format!("Run: chmod 600 \"{}\"", path.display()),
70                        )
71                        .with_evidence(format!("mode={}", actual)),
72                    );
73                }
74            }
75        }
76
77        // The install root directory should not be world-readable.
78        if let Ok(meta) = std::fs::metadata(root) {
79            let mode = meta.permissions().mode();
80            if mode & 0o007 != 0 {
81                findings.push(
82                    Finding::new(
83                        Severity::High,
84                        Category::FilePermissions,
85                        "Installation directory is world-accessible",
86                        format!(
87                            "The directory '{}' has permissions {:o}. Any user on this \
88                             system can list or read files inside it.",
89                            root.display(),
90                            mode & 0o777
91                        ),
92                        root,
93                        format!("Run: chmod 700 \"{}\"", root.display()),
94                    )
95                    .with_evidence(format!("mode={:o}", mode & 0o777)),
96                );
97            }
98        }
99
100        // Check backups/ directory permissions
101        let backups = root.join("backups");
102        if backups.exists() {
103            if let Ok(meta) = std::fs::metadata(&backups) {
104                let mode = meta.permissions().mode();
105                if mode & 0o044 != 0 {
106                    findings.push(
107                        Finding::new(
108                            Severity::High,
109                            Category::FilePermissions,
110                            "Backups directory is readable by others",
111                            format!(
112                                "'{}' contains backup files and has permissions {:o}. \
113                                 Backups may contain credentials or sensitive history.",
114                                backups.display(),
115                                mode & 0o777
116                            ),
117                            &backups,
118                            format!("Run: chmod 700 \"{}\"", backups.display()),
119                        )
120                        .with_evidence(format!("mode={:o}", mode & 0o777)),
121                    );
122                }
123            }
124        }
125    }
126
127    #[cfg(not(unix))]
128    {
129        // Windows does not have Unix-style permission bits.
130        // Emit an informational finding so users know the check was skipped.
131        let _ = root;
132        let _ = findings;
133    }
134}
135
136// ── Tests ─────────────────────────────────────────────────────────────────────
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[cfg(unix)]
143    mod unix_tests {
144        use super::*;
145        use std::os::unix::fs::PermissionsExt;
146        use tempfile::TempDir;
147
148        fn make_file_with_mode(dir: &TempDir, name: &str, mode: u32) -> std::path::PathBuf {
149            let path = dir.path().join(name);
150            std::fs::write(&path, b"test").unwrap();
151            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
152            path
153        }
154
155        #[test]
156        fn detects_world_readable_credentials() {
157            let dir = tempfile::tempdir().unwrap();
158            make_file_with_mode(&dir, ".credentials.json", 0o644);
159            let mut findings = Vec::new();
160            check_permissions(dir.path(), &mut findings);
161            assert!(
162                findings.iter().any(|f| f.severity == Severity::Critical),
163                "should flag world-readable credentials"
164            );
165        }
166
167        #[test]
168        fn no_finding_for_secure_credentials() {
169            let dir = tempfile::tempdir().unwrap();
170            make_file_with_mode(&dir, ".credentials.json", 0o600);
171            let mut findings = Vec::new();
172            check_permissions(dir.path(), &mut findings);
173            assert!(
174                !findings.iter().any(|f| f.title.contains("credentials")),
175                "should not flag 600 credentials file"
176            );
177        }
178
179        #[test]
180        fn detects_world_readable_history() {
181            let dir = tempfile::tempdir().unwrap();
182            make_file_with_mode(&dir, "history.jsonl", 0o644);
183            let mut findings = Vec::new();
184            check_permissions(dir.path(), &mut findings);
185            assert!(
186                findings.iter().any(|f| f.title.contains("history")),
187                "should flag world-readable history"
188            );
189        }
190
191        #[test]
192        fn no_finding_when_files_absent() {
193            let dir = tempfile::tempdir().unwrap();
194            // Don't create any sensitive files
195            let mut findings = Vec::new();
196            check_permissions(dir.path(), &mut findings);
197            // Only the directory itself might trigger; credential files should not
198            assert!(!findings.iter().any(|f| f.title.contains("credentials")),);
199        }
200    }
201
202    #[test]
203    fn scanner_returns_ok() {
204        let dir = tempfile::tempdir().unwrap();
205        let ctx = ScanContext {
206            root: dir.path().to_path_buf(),
207            framework: crate::paths::FrameworkHint::Unknown,
208        };
209        let scanner = PermissionsScanner;
210        assert!(scanner.scan(&ctx).is_ok());
211    }
212}