Skip to main content

security_rust/
scanner.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::data::{
4    CsvInjectionDetector, DeserializationDetector, FormulaInjectionDetector, JwtAttackDetector,
5    MailHeaderDetector, PrototypePollutionDetector, ReDoSDetector,
6};
7use crate::file::{DataLeakDetector, PathTraversalDetector, UploadDetector};
8use crate::injection::{
9    CommandInjectionDetector, FormatStringDetector, GraphQlInjectionDetector,
10    JndiInjectionDetector, LdapInjectionDetector, NoSqlInjectionDetector, SqlInjectionDetector,
11    SsiInjectionDetector, SstiDetector, XPathInjectionDetector, XssDetector,
12};
13use crate::protocol::{
14    CorsDetector, DnsRebindingDetector, HeaderInjectionDetector, HostHeaderDetector,
15    HttpParameterPollutionDetector, Log4ShellDetector, OpenRedirectDetector,
16    RequestSmugglingDetector, SsrfDetector, WebSocketDetector, XxeDetector,
17};
18use crate::score::{self, RiskAssessment};
19use crate::{Detector, result::DetectionResult};
20
21/// 检测器集合:按注册顺序对同一输入逐个运行。
22///
23/// ## 两套装配语义
24///
25/// [`Scanner::default()`] / [`Scanner::new()`] 一键装配全部 32 个检测器,零配置可用;
26/// [`Scanner::builder()`] 则**从空表起步** —— 忘了 `.with_detector()` 也能 `build()`
27/// 成功,得到一个什么都不检的 scanner,`scan()` 恒返回空且不报错。自定义装配后
28/// 用 [`Scanner::detector_names`] 自检注册结果(失败是静默的,这是最容易踩的坑)。
29///
30/// ## `scan` 与 `scan_with`
31///
32/// - [`scan`](Self::scan) 跑全部检测器;[`scan_with`](Self::scan_with) 只跑名字在
33///   名单里的那些。名单里的**未知名字静默跳过、不报错**(`scan_with(x, &["typo"])`
34///   恒为空),重复名字也不会让检测器跑两遍 —— 调用方可以放心用一份跨版本共享的
35///   名单,不必逐个做存在性检查。
36/// - 两者对同一输入**每个检测器至多产出一条**结果(有强弱两档的先试强档)。
37/// - 结果顺序 = 注册顺序,同一输入任意次扫描结果完全一致(无状态、确定性)。
38///
39/// ## 只报告,不拦截
40///
41/// 库自身不做任何拒绝动作:拒绝线(如 `risk.level >= RiskLevel::High`)由调用方定,
42/// 参考 `examples/waf.rs`。
43pub struct Scanner {
44    detectors: Vec<Box<dyn Detector>>,
45}
46
47impl Default for Scanner {
48    fn default() -> Self {
49        Self {
50            detectors: vec![
51                // Injection
52                Box::new(XssDetector),
53                Box::new(SqlInjectionDetector),
54                Box::new(CommandInjectionDetector),
55                Box::new(NoSqlInjectionDetector),
56                Box::new(LdapInjectionDetector),
57                Box::new(XPathInjectionDetector),
58                Box::new(JndiInjectionDetector),
59                Box::new(SsiInjectionDetector),
60                Box::new(GraphQlInjectionDetector),
61                Box::new(SstiDetector),
62                Box::new(FormatStringDetector),
63                // Protocol
64                Box::new(SsrfDetector),
65                Box::new(XxeDetector),
66                Box::new(HeaderInjectionDetector),
67                Box::new(HostHeaderDetector),
68                Box::new(RequestSmugglingDetector),
69                Box::new(OpenRedirectDetector),
70                Box::new(CorsDetector),
71                Box::new(WebSocketDetector),
72                Box::new(DnsRebindingDetector),
73                Box::new(Log4ShellDetector),
74                Box::new(HttpParameterPollutionDetector),
75                // Data
76                Box::new(DeserializationDetector),
77                Box::new(CsvInjectionDetector),
78                Box::new(MailHeaderDetector),
79                Box::new(JwtAttackDetector),
80                Box::new(PrototypePollutionDetector),
81                Box::new(FormulaInjectionDetector),
82                Box::new(ReDoSDetector),
83                // File
84                Box::new(PathTraversalDetector),
85                Box::new(UploadDetector),
86                Box::new(DataLeakDetector),
87            ],
88        }
89    }
90}
91
92impl Scanner {
93    /// 等价于 [`Scanner::default()`]:装配全部 32 个检测器。空表起步请走
94    /// [`Scanner::builder()`]。
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// 自定义装配的起点,**空表**、不含任何检测器(与 `default()` 不同)。
100    pub fn builder() -> ScannerBuilder {
101        ScannerBuilder::default()
102    }
103
104    /// 用全部检测器扫描 `input`,返回全部命中(顺序 = 注册顺序)。
105    ///
106    /// 输入干净或为空串时返回空 `Vec` —— 没有错误通道,检测器不产生"扫描失败"这种状态。
107    /// 有强弱两档的检测器先试强档,故**每个 `attack_type` 至多出现一次**;弱信号
108    /// 也在结果里(`severity == Severity::Low`),不会静默漏报。
109    ///
110    /// 判"要不要拒绝这条输入"别只看结果非空 —— 单条弱信号不足以越过拒绝线,用
111    /// [`Scanner::assess`] 看聚合分。
112    // 丢掉扫描结果 = 静默放行:命中被算出来又没人看,等于 WAF 没装。
113    #[must_use]
114    pub fn scan(&self, input: &str) -> Vec<DetectionResult> {
115        let mut results = Vec::new();
116        for detector in &self.detectors {
117            if let Some(result) = detector.detect(input) {
118                results.push(result);
119            }
120        }
121        results
122    }
123
124    /// 把「有/无命中」升级为「累积风险」:内部就是 `scan` + [`score::assess`],
125    /// 多条低危叠加可升到更高等级。
126    ///
127    /// 返回的 [`RiskAssessment`] 带三项:`level`(聚合等级)、`score`(权重和)、
128    /// `results`(参与聚合的命中条数)。拒绝线看 `level` 而不是"有无命中":
129    /// 单条弱信号(Low,5 分)够不到 `RiskLevel::High`(40 分),而多条弱信号叠加
130    /// 会升级 —— 这正是弱信号档存在的意义(见 [`DetectionResult::severity`])。
131    #[must_use]
132    pub fn assess(&self, input: &str) -> RiskAssessment {
133        score::assess(&self.scan(input))
134    }
135
136    /// 只跑名字出现在 `names` 里的检测器,其余完全跳过。
137    ///
138    /// `names` 里的**未知名字静默忽略**(不报错、不告警):`scan_with(x, &["typo"])`
139    /// 返回空 `Vec` 而不是失败;重复的名字不会让同一检测器跑两遍。语义上等价于
140    /// `scan(input)` 再按 `attack_type ∈ names` 过滤(`tests/invariants.rs` 对 32 个
141    /// 名字逐一核验这条等价性),但省掉了不相关检测器的正则开销。
142    ///
143    /// 空名单 ⇒ 恒返回空 `Vec`。
144    #[must_use]
145    pub fn scan_with(&self, input: &str, names: &[&str]) -> Vec<DetectionResult> {
146        let mut results = Vec::new();
147        for detector in &self.detectors {
148            if names.contains(&detector.name())
149                && let Some(result) = detector.detect(input)
150            {
151                results.push(result);
152            }
153        }
154        results
155    }
156
157    /// 按注册顺序列出全部检测器名字。
158    ///
159    /// 用途是自检:`Scanner::builder()` 从空表起步,装完可以先看这里确认装上了什么;
160    /// 也可以拿它给 [`Scanner::scan_with`] 拼名单。名字即
161    /// [`Detector::name`] 的返回值,同时是 [`DetectionResult::attack_type`] 的取值。
162    ///
163    /// 返回 `impl Iterator<Item = &'static str> + '_`:名字本身是 `'static` 字面量,
164    /// 迭代器只在借用 `self` 期间有效;收集成 `Vec<&'static str>` 即可脱离 scanner 保存。
165    pub fn detector_names(&self) -> impl Iterator<Item = &'static str> + '_ {
166        self.detectors.iter().map(|d| d.name())
167    }
168}
169
170/// [`Scanner`] 的自定义装配器。用 [`Scanner::builder()`] 构造。
171///
172/// **从空表起步**:与 `Scanner::default()` 的 32 个检测器无关,装多少就是多少;
173/// 一个都不装也能 `build()` 成功,得到的 scanner 恒返回空结果 —— 这是行为不是错误,
174/// 自检用 [`Scanner::detector_names`]。
175#[derive(Default)]
176pub struct ScannerBuilder {
177    detectors: Vec<Box<dyn Detector>>,
178}
179
180impl ScannerBuilder {
181    /// 追加一个检测器(按调用顺序注册,也决定 `scan()` 的结果顺序)。
182    ///
183    /// 同一个检测器注册两次不报错,`scan()` 里就会跑两遍、产出两条结果 ——
184    /// 名字不用于去重(32 个内置检测器名字唯一,由 `tests/invariants.rs` 钉住)。
185    #[must_use]
186    pub fn with_detector(mut self, detector: Box<dyn Detector>) -> Self {
187        self.detectors.push(detector);
188        self
189    }
190
191    /// 产出 scanner。不会失败,也不会补默认检测器(空表就是空 scanner)。
192    pub fn build(self) -> Scanner {
193        Scanner {
194            detectors: self.detectors,
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::{AttackCategory, Severity};
203
204    /// 弱信号载荷:`<script>` 的存在本身在正常网页里极常见,故判 Low。
205    const XSS: &str = "<script>alert(1)</script>";
206
207    /// 强信号载荷:事件处理器是攻击者可控的可执行形态,判 Critical。
208    /// 需要走 Critical 路径的测试用这个,别把 `XSS` 改判 —— 那会让测试名说谎。
209    const XSS_CRITICAL: &str = "<img src=x onerror=alert(1)>";
210
211    fn types(results: &[DetectionResult]) -> Vec<&str> {
212        results.iter().map(|r| r.attack_type.as_str()).collect()
213    }
214
215    #[test]
216    fn default_scanner_registers_all_32_detectors() {
217        assert_eq!(Scanner::default().detectors.len(), 32);
218    }
219
220    #[test]
221    fn default_scanner_registers_each_detector_once() {
222        let scanner = Scanner::default();
223        let mut names: Vec<&str> = scanner.detectors.iter().map(|d| d.name()).collect();
224        names.sort_unstable();
225        let before = names.len();
226        names.dedup();
227        assert_eq!(names.len(), before, "检测器名字重复: {names:?}");
228    }
229
230    #[test]
231    fn new_detectors_are_registered() {
232        let scanner = Scanner::default();
233        let names: Vec<&str> = scanner.detectors.iter().map(|d| d.name()).collect();
234        for expected in [
235            "log4shell",
236            "hpp",
237            "formula_injection",
238            "redos",
239            "format_string",
240        ] {
241            assert!(names.contains(&expected), "缺少检测器: {expected}");
242        }
243    }
244
245    /// 只覆盖新增的这几个检测器,不代表整个 scanner 对干净输入全静默
246    /// ——粗粒度层(csv_injection 认行首 `=+-@`、ssti 认 `${`)本来就会命中,
247    /// 判定"整机干净"要看 assess 的评分,别拿这个测试当整机基线。
248    #[test]
249    fn new_detectors_only_do_not_fire_on_clean_input() {
250        let scanner = Scanner::default();
251        for input in [
252            "the price is ${amount}",
253            "a=1&b=2",
254            "= 5",
255            "-3 度",
256            "a@b.com",
257            "100% safe",
258            "50% off",
259            "line one\r\nline two",
260            "5*(3+2)",
261        ] {
262            let results = scanner.scan_with(
263                input,
264                &[
265                    "log4shell",
266                    "hpp",
267                    "formula_injection",
268                    "redos",
269                    "format_string",
270                ],
271            );
272            assert!(
273                results.is_empty(),
274                "新检测器误报 {input:?}: {:?}",
275                types(&results)
276            );
277        }
278    }
279
280    #[test]
281    fn assess_returns_none_for_clean_input() {
282        let a = Scanner::default().assess("hello world 123");
283        assert_eq!(a.level, crate::score::RiskLevel::None);
284        assert_eq!(a.score, 0);
285        assert_eq!(a.results, 0);
286    }
287
288    #[test]
289    fn assess_returns_critical_for_critical_hit() {
290        let a = Scanner::default().assess(XSS_CRITICAL);
291        assert_eq!(a.level, crate::score::RiskLevel::Critical);
292        assert_eq!(a.results, 1);
293        assert!(a.score > 0);
294    }
295
296    /// 分档契约:标签「存在」是弱信号,报 Low 且不足以触发拒绝;
297    /// 事件处理器是强信号,报 Critical 且直接拒绝。
298    /// 这条钉住的是「正常网页不该被拒」这个产品行为。
299    #[test]
300    fn tag_presence_is_low_while_event_handler_is_critical() {
301        let scanner = Scanner::default();
302
303        let weak = scanner.scan(XSS);
304        assert_eq!(weak.len(), 1);
305        assert_eq!(weak[0].attack_type, "xss");
306        assert_eq!(weak[0].severity, Severity::Low);
307        assert!(
308            scanner.assess(XSS).level < crate::score::RiskLevel::High,
309            "标签存在不得单独越线"
310        );
311
312        let strong = scanner.scan(XSS_CRITICAL);
313        assert_eq!(strong[0].severity, Severity::Critical);
314        assert_eq!(
315            scanner.assess(XSS_CRITICAL).level,
316            crate::score::RiskLevel::Critical
317        );
318    }
319
320    #[test]
321    fn assess_escalates_on_stacked_medium_hits() {
322        let input = "=cmd|' /C calc'!A0 `cat /etc/passwd` ../../../etc/passwd";
323        let a = Scanner::default().assess(input);
324        assert!(a.results >= 3, "期望多条命中,实际 {:?}", a);
325        assert!(
326            a.level >= crate::score::RiskLevel::High,
327            "叠加后应升级: {:?}",
328            a
329        );
330    }
331
332    #[test]
333    fn scan_detects_known_attack() {
334        let results = Scanner::default().scan(XSS_CRITICAL);
335        assert_eq!(results.len(), 1);
336        assert_eq!(results[0].attack_type, "xss");
337        assert_eq!(results[0].category, AttackCategory::Injection);
338        assert_eq!(results[0].severity, Severity::Critical);
339    }
340
341    #[test]
342    fn scan_returns_empty_for_clean_input() {
343        for input in [
344            "hello world 123",
345            "q=2024--2025",
346            "q=donation=5",
347            "穿越之霸道总裁爱上我--重生之都市修仙",
348        ] {
349            assert!(
350                Scanner::default().scan(input).is_empty(),
351                "false positive: {input}"
352            );
353        }
354    }
355
356    #[test]
357    fn scan_returns_empty_for_empty_string() {
358        assert!(Scanner::default().scan("").is_empty());
359    }
360
361    #[test]
362    fn scan_with_filters_by_detector_name() {
363        let scanner = Scanner::default();
364        assert_eq!(scanner.scan_with(XSS, &["xss"]).len(), 1);
365        assert!(scanner.scan_with(XSS, &["sql_injection"]).is_empty());
366        assert!(scanner.scan_with(XSS, &["unknown"]).is_empty());
367    }
368
369    #[test]
370    fn scan_with_multiple_names() {
371        let input = "1 UNION SELECT password FROM users; <script>alert(1)</script>";
372        let results = Scanner::default().scan_with(input, &["sql_injection", "xss"]);
373        let t = types(&results);
374        assert!(t.contains(&"sql_injection") && t.contains(&"xss"));
375    }
376
377    #[test]
378    fn new_matches_default() {
379        assert_eq!(
380            Scanner::new().detectors.len(),
381            Scanner::default().detectors.len()
382        );
383    }
384
385    #[test]
386    fn builder_without_detectors_scans_nothing() {
387        assert!(Scanner::builder().build().scan(XSS).is_empty());
388    }
389
390    #[test]
391    fn builder_with_custom_detector() {
392        let scanner = Scanner::builder()
393            .with_detector(Box::new(crate::injection::XssDetector))
394            .build();
395        let results = scanner.scan(XSS);
396        assert_eq!(results.len(), 1);
397        assert_eq!(results[0].attack_type, "xss");
398    }
399
400    #[test]
401    fn detection_result_has_pattern_offset_and_message() {
402        let results = Scanner::default().scan(XSS);
403        let r = &results[0];
404        assert!(!r.matched_pattern.is_empty());
405        assert!(r.offset <= XSS.len());
406        assert!(!r.message.is_empty());
407    }
408
409    #[test]
410    fn scan_is_deterministic() {
411        let input = "SELECT 1; <script>alert(1)</script>";
412        assert_eq!(
413            Scanner::default().scan(input),
414            Scanner::default().scan(input)
415        );
416    }
417}