openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! `ocls` — security scanner for agentic AI framework installations.
//!
//! # Exit codes
//! | Code | Meaning |
//! |------|---------|
//! | 0 | No findings at or above the minimum severity threshold. |
//! | 1 | One or more findings detected. |
//! | 2 | Scan could not complete (I/O error, invalid path, etc.). |

use std::process;

use clap::Parser;

mod cli;
mod finding;
mod output;
mod paths;
mod report;
mod scanner;

use cli::Cli;
use finding::Severity;
use output::OutputConfig;
use paths::resolve;
use report::Report;
use scanner::run_all;

fn main() {
    match run() {
        Ok(exit_code) => process::exit(exit_code),
        Err(err) => {
            // Print the error chain so the user gets useful context.
            eprintln!("error: {err:#}");
            process::exit(2);
        }
    }
}

/// Inner `main` that returns a result so `?` propagation is ergonomic.
///
/// Returns the exit code (0 = clean, 1 = findings, 2 reserved for `main`
/// error handler).
fn run() -> anyhow::Result<i32> {
    let cli = Cli::parse();

    // ── Resolve paths ────────────────────────────────────────────────────────
    let roots = resolve(cli.paths.clone())?;

    // ── Build scan contexts and run scanners ─────────────────────────────────
    let mut all_findings = Vec::new();
    let mut scanned_paths: Vec<String> = Vec::new();

    for root in &roots {
        let ctx = scanner::ScanContext {
            root: root.path.clone(),
            framework: root.framework,
        };

        let mut findings = run_all(&ctx);

        // If a specific category was requested, keep only those findings.
        if let Some(cat_filter) = cli.category {
            let cat = cat_filter.to_category();
            findings.retain(|f| f.category == cat);
        }

        scanned_paths.push(root.path.display().to_string());
        all_findings.extend(findings);
    }

    // ── Build report ─────────────────────────────────────────────────────────
    let report = Report::build(all_findings, scanned_paths, env!("CARGO_PKG_VERSION"));

    // ── Apply minimum severity filter ─────────────────────────────────────────
    let threshold: Severity = cli.min_severity.to_severity();
    let visible_report = filter_report(report, threshold);

    // ── Render output ─────────────────────────────────────────────────────────
    let cfg = OutputConfig {
        json: cli.json,
        quiet: cli.quiet,
        verbose: cli.verbose,
        // Honour --no-color; actual tty detection happens inside terminal::print.
        color: !cli.no_color,
    };

    output::render(&visible_report, &cfg)?;

    // ── Decide exit code ───────────────────────────────────────────────────────
    // Exit 1 if there are ANY findings at or above the threshold (not just the
    // ones that are displayed — the threshold affects display, not the exit signal).
    let has_findings = visible_report.has_findings_at(threshold);
    Ok(if has_findings { 1 } else { 0 })
}

/// Return a copy of `report` with findings below `min_severity` removed.
///
/// Category sub-scores are **not** recomputed — they always reflect the full
/// picture so users understand the real security posture of each category.
/// The top-level `total_*` counts ARE recomputed to stay in sync with the
/// visible findings list so JSON output is consistent (M-1 fix).
fn filter_report(mut report: Report, min: Severity) -> Report {
    report.findings.retain(|f| f.severity >= min);
    report.total_critical = report
        .findings
        .iter()
        .filter(|f| f.severity == Severity::Critical)
        .count();
    report.total_high = report
        .findings
        .iter()
        .filter(|f| f.severity == Severity::High)
        .count();
    report.total_medium = report
        .findings
        .iter()
        .filter(|f| f.severity == Severity::Medium)
        .count();
    report.total_low = report
        .findings
        .iter()
        .filter(|f| f.severity == Severity::Low)
        .count();
    report.total_info = report
        .findings
        .iter()
        .filter(|f| f.severity == Severity::Info)
        .count();
    report
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use crate::finding::{Category, Finding};

    fn make_report(findings: Vec<Finding>) -> Report {
        Report::build(findings, vec![], env!("CARGO_PKG_VERSION"))
    }

    #[test]
    fn filter_report_removes_low_when_threshold_is_medium() {
        let findings = vec![
            Finding::new(Severity::Low, Category::DataExposure, "Low", "D", "/f", "R"),
            Finding::new(
                Severity::Medium,
                Category::ConfigSecurity,
                "Medium",
                "D",
                "/f",
                "R",
            ),
            Finding::new(
                Severity::Critical,
                Category::SecretDetection,
                "Critical",
                "D",
                "/f",
                "R",
            ),
        ];
        let report = make_report(findings);
        let filtered = filter_report(report, Severity::Medium);
        assert_eq!(filtered.findings.len(), 2);
        assert!(filtered
            .findings
            .iter()
            .all(|f| f.severity >= Severity::Medium));
    }

    #[test]
    fn filter_report_keeps_all_when_threshold_is_info() {
        let findings = vec![
            Finding::new(Severity::Info, Category::DataExposure, "I", "D", "/f", "R"),
            Finding::new(
                Severity::Critical,
                Category::SecretDetection,
                "C",
                "D",
                "/f",
                "R",
            ),
        ];
        let count = findings.len();
        let report = make_report(findings);
        let filtered = filter_report(report, Severity::Info);
        assert_eq!(filtered.findings.len(), count);
    }

    #[test]
    fn filter_report_recomputes_totals() {
        // Build a report with 1 low + 1 critical, then filter to medium+.
        // total_low must drop to 0; total_critical must stay 1.
        let findings = vec![
            Finding::new(Severity::Low, Category::DataExposure, "L", "D", "/f", "R"),
            Finding::new(
                Severity::Critical,
                Category::SecretDetection,
                "C",
                "D",
                "/f",
                "R",
            ),
        ];
        let report = make_report(findings);
        let filtered = filter_report(report, Severity::Medium);
        assert_eq!(filtered.total_critical, 1);
        assert_eq!(filtered.total_low, 0);
    }

    #[test]
    fn filter_report_removes_everything_when_threshold_is_critical_and_no_criticals() {
        let findings = vec![Finding::new(
            Severity::High,
            Category::ConfigSecurity,
            "H",
            "D",
            "/f",
            "R",
        )];
        let report = make_report(findings);
        let filtered = filter_report(report, Severity::Critical);
        assert!(filtered.findings.is_empty());
    }
}