use std::fmt;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[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 {
pub fn penalty(self) -> u32 {
match self {
Severity::Critical => 25,
Severity::High => 12,
Severity::Medium => 5,
Severity::Low => 2,
Severity::Info => 0,
}
}
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())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Category {
ConfigSecurity,
SecretDetection,
FilePermissions,
NetworkSecurity,
DependencySecurity,
HookSecurity,
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 ",
}
}
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())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub severity: Severity,
pub category: Category,
pub title: String,
pub description: String,
pub path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
pub remediation: String,
}
impl Finding {
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(),
}
}
pub fn with_line(mut self, line: usize) -> Self {
self.line = Some(line);
self
}
pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
self.evidence = Some(evidence.into());
self
}
}
pub fn redact(value: &str, keep: usize) -> String {
let chars: Vec<char> = value.chars().collect();
let safe_keep = keep.min(chars.len().saturating_sub(4));
let prefix: String = chars[..safe_keep].iter().collect();
format!("{}****", prefix)
}
#[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() {
assert_eq!(redact("abc", 6), "****");
}
#[test]
fn redact_minimum_masking_guarantee() {
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() {
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\""));
}
}