Skip to main content

security_rust/
result.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use std::fmt;
4
5/// 单条命中的严重度。声明顺序**从重到轻**(与
6/// [`RiskLevel`](crate::score::RiskLevel) 相反),因此故意不派生 `Ord`:
7/// 拿 `max()` 取"最严重"会静默取到最轻的那条,聚合权重写在 `score` 侧。
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Severity {
10    Critical,
11    High,
12    Medium,
13    Low,
14}
15
16impl fmt::Display for Severity {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Severity::Critical => write!(f, "CRITICAL"),
20            Severity::High => write!(f, "HIGH"),
21            Severity::Medium => write!(f, "MEDIUM"),
22            Severity::Low => write!(f, "LOW"),
23        }
24    }
25}
26
27/// 攻击所属的四大类,与 `src/` 下四个检测器模块一一对应。
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum AttackCategory {
30    Injection,
31    Protocol,
32    Data,
33    File,
34}
35
36/// 小写的分类标签,供日志直接打印。不是处置结论,故不跟 [`Severity`] 的大写风格。
37impl fmt::Display for AttackCategory {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            AttackCategory::Injection => write!(f, "injection"),
41            AttackCategory::Protocol => write!(f, "protocol"),
42            AttackCategory::Data => write!(f, "data"),
43            AttackCategory::File => write!(f, "file"),
44        }
45    }
46}
47
48/// 一条命中:字段是只读快照,切片、告警、审计日志都从这里取,库自身不解释处置动作。
49///
50/// 两个对调用方有实际后果的契约:
51/// - **每个检测器对同一输入至多产出一条**:同一个 `attack_type` 不会重复出现。
52///   有强弱两档的检测器先试强档,未中再试弱档,两档共用同一个 `attack_type`,
53///   只有 [`severity`](DetectionResult::severity) 不同。
54/// - **弱信号固定报 [`Severity::Low`]**,单独不足以构成拒绝理由,详见 `severity` 字段。
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct DetectionResult {
57    /// 检测器名,与 [`Detector::name`](crate::Detector::name) 逐字相等
58    /// (`tests/invariants.rs` 对 32 个检测器逐一核验);同时是
59    /// [`Scanner::scan_with`](crate::Scanner::scan_with) 的过滤键。
60    pub attack_type: String,
61    /// 四大类之一:注入 / 协议 / 数据 / 文件。
62    pub category: AttackCategory,
63    /// 严重度。**弱信号(weak signal)固定为 [`Severity::Low`](5 分)**:
64    /// `Low` 不单是"轻",更是"该形态单独出现是正常内容的常态"—— 正常网页里的
65    /// `<script src=...>`、源码里的单级 `../` 都落这一档,因此
66    /// **单条弱信号不得构成拒绝理由**(参考流水线的拒绝线是
67    /// `risk.level >= RiskLevel::High`,40 分)。弱信号仍然被检出、照常计入
68    /// [`Scanner::assess`](crate::Scanner::assess) 的累加,要不要拒绝看聚合分:
69    /// 多条弱信号叠加会升级(3×Low = 15 → Medium,8×Low = 40 → High)。
70    pub severity: Severity,
71    /// 命中的具体片段,恰好是 `&input[offset..offset + matched_pattern.len()]`,
72    /// 调用方可直接用它给用户标红。
73    pub matched_pattern: String,
74    /// `matched_pattern` 在输入中的**字节**偏移(UTF-8 字节下标,不是字符数)。
75    pub offset: usize,
76    /// 人类可读说明(英文,供日志直接打印)。
77    pub message: String,
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn sample() -> DetectionResult {
85        DetectionResult {
86            attack_type: "xss".into(),
87            category: AttackCategory::Injection,
88            severity: Severity::Critical,
89            matched_pattern: "<script>".into(),
90            offset: 0,
91            message: "XSS detected".into(),
92        }
93    }
94
95    #[test]
96    fn severity_display_uppercase() {
97        assert_eq!(Severity::Critical.to_string(), "CRITICAL");
98        assert_eq!(Severity::High.to_string(), "HIGH");
99        assert_eq!(Severity::Medium.to_string(), "MEDIUM");
100        assert_eq!(Severity::Low.to_string(), "LOW");
101    }
102
103    #[test]
104    fn severity_equality() {
105        assert_eq!(Severity::Critical, Severity::Critical);
106        assert_ne!(Severity::Critical, Severity::High);
107        assert_ne!(Severity::Medium, Severity::Low);
108    }
109
110    #[test]
111    fn attack_category_display_tags() {
112        assert_eq!(AttackCategory::Injection.to_string(), "injection");
113        assert_eq!(AttackCategory::Protocol.to_string(), "protocol");
114        assert_eq!(AttackCategory::Data.to_string(), "data");
115        assert_eq!(AttackCategory::File.to_string(), "file");
116    }
117
118    #[test]
119    fn attack_category_equality() {
120        assert_eq!(AttackCategory::Injection, AttackCategory::Injection);
121        assert_ne!(AttackCategory::Injection, AttackCategory::Protocol);
122        assert_ne!(AttackCategory::Data, AttackCategory::File);
123    }
124
125    #[test]
126    fn detection_result_clone_and_equality() {
127        let a = sample();
128        assert_eq!(a, a.clone());
129    }
130
131    #[test]
132    fn detection_result_field_difference_changes_equality() {
133        let a = sample();
134        let b = DetectionResult {
135            severity: Severity::High,
136            ..a.clone()
137        };
138        assert_ne!(a, b);
139    }
140
141    #[test]
142    fn detection_result_debug_output() {
143        let dbg = format!("{:?}", sample());
144        assert!(dbg.contains("xss"));
145        assert!(dbg.contains("Injection"));
146        assert!(dbg.contains("CRITICAL") || dbg.contains("Critical"));
147    }
148}