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
21pub struct Scanner {
22    detectors: Vec<Box<dyn Detector>>,
23}
24
25impl Default for Scanner {
26    fn default() -> Self {
27        Self {
28            detectors: vec![
29                // Injection
30                Box::new(XssDetector),
31                Box::new(SqlInjectionDetector),
32                Box::new(CommandInjectionDetector),
33                Box::new(NoSqlInjectionDetector),
34                Box::new(LdapInjectionDetector),
35                Box::new(XPathInjectionDetector),
36                Box::new(JndiInjectionDetector),
37                Box::new(SsiInjectionDetector),
38                Box::new(GraphQlInjectionDetector),
39                Box::new(SstiDetector),
40                Box::new(FormatStringDetector),
41                // Protocol
42                Box::new(SsrfDetector),
43                Box::new(XxeDetector),
44                Box::new(HeaderInjectionDetector),
45                Box::new(HostHeaderDetector),
46                Box::new(RequestSmugglingDetector),
47                Box::new(OpenRedirectDetector),
48                Box::new(CorsDetector),
49                Box::new(WebSocketDetector),
50                Box::new(DnsRebindingDetector),
51                Box::new(Log4ShellDetector),
52                Box::new(HttpParameterPollutionDetector),
53                // Data
54                Box::new(DeserializationDetector),
55                Box::new(CsvInjectionDetector),
56                Box::new(MailHeaderDetector),
57                Box::new(JwtAttackDetector),
58                Box::new(PrototypePollutionDetector),
59                Box::new(FormulaInjectionDetector),
60                Box::new(ReDoSDetector),
61                // File
62                Box::new(PathTraversalDetector),
63                Box::new(UploadDetector),
64                Box::new(DataLeakDetector),
65            ],
66        }
67    }
68}
69
70impl Scanner {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    pub fn builder() -> ScannerBuilder {
76        ScannerBuilder::default()
77    }
78
79    pub fn scan(&self, input: &str) -> Vec<DetectionResult> {
80        let mut results = Vec::new();
81        for detector in &self.detectors {
82            if let Some(result) = detector.detect(input) {
83                results.push(result);
84            }
85        }
86        results
87    }
88
89    /// 把「有/无命中」升级为「累积风险」:多条低危叠加可升到更高等级。
90    pub fn assess(&self, input: &str) -> RiskAssessment {
91        score::assess(&self.scan(input))
92    }
93
94    pub fn scan_with(&self, input: &str, names: &[&str]) -> Vec<DetectionResult> {
95        let mut results = Vec::new();
96        for detector in &self.detectors {
97            if names.contains(&detector.name())
98                && let Some(result) = detector.detect(input)
99            {
100                results.push(result);
101            }
102        }
103        results
104    }
105}
106
107#[derive(Default)]
108pub struct ScannerBuilder {
109    detectors: Vec<Box<dyn Detector>>,
110}
111
112impl ScannerBuilder {
113    pub fn with_detector(mut self, detector: Box<dyn Detector>) -> Self {
114        self.detectors.push(detector);
115        self
116    }
117
118    pub fn build(self) -> Scanner {
119        Scanner {
120            detectors: self.detectors,
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::{AttackCategory, Severity};
129
130    /// 弱信号载荷:`<script>` 的存在本身在正常网页里极常见,故判 Low。
131    const XSS: &str = "<script>alert(1)</script>";
132
133    /// 强信号载荷:事件处理器是攻击者可控的可执行形态,判 Critical。
134    /// 需要走 Critical 路径的测试用这个,别把 `XSS` 改判 —— 那会让测试名说谎。
135    const XSS_CRITICAL: &str = "<img src=x onerror=alert(1)>";
136
137    fn types(results: &[DetectionResult]) -> Vec<&str> {
138        results.iter().map(|r| r.attack_type.as_str()).collect()
139    }
140
141    #[test]
142    fn default_scanner_registers_all_32_detectors() {
143        assert_eq!(Scanner::default().detectors.len(), 32);
144    }
145
146    #[test]
147    fn default_scanner_registers_each_detector_once() {
148        let scanner = Scanner::default();
149        let mut names: Vec<&str> = scanner.detectors.iter().map(|d| d.name()).collect();
150        names.sort_unstable();
151        let before = names.len();
152        names.dedup();
153        assert_eq!(names.len(), before, "检测器名字重复: {names:?}");
154    }
155
156    #[test]
157    fn new_detectors_are_registered() {
158        let scanner = Scanner::default();
159        let names: Vec<&str> = scanner.detectors.iter().map(|d| d.name()).collect();
160        for expected in [
161            "log4shell",
162            "hpp",
163            "formula_injection",
164            "redos",
165            "format_string",
166        ] {
167            assert!(names.contains(&expected), "缺少检测器: {expected}");
168        }
169    }
170
171    /// 只覆盖新增的这几个检测器,不代表整个 scanner 对干净输入全静默
172    /// ——粗粒度层(csv_injection 认行首 `=+-@`、ssti 认 `${`)本来就会命中,
173    /// 判定"整机干净"要看 assess 的评分,别拿这个测试当整机基线。
174    #[test]
175    fn new_detectors_only_do_not_fire_on_clean_input() {
176        let scanner = Scanner::default();
177        for input in [
178            "the price is ${amount}",
179            "a=1&b=2",
180            "= 5",
181            "-3 度",
182            "a@b.com",
183            "100% safe",
184            "50% off",
185            "line one\r\nline two",
186            "5*(3+2)",
187        ] {
188            let results = scanner.scan_with(
189                input,
190                &[
191                    "log4shell",
192                    "hpp",
193                    "formula_injection",
194                    "redos",
195                    "format_string",
196                ],
197            );
198            assert!(
199                results.is_empty(),
200                "新检测器误报 {input:?}: {:?}",
201                types(&results)
202            );
203        }
204    }
205
206    #[test]
207    fn assess_returns_none_for_clean_input() {
208        let a = Scanner::default().assess("hello world 123");
209        assert_eq!(a.level, crate::score::RiskLevel::None);
210        assert_eq!(a.score, 0);
211        assert_eq!(a.results, 0);
212    }
213
214    #[test]
215    fn assess_returns_critical_for_critical_hit() {
216        let a = Scanner::default().assess(XSS_CRITICAL);
217        assert_eq!(a.level, crate::score::RiskLevel::Critical);
218        assert_eq!(a.results, 1);
219        assert!(a.score > 0);
220    }
221
222    /// 分档契约:标签「存在」是弱信号,报 Low 且不足以触发拒绝;
223    /// 事件处理器是强信号,报 Critical 且直接拒绝。
224    /// 这条钉住的是「正常网页不该被拒」这个产品行为。
225    #[test]
226    fn tag_presence_is_low_while_event_handler_is_critical() {
227        let scanner = Scanner::default();
228
229        let weak = scanner.scan(XSS);
230        assert_eq!(weak.len(), 1);
231        assert_eq!(weak[0].attack_type, "xss");
232        assert_eq!(weak[0].severity, Severity::Low);
233        assert!(
234            scanner.assess(XSS).level < crate::score::RiskLevel::High,
235            "标签存在不得单独越线"
236        );
237
238        let strong = scanner.scan(XSS_CRITICAL);
239        assert_eq!(strong[0].severity, Severity::Critical);
240        assert_eq!(
241            scanner.assess(XSS_CRITICAL).level,
242            crate::score::RiskLevel::Critical
243        );
244    }
245
246    #[test]
247    fn assess_escalates_on_stacked_medium_hits() {
248        let input = "=cmd|' /C calc'!A0 `cat /etc/passwd` ../../../etc/passwd";
249        let a = Scanner::default().assess(input);
250        assert!(a.results >= 3, "期望多条命中,实际 {:?}", a);
251        assert!(
252            a.level >= crate::score::RiskLevel::High,
253            "叠加后应升级: {:?}",
254            a
255        );
256    }
257
258    #[test]
259    fn scan_detects_known_attack() {
260        let results = Scanner::default().scan(XSS_CRITICAL);
261        assert_eq!(results.len(), 1);
262        assert_eq!(results[0].attack_type, "xss");
263        assert_eq!(results[0].category, AttackCategory::Injection);
264        assert_eq!(results[0].severity, Severity::Critical);
265    }
266
267    #[test]
268    fn scan_returns_empty_for_clean_input() {
269        for input in [
270            "hello world 123",
271            "q=2024--2025",
272            "q=donation=5",
273            "穿越之霸道总裁爱上我--重生之都市修仙",
274        ] {
275            assert!(
276                Scanner::default().scan(input).is_empty(),
277                "false positive: {input}"
278            );
279        }
280    }
281
282    #[test]
283    fn scan_returns_empty_for_empty_string() {
284        assert!(Scanner::default().scan("").is_empty());
285    }
286
287    #[test]
288    fn scan_with_filters_by_detector_name() {
289        let scanner = Scanner::default();
290        assert_eq!(scanner.scan_with(XSS, &["xss"]).len(), 1);
291        assert!(scanner.scan_with(XSS, &["sql_injection"]).is_empty());
292        assert!(scanner.scan_with(XSS, &["unknown"]).is_empty());
293    }
294
295    #[test]
296    fn scan_with_multiple_names() {
297        let input = "1 UNION SELECT password FROM users; <script>alert(1)</script>";
298        let results = Scanner::default().scan_with(input, &["sql_injection", "xss"]);
299        let t = types(&results);
300        assert!(t.contains(&"sql_injection") && t.contains(&"xss"));
301    }
302
303    #[test]
304    fn new_matches_default() {
305        assert_eq!(
306            Scanner::new().detectors.len(),
307            Scanner::default().detectors.len()
308        );
309    }
310
311    #[test]
312    fn builder_without_detectors_scans_nothing() {
313        assert!(Scanner::builder().build().scan(XSS).is_empty());
314    }
315
316    #[test]
317    fn builder_with_custom_detector() {
318        let scanner = Scanner::builder()
319            .with_detector(Box::new(crate::injection::XssDetector))
320            .build();
321        let results = scanner.scan(XSS);
322        assert_eq!(results.len(), 1);
323        assert_eq!(results[0].attack_type, "xss");
324    }
325
326    #[test]
327    fn detection_result_has_pattern_offset_and_message() {
328        let results = Scanner::default().scan(XSS);
329        let r = &results[0];
330        assert!(!r.matched_pattern.is_empty());
331        assert!(r.offset <= XSS.len());
332        assert!(!r.message.is_empty());
333    }
334
335    #[test]
336    fn scan_is_deterministic() {
337        let input = "SELECT 1; <script>alert(1)</script>";
338        assert_eq!(
339            Scanner::default().scan(input),
340            Scanner::default().scan(input)
341        );
342    }
343}