security_rust/data/
csv_injection.rs1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
12 Regex::new(
13 &[
14 r#"(?m:[,;\t][ \t]*"?[ \t]*=[^ \t])"#,
21 r"|(?im:^\s*DDE)",
22 r"|(?im:^\s*cmd\s*\|)",
23 r"|(?im:^\s*@SUM\s*\()",
24 ]
25 .concat(),
26 )
27 .unwrap()
28});
29
30static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
42 Regex::new(r"(?m)^[=+\-][^=+\-\s]").unwrap()
45});
46
47pub struct CsvInjectionDetector;
48
49impl Detector for CsvInjectionDetector {
50 fn name(&self) -> &'static str {
51 "csv_injection"
52 }
53
54 fn detect(&self, input: &str) -> Option<DetectionResult> {
55 regex_detect(
56 std::slice::from_ref(&*STRONG_PATTERNS),
57 self.name(),
58 AttackCategory::Data,
59 Severity::Medium,
60 "CSV formula injection detected",
61 input,
62 )
63 .or_else(|| {
64 regex_detect(
65 std::slice::from_ref(&*WEAK_PATTERNS),
66 self.name(),
67 AttackCategory::Data,
68 Severity::Low,
69 "CSV formula prefix present (weak signal)",
70 input,
71 )
72 })
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use crate::test_helpers::{assert_clean, assert_detected};
80
81 fn det() -> CsvInjectionDetector {
82 CsvInjectionDetector
83 }
84
85 fn assert_hit_at(input: &str, severity: Severity) {
86 assert_detected(&det(), input, AttackCategory::Data, severity);
87 }
88
89 #[test]
90 fn name_returns_attack_type() {
91 assert_eq!(CsvInjectionDetector.name(), "csv_injection");
92 }
93
94 #[test]
95 fn detects_formula_prefixes() {
96 for payload in [
98 "@SUM(1+1)*cmd",
99 "\t=1",
100 "列1\t=1",
101 "admin,=1+1",
102 "x;=HYPERLINK(\"http://evil.com\")",
103 ",\"=cmd|' /C calc'!A0\"",
104 "DDE;cmd",
105 "cmd|' /C calc'!A0",
106 ] {
107 assert_hit_at(payload, Severity::Medium);
108 }
109 for payload in ["=cmd|' /C calc'!A0", "+1+1", "-2+3"] {
111 assert_hit_at(payload, Severity::Low);
112 }
113 }
114
115 #[test]
118 fn prefix_followed_by_content_is_low() {
119 for input in [
120 "-2+3",
121 "-2 degrees overnight",
122 "=1+1",
123 "+1 more item",
124 ] {
125 assert_hit_at(input, Severity::Low);
126 }
127 }
128
129 #[test]
134 fn bare_prefixes_are_clean() {
135 for input in [
136 "--x--",
137 "--boundary123--",
138 "----------------------------d74496d66958873e",
139 "- item",
140 "--",
141 "---",
142 "=====",
143 "++i;",
144 "@media (max-width: 640px) { .card { flex-direction: column; } }",
145 "## Deployment\n\n- item one\n- item two",
146 "Title\n=====",
147 ] {
148 assert_clean(&det(), input);
149 }
150 }
151
152 #[test]
156 fn real_multipart_upload_is_clean() {
157 let body = "POST /upload HTTP/1.1\r\n\
158Host: example.com\r\n\
159User-Agent: curl/8.5.0\r\n\
160Content-Type: multipart/form-data; boundary=------------------------d74496d66958873e\r\n\
161Content-Length: 4096\r\n\
162\r\n\
163----------------------------d74496d66958873e\r\n\
164Content-Disposition: form-data; name=\"file\"; filename=\"report.pdf\"\r\n\
165Content-Type: application/pdf\r\n\
166\r\n\
167%PDF-1.4 binary payload\r\n\
168----------------------------d74496d66958873e\r\n\
169Content-Disposition: form-data; name=\"note\"\r\n\
170\r\n\
171- reproduced on staging\r\n\
172----------------------------d74496d66958873e--\r\n";
173 assert_clean(&det(), body);
174 }
175
176 #[test]
177 fn ignores_benign_inputs() {
178 for input in [
179 "Hello, this is a normal text input.",
180 "a=1+1",
181 "SUM(1+1)",
182 "cmd /C calc",
183 "not a formula",
184 "\t\n\r",
186 "a\tb",
187 "列1\t列2\t列3",
188 "hello world",
189 "line one\r\n\r\nline two",
190 "a, b, c",
191 "2024-01-01",
192 "1, -2, -3",
194 "me, @alice",
195 ] {
196 assert!(
197 CsvInjectionDetector.detect(input).is_none(),
198 "false positive: {:?}",
199 input
200 );
201 }
202 }
203
204 #[test]
205 fn edge_cases() {
206 assert!(CsvInjectionDetector.detect("").is_none());
207 assert!(CsvInjectionDetector.detect(" ").is_none());
208 assert!(CsvInjectionDetector.detect("=cmd|' /C calc'!A0").is_none()); }
210}