Skip to main content

openclaw_scan/
finding.rs

1//! Core finding types shared across all scanners.
2
3use std::fmt;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8// ── Severity ──────────────────────────────────────────────────────────────────
9
10/// Security finding severity.
11///
12/// Variants are ordered **least to most severe** so that the derived `Ord`
13/// implementation satisfies `Critical > High > Medium > Low > Info`.
14/// Use `>=` comparisons to test "at least as severe as".
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Severity {
18    Info,
19    Low,
20    Medium,
21    High,
22    Critical,
23}
24
25impl Severity {
26    /// Score penalty applied for each occurrence of this severity.
27    pub fn penalty(self) -> u32 {
28        match self {
29            Severity::Critical => 25,
30            Severity::High => 12,
31            Severity::Medium => 5,
32            Severity::Low => 2,
33            Severity::Info => 0,
34        }
35    }
36
37    /// Short uppercase label used in terminal output.
38    pub fn label(self) -> &'static str {
39        match self {
40            Severity::Critical => "CRITICAL",
41            Severity::High => "HIGH    ",
42            Severity::Medium => "MEDIUM  ",
43            Severity::Low => "LOW     ",
44            Severity::Info => "INFO    ",
45        }
46    }
47}
48
49impl fmt::Display for Severity {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(self.label().trim())
52    }
53}
54
55// ── Category ──────────────────────────────────────────────────────────────────
56
57/// The security domain a finding belongs to.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Category {
61    /// settings.json / settings.local.json permission rules.
62    ConfigSecurity,
63    /// Credential / API key leakage in any stored file.
64    SecretDetection,
65    /// Filesystem permission issues on sensitive files.
66    FilePermissions,
67    /// MCP server endpoints and OAuth configuration.
68    NetworkSecurity,
69    /// Plugin / MCP server supply-chain risk.
70    DependencySecurity,
71    /// Hook script injection and privilege escalation.
72    HookSecurity,
73    /// Excessive data retention in history, logs, and backups.
74    DataExposure,
75}
76
77impl Category {
78    pub fn label(self) -> &'static str {
79        match self {
80            Category::ConfigSecurity => "Config     ",
81            Category::SecretDetection => "Secrets    ",
82            Category::FilePermissions => "Permissions",
83            Category::NetworkSecurity => "Network    ",
84            Category::DependencySecurity => "Dependencies",
85            Category::HookSecurity => "Hooks      ",
86            Category::DataExposure => "Data       ",
87        }
88    }
89
90    /// All categories in display order.
91    pub fn all() -> &'static [Category] {
92        &[
93            Category::ConfigSecurity,
94            Category::SecretDetection,
95            Category::FilePermissions,
96            Category::NetworkSecurity,
97            Category::DependencySecurity,
98            Category::HookSecurity,
99            Category::DataExposure,
100        ]
101    }
102}
103
104impl fmt::Display for Category {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str(self.label().trim())
107    }
108}
109
110// ── Finding ───────────────────────────────────────────────────────────────────
111
112/// A single security finding produced by a scanner.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Finding {
115    /// How severe the issue is.
116    pub severity: Severity,
117
118    /// Which security domain the issue belongs to.
119    pub category: Category,
120
121    /// Short, human-readable title (one line).
122    pub title: String,
123
124    /// Full description of the problem and its risk.
125    pub description: String,
126
127    /// The file or directory where the issue was found.
128    pub path: PathBuf,
129
130    /// Line number within the file, if applicable.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub line: Option<usize>,
133
134    /// Redacted evidence snippet, e.g. `"sk-ant-****"`.
135    /// Never contains the full secret value.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub evidence: Option<String>,
138
139    /// Concrete remediation steps the user should take.
140    pub remediation: String,
141}
142
143impl Finding {
144    /// Construct a finding — the primary constructor used by scanners.
145    pub fn new(
146        severity: Severity,
147        category: Category,
148        title: impl Into<String>,
149        description: impl Into<String>,
150        path: impl Into<PathBuf>,
151        remediation: impl Into<String>,
152    ) -> Self {
153        Self {
154            severity,
155            category,
156            title: title.into(),
157            description: description.into(),
158            path: path.into(),
159            line: None,
160            evidence: None,
161            remediation: remediation.into(),
162        }
163    }
164
165    /// Attach an optional line number.
166    pub fn with_line(mut self, line: usize) -> Self {
167        self.line = Some(line);
168        self
169    }
170
171    /// Attach a **redacted** evidence snippet.
172    ///
173    /// The caller is responsible for redacting the value before passing it
174    /// here. Use [`redact`] from this module for the standard format.
175    pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
176        self.evidence = Some(evidence.into());
177        self
178    }
179}
180
181// ── Redaction helper ──────────────────────────────────────────────────────────
182
183/// Redact a secret value for safe display, keeping the first `keep` chars.
184///
185/// Always masks at least 4 characters, regardless of `keep`, to prevent
186/// near-complete exposure of short secrets.
187///
188/// # Examples
189/// ```
190/// use openclaw_scan::finding::redact;
191/// assert_eq!(redact("ghp_abc123xyz789", 4), "ghp_****");
192/// ```
193pub fn redact(value: &str, keep: usize) -> String {
194    let chars: Vec<char> = value.chars().collect();
195    // Cap keep so at least 4 chars are always masked.
196    let safe_keep = keep.min(chars.len().saturating_sub(4));
197    let prefix: String = chars[..safe_keep].iter().collect();
198    format!("{}****", prefix)
199}
200
201// ── Tests ─────────────────────────────────────────────────────────────────────
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn severity_ordering() {
209        assert!(Severity::Critical > Severity::High);
210        assert!(Severity::High > Severity::Medium);
211        assert!(Severity::Medium > Severity::Low);
212        assert!(Severity::Low > Severity::Info);
213    }
214
215    #[test]
216    fn severity_penalty_values() {
217        assert_eq!(Severity::Critical.penalty(), 25);
218        assert_eq!(Severity::High.penalty(), 12);
219        assert_eq!(Severity::Medium.penalty(), 5);
220        assert_eq!(Severity::Low.penalty(), 2);
221        assert_eq!(Severity::Info.penalty(), 0);
222    }
223
224    #[test]
225    fn redact_keeps_prefix() {
226        assert_eq!(redact("sk-ant-api01-secret", 6), "sk-ant****");
227    }
228
229    #[test]
230    fn redact_short_value() {
231        // Value shorter than keep+4: prefix is empty, always appends "****".
232        assert_eq!(redact("abc", 6), "****");
233    }
234
235    #[test]
236    fn redact_minimum_masking_guarantee() {
237        // Even with keep=6, a 7-char value must mask at least 4.
238        // safe_keep = min(6, 7-4) = 3 → "abc****"
239        assert_eq!(redact("abcdefg", 6), "abc****");
240    }
241
242    #[test]
243    fn finding_builder_chain() {
244        let f = Finding::new(
245            Severity::High,
246            Category::SecretDetection,
247            "Test",
248            "Desc",
249            "/tmp/test.json",
250            "Fix it",
251        )
252        .with_line(42)
253        .with_evidence("sk-ant****");
254
255        assert_eq!(f.line, Some(42));
256        assert_eq!(f.evidence.as_deref(), Some("sk-ant****"));
257    }
258
259    #[test]
260    fn category_all_complete() {
261        // Every category variant must be present in Category::all()
262        let all = Category::all();
263        assert_eq!(all.len(), 7);
264    }
265
266    #[test]
267    fn finding_serialises_to_json() {
268        let f = Finding::new(
269            Severity::Critical,
270            Category::SecretDetection,
271            "API key found",
272            "An API key was detected in history.jsonl",
273            "/home/user/.openclaw/history.jsonl",
274            "Rotate the key immediately and remove it from history.",
275        );
276        let json = serde_json::to_string(&f).expect("serialisation failed");
277        assert!(json.contains("\"critical\""));
278        assert!(json.contains("\"secret_detection\""));
279    }
280}