openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! File permission security scanner.
//!
//! Checks that credential files, settings, and the installation directory
//! itself have appropriately restrictive filesystem permissions.
//!
//! **Unix only** — on Windows all checks are silently skipped.

use std::path::Path;

use anyhow::Result;

use crate::finding::{Category, Finding, Severity};
use crate::scanner::{ScanContext, Scanner};

pub struct PermissionsScanner;

impl Scanner for PermissionsScanner {
    fn name(&self) -> &'static str {
        "permissions"
    }

    fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
        let mut findings = Vec::new();
        check_permissions(&ctx.root, &mut findings);
        Ok(findings)
    }
}

fn check_permissions(root: &Path, findings: &mut Vec<Finding>) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let sensitive_files = [
            (".credentials.json", Severity::Critical, 0o077),
            ("credentials.json", Severity::Critical, 0o077),
            ("history.jsonl", Severity::High, 0o044),
            ("settings.json", Severity::Medium, 0o022),
        ];

        for (name, severity, bad_mask) in &sensitive_files {
            let path = root.join(name);
            if !path.exists() {
                continue;
            }
            if let Ok(meta) = std::fs::metadata(&path) {
                let mode = meta.permissions().mode();
                if mode & bad_mask != 0 {
                    let actual = format!("{:o}", mode & 0o777);
                    // L-8: 0o044 only sets read bits; 0o022 additionally sets write bits.
                    let action = if bad_mask & 0o022 != 0 {
                        "read or write"
                    } else {
                        "read"
                    };
                    findings.push(
                        Finding::new(
                            *severity,
                            Category::FilePermissions,
                            format!("Insecure permissions on {}", name),
                            format!(
                                "'{}' has permissions {:o} — other users on this system \
                                 can {} this file, which may expose credentials or allow tampering.",
                                path.display(),
                                mode & 0o777,
                                action,
                            ),
                            &path,
                            format!("Run: chmod 600 \"{}\"", path.display()),
                        )
                        .with_evidence(format!("mode={}", actual)),
                    );
                }
            }
        }

        // The install root directory should not be world-readable.
        if let Ok(meta) = std::fs::metadata(root) {
            let mode = meta.permissions().mode();
            if mode & 0o007 != 0 {
                findings.push(
                    Finding::new(
                        Severity::High,
                        Category::FilePermissions,
                        "Installation directory is world-accessible",
                        format!(
                            "The directory '{}' has permissions {:o}. Any user on this \
                             system can list or read files inside it.",
                            root.display(),
                            mode & 0o777
                        ),
                        root,
                        format!("Run: chmod 700 \"{}\"", root.display()),
                    )
                    .with_evidence(format!("mode={:o}", mode & 0o777)),
                );
            }
        }

        // Check backups/ directory permissions
        let backups = root.join("backups");
        if backups.exists() {
            if let Ok(meta) = std::fs::metadata(&backups) {
                let mode = meta.permissions().mode();
                if mode & 0o044 != 0 {
                    findings.push(
                        Finding::new(
                            Severity::High,
                            Category::FilePermissions,
                            "Backups directory is readable by others",
                            format!(
                                "'{}' contains backup files and has permissions {:o}. \
                                 Backups may contain credentials or sensitive history.",
                                backups.display(),
                                mode & 0o777
                            ),
                            &backups,
                            format!("Run: chmod 700 \"{}\"", backups.display()),
                        )
                        .with_evidence(format!("mode={:o}", mode & 0o777)),
                    );
                }
            }
        }
    }

    #[cfg(not(unix))]
    {
        // Windows does not have Unix-style permission bits.
        // Emit an informational finding so users know the check was skipped.
        let _ = root;
        let _ = findings;
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(unix)]
    mod unix_tests {
        use super::*;
        use std::os::unix::fs::PermissionsExt;
        use tempfile::TempDir;

        fn make_file_with_mode(dir: &TempDir, name: &str, mode: u32) -> std::path::PathBuf {
            let path = dir.path().join(name);
            std::fs::write(&path, b"test").unwrap();
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
            path
        }

        #[test]
        fn detects_world_readable_credentials() {
            let dir = tempfile::tempdir().unwrap();
            make_file_with_mode(&dir, ".credentials.json", 0o644);
            let mut findings = Vec::new();
            check_permissions(dir.path(), &mut findings);
            assert!(
                findings.iter().any(|f| f.severity == Severity::Critical),
                "should flag world-readable credentials"
            );
        }

        #[test]
        fn no_finding_for_secure_credentials() {
            let dir = tempfile::tempdir().unwrap();
            make_file_with_mode(&dir, ".credentials.json", 0o600);
            let mut findings = Vec::new();
            check_permissions(dir.path(), &mut findings);
            assert!(
                !findings.iter().any(|f| f.title.contains("credentials")),
                "should not flag 600 credentials file"
            );
        }

        #[test]
        fn detects_world_readable_history() {
            let dir = tempfile::tempdir().unwrap();
            make_file_with_mode(&dir, "history.jsonl", 0o644);
            let mut findings = Vec::new();
            check_permissions(dir.path(), &mut findings);
            assert!(
                findings.iter().any(|f| f.title.contains("history")),
                "should flag world-readable history"
            );
        }

        #[test]
        fn no_finding_when_files_absent() {
            let dir = tempfile::tempdir().unwrap();
            // Don't create any sensitive files
            let mut findings = Vec::new();
            check_permissions(dir.path(), &mut findings);
            // Only the directory itself might trigger; credential files should not
            assert!(!findings.iter().any(|f| f.title.contains("credentials")),);
        }
    }

    #[test]
    fn scanner_returns_ok() {
        let dir = tempfile::tempdir().unwrap();
        let ctx = ScanContext {
            root: dir.path().to_path_buf(),
            framework: crate::paths::FrameworkHint::Unknown,
        };
        let scanner = PermissionsScanner;
        assert!(scanner.scan(&ctx).is_ok());
    }
}