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    const XSS: &str = "<script>alert(1)</script>";
131
132    fn types(results: &[DetectionResult]) -> Vec<&str> {
133        results.iter().map(|r| r.attack_type.as_str()).collect()
134    }
135
136    #[test]
137    fn default_scanner_registers_all_32_detectors() {
138        assert_eq!(Scanner::default().detectors.len(), 32);
139    }
140
141    #[test]
142    fn default_scanner_registers_each_detector_once() {
143        let scanner = Scanner::default();
144        let mut names: Vec<&str> = scanner.detectors.iter().map(|d| d.name()).collect();
145        names.sort_unstable();
146        let before = names.len();
147        names.dedup();
148        assert_eq!(names.len(), before, "检测器名字重复: {names:?}");
149    }
150
151    #[test]
152    fn new_detectors_are_registered() {
153        let scanner = Scanner::default();
154        let names: Vec<&str> = scanner.detectors.iter().map(|d| d.name()).collect();
155        for expected in [
156            "log4shell",
157            "hpp",
158            "formula_injection",
159            "redos",
160            "format_string",
161        ] {
162            assert!(names.contains(&expected), "缺少检测器: {expected}");
163        }
164    }
165
166    /// 只覆盖新增的这几个检测器,不代表整个 scanner 对干净输入全静默
167    /// ——粗粒度层(csv_injection 认行首 `=+-@`、ssti 认 `${`)本来就会命中,
168    /// 判定"整机干净"要看 assess 的评分,别拿这个测试当整机基线。
169    #[test]
170    fn new_detectors_only_do_not_fire_on_clean_input() {
171        let scanner = Scanner::default();
172        for input in [
173            "the price is ${amount}",
174            "a=1&b=2",
175            "= 5",
176            "-3 度",
177            "a@b.com",
178            "100% safe",
179            "50% off",
180            "line one\r\nline two",
181            "5*(3+2)",
182        ] {
183            let results = scanner.scan_with(
184                input,
185                &[
186                    "log4shell",
187                    "hpp",
188                    "formula_injection",
189                    "redos",
190                    "format_string",
191                ],
192            );
193            assert!(
194                results.is_empty(),
195                "新检测器误报 {input:?}: {:?}",
196                types(&results)
197            );
198        }
199    }
200
201    #[test]
202    fn assess_returns_none_for_clean_input() {
203        let a = Scanner::default().assess("hello world 123");
204        assert_eq!(a.level, crate::score::RiskLevel::None);
205        assert_eq!(a.score, 0);
206        assert_eq!(a.results, 0);
207    }
208
209    #[test]
210    fn assess_returns_critical_for_critical_hit() {
211        let a = Scanner::default().assess(XSS);
212        assert_eq!(a.level, crate::score::RiskLevel::Critical);
213        assert_eq!(a.results, 1);
214        assert!(a.score > 0);
215    }
216
217    #[test]
218    fn assess_escalates_on_stacked_medium_hits() {
219        let input = "=cmd|' /C calc'!A0 `cat /etc/passwd` ../../../etc/passwd";
220        let a = Scanner::default().assess(input);
221        assert!(a.results >= 3, "期望多条命中,实际 {:?}", a);
222        assert!(
223            a.level >= crate::score::RiskLevel::High,
224            "叠加后应升级: {:?}",
225            a
226        );
227    }
228
229    #[test]
230    fn scan_detects_known_attack() {
231        let results = Scanner::default().scan(XSS);
232        assert_eq!(results.len(), 1);
233        assert_eq!(results[0].attack_type, "xss");
234        assert_eq!(results[0].category, AttackCategory::Injection);
235        assert_eq!(results[0].severity, Severity::Critical);
236    }
237
238    #[test]
239    fn scan_returns_empty_for_clean_input() {
240        for input in [
241            "hello world 123",
242            "q=2024--2025",
243            "q=donation=5",
244            "穿越之霸道总裁爱上我--重生之都市修仙",
245        ] {
246            assert!(
247                Scanner::default().scan(input).is_empty(),
248                "false positive: {input}"
249            );
250        }
251    }
252
253    #[test]
254    fn scan_returns_empty_for_empty_string() {
255        assert!(Scanner::default().scan("").is_empty());
256    }
257
258    #[test]
259    fn scan_with_filters_by_detector_name() {
260        let scanner = Scanner::default();
261        assert_eq!(scanner.scan_with(XSS, &["xss"]).len(), 1);
262        assert!(scanner.scan_with(XSS, &["sql_injection"]).is_empty());
263        assert!(scanner.scan_with(XSS, &["unknown"]).is_empty());
264    }
265
266    #[test]
267    fn scan_with_multiple_names() {
268        let input = "1 UNION SELECT password FROM users; <script>alert(1)</script>";
269        let results = Scanner::default().scan_with(input, &["sql_injection", "xss"]);
270        let t = types(&results);
271        assert!(t.contains(&"sql_injection") && t.contains(&"xss"));
272    }
273
274    #[test]
275    fn new_matches_default() {
276        assert_eq!(
277            Scanner::new().detectors.len(),
278            Scanner::default().detectors.len()
279        );
280    }
281
282    #[test]
283    fn builder_without_detectors_scans_nothing() {
284        assert!(Scanner::builder().build().scan(XSS).is_empty());
285    }
286
287    #[test]
288    fn builder_with_custom_detector() {
289        let scanner = Scanner::builder()
290            .with_detector(Box::new(crate::injection::XssDetector))
291            .build();
292        let results = scanner.scan(XSS);
293        assert_eq!(results.len(), 1);
294        assert_eq!(results[0].attack_type, "xss");
295    }
296
297    #[test]
298    fn detection_result_has_pattern_offset_and_message() {
299        let results = Scanner::default().scan(XSS);
300        let r = &results[0];
301        assert!(!r.matched_pattern.is_empty());
302        assert!(r.offset <= XSS.len());
303        assert!(!r.message.is_empty());
304    }
305
306    #[test]
307    fn scan_is_deterministic() {
308        let input = "SELECT 1; <script>alert(1)</script>";
309        assert_eq!(
310            Scanner::default().scan(input),
311            Scanner::default().scan(input)
312        );
313    }
314}