openclaw-scan 0.1.1

Security scanner for agentic AI framework installations (OpenClaw, Claude Code, and compatible)
Documentation
//! Core finding types shared across all scanners.

use std::fmt;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

// ── Severity ──────────────────────────────────────────────────────────────────

/// Security finding severity.
///
/// Variants are ordered **least to most severe** so that the derived `Ord`
/// implementation satisfies `Critical > High > Medium > Low > Info`.
/// Use `>=` comparisons to test "at least as severe as".
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

impl Severity {
    /// Score penalty applied for each occurrence of this severity.
    pub fn penalty(self) -> u32 {
        match self {
            Severity::Critical => 25,
            Severity::High => 12,
            Severity::Medium => 5,
            Severity::Low => 2,
            Severity::Info => 0,
        }
    }

    /// Short uppercase label used in terminal output.
    pub fn label(self) -> &'static str {
        match self {
            Severity::Critical => "CRITICAL",
            Severity::High => "HIGH    ",
            Severity::Medium => "MEDIUM  ",
            Severity::Low => "LOW     ",
            Severity::Info => "INFO    ",
        }
    }
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.label().trim())
    }
}

// ── Category ──────────────────────────────────────────────────────────────────

/// The security domain a finding belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Category {
    /// settings.json / settings.local.json permission rules.
    ConfigSecurity,
    /// Credential / API key leakage in any stored file.
    SecretDetection,
    /// Filesystem permission issues on sensitive files.
    FilePermissions,
    /// MCP server endpoints and OAuth configuration.
    NetworkSecurity,
    /// Plugin / MCP server supply-chain risk.
    DependencySecurity,
    /// Hook script injection and privilege escalation.
    HookSecurity,
    /// Excessive data retention in history, logs, and backups.
    DataExposure,
}

impl Category {
    pub fn label(self) -> &'static str {
        match self {
            Category::ConfigSecurity => "Config     ",
            Category::SecretDetection => "Secrets    ",
            Category::FilePermissions => "Permissions",
            Category::NetworkSecurity => "Network    ",
            Category::DependencySecurity => "Dependencies",
            Category::HookSecurity => "Hooks      ",
            Category::DataExposure => "Data       ",
        }
    }

    /// All categories in display order.
    pub fn all() -> &'static [Category] {
        &[
            Category::ConfigSecurity,
            Category::SecretDetection,
            Category::FilePermissions,
            Category::NetworkSecurity,
            Category::DependencySecurity,
            Category::HookSecurity,
            Category::DataExposure,
        ]
    }
}

impl fmt::Display for Category {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.label().trim())
    }
}

// ── Finding ───────────────────────────────────────────────────────────────────

/// A single security finding produced by a scanner.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
    /// How severe the issue is.
    pub severity: Severity,

    /// Which security domain the issue belongs to.
    pub category: Category,

    /// Short, human-readable title (one line).
    pub title: String,

    /// Full description of the problem and its risk.
    pub description: String,

    /// The file or directory where the issue was found.
    pub path: PathBuf,

    /// Line number within the file, if applicable.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line: Option<usize>,

    /// Redacted evidence snippet, e.g. `"sk-ant-****"`.
    /// Never contains the full secret value.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub evidence: Option<String>,

    /// Concrete remediation steps the user should take.
    pub remediation: String,
}

impl Finding {
    /// Construct a finding — the primary constructor used by scanners.
    pub fn new(
        severity: Severity,
        category: Category,
        title: impl Into<String>,
        description: impl Into<String>,
        path: impl Into<PathBuf>,
        remediation: impl Into<String>,
    ) -> Self {
        Self {
            severity,
            category,
            title: title.into(),
            description: description.into(),
            path: path.into(),
            line: None,
            evidence: None,
            remediation: remediation.into(),
        }
    }

    /// Attach an optional line number.
    pub fn with_line(mut self, line: usize) -> Self {
        self.line = Some(line);
        self
    }

    /// Attach a **redacted** evidence snippet.
    ///
    /// The caller is responsible for redacting the value before passing it
    /// here. Use [`redact`] from this module for the standard format.
    pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
        self.evidence = Some(evidence.into());
        self
    }
}

// ── Redaction helper ──────────────────────────────────────────────────────────

/// Redact a secret value for safe display, keeping the first `keep` chars.
///
/// Always masks at least 4 characters, regardless of `keep`, to prevent
/// near-complete exposure of short secrets.
///
/// # Examples
/// ```
/// use openclaw_scan::finding::redact;
/// assert_eq!(redact("ghp_abc123xyz789", 4), "ghp_****");
/// ```
pub fn redact(value: &str, keep: usize) -> String {
    let chars: Vec<char> = value.chars().collect();
    // Cap keep so at least 4 chars are always masked.
    let safe_keep = keep.min(chars.len().saturating_sub(4));
    let prefix: String = chars[..safe_keep].iter().collect();
    format!("{}****", prefix)
}

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

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

    #[test]
    fn severity_ordering() {
        assert!(Severity::Critical > Severity::High);
        assert!(Severity::High > Severity::Medium);
        assert!(Severity::Medium > Severity::Low);
        assert!(Severity::Low > Severity::Info);
    }

    #[test]
    fn severity_penalty_values() {
        assert_eq!(Severity::Critical.penalty(), 25);
        assert_eq!(Severity::High.penalty(), 12);
        assert_eq!(Severity::Medium.penalty(), 5);
        assert_eq!(Severity::Low.penalty(), 2);
        assert_eq!(Severity::Info.penalty(), 0);
    }

    #[test]
    fn redact_keeps_prefix() {
        assert_eq!(redact("sk-ant-api01-secret", 6), "sk-ant****");
    }

    #[test]
    fn redact_short_value() {
        // Value shorter than keep+4: prefix is empty, always appends "****".
        assert_eq!(redact("abc", 6), "****");
    }

    #[test]
    fn redact_minimum_masking_guarantee() {
        // Even with keep=6, a 7-char value must mask at least 4.
        // safe_keep = min(6, 7-4) = 3 → "abc****"
        assert_eq!(redact("abcdefg", 6), "abc****");
    }

    #[test]
    fn finding_builder_chain() {
        let f = Finding::new(
            Severity::High,
            Category::SecretDetection,
            "Test",
            "Desc",
            "/tmp/test.json",
            "Fix it",
        )
        .with_line(42)
        .with_evidence("sk-ant****");

        assert_eq!(f.line, Some(42));
        assert_eq!(f.evidence.as_deref(), Some("sk-ant****"));
    }

    #[test]
    fn category_all_complete() {
        // Every category variant must be present in Category::all()
        let all = Category::all();
        assert_eq!(all.len(), 7);
    }

    #[test]
    fn finding_serialises_to_json() {
        let f = Finding::new(
            Severity::Critical,
            Category::SecretDetection,
            "API key found",
            "An API key was detected in history.jsonl",
            "/home/user/.openclaw/history.jsonl",
            "Rotate the key immediately and remove it from history.",
        );
        let json = serde_json::to_string(&f).expect("serialisation failed");
        assert!(json.contains("\"critical\""));
        assert!(json.contains("\"secret_detection\""));
    }
}