openclaw_scan/scanner/
permissions.rs1use 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 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 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 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 let _ = root;
132 let _ = findings;
133 }
134}
135
136#[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 let mut findings = Vec::new();
196 check_permissions(dir.path(), &mut findings);
197 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}