use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
&[
r#"(?m:[,;\t][ \t]*"?[ \t]*=[^ \t])"#,
r"|(?im:^\s*DDE)",
r"|(?im:^\s*cmd\s*\|)",
r"|(?im:^\s*@SUM\s*\()",
]
.concat(),
)
.unwrap()
});
static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?m)^[=+\-][^=+\-\s]").unwrap()
});
pub struct CsvInjectionDetector;
impl Detector for CsvInjectionDetector {
fn name(&self) -> &'static str {
"csv_injection"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
std::slice::from_ref(&*STRONG_PATTERNS),
self.name(),
AttackCategory::Data,
Severity::Medium,
"CSV formula injection detected",
input,
)
.or_else(|| {
regex_detect(
std::slice::from_ref(&*WEAK_PATTERNS),
self.name(),
AttackCategory::Data,
Severity::Low,
"CSV formula prefix present (weak signal)",
input,
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{assert_clean, assert_detected};
fn det() -> CsvInjectionDetector {
CsvInjectionDetector
}
fn assert_hit_at(input: &str, severity: Severity) {
assert_detected(&det(), input, AttackCategory::Data, severity);
}
#[test]
fn name_returns_attack_type() {
assert_eq!(CsvInjectionDetector.name(), "csv_injection");
}
#[test]
fn detects_formula_prefixes() {
for payload in [
"@SUM(1+1)*cmd",
"\t=1",
"列1\t=1",
"admin,=1+1",
"x;=HYPERLINK(\"http://evil.com\")",
",\"=cmd|' /C calc'!A0\"",
"DDE;cmd",
"cmd|' /C calc'!A0",
] {
assert_hit_at(payload, Severity::Medium);
}
for payload in ["=cmd|' /C calc'!A0", "+1+1", "-2+3"] {
assert_hit_at(payload, Severity::Low);
}
}
#[test]
fn prefix_followed_by_content_is_low() {
for input in [
"-2+3",
"-2 degrees overnight",
"=1+1",
"+1 more item",
] {
assert_hit_at(input, Severity::Low);
}
}
#[test]
fn bare_prefixes_are_clean() {
for input in [
"--x--",
"--boundary123--",
"----------------------------d74496d66958873e",
"- item",
"--",
"---",
"=====",
"++i;",
"@media (max-width: 640px) { .card { flex-direction: column; } }",
"## Deployment\n\n- item one\n- item two",
"Title\n=====",
] {
assert_clean(&det(), input);
}
}
#[test]
fn real_multipart_upload_is_clean() {
let body = "POST /upload HTTP/1.1\r\n\
Host: example.com\r\n\
User-Agent: curl/8.5.0\r\n\
Content-Type: multipart/form-data; boundary=------------------------d74496d66958873e\r\n\
Content-Length: 4096\r\n\
\r\n\
----------------------------d74496d66958873e\r\n\
Content-Disposition: form-data; name=\"file\"; filename=\"report.pdf\"\r\n\
Content-Type: application/pdf\r\n\
\r\n\
%PDF-1.4 binary payload\r\n\
----------------------------d74496d66958873e\r\n\
Content-Disposition: form-data; name=\"note\"\r\n\
\r\n\
- reproduced on staging\r\n\
----------------------------d74496d66958873e--\r\n";
assert_clean(&det(), body);
}
#[test]
fn ignores_benign_inputs() {
for input in [
"Hello, this is a normal text input.",
"a=1+1",
"SUM(1+1)",
"cmd /C calc",
"not a formula",
"\t\n\r",
"a\tb",
"列1\t列2\t列3",
"hello world",
"line one\r\n\r\nline two",
"a, b, c",
"2024-01-01",
"1, -2, -3",
"me, @alice",
] {
assert!(
CsvInjectionDetector.detect(input).is_none(),
"false positive: {:?}",
input
);
}
}
#[test]
fn edge_cases() {
assert!(CsvInjectionDetector.detect("").is_none());
assert!(CsvInjectionDetector.detect(" ").is_none());
assert!(CsvInjectionDetector.detect("=cmd|' /C calc'!A0").is_none()); }
}