1use std::fmt;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Category {
61 ConfigSecurity,
63 SecretDetection,
65 FilePermissions,
67 NetworkSecurity,
69 DependencySecurity,
71 HookSecurity,
73 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct Finding {
115 pub severity: Severity,
117
118 pub category: Category,
120
121 pub title: String,
123
124 pub description: String,
126
127 pub path: PathBuf,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub line: Option<usize>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
137 pub evidence: Option<String>,
138
139 pub remediation: String,
141}
142
143impl Finding {
144 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 pub fn with_line(mut self, line: usize) -> Self {
167 self.line = Some(line);
168 self
169 }
170
171 pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
176 self.evidence = Some(evidence.into());
177 self
178 }
179}
180
181pub fn redact(value: &str, keep: usize) -> String {
194 let chars: Vec<char> = value.chars().collect();
195 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#[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 assert_eq!(redact("abc", 6), "****");
233 }
234
235 #[test]
236 fn redact_minimum_masking_guarantee() {
237 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 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}