openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! Scanner orchestration — trait definition and parallel runner.

pub mod config;
pub mod dependencies;
pub mod history;
pub mod hooks;
pub mod network;
pub mod permissions;
pub mod secrets;

use anyhow::Result;
use rayon::prelude::*;

use crate::finding::Finding;
use crate::paths::{FrameworkHint, InstallRoot};

// ── ScanContext ───────────────────────────────────────────────────────────────

/// Shared context passed to every scanner.
#[derive(Debug, Clone)]
pub struct ScanContext {
    /// Resolved installation root (any supported framework).
    pub root: std::path::PathBuf,
    /// Which framework was detected — informational only.
    #[allow(dead_code)]
    pub framework: FrameworkHint,
}

impl ScanContext {
    #[allow(dead_code)]
    pub fn from_root(root: &InstallRoot) -> Self {
        ScanContext {
            root: root.path.clone(),
            framework: root.framework,
        }
    }

    /// Return `root / sub` if it exists, otherwise `None`.
    #[allow(dead_code)]
    pub fn subpath(&self, sub: &str) -> Option<std::path::PathBuf> {
        let p = self.root.join(sub);
        if p.exists() {
            Some(p)
        } else {
            None
        }
    }
}

// ── Scanner trait ─────────────────────────────────────────────────────────────

/// Every scanner implements this trait.
pub trait Scanner: Send + Sync {
    fn name(&self) -> &'static str;
    fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>>;
}

// ── run_all ───────────────────────────────────────────────────────────────────

/// Run all scanners in parallel and collect every finding.
///
/// Scanner errors are logged to stderr and treated as empty results so a
/// single broken scanner never blocks the full report.
pub fn run_all(ctx: &ScanContext) -> Vec<Finding> {
    let scanners: Vec<Box<dyn Scanner>> = vec![
        Box::new(secrets::SecretsScanner),
        Box::new(permissions::PermissionsScanner),
        Box::new(config::ConfigScanner),
        Box::new(network::NetworkScanner),
        Box::new(hooks::HooksScanner),
        Box::new(dependencies::DependenciesScanner),
        Box::new(history::HistoryScanner),
    ];

    scanners
        .par_iter()
        .flat_map(|s| match s.scan(ctx) {
            Ok(findings) => findings,
            Err(e) => {
                eprintln!("ocls: scanner '{}' error: {}", s.name(), e);
                vec![]
            }
        })
        .collect()
}

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

    #[test]
    fn scan_context_subpath_nonexistent() {
        let ctx = ScanContext {
            root: PathBuf::from("/tmp/__ocls_test_nonexistent__"),
            framework: FrameworkHint::Unknown,
        };
        assert!(ctx.subpath("settings.json").is_none());
    }

    #[test]
    fn scan_context_subpath_existing() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("settings.json"), b"{}").unwrap();
        let ctx = ScanContext {
            root: dir.path().to_path_buf(),
            framework: FrameworkHint::Unknown,
        };
        assert!(ctx.subpath("settings.json").is_some());
    }
}