Skip to main content

security_rust/data/
csv_injection.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
8    vec![
9        // 行首公式起始符。制表符与回车**不是**公式起始——它们是分隔符:
10        // `"\t\n\r"`(制表符分隔的空行)和 `"a\r\n\r\nb"`(CRLF 空行)
11        // 都曾被这一条判成数据注入。
12        Regex::new(r"(?m)^[=+\-@]").unwrap(),
13        // 字段分隔符之后的 `=`:`admin,=1+1`、`x;=HYPERLINK(...)`、`\t=1`、
14        // `,"=cmd|..."`。两条收紧:
15        //   - 只认 `=`——`,`/`;`/`\t` 后的 `+`/`-`/`@` 在散文里太常见
16        //     (`1, -2, -3`、`me, @alice`);
17        //   - `=` 后面必须紧跟非空白——`key\t= value` 这种制表符对齐的配置
18        //     不是公式,公式里 `=` 后面是操作数。
19        Regex::new(r#"(?m)[,;\t][ \t]*"?[ \t]*=[^ \t]"#).unwrap(),
20        Regex::new(r"(?im)^\s*DDE").unwrap(),
21        Regex::new(r"(?im)^\s*cmd\s*\|").unwrap(),
22        Regex::new(r"(?im)^\s*@SUM\s*\(").unwrap(),
23    ]
24});
25
26pub struct CsvInjectionDetector;
27
28impl Detector for CsvInjectionDetector {
29    fn name(&self) -> &'static str {
30        "csv_injection"
31    }
32
33    fn detect(&self, input: &str) -> Option<DetectionResult> {
34        regex_detect(
35            &PATTERNS,
36            self.name(),
37            AttackCategory::Data,
38            Severity::Medium,
39            "CSV formula injection detected",
40            input,
41        )
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48
49    #[test]
50    fn name_returns_attack_type() {
51        assert_eq!(CsvInjectionDetector.name(), "csv_injection");
52    }
53
54    #[test]
55    fn detects_formula_prefixes() {
56        for payload in [
57            "=cmd|' /C calc'!A0",
58            "+1+1",
59            "-2+3",
60            "@SUM(1+1)*cmd",
61            "\t=1",
62            "列1\t=1",
63            "admin,=1+1",
64            "x;=HYPERLINK(\"http://evil.com\")",
65            ",\"=cmd|' /C calc'!A0\"",
66            "DDE;cmd",
67            "cmd|' /C calc'!A0",
68        ] {
69            let r = CsvInjectionDetector
70                .detect(payload)
71                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
72            assert_eq!(r.attack_type, "csv_injection");
73            assert_eq!(r.category, AttackCategory::Data);
74            assert_eq!(r.severity, Severity::Medium);
75            assert!(
76                !r.matched_pattern.is_empty(),
77                "matched_pattern empty for {:?}",
78                payload
79            );
80            assert!(
81                r.offset <= payload.len(),
82                "offset out of range for {:?}",
83                payload
84            );
85        }
86    }
87
88    #[test]
89    fn ignores_benign_inputs() {
90        for input in [
91            "Hello, this is a normal text input.",
92            "a=1+1",
93            "SUM(1+1)",
94            "cmd /C calc",
95            "not a formula",
96            // 空白字符是分隔符,不是公式起始符
97            "\t\n\r",
98            "a\tb",
99            "列1\t列2\t列3",
100            "hello world",
101            "line one\r\n\r\nline two",
102            "a, b, c",
103            "2024-01-01",
104            // 逗号后的 `+`/`-`/`@` 是散文形态,不认
105            "1, -2, -3",
106            "me, @alice",
107        ] {
108            assert!(
109                CsvInjectionDetector.detect(input).is_none(),
110                "false positive: {:?}",
111                input
112            );
113        }
114    }
115
116    #[test]
117    fn edge_cases() {
118        assert!(CsvInjectionDetector.detect("").is_none());
119        assert!(CsvInjectionDetector.detect("   ").is_none());
120        assert!(CsvInjectionDetector.detect("=cmd|' /C calc'!A0").is_none()); // fullwidth equals
121    }
122}