oletools_rs 0.1.0

Rust port of oletools — analysis tools for Microsoft Office files (VBA macros, DDE, OLE objects, RTF exploits)
Documentation
//! DDE detection in CSV files.
//!
//! Scans for DDE formulas that start with `=`, `+`, `-`, or `@`
//! and contain a pipe character followed by an exclamation mark,
//! which indicates a DDE command injection.

use std::sync::LazyLock;

use regex::Regex;

use crate::msodde::field_parser::DdeField;

/// Pattern for DDE formula injection in CSV cells.
/// Matches cells starting with =, +, -, or @ that contain `|...|!` pattern.
static RE_CSV_DDE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r#"(?m)^["']?[=+\-@].*\|.*!"#).unwrap()
});

/// Scan CSV text for DDE formula injection patterns.
pub fn process_csv(data: &[u8]) -> Vec<DdeField> {
    let text = String::from_utf8_lossy(data);
    let mut fields = Vec::new();

    for mat in RE_CSV_DDE.find_iter(&text) {
        let matched = mat.as_str().trim();

        // Extract the formula content
        let formula = matched.trim_start_matches(['"', '\'']);

        fields.push(DdeField {
            field_instruction: formula.to_string(),
            command: formula.to_string(),
            source: extract_dde_source(formula),
            quote_decoded: None,
        });
    }

    fields
}

/// Extract the DDE source application from a formula.
/// e.g., `=cmd|'/c calc'!A0` -> "cmd"
fn extract_dde_source(formula: &str) -> String {
    // Skip the leading =, +, -, @
    let stripped = formula.trim_start_matches(|c: char| "=+-@".contains(c));

    // Extract up to the pipe character
    if let Some(pipe_pos) = stripped.find('|') {
        stripped[..pipe_pos].trim().to_string()
    } else {
        String::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_csv_dde_formula() {
        let csv = b"Name,Value\n=cmd|'/c calc'!A0,test\n";
        let fields = process_csv(csv);
        assert_eq!(fields.len(), 1);
        assert_eq!(fields[0].source, "cmd");
    }

    #[test]
    fn test_csv_dde_plus() {
        let csv = b"+cmd|'/c whoami'!A0\n";
        let fields = process_csv(csv);
        assert_eq!(fields.len(), 1);
        assert_eq!(fields[0].source, "cmd");
    }

    #[test]
    fn test_csv_dde_minus() {
        let csv = b"-cmd|'/c dir'!A0\n";
        let fields = process_csv(csv);
        assert_eq!(fields.len(), 1);
    }

    #[test]
    fn test_csv_dde_at() {
        let csv = b"@SUM(cmd|'/c calc'!A0)\n";
        let fields = process_csv(csv);
        assert_eq!(fields.len(), 1);
    }

    #[test]
    fn test_csv_no_dde() {
        let csv = b"Name,Value\nAlice,100\nBob,200\n";
        let fields = process_csv(csv);
        assert!(fields.is_empty());
    }

    #[test]
    fn test_csv_normal_formula() {
        let csv = b"=A1+B1\n=SUM(A1:A10)\n";
        let fields = process_csv(csv);
        assert!(fields.is_empty(), "Normal formulas without pipe should not match");
    }

    #[test]
    fn test_csv_empty() {
        let fields = process_csv(b"");
        assert!(fields.is_empty());
    }

    #[test]
    fn test_extract_dde_source() {
        assert_eq!(extract_dde_source("=cmd|'/c calc'!A0"), "cmd");
        assert_eq!(extract_dde_source("+MSEXCEL|'Sheet1'!A1"), "MSEXCEL");
        assert_eq!(extract_dde_source("=no_pipe"), "");
    }
}